contents
OOP의 SOLID 원칙: 상세 설명과 예제 코드
SOLID 원칙은 유지 보수 가능하고 확장하기 쉬우며, 견고한 객체지향 코드를 작성하기 위한 5가지 핵심 설계 원칙입니다. 실제 프로젝트와 면접에서 매우 중요하며, 테스트와 변경이 쉬운 구조를 만드는 데 효과적입니다.
1. S – 단일 책임 원칙 (Single Responsibility Principle, SRP)
정의:
클래스는 하나의 책임만 가져야 하며, 변경해야 할 이유도 하나여야 합니다.
이유:
- 이해하기 쉽고, 테스트와 유지 관리가 용이해집니다.
- 하나의 책임 변경이 다른 책임에 영향을 주지 않습니다.
❌ 잘못된 예:
class UserManager {
public void addUser(User user) { /* ... */ }
public void saveToDatabase(User user) { /* ... */ }
public void sendEmail(User user, String msg) { /* ... */ }
}
// 사용자 로직, 데이터베이스 처리, 이메일 전송이라는 여러 책임이 하나의 클래스에 있음
✅ 올바른 예:
class UserManager {
public void addUser(User user) { /* ... */ }
}
class UserRepository {
public void saveToDatabase(User user) { /* ... */ }
}
class EmailService {
public void sendEmail(User user, String msg) { /* ... */ }
}
// 각 클래스가 하나의 명확한 책임만 가짐
2. O – 개방/폐쇄 원칙 (Open/Closed Principle, OCP)
정의:
소프트웨어 엔티티는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.
이유:
- 검증된 기존 코드를 변경하지 않고도 새 기능을 추가할 수 있음 → 안정성 증가
❌ 잘못된 예:
class SalaryCalculator {
public double calculateSalary(Employee emp) {
if (emp.getType().equals("Manager"))
return emp.getBase() * 2;
else if (emp.getType().equals("Engineer"))
return emp.getBase() * 1.5;
// 새로운 타입이 생기면 코드 수정 필요
}
}
✅ 올바른 예 (다형성 활용):
abstract class Employee {
abstract double calculateSalary();
}
class Manager extends Employee {
double calculateSalary() { return getBase() * 2; }
}
class Engineer extends Employee {
double calculateSalary() { return getBase() * 1.5; }
}
// 새로운 역할이 추가되어도 기존 클래스 수정 없이 확장 가능
3. L – 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
정의:
자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
이유:
- 상속이 의미 있게 작동하고, 프로그램의 예상 동작을 깨지 않음
❌ 잘못된 예:
class Bird {
void fly() { /* ... */ }
}
class Ostrich extends Bird {
void fly() { throw new UnsupportedOperationException(); }
}
// 타조는 날 수 없는데 fly()를 가지고 있음 → LSP 위반
✅ 올바른 예:
abstract class Bird {}
class FlyingBird extends Bird {
void fly() { /* ... */ }
}
class Sparrow extends FlyingBird {}
class Ostrich extends Bird {} // Ostrich는 fly() 메서드 없음
// 기반 클래스 설계를 변경하여 위반 방지
4. I – 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
정의:
클라이언트는 사용하지 않는 함수에 의존하도록 강요받아서는 안 된다.
이유:
- 인터페이스가 커지면 의도와 무관한 메서드를 강제로 구현해야 해서 불필요한 의존 발생
❌ 잘못된 예:
interface MultiFunctionDevice {
void print();
void scan();
void fax();
}
class SimplePrinter implements MultiFunctionDevice {
public void print() { /* ... */ }
public void scan() { /* 미사용 → 빈 구현 */ }
public void fax() { /* 미사용 → 빈 구현 */ }
}
✅ 올바른 예:
interface Printer { void print(); }
interface Scanner { void scan(); }
interface Fax { void fax(); }
class SimplePrinter implements Printer {
public void print() { /* ... */ }
}
// 필요한 기능만 갖는 인터페이스 구현
5. D – 의존 역전 원칙 (Dependency Inversion Principle, DIP)
정의:
고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.
이유:
- 유연한 설계: 구현과 관계없이 의존성을 쉽게 주입/교체 가능
- 테스트 용이
❌ 잘못된 예:
class UserService {
private UserRepository repo = new UserRepository();
public void register(User user) {
repo.save(user);
}
}
// 구체 구현에 직접적으로 의존 → 테스트 어려움
✅ 올바른 예:
interface UserRepository {
void save(User user);
}
class DatabaseUserRepository implements UserRepository {
public void save(User user) { /* DB 저장 */ }
}
class UserService {
private final UserRepository repo;
public UserService(UserRepository repo) {
this.repo = repo;
}
public void register(User user) {
repo.save(user);
}
}
// 인터페이스를 통해 의존성 역전, DI 적용 가능
요약 비교표
| 원칙 | 초점 | 지키지 않았을 때 | 지켰을 때 |
|---|---|---|---|
| SRP | 하나의 책임 | 변경 사유가 서로 섞여 복잡해짐 | 유지보수 쉬운 작고 명확한 클래스 구성 |
| OCP | 확장 가능 + 안정성 유지 | 코드 수정 시 오류 가능, 로직 변경 필요 | 기능 추가가 수월하며, 테스트 안정성 보장 |
| LSP | 자식이 부모로 대체 가능 | Unexpected behavior 발생 | 안전한 상속 구조, 다형성 활용 가능 |
| ISP | 인터페이스는 작게 | 강제 구현 메서드 다수 → 복잡해짐 | 필요한 기능만 명확하게 구분하여 구현 가능 |
| DIP | 구현이 아닌 추상에 의존 | 테스트 어려움, 강한 결합 구조 | 유연한 설계, DI 및 Mock 주입 가능 |
실전 팁
- SOLID 원칙을 따르면 모듈화되고 테스트 가능한 구조로 앱을 설계할 수 있습니다.
- Spring Framework나 Kotlin의 Koin은 의존성 주입(DI) 기반으로 SOLID를 자연스럽게 지지합니다.
- 객체의 책임 구분, 상속 구조의 신중한 설계, 의존성 분리 등의 감각을 길러두면 실제 개발과 면접에서 크게 도움이 됩니다.
Spring 기반의 SOLID 원칙 심화 예제
아래는 각 SOLID 원칙이 실제 Spring(Spring Boot) 애플리케이션에서 어떻게 적용되는지를 깊이 있게 설명한 예제입니다. 단순한 구조를 넘어서, 유지보수성, 확장성, 테스트 용이성이 어떻게 향상되는지 보여줍니다.
1. SRP (단일 책임 원칙) — Service, Validation, Persistence 분리
❌ 나쁜 예 — 하나의 클래스가 모든 역할 처리
@Service
public class OrderService {
@Autowired private OrderRepository repo;
public void createOrder(Order order) {
// 비즈니스 로직
if (order.getPrice() < 0) throw new IllegalArgumentException("...");
// 검증 & 이메일
if (!EmailValidator.isValid(order.getEmail())) throw new ...;
EmailUtils.sendEmail(order.getEmail(), "Thanks!");
// 데이터 저장
repo.save(order);
}
}
- 이메일, 검증, 저장 등 책임이 너무 분산 → 변경이 여러 로직에 영향을 줍니다.
✅ 좋은 예 — 역할별 클래스 분리
@Service
public class OrderService {
@Autowired OrderValidator validator;
@Autowired NotificationService notification;
@Autowired private OrderRepository repo;
public void createOrder(Order order) {
validator.validate(order);
repo.save(order);
notification.sendOrderConfirmation(order);
}
}
@Component
public class OrderValidator {
public void validate(Order order) { /* 유효성 검사 */ }
}
@Service
public class NotificationService {
public void sendOrderConfirmation(Order order) { /* 이메일 전송 */ }
}
- 하나의 책임만 가진 클래스 → 향후 변경/유지보수/테스트가 훨씬 용이
2. OCP (개방-폐쇄 원칙) — 결제 시스템 확장
❌ 나쁜 설계 — if문으로 모든 타입 처리
@Service
public class PaymentService {
public void pay(Order order) {
if (order.getType().equals("CARD")) processCard(order);
else if (order.getType().equals("PAYPAL")) processPayPal(order);
}
}
- 새로운 결제 수단이 나오면 기존 코드 수정이 필요 → 버그 위험 증가
✅ 좋은 설계 — 전략 인터페이스 + @Component 조합
public interface PaymentStrategy {
boolean supports(Order order);
void pay(Order order);
}
@Component
public class CardPayment implements PaymentStrategy { /* 카드 결제 */ }
@Component
public class PaypalPayment implements PaymentStrategy { /* 페이팔 결제 */ }
@Service
public class PaymentService {
@Autowired List<PaymentStrategy> strategies;
public void pay(Order order) {
strategies.stream()
.filter(s -> s.supports(order))
.findFirst()
.orElseThrow()
.pay(order);
}
}
- 새로운 결제 수단이 생겨도, 새로운 빈만 만들면 됨 → 기존 코드 수정 없음
3. DIP (의존 역전 원칙) — 구체 구현에 직접 의존하지 않기
❌ 나쁜 예
@Service
public class UserService {
private final JdbcUserRepository repo = new JdbcUserRepository();
}
- JDBC 구현에 강하게 결합 → 테스트, 구조 변경 어렵고 유연성 낮음
✅ 좋은 예 — 인터페이스 기반 DI
public interface UserRepository {
void save(User user);
}
@Repository
public class JdbcUserRepository implements UserRepository { ... }
@Repository
public class MongoUserRepository implements UserRepository { ... }
@Service
public class UserService {
private final UserRepository repo;
@Autowired
public UserService(UserRepository repo) {
this.repo = repo;
}
}
- 추상(인터페이스)에 의존 → 구현체는 주입을 통해 동적으로 대체 가능
4. ISP (인터페이스 분리 원칙) — 필요한 기능만 제공하는 인터페이스
❌ 안 좋은 예 — 모든 기능을 가진 인터페이스
public interface MultiFunctionService {
void create();
void update();
void delete();
void archive(); // 어떤 클래스는 사용 안 함
}
@Service
public class CommentService implements MultiFunctionService {
public void create() {...}
public void update() {...}
public void delete() {...}
public void archive() {
throw new UnsupportedOperationException();
}
}
✅ 좋은 예 — 역할별 인터페이스 분리
public interface Creatable { void create(); }
public interface Updatable { void update(); }
public interface Deletable { void delete(); }
@Service
public class CommentService implements Creatable, Updatable, Deletable {
// 필요한 기능만 구현
}
- 서비스나 컨트롤러는 자신이 필요로 하는 인터페이스만 구현 → 유지보수성 증가
5. LSP (리스코프 치환 원칙) — 인터페이스의 구현체는 대체 가능해야 함
예제: Notification 인터페이스의 대체 가능성
public interface NotificationService {
void notify(String msg);
}
@Service
public class EmailNotification implements NotificationService { ... }
@Service
public class SmsNotification implements NotificationService { ... }
- 어떤 NotificationService를 주입하든 notify() 메서드는 항상 기대대로 동작해야 함
- 만약 특정 구현체가 특정 조건에서
notify()를 던지거나 동작하지 않는다면 LSP를 위반하게 됩니다
Spring에서 SOLID를 실현하는 베스트 프랙티스
- ✅ 생성자 기반 DI: DIP 원칙 강화 → 의존성 주입 가능
- ✅ @Component 기반 확장성: 새로운 기능은 추가만 하면 동작 (OCP)
- ✅ @Profile, @Qualifier 사용: 다양한 구현체에 따라 스위칭 가능
- ✅ 분리된 작은 Bean으로 책임 분산: SRP/ISP 구현에 자연스럽게 적합
- ✅ 다형성과 EventListener, 전략패턴 등은 LSP 실현에 기여
references