본문으로 건너뛰기
Advertisement

4.5 SOLID 원칙 TypeScript 적용

SOLID는 Robert C. Martin(Uncle Bob)이 정리한 객체지향 설계의 다섯 가지 원칙이다. 코드를 변경하기 쉽고, 이해하기 쉽고, 확장하기 쉽게 만드는 지침이다. 원칙을 지키면 한 곳의 변경이 다른 곳에 예상치 못한 영향을 주는 일이 줄어든다.

TypeScript는 타입 시스템 덕분에 SOLID를 적용했을 때 그 효과가 코드에 명확히 드러난다. 인터페이스 분리, 의존성 역전, 리스코프 치환이 타입 검사로 강제되기 때문이다. 이 장에서는 원칙마다 위반 예시와 리팩토링 예시를 나란히 보여주고, 마지막에는 주문 처리 시스템 예제로 5원칙을 통합 적용한다.


S — 단일 책임 원칙 (Single Responsibility Principle)

"클래스는 변경해야 할 이유가 하나뿐이어야 한다."

하나의 클래스가 너무 많은 일을 담당하면, 하나를 고칠 때 다른 기능이 깨지는 위험이 생긴다. 책임을 분리하면 각 클래스를 독립적으로 테스트하고 변경할 수 있다.

위반 예시

// 나쁜 예: 하나의 클래스가 너무 많은 책임
class UserManager {
// 책임 1: 사용자 데이터 관리
private users: { id: number; name: string; email: string }[] = [];

addUser(name: string, email: string): void {
const id = this.users.length + 1;
this.users.push({ id, name, email });
}

// 책임 2: 데이터베이스 저장 (DB 로직 변경 시 이 클래스 수정)
saveToDatabase(): void {
console.log("DB에 저장:", this.users);
// SQL 쿼리, ORM 코드 등...
}

// 책임 3: 이메일 발송 (이메일 템플릿 변경 시 이 클래스 수정)
sendWelcomeEmail(userId: number): void {
const user = this.users.find((u) => u.id === userId);
console.log(`이메일 발송: ${user?.email} 에게 환영 메시지`);
}

// 책임 4: 보고서 생성 (보고서 형식 변경 시 이 클래스 수정)
generateReport(): string {
return this.users.map((u) => `${u.id}: ${u.name}`).join("\n");
}
}
// 이 클래스는 DB, 이메일, 보고서 때문에 모두 변경될 수 있다.

리팩토링 — 책임 분리

// 좋은 예: 각 클래스가 하나의 책임만 담당

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

// 책임 1: 사용자 데이터 관리만
class UserRepository {
private users: User[] = [];

add(name: string, email: string): User {
const user: User = { id: this.users.length + 1, name, email };
this.users.push(user);
return user;
}

findById(id: number): User | undefined {
return this.users.find((u) => u.id === id);
}

findAll(): User[] {
return [...this.users];
}
}

// 책임 2: 데이터베이스 저장만
class UserDatabaseService {
save(users: User[]): void {
console.log("DB에 저장:", users);
}
}

// 책임 3: 이메일 발송만
class UserEmailService {
sendWelcome(user: User): void {
console.log(`[Email] ${user.email}에게 환영 메시지 발송`);
}

sendNotification(user: User, message: string): void {
console.log(`[Email] ${user.email}: ${message}`);
}
}

// 책임 4: 보고서 생성만
class UserReportService {
generate(users: User[]): string {
return users.map((u) => `${u.id}: ${u.name} <${u.email}>`).join("\n");
}
}

// 조율은 Application Service 레이어에서
class UserApplicationService {
constructor(
private repository: UserRepository,
private dbService: UserDatabaseService,
private emailService: UserEmailService
) {}

registerUser(name: string, email: string): User {
const user = this.repository.add(name, email);
this.dbService.save(this.repository.findAll());
this.emailService.sendWelcome(user);
return user;
}
}

O — 개방-폐쇄 원칙 (Open-Closed Principle)

"소프트웨어 요소는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다."

새 기능을 추가할 때 기존 코드를 수정하지 않고 새 코드를 추가하는 방식으로 구현해야 한다.

위반 예시

// 나쁜 예: 새 할인 방식이 생길 때마다 이 함수를 수정해야 함
type DiscountType = "none" | "percentage" | "fixed" | "buy2get1";

function calculateDiscount(price: number, type: DiscountType): number {
if (type === "none") {
return price;
} else if (type === "percentage") {
return price * 0.9; // 10% 할인 (하드코딩)
} else if (type === "fixed") {
return price - 1000; // 1000원 할인 (하드코딩)
} else if (type === "buy2get1") {
return price * (2 / 3);
}
// 새 타입 추가 시 여기를 수정해야 함 — OCP 위반
return price;
}

리팩토링 — 추상화로 확장 가능하게

// 좋은 예: 새 할인 정책 추가 시 기존 코드 수정 불필요

interface DiscountPolicy {
apply(price: number): number;
describe(): string;
}

class NoDiscount implements DiscountPolicy {
apply(price: number): number { return price; }
describe(): string { return "할인 없음"; }
}

class PercentageDiscount implements DiscountPolicy {
constructor(private percent: number) {}
apply(price: number): number { return price * (1 - this.percent / 100); }
describe(): string { return `${this.percent}% 할인`; }
}

class FixedDiscount implements DiscountPolicy {
constructor(private amount: number) {}
apply(price: number): number { return Math.max(0, price - this.amount); }
describe(): string { return `${this.amount}원 할인`; }
}

// 나중에 추가 — 기존 코드 수정 없음
class BuyTwoGetOneFree implements DiscountPolicy {
apply(price: number): number { return price * (2 / 3); }
describe(): string { return "2+1 행사"; }
}

class SeasonalDiscount implements DiscountPolicy {
constructor(private season: "spring" | "summer" | "fall" | "winter") {}

apply(price: number): number {
const rates: Record<string, number> = {
spring: 0.95, summer: 0.85, fall: 0.9, winter: 0.8
};
return price * rates[this.season];
}

describe(): string { return `시즌 할인 (${this.season})`; }
}

class PriceCalculator {
calculate(price: number, policy: DiscountPolicy): number {
const discounted = policy.apply(price);
console.log(`${policy.describe()}: ${price}원 → ${discounted}`);
return discounted;
}
}

const calculator = new PriceCalculator();
calculator.calculate(10000, new PercentageDiscount(10)); // 10% 할인
calculator.calculate(10000, new SeasonalDiscount("summer")); // 새 정책 — 기존 코드 수정 없이 추가

L — 리스코프 치환 원칙 (Liskov Substitution Principle)

"서브클래스는 부모 클래스를 완전히 대체할 수 있어야 한다."

부모 클래스를 사용하는 코드에 서브클래스를 넣어도 프로그램이 올바르게 동작해야 한다. 서브클래스가 부모의 계약(사전조건, 사후조건, 불변식)을 위반하면 LSP를 어긴다.

위반 예시

// 나쁜 예: 정사각형-직사각형 문제 (고전적 LSP 위반)
class Rectangle {
constructor(protected width: number, protected height: number) {}

setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }

area(): number { return this.width * this.height; }
}

class Square extends Rectangle {
constructor(size: number) { super(size, size); }

// LSP 위반: 부모의 계약을 바꿈
// Rectangle은 width와 height가 독립적이어야 하지만
// Square는 이를 강제로 같게 만든다
setWidth(w: number): void {
this.width = w;
this.height = w; // 높이도 같이 바꿈
}

setHeight(h: number): void {
this.width = h; // 너비도 같이 바꿈
this.height = h;
}
}

function testRectangle(rect: Rectangle): void {
rect.setWidth(5);
rect.setHeight(10);
// Rectangle이라면 항상 50이어야 한다고 가정
console.assert(rect.area() === 50, `넓이가 50이어야 하는데 ${rect.area()}`);
}

testRectangle(new Rectangle(1, 1)); // OK: 50
testRectangle(new Square(1)); // 실패: 100 (width도 10으로 바뀌어서)

리팩토링 — 상속 계층 재설계

// 좋은 예: 공통 추상화를 인터페이스로 분리

interface Shape2D {
area(): number;
perimeter(): number;
}

// Rectangle과 Square를 독립 클래스로 설계
class Rectangle2D implements Shape2D {
constructor(public readonly width: number, public readonly height: number) {}
area(): number { return this.width * this.height; }
perimeter(): number { return 2 * (this.width + this.height); }
}

class Square2D implements Shape2D {
constructor(public readonly side: number) {}
area(): number { return this.side ** 2; }
perimeter(): number { return 4 * this.side; }
}

// 두 클래스 모두 Shape2D 계약을 완전히 준수
function printShapeInfo(shape: Shape2D): void {
console.log(`넓이: ${shape.area()}, 둘레: ${shape.perimeter()}`);
}

printShapeInfo(new Rectangle2D(5, 10)); // 넓이: 50
printShapeInfo(new Square2D(5)); // 넓이: 25

// LSP 올바른 상속 예시
abstract class Bird {
abstract name(): string;
move(): string { return `${this.name()}가 이동합니다.`; }
}

abstract class FlyingBird extends Bird {
fly(): string { return `${this.name()}가 납니다.`; }
}

abstract class SwimmingBird extends Bird {
swim(): string { return `${this.name()}가 헤엄칩니다.`; }
}

class Eagle extends FlyingBird {
name(): string { return "독수리"; }
}

class Penguin extends SwimmingBird {
name(): string { return "펭귄"; }
}

// Penguin에 fly()를 강제하지 않음 — LSP 준수

I — 인터페이스 분리 원칙 (Interface Segregation Principle)

"클라이언트는 자신이 사용하지 않는 인터페이스에 의존하도록 강제되어서는 안 된다."

하나의 큰 인터페이스보다 여러 개의 작은 인터페이스가 낫다.

위반 예시

// 나쁜 예: 모든 기능이 하나의 인터페이스에 — ISP 위반
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
writeCode(): void;
designUI(): void;
managePeople(): void;
}

// 백엔드 개발자: designUI, managePeople은 불필요하지만 구현해야 함
class BackendDeveloper implements Worker {
work(): void { console.log("API 개발"); }
eat(): void { console.log("점심 식사"); }
sleep(): void { console.log("수면"); }
attendMeeting(): void { console.log("회의 참석"); }
writeCode(): void { console.log("코드 작성"); }
designUI(): void { throw new Error("UI 디자인 못함!"); } // ISP 위반
managePeople(): void { throw new Error("팀 관리 못함!"); } // ISP 위반
}

리팩토링 — 인터페이스 분리

// 좋은 예: 역할별 인터페이스 분리

interface BasicWorker {
work(): void;
eat(): void;
sleep(): void;
}

interface MeetingParticipant {
attendMeeting(): void;
}

interface Coder {
writeCode(): void;
}

interface UIDesigner {
designUI(): void;
}

interface PeopleManager {
managePeople(): void;
}

// 각 직군은 필요한 인터페이스만 구현
class BackendDev implements BasicWorker, MeetingParticipant, Coder {
work(): void { console.log("API 개발"); }
eat(): void { console.log("점심 식사"); }
sleep(): void { console.log("수면"); }
attendMeeting(): void { console.log("회의 참석"); }
writeCode(): void { console.log("백엔드 코드 작성"); }
}

class Designer implements BasicWorker, MeetingParticipant, UIDesigner {
work(): void { console.log("UI 설계"); }
eat(): void { console.log("점심 식사"); }
sleep(): void { console.log("수면"); }
attendMeeting(): void { console.log("회의 참석"); }
designUI(): void { console.log("화면 디자인"); }
}

class TeamLead implements BasicWorker, MeetingParticipant, Coder, PeopleManager {
work(): void { console.log("코드 리뷰 및 팀 관리"); }
eat(): void { console.log("팀 점심"); }
sleep(): void { console.log("수면"); }
attendMeeting(): void { console.log("회의 주도"); }
writeCode(): void { console.log("핵심 코드 작성"); }
managePeople(): void { console.log("팀원 성과 관리"); }
}

// 함수는 필요한 능력만 요구
function runCodeReview(coder: Coder): void {
coder.writeCode();
}

function scheduleDesignReview(designer: UIDesigner): void {
designer.designUI();
}

const dev = new BackendDev();
const lead = new TeamLead();
runCodeReview(dev); // OK
runCodeReview(lead); // OK

D — 의존성 역전 원칙 (Dependency Inversion Principle)

"상위 모듈은 하위 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다." "추상화는 세부 사항에 의존하면 안 된다. 세부 사항이 추상화에 의존해야 한다."

위반 예시

// 나쁜 예: 상위 모듈이 구체 클래스에 직접 의존

class MySQLDatabase {
save(data: string): void {
console.log(`MySQL에 저장: ${data}`);
}

find(id: number): string {
return `MySQL 데이터 ${id}`;
}
}

class FileLogger {
log(message: string): void {
console.log(`파일에 기록: ${message}`);
}
}

// OrderService가 MySQLDatabase, FileLogger에 직접 의존
// DB를 PostgreSQL로 바꾸려면 OrderService를 수정해야 함 — DIP 위반
class OrderService {
private db = new MySQLDatabase(); // 구체 클래스에 의존
private logger = new FileLogger(); // 구체 클래스에 의존

placeOrder(orderId: number, product: string): void {
this.logger.log(`주문 시작: ${orderId}`);
this.db.save(`주문 ${orderId}: ${product}`);
this.logger.log(`주문 완료: ${orderId}`);
}
}

리팩토링 — 추상화에 의존

// 좋은 예: 인터페이스(추상화)에 의존

interface Database {
save(data: string): void;
find(id: number): string;
}

interface Logger {
log(message: string): void;
error(message: string): void;
}

// 구체 구현: Database 인터페이스 구현
class MySQLAdapter implements Database {
save(data: string): void { console.log(`MySQL 저장: ${data}`); }
find(id: number): string { return `MySQL 레코드 ${id}`; }
}

class PostgreSQLAdapter implements Database {
save(data: string): void { console.log(`PostgreSQL 저장: ${data}`); }
find(id: number): string { return `PostgreSQL 레코드 ${id}`; }
}

class InMemoryDatabase implements Database {
private store: Map<string, string> = new Map();

save(data: string): void {
const id = Date.now().toString();
this.store.set(id, data);
console.log(`메모리 저장: ${data}`);
}

find(id: number): string {
return this.store.get(id.toString()) ?? "Not Found";
}
}

// 구체 구현: Logger 인터페이스 구현
class ConsoleLogger implements Logger {
log(message: string): void { console.log(`[INFO] ${message}`); }
error(message: string): void { console.error(`[ERROR] ${message}`); }
}

class FileLogger2 implements Logger {
log(message: string): void { console.log(`[FILE] ${message}`); }
error(message: string): void { console.error(`[FILE ERROR] ${message}`); }
}

// 상위 모듈: 인터페이스에만 의존 — 구체 클래스를 전혀 모름
class OrderService2 {
constructor(
private db: Database, // 추상화에 의존
private logger: Logger // 추상화에 의존
) {}

placeOrder(orderId: number, product: string): void {
this.logger.log(`주문 시작: ${orderId}`);
this.db.save(`주문 ${orderId}: ${product}`);
this.logger.log(`주문 완료: ${orderId}`);
}
}

// 의존성 주입: 외부에서 구체 구현을 주입
const productionService = new OrderService2(
new MySQLAdapter(),
new ConsoleLogger()
);

const testService = new OrderService2(
new InMemoryDatabase(), // 테스트 시 메모리 DB
new ConsoleLogger()
);

productionService.placeOrder(1001, "TypeScript 교재");
testService.placeOrder(9999, "테스트 상품");

실전 예제: 주문 처리 시스템으로 5원칙 모두 적용

// ==================== 인터페이스 정의 (DIP + ISP) ====================

// ISP: 각 역할에 필요한 인터페이스만 분리
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
}

interface PaymentGateway {
charge(amount: number, customerId: string): Promise<PaymentResult>;
refund(paymentId: string): Promise<void>;
}

interface NotificationService {
notify(customerId: string, message: string): Promise<void>;
}

interface InventoryService {
checkStock(productId: string): Promise<number>;
reserve(productId: string, quantity: number): Promise<void>;
release(productId: string, quantity: number): Promise<void>;
}

// ==================== 도메인 모델 (SRP) ====================

type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";

interface OrderItem {
productId: string;
name: string;
quantity: number;
unitPrice: number;
}

interface PaymentResult {
paymentId: string;
success: boolean;
errorMessage?: string;
}

class Order {
readonly id: string;
readonly customerId: string;
private items: OrderItem[];
private status: OrderStatus;
readonly createdAt: Date;

constructor(customerId: string, items: OrderItem[]) {
this.id = `ORD-${Date.now()}`;
this.customerId = customerId;
this.items = [...items];
this.status = "pending";
this.createdAt = new Date();
}

getStatus(): OrderStatus { return this.status; }
getItems(): OrderItem[] { return [...this.items]; }

confirm(): void {
if (this.status !== "pending") {
throw new Error(`${this.status} 상태에서는 확정할 수 없습니다.`);
}
this.status = "confirmed";
}

cancel(): void {
if (this.status === "shipped" || this.status === "delivered") {
throw new Error("배송 중이거나 배송 완료된 주문은 취소할 수 없습니다.");
}
this.status = "cancelled";
}

totalAmount(): number {
return this.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
}
}

// ==================== 할인 정책 (OCP) ====================

interface DiscountPolicy2 {
apply(order: Order): number;
describe(): string;
}

class NoDiscount2 implements DiscountPolicy2 {
apply(order: Order): number { return order.totalAmount(); }
describe(): string { return "할인 없음"; }
}

class MemberDiscount implements DiscountPolicy2 {
constructor(private discountRate: number) {}

apply(order: Order): number {
return order.totalAmount() * (1 - this.discountRate);
}

describe(): string { return `회원 ${this.discountRate * 100}% 할인`; }
}

class VIPDiscount implements DiscountPolicy2 {
apply(order: Order): number {
const total = order.totalAmount();
return total > 100000 ? total * 0.8 : total * 0.9;
}

describe(): string { return "VIP 등급 할인 (10만원 이상 20%, 미만 10%)"; }
}

// ==================== 주문 서비스 (SRP + DIP) ====================

class OrderPlacementService {
// DIP: 구체 클래스 대신 인터페이스에 의존
constructor(
private repository: OrderRepository,
private payment: PaymentGateway,
private notification: NotificationService,
private inventory: InventoryService,
private discountPolicy: DiscountPolicy2 // OCP: 교체 가능
) {}

async placeOrder(
customerId: string,
items: OrderItem[]
): Promise<Order> {
// SRP: 각 단계를 분리된 메서드로 처리
await this.validateStock(items);
const order = new Order(customerId, items);
await this.processPayment(order);
await this.repository.save(order);
await this.notification.notify(
customerId,
`주문이 완료되었습니다. 주문번호: ${order.id}`
);
return order;
}

private async validateStock(items: OrderItem[]): Promise<void> {
for (const item of items) {
const stock = await this.inventory.checkStock(item.productId);
if (stock < item.quantity) {
throw new Error(`상품 ${item.productId} 재고 부족 (보유: ${stock}개)`);
}
}
}

private async processPayment(order: Order): Promise<void> {
const discountedAmount = this.discountPolicy.apply(order);
console.log(
`결제 금액: ${order.totalAmount()}원 → ${discountedAmount}원 (${this.discountPolicy.describe()})`
);

const result = await this.payment.charge(discountedAmount, order.customerId);
if (!result.success) {
throw new Error(`결제 실패: ${result.errorMessage}`);
}
order.confirm();
}
}

// ==================== 구체 구현 (LSP 준수) ====================

// LSP: 모든 구현체가 인터페이스 계약을 완전히 준수
class InMemoryOrderRepository implements OrderRepository {
private store = new Map<string, Order>();

async save(order: Order): Promise<void> {
this.store.set(order.id, order);
}

async findById(id: string): Promise<Order | null> {
return this.store.get(id) ?? null;
}

async findByCustomer(customerId: string): Promise<Order[]> {
return [...this.store.values()].filter((o) => o.customerId === customerId);
}
}

class MockPaymentGateway implements PaymentGateway {
async charge(amount: number, customerId: string): Promise<PaymentResult> {
console.log(`[Payment] ${customerId}에게 ${amount}원 청구`);
return { paymentId: `PAY-${Date.now()}`, success: true };
}

async refund(paymentId: string): Promise<void> {
console.log(`[Payment] ${paymentId} 환불 처리`);
}
}

class EmailNotificationService implements NotificationService {
async notify(customerId: string, message: string): Promise<void> {
console.log(`[Email] ${customerId}: ${message}`);
}
}

class MockInventoryService implements InventoryService {
private stock = new Map<string, number>([
["TS-BOOK", 50],
["TS-COURSE", 100],
]);

async checkStock(productId: string): Promise<number> {
return this.stock.get(productId) ?? 0;
}

async reserve(productId: string, quantity: number): Promise<void> {
const current = this.stock.get(productId) ?? 0;
this.stock.set(productId, current - quantity);
}

async release(productId: string, quantity: number): Promise<void> {
const current = this.stock.get(productId) ?? 0;
this.stock.set(productId, current + quantity);
}
}

// ==================== 조립 및 실행 ====================

async function main() {
// 일반 회원 주문
const memberService = new OrderPlacementService(
new InMemoryOrderRepository(),
new MockPaymentGateway(),
new EmailNotificationService(),
new MockInventoryService(),
new MemberDiscount(0.1) // 10% 회원 할인
);

const order = await memberService.placeOrder("CUST-001", [
{ productId: "TS-BOOK", name: "TypeScript 마스터", quantity: 2, unitPrice: 35000 },
{ productId: "TS-COURSE", name: "온라인 강의", quantity: 1, unitPrice: 59000 },
]);

console.log(`주문 완료: ${order.id}, 상태: ${order.getStatus()}`);
}

main();

고수 팁

SOLID의 한계와 과도한 추상화 주의

SOLID는 지침이지 절대 법칙이 아니다. 맹목적으로 따르면 오히려 코드가 복잡해지는 "아키텍처 우주인(Architecture Astronaut)" 문제가 생긴다.

// 과도한 추상화: 단순한 유틸 함수에 불필요한 인터페이스
// 이런 코드는 실제로 나쁜 예다
interface StringLengthCalculator {
calculate(str: string): number;
}

class StandardStringLengthCalculator implements StringLengthCalculator {
calculate(str: string): number { return str.length; }
}

// 위처럼 쓰지 말고, 그냥 이렇게 써라:
function getLength(str: string): number { return str.length; }

// pragmatic 접근: 변경 가능성이 있을 때만 추상화
// - DB 구현체: 나중에 바꿀 가능성 있음 → 인터페이스 적합
// - 문자열 길이: 바꿀 일 없음 → 그냥 함수

YAGNI (You Aren't Gonna Need It)

"지금 필요하지 않으면 만들지 말라." 미래를 과도하게 예측해서 추상화를 남발하면 현재 코드가 복잡해지고 이해하기 어려워진다. 변경이 실제로 발생했을 때 리팩토링하는 것이 더 낫다.

원칙 적용 우선순위

실무에서는 다음 순서로 원칙을 적용하면 효과적이다.

  1. SRP 먼저: 클래스가 너무 크면 나눈다. 테스트 가능성이 즉시 높아진다.
  2. DIP: 외부 시스템(DB, API, 파일)과의 경계에 인터페이스를 둔다.
  3. OCP: 변경 빈도가 높은 로직(할인, 알림, 정렬)에 적용한다.
  4. ISP: 인터페이스가 커지면 나눈다.
  5. LSP: 상속을 사용할 때 계약을 지키고 있는지 검토한다.

정리

원칙약자핵심 질문TypeScript 적용
단일 책임SRP이 클래스가 바뀌는 이유가 하나인가?클래스 분리, 관심사 분리
개방-폐쇄OCP새 기능 추가 시 기존 코드를 수정하는가?인터페이스 + 다형성
리스코프 치환LSP서브클래스가 부모를 완전히 대체하는가?추상 클래스/인터페이스 계약 준수
인터페이스 분리ISP사용하지 않는 메서드를 구현하는가?작은 인터페이스로 분리
의존성 역전DIP구체 클래스에 직접 의존하는가?생성자 주입, 인터페이스 의존

다음 장에서는 TypeScript의 제네릭(Generics)을 배운다. 타입을 파라미터로 만들어 재사용 가능한 컴포넌트를 설계하는 방법, 제네릭 제약 조건, 조건부 타입과의 조합을 실전 예제로 익힌다.

Advertisement