비교 요약으로 돌아가기

Full output

GPT-5.6 Terra 원문 결과물

주문 금액 계산 엔진 보수 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-28

gpt-5.6-terra-high

구현과 테스트를 완료했습니다.

  • 변경 파일

    • OrderPricingService.java
    • PricingController.java
    • InvalidPricingRequestException.java
    • PricingExceptionHandler.java
    • PricingControllerTest.java
  • 테스트 결과: ./gradlew test --rerun-tasks 성공 (BUILD SUCCESSFUL).

기존 오류는 할인 대상·기준 금액·반올림 시점이 명세와 달랐고, double 기반 쿠폰 배분으로 잔여 원이 누락될 수 있었으며, 배송비가 할인 전 합계를 기준으로 계산됐습니다. 입력 null·오버플로·잘못된 enum도 계약된 400 형식으로 처리되지 않았습니다.

정수 및 BigInteger 나머지 연산으로 주문 단위 할인 배분을 구현해 소수점 큰 순서와 입력 순서 우선 규칙을 보장했습니다. 모든 산술 오버플로를 검증하고, JSON 역직렬화 오류까지 INVALID_PRICING_REQUEST 400 응답으로 통일했습니다. 필수 13개 시나리오와 0원 행·JSON long 범위 초과를 MockMvc로 검증했습니다.

트레이드오프는 정확한 배분을 위해 BigInteger를 배분 계산에만 사용했다는 점입니다. 일반 금액 계산은 long과 안전한 정수 연산으로 유지했습니다.

실제 변경 코드

아래는 최종 유효 실행으로 기록된 격리 작업 사본을 원본 하네스와 대조해 확인한 변경 파일의 전체 내용입니다. 원본 HarnessSmokeTest.java는 이 사본에서 제거됐습니다.

src/main/java/com/samebrief/pricing/api/InvalidPricingRequestException.java

package com.samebrief.pricing.api;

public class InvalidPricingRequestException extends RuntimeException {

    public InvalidPricingRequestException() {
        super("Invalid pricing request");
    }
}

src/main/java/com/samebrief/pricing/api/PricingController.java

package com.samebrief.pricing.api;

import com.samebrief.pricing.service.OrderPricingService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/orders")
public class PricingController {

    private final OrderPricingService orderPricingService;

    public PricingController(OrderPricingService orderPricingService) {
        this.orderPricingService = orderPricingService;
    }

    @PostMapping("/price")
    public ResponseEntity<PricingResponse> price(@RequestBody PricingRequest request) {
        return ResponseEntity.ok(orderPricingService.price(request));
    }
}

src/main/java/com/samebrief/pricing/api/PricingExceptionHandler.java

package com.samebrief.pricing.api;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class PricingExceptionHandler {

    private static final PricingErrorResponse INVALID_REQUEST =
            new PricingErrorResponse("INVALID_PRICING_REQUEST", "주문 정보를 확인해 주세요.");

    @ExceptionHandler({InvalidPricingRequestException.class, HttpMessageNotReadableException.class})
    public ResponseEntity<PricingErrorResponse> invalidRequest(Exception exception) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(INVALID_REQUEST);
    }
}

src/main/java/com/samebrief/pricing/service/OrderPricingService.java

package com.samebrief.pricing.service;

import com.samebrief.pricing.api.InvalidPricingRequestException;
import com.samebrief.pricing.api.PricingRequest;
import com.samebrief.pricing.api.PricingResponse;
import com.samebrief.pricing.domain.Category;
import com.samebrief.pricing.domain.CouponType;
import com.samebrief.pricing.domain.MemberLevel;
import com.samebrief.pricing.domain.Region;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class OrderPricingService {

    private static final long FREE_SHIPPING_THRESHOLD = 50_000L;
    private static final long BASE_SHIPPING_FEE = 3_000L;
    private static final long REMOTE_EXTRA_SHIPPING_FEE = 5_000L;
    private static final long FIXED_COUPON_THRESHOLD = 30_000L;
    private static final long FIXED_COUPON_AMOUNT = 5_000L;
    private static final long RATE_COUPON_THRESHOLD = 50_000L;
    private static final long RATE_COUPON_MAX_DISCOUNT = 10_000L;

    public PricingResponse price(PricingRequest request) {
        validate(request);
        List<PricingRequest.Line> requestLines = request.getLines();
        int size = requestLines.size();
        long[] gross = new long[size];
        long[] itemDiscounts = new long[size];
        long[] memberDiscounts = new long[size];
        long[] couponDiscounts = new long[size];

        long itemSubtotal = 0;
        long itemDiscountTotal = 0;
        for (int i = 0; i < size; i++) {
            PricingRequest.Line line = requestLines.get(i);
            gross[i] = multiply(line.getUnitPrice(), line.getQuantity());
            itemSubtotal = add(itemSubtotal, gross[i]);
            itemDiscounts[i] = itemDiscountOf(line, gross[i]);
            itemDiscountTotal = add(itemDiscountTotal, itemDiscounts[i]);
        }

        long afterItemDiscount = subtract(itemSubtotal, itemDiscountTotal);
        long memberEligibleTotal = 0;
        long[] memberBases = new long[size];
        for (int i = 0; i < size; i++) {
            if (requestLines.get(i).getCategory() != Category.BOOK) {
                memberBases[i] = subtract(gross[i], itemDiscounts[i]);
                memberEligibleTotal = add(memberEligibleTotal, memberBases[i]);
            }
        }

        long memberDiscountTotal = request.getMemberLevel() == MemberLevel.GOLD
                ? percentOf(memberEligibleTotal, 5)
                : 0;
        allocate(memberDiscountTotal, memberBases, memberDiscounts);

        long afterMemberDiscount = subtract(afterItemDiscount, memberDiscountTotal);
        long couponDiscountTotal = couponDiscountOf(request.getCouponType(), afterMemberDiscount);
        long[] couponBases = new long[size];
        for (int i = 0; i < size; i++) {
            couponBases[i] = subtract(subtract(gross[i], itemDiscounts[i]), memberDiscounts[i]);
        }
        allocate(couponDiscountTotal, couponBases, couponDiscounts);

        return responseFor(requestLines, request.getRegion(), gross, itemDiscounts, memberDiscounts, couponDiscounts,
                itemSubtotal, itemDiscountTotal, memberDiscountTotal, couponDiscountTotal);
    }

    private PricingResponse responseFor(List<PricingRequest.Line> requestLines, Region region, long[] gross, long[] itemDiscounts,
                                        long[] memberDiscounts, long[] couponDiscounts, long itemSubtotal,
                                        long itemDiscountTotal, long memberDiscountTotal, long couponDiscountTotal) {
        List<PricingResponse.Line> resultLines = new ArrayList<>();
        long merchandiseTotal = 0;
        for (int i = 0; i < requestLines.size(); i++) {
            long finalAmount = subtract(subtract(subtract(gross[i], itemDiscounts[i]), memberDiscounts[i]), couponDiscounts[i]);
            PricingResponse.Line resultLine = new PricingResponse.Line();
            resultLine.setProductId(requestLines.get(i).getProductId());
            resultLine.setGrossAmount(gross[i]);
            resultLine.setItemDiscount(itemDiscounts[i]);
            resultLine.setMemberDiscount(memberDiscounts[i]);
            resultLine.setCouponDiscount(couponDiscounts[i]);
            resultLine.setFinalAmount(finalAmount);
            merchandiseTotal = add(merchandiseTotal, finalAmount);
            resultLines.add(resultLine);
        }

        long shippingFee = shippingFeeOf(region, merchandiseTotal);
        PricingResponse response = new PricingResponse();
        response.setItemSubtotal(itemSubtotal);
        response.setItemDiscount(itemDiscountTotal);
        response.setMemberDiscount(memberDiscountTotal);
        response.setCouponDiscount(couponDiscountTotal);
        response.setMerchandiseTotal(merchandiseTotal);
        response.setShippingFee(shippingFee);
        response.setGrandTotal(add(merchandiseTotal, shippingFee));
        response.setLines(resultLines);
        return response;
    }

    private void validate(PricingRequest request) {
        if (request == null || request.getLines() == null || request.getLines().isEmpty()
                || request.getMemberLevel() == null || request.getCouponType() == null || request.getRegion() == null) {
            throw invalid();
        }
        for (PricingRequest.Line line : request.getLines()) {
            if (line == null || line.getProductId() == null || line.getProductId().trim().isEmpty()
                    || line.getCategory() == null || line.getUnitPrice() == null || line.getUnitPrice() < 0
                    || line.getQuantity() == null || line.getQuantity() < 1) {
                throw invalid();
            }
        }
    }

    private long itemDiscountOf(PricingRequest.Line line, long grossAmount) {
        if (line.getCategory() == Category.BOOK) {
            return percentOf(grossAmount, 10);
        }
        if (line.getCategory() == Category.FASHION && line.getQuantity() >= 3) {
            return percentOf(grossAmount, 15);
        }
        return 0;
    }

    private long couponDiscountOf(CouponType couponType, long amountBeforeCoupon) {
        if (amountBeforeCoupon == 0 || couponType == CouponType.NONE) {
            return 0;
        }
        if (couponType == CouponType.FIXED_5000 && amountBeforeCoupon >= FIXED_COUPON_THRESHOLD) {
            return Math.min(FIXED_COUPON_AMOUNT, amountBeforeCoupon);
        }
        if (couponType == CouponType.RATE_10 && amountBeforeCoupon >= RATE_COUPON_THRESHOLD) {
            return Math.min(Math.min(percentOf(amountBeforeCoupon, 10), RATE_COUPON_MAX_DISCOUNT), amountBeforeCoupon);
        }
        return 0;
    }

    /** Allocates a non-negative order discount using exact integer quotient/remainder arithmetic. */
    private void allocate(long discount, long[] bases, long[] allocations) {
        long totalBase = 0;
        for (long base : bases) {
            totalBase = add(totalBase, base);
        }
        if (discount == 0 || totalBase == 0) {
            return;
        }

        BigInteger denominator = BigInteger.valueOf(totalBase);
        List<AllocationRemainder> remainders = new ArrayList<>();
        long allocated = 0;
        for (int i = 0; i < bases.length; i++) {
            BigInteger[] quotientAndRemainder = BigInteger.valueOf(discount)
                    .multiply(BigInteger.valueOf(bases[i]))
                    .divideAndRemainder(denominator);
            allocations[i] = quotientAndRemainder[0].longValueExact();
            allocated = add(allocated, allocations[i]);
            remainders.add(new AllocationRemainder(i, quotientAndRemainder[1]));
        }

        long remaining = subtract(discount, allocated);
        remainders.sort(Comparator.comparing(AllocationRemainder::remainder).reversed()
                .thenComparingInt(AllocationRemainder::index));
        for (AllocationRemainder remainder : remainders) {
            if (remaining == 0) {
                break;
            }
            int index = remainder.index();
            if (allocations[index] < bases[index]) {
                allocations[index] = add(allocations[index], 1);
                remaining--;
            }
        }
        if (remaining != 0) {
            throw invalid();
        }
    }

    private long shippingFeeOf(Region region, long merchandiseTotal) {
        long fee = merchandiseTotal >= FREE_SHIPPING_THRESHOLD ? 0 : BASE_SHIPPING_FEE;
        return region == Region.REMOTE ? add(fee, REMOTE_EXTRA_SHIPPING_FEE) : fee;
    }

    private long multiply(long left, int right) {
        try {
            return Math.multiplyExact(left, right);
        } catch (ArithmeticException exception) {
            throw invalid();
        }
    }

    private long add(long left, long right) {
        try {
            return Math.addExact(left, right);
        } catch (ArithmeticException exception) {
            throw invalid();
        }
    }

    private long subtract(long left, long right) {
        try {
            return Math.subtractExact(left, right);
        } catch (ArithmeticException exception) {
            throw invalid();
        }
    }

    private long percentOf(long amount, int percent) {
        return add(Math.multiplyExact(amount / 100, percent), ((amount % 100) * percent) / 100);
    }

    private InvalidPricingRequestException invalid() {
        return new InvalidPricingRequestException();
    }

    private record AllocationRemainder(int index, BigInteger remainder) {
    }
}

src/test/java/com/samebrief/pricing/PricingControllerTest.java

package com.samebrief.pricing;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

@SpringBootTest
@AutoConfigureMockMvc
class PricingControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void appliesItemDiscountsAndUsesDiscountedMerchandiseForShipping() throws Exception {
        price(lines(line("food-1", "FOOD", 10_000, 2), line("book-1", "BOOK", 15_000, 1)), "REGULAR", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.itemSubtotal").value(35_000))
                .andExpect(jsonPath("$.itemDiscount").value(1_500))
                .andExpect(jsonPath("$.memberDiscount").value(0))
                .andExpect(jsonPath("$.couponDiscount").value(0))
                .andExpect(jsonPath("$.merchandiseTotal").value(33_500))
                .andExpect(jsonPath("$.shippingFee").value(3_000))
                .andExpect(jsonPath("$.grandTotal").value(36_500))
                .andExpect(jsonPath("$.lines[1].itemDiscount").value(1_500))
                .andExpect(jsonPath("$.lines[1].finalAmount").value(13_500));
    }

    @Test
    void floorsFashionDiscountWhenQuantityIsAtLeastThree() throws Exception {
        price(lines(line("fashion", "FASHION", 10_001, 3)), "REGULAR", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.itemDiscount").value(4_500))
                .andExpect(jsonPath("$.lines[0].itemDiscount").value(4_500))
                .andExpect(jsonPath("$.lines[0].finalAmount").value(25_503));
    }

    @Test
    void allocatesGoldDiscountByLargestRemainderInRequestOrder() throws Exception {
        price(lines(line("first", "FOOD", 10_001, 1), line("second", "FOOD", 10_010, 1)), "GOLD", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.memberDiscount").value(1_000))
                .andExpect(jsonPath("$.lines[0].memberDiscount").value(500))
                .andExpect(jsonPath("$.lines[1].memberDiscount").value(500))
                .andExpect(jsonPath("$.lines[0].finalAmount").value(9_501))
                .andExpect(jsonPath("$.lines[1].finalAmount").value(9_510))
                .andExpect(jsonPath("$.grandTotal").value(22_011));
    }

    @Test
    void allocatesFixedCouponExactlyAcrossLines() throws Exception {
        price(lines(line("a", "FOOD", 10_000, 1), line("b", "FOOD", 10_000, 1), line("c", "FOOD", 10_000, 1)), "REGULAR", "FIXED_5000", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.couponDiscount").value(5_000))
                .andExpect(jsonPath("$.lines[0].couponDiscount").value(1_667))
                .andExpect(jsonPath("$.lines[1].couponDiscount").value(1_667))
                .andExpect(jsonPath("$.lines[2].couponDiscount").value(1_666))
                .andExpect(jsonPath("$.grandTotal").value(28_000));
    }

    @Test
    void capsRateCouponAndAllocatesRemainderInInputOrder() throws Exception {
        price(lines(line("a", "FOOD", 40_000, 1), line("b", "FOOD", 40_000, 1), line("c", "FOOD", 40_000, 1)), "REGULAR", "RATE_10", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.couponDiscount").value(10_000))
                .andExpect(jsonPath("$.lines[0].couponDiscount").value(3_334))
                .andExpect(jsonPath("$.lines[1].couponDiscount").value(3_333))
                .andExpect(jsonPath("$.lines[2].couponDiscount").value(3_333))
                .andExpect(jsonPath("$.shippingFee").value(0))
                .andExpect(jsonPath("$.grandTotal").value(110_000));
    }

    @Test
    void fixedCouponCanCauseShippingToBeCharged() throws Exception {
        price(lines(line("food", "FOOD", 52_000, 1)), "REGULAR", "FIXED_5000", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.merchandiseTotal").value(47_000))
                .andExpect(jsonPath("$.shippingFee").value(3_000))
                .andExpect(jsonPath("$.grandTotal").value(50_000));
    }

    @Test
    void remoteOrdersAlwaysAddRemoteSurcharge() throws Exception {
        price(lines(line("food", "FOOD", 60_000, 1)), "REGULAR", "NONE", "REMOTE")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.shippingFee").value(5_000))
                .andExpect(jsonPath("$.grandTotal").value(65_000));
    }

    @Test
    void excludesBooksFromGoldDiscountAndHandlesNoEligibleLine() throws Exception {
        price(lines(line("book", "BOOK", 10_000, 1), line("food", "FOOD", 20_000, 1)), "GOLD", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.memberDiscount").value(1_000))
                .andExpect(jsonPath("$.lines[0].memberDiscount").value(0))
                .andExpect(jsonPath("$.lines[0].finalAmount").value(9_000))
                .andExpect(jsonPath("$.lines[1].memberDiscount").value(1_000))
                .andExpect(jsonPath("$.lines[1].finalAmount").value(19_000))
                .andExpect(jsonPath("$.grandTotal").value(31_000));

        price(lines(line("book", "BOOK", 10_000, 1)), "GOLD", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.memberDiscount").value(0))
                .andExpect(jsonPath("$.lines[0].memberDiscount").value(0))
                .andExpect(jsonPath("$.grandTotal").value(12_000));
    }

    @Test
    void doesNotApplyCouponsBelowTheirThresholds() throws Exception {
        price(lines(line("food", "FOOD", 29_999, 1)), "REGULAR", "FIXED_5000", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.couponDiscount").value(0))
                .andExpect(jsonPath("$.grandTotal").value(32_999));
        price(lines(line("food", "FOOD", 49_999, 1)), "REGULAR", "RATE_10", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.couponDiscount").value(0))
                .andExpect(jsonPath("$.grandTotal").value(52_999));
    }

    @Test
    void requiresThreeFashionItemsAndMakesShippingFreeAtTheThreshold() throws Exception {
        price(lines(line("fashion", "FASHION", 10_000, 2)), "REGULAR", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.itemDiscount").value(0))
                .andExpect(jsonPath("$.grandTotal").value(23_000));
        price(lines(line("food", "FOOD", 25_000, 2)), "REGULAR", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.shippingFee").value(0))
                .andExpect(jsonPath("$.grandTotal").value(50_000));
    }

    @Test
    void rejectsInvalidRequestsWithTheContractedError() throws Exception {
        assertInvalid("{\"lines\":[],\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":\"LOCAL\"}");
        assertInvalid("{\"lines\":null,\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":\"LOCAL\"}");
        assertInvalid(request(lines(line("   ", "FOOD", 1, 1)), "REGULAR", "NONE", "LOCAL"));
        assertInvalid(request(lines(line("food", "FOOD", -1, 1)), "REGULAR", "NONE", "LOCAL"));
        assertInvalid(request(lines(line("food", "FOOD", 1, 0)), "REGULAR", "NONE", "LOCAL"));
        assertInvalid("{\"lines\":[{\"productId\":\"food\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1}],\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":null}");
        assertInvalid("{\"lines\":[{\"productId\":\"food\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1}],\"memberLevel\":\"PLATINUM\",\"couponType\":\"NONE\",\"region\":\"LOCAL\"}");
        assertInvalid(request(lines(line("food", "FOOD", Long.MAX_VALUE, 2)), "REGULAR", "NONE", "LOCAL"));
        assertInvalid(request(lines(line("first", "FOOD", Long.MAX_VALUE, 1), line("second", "FOOD", Long.MAX_VALUE, 1)), "REGULAR", "NONE", "LOCAL"));
        assertInvalid("{\"lines\":[{\"productId\":\"food\",\"category\":\"FOOD\",\"unitPrice\":9223372036854775808,\"quantity\":1}],\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":\"LOCAL\"}");
    }

    @Test
    void acceptsZeroValueLinesWithoutAZeroDivisionError() throws Exception {
        price(lines(line("free", "FOOD", 0, 1), line("book", "BOOK", 10_000, 1)), "GOLD", "NONE", "LOCAL")
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.memberDiscount").value(0))
                .andExpect(jsonPath("$.lines[0].finalAmount").value(0))
                .andExpect(jsonPath("$.grandTotal").value(12_000));
    }

    private void assertInvalid(String body) throws Exception {
        mockMvc.perform(post("/api/v1/orders/price").contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_PRICING_REQUEST"))
                .andExpect(jsonPath("$.message").value("주문 정보를 확인해 주세요."));
    }

    private ResultActions price(String lines, String memberLevel, String couponType, String region) throws Exception {
        return mockMvc.perform(post("/api/v1/orders/price")
                .contentType(MediaType.APPLICATION_JSON)
                .content(request(lines, memberLevel, couponType, region)));
    }

    private String request(String lines, String memberLevel, String couponType, String region) {
        return "{\"lines\":" + lines + ",\"memberLevel\":\"" + memberLevel + "\",\"couponType\":\""
                + couponType + "\",\"region\":\"" + region + "\"}";
    }

    private String lines(String... values) {
        return "[" + String.join(",", values) + "]";
    }

    private String line(String productId, String category, long unitPrice, int quantity) {
        return "{\"productId\":\"" + productId + "\",\"category\":\"" + category + "\",\"unitPrice\":"
                + unitPrice + ",\"quantity\":" + quantity + "}";
    }
}