icon안동민 개발노트

SOLID 원칙 적용


 SOLID 원칙은 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 말합니다.

 타입스크립트에서 이 원칙들을 적용하면 더 유지보수가 쉽고, 유연하며, 확장 가능한 소프트웨어를 만들 수 있습니다.

단일 책임 원칙

 단일 책임 원칙 (Single Responsibility Principle)은 하나의 클래스나 모듈이 하나의 책임만을 가져야 한다고 말합니다.

 타입스크립트 예시

// 나쁜 예
class User {
    constructor(public name: string, public email: string) {}
 
    saveToDatabase() { /* ... */ }
    sendEmail() { /* ... */ }
}
 
// 좋은 예
class User {
    constructor(public name: string, public email: string) {}
}
 
class UserRepository {
    saveUser(user: User) { /* ... */ }
}
 
class EmailService {
    sendEmail(user: User, message: string) { /* ... */ }
}

 타입스크립트의 인터페이스를 사용하면 이 원칙을 더 잘 지킬 수 있습니다.

interface UserData {
    name: string;
    email: string;
}
 
interface UserRepository {
    save(user: UserData): Promise<void>;
}
 
interface EmailService {
    send(to: string, message: string): Promise<void>;
}

개방-폐쇄 원칙

 개방-폐쇄 원칙 (Open-Closed Principle)에서는 소프트웨어 엔티티는 확장에는 열려 있어야 하지만 수정에는 닫혀 있어야 합니다.

 타입스크립트 예시

abstract class Shape {
    abstract area(): number;
}
 
class Rectangle extends Shape {
    constructor(private width: number, private height: number) {
        super();
    }
 
    area(): number {
        return this.width * this.height;
    }
}
 
class Circle extends Shape {
    constructor(private radius: number) {
        super();
    }
 
    area(): number {
        return Math.PI * this.radius ** 2;
    }
}
 
function calculateTotalArea(shapes: Shape[]): number {
    return shapes.reduce((total, shape) => total + shape.area(), 0);
}

 여기서 calculateTotalArea 함수는 새로운 Shape 타입이 추가되어도 수정할 필요가 없습니다.

리스코프 치환 원칙

 리스코프 치환 원칙 (Liskov Substitution Principle), 하위 타입은 상위 타입을 대체할 수 있어야 합니다.

interface Bird {
    fly(): void;
}
 
class Sparrow implements Bird {
    fly() {
        console.log("Sparrow flying");
    }
}
 
class Ostrich implements Bird {
    fly() {
        throw new Error("Ostriches can't fly");
    }
}
 
// 이 경우 Ostrich는 LSP를 위반합니다.
// 더 나은 설계:
 
interface FlyingBird {
    fly(): void;
}
 
interface RunningBird {
    run(): void;
}
 
class Sparrow implements FlyingBird {
    fly() {
        console.log("Sparrow flying");
    }
}
 
class Ostrich implements RunningBird {
    run() {
        console.log("Ostrich running");
    }
}

인터페이스 분리 원칙

 인터페이스 분리 원칙 (Interface Segregation Principle), 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하도록 강제되어서는 안 됩니다.

// 나쁜 예
interface Worker {
    work(): void;
    eat(): void;
}
 
// 좋은 예
interface Workable {
    work(): void;
}
 
interface Eatable {
    eat(): void;
}
 
class Human implements Workable, Eatable {
    work() { /* ... */ }
    eat() { /* ... */ }
}
 
class Robot implements Workable {
    work() { /* ... */ }
}

의존관계 역전 원칙

 의존관계 역전 원칙 (Dependency Inversion Principle), 고수준 모듈은 저수준 모듈에 의존해서는 안 되며 둘 다 추상화에 의존해야 합니다.

// 나쁜 예
class EmailNotifier {
    sendEmail(message: string) { /* ... */ }
}
 
class NotificationService {
    private emailNotifier = new EmailNotifier();
 
    notify(message: string) {
        this.emailNotifier.sendEmail(message);
    }
}
 
// 좋은 예
interface Notifier {
    send(message: string): void;
}
 
class EmailNotifier implements Notifier {
    send(message: string) { /* ... */ }
}
 
class SMSNotifier implements Notifier {
    send(message: string) { /* ... */ }
}
 
class NotificationService {
    constructor(private notifier: Notifier) {}
 
    notify(message: string) {
        this.notifier.send(message);
    }
}

타입스크립트의 이점

 타입스크립트의 정적 타입 시스템은 SOLID 원칙 적용에 큰 도움을 줍니다.

  1. 인터페이스와 추상 클래스를 통한 명확한 계약 정의
  2. 제네릭을 통한 재사용 가능한 컴포넌트 생성
  3. 컴파일 시간에 원칙 위반 감지 가능

제네릭을 활용한 SRP와 ISP 강화

interface Repository<T> {
    find(id: string): Promise<T>;
    save(item: T): Promise<void>;
}
 
class UserRepository implements Repository<User> {
    // 구현...
}
 
class ProductRepository implements Repository<Product> {
    // 구현...
}

 이 접근 방식은 각 리포지토리가 단일 책임을 가지면서도, 공통 인터페이스를 통해 일관성을 유지합니다.

균형 잡힌 SOLID 적용

 SOLID 원칙을 과도하게 적용하면 불필요한 복잡성이 증가할 수 있습니다. 다음 지침을 고려하세요.

  1. 실제 필요성에 기반한 추상화 도입
  2. 현재와 가까운 미래의 요구사항만 고려
  3. 팀의 기술 수준과 프로젝트 규모에 맞는 설계 복잡도 유지

레거시 코드 리팩토링 전략

  1. 점진적 접근 : 한 번에 하나의 원칙에 집중
  2. 단위 테스트 작성 : 리팩토링 전 동작을 보장
  3. 인터페이스 도입 : 기존 클래스를 인터페이스로 추상화
  4. 의존성 주입 도입 : 하드코딩된 의존성을 제거
  5. 책임 분리 : 큰 클래스를 작은 클래스들로 분할

Best Practices와 주의사항

  1. 인터페이스 우선 : 구체적인 구현보다 인터페이스에 의존
  2. 의존성 주입 활용 : 생성자 주입을 통한 느슨한 결합 구현
  3. 작은 인터페이스 선호 : 큰 인터페이스보다 작고 집중된 인터페이스 사용
  4. 상속보다 합성 : 상속 대신 합성을 통한 유연한 설계
  5. 단위 테스트 작성 : SOLID 원칙 준수 여부를 테스트로 검증
  6. 문서화 : 복잡한 추상화의 의도와 사용법 문서화
  7. 과도한 엔지니어링 주의 : 실제 필요 이상의 추상화 지양
  8. 지속적인 리팩토링 : 코드 품질 유지를 위한 정기적인 리팩토링 수행

 타입스크립트의 강력한 타입 시스템은 이러한 원칙들을 더 효과적으로 구현하고 강제할 수 있게 해줍니다.

 특히, 인터페이스와 추상 클래스를 활용한 개방-폐쇄 원칙과 리스코프 치환 원칙의 구현은 타입스크립트에서 매우 자연스럽게 이루어집니다.

 타입 체크를 통해 상위 타입을 하위 타입으로 안전하게 대체할 수 있는지 컴파일 시간에 확인할 수 있기 때문입니다.

 제네릭의 사용은 단일 책임 원칙과 인터페이스 분리 원칙을 강화하는 데 큰 도움이 됩니다.

 제네릭을 통해 타입에 구애받지 않는 재사용 가능한 컴포넌트를 만들 수 있으며, 이는 코드의 중복을 줄이고 응집도를 높이는 데 기여합니다.

 의존관계 역전 원칙의 구현에 있어서도 타입스크립트의 인터페이스와 의존성 주입 패턴을 결합하면 모듈 간의 결합도를 크게 낮출 수 있습니다.