SOLID 원칙을 각각 설명해주세요.
힌트
단일 책임, 개방-폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전
정답 및 해설
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)
정의
- 고수준 모듈이 저수준 모듈에 직접 의존하면 안 됩니다.
- 둘 다 추상화(인터페이스/추상 클래스)에 의존해야 합니다.
구체적인 구현이 아닌 추상화에 의존함으로써, 구현 세부사항이 변경되어도 고수준 로직이 영향받지 않습니다.
예시
// ❌ 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 원칙은 당장 코드를 복잡하게 만드는 것처럼 보일 수 있지만, 소프트웨어가 성장하면서 변경과 확장이 발생할 때 그 가치가 드러납니다. 특히 테스트 코드 작성이 쉬워지는 것이 주요 장점입니다.