오늘은 SOLID의 두 번째 원칙인 개방-폐쇄 원칙(OCP)을 자세히 알아볼게요!
1. 개방-폐쇄 원칙(OCP)이란? 💡
핵심: "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려있고, 수정에는 닫혀있어야 한다"
Bad Case: OCP 위반 사례
// ❌ 이렇게 하면 안돼요!
public class PaymentProcessor {
public void processPayment(String type, Money amount) {
if ("CREDIT_CARD".equals(type)) {
processCreditCardPayment(amount);
} else if ("BANK_TRANSFER".equals(type)) {
processBankTransferPayment(amount);
} else if ("KAKAO_PAY".equals(type)) { // 새로운 결제 수단 추가시 코드 수정 필요
processKakaoPayPayment(amount);
}
}
}
이게 왜 문제일까요? 🤔
- 새로운 결제 수단이 추가될 때마다 기존 코드를 수정해야 함
- 각 결제 수단의 로직이 한 클래스에 모두 포함됨
- if-else 구문이 길어지면서 코드가 복잡해짐
Good Case: OCP 적용 사례
// ✅ 이렇게 인터페이스로 추상화하면 좋아요!
public interface PaymentStrategy {
void processPayment(Money amount);
}
@Component
public class CreditCardPayment implements PaymentStrategy {
@Override
public void processPayment(Money amount) {
// 신용카드 결제 처리 로직
}
}
@Component
public class BankTransferPayment implements PaymentStrategy {
@Override
public void processPayment(Money amount) {
// 계좌이체 결제 처리 로직
}
}
@Component
public class KakaoPayPayment implements PaymentStrategy {
@Override
public void processPayment(Money amount) {
// 카카오페이 결제 처리 로직
}
}
// 결제 처리기는 인터페이스만 알면 됨
@Service
public class PaymentProcessor {
private final Map<String, PaymentStrategy> paymentStrategies;
// 스프링이 자동으로 모든 PaymentStrategy 구현체를 주입
public PaymentProcessor(List<PaymentStrategy> strategies) {
paymentStrategies = strategies.stream()
.collect(Collectors.toMap(
strategy -> strategy.getClass().getSimpleName(),
strategy -> strategy
));
}
public void processPayment(String type, Money amount) {
PaymentStrategy strategy = paymentStrategies.get(type);
if (strategy == null) {
throw new UnsupportedPaymentTypeException(type);
}
strategy.processPayment(amount);
}
}
2. 실전 적용 예시: 할인 정책 시스템 🛍
2-1. 기본 구조 설계
// 할인 정책 인터페이스
public interface DiscountPolicy {
Money calculateDiscount(Order order);
}
// 정률 할인
@Component
public class PercentageDiscountPolicy implements DiscountPolicy {
private final double discountPercent;
public PercentageDiscountPolicy(@Value("${discount.percent}") double percent) {
this.discountPercent = percent;
}
@Override
public Money calculateDiscount(Order order) {
return order.getTotalAmount()
.multiply(discountPercent);
}
}
// 정액 할인
@Component
public class FixedAmountDiscountPolicy implements DiscountPolicy {
private final Money discountAmount;
public FixedAmountDiscountPolicy(@Value("${discount.amount}") long amount) {
this.discountAmount = Money.wons(amount);
}
@Override
public Money calculateDiscount(Order order) {
return discountAmount;
}
}
2-2. 복합 할인 정책 구현
// 여러 할인 정책을 조합할 수 있는 구현체
@Component
public class CompositeDiscountPolicy implements DiscountPolicy {
private final List<DiscountPolicy> policies;
public CompositeDiscountPolicy(List<DiscountPolicy> policies) {
this.policies = policies;
}
@Override
public Money calculateDiscount(Order order) {
return policies.stream()
.map(policy -> policy.calculateDiscount(order))
.reduce(Money.ZERO, Money::plus);
}
}
// 시즌 할인 정책 추가 (기존 코드 수정 없이 확장)
@Component
public class SeasonalDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscount(Order order) {
if (isSeasonalDiscountPeriod()) {
return order.getTotalAmount().multiply(0.2); // 시즌 할인 20%
}
return Money.ZERO;
}
private boolean isSeasonalDiscountPeriod() {
LocalDate now = LocalDate.now();
return now.getMonth() == Month.DECEMBER; // 12월에만 적용
}
}
2-3. 실제 사용 예시
@Service
public class OrderService {
private final DiscountPolicy discountPolicy;
public OrderService(@Qualifier("compositeDiscountPolicy") DiscountPolicy policy) {
this.discountPolicy = policy;
}
@Transactional
public Order createOrder(OrderRequest request) {
Order order = new Order(request);
// 할인 금액 계산
Money discount = discountPolicy.calculateDiscount(order);
order.applyDiscount(discount);
return order;
}
}
// 테스트 코드
@Test
void 복합할인정책_적용() {
// Given
List<DiscountPolicy> policies = Arrays.asList(
new PercentageDiscountPolicy(0.1), // 10% 할인
new FixedAmountDiscountPolicy(1000) // 1000원 추가 할인
);
DiscountPolicy policy = new CompositeDiscountPolicy(policies);
Order order = new Order(Money.wons(10000));
// When
Money discount = policy.calculateDiscount(order);
// Then
assertThat(discount).isEqualTo(Money.wons(2000)); // 1000원 + (10000 * 0.1)
}
3. OCP의 실제 활용 사례 📱
3-1. 알림 시스템
// 알림 발송 인터페이스
public interface NotificationSender {
void send(String message, String recipient);
}
// 이메일 알림
@Component
public class EmailNotificationSender implements NotificationSender {
private final JavaMailSender mailSender;
@Override
public void send(String message, String recipient) {
SimpleMailMessage mail = new SimpleMailMessage();
mail.setTo(recipient);
mail.setText(message);
mailSender.send(mail);
}
}
// SMS 알림
@Component
public class SmsNotificationSender implements NotificationSender {
private final SmsService smsService;
@Override
public void send(String message, String recipient) {
smsService.sendSms(recipient, message);
}
}
// 카카오톡 알림 (새로운 기능 추가)
@Component
public class KakaoNotificationSender implements NotificationSender {
private final KakaoClient kakaoClient;
@Override
public void send(String message, String recipient) {
kakaoClient.sendMessage(recipient, message);
}
}
3-2. 파일 저장 시스템
public interface FileStorage {
String save(MultipartFile file);
Resource load(String filename);
}
@Component
@Profile("local")
public class LocalFileStorage implements FileStorage {
private final Path rootLocation;
@Override
public String save(MultipartFile file) {
String filename = generateFilename(file);
try {
Files.copy(file.getInputStream(),
rootLocation.resolve(filename));
return filename;
} catch (IOException e) {
throw new StorageException("Failed to store file", e);
}
}
}
@Component
@Profile("cloud")
public class S3FileStorage implements FileStorage {
private final AmazonS3 s3Client;
private final String bucketName;
@Override
public String save(MultipartFile file) {
String filename = generateFilename(file);
try {
PutObjectRequest request = new PutObjectRequest(
bucketName,
filename,
file.getInputStream(),
new ObjectMetadata()
);
s3Client.putObject(request);
return filename;
} catch (IOException e) {
throw new StorageException("Failed to store file to S3", e);
}
}
}
4. OCP 적용 시 주의사항 ⚠️
4-1. 과도한 추상화 피하기
// ❌ 너무 복잡한 추상화는 피하세요
public interface PaymentStrategy {
void preProcess();
boolean validate();
void process();
void postProcess();
void handleError();
void rollback();
}
// ✅ 적절한 수준의 추상화를 유지하세요
public interface PaymentStrategy {
PaymentResult processPayment(PaymentRequest request);
}
4-2. 구현체 선택 로직 개선
// ❌ enum을 사용한 타입 코드는 새로운 타입 추가시 수정이 필요해요
public enum PaymentType {
CREDIT_CARD,
BANK_TRANSFER,
KAKAO_PAY
}
// ✅ 전략을 동적으로 등록하고 조회할 수 있게 만드세요
@Component
public class PaymentStrategyRegistry {
private final Map<String, PaymentStrategy> strategies = new HashMap<>();
public void registerStrategy(String type, PaymentStrategy strategy) {
strategies.put(type, strategy);
}
public PaymentStrategy getStrategy(String type) {
return Optional.ofNullable(strategies.get(type))
.orElseThrow(() -> new UnsupportedPaymentTypeException(type));
}
}
5. OCP의 장점 정리 🎁
- 유연성 향상
- 새로운 기능 추가가 쉬워짐
- 기존 코드 수정 없이 확장 가능
- 재사용성 향상
- 추상화된 인터페이스를 통해 다양한 구현체 활용 가능
- 테스트 용이성
- 각 구현체를 독립적으로 테스트 가능
- 모의 객체(Mock) 사용이 쉬워짐
- 유지보수성 향상
- 기존 코드를 변경하지 않으므로 버그 발생 위험 감소
실무 적용 전략 💪
- 인터페이스 설계 원칙
- 단일 책임 원칙과 함께 고려
- 확장 가능성이 높은 부분을 식별
- 적절한 추상화 수준 유지
- 구현체 관리 전략
@Configuration public class PaymentConfig { @Bean public Map<String, PaymentStrategy> paymentStrategies( List<PaymentStrategy> strategies ) { return strategies.stream() .collect(Collectors.toMap( strategy -> strategy.getClass().getSimpleName(), strategy -> strategy )); } }
- 코드 리뷰 체크리스트
- 새로운 기능 추가 시 기존 코드 수정이 필요한가?
- 인터페이스가 적절히 추상화되어 있는가?
- 구현체 추가가 용이한가?
- 테스트 코드 작성이 쉬운가?
다음 시간에는 리스코프 치환 원칙(LSP)에 대해 자세히 알아볼게요!
질문이나 궁금한 점이 있다면 댓글로 남겨주세요! 😊
728x90