iBetter Books
수정

제네릭으로 재사용 가능한 코드 만들기

좋은 건물에는 설계도가 있습니다. 아파트 설계도 하나로 101동도, 102동도, 103동도 짓습니다. 세부 마감재나 배치는 동마다 다를 수 있지만, 기본 구조는 동일합니다. 설계도를 한 번 잘 만들면 반복 작업이 줄어듭니다.

제네릭 인터페이스와 클래스가 이 설계도입니다. 타입을 매개변수로 받는 "틀"을 만들어놓으면, 여러 타입에 걸쳐 같은 구조가 반복됩니다.

제네릭 인터페이스 — ApiResponse

API를 호출하면 성공할 때도 있고 실패할 때도 있습니다. 응답 형태를 일관성 있게 다루는 타입을 만들어봅시다.

// 새 파일: api-response.tsinterface ApiResponse<T> {  data: T;  status: number;  message: string;  success: boolean;}// 사용자 API 응답interface User {  id: number;  name: string;  email: string;}// 상품 API 응답interface Product {  id: number;  name: string;  price: number;}// 사용자 목록 응답const userListResponse: ApiResponse<User[]> = {  data: [    { id: 1, name: "Alice", email: "[email protected]" },    { id: 2, name: "Bob", email: "[email protected]" },  ],  status: 200,  message: "성공",  success: true,};// 단일 상품 응답const productResponse: ApiResponse<Product> = {  data: { id: 10, name: "노트북", price: 1500000 },  status: 200,  message: "성공",  success: true,};// TypeScript가 data의 타입을 정확하게 압니다console.log(userListResponse.data[0].name.toUpperCase()); // ALICEconsole.log(productResponse.data.price.toFixed(0));       // 1500000

ApiResponse<T>라는 틀 하나로 사용자 응답, 상품 응답, 주문 응답 등 어떤 데이터도 감쌀 수 있습니다. data 속성의 타입이 T이므로, ApiResponse<User>에서 dataUser, ApiResponse<Product>에서 dataProduct입니다.

제네릭 인터페이스 — 페이지네이션

목록을 페이지로 나눠서 받는 패턴도 흔합니다.

// 새 파일: pagination.tsinterface Page<T> {  items: T[];  total: number;  page: number;  pageSize: number;  hasNext: boolean;}interface User {  id: number;  name: string;  email: string;}interface Post {  id: number;  title: string;  content: string;}function createPage<T>(items: T[], total: number, page: number, pageSize: number): Page<T> {  return {    items,    total,    page,    pageSize,    hasNext: page * pageSize < total,  };}const userPage = createPage<User>(  [    { id: 1, name: "Alice", email: "[email protected]" },    { id: 2, name: "Bob", email: "[email protected]" },  ],  50,  1,  10);console.log(userPage.total);          // 50console.log(userPage.hasNext);        // trueconsole.log(userPage.items[0].name);  // Aliceconst postPage = createPage<Post>(  [{ id: 1, title: "첫 글", content: "내용" }],  1,  1,  10);console.log(postPage.items[0].title); // 첫 글console.log(postPage.hasNext);        // false

Page<T>를 한 번 만들어놓으면 사용자 목록 페이지, 게시글 목록 페이지, 주문 목록 페이지 등을 동일한 구조로 다룰 수 있습니다.

제네릭 클래스 — Stack

클래스에도 제네릭을 쓸 수 있습니다. 스택(Stack)은 나중에 넣은 것을 먼저 꺼내는 자료구조입니다.

// 새 파일: generic-stack.tsclass Stack<T> {  private items: T[] = [];  push(item: T): void {    this.items.push(item);  }  pop(): T | undefined {    return this.items.pop();  }  peek(): T | undefined {    return this.items[this.items.length - 1];  }  isEmpty(): boolean {    return this.items.length === 0;  }  get size(): number {    return this.items.length;  }}// 숫자 스택const numberStack = new Stack<number>();numberStack.push(1);numberStack.push(2);numberStack.push(3);console.log(numberStack.peek());  // 3console.log(numberStack.pop());   // 3console.log(numberStack.size);    // 2// 문자열 스택const stringStack = new Stack<string>();stringStack.push("a");stringStack.push("b");stringStack.push("c");console.log(stringStack.pop());   // cconsole.log(stringStack.peek());  // b// 타입이 섞이면 오류가 납니다// numberStack.push("hello"); // 오류 — string은 number가 아닙니다

Stack<number>는 숫자만 담을 수 있고, Stack<string>은 문자열만 담을 수 있습니다. push("hello")를 숫자 스택에 하면 컴파일 오류가 납니다.

제네릭 클래스 — Repository

실전에서 자주 보이는 Repository 패턴입니다. 데이터 접근 로직을 한 곳에 모아둡니다.

// 새 파일: repository.tsinterface HasId {  id: number;}class Repository<T extends HasId> {  private store: Map<number, T> = new Map();  save(item: T): T {    this.store.set(item.id, item);    return item;  }  findById(id: number): T | undefined {    return this.store.get(id);  }  findAll(): T[] {    return Array.from(this.store.values());  }  delete(id: number): boolean {    return this.store.delete(id);  }  count(): number {    return this.store.size;  }}interface User {  id: number;  name: string;  email: string;}interface Product {  id: number;  name: string;  price: number;}// 사용자 저장소const userRepo = new Repository<User>();userRepo.save({ id: 1, name: "Alice", email: "[email protected]" });userRepo.save({ id: 2, name: "Bob", email: "[email protected]" });const user = userRepo.findById(1);console.log(user?.name);  // Aliceconst allUsers = userRepo.findAll();console.log(allUsers.length); // 2// 상품 저장소const productRepo = new Repository<Product>();productRepo.save({ id: 1, name: "노트북", price: 1500000 });productRepo.save({ id: 2, name: "마우스", price: 30000 });const product = productRepo.findById(2);console.log(product?.price.toFixed(0)); // 30000console.log(productRepo.count()); // 2

Repository<T extends HasId>id: number를 가진 타입만 저장할 수 있습니다. User도 되고 Product도 됩니다. save, findById, findAll, delete — 공통 로직을 한 번만 작성했습니다.

제네릭으로 유틸 함수 묶기

비슷한 유틸 함수들을 네임스페이스처럼 묶을 수도 있습니다.

// 새 파일: array-utils-generic.tsfunction groupBy<T, K extends string | number>(  arr: T[],  getKey: (item: T) => K): Record<K, T[]> {  return arr.reduce(    (result, item) => {      const key = getKey(item);      if (!result[key]) {        result[key] = [];      }      result[key].push(item);      return result;    },    {} as Record<K, T[]>  );}function sortBy<T>(arr: T[], getKey: (item: T) => number | string): T[] {  return [...arr].sort((a, b) => {    const keyA = getKey(a);    const keyB = getKey(b);    return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;  });}function unique<T>(arr: T[], getKey: (item: T) => unknown): T[] {  const seen = new Set();  return arr.filter((item) => {    const key = getKey(item);    if (seen.has(key)) return false;    seen.add(key);    return true;  });}// 사용 예시interface Order {  id: number;  userId: number;  product: string;  amount: number;}const orders: Order[] = [  { id: 1, userId: 1, product: "노트북", amount: 1500000 },  { id: 2, userId: 2, product: "마우스", amount: 30000 },  { id: 3, userId: 1, product: "키보드", amount: 80000 },  { id: 4, userId: 3, product: "모니터", amount: 400000 },  { id: 5, userId: 2, product: "헤드셋", amount: 120000 },];// 사용자별로 주문 묶기const ordersByUser = groupBy(orders, (o) => o.userId);console.log(ordersByUser[1].length); // 2 — userId 1의 주문 수// 금액 기준으로 정렬const sortedOrders = sortBy(orders, (o) => o.amount);console.log(sortedOrders[0].product);  // 마우스 (가장 저렴)console.log(sortedOrders[4].product);  // 노트북 (가장 비쌈)// userId 기준으로 중복 제거 (각 사용자의 첫 번째 주문만)const firstOrders = unique(orders, (o) => o.userId);console.log(firstOrders.length); // 3 — 사용자 3명

groupBy, sortBy, unique — 배열을 다루는 유틸 함수들입니다. 모두 제네릭이라서 Order 배열에도, User 배열에도, Product 배열에도 쓸 수 있습니다.

앞서 배운 모든 것이 연결된다

지금까지 배운 제네릭 요소들이 실전 코드에서 어떻게 조합되는지 봅시다.

// 새 파일: generic-full-example.ts// 제네릭 인터페이스interface ApiResponse<T> {  data: T;  status: number;  success: boolean;  message: string;}// 제네릭 제약interface HasId {  id: number;}// 제네릭 함수 — 성공 응답 생성function successResponse<T>(data: T): ApiResponse<T> {  return {    data,    status: 200,    success: true,    message: "성공",  };}// 제네릭 함수 — 실패 응답 생성function errorResponse<T>(message: string): ApiResponse<T | null> {  return {    data: null,    status: 400,    success: false,    message,  };}// 유틸리티 타입과 조합interface User {  id: number;  name: string;  email: string;  password: string;}type PublicUser = Omit<User, "password">;// 비밀번호를 제외한 공개 정보// 제네릭 클래스class UserService {  private users: User[] = [];  create(input: Omit<User, "id">): ApiResponse<PublicUser> {    const newUser: User = { ...input, id: this.users.length + 1 };    this.users.push(newUser);    const { password, ...publicUser } = newUser;    return successResponse(publicUser);  }  findById(id: number): ApiResponse<PublicUser | null> {    const user = this.users.find((u) => u.id === id);    if (!user) {      return errorResponse("사용자를 찾을 수 없습니다.");    }    const { password, ...publicUser } = user;    return successResponse(publicUser);  }}const service = new UserService();const createResult = service.create({  name: "Alice",  email: "[email protected]",  password: "secret123",});console.log(createResult.success);       // trueconsole.log(createResult.data?.name);    // Alice// createResult.data?.password           // 오류 — password는 없습니다const findResult = service.findById(1);console.log(findResult.data?.email);     // [email protected]const notFound = service.findById(99);console.log(notFound.success);           // falseconsole.log(notFound.message);           // 사용자를 찾을 수 없습니다.

ApiResponse<T> 제네릭 인터페이스, Omit<User, "password"> 유틸리티 타입, 제네릭 함수 successResponseerrorResponse가 모두 조합되어 동작합니다. 각 부분이 타입 안전하게 연결됩니다.


설계도를 한 번 잘 만들면 어디든 쓸 수 있습니다. ApiResponse<T> 하나로 모든 API 응답을 일관되게 다루고, Repository<T> 하나로 모든 데이터 접근 로직을 처리합니다.

제네릭 함수로 시작해서, 제약을 걸고, 유틸리티 타입으로 변형하고, 인터페이스와 클래스로 구조를 만들었습니다. PART 06에서 배운 모든 내용이 연결되어 있습니다.

PART 06을 마쳤습니다. 타입을 매개변수로 받는다는 발상이 처음에는 낯설었겠지만, 이제는 익숙해졌을 겁니다. "어떤 타입이든 처리할 수 있으면서 타입 정보를 잃지 않는다" — 이것이 제네릭의 핵심입니다.

다음 PART에서는 코드를 파일로 나누고 모듈로 구성하는 방법을 배웁니다. 지금까지 하나의 파일에 작성했던 코드를 여러 파일로 정리하고 불러오는 방식을 다룹니다.