5 분 소요

미션3 🥡점심 뭐 먹지 1단계

🔥1단계 구현 사항

1단계 - 음식점 목록

1. RestaurantService

src/interface/RestaurantInterfaces.ts

레스토랑 도메인의 인터페이스 코드이다. 이 타입들로 레스토랑 도메인을 만들었다.

type Category = "한식" | "중식" | "일식" | "아시안" | "양식" | "기타";

type FilteringCategory = "전체" | Category;

type Distance = 5 | 10 | 15 | 20 | 30;

type SortingProperty = "name" | "distance";

interface Restaurant {
  category: Category;
  name: string;
  distance: Distance;
  description?: string;
  link?: string;
}

interface Restaurants {
  addRestaurant: (
    restaurant: Restaurant,
    restaurantList: Restaurant[]
  ) => boolean;
  filterByCategory: (
    category: Category,
    restaurantList: Restaurant[]
  ) => Restaurant[];
  sortByProperty: (
    property: SortingProperty,
    restaurantList: Restaurant[]
  ) => Restaurant[];
}

export {
  Category,
  FilteringCategory,
  Distance,
  SortingProperty,
  Restaurant,
  Restaurants,
};


src/domain/RestaurantService.ts

클래스에서 인터페이스는 implements를 사용해서 타입을 지정할 수 있다.

import { LOCALSTORAGE_KEY } from "../constant/constants";
import {
  FilteringCategory,
  SortingProperty,
  Restaurant,
  Restaurants,
} from "../interface/RestaurantInterfaces";

class RestaurantService implements Restaurants {
  addRestaurant(restaurant: Restaurant, restaurantList: Restaurant[]): boolean {
    const existingRestaurant = restaurantList.find(
      (item) =>
        item.category === restaurant.category && item.name === restaurant.name
    );
    if (existingRestaurant) {
      return false;
    }
    restaurantList.push(restaurant);
    localStorage.setItem(
      LOCALSTORAGE_KEY.RESTAURANT_LIST,
      JSON.stringify(restaurantList)
    );
    return true;
  }

  filterByCategory(
    category: FilteringCategory,
    restaurantList: Restaurant[]
  ): Restaurant[] {
    if (category === "전체") return restaurantList;
    return restaurantList.filter(
      (restaurant) => restaurant.category === category
    );
  }

  sortByProperty(
    property: SortingProperty,
    restaurantList: Restaurant[]
  ): Restaurant[] {
    return restaurantList.sort((a: Restaurant, b: Restaurant) =>
      a[property] > b[property] ? 1 : -1
    );
  }
}

export default RestaurantService;



2. 버튼 컴포넌트

src/components/Common/Button.ts

공통 버튼 컴포넌트를 만들었다.

const Button = (
  type: string,
  variant: "primary" | "secondary",
  content: string
) => {
  return /*html*/ `
    <button type=${type} class="button button--${variant} text-caption">${content}</button>
  `;
};

export default Button;


src/components/Modal/AddRestaurant.ts

Button이 JavaScript 함수라면 ${}를 사용하여 버튼 컴포넌트에서 반환된 HTML 문자열을 템플릿 리터럴에 삽입할 수 있다.

const AddRestaurant = () => {
  return /*html*/ `
    <div class="button-container">
      ${Button("reset", "secondary", "취소하기")}
      ${Button("submit", "primary", "추가하기")}
    </div>
  `;
};



3. insertAdjacentHTML

index.html

index.html에 뼈대 코드가 있다. 이 안에 필터 드롭다운 부분을 추가하려고 한다.

<!-- 카테고리/정렬 필터 -->
<section class="restaurant-filter-container"></section>


src/controllers/RestaurantController.js

insertAdjacentHTML라는 Web API를 사용하여 filterContainer의 마지막 자식 요소 뒤에 filterDropdownsortDropdown를 차례대로 추가한다.

showFilterDropdown() {
  const filterContainer = $('.restaurant-filter-container');

  const filterDropdown = Dropdown(FILTER_DROPDOWN_PROPS);
  const sortDropdown = Dropdown(SORT_DROPDOWN_PROPS);

  filterContainer.insertAdjacentHTML('beforeend', filterDropdown);
  filterContainer.insertAdjacentHTML('beforeend', sortDropdown);
}



🔥1단계 피드백

1. 확정 할당 어선셜

[질문 사항]

View와 Controller를 JS로 구현했다.
TS에서 undefined나 null 일 수 있다는 에러가 떠서 확정 할당 연산자('!')를 사용할까 했지만 모든 querySelector에 ‘!’를 남발하는 것 같아서 JS로 바꿨다. 만약 후에 TS를 쓰게 되면 이 부분에 ‘!’를 쓰는 게 맞을까?

const form = document.querySelector("form")!;


[추가 공부]

만약 HTML 페이지에 <form> 요소가 존재하지 않으면, form은 null이 되고, form!는 이를 강제로 null이 아님으로 간주하여 타입스크립트의 타입 검사기를 우회한다. 이 후에 addEventListener를 호출하면 null에 대해 메서드를 호출하는 것이라 런타임 오류가 발생하게 된다.

const form = document.querySelector("form")!; // form이 null일 가능성 무시

// form이 실제로 null일 경우, 아래 코드에서 오류가 발생
form.addEventListener("submit", (event) => {
  // form이 null일 때 실행되면 오류가 발생
});


[피드백]

!을 남발하는 것은 매우 안 좋은 방식이다. !를 사용할 경우 휴먼 에러를 방지할 수 없으며, 찾고자 하는 요소가 없을 때 앱이 그냥 터져버리는 큰 문제가 발생하므로 사용을 지양해야 한다.

const form = document.querySelector('form');

if (!form) {
  // do something...
}

form.addEventListener('submit', ...)



🔥생각해보기

1. 읽기 전용으로 만들기

1. Object.freeze
객체를 런타임에서 불변으로 만든다.

속성을 수정, 추가, 삭제할 수 없게 만든다. 컴파일 시 타입 체크가 아닌 런타임에서 동작한다.

const obj = Object.freeze({ name: "Alice", age: 30 });
obj.name = "Bob"; // Error: Cannot assign to read-only property

배열도 고정되지만 내부 요소는 참조 가능(얕은 동결)한다.

const deepObj = Object.freeze({ inner: { value: 10 } });
deepObj.inner.value = 20; // 변경 가능


2. as const
리터럴 객체나 배열을 읽기 전용 리터럴 타입으로 만든다.

컴파일 단계에서 리터럴 값을 변하지 않는 상수 타입으로 추론한다. 객체와 배열 모두 지원하고, 타입스크립트 수준에서 타입을 고정(불변)한다.

const obj = { name: "Alice", age: 30 } as const;
obj.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property

const arr = [1, 2, 3] as const;
arr[0] = 4; // Error: Index signature in type 'readonly [1, 2, 3]' only permits reading

중첩된 구조도 고정된다. (리터럴 타입으로 추론)

const deepObj = { inner: { value: 10 } } as const;
deepObj.inner.value = 20; // Error


3. readonly
객체 속성을 읽기 전용으로 지정한다.

type 또는 interface 정의에서 사용한다. 객체를 런타임에서 불변으로 만들지는 않는다. 읽기 전용 속성은 컴파일러에서 변경 불가로 체크한다.

type User = {
  readonly name: string;
  age: number;
};

const user: User = { name: "Alice", age: 30 };
user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property
user.age = 35; // 가능


4. Readonly<Type>
기존 타입의 모든 속성을 읽기 전용으로 변환한다.

기존 타입을 쉽게 변환하는 유틸리티 타입이다. Partial, Required 등과 함께 사용 가능하다.

type User = { name: string; age: number };
const user: Readonly<User> = { name: "Alice", age: 30 };
user.age = 35; // Error: Cannot assign to 'age' because it is a read-only property



2. Type Alias vs Interface

1. Type Alias
타입에 별칭을 부여한다. 기본 타입이나 유니언 타입을 정의할 수 있다. 객체, 함수 타입도 정의 가능하다. & 연산자로 타입 확장을 할 수 있다.

type ID = string | number; // 유니언 타입
type Point = { x: number; y: number }; // 객체 타입
type Add = (a: number, b: number) => number; // 함수 타입

// 타입 확장
type Animal = { name: string };
type Dog = Animal & { breed: string };


2. Interface
객체 타입의 구조를 정의한다. extends 키워드로 확장할 수 있다.

// 타입 확장
interface Animal {
  name: string;
}
interface Dog extends Animal {
  breed: string;
}



3. 타입 내로잉(narrowing)

유니언 타입과 같은 넓은 타입을 더 구체적인 타입으로 좁히는 과정이다.
타입스크립트는 코드 실행 중에 구체적인 타입을 알 수 없다. 타입 내로잉을 통해 타입을 명확히 하면 안전하게 작업할 수 있다.

1. 유니언 타입 처리

function printId(id: string | number) {
  if (typeof id === "string") {
    console.log(id.toUpperCase()); // string으로 좁힘
  } else {
    console.log(id.toFixed(2)); // number로 좁힘
  }
}

2. 타입 가드 사용

function isDog(animal: Dog | Cat): animal is Dog {
  return (animal as Dog).bark !== undefined;
}

3. 속성 존재 여부 체크

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    animal.swim();
  } else {
    animal.fly();
  }
}

4. instanceof 사용

if (date instanceof Date) {
  console.log(date.getTime()); // Date 타입으로 좁힘
}

댓글남기기