[이펙티브 타입스크립트] 2. 타입스크립트의 타입 시스템

공효은·2023년 8월 29일
0

typescript

목록 보기
3/8
post-thumbnail

아이템 11 잉여 속성 체크의 한계 인지하기


📣 타입이 명시된 변수객체 리터럴할당할 때 타입스크립트는 해당 타입의 속성이 있는지, 그리고 '그 외의 속성은 없는지' 확인한다.

리터럴 - javascript 에서 값을 나타낸다. 이는 변수가 아닌 문자 그대로 스크립트에 제공한 고정된 값이다.

🤔 Room 타입에 elephant 속성이 있는 것이 어색하지만, 구조적 타이핑 관점으로 생각해보면 오류가 발생하지 않아야한다.

💡 임시 변수를 도입해보자. => obj 객체는 Room 타입에 할당이 가능하다.

  • obj의 타입은 {numberDoors: number; ceilingHeightFt: number; elephant: string} 으로 추론된다. obj 타입은 Room 타입의 부분 집합을 포함하므로, Room에 할당 가능하며 타입 체커도 통과한다.

  • 첫 번째 예제에서는, 구조적 타입 시스템에서 발생할 수 있는 중요한 종류의 오류를 잡을 수 있도록 '잉여 속성 체크' 라는 과정이 수행되었다. 하지만 잉여 속성 체크 역시 조건에 따라 동작하지 않는다는 한계가 있다.

  • 잉여 속성 체크가 할당 가능 검사와는 별도의 과정이라는 것을 알아야한다.

📣 타입스크립트는 단순히 런타임에 예외를 던지는 코드에 오류를 표시하는 것뿐 아니라, 의도와 다르게 작성된 코드까지 찾으려고한다.

코드를 실행하면 런타임에 오류가 발생하지 않는다. 그러나 타입스크립트가 알려주는 오류 메시지 처럼 의도한 대로 동작하지 않을 수 있다.
오류가 발생한 부분은 darkmode가 아닌 darkMode(대문자 M) 이어야 한다.

💡 결론: 객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행된다.


darkmode를 지우면 오류가 발생하지 않는다.

darkMode 옵셔널을 지우면 오류가 발생한다.


📣 Options는 정말 넓은 타입이다. string 타입인 title 속성과 '또 다른 어떤 속성'을 가지는 모든 객체는 Options 타입의 범위에 속한다.

interface Options {
  title: string;
  darkMode?:boolean;
}

const o1: Options = document; // 정상
const o2: Options = new HTMLAnchorElement; // 정상
  • document와 HTMLAnchorElement의 인스턴스 모두 string 타입인 title 속성을 갖고 있기 때문에 할당문은 정상이다.
  • document나 HTMLAnchorElements은 객체 리터럴이 아니기 때문에 잉여 속성 체크가 안된다. 그러나 {title, darkmode} 객체는 체크가 된다.

💡 결론: 잉여속성 체크를 사용하면 기본적으로 타입 시스템의 구조적 본질을 해치지 않으면서도 객체 리터럴에 알 수 없는 속성을 허용하지 않음으로써, 앞에서 다룬 Room이나 Options 예제 같은 문제점을 방지할 수 있다.(그래서 '엄격한 객체 리터럴 체크' 라고도 불린다.)


📣 잉여 속성 체크의 한계

1) 타입 구문 없는 임시 변수를 사용해보자.

  • 첫 번째 줄의 오른쪽은 객체 리터럴이지만, 두번쨰 줄의 오른쪽 (intermediate)는 객체 리터럴이 아니다. 따라서 잉여 속성 체크가 적용되지 않고 오류는 사라진다.

2) 잉여 속성 체크는 타입 단언문을 사용할 때도 적용되지 않는다.

잉여 속성 체크를 원하지 않는다면? 인덱스 시그니처를 사용해서 타입스크립트가 추가적인 속성을 예상하도록 할 수 있다.

선택적 속성만 가지는 '약한(weak)' 타입에도 비슷한 체크가 동작한다.

🤔 구조적 관점에서 LineChartOptions 타입은 모든 속성이 선택적이므로 모든 객체를 포함할 수 있다.
💡 약한 타입에 대해서 타입스크립트는 값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크를 수행한다. 임시 변수를 제거하더라도 공통 속성 체크는 여전히 동작한다.

음.. 공통 속성 체크가 아닌데?

아이템12 함수 표현식에 타입 적용하기

자바스크립트(그리고 타입스크립트) 에서는 함수 '문장(statement)'과 함수 '표현식(expression)'을 다르게 인식한다.

function rollDice1(sides: number):number {/* ... */} // 문장 (함수 선언문..)
const rollDice2 = function(sides: number): number {/*...*/}; //표현식
const rollDice3 = (sides: number):number => {/*...*/}; // 표현식

타입스크립트에서는 함수 표현식을 사용하는 것이 좋다. 함수의 매개변수 부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다는 장점이 있다.


편집기에서 sides에 마우스를 올려보면, 타입스크립트에서는 이미 sides의 타입을 number로 인식하고 있다.
함수 타입은 선언은 불필요한 코드의 반복을 줄인다. 사칙연산을 하는 함수 네 개는 다음과 같이 작성할 수 있다.

반복되는 함수 시그니처를 하나의 함수 타입으로 통합할 수 있다.

함수 타입 선언을 이용했던 예제보다 타입 구문이 적다. 함수 구현부도 분리되어있어 로직이 보다 분명해진다.

시그니처가 일치하는 다른 함수가 있을 때도 함수 표현식에 타입을 적용해 볼만 하다. 예를 들어, 웹브라우저에서 fetch 함수는 특정 리소스에 HTTP 요청을 보낸다.

async function getQuote(){
  const responseP = fetch('/quote?by-Mark+Twain'); // 타입이 Promise<Response>
  const quote = await response.json();
  return quote;
}
// {
//   "quote": If you tell the truth, you don't have to remember anything.",
//   "source": "notebook",
//   "date": "1894"
// }

여기에는 버그가 있다.!

  • /quote가 존재하지 않는 API라면, '404 Not Found'가 포함된 내용을 응답한다. 응답은 JSON 형식이 아닐 수 있다. response.json은 JSON 형식이 아니라는 새로운 오류 메시지를 담아 거절된 (rejected) 프로미스를 반환한다.
    😭 호출한 곳에서는 새로운 오류 메시지가 전달되어 실제 오류인 404가 감추어 진다.

  • fetch가 실패하면 거절된 프로미스를 응답하지 않는다는 걸 간과하기 쉽다.
    💡 상태 체크를 수행해 줄 checkedFetch 함수를 작성해 보자!

declare function fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;

async function checkedFetch(input: RequestInfo, init?: RequestInit){
  const response = await fetch(input, init);
  if(!response.ok){
    // 비동기 함수 내에서는 거절된 프로미스로 변환한다.
    throw new Error('Request failed: ' + response.status);
  }
  return response;
}

더 간결하게 작성할 수 있다.

throw 대신 return을 사용했다면, 타입스크립트는 그 실수를 잡아낸다.


💡 결론
: 함수의 매개별수에 타입 선언을 하느 것보다 함수 표현식 전체 타입을 정의 하는 것이 코드도 간결하고 안전하다. 다른 함수의 시그니처와 동일한 타입을 가지는 새 함수를 작성하거나, 동일한 타입 시그니처를 가지는 여러 개의 함수를 작성할 때는 매개변수의 타입과 반환 타입을 반복해서 작성하지 말고 함수 전체의 타입 선언을 적용해야 한다.

아이템13 타입과 인터페이스의 차이점 알기

타입스크립트에서 명명된 타입(named type)을 정의하는 방법은 두 가지가 있다.

type TState = {
  name: string;
  capital: string;
}

interface IState {
  name: string;
  capital: string;
}

대부분의 경우에는 타입을 사용해도 되고 인터페이스를 사용해도 된다. 그러나 차이를 분명히 알고, 같은 상황에서는 동일한 방법으로 명명된 타입을 정의해 일관성을 유지해야한다.

I(인터페이스) 또는 T(타입)로 시작해 어떤 형태로 정의 되었는지 나타냈다. 이는 C#에서 비롯된 관례이다. 이 영향을 받아 타입스크립트 최에는 종종사용했으나 현재는 지양해야한다.

비슷한점

IState와 TState를 추가 속성과 함께 할당한다면 동일한 오류가 발생한다.

인덱스 시그니처는 인터페이스와 타입에서 모두 사용할 수 있다.

type TDict = { [key: string]: string };
interface IDict {
  [key: string]: string;
}

함수 타입도 인터페이스나 타입으로 정의할 수 있다.

type TFn = (x: number) => string;
interface IFn {
  (x: number): string;
}

const toStrT: TFn = x => '' + x; //정상
const toStrI: IFn = x => '' + x; //정상

함수 타입에 추가적인 속성이 있다면 타입이나 인터페이스 어떤 것을 선택하든 차이가 없다.

문법이 생소할 수 있지만 자바스크립트에서 함수는 호출 가능한 객체라는 것을 떠올리면 납득할 수 있는 코드이다.

type IFnWithProperties = {
  (x: number): string;
  prop: string;
}

interface IFnWithProperties {
  (x: number): string;
  prop: string;
}

타입 별칭과 인터페이스는 모두 제너릭이 가능하다.

type TPair<T> = {
  first: T;
  second: T;
}

interface IPair<T> {
  first: T;
  second: T;
}

인터페이스는 타입을 확장할 수 있으며, 타입은 인터페이스를 확장할 수 있다.

interface IStateWithPop extends IState {
  population: number;
}
type TStateWithPop = IState & { population: number; };

IstateWithPop과 TStateWithPop은 동일하다. 하지만 인터페이스는 유니온 타입 같은 복잡한 타입을 확장하지 못한다. 복잡한 타입을 확장하고 싶다면 타입과 &를 사용해야 한다.

클래스를 구현(implements)할 때는, 타입(TState)과 인터페이스(IState) 둘 다 사용할 수 있다.

class StateT implemments TState {
  name: string = '';
  capital: string = '';
}

class StateI implements IState {
  name: string = '';
  capital: string = '';
}

차이점

유니온 타입은 있지만 유니온 인터페이스라는 개념은 없다. 인터페이스는 타입을 확장할 수 있지만, 유니온은 할 수 없다. 그런데 유니온 타입을 확장하는 게 필요할 때가 있다.

Input과 Output은 별도의 타입이며 이 둘을 하나의 변수명으로 매핑하는 VariableMap 인터페이스를 만들 수 있다.

type Input = { /* ... */ };
type Output = { /* ... */ };
interface Variablemap {
  [name: string]: Input | Output;
}

또는 유니온 타입에 name 속성을 붙인 타입을 만들 수도 있다. 이 타입은 인터페이스로 표현할 수 없다.

type NamedVariable = (Input | Output) & { name: string };

type 키워드는 일반적으로 interface보다 쓰임새가 많다.
type 키워드는 유니온이 될 수 있고, 매핑된 타입 또는 조건부 타입과 같은 고급 기능에 활용되기도 한다.

튜플과 배열 타입도 type 키워드를 이용해 더 간결하게 표현할 수 있다.

type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];

인터페이스로도 튜플과 비슷하게 구현할 수 있다.

interface Tuple {
  0: number;
  1: number;
  length: 2;
}

const t: Tuple = [10,20]

🚨 그러나 인터페이스로 튜플과 비슷하게 구현하면 튜플에서 사용할 수 잇는 concat 같은 메서드들을 사용할 수 없다. 그러므로 튜플은 type 키워드로 구현하는 것이 낫다!

인터페이스는 타입에 없는 몇 가지 기능이 잆다. 그중 하나는 바로 보강(augment)가 가능하다는것이다.

이와 같이 속성을 확장하는 것을 '선언 병합(declaration merging)' 이라고 한다.
따러서 타입 선언 파일을 작성할 때는 선언 병합을 지원하기 위해 반드시 인터페이스를 사용해야 하며 표준을 따라야 한다. 따입 선언에는 사용자가 채워야 하는 빈틈이 있을 수 있는데, 바로 이 선언 병합이 그렇다.

타입스크립트는 여러 버전의 자바스크립트 표준 라이브러리에서 여러 타입을 모아 병합한다.
예를 들어 , Array 인터페이스는 lib.es5.d.ts 에 정의되어 있고 기본적으로는 lib.es5.d.ts에 선언된 인터페이스가 사용된다.
그러나 tsconfig.json의 lib 목록에 ES2015를 추가하면 타입 스크립트는 lib.es2015.d.ts에 선언된 인터페이스를 병합한다. (ex find 메소드 포함) 병합을 통해 다른 Array 인터페이스에 추가된다.
결과적으로 각 선언이 병합되어 전체 메서드를 가지는 하나의 Array 타입을 얻는다. 프로퍼티가 추가되는 것을 원하지 않는다면 인터페이스 대신 타입을 사용해야한다.

💡결론 : 타입과 인터페이스중 어느것을 사용해야할까?

  • 복잡한 타입이라면 타입 별칭을 사용한다.
  • 타입과 인터페이스, 두 가지 방법으로 모두 표현할 수 있는 간단한 객체 타입이라면 일관성과 보강의 관점에서 고려해 봐야한다. 일관되게 인터페이스를 사용하는 코드베이스에서는 인터페이스, 일관되게 타입을 사용 중이라면 타입을 사용한다.
  • 스타일이 확립되지 않은 프로젝트라면, 향후에 보강의 가능성이 있을지 생각해 봐야한다.
  • 어떤 API에 대한 타입 선언을 작성해야 한다면 인터페이스를 사용하는 게 좋다. API가 변경될 때 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하다.
  • 프로젝트 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계이다 이럴때는 타입을 사용한다.

아이템14 타입 연산과 제너릭 사용으로 반복 줄이기

소프트웨어 개발자라면 같은 코드를 반복하지 말라는 DRY(don't repeat yourself) 원칙을 들어 봤을것이다. 그런데 타입에 대해서는 간과했을지 모른다.

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate {
  firstname: string;
  lastName: string;
  birth: Date;
}

타입 중복은 코드 중복만큼 많은 문제를 발생시킨다. 예를들어 선택적 필드인 middleName을 Person에 추가한다고 가정해보자. 그러면 Person과 PersonWithBirthdate는 다른 타입이 된다.

반복을 줄이는 가장 간단한 방법은 타입에 이름을 붙이는 것이다.
다음 예제의 거리 계산 함수에는 타입이 반복적으로 등장한다.

function distance(a:{x: number, y:number}, b: {x: number, y:number}) {
  return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}

💡 코드를 수정해 타입에 이름을 붙여보자

interface Point2D {
  x: number;
  y: number;
}
function distance(a: Point2D, b: Point2D) { /* ... */}

이 코드는 상수를 사용해서 반복을 줄이는 기법을 동일하게 타입 시스템에 적용한 것이다.

중복된 타입은 종종 문법에 의해서 가려지기도 한다. 예를 들어, 몇몇 함수가 같은 타입 시그니처를 공유하고 있다고 해보자.

function get(url: string, opts: Options): Promise<Response> {/* ... */}
function post(url: string, opts: Options): Promise<Response> {/* ... */}

해당 시그니처를 명명된 타입으로 분리해낼 수 있다.

type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
const get: HTTPFunction = (url, opts) => {/* ... */};
const post: HTTPFunction = (url, opts) => {/* ... */};

Person/PersonWithBirthDate 예제에서는 한 인터페이스가 다른 인터페이스를 확장하게 해서 반복을 제거할 수 있다.

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithbirthDate extends Person {
  birth: Date;
}

일반 적이지 않지만 인터섹션 연산자(&)쓸 수 있다

type PersonWithBirthDate = Person & { birth: Date };

그렇다면 이제 다른 측면을 생각해 보자!

전체 애플리케이션의 상태를 표현하는 State 타입과 단지 부분만 표현하는 TopNavState가 있는 경우를 살펴보자~

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}

interface TopNavState {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
}

TopNavState를 확장하여 State를 구성하기보다, State의 부분 집합으로 TopNavState를 정의하는것이 바람직하다.
State를 인덱싱하여 속성의 타입에서 중복을 제거할 수 있다.

type TopNavState = {
  userId: State['userId'];
  pageTitle: State['pageTitle'];
  recentFiles: State['recentFiles'];
}

State내의 pageTitle의 타입이 바뀌면 TopNavState에도 반영된다. 그러나 여전히 반복되는 코드가 존재한다. 이때 '매핑된 타입'을 사용하면 좀 더 나아진다. (중복이 쫴끔 줄어든다ㅎㅎ)

type TopNavState = {
  [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
}

매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식이다. 이 패턴은 표준 라이브러리에서도 일반적으로 찾을 수 있으며, Pick 이라고 한다.

type Pick<T, K> = { [k in K]: T[k] };

type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;                                         

중복된 코드를 없앤다는 관점으로, Pick을 사용하는 것은 함수를 호출하는 것에 비유할 수 있다. 마치 함수에서 두개의 매개변수 값을 받아서 겨로갓값을 반환하는 것처럼, Pick은 T와 K 두 가지 타입을 받아서 결과 타입을 반환한다.

태그된 유니온에서도 다른 형태의 중복이 발생할 수 있다. 그런데 단순히 태그를 붙이기 위해 타입을 사용하면 어떨 지 생각해보자.

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'load';
  // ...
}

type Action = SaveAction | LoadAction;
type ActionType = 'save' | 'load'; // 타입 중복!

Action 유니온을 인덱싱하면 타입 반복 없이 ActionType 을 정의할 수 있습니다.

Action 유니온에 타입을 더 추가하면 ActionType은 자동적으로 그 타입을 포함한다. ActionType은 Pick을 사용하여 얻게 되는, type 속성을 가지는 인터페이스와는 다르다.

interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}
interface OptionsUpdate {
  width?: number;
  height?: number;
  color?: string;
  label?: string;
}
class UIWidget {
  constructor(init: Options) { /* ... */}
  update(options: OptionsUpdate) { /* ... */}
}

매핑된 타입과 keyof를 사용하면 Options으로 부터 OptionsUpdate를 만들 수 있다.

type OptionsUpdate = {[k in keyof Options]?: Options[k]};

keyof는 타입을 받아서 속성 타입의 유니온을 반환한다.

type OptionKeys = keyof Options;
// 타입이 "width"| "height" | "color" | "label"

매핑된 타입([k in keyof Options])은 순회하며 Options 내 k 값에 해당하는 속성이 있는지 찾는다. ? 는 각 속성을 선택적으로 만든다. 이 패턴은 일반적이며 표준 라이브러리에 Partial 이라는 이름으로 포함되어있다.

class UIWidget {
	constructor(init: Options) { /* ... */ }
    update(options: Partial<Options>){ /* ... */}
}

keyof 연산자는 객체타입에서 객체의 키값들을 숫자나 문자열 리터럴 유니언을 생성한다.

값의 형태에 해당하는 타입을 정의하고 싶을 때도 있다.

const INIT_OPTION = {
  width: 640,
  height: 480,
  color: '#00FF00',
  label: 'VGA', 
};
interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}

이런 경우 typeof를 사용하면 된다.

이 코드는 타입스크립트 단계에서 연산되며 훨씬 더 정확하게 타입을 표현한다.
그런데 값으로 부터 타입을 만들 때는 선언의 순서에 주의해야한다. 타입 정의를 먼저하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다. 그렇게 해야 타입이 더 명확해지고, 예상하기 어려운 타입 변동을 방지할 수 있다.

함수나 메서드의 반환 값에 명명된 타입을 만들어보자!

function getuserInfo(userId: string){
  // ...
  return {
    userId,
    name,
    age,
    height,
    weight,
    favoriteColor,
  }
}
// 추론된 반환 타입은 {userId: string; name: string; age: number, ... }

이때 는 조건부 타입이 필요하다. 이런 경우 ReturnType 제너릭이 정확히 맞다.

type userInfo = ReturnType<typeof getUserInfo>;

ReturnType은 함수의 '값'인 getUserInfo가 아니라 함수의 '타입'인 typeof getUserInfo 에 적용되었다. typeof와 마찬가지로 이런 기법은 신중하게 사용해야한다. 적용 대상이 값인지 타입인지 정확히 알고, 구분해서 처리해야한다.

제너릭 타입에서의 매개변수 제한

함수에서 매개변수로 매핑할 수 있는 값을 제한하기 위해 타입 시스템을 사용하는 것처럼 제너릭 타입에서 매개변수를 제한할 수 있는 방법이 필요하다.

제너릭 타입에서 매개변수를 제한할 수 있는 방법은 extends를 사용하는 것이다.
extends를 이용하면 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있다.


{first:string} 은 Name을 확장하지 않기 때문에 오류가 발생한다.

Pick의 정의는 extends를 사용해서 완성할 수 있다. 타입 체커를 통해 기존 예제를 실행해 보면 오류가 발생한다.

K는 T타입과 무관하고 범위가 너무 넓다. K는 인덱스로 사용될 수 있는 string|number|symbol이 되어야하며 실제로는 범위를 조금더 좁힐 수 있다. K는 실제로 T의 키의 부분 집합, 즉 keyof T가 되어야한다.

타입이 값의 집합이라는 관점에서 생각하면 extends를 '확장'이 아니라 '부분 집합'이라는 걸 이해하는데 도움이된다.

아이템15 동적 데이터에 인덱스 시그니처 사용하기

타입스크립트에서는 타입에 '인덱스 시그니처'를 명시하여 유현하게 매핑을 표현할 수 있따.

type Rocket = {[property:string]:string};
const rocket: Rocket = {
  name: 'Falcon 9',
  variant: 'v1.0',
  thrust: '4,940 kN',
}

[property: string]: string 은 인덱스 시그니처이며, 다음 세 가지 의미를 담고있다.

  • 키의 이름: 키의 위치만 표시하는 용도이다. 타입 체커에서는 사용하지 않기 때문에 무시할 수 있는 참고 정보라고 생각해도 된다.
  • 키의 티입: string이다 number 또는 symbol의 조합이어야 하지만, 보통은 string을 사용한다.
  • 값의 타입: 어떤 것이든 될 수 있다.

이렇게 타입 체크가 수행되면 단점이 있다.

  • 잘못된 키를 포함해 모든 키를 허용한다. name 대신 Name으로 작성해도 유효한 Rocket 타입이 된다.
  • 특정 키가 필요하지 않다. {} 도 유효한 Rocket 타입이다.
  • 키마다 다른 타입을 가질 수 없다. 예를들어, thrust는 string이 아니라 number 여야 할 수도 있다.
  • 타입스크립트 언어 서버스는 다음과 같은 경우에 도움이 되지 못한다. name:을 입력할 때, 키는 무엇이든 가능하기 때문에 자동 완성 기능이 동작하지 않는다.

인덱스 시그니처는 동적 데이터를 표현할 때 사용한다.

선언해 둔 열들이 런타임에 실제로 일치한다는 보장은 없으므로 이 부분이 걱정된다면 undefined를 추가한다. 하지만 체크를 추가해야기 때문에 상황에 맞게 판닫ㄴ해야한다.

모든 행들에 대한 데이터 타입을 알고 있을 경우에는 interface나 type을 사용하는 것이 더 안전하고, 타입스크립트에서 지공하는 언어 서비스를 모두 사용할 수 있다.(자동완성, 정의로 이동, 이름 바꾸기)

profile
잼나게 코딩하면서 살고 싶어요 ^O^/

0개의 댓글