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)
"지금 필요하지 않으면 만들지 말라." 미래를 과도하게 예측해서 추상화를 남발하면 현재 코드가 복잡해지고 이해하기 어려워진다. 변경이 실제로 발생했을 때 리팩토링하는 것이 더 낫다.
원칙 적용 우선순위
실무에서는 다음 순서로 원칙을 적용하면 효과적이다.
- SRP 먼저: 클래스가 너무 크면 나눈다. 테스트 가능성이 즉시 높아진다.
- DIP: 외부 시스템(DB, API, 파일)과의 경계에 인터페이스를 둔다.
- OCP: 변경 빈도가 높은 로직(할인, 알림, 정렬)에 적용한다.
- ISP: 인터페이스가 커지면 나눈다.
- LSP: 상속을 사용할 때 계약을 지키고 있는지 검토한다.
정리
| 원칙 | 약자 | 핵심 질문 | TypeScript 적용 |
|---|---|---|---|
| 단일 책임 | SRP | 이 클래스가 바뀌는 이유가 하나인가? | 클래스 분리, 관심사 분리 |
| 개방-폐쇄 | OCP | 새 기능 추가 시 기존 코드를 수정하는가? | 인터페이스 + 다형성 |
| 리스코프 치환 | LSP | 서브클래스가 부모를 완전히 대체하는가? | 추상 클래스/인터페이스 계약 준수 |
| 인터페이스 분리 | ISP | 사용하지 않는 메서드를 구현하는가? | 작은 인터페이스로 분리 |
| 의존성 역전 | DIP | 구체 클래스에 직접 의존하는가? | 생성자 주입, 인터페이스 의존 |
다음 장에서는 TypeScript의 제네릭(Generics)을 배운다. 타입을 파라미터로 만들어 재사용 가능한 컴포넌트를 설계하는 방법, 제네릭 제약 조건, 조건부 타입과의 조합을 실전 예제로 익힌다.