설계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) 활용 |