@Service
public class ReservationService {
public int calculateFinalPrice(int originalPrice, String discountType) {
int finalPrice = originalPrice;
// 문제 1: 문자열로 타입 구분 → 오타 위험, 타입 안전성 없음
// 문제 2: 모든 할인 로직이 한 곳에 뭉쳐있음
// 문제 3: 매직 넘버(1000, 0.1) 직접 사용
// 문제 4: 정책 추가될 때마다 if문 계속 추가해야 함 (OCP 위반)
if (discountType.equals("AMOUNT")) {
finalPrice = originalPrice - 1000;
} else if (discountType.equals("PERCENT")) {
finalPrice = originalPrice - (int)(originalPrice * 0.1);
} else if (discountType.equals("VIP")) {
finalPrice = originalPrice - (int)(originalPrice * 0.2);
} else if (discountType.equals("COUPON")) {
finalPrice = originalPrice - 2000;
}
// 새로운 정책? → 또 else if 추가...
return finalPrice;
}
}
위의 코드를 살펴봤을 때 여러가지 문제들이 보이실 것입니다. 문자열로 타입을 구분하면서 타입 안정성이 떨어지는 것과 다양한 할인 정책들이 하나의 메서드 안에서 처리되는 것, 매직 넘버를 통해 직접 코딩이 구현되어 있는 것, 단일 책임 원칙을 위반한 것 등의 문제들이 담긴 코드라는 것을 알 수 있습니다.
String -> Enum(타입 안정성)
// 1-1. Enum 생성
public enum DiscountType {
AMOUNT,
PERCENT,
VIP,
NONE
}
// 1-2. 서비스 수정 - String → Enum
@Service
public class ReservationService {
// discountType이 이제 Enum이므로 오타 방지
public int calculateFinalPrice(int originalPrice, DiscountType discountType) {
int finalPrice = originalPrice;
// 문자열 비교에서 Enum 비교로 변경
if (discountType == DiscountType.AMOUNT) {
finalPrice = originalPrice - 1000;
} else if (discountType == DiscountType.PERCENT) {
finalPrice = originalPrice - (int)(originalPrice * 0.1);
} else if (discountType == DiscountType.VIP) {
finalPrice = originalPrice - (int)(originalPrice * 0.2);
}
return finalPrice;
}
}
일단 첫번째로 String으로 비교하던 코드를 Enum으로 수정해봅시다. Enum으로 변경하면서 이제 타입 안정성이 강화되었고 중복 로직에 대한 발생 가능성도 크게 낮아졌습니다. 왜냐하면 Enum을 기반으로 다른 로직들도 처리될 것이기 때문입니다.
상수(유지보수성 향상)
@Service
public class ReservationService {
// 상수로 추출 - 의미가 명확해지고 한 곳에서 관리 가능
private static final int AMOUNT_DISCOUNT = 1000;
private static final double PERCENT_DISCOUNT_RATE = 0.1;
private static final double VIP_DISCOUNT_RATE = 0.2;
public int calculateFinalPrice(int originalPrice, DiscountType discountType) {
int finalPrice = originalPrice;
if (discountType == DiscountType.AMOUNT) {
finalPrice = originalPrice - AMOUNT_DISCOUNT;
} else if (discountType == DiscountType.PERCENT) {
finalPrice = originalPrice - (int)(originalPrice * PERCENT_DISCOUNT_RATE);
} else if (discountType == DiscountType.VIP) {
finalPrice = originalPrice - (int)(originalPrice * VIP_DISCOUNT_RATE);
}
return finalPrice;
}
}
매직 넘버를 상수로 추출하였습니다. 매직 넘버를 상수로 바꾼 이유는 숫자만 보았을 때 이 숫자 값이 무엇을 의미하는지 어떤 역활을 하는지 이해하기 어렵기 때문에 다른 개발자가 수정하거나 변경하여 큰 문제가 발생할 수 있기 때문입니다. 할인율을 수정할 때에는 이제 상수값만 변경하면 되기 때문에 변경으로 인한 사이드 이펙트는 최소화될 수 있었습니다.
메서드 분리(단일 책임)
@Service
public class ReservationService {
private static final int AMOUNT_DISCOUNT = 1000;
private static final double PERCENT_DISCOUNT_RATE = 0.1;
private static final double VIP_DISCOUNT_RATE = 0.2;
// 메인 로직이 깔끔해짐
public int calculateFinalPrice(int originalPrice, DiscountType discountType) {
int discount = calculateDiscount(originalPrice, discountType);
return originalPrice - discount;
}
// 할인 계산 로직을 별도 메서드로 분리
private int calculateDiscount(int originalPrice, DiscountType discountType) {
switch (discountType) {
case AMOUNT:
return AMOUNT_DISCOUNT;
case PERCENT:
return (int)(originalPrice * PERCENT_DISCOUNT_RATE);
case VIP:
return (int)(originalPrice * VIP_DISCOUNT_RATE);
default:
return 0;
}
}
}
각 메서드가 하나의 역활을 할 수 있도록 메서드를 분리하였습니다. 이제 할인을 처리하는 로직과 할인을 계산하는 로직이 분산되어 할인을 적용하는 로직이 수정되거나 할인 정책이 수정되는 상황이 발생해도 서로의 코드의 영향이 가지 않도록 수정되었습니다. 하지만 아직도 여러 할인 정책을 하나의 메서드로 가지고 있는 것이 불편하니 수정해봅시다.
Enum에 로직 위임(책임 이동)
// Enum이 자신의 할인 로직을 직접 가짐
public enum DiscountType {
AMOUNT(1000, 0) {
@Override
public int calculateDiscount(int price) {
return discountAmount; // 고정 금액 할인
}
},
PERCENT(0, 0.1) {
@Override
public int calculateDiscount(int price) {
return (int)(price * discountRate); // 비율 할인
}
},
VIP(0, 0.2) {
@Override
public int calculateDiscount(int price) {
return (int)(price * discountRate);
}
},
NONE(0, 0) {
@Override
public int calculateDiscount(int price) {
return 0;
}
};
protected final int discountAmount;
protected final double discountRate;
DiscountType(int discountAmount, double discountRate) {
this.discountAmount = discountAmount;
this.discountRate = discountRate;
}
// 각 Enum 상수가 구현해야 하는 추상 메서드
public abstract int calculateDiscount(int price);
}
// 서비스가 매우 깔끔해짐
@Service
public class ReservationService {
public int calculateFinalPrice(int originalPrice, DiscountType discountType) {
// 할인 계산은 Enum에게 위임
int discount = discountType.calculateDiscount(originalPrice);
return originalPrice - discount;
}
}
Enum이 자신의 할인 방식을 책임지면서 서비스에서 switch문 제거되었습니다. 이제 할인 정책에 대한 개념은 서비스에서 알 필요가 없이 도메인 계층에서 처리되어 서비스는 할인 정책이 추가되거나 변경되어도 영향없이 할인 로직을 핸들링할 수 있게 되었습니다.
여기서 이제 어떤 부분이 문제일까요? 바로 런타임 환경에서 할인 정책을 수정하기 어렵다는 부분과 테스트를 진행할 때 Mocking하기 쉽지 않다는 점입니다. 정책을 유연하게 교체하기 위해서 필요한 건 무엇일까요? 바로 인터베이스와 DI 입니다.
인터페이스 + DI(OCP, DIP, 테스트 용이성)
// 5-1. 인터페이스 정의
public interface DiscountPolicy {
int calculateDiscount(int price);
}
// 5-2. 각 정책이 독립적인 클래스로 분리
@Component
public class AmountDiscountPolicy implements DiscountPolicy {
private static final int DISCOUNT_AMOUNT = 1000;
@Override
public int calculateDiscount(int price) {
return DISCOUNT_AMOUNT;
}
}
@Component
public class PercentDiscountPolicy implements DiscountPolicy {
private static final double DISCOUNT_RATE = 0.1;
@Override
public int calculateDiscount(int price) {
return (int)(price * DISCOUNT_RATE);
}
}
@Component
public class VipDiscountPolicy implements DiscountPolicy {
private static final double DISCOUNT_RATE = 0.2;
@Override
public int calculateDiscount(int price) {
return (int)(price * DISCOUNT_RATE);
}
}
// 5-3. 서비스는 인터페이스에만 의존 (DIP)
@Service
@RequiredArgsConstructor
public class ReservationService {
private final DiscountPolicy discountPolicy; // 어떤 구현체인지 몰라도 됨
public int calculateFinalPrice(int originalPrice) {
int discount = discountPolicy.calculateDiscount(originalPrice);
return originalPrice - discount;
}
}
// 5-4. 설정에서 정책 결정
@Configuration
public class DiscountConfig {
@Bean
public DiscountPolicy discountPolicy() {
// 정책 변경 시 이 부분만 수정
return new PercentDiscountPolicy();
}
}
인터페이스를 두고 분리를 하면서 이제 OCP를 준수하는 코드가 되었습니다. 새로운 할인 정책을 추가한다고 했을 때 기존 코드의 수정없이 새로운 정책을 추가할 수 있도 테스트 또한 인터페이스로 되어있기 때문에 테스트 케이스에 따라 맞는 Policy를 찾아 주입함으로써 테스트 작성 또한 편리해졌습니다.
정리
핵심은 한 번에 다 바꾸지 않고, 문제를 하나씩 인식하면서 점진적으로 개선하는 것입니다. 이게 리팩토링의 본질이에요.
'spring' 카테고리의 다른 글
| 토비의 스프링 정복하기 10편 - 비즈니스 로직을 기술로부터 독립시키기 (0) | 2026.02.07 |
|---|---|
| 토비의 스프링 8편 - 스프링 예외 처리 실전편 (0) | 2026.01.31 |
| 토비의 스프링 7편 - 스프링 예외 처리의 철학 (1) | 2026.01.28 |
| 토비의 스프링 정복하기 6편 - 제어의 역전 (0) | 2026.01.25 |
| 토비의 스프링 정복하기 5편 - 변하는 것과 변하지 않는 것 (0) | 2026.01.21 |