SOLID는 객체지향 설계에서 권장되는 다섯 가지 핵심 원칙을 말합니다. 코드의 유지보수성, 확장성, 재사용성을 높이는 데 큰 도움을 줍니다! 스프링에서는 DI(Dependency Injection)와 IoC(Inversion of Control)를 비롯해 다양한 기술을 통해 SOLID 원칙을 자연스럽게 구현할 수 있습니다. 각 원칙을 스프링 관점에서 알아보겠습니다.
예: `@Service`는 비즈니스 로직, `@Repository`는 데이터베이스 접근 등으로 명확히 분리.
잘못된 예시 (SRP 위반)
@Service
public class UserService {
public void createUser(User user) {
// 사용자 생성 로직
}
public User getUser(String userId) {
// 사용자 조회 로직
}
public void sendEmail(String email, String message) {
// 이메일 발송 로직
}
}
`UserService`가 사용자 생성, 조회, 이메일 발송까지 모두 담당 → SRP 위반
올바른 예시
@Service
public class UserService {
public void createUser(User user) {
// 사용자 생성 로직
}
public User getUser(String userId) {
// 사용자 조회 로직
}
}
@Service
public class EmailService {
public void sendEmail(String email, String message) {
// 이메일 발송 로직
}
}
사용자 로직(UserService)과 이메일 로직(EmailService)을 분리해 책임이 명확해졌습니다.
2. 개방-폐쇄 원칙 (OCP)
확장에는 열려 있고, 변경에는 닫혀 있어야 한다.
새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 합니다.
상속, 인터페이스 구현, 의존성 주입(DI) 등을 통해 유연한 구조를 만들 수 있습니다.
스프링에서의 적용
`DI(Dependency Injection)`와 `IoC(Inversion of Control)`를 통해 새로운 구현체를 추가하더라도 기존 코드를 수정하지 않고 빈 설정이나 인터페이스만으로 확장 가능합니다.
AOP 활용: 공통 관심사를 모듈화하여 기존 코드 변경 없이 기능을 추가할 수 있습니다.
잘못된 예시 (OCP 위반)
@Service
public class PaymentService {
public void processPayment(String type) {
if (type.equals("creditCard")) {
// 신용카드 결제 로직
} else if (type.equals("paypal")) {
// 페이팔 결제 로직
}
}
}
새로운 결제 방식을 추가할 때마다 `PaymentService` 코드를 직접 수정해야 합니다.
올바른 예시
public interface PaymentProcessor {
void processPayment();
}
@Service
public class CreditCardPaymentProcessor implements PaymentProcessor {
@Override
public void processPayment() {
// 신용카드 결제 로직
}
}
@Service
public class PaypalPaymentProcessor implements PaymentProcessor {
@Override
public void processPayment() {
// 페이팔 결제 로직
}
}
@Service
public class PaymentService {
private final List<PaymentProcessor> paymentProcessors;
@Autowired
public PaymentService(List<PaymentProcessor> paymentProcessors) {
this.paymentProcessors = paymentProcessors;
}
public void processPayments() {
for (PaymentProcessor processor : paymentProcessors) {
processor.processPayment();
}
}
}
새로운 결제 방식을 추가해도 `PaymentProcessor `구현체만 만들면 됩니다.
즉 `PaymentService`는 변경 없이 확장 가능합니다.
3. 리스코프 치환 원칙 (LSP)
하위 타입은 상위 타입으로 대체 가능해야 한다.
상위 타입(추상 클래스, 인터페이스)이 하는 모든 일을 하위 타입도 문제없이 수행해야 합니다.
LSP를 위반하면 상속받은 클래스가 상위 타입의 계약을 지키지 못해 예외를 던지거나 오동작을 일으킬 수 있습니다.
스프링에서의 적용
인터페이스 기반 설계: 스프링에서는 DI를 통해 상위 타입(인터페이스)만을 주입받도록 설계합니다.
계약 준수: 하위 클래스는 상위 클래스 또는 인터페이스의 계약(메서드 시그니처, 기능)을 완벽히 준수해야 하며, 클라이언트는 하위 구현체를 사용해도 동일하게 동작해야 합니다.
LSP위반 예시
public class Bird {
public void fly() {
// 새가 날 수 있는 로직
}
}
public class Chicken extends Bird {
@Override
public void fly() {
System.out.println("닭이 날아갑니다");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다.");
}
}
`Penguin`은 `Bird`의 서브클래스지만, 상위 클래스의 계약(날 수 있음)을 준수하지 않아 예외를 발생시킵니다.
즉 `Bird` 타입을 기대하는 클라이언트에서는 `Penguin`을 대체할 경우 문제가 발생합니다.
4. 인터페이스 분리 원칙 (ISP)
클라이언트는 사용하지 않는 메서드에 의존하지 않아야 한다.
작은 단위로 역할이 명확한 인터페이스를 여러 개 두는 것이 좋습니다.
하나의 인터페이스에 너무 많은 기능이 담겨 있으면, 불필요한 의존성으로 인해 결합도가 높아지고, 특정 기능의 변경이 여러 클래스에 영향을 미칠 수 있습니다.
스프링에서의 적용
작고 구체적인 인터페이스 구성: 하나의 거대한 인터페이스 대신, 역할에 맞는 여러 개의 작은 인터페이스를 정의합니다.
필요한 기능만 주입: 스프링에서는 필요한 기능만을 제공하는 인터페이스를 빈으로 등록해, 클라이언트가 불필요한 메서드에 의존하지 않도록 합니다
`Printer` 인터페이스에 모든 기능을 넣으면, 스캔이 필요 없는 클래스도 scan()을 구현해야 하는 문제가 생깁니다.
올바른 예시
public interface Printer {
void print();
}
public interface Scanner {
void scan();
}
public interface Fax {
void fax();
}
public class NoScannerPrinter implements Printer, Fax {
// 필요한 기능만 각각 구현
}
인터페이스를 기능별로 분리하여 필요 없는 기능에 의존하지 않도록 설계했습니다.
`Scanner`기능이 없는 `NoScannerPrinter`은 `Printer`와 `Fax`만 의존하면 됩니다.