전체 목록
설계Medium#99

SOLID 원칙을 각각 설명해주세요.

#설계#SOLID#OOP#객체지향
힌트

단일 책임, 개방-폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전

정답 및 해설

SOLID 원칙을 각각 설명해주세요.

SOLID는 객체 지향 프로그래밍에서 유지보수하기 쉽고 확장 가능한 소프트웨어를 만들기 위한 5가지 설계 원칙의 앞 글자를 모은 것입니다. Robert C. Martin(Uncle Bob)이 정리한 이 원칙들을 따르면 변경에 유연하고, 코드 중복이 적으며, 테스트하기 쉬운 코드를 작성할 수 있습니다.

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

정의

클래스나 함수는 변경될 이유가 하나여야 합니다. 즉, 하나의 클래스는 하나의 책임(기능)만 가져야 합니다.

예시

// ❌ SRP 위반: 하나의 클래스가 너무 많은 책임을 가짐
class User {
  constructor(private name: string, private email: string) {}

  // 책임 1: 사용자 데이터 관리
  getName(): string { return this.name; }
  setEmail(email: string) { this.email = email; }

  // 책임 2: 데이터베이스 저장 (DB 변경 시 이 클래스도 변경)
  saveToDatabase(): void {
    const db = new Database();
    db.execute(`INSERT INTO users ...`);
  }

  // 책임 3: 이메일 전송 (이메일 로직 변경 시 이 클래스도 변경)
  sendWelcomeEmail(): void {
    const emailService = new EmailService();
    emailService.send(this.email, '환영합니다!');
  }

  // 책임 4: 리포트 생성 (리포트 형식 변경 시 이 클래스도 변경)
  generateReport(): string {
    return `사용자 리포트: ${this.name}`;
  }
}

// ✅ SRP 준수: 각 클래스가 하나의 책임만 가짐
class User {
  constructor(public readonly name: string, public readonly email: string) {}
  getName(): string { return this.name; }
}

class UserRepository {
  save(user: User): void {
    // 데이터베이스 저장 로직만 담당
    const db = new Database();
    db.execute(`INSERT INTO users (name, email) VALUES (?, ?)`, [user.name, user.email]);
  }

  findById(id: number): User | null {
    // 조회 로직
    return null;
  }
}

class UserEmailService {
  sendWelcome(user: User): void {
    // 이메일 전송 로직만 담당
    emailProvider.send(user.email, '환영합니다!', `안녕하세요 ${user.name}님`);
  }
}

class UserReportGenerator {
  generate(user: User): string {
    // 리포트 생성 로직만 담당
    return `사용자 리포트: ${user.name}`;
  }
}

결과: 이메일 로직이 바뀌어도 User, UserRepository, UserReportGenerator는 변경할 필요가 없습니다.

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

정의

소프트웨어 개체는 확장에는 열려 있고(Open for extension), 수정에는 닫혀 있어야(Closed for modification) 합니다. 새 기능을 추가할 때 기존 코드를 수정하지 않고 코드를 추가하는 방식으로 구현해야 합니다.

예시

// ❌ OCP 위반: 새 결제 방법 추가 시 기존 코드 수정 필요
class PaymentProcessor {
  processPayment(type: string, amount: number): void {
    if (type === 'credit') {
      // 신용카드 처리
      console.log(`신용카드 ${amount}원 결제`);
    } else if (type === 'kakao') {
      // 카카오페이 처리
      console.log(`카카오페이 ${amount}원 결제`);
    } else if (type === 'naver') {
      // 네이버페이 추가 시 이 클래스를 수정해야 함 ← OCP 위반
      console.log(`네이버페이 ${amount}원 결제`);
    }
    // 새 결제 수단 추가 = 기존 코드 수정 = 기존 테스트 다시 실행 필요
  }
}

// ✅ OCP 준수: 추상화로 확장 가능하게 설계
interface PaymentMethod {
  pay(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
  pay(amount: number): void {
    console.log(`신용카드 ${amount}원 결제`);
  }
}

class KakaoPayPayment implements PaymentMethod {
  pay(amount: number): void {
    console.log(`카카오페이 ${amount}원 결제`);
  }
}

// 새 결제 수단 추가 = 새 클래스 추가만! 기존 코드 수정 없음
class NaverPayPayment implements PaymentMethod {
  pay(amount: number): void {
    console.log(`네이버페이 ${amount}원 결제`);
  }
}

class PaymentProcessor {
  // 기존 코드 수정 없이 새 결제 수단 지원
  processPayment(method: PaymentMethod, amount: number): void {
    method.pay(amount);
  }
}

// 사용
const processor = new PaymentProcessor();
processor.processPayment(new CreditCardPayment(), 10000);
processor.processPayment(new NaverPayPayment(), 20000);  // 기존 코드 변경 없이 추가

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

정의

부모 클래스 자리에 자식 클래스를 넣어도 동작에 문제가 없어야 합니다. 자식 클래스는 부모 클래스의 계약(contract)을 지켜야 합니다.

예시

// ❌ LSP 위반: 정사각형은 직사각형의 서브타입이 될 수 없음
class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(width: number): void { this.width = width; }
  setHeight(height: number): void { this.height = height; }
  getArea(): number { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(side: number): void {
    this.width = side;
    this.height = side;  // 정사각형이므로 높이도 함께 변경
  }
  setHeight(side: number): void {
    this.width = side;
    this.height = side;
  }
}

function testRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(3);
  // 직사각형이면 면적은 15여야 함
  console.assert(rect.getArea() === 15, '면적이 15이어야 함');
}

testRectangle(new Rectangle(1, 1));  // ✅ 통과 (5 * 3 = 15)
testRectangle(new Square(1));        // ❌ 실패! (3 * 3 = 9, setHeight가 width도 변경)
// → Square는 Rectangle의 LSP를 위반

// ✅ LSP 준수: 공통 인터페이스로 분리
interface Shape {
  getArea(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  getArea(): number { return this.width * this.height; }
}

class Square implements Shape {
  constructor(private side: number) {}
  getArea(): number { return this.side * this.side; }
}

// ✅ 현실적인 LSP 활용 예시
class Bird {
  name: string;
  constructor(name: string) { this.name = name; }
  move(): string { return `${this.name}이 이동합니다`; }
}

class Eagle extends Bird {
  move(): string { return `${this.name}이 날아다닙니다`; }  // 이동 방식 재정의
}

class Penguin extends Bird {
  move(): string { return `${this.name}이 걸어다닙니다`; }  // OK
  // fly()를 강제로 구현하지 않아도 됨
}

function makeMove(bird: Bird): void {
  console.log(bird.move());  // Eagle이든 Penguin이든 올바르게 동작
}

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

정의

클라이언트가 사용하지 않는 메서드에 의존하도록 강요해서는 안 됩니다. 큰 인터페이스보다 여러 개의 작은 인터페이스가 낫습니다.

예시

// ❌ ISP 위반: 모든 기능이 하나의 거대한 인터페이스에
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  code(): void;
  design(): void;
  test(): void;
}

class HumanDeveloper implements Worker {
  work(): void { console.log('일한다'); }
  eat(): void { console.log('먹는다'); }
  sleep(): void { console.log('잔다'); }
  code(): void { console.log('코딩한다'); }
  design(): void { console.log('디자인한다'); }
  test(): void { console.log('테스트한다'); }
}

class Robot implements Worker {
  work(): void { console.log('일한다'); }
  eat(): void { throw new Error('로봇은 먹지 않음'); }   // ← 사용하지 않는 메서드
  sleep(): void { throw new Error('로봇은 자지 않음'); } // ← ISP 위반
  code(): void { console.log('코딩한다'); }
  design(): void { throw new Error('로봇은 디자인 못함'); }
  test(): void { console.log('테스트한다'); }
}

// ✅ ISP 준수: 인터페이스 분리
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface Codeable {
  code(): void;
}

interface Testable {
  test(): void;
}

// 인간 개발자: 필요한 인터페이스만 구현
class HumanDeveloper implements Workable, Eatable, Sleepable, Codeable, Testable {
  work(): void { console.log('일한다'); }
  eat(): void { console.log('먹는다'); }
  sleep(): void { console.log('잔다'); }
  code(): void { console.log('코딩한다'); }
  test(): void { console.log('테스트한다'); }
}

// 로봇: 해당하는 인터페이스만 구현 (Eatable, Sleepable 불필요)
class Robot implements Workable, Codeable, Testable {
  work(): void { console.log('일한다'); }
  code(): void { console.log('코딩한다'); }
  test(): void { console.log('테스트한다'); }
}

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

정의

  1. 고수준 모듈이 저수준 모듈에 직접 의존하면 안 됩니다.
  2. 둘 다 추상화(인터페이스/추상 클래스)에 의존해야 합니다.

구체적인 구현이 아닌 추상화에 의존함으로써, 구현 세부사항이 변경되어도 고수준 로직이 영향받지 않습니다.

예시

// ❌ DIP 위반: 고수준 모듈이 저수준 모듈에 직접 의존
class MySQLDatabase {
  save(data: string): void {
    console.log(`MySQL에 저장: ${data}`);
  }
  find(id: number): string {
    return `MySQL에서 조회: ${id}`;
  }
}

class UserService {
  // 구체적인 구현(MySQL)에 직접 의존 ← DIP 위반
  private db = new MySQLDatabase();

  createUser(name: string): void {
    this.db.save(name);  // MySQL을 MongoDB로 바꾸면 UserService 코드도 변경!
  }
}

// ✅ DIP 준수: 추상화에 의존
// 추상화 정의
interface Database {
  save(data: string): void;
  find(id: number): string;
}

// 저수준 모듈들 (추상화를 구현)
class MySQLDatabase implements Database {
  save(data: string): void { console.log(`MySQL에 저장: ${data}`); }
  find(id: number): string { return `MySQL 조회: ${id}`; }
}

class MongoDatabase implements Database {
  save(data: string): void { console.log(`MongoDB에 저장: ${data}`); }
  find(id: number): string { return `MongoDB 조회: ${id}`; }
}

// 테스트용 Mock
class InMemoryDatabase implements Database {
  private store: Map<number, string> = new Map();
  save(data: string): void { this.store.set(this.store.size, data); }
  find(id: number): string { return this.store.get(id) ?? ''; }
}

// 고수준 모듈 (추상화에 의존)
class UserService {
  constructor(private db: Database) {}  // 의존성 주입(DI)

  createUser(name: string): void {
    this.db.save(name);  // 어떤 DB든 동일하게 동작
  }
}

// 사용: 의존성 외부에서 주입
const mysqlService = new UserService(new MySQLDatabase());
const mongoService = new UserService(new MongoDatabase());

// 테스트: 실제 DB 대신 Mock 사용
const testService = new UserService(new InMemoryDatabase());
testService.createUser('Alice');  // 실제 DB 연결 없이 테스트 가능

DI 컨테이너 패턴 (NestJS 예시)

// NestJS에서의 의존성 역전 + 주입
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,  // TypeORM Repository 추상화에 의존
    private emailService: EmailService,
  ) {}

  async createUser(dto: CreateUserDto): Promise<User> {
    const user = this.userRepository.create(dto);
    await this.userRepository.save(user);
    await this.emailService.sendWelcome(user);
    return user;
  }
}
// NestJS IoC 컨테이너가 의존성을 자동으로 주입

SOLID 원칙 요약

원칙약자핵심이점
단일 책임 원칙SRP클래스는 변경 이유가 하나응집도 향상, 유지보수 쉬움
개방-폐쇄 원칙OCP확장에 열려있고 수정에 닫혀있음기존 코드 불변, 확장 용이
리스코프 치환 원칙LSP자식 클래스가 부모를 대체 가능상속의 올바른 사용
인터페이스 분리 원칙ISP작고 특화된 인터페이스 사용불필요한 의존성 제거
의존성 역전 원칙DIP추상화에 의존유연성, 테스트 용이성 향상

핵심: SOLID 원칙은 당장 코드를 복잡하게 만드는 것처럼 보일 수 있지만, 소프트웨어가 성장하면서 변경과 확장이 발생할 때 그 가치가 드러납니다. 특히 테스트 코드 작성이 쉬워지는 것이 주요 장점입니다.