전체 목록
설계Hard#48

SOLID 원칙이란 무엇이며 각각을 간단한 예시와 함께 설명해주세요.

#설계#SOLID#OOP#아키텍처
힌트

단일 책임, 개방-폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전의 첫 글자입니다.

정답 및 해설

SOLID 원칙이란 무엇이며 각각을 간단한 예시와 함께 설명해주세요.

SOLID 원칙은 로버트 C. 마틴(Uncle Bob)이 정리한 객체 지향 프로그래밍의 다섯 가지 설계 원칙입니다. 이 원칙들을 따르면 코드가 변경에 유연하고, 이해하기 쉬우며, 재사용성과 유지보수성이 높아집니다. 각 글자는 Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, Dependency Inversion의 앞글자를 따온 것입니다.

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

원칙

"클래스는 변경의 이유가 하나여야 한다." 즉, 클래스는 하나의 책임(역할)만 가져야 합니다.

위반 예시

// ❌ SRP 위반 - UserService가 너무 많은 책임을 가짐
class UserService {
  // 책임 1: 사용자 데이터 처리
  createUser(name: string, email: string) {
    const user = { id: Date.now(), name, email }
    // 책임 2: 데이터베이스 저장 (DB 접근)
    const db = new Database()
    db.save('users', user)
    // 책임 3: 이메일 발송
    const mailer = new EmailClient()
    mailer.send(email, '환영합니다!')
    // 책임 4: 로깅
    console.log(`User created: ${email}`)
    return user
  }
}
// DB 구조 변경, 이메일 라이브러리 교체, 로깅 방식 변경 등
// 모두 이 클래스를 수정해야 함 → 변경의 이유가 4가지

개선 예시

// ✅ SRP 준수 - 각 클래스가 하나의 책임만
class UserRepository {
  save(user: User): void {
    const db = new Database()
    db.save('users', user)
  }

  findById(id: number): User | null {
    const db = new Database()
    return db.find('users', id)
  }
}

class UserEmailService {
  sendWelcomeEmail(email: string): void {
    const mailer = new EmailClient()
    mailer.send(email, '환영합니다!')
  }
}

class UserLogger {
  logCreation(email: string): void {
    console.log(`[${new Date().toISOString()}] User created: ${email}`)
  }
}

class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: UserEmailService,
    private logger: UserLogger
  ) {}

  createUser(name: string, email: string): User {
    const user = { id: Date.now(), name, email }
    this.userRepo.save(user)       // 저장은 Repository에게
    this.emailService.sendWelcomeEmail(email)  // 이메일은 EmailService에게
    this.logger.logCreation(email) // 로깅은 Logger에게
    return user
  }
}

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

원칙

"소프트웨어 엔티티는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다." 새로운 기능 추가 시 기존 코드를 수정하지 않고 새 코드를 추가해야 합니다.

위반 예시

// ❌ OCP 위반 - 새 할인 방식 추가 시마다 기존 코드 수정 필요
class DiscountCalculator {
  calculate(price: number, discountType: string): number {
    if (discountType === 'percentage') {
      return price * 0.9 // 10% 할인
    } else if (discountType === 'fixed') {
      return price - 1000 // 1000원 할인
    } else if (discountType === 'vip') {
      return price * 0.7 // VIP 30% 할인
    }
    // 새 할인 유형 추가 시마다 이 클래스를 수정해야 함!
    return price
  }
}

개선 예시

// ✅ OCP 준수 - 추상화를 통한 확장
interface DiscountPolicy {
  calculate(price: number): number
}

class PercentageDiscount implements DiscountPolicy {
  constructor(private percent: number) {}
  calculate(price: number): number {
    return price * (1 - this.percent / 100)
  }
}

class FixedDiscount implements DiscountPolicy {
  constructor(private amount: number) {}
  calculate(price: number): number {
    return price - this.amount
  }
}

class VIPDiscount implements DiscountPolicy {
  calculate(price: number): number {
    return price * 0.7
  }
}

// 새 할인 정책 추가 시 기존 코드 수정 없이 새 클래스만 추가
class SeasonalDiscount implements DiscountPolicy {
  calculate(price: number): number {
    return price * 0.8 // 시즌 할인 20%
  }
}

class OrderService {
  // 기존 코드 수정 없이 새 할인 정책 사용 가능
  calculateFinalPrice(price: number, discount: DiscountPolicy): number {
    return discount.calculate(price)
  }
}

const orderService = new OrderService()
console.log(orderService.calculateFinalPrice(10000, new PercentageDiscount(10))) // 9000
console.log(orderService.calculateFinalPrice(10000, new SeasonalDiscount()))     // 8000

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

원칙

"자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다." 부모 클래스 타입의 변수에 자식 클래스 인스턴스를 할당해도 프로그램이 올바르게 동작해야 합니다.

위반 예시 (고전적인 직사각형-정사각형 문제)

// ❌ 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(width: number): void {
    this.width = width
    this.height = width  // 부모 클래스의 동작과 다름!
  }
  setHeight(height: number): void {
    this.height = height
    this.width = height  // 부모 클래스의 동작과 다름!
  }
}

// Rectangle을 기대하는 함수
function resizeAndCalculate(rect: Rectangle): number {
  rect.setWidth(5)
  rect.setHeight(10)
  // Rectangle이면 50, Square이면 100 → LSP 위반!
  return rect.getArea()
}

const rect = new Rectangle(2, 3)
console.log(resizeAndCalculate(rect)) // 50 (예상대로)

const square = new Square(2, 2)
console.log(resizeAndCalculate(square)) // 100 (예상과 다름!)

개선 예시

// ✅ LSP 준수 - 상속 대신 공통 인터페이스 사용
interface Shape {
  getArea(): number
}

class Rectangle implements Shape {
  constructor(private width: number, private 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 implements Shape {
  constructor(private side: number) {}

  setSide(side: number): void { this.side = side }

  getArea(): number { return this.side * this.side }
}

// Shape 인터페이스에 의존 - 어떤 Shape든 올바르게 동작
function printArea(shape: Shape): void {
  console.log('넓이:', shape.getArea())
}

printArea(new Rectangle(5, 10)) // 50
printArea(new Square(5))         // 25

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

원칙

"클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다." 하나의 큰 인터페이스보다 여러 개의 작은 인터페이스가 낫습니다.

위반 예시

// ❌ ISP 위반 - 너무 큰 인터페이스
interface Worker {
  work(): void
  eat(): void
  sleep(): void
  code(): void
  design(): void
  test(): void
}

// Robot은 eat, sleep이 필요 없는데 구현 강제됨
class Robot implements Worker {
  work(): void { console.log('일하는 중') }
  eat(): void { throw new Error('로봇은 먹지 않음') }     // 불필요한 구현
  sleep(): void { throw new Error('로봇은 자지 않음') }   // 불필요한 구현
  code(): void { console.log('코딩 중') }
  design(): void { console.log('디자인 중') }
  test(): void { console.log('테스트 중') }
}

개선 예시

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

interface Eatable {
  eat(): void
}

interface Sleepable {
  sleep(): void
}

interface Codeable {
  code(): void
}

// 필요한 인터페이스만 조합
class HumanDeveloper implements Workable, Eatable, Sleepable, Codeable {
  work(): void { console.log('일하는 중') }
  eat(): void { console.log('식사 중') }
  sleep(): void { console.log('수면 중') }
  code(): void { console.log('코딩 중') }
}

class Robot implements Workable, Codeable {
  work(): void { console.log('일하는 중') }
  code(): void { console.log('코딩 중') }
  // eat, sleep 불필요 → 구현 안 해도 됨
}

// React 컴포넌트의 Props 인터페이스 분리 예시
interface BaseButtonProps {
  label: string
  onClick: () => void
}

interface LoadingButtonProps extends BaseButtonProps {
  isLoading: boolean
}

interface IconButtonProps extends BaseButtonProps {
  icon: React.ReactNode
}

// 버튼 컴포넌트가 필요한 Props만 받음
const Button: React.FC<BaseButtonProps> = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
)

const LoadingButton: React.FC<LoadingButtonProps> = ({ label, onClick, isLoading }) => (
  <button onClick={onClick} disabled={isLoading}>
    {isLoading ? '로딩중...' : label}
  </button>
)

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

원칙

"고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다." 구체적인 구현보다 추상화(인터페이스)에 의존하여 결합도를 낮춥니다.

위반 예시

// ❌ DIP 위반 - 고수준 모듈이 저수준 구현에 직접 의존
class MySQLDatabase {
  save(data: object): void {
    console.log('MySQL에 저장:', data)
  }
}

class OrderService {
  private db: MySQLDatabase  // 구체적인 MySQL에 의존!

  constructor() {
    this.db = new MySQLDatabase()  // 직접 생성 (결합도 높음)
  }

  createOrder(order: object): void {
    this.db.save(order)
    // MySQL을 MongoDB로 바꾸려면 OrderService 코드 수정 필요
  }
}

개선 예시

// ✅ DIP 준수 - 추상화(인터페이스)에 의존
interface DatabaseRepository {
  save(data: object): void
  findById(id: number): object | null
  delete(id: number): void
}

// 저수준 모듈들 - 인터페이스 구현
class MySQLRepository implements DatabaseRepository {
  save(data: object): void { console.log('MySQL에 저장:', data) }
  findById(id: number): object | null { return null }
  delete(id: number): void { console.log('MySQL에서 삭제:', id) }
}

class MongoDBRepository implements DatabaseRepository {
  save(data: object): void { console.log('MongoDB에 저장:', data) }
  findById(id: number): object | null { return null }
  delete(id: number): void { console.log('MongoDB에서 삭제:', id) }
}

class InMemoryRepository implements DatabaseRepository {
  private store: Map<number, object> = new Map()
  save(data: any): void { this.store.set(data.id, data) }
  findById(id: number): object | null { return this.store.get(id) || null }
  delete(id: number): void { this.store.delete(id) }
}

// 고수준 모듈 - 추상화에만 의존
class OrderService {
  constructor(private db: DatabaseRepository) {}  // 인터페이스에 의존

  createOrder(order: object): void {
    this.db.save(order)  // 어떤 DB든 동일하게 사용
  }
}

// 의존성 주입 (Dependency Injection)
const productionService = new OrderService(new MySQLRepository())
const testService = new OrderService(new InMemoryRepository())  // 테스트 시 교체 용이
const mongoService = new OrderService(new MongoDBRepository())  // DB 변경 시 서비스 수정 불필요

DIP와 의존성 주입 (실전)

// NestJS 스타일의 의존성 주입
import { Injectable } from '@nestjs/common'

@Injectable()
class EmailService {
  send(to: string, message: string): Promise<void> {
    // 실제 이메일 전송 구현
  }
}

@Injectable()
class UserService {
  // NestJS가 자동으로 EmailService를 주입
  constructor(private emailService: EmailService) {}

  async register(email: string): Promise<void> {
    // 사용자 등록 로직
    await this.emailService.send(email, '환영합니다!')
  }
}

// React에서 DIP - Context API로 의존성 주입
const DatabaseContext = createContext<DatabaseRepository | null>(null)

function App() {
  return (
    // 환경에 따라 다른 구현체 주입
    <DatabaseContext.Provider value={new MySQLRepository()}>
      <OrderPage />
    </DatabaseContext.Provider>
  )
}

function OrderPage() {
  const db = useContext(DatabaseContext) // 인터페이스에만 의존
  // ...
}

SOLID 원칙 간의 관계

SRP → 변경 이유를 하나로 → 클래스가 작고 명확해짐
OCP → 수정 없이 확장 → 추상화 필요 → ISP, DIP와 연결
LSP → 상속을 올바르게 → 다형성 보장
ISP → 인터페이스 작게 → 불필요한 의존 없음
DIP → 추상화에 의존 → OCP 실현 가능

정리 표

원칙핵심위반 시 문제해결책
SRP하나의 책임만변경 사유가 많아 자주 수정됨역할별 클래스 분리
OCP확장 O, 수정 X기능 추가마다 기존 코드 수정인터페이스 + 다형성
LSP자식이 부모 대체 가능부모 타입 사용 시 예외 발생상속보다 인터페이스 사용
ISP필요한 인터페이스만불필요한 메서드 구현 강제인터페이스 작게 분리
DIP추상화에 의존구현 변경 시 고수준 코드도 수정의존성 주입(DI) 활용