카테고리 없음

객체지향 심화 학습 2편: 개방-폐쇄 원칙 (OCP) 완전정복 🎯

블로글러 2024. 11. 3. 21:05

오늘은 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);
        }
    }
}

이게 왜 문제일까요? 🤔

  1. 새로운 결제 수단이 추가될 때마다 기존 코드를 수정해야 함
  2. 각 결제 수단의 로직이 한 클래스에 모두 포함됨
  3. 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의 장점 정리 🎁

  1. 유연성 향상
    • 새로운 기능 추가가 쉬워짐
    • 기존 코드 수정 없이 확장 가능
  2. 재사용성 향상
    • 추상화된 인터페이스를 통해 다양한 구현체 활용 가능
  3. 테스트 용이성
    • 각 구현체를 독립적으로 테스트 가능
    • 모의 객체(Mock) 사용이 쉬워짐
  4. 유지보수성 향상
    • 기존 코드를 변경하지 않으므로 버그 발생 위험 감소

실무 적용 전략 💪

  1. 인터페이스 설계 원칙
    • 단일 책임 원칙과 함께 고려
    • 확장 가능성이 높은 부분을 식별
    • 적절한 추상화 수준 유지
  2. 구현체 관리 전략
  3. @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
            ));
    }
    }
  4. 코드 리뷰 체크리스트
    • 새로운 기능 추가 시 기존 코드 수정이 필요한가?
    • 인터페이스가 적절히 추상화되어 있는가?
    • 구현체 추가가 용이한가?
    • 테스트 코드 작성이 쉬운가?

다음 시간에는 리스코프 치환 원칙(LSP)에 대해 자세히 알아볼게요!
질문이나 궁금한 점이 있다면 댓글로 남겨주세요! 😊

728x90