백엔드 · 리팩터링
할인 규칙 계산 서비스 리팩터링 비교
Java 21과 Spring Boot 3 할인 계산 서비스의 중복 검증·반올림 로직을 제거하고 할인 유형별 계산 책임을 분리한다.
Task
성공 조건
- 기존 테스트 파일을 수정하지 않고 ./gradlew test가 전부 통과한다.
- 4가지 할인 유형의 검증 로직이 한 곳에만 존재한다.
- 4가지 할인 유형의 원 단위 반올림 로직이 한 곳에만 존재한다.
- 컨트롤러에 할인 유형 분기 로직이 남아 있지 않다.
- 매직 넘버가 이름 있는 상수로 추출되어 있다.
- 변경·추가 파일이 4~10개 이내이고 실행 방법을 결과에 명시한다.
Common conditions
핵심 조건
- 공개 API와 계산 결과를 유지하면서 DiscountService의 중복 검증·반올림·응답 조립을 제거한다.
- 정률·정액·BOGO·구간별 할인 계산을 독립된 단위로 분리하고 컨트롤러에는 요청·응답 처리만 남긴다.
- 기존 테스트를 수정하지 않고 Java 21·Spring Boot 3·Gradle 환경에서 외부 의존성 없이 검증한다.
Model results
모델별 결과
공통 검증·계산 지원 클래스를 두고 네 할인 유형을 독립 전략으로 분리한 8파일 리팩터링.
luna-xhigh
구현 결과
DiscountRequestValidator로 쿠폰 유형별 입력 검증을 한 곳으로 모았다.DiscountCalculationSupport로 나눗셈 정밀도, 원 단위HALF_UP반올림, 할인액 하한·상한 클램핑, 응답 조립을 공통화했다.- 정률·정액·BOGO·구간별 할인 계산을 각각 독립된
DiscountCalculator구현체로 분리했다. DiscountService는 검증, 전략 선택, 공통 응답 변환만 수행하며 할인 유형별 계산 분기를 제거했다.- 최대 할인율, BOGO 최소 수량, 구간 임계값과 할인율, 반올림 관련 수치를 이름 있는 상수로 추출했다.
변경 파일
작업 디렉터리: verification/work/15-discount-rule-refactor/luna-xhigh
- 수정:
src/main/java/com/samebrief/discount/service/DiscountService.java - 추가:
DiscountCalculator.java - 추가:
DiscountRequestValidator.java - 추가:
DiscountCalculationSupport.java - 추가:
PercentageDiscountCalculator.java - 추가:
FlatDiscountCalculator.java - 추가:
BogoDiscountCalculator.java - 추가:
TieredDiscountCalculator.java
총 8개 파일이며, 기존 테스트 파일은 수정하지 않았다.
검증
실행 명령:
./gradlew clean test --rerun-tasks
결과: BUILD SUCCESSFUL
DiscountServiceTest: 12개 통과DiscountControllerTest: 5개 통과HarnessSmokeTest: 1개 통과- 총 18개 테스트 통과, 실패 0개
제거한 중복과 트레이드오프
기존 DiscountService의 네 개 if 블록에 반복되던 유형별 검증, 원 단위 반올림, 할인액 클램핑, 응답 조립을 공통 컴포넌트와 전략 구현체로 이동했다. 서비스의 무인자 생성자를 유지해 기존 단위 테스트 계약도 보존했다. 검증기 내부에는 쿠폰 유형별 필수 파라미터 차이를 표현하기 위한 단일 switch가 남아 있다.
코드 산출물
아래는 설명만 읽지 않고 리팩터링 전후의 실제 핵심 코드를 확인할 수 있도록 공개한 것이다. DiscountController와 DTO·enum은 변경하지 않았으며, 아래 작업 사본의 코드와 테스트를 기준으로 한다.
리팩터링 전
DiscountService.java — 시작 하네스의 원본
@Service
public class DiscountService {
public DiscountResponse calculate(DiscountRequest request) {
CouponType couponType = request.getCouponType();
if (couponType == null) {
throw new IllegalArgumentException("couponType is required");
}
long originalPrice = request.getOriginalPrice();
if (originalPrice <= 0) {
throw new IllegalArgumentException("originalPrice must be positive");
}
if (couponType == CouponType.PERCENTAGE) {
BigDecimal value = request.getValue();
if (value == null || value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(new BigDecimal("80")) > 0) {
throw new IllegalArgumentException("percentage value must be between 0 and 80");
}
BigDecimal raw = BigDecimal.valueOf(originalPrice)
.multiply(value)
.divide(new BigDecimal("100"), 10, RoundingMode.HALF_UP);
long discountAmount = raw.setScale(0, RoundingMode.HALF_UP).longValue();
discountAmount = Math.max(0, Math.min(discountAmount, originalPrice));
long discountedPrice = originalPrice - discountAmount;
return new DiscountResponse(couponType, originalPrice, discountAmount, discountedPrice);
}
if (couponType == CouponType.FLAT) {
BigDecimal value = request.getValue();
if (value == null || value.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("flat value must be zero or positive");
}
long discountAmount = value.setScale(0, RoundingMode.HALF_UP).longValue();
discountAmount = Math.max(0, Math.min(discountAmount, originalPrice));
long discountedPrice = originalPrice - discountAmount;
return new DiscountResponse(couponType, originalPrice, discountAmount, discountedPrice);
}
if (couponType == CouponType.BOGO) {
Integer quantity = request.getQuantity();
if (quantity == null || quantity < 2) {
throw new IllegalArgumentException("quantity must be at least 2 for BOGO");
}
BigDecimal unitPrice = BigDecimal.valueOf(originalPrice)
.divide(BigDecimal.valueOf(quantity), 10, RoundingMode.HALF_UP);
BigDecimal raw = unitPrice.multiply(BigDecimal.valueOf(quantity / 2));
long discountAmount = raw.setScale(0, RoundingMode.HALF_UP).longValue();
discountAmount = Math.max(0, Math.min(discountAmount, originalPrice));
long discountedPrice = originalPrice - discountAmount;
return new DiscountResponse(couponType, originalPrice, discountAmount, discountedPrice);
}
if (couponType == CouponType.TIERED) {
BigDecimal raw = BigDecimal.valueOf(originalPrice).multiply(tieredRateOf(originalPrice));
long discountAmount = raw.setScale(0, RoundingMode.HALF_UP).longValue();
discountAmount = Math.max(0, Math.min(discountAmount, originalPrice));
long discountedPrice = originalPrice - discountAmount;
return new DiscountResponse(couponType, originalPrice, discountAmount, discountedPrice);
}
throw new IllegalArgumentException("unsupported couponType: " + couponType);
}
}
Luna xhigh 결과
DiscountService.java — 검증·전략 선택·공통 응답 변환만 담당
@Service
public class DiscountService {
private final DiscountRequestValidator requestValidator;
private final Map<CouponType, DiscountCalculator> calculators;
public DiscountService() {
this(new DiscountRequestValidator(), defaultCalculators());
}
DiscountService(DiscountRequestValidator requestValidator, Map<CouponType, DiscountCalculator> calculators) {
this.requestValidator = requestValidator;
this.calculators = Map.copyOf(calculators);
}
public DiscountResponse calculate(DiscountRequest request) {
requestValidator.validate(request);
CouponType couponType = request.getCouponType();
DiscountCalculator calculator = Optional.ofNullable(calculators.get(couponType))
.orElseThrow(() -> new IllegalArgumentException("unsupported couponType: " + couponType));
return DiscountCalculationSupport.toResponse(
couponType,
request.getOriginalPrice(),
calculator.calculate(request)
);
}
private static Map<CouponType, DiscountCalculator> defaultCalculators() {
return Map.of(
CouponType.PERCENTAGE, new PercentageDiscountCalculator(),
CouponType.FLAT, new FlatDiscountCalculator(),
CouponType.BOGO, new BogoDiscountCalculator(),
CouponType.TIERED, new TieredDiscountCalculator()
);
}
}
DiscountRequestValidator.java — 네 유형의 입력 검증을 한 곳에서 담당
final class DiscountRequestValidator {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private static final BigDecimal MAX_PERCENTAGE = BigDecimal.valueOf(80);
private static final int MIN_BOGO_QUANTITY = 2;
void validate(DiscountRequest request) {
if (request == null) {
throw new IllegalArgumentException("request is required");
}
CouponType couponType = request.getCouponType();
if (couponType == null) {
throw new IllegalArgumentException("couponType is required");
}
if (request.getOriginalPrice() <= 0) {
throw new IllegalArgumentException("originalPrice must be positive");
}
switch (couponType) {
case PERCENTAGE -> validatePercentage(request.getValue());
case FLAT -> validateFlat(request.getValue());
case BOGO -> validateBogo(request.getQuantity());
case TIERED -> { }
}
}
}
DiscountCalculationSupport.java — 중간 나눗셈과 최종 원 단위 정규화를 공통 처리
final class DiscountCalculationSupport {
private static final int DIVISION_SCALE = 10;
private static final int CURRENCY_SCALE = 0;
private static final long MIN_DISCOUNT_AMOUNT = 0;
private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;
static BigDecimal divide(BigDecimal dividend, BigDecimal divisor) {
return dividend.divide(divisor, DIVISION_SCALE, ROUNDING_MODE);
}
static DiscountResponse toResponse(CouponType couponType, long originalPrice, BigDecimal rawDiscountAmount) {
long discountAmount = rawDiscountAmount
.setScale(CURRENCY_SCALE, ROUNDING_MODE)
.longValue();
long boundedDiscountAmount = Math.max(
MIN_DISCOUNT_AMOUNT,
Math.min(discountAmount, originalPrice)
);
return new DiscountResponse(
couponType,
originalPrice,
boundedDiscountAmount,
originalPrice - boundedDiscountAmount
);
}
}
나머지 전략 클래스는 각각 원시 할인액만 계산한다.
service/
├── BogoDiscountCalculator.java
├── DiscountCalculationSupport.java
├── DiscountCalculator.java
├── DiscountRequestValidator.java
├── DiscountService.java
├── FlatDiscountCalculator.java
├── PercentageDiscountCalculator.java
└── TieredDiscountCalculator.java
Recommendation
이 과업의 추천 모델: GPT-5.6 Terra · medium
두 구현 모두 동일한 독립 재실행에서 18개 테스트와 여섯 가지 성공 조건을 통과했다. Terra는 같은 공개 계약과 구조적 목표를 7개 파일로 충족해 Luna보다 변경 범위가 작고, Luna는 중간 나눗셈까지 공통 지원 클래스로 모은 점이 강점이다. 순수 리팩터링의 변경 최소화와 유지보수 비용을 우선해 Terra를 추천한다.
추천 근거 보기
판단 조건
- 기존 테스트 파일을 수정하지 않고 ./gradlew test가 전부 통과한다.
- 변경·추가 파일이 4~10개 이내이고 실행 방법을 결과에 명시한다.
- 4가지 할인 유형의 검증 로직이 한 곳에만 존재한다.
반영한 평가 항목
정확성 · 완성도 · 실무성 · 효율성
잘 맞는 경우
- 기존 Java 서비스의 공개 계약을 유지하면서 최소 파일 변경으로 구조를 개선해야 하는 리팩터링
- 유형별 계산 전략과 공통 검증·응답 정규화를 분리하는 소규모 백엔드 작업
주의할 점
- 두 결과 모두 고정 하네스 18개를 통과했으며, 복잡한 신규 할인 유형이나 동시성 환경은 검증 범위에 포함되지 않는다.
- Terra의 추천 우위는 기능 통과 차이가 아니라 한 개 적은 변경 파일 수에 따른 효율성 차이다.
Prompt and environment
공통 프롬프트와 실행 환경
프롬프트 전문 보기
# 15. 할인 규칙 계산 리팩터링
## 문서 상태
`verification/harnesses/spring-boot-3-java21-discount` 하네스를 아래 계약대로 구현했다. `DiscountServiceTest`(12개) · `DiscountControllerTest`(5개) · `HarnessSmokeTest`(1개) 총 18개 테스트로 현재 동작을 고정했고, `./gradlew test`로 전부 통과하는 것을 로컬에서 확인했다. 아래 "제공되는 시작 코드 계약"과 "API 계약"은 실제 구현을 반영해 갱신했다.
참여 모델은 GPT-5.6 Luna(xhigh)와 GPT-5.6 Terra(medium) 두 개로 확정했고, 두 모델의 결과와 격리 작업 사본을 보존했다. 두 결과를 동일한 기준으로 독립 재실행·점검하고 `taskType: refactoring`을 포함한 게시용 `index.md`를 작성했다.
## 참여 모델
- GPT-5.6 Luna · 추론 설정 `xhigh` · 실행 사본 `verification/work/15-discount-rule-refactor/luna-xhigh` · 결과 `outputs/gpt-5.6-luna-xhigh.md`
- GPT-5.6 Terra · 추론 설정 `medium` · 실행 사본 `verification/work/15-discount-rule-refactor/gpt-5.6-terra-medium` · 결과 `outputs/gpt-5.6-terra-medium.md`
## 과업
Java 21과 Spring Boot 3으로 작성된 할인 계산 서비스를 리팩터링해 주세요. 현재 `DiscountService`는 정률 할인(PERCENTAGE), 정액 할인(FLAT), 1+1(BOGO), 구간별 누진 할인(TIERED) 네 가지 유형을 각각 거의 동일한 입력 검증과 원 단위 반올림 코드를 복사해 넣은 if-else 분기로 처리합니다.
공개 API 요청·응답 형식과 계산 결과(기존 테스트가 고정하는 동작)는 그대로 유지한 채, 중복된 검증·반올림 로직을 하나의 지점으로 모으고 할인 유형별 계산 책임을 분리하세요. 새로운 기능을 추가하거나 버그를 고치는 과업이 아니라, 동일한 동작을 더 나은 구조로 옮기는 순수 리팩터링 과업입니다.
## 리팩터링 목표 (구조적 성공 조건)
- 4가지 할인 유형의 입력 검증 로직이 하나의 공통 지점에만 존재한다.
- 원 단위 반올림(HALF_UP) 로직이 하나의 공통 지점에만 존재한다.
- 할인 유형별 계산이 서로의 코드를 복사하지 않고 각각 독립된 단위(전략 패턴 등 자유로운 방식)로 분리된다.
- 컨트롤러에는 할인 유형 분기나 계산 로직이 남아 있지 않고 요청 파싱과 응답 매핑만 남는다.
- 매직 넘버(최대 할인율 80%, 반올림 단위 등)가 이름 있는 상수로 추출된다.
- 기존 테스트 파일을 한 줄도 수정하지 않고 전부 통과한다.
- 리팩터링 결과물(신규·수정 파일 합계)이 4~10개 파일 이내다.
## 실행 환경
- Java 21
- Gradle 8.x
- Spring Boot 3.x, Spring Web MVC
- JUnit 5, MockMvc
- 금액 단위는 대한민국 원
- 외부 데이터베이스, 메시지 브로커, 캐시, 파일 시스템, 외부 API는 사용하지 않음
- 새로운 외부 의존성 추가 금지 (JDK 표준 라이브러리와 이미 포함된 Spring 의존성만 사용)
- 실행 명령은 `./gradlew test`
## 제공되는 시작 코드 계약 (구현 완료)
```text
com.samebrief.discount
├── DiscountApplication.java
├── api
│ ├── DiscountController.java
│ ├── DiscountErrorResponse.java
│ ├── DiscountRequest.java
│ └── DiscountResponse.java
├── domain
│ └── CouponType.java # PERCENTAGE | FLAT | BOGO | TIERED
└── service
└── DiscountService.java # 리팩터링 대상, 아래 중복 패턴을 실제로 포함
```
`DiscountService.calculate()`는 네 유형을 각각 독립된 if 블록에서 처리하며, 아래 세 가지가 유형마다 거의 그대로 복사되어 있다.
- 입력 검증 (`couponType`·`originalPrice`·유형별 파라미터 null/범위 검사, 실패 시 `IllegalArgumentException`)
- 반올림·하한 클램핑 (`setScale(0, RoundingMode.HALF_UP)` 후 `Math.max(0, Math.min(discountAmount, originalPrice))`)
- `DiscountResponse` 조립 (`return new DiscountResponse(couponType, originalPrice, discountAmount, discountedPrice)`)
전체 구현은 `verification/harnesses/spring-boot-3-java21-discount/src/main/java/com/samebrief/discount/service/DiscountService.java`에 있다. `DiscountRequest`·`DiscountResponse`·`DiscountErrorResponse`의 공개 필드, JSON 이름, 컨트롤러 경로는 고정 계약이며, `DiscountServiceTest`(12개)·`DiscountControllerTest`(5개)·`HarnessSmokeTest`(1개)가 현재 동작을 고정한다. 모델은 이 세 테스트 파일을 수정할 수 없다.
## API 계약 (구현 완료)
`POST /api/v1/discounts/calculate`
```json
{
"couponType": "PERCENTAGE",
"originalPrice": 10000,
"value": 15
}
```
```json
{
"couponType": "PERCENTAGE",
"originalPrice": 10000,
"discountAmount": 1500,
"discountedPrice": 8500
}
```
BOGO는 `value` 대신 `quantity`(2 이상의 정수)를 사용한다. 짝수 개는 절반, 홀수 개는 내림한 절반이 무료 단위로 계산된다.
```json
{
"couponType": "BOGO",
"originalPrice": 40000,
"quantity": 4
}
```
유효하지 않은 `couponType`, 범위를 벗어난 `value`, `quantity` 누락(BOGO), 0 이하 `originalPrice`는 모두 400과 함께 다음 오류 응답을 반환한다.
```json
{
"code": "INVALID_DISCOUNT_REQUEST",
"message": "percentage value must be between 0 and 80"
}
```
## 성공 조건 (모델별 채점용)
- 기존 테스트 파일을 수정하지 않고 `./gradlew test`가 전부 통과한다.
- 4가지 할인 유형의 검증 로직이 한 곳에만 존재한다 (중복 제거 확인).
- 4가지 할인 유형의 반올림 로직이 한 곳에만 존재한다 (중복 제거 확인).
- 컨트롤러에 할인 유형 분기 로직이 남아 있지 않다.
- 매직 넘버가 이름 있는 상수로 추출되어 있다.
- 변경·추가 파일이 4~10개 이내이고, 실행 방법이 결과에 명시되어 있다.
## 프롬프트 (초안, 모든 모델에 동일하게 전달)
> Java 21과 Spring Boot 3으로 작성된 할인 계산 서비스를 리팩터링해 주세요. 주어진 단독 Gradle 애플리케이션에서 `DiscountService`는 정률·정액·1+1·구간별 누진 할인 네 가지를 각각 거의 동일한 검증과 원 단위 반올림 코드를 복사해 넣은 if-else 분기로 처리합니다.
>
> 공개 API 요청·응답 형식과 계산 결과는 그대로 유지하세요. 기존 테스트 파일은 한 줄도 수정하지 말고 전부 통과해야 합니다. 중복된 검증·반올림 로직을 하나의 지점으로 모으고, 할인 유형별 계산 책임을 분리하세요. 컨트롤러에는 요청 파싱과 응답 매핑만 남기고, 매직 넘버는 이름 있는 상수로 추출하세요. 새로운 외부 의존성은 추가하지 말고, 변경·추가 파일은 4~10개 안에서 유지하세요.
>
> 변경한 파일과 `./gradlew test` 실행 결과, 리팩터링으로 제거한 중복 지점을 응답 마지막에 정리해 주세요.
## 비교 실행 격리 규칙
하네스가 실제로 구현되고 기존 테스트가 통과하는 상태로 확정된 뒤, 다른 과업(`12-order-pricing-engine` 등)과 같은 절차를 따른다. `<run-id>`는 해당 실행에 지정한 출력 파일명에서 `.md`를 뺀 값이다.
### 실행 준비
1. 각 모델 실행을 시작하기 전에 새 대화나 새 세션을 열어 이전 모델의 컨텍스트를 완전히 비운다.
2. 편집자는 수정되지 않은 원본 하네스를 모델별 전용 작업 디렉터리에 복사한다.
```bash
discount_run_dir="verification/work/15-discount-rule-refactor/<run-id>"
if [ -e "$discount_run_dir" ]; then
echo "작업 디렉터리가 이미 존재합니다: $discount_run_dir"
exit 1
fi
mkdir -p "$discount_run_dir"
rsync -a \
--exclude .gradle \
--exclude build \
verification/harnesses/spring-boot-3-java21-discount/ \
"$discount_run_dir/"
```
3. 기존 작업 디렉터리를 재사용하거나 그 위에 다시 복사하지 않는다. 같은 모델을 재실행할 때도 새 `<run-id>`를 사용한다.
4. 모델의 작업 디렉터리는 `verification/work/15-discount-rule-refactor/<run-id>`로 고정한다.
5. 모든 모델에 이 `brief.md`의 "과업"부터 "프롬프트" 절과 자신의 `<run-id>`만 전달한다. 프롬프트, reasoning effort와 사용 가능한 도구 조건도 모델마다 같게 유지한다.
### 모델이 지켜야 할 범위
- `verification/harnesses/spring-boot-3-java21-discount` 원본을 직접 수정하지 않는다.
- 자신에게 지정된 `verification/work/15-discount-rule-refactor/<run-id>` 안의 코드와 테스트만 수정한다.
- 다른 모델의 작업 디렉터리, `content/comparisons/15-discount-rule-refactor/outputs`, 게시용 `index.md`, 편집자용 채점 자료와 이전 모델의 응답을 열람하거나 참고하지 않는다.
- 다른 모델이 만든 코드, 설명을 복사하거나 이어서 작업하지 않는다.
- 리팩터링과 자체 테스트가 끝나기 전에는 결과 Markdown을 작성하지 않는다.
- 자체 작업 사본에서 `./gradlew test`를 실제로 실행한 뒤, 변경 파일·테스트 결과·제거한 중복 지점과 트레이드오프를 마지막 응답에 정리한다.
### 실행 완료 후
1. 편집자는 모델의 마지막 응답을 `content/comparisons/15-discount-rule-refactor/outputs/<run-id>.md`에 저장한다.
2. 모델별 작업 사본은 서로 덮어쓰지 않고 채점이 끝날 때까지 보존한다.
3. 독립 채점은 모델 실행과 컨텍스트가 종료된 뒤 편집자가 동일한 채점 스위트로 각각 수행한다.
4. 다음 모델을 실행하기 전에 다시 새 컨텍스트를 시작한다.
5. 모든 결과와 독립 채점 기록이 준비된 뒤에만 게시용 `index.md`를 만든다.
## 다음 단계
1. ~~`verification/harnesses/spring-boot-3-java21-discount` 하네스를 이 문서의 계약대로 구현하고, 기존 동작을 고정하는 테스트를 작성해 로컬에서 전부 통과시킨다.~~ (완료: 18개 테스트 통과)
2. ~~참여 모델을 확정하고 공식 모델 ID·버전·접근 등급을 게시 직전에 확인한다.~~ (비교 대상 확정: GPT-5.6 Luna xhigh, GPT-5.6 Terra medium. 공식 모델 정보는 게시 직전에 확인한다.)
3. ~~위 격리 절차대로 모델별로 실행하고 결과를 저장한다.~~ (완료: 두 모델의 결과 Markdown과 격리 작업 사본 보존)
4. ~~동일한 채점 스위트로 독립 채점한 뒤 `taskType: refactoring`으로 `index.md`를 작성해 게시한다.~~ (완료: 두 사본 독립 테스트 재실행, 구조 점검, [index.md](./index.md) 작성)
- Java 21
- Gradle 8.14.3
- Spring Boot 3.5.16 · Spring Web MVC
- JUnit 5 · MockMvc
- 대한민국 원 단위 · 외부 인프라 없음