TypeScript로 고급 프로그래밍하기

반응형
반응형

TypeScript로 고급 프로그래밍하기
TypeScript로 고급 프로그래밍하기

고급 TypeScript 테크닉으로 코드 품질과 생산성 향상하기

TypeScript는 단순히 타입을 추가하는 것 이상의 기능을 제공하여, 대규모 프로젝트와 협업 환경에서도 안정적이고 유지보수 가능한 코드를 작성할 수 있습니다. 특히 고급 프로그래밍 기법을 활용하면 코드의 재사용성과 가독성을 크게 높일 수 있습니다. 이번 글에서는 TypeScript의 고급 기능과 패턴을 알아보고, 효율적이고 강력한 코드를 작성하는 방법을 소개합니다.

1. 제네릭을 활용한 유연한 타입 설계

 

TypeScript에서 제네릭(Generic)은 함수나 클래스, 인터페이스에서 다양한 타입을 유연하게 다룰 수 있는 강력한 기능입니다. 특히 데이터 타입이 고정되지 않은 상황에서 타입 안정성을 유지하면서도, 재사용 가능한 코드를 작성하는 데 유용합니다. 제네릭을 사용하면 코드 중복을 줄이고, 코드의 유지보수성을 크게 향상할 수 있습니다. 이제 제네릭을 활용해 유연한 타입을 설계하는 방법을 살펴보겠습니다.

1. 제네릭 함수로 다양한 데이터 타입 처리하기

제네릭 함수는 특정 타입에 제한되지 않고 여러 타입을 다룰 수 있습니다. 예를 들어, 배열의 첫 번째 요소를 반환하는 함수를 제네릭으로 구현하면, 숫자 배열이든 문자열 배열이든 모든 타입의 배열에 사용할 수 있습니다.

function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

// 예시
const firstNumber = getFirstElement([1, 2, 3]); // number 타입
const firstString = getFirstElement(["a", "b", "c"]); // string 타입

위 함수는 T라는 제네릭 타입 매개변수를 사용해 배열의 요소 타입을 지정합니다. 이 방식으로, 함수 하나로 다양한 데이터 타입을 처리할 수 있으며, 타입 안정성도 유지할 수 있습니다.

2. 제네릭 인터페이스로 유연한 데이터 구조 설계

제네릭은 인터페이스에서도 활용 가능합니다. 예를 들어, API 응답 데이터가 다양한 구조를 가질 수 있을 때 제네릭 인터페이스를 사용하면 다양한 타입에 대응할 수 있는 구조를 설계할 수 있습니다.

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// 예시
const userResponse: ApiResponse<{ name: string; age: number }> = {
  data: { name: "Alice", age: 25 },
  status: 200,
  message: "Success",
};

ApiResponse 인터페이스는 제네릭을 사용해 data의 타입을 유연하게 설정할 수 있습니다. 이렇게 하면 다양한 API 응답 타입에 대응할 수 있으며, 새로운 데이터 타입이 추가되어도 기존 코드를 수정할 필요가 없습니다.

3. 제네릭 클래스 활용으로 코드 재사용성 높이기

제네릭은 클래스에서도 강력한 재사용성을 제공합니다. 예를 들어, 여러 타입의 데이터를 처리할 수 있는 큐(Queue) 클래스를 제네릭으로 구현하면, 모든 타입의 데이터를 큐에 넣고 뺄 수 있습니다.

class Queue<T> {
  private items: T[] = [];

  enqueue(item: T): void {
    this.items.push(item);
  }

  dequeue(): T | undefined {
    return this.items.shift();
  }
}

// 예시
const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
const firstNumber = numberQueue.dequeue(); // 1

Queue 클래스는 enqueuedequeue 메서드를 통해 유연한 데이터 타입 처리가 가능하도록 제네릭을 사용했습니다. 이렇게 하면 숫자, 문자열 등 다양한 타입의 데이터를 하나의 큐 구조로 처리할 수 있어 코드 재사용성이 높아집니다.

제네릭의 활용으로 유연하고 강력한 타입 설계

제네릭을 사용하면 코드 중복을 줄이고 다양한 데이터 타입에 대응하는 유연한 코드를 작성할 수 있습니다. 이러한 유연성 덕분에, 프로젝트의 복잡도가 증가하더라도 타입 안정성을 유지하면서 재사용 가능한 코드를 구현할 수 있습니다. TypeScript의 제네릭을 효과적으로 활용해 더욱 효율적인 코드를 작성해 보세요!

GitHub Copilot 사용 후기: 개발 생산성을 200% 높이는 비법

2. 유틸리티 타입으로 간결한 타입 정의

 

TypeScript는 다양한 유틸리티 타입을 제공하여 코드에서 반복적인 타입 정의를 줄이고, 간결하면서도 가독성 높은 코드를 작성할 수 있게 돕습니다. 유틸리티 타입은 기본 타입을 확장하거나 변형해 새로운 타입을 쉽게 생성할 수 있게 해 주며, 대규모 프로젝트에서 특히 유용합니다. 아래에서는 대표적인 유틸리티 타입과 그 활용 방법을 예제와 함께 살펴보겠습니다.

1. Partial로 선택적 프로퍼티 설정하기

Partial 유틸리티 타입은 기존 객체 타입의 모든 프로퍼티를 선택적으로 만들어 줍니다. 이를 통해 업데이트 시 일부 프로퍼티만 수정해야 할 때 유용하게 사용할 수 있습니다.

interface User {
  id: number;
  name: string;
  email: string;
}

function updateUser(id: number, update: Partial<User>): void {
  // 여기서 update는 선택적 프로퍼티를 갖습니다.
}

updateUser(1, { name: "Alice" }); // 일부 프로퍼티만 전달 가능

위 예제에서 Partial<User>User 타입의 모든 프로퍼티를 선택적으로 만들어 주어, 필요한 부분만 업데이트할 수 있게 합니다. 선택적 프로퍼티를 통해 불필요한 타입 정의를 줄이고 유지보수를 간편하게 할 수 있습니다.

2. Pick으로 필요한 프로퍼티만 선택하기

Pick 유틸리티 타입은 특정 타입에서 필요한 프로퍼티만 선택하여 새로운 타입을 생성합니다. 이는 API 응답에서 필요한 프로퍼티만 추출하거나 부분적인 타입을 생성할 때 유용합니다.

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

type ProductSummary = Pick<Product, "id" | "name" | "price">;

const product: ProductSummary = {
  id: 1,
  name: "Laptop",
  price: 1000,
};

위의 ProductSummary 타입은 Product에서 id, name, price 프로퍼티만 추출하여 생성되었습니다. 필요한 정보만 다룰 수 있어 코드 가독성을 높이고, 불필요한 데이터 취급을 줄일 수 있습니다.

3. Readonly로 불변 객체 타입 만들기

Readonly 유틸리티 타입은 객체의 모든 프로퍼티를 읽기 전용으로 변환합니다. 이는 중요한 데이터나 외부에서 변경되어서는 안 되는 데이터를 다룰 때 유용합니다.

interface Config {
  apiEndpoint: string;
  timeout: number;
}

const config: Readonly<Config> = {
  apiEndpoint: "https://api.example.com",
  timeout: 5000,
};

// config.timeout = 6000; // 오류 발생: 읽기 전용 속성입니다.

Readonly<Config> 타입을 적용하면 변경 불가능한 객체를 생성하여, 코드에서 의도치 않은 수정이 발생하지 않도록 방지할 수 있습니다. 이는 안정성을 높이고 데이터 무결성을 유지하는 데 큰 도움이 됩니다.

4. Omit으로 특정 프로퍼티 제거하기

Omit 유틸리티 타입은 특정 타입에서 일부 프로퍼티를 제외한 새로운 타입을 생성합니다. 특정 데이터만 필요하거나 보안상의 이유로 일부 데이터를 제외하고자 할 때 유용합니다.

interface UserDetails {
  id: number;
  name: string;
  password: string;
  email: string;
}

type PublicUser = Omit<UserDetails, "password">;

const user: PublicUser = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
};

위의 예제에서는 password 필드를 제외한 PublicUser 타입을 생성하여, 보안이 필요한 데이터는 제외하고 나머지 정보만 다룰 수 있습니다. 이는 데이터 관리에 있어 중요한 역할을 합니다.

유틸리티 타입을 통한 유지보수성 향상

TypeScript의 유틸리티 타입은 복잡한 타입을 간결하게 정의하여 코드의 가독성과 유지보수성을 크게 높입니다. Partial, Pick, Readonly, Omit 등의 유틸리티 타입을 활용하면, 필요에 따라 타입을 손쉽게 변형하고, 효율적이고 안정적인 코드를 작성할 수 있습니다.

3. 조건부 타입으로 복잡한 타입 로직 구현

 

조건부 타입은 TypeScript에서 특정 조건에 따라 타입을 결정하는 고급 기능으로, 복잡한 타입 로직을 효율적으로 구현할 수 있습니다. 특히 대규모 코드베이스에서 재사용성과 유연성을 높이는 데 유용하며, 다양한 조건에 따라 유연하게 타입을 정의할 수 있어 코드 유지보수와 안정성에 큰 도움이 됩니다.

1. 조건부 타입의 기본 개념

조건부 타입은 T extends U ? X : Y 형식을 따르며, TU를 확장할 때 X 타입을, 그렇지 않으면 Y 타입을 선택합니다. 이러한 조건부 타입을 사용하면 조건에 따라 달라지는 타입 로직을 정의할 수 있어, 복잡한 타입을 간결하게 표현할 수 있습니다.

type IsString<T> = T extends string ? "String Type" : "Not a String Type";

// 예시
type A = IsString<string>; // "String Type"
type B = IsString<number>; // "Not a String Type"

위 예제에서는 IsString이라는 조건부 타입을 정의해, 입력 타입이 string일 경우 "String Type"을 반환하고, 그렇지 않으면 "Not a String Type"을 반환하도록 설정했습니다. 이처럼 조건부 타입을 통해 타입 선택을 자동화할 수 있습니다.

2. 조건부 타입을 활용한 함수 반환 타입 설정

함수의 반환 타입도 조건부 타입을 사용하여 설정할 수 있습니다. 예를 들어, 배열일 경우 length를 반환하고, 객체일 경우 keys를 반환하는 함수를 작성할 때 조건부 타입을 활용하면 동적인 타입 처리가 가능합니다.

type ReturnTypeBasedOnInput<T> = T extends any[] ? number : T extends object ? string[] : never;

function getReturnType<T>(input: T): ReturnTypeBasedOnInput<T> {
  if (Array.isArray(input)) {
    return input.length as ReturnTypeBasedOnInput<T>;
  } else if (typeof input === "object") {
    return Object.keys(input) as ReturnTypeBasedOnInput<T>;
  }
  throw new Error("Invalid type");
}

// 예시
const arrayResult = getReturnType([1, 2, 3]); // number 타입
const objectResult = getReturnType({ name: "Alice", age: 25 }); // string[] 타입

위 예제는 ReturnTypeBasedOnInput이라는 조건부 타입을 사용하여, 함수가 배열을 입력받으면 number 타입의 length를 반환하고, 객체를 입력받으면 string[] 타입의 keys를 반환하도록 설정했습니다. 이를 통해 조건에 맞는 타입 반환이 가능해집니다.

3. 유틸리티 타입과 조건부 타입의 결합

조건부 타입은 유틸리티 타입과 결합해 더욱 복잡한 로직을 구현할 수 있습니다. 예를 들어, 특정 속성을 필수로 설정하는 RequiredIf 타입을 만들어 선택적으로 필수 속성을 부여하는 경우를 살펴보겠습니다.

type RequiredIf<T, K extends keyof T> = T & { [P in K]-?: T[P] };

// 예시
interface User {
  name?: string;
  age?: number;
}

// age 속성만 필수로 설정
type UserWithRequiredAge = RequiredIf<User, "age">;

const user1: UserWithRequiredAge = { name: "Alice", age: 25 }; // 정상
const user2: UserWithRequiredAge = { name: "Bob" }; // 오류: age 속성이 필수

이처럼 RequiredIf 타입은 특정 속성만 필수로 설정할 수 있어, 조건부 타입을 활용해 객체의 특정 필드를 동적으로 조절하는 데 유용합니다.

4. 조건부 타입의 활용 예시: API 응답 타입 처리

조건부 타입은 API 응답을 처리할 때도 유용하게 사용할 수 있습니다. 예를 들어, API 응답이 성공하면 데이터 타입을 반환하고, 실패하면 오류 메시지를 반환하도록 설정할 수 있습니다.

type ApiResponse<T> = T extends { success: true } ? T : { error: string };

interface SuccessResponse {
  success: true;
  data: { id: number; name: string };
}

interface ErrorResponse {
  success: false;
  error: string;
}

// 성공 응답 타입 예시
type Response1 = ApiResponse<SuccessResponse>; // SuccessResponse 타입
type Response2 = ApiResponse<ErrorResponse>; // { error: string } 타입

이 예제에서는 ApiResponse 조건부 타입을 사용하여, 성공 응답일 경우 데이터 타입을 유지하고, 실패 응답일 경우 오류 메시지 타입을 반환하도록 설정했습니다. 이렇게 하면 API의 다양한 응답 형태를 간결하게 처리할 수 있어 코드 가독성 및 유지보수성이 향상됩니다.

조건부 타입을 활용한 고급 타입 로직 구현의 중요성

조건부 타입을 활용하면 복잡한 타입 로직도 간결하게 표현할 수 있습니다. 이로 인해 코드 재사용성 및 유지보수성이 높아지며, 대규모 프로젝트에서도 효율적이고 일관된 코드 작성이 가능합니다. 조건부 타입을 활용한 고급 프로그래밍으로 TypeScript의 잠재력을 최대한 발휘해 보세요.

왜 ReactJS와 TypeScript를 같이 쓸까? 초보자를 위한 완벽 가이드

4. 매핑된 타입으로 일관된 객체 타입 작성

반응형

TypeScript의 매핑된 타입(Mapped Types)은 객체 타입에서 각 속성을 반복적으로 변환하거나 특정 조건을 적용할 때 유용합니다. 매핑된 타입을 사용하면 동일한 구조를 가진 여러 타입을 쉽게 정의하고 유지보수성을 높일 수 있습니다. 이 장에서는 매핑된 타입을 활용해 객체 타입을 일관되게 정의하는 방법을 구체적으로 살펴보겠습니다.

1. 기본 매핑된 타입의 개념

매핑된 타입은 객체 타입의 모든 속성을 특정 타입으로 변경하거나 조건을 적용할 때 유용합니다. 예를 들어, 객체의 모든 속성을 선택적(optional) 또는 필수(required)로 변경하고자 할 때 사용할 수 있습니다.

type Optional<T> = {
  [P in keyof T]?: T[P];
};

interface User {
  id: number;
  name: string;
  email: string;
}

type OptionalUser = Optional<User>;

이 예제에서 Optional 타입은 User 타입의 모든 속성을 선택적 속성으로 변환합니다. 이를 통해 반복적인 타입 정의를 줄이고 유지보수를 용이하게 할 수 있습니다.

2. 객체의 모든 속성을 특정 타입으로 변경하기

매핑된 타입은 객체 속성을 특정 타입으로 일괄 변경할 때도 사용됩니다. 예를 들어, 객체 속성을 모두 문자열로 변환해야 하는 경우 아래와 같이 정의할 수 있습니다.

type Stringify<T> = {
  [P in keyof T]: string;
};

interface Product {
  id: number;
  price: number;
  title: string;
}

type StringifiedProduct = Stringify<Product>;

위의 Stringify 타입은 Product의 모든 속성을 문자열 타입으로 변환합니다. 이를 통해 특정 형식의 데이터를 필요로 하는 상황에 맞춘 타입 일관성을 유지할 수 있습니다.

3. 읽기 전용(Readonly) 또는 필수(Required) 속성 적용하기

TypeScript는 기본적으로 Readonly<T>Required<T>라는 유틸리티 타입을 제공해 객체의 속성을 읽기 전용 또는 필수로 쉽게 변환할 수 있습니다.

interface Task {
  title: string;
  description?: string;
}

type ReadonlyTask = Readonly<Task>;
type RequiredTask = Required<Task>;

여기서 ReadonlyTaskTask의 모든 속성을 읽기 전용으로, RequiredTask는 모든 속성을 필수로 변환합니다. 이러한 매핑된 타입을 통해 객체의 속성을 명확히 제한하거나 조건을 강제할 수 있어 유지보수성을 높일 수 있습니다.

4. 조건부 타입과 결합한 고급 매핑된 타입

조건부 타입을 매핑된 타입과 결합하여 보다 복잡한 타입을 생성할 수도 있습니다. 예를 들어, 객체의 속성 타입이 문자열일 경우에만 특정 속성을 추가하고 싶다면 조건부 타입을 사용할 수 있습니다.

type StringPropsOnly<T> = {
  [P in keyof T as T[P] extends string ? P : never]: T[P];
};

interface Profile {
  name: string;
  age: number;
  city: string;
}

type StringOnlyProfile = StringPropsOnly<Profile>;

위 예제에서는 StringPropsOnly 타입이 Profile 타입 중 문자열 속성만 선택합니다. 이와 같은 방식으로 조건부 타입과 매핑된 타입을 결합하여 유연하면서도 정교한 타입 시스템을 구현할 수 있습니다.

매핑된 타입을 활용한 일관성 있는 객체 타입 작성

TypeScript의 매핑된 타입을 사용하면 객체 타입의 구조를 간단하게 정의하고 유지보수성을 높일 수 있습니다. 매핑된 타입은 타입 변환의 반복을 줄이고 코드의 일관성을 유지하여 대규모 프로젝트에서도 효율적이고 안정적인 타입 관리를 지원합니다. 이러한 고급 타입을 통해 더 나은 코드 품질을 유지하고 생산성을 높여보세요.

5. 디코레이션 패턴과 데코레이터 활용

디코레이션 패턴과 데코레이터는 TypeScript에서 객체나 클래스의 동작을 쉽게 수정하거나 확장할 수 있는 강력한 고급 기능입니다. 특히 대규모 애플리케이션에서 코드를 재사용하고 일관성 있는 동작을 추가할 때 유용합니다. 이 장에서는 디코레이션 패턴의 원리와 TypeScript의 데코레이터를 실제로 어떻게 활용하는지 알아보겠습니다.

1. 디코레이션 패턴과 데코레이터란?

디코레이션 패턴은 기존 객체나 클래스에 새로운 기능을 동적으로 추가할 때 사용하는 디자인 패턴입니다. TypeScript에서는 @decorator 문법을 통해 클래스, 메서드, 프로퍼티, 파라미터에 데코레이터를 적용하여 동작을 확장할 수 있습니다.

예를 들어, 특정 메서드에 로그를 자동으로 남기거나, API 요청 시 인증을 추가하는 등의 작업을 데코레이터로 구현할 수 있습니다. 이러한 방식은 코드 중복을 줄이고, 일관된 기능을 여러 곳에서 손쉽게 적용할 수 있게 합니다.

2. 메서드 데코레이터: 로그 기록 예제

메서드 데코레이터는 함수 호출 전후로 원하는 로직을 추가할 때 유용합니다. 아래는 메서드가 실행될 때마다 로그를 출력하는 logExecution 데코레이터의 예제입니다.

function logExecution(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`메서드 ${propertyKey} 실행, 매개변수: ${JSON.stringify(args)}`);
    return originalMethod.apply(this, args);
  };
  return descriptor;
}

class Calculator {
  @logExecution
  add(a: number, b: number): number {
    return a + b;
  }
}

// 예시
const calculator = new Calculator();
calculator.add(2, 3); // 로그: 메서드 add 실행, 매개변수: [2,3]

이 예제에서는 logExecution 데코레이터가 add 메서드의 실행을 감싸고 있어, 메서드가 호출될 때마다 로그가 출력됩니다. 이를 통해 메서드 호출 기록을 일관되게 관리할 수 있습니다.

3. 클래스 데코레이터: 인증 기능 추가하기

클래스 데코레이터는 클래스 자체에 동작을 추가하거나 수정할 때 유용합니다. 예를 들어, 특정 클래스에서 인증이 필요한 작업을 수행할 때 인증 여부를 확인하는 데코레이터를 적용해 보겠습니다.

function RequiresAuth(constructor: Function) {
  return class extends constructor {
    authenticated = false;
    constructor(...args: any[]) {
      super(...args);
      if (!this.authenticated) {
        throw new Error("인증이 필요합니다.");
      }
    }
  };
}

@RequiresAuth
class SecureService {
  fetchData() {
    console.log("데이터를 불러옵니다.");
  }
}

// 인증 실패 예제
try {
  const service = new SecureService();
  service.fetchData();
} catch (e) {
  console.error(e.message); // "인증이 필요합니다."
}

위 예제에서는 RequiresAuth 데코레이터가 클래스에 인증 로직을 추가하여 인증되지 않은 사용자의 접근을 차단합니다. 이처럼 데코레이터를 사용하면 중요한 보안 로직을 각 클래스에 쉽게 통합할 수 있습니다.

4. 프로퍼티 데코레이터: 기본 값 초기화

프로퍼티 데코레이터는 클래스의 특정 속성에 기본값을 설정하거나 초기화 로직을 추가할 때 유용합니다. 다음 예제에서는 특정 프로퍼티의 기본값을 자동으로 설정하는 데코레이터를 정의합니다.

function DefaultValue(defaultValue: any) {
  return function (target: any, propertyKey: string) {
    let value = defaultValue;
    const getter = () => value;
    const setter = (newValue: any) => {
      console.log(`새 값 설정: ${newValue}`);
      value = newValue;
    };
    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
    });
  };
}

class Settings {
  @DefaultValue("dark")
  theme: string;
}

// 예시
const settings = new Settings();
console.log(settings.theme); // "dark"
settings.theme = "light"; // 새 값 설정: light

이 예제에서 DefaultValue 데코레이터는 theme 속성의 기본값을 설정하고, 값이 변경될 때마다 로그를 출력합니다. 이를 통해 클래스의 속성을 효율적으로 관리하고, 속성 변경에 대한 추적을 일관성 있게 유지할 수 있습니다.

TypeScript 데코레이터의 강력한 장점

TypeScript의 데코레이터 기능은 코드의 재사용성과 유지보수성을 크게 향상합니다. 디코레이션 패턴을 통해 공통 기능을 쉽게 추가하고, 프로젝트 전반에 걸쳐 일관성 있는 로직을 유지할 수 있습니다. 특히, 인증, 로깅, 속성 초기화와 같은 기능을 반복 없이 구현할 수 있어, 코드의 가독성을 높이고 유지보수를 용이하게 합니다. 데코레이터를 활용한 고급 프로그래밍을 통해 더욱 효율적이고 강력한 코드를 작성해 보세요.

가장 많이 찾는 글

 

자바스크립트 학습 가이드: 3주 만에 기초 마스터하기

자바스크립트 초보자를 위한 핵심 개념 쉽게 이해하기자바스크립트(JavaScript)는 오늘날 웹 개발에서 필수적인 프로그래밍 언어로, 웹 페이지의 동적 기능을 구현하는 데 중요한 역할을 합니다. H

it.rushmac.net

 

자바스크립트 ES6+ 문법으로 효율적인 코드 작성하기: 핵심 팁

초보자도 이해할 수 있는 자바스크립트 ES6+ 문법: 실전 예제 포함자바스크립트는 웹 개발의 핵심 언어로, 그 중요성은 날로 커지고 있습니다. 특히 2015년에 도입된 ES6(ECMAScript 2015)는 자바스크립

it.rushmac.net

 

타입스크립트의 미래: AI와 함께하는 새로운 개발 패러다임

타입스크립트의 10년 진화: 과거와 현재, 그리고 미래타입스크립트는 2012년 마이크로소프트에서 처음 선보인 이후 빠르게 성장하여 현대 웹 개발의 중요한 부분을 차지하고 있습니다. 타입스크

it.rushmac.net

결론

TypeScript의 고급 기능을 활용하면 코드의 효율성과 유지보수성이 크게 향상됩니다. 제네릭, 조건부 타입, 유틸리티 타입 등을 적절히 사용하면 유연하고 강력한 코드를 작성할 수 있으며, 대규모 프로젝트에서도 안정적이고 확장 가능한 프로그램을 구현할 수 있습니다. 고급 TypeScript 프로그래밍을 익혀 코드 품질을 한 단계 업그레이드해 보세요!

반응형

댓글