인터페이스와 결합도의 실전적 고찰

우리는 인터페이스 사용과 결합도에 대한 기본적인 고찰을 진행했습니다.

이번에는 실제 현장에서 마주할 수 있는 더 복잡한 상황들을 살펴보고, 인터페이스의 실질적인 가치와 활용 방안을 탐구해보고자 합니다.

1. 외부 시스템 연동 시의 인터페이스 활용

실제 프로젝트에서 가장 빈번하게 마주치는 상황 중 하나는 외부 시스템과의 연동입니다.

예를 들어, 결제 시스템을 연동하는 상황을 생각해보겠습니다.

// 결제 처리를 위한 인터페이스
public interface PaymentProcessor {
    PaymentResult processPayment(PaymentRequest request);
    PaymentStatus checkPaymentStatus(String paymentId);
    RefundResult processRefund(RefundRequest request);
}

// 토스 결제 구현체
@Component
@Primary
public class TossPaymentProcessor implements PaymentProcessor {
    private final TossPayClient tossClient;
    private final PaymentProperties properties;
    
    @Override
    public PaymentResult processPayment(PaymentRequest request) {
        TossPayRequest tossRequest = TossPayRequest.builder()
            .amount(request.getAmount())
            .orderId(request.getOrderId())
            .customerName(request.getCustomerName())
            .build();
            
        TossPayResponse response = tossClient.requestPayment(tossRequest);
        return PaymentResult.builder()
            .success(response.isSuccess())
            .paymentId(response.getPaymentId())
            .build();
    }
    
    // 나머지 구현...
}

// 카카오페이 결제 구현체
@Component
public class KakaoPayProcessor implements PaymentProcessor {
    private final KakaoPayClient kakaoClient;
    
    @Override
    public PaymentResult processPayment(PaymentRequest request) {
        KakaoPayRequest kakaoRequest = new KakaoPayRequest();
        kakaoRequest.setTotalAmount(request.getAmount());
        kakaoRequest.setOrderNumber(request.getOrderId());
        // ... 카카오페이 특화 설정
        
        KakaoPayResponse response = kakaoClient.pay(kakaoRequest);
        return PaymentResult.builder()
            .success(response.getStatus().equals("SUCCESS"))
            .paymentId(response.getTid())
            .build();
    }
    
    // 나머지 구현...
}

여기서 주목할 점은 다음과 같습니다.

  • 테스트의 용이성: Mock 객체를 통한 테스트가 매우 쉬워집니다.
  • 전환의 유연성: 결제 시스템 변경 시 새로운 구현체만 추가하면 됩니다.

2. 캐시 전략 변경 시나리오

실제 서비스에서 자주 발생하는 캐시 전략 변경 상황을 살펴보겠습니다.

public interface CacheManager {
    Optional<Object> get(String key);
    void put(String key, Object value);
    void remove(String key);
    void clear();
}

@Component
@Primary
public class LocalCacheManager implements CacheManager {
    private final Cache<String, Object> cache;
    
    public LocalCacheManager() {
        this.cache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .build();
    }
    
    @Override
    public Optional<Object> get(String key) {
        return Optional.ofNullable(cache.getIfPresent(key));
    }
    
    // 나머지 구현...
}

@Component
@Conditional(RedisEnvironmentCondition.class)
public class RedisCacheManager implements CacheManager {
    private final RedisTemplate<String, Object> redisTemplate;
    
    @Override
    public Optional<Object> get(String key) {
        return Optional.ofNullable(redisTemplate.opsForValue().get(key));
    }
    
    // 나머지 구현...
}

3. 동적 정책 변경이 필요한 경우

비즈니스 정책이 자주 변경되는 경우의 예시를 살펴보겠습니다.

public interface DiscountPolicy {
    BigDecimal calculateDiscount(Order order);
    boolean isApplicable(Order order);
}

@Component
public class WeekendDiscountPolicy implements DiscountPolicy {
    @Override
    public BigDecimal calculateDiscount(Order order) {
        if (!isApplicable(order)) {
            return BigDecimal.ZERO;
        }
        return order.getTotalAmount().multiply(BigDecimal.valueOf(0.1));
    }
    
    @Override
    public boolean isApplicable(Order order) {
        DayOfWeek dayOfWeek = order.getOrderDate().getDayOfWeek();
        return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
    }
}

@Component
public class FirstOrderDiscountPolicy implements DiscountPolicy {
    private final OrderRepository orderRepository;
    
    @Override
    public BigDecimal calculateDiscount(Order order) {
        if (!isApplicable(order)) {
            return BigDecimal.ZERO;
        }
        return BigDecimal.valueOf(5000);
    }
    
    @Override
    public boolean isApplicable(Order order) {
        return orderRepository.countByCustomerId(order.getCustomerId()) == 0;
    }
}

@Service
public class OrderService {
    private final List<DiscountPolicy> discountPolicies;
    
    public Order createOrder(OrderRequest request) {
        Order order = new Order(request);
        
        BigDecimal totalDiscount = discountPolicies.stream()
            .map(policy -> policy.calculateDiscount(order))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
            
        order.applyDiscount(totalDiscount);
        return order;
    }
}

4. 알림 발송 전략

다양한 알림 채널을 지원해야 하는 경우의 예시입니다.

public interface NotificationSender {
    void send(NotificationRequest request);
    boolean supports(NotificationType type);
}

@Component
public class EmailNotificationSender implements NotificationSender {
    private final JavaMailSender mailSender;
    private final TemplateEngine templateEngine;
    
    @Override
    public void send(NotificationRequest request) {
        Context context = new Context();
        context.setVariable("user", request.getUser());
        context.setVariable("content", request.getContent());
        
        String emailContent = templateEngine.process("email-template", context);
        
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(request.getUser().getEmail());
        message.setSubject(request.getTitle());
        message.setText(emailContent);
        
        mailSender.send(message);
    }
    
    @Override
    public boolean supports(NotificationType type) {
        return type == NotificationType.EMAIL;
    }
}

@Component
public class SlackNotificationSender implements NotificationSender {
    private final SlackClient slackClient;
    private final ObjectMapper objectMapper;
    
    @Override
    public void send(NotificationRequest request) {
        SlackMessage message = SlackMessage.builder()
            .channel(request.getChannel())
            .text(request.getContent())
            .attachments(createAttachments(request))
            .build();
            
        slackClient.sendMessage(message);
    }
    
    @Override
    public boolean supports(NotificationType type) {
        return type == NotificationType.SLACK;
    }
}

@Service
public class NotificationService {
    private final List<NotificationSender> senders;
    
    public void sendNotification(NotificationRequest request) {
        NotificationSender sender = senders.stream()
            .filter(s -> s.supports(request.getType()))
            .findFirst()
            .orElseThrow(() -> new UnsupportedNotificationTypeException(request.getType()));
            
        sender.send(request);
    }
}

결론

실제 프로젝트에서 인터페이스의 가치는 다음과 같은 상황에서 빛을 발합니다.

  • 외부 시스템과의 연동
  • 캐시 전략 변경
  • 비즈니스 정책의 동적 적용
  • 확장 가능한 기능 구현

이러한 상황들에서 인터페이스를 활용하면 다음과 같은 실질적인 이점을 얻을 수 있습니다.

  1. 시스템 변경에 대한 유연한 대응
  2. 테스트 용이성 확보
  3. 점진적인 기능 확장
  4. 안전한 리팩토링

중요한 것은 무조건적인 인터페이스 사용이 아닌, 실제 변경이나 확장이 예상되는 부분에 대해 선별적으로 적용하는 것입니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글