AI 개발 · 심화
AI 코딩 도구로 팀 개발하기 - Java 실무 저장소편
종합 실습 - 요구 하나를 PR 에서 병합까지
요구 정리→작업 쪼개기→테스트 먼저→AI 변경 반영→리뷰 대조표→CI 관문→인계 기록까지 한 바퀴, 완성 코드는 반품 기능 요구를 받아 위 단계를 모두 실행하고 단계별 결과를 출력한다
개발자KR · 원고 갱신
이 장에서 배우는 것
작은 쇼핑몰 주문 서비스에 반품 기능을 추가한다. 요구를 읽고 코드를 만드는 데서 끝내지 않고, 테스트와 리뷰 근거를 모아 병합 여부를 결정한다. 앞 장에서 정한 작업 범위와 중단 기준을 실제 변경 하나에 적용하는 실습이다. 여러 절차를 소개하는 대신, 같은 요구가 각 단계에서 어떤 결과물로 바뀌는지 끝까지 따라간다.
완성 프로그램은 개발 절차를 재현하는 실행 모형이다. 실제 원격 저장소에 접속하거나 AI를 호출하지 않는다. AI가 제출했다고 가정한 첫 변경과 검토 후 수정한 변경을 각각 실행하고, 테스트 결과에 따라 병합 가능 여부를 출력한다. 독자는 실행 결과를 읽으며 어느 근거가 다음 단계의 진행을 허용하는지 확인할 수 있다.
- 반품 요구를 입력 조건, 상태 변화, 실패 시 보존 조건으로 정리한다.
- 한 변경 요청(PR, Pull Request)을 검토 가능한 작업으로 나누고 테스트를 먼저 준비한다.
- AI의 설명과 실제 실행 결과를 대조하여 수정할 부분을 찾는다.
- 지속적 통합(CI, Continuous Integration) 관문을 통과한 변경만 병합 대상으로 삼는다.
- 다음 담당자가 재현할 수 있도록 결정 사항과 남은 범위를 인계 기록으로 남긴다.
문제 상황
팀의 주문 서비스는 결제 완료와 배송 완료를 구분한다. 창고 수량은 주문을 처리하면서 이미 줄어든 상태다. 이제 고객이 배송받은 상품 전체를 반품하면 결제 모의 객체에 환불을 기록하고 창고 수량을 되돌려야 한다. 화면이나 외부 결제 연동은 이번 변경에 포함하지 않는다.
요청 문장은 짧다. “배송 후 일주일 안에 반품할 수 있게 해 달라.” 그러나 이 문장만으로는 구현을 판단하기 어렵다. 배송일을 포함하는지, 일부 수량도 받는지, 같은 요청을 두 번 받으면 어떻게 하는지 정해져 있지 않다. 팀은 구현에 앞서 예제의 업무 규칙을 아래처럼 합의한다. 이 규칙은 실습을 위해 정한 정책이며 실제 상점의 운영 정책을 대신하지 않는다.
| 항목 | 합의한 조건 | 확인할 결과 |
|---|---|---|
| 접수 상태 | 결제와 배송을 마친 주문만 받는다 | 미결제 주문은 거절한다 |
| 접수 날짜 | 배송일부터 7일 뒤까지 양 끝 날짜를 포함한다 | 배송 전날과 8일 뒤는 거절한다 |
| 접수 수량 | 주문한 전체 수량만 받는다 | 일부 수량은 거절한다 |
| 반품 완료 | 환불 성공 후 재고와 주문 상태를 바꾼다 | 환불 실패 시 세 상태를 보존한다 |
| 중복 접수 | 완료한 주문의 재요청은 거절한다 | 환불과 재고 복구는 한 번만 일어난다 |
팀이 전달받은 첫 변경에는 “기간 검증과 중복 방지를 구현했다”는 설명이 붙어 있다. 하지만 설명을 읽는 것만으로는 실제 조건식이 있는지 알 수 없다. 프로그램은 이 변경을 후보 구현으로 보관하고, 동일한 테스트를 수정 구현에도 적용한다. 두 구현의 차이는 반품 기한의 상한을 검사하는 조건 하나다.
AI의 첫 제출 설명: 배송 완료 여부와 요청 수량을 검사하고 환불 후 재고를 복구했다. 날짜와 중복 요청도 처리했으므로 병합할 수 있다.
이 설명은 검증 결과가 아니라 검증할 주장이다. 특히 “날짜를 처리했다”는 문장이 배송 전 요청과 기한 초과 요청을 모두 막았다는 뜻인지는 코드를 실행해 확인해야 한다. 팀의 책임은 설명을 승인하는 것이 아니라, 요구와 실행 결과가 맞는지 확인하는 데 있다.
요구를 작업과 증거로 연결한다
작업을 나누는 기준은 파일 개수가 아니다. 각 작업이 끝났다는 근거를 따로 확인할 수 있어야 한다. 이번 요구는 규칙 확정, 테스트 준비, 서비스 변경, 검토와 기록의 네 작업으로 나눈다. 모두 같은 반품 기능을 완성하기 위한 작업이며 하나의 변경 요청에 묶는다. 저장소 전체의 상태 표현을 바꾸거나 결제 구조를 다시 설계하는 일은 포함하지 않는다.
테스트를 먼저 준비한다는 말은 구현 코드를 전혀 보지 않는다는 뜻이 아니다. 공개할 메서드의 입력과 반환값을 먼저 정하고, 그 계약에 맞는 검증을 구현보다 앞서 고정한다는 뜻이다. 여기서는 주문, 요청 수량, 접수 날짜를 받고 접수 성공 여부를 반환한다. 성공 여부만 검사하면 재고가 두 번 늘어나는 오류를 놓칠 수 있으므로 재고, 환불 횟수, 반품 상태도 함께 관찰한다.
첫 테스트는 배송일 접수다. 다음은 허용 마지막 날과 그 다음 날이다. 날짜 경계에 붙어 있는 두 사례를 함께 두면, 조건식의 방향과 포함 여부를 비교하기 쉽다. 나머지 사례는 배송 전 접수, 전체 수량이 아닌 요청, 미결제 주문, 환불 실패, 중복 접수다. 서로 다른 목적의 사례가 앞 사례의 상태를 물려받지 않도록 매번 새 주문과 모의 객체를 만든다.
실패 사례에는 공통 보존 조건이 있다. 접수가 거절되면 재고는 10개, 성공한 환불 횟수는 0회, 반품 완료 상태는 거짓이어야 한다. 이는 반환값만 거짓으로 돌려놓는 수정을 막는다. 환불이 실패했는데 이미 창고 수량이 늘었다면 고객에게 실패를 알렸더라도 업무 상태는 틀린 것이다.
이 실습의 결제 객체는 성공한 환불 횟수만 기록한다. 실패했을 때의 외부 부작용은 없다고 가정한다. 따라서 실행 결과가 입증하는 범위도 메모리 안의 단일 처리에 한정된다. 실제 결제 서버의 응답이 끊겼을 때 환불이 되었는지 모르는 문제까지 해결했다고 해석해서는 안 된다.
첫 변경을 실행하고 리뷰 대조표를 채운다
후보 구현은 배송 전 날짜를 거절하지만 기한의 끝을 검사하지 않는다. 이 경우 허용 마지막 날 테스트는 통과한다. 정상 사례만 실행했다면 설명과 코드가 일치한다고 판단하기 쉽다. 8일 뒤 접수 사례를 넣어야 누락이 드러난다. 프로그램은 후보 구현에서 이 사례의 실패를 출력하고, 수정 구현에서 같은 사례가 통과하는지 확인한다.
| 요구 | 첫 변경 관찰 | 수정 및 확인 근거 |
|---|---|---|
| 7일 뒤까지 접수 | 8일 뒤 요청도 접수한다 | 날짜 상한 검사 추가, 동일 사례 재실행 |
| 실패 시 상태 보존 | 환불 거절 시 상태가 유지된다 | 반환값과 세 상태를 함께 검사 |
| 중복 처리 방지 | 순차 재요청을 거절한다 | 두 번 호출해 환불 1회와 재고 12개 확인 |
| 기존 조회 유지 | 조회 문구 변경은 요구하지 않았다 | 같은 주문을 이전·이후 조회 함수에 전달 |
리뷰어는 실패 한 건을 찾았다고 바로 병합하지 않는다. 날짜 조건을 고친 뒤 전체 묶음을 다시 실행한다. 한 조건의 추가가 정상 요청까지 막을 수 있기 때문이다. 기존 주문 조회의 결과도 함께 비교한다. 새 기능에서 달라져야 할 결과는 요구에 맞게 달라져야 하고, 기존 조회처럼 유지해야 할 결과는 같은 입력에서 같아야 한다.
완성 코드의 두 조회 함수는 작은 비교 장치다. 이전 함수는 문자열 연결을, 이후 함수는 문자열 결합 메서드를 사용한다. 같은 주문에서 두 결과가 같은지 실행해 확인한다. 실제 저장소에서는 해당 위치에 변경 전 조회 결과를 고정한 검증을 둔다. 이 한 사례가 모든 기존 동작의 보존을 입증하는 것은 아니며, 이번 실습에서 명시적으로 확인한 범위만 기록한다.
반품 처리의 수정 전후는 결과가 같아야 하는 대상이 아니다. 기한 초과 입력은 후보에서 승인되고 수정 후 거절되어야 한다. 반면 배송일 접수와 마지막 날 접수는 두 구현 모두 승인해야 한다. 이처럼 의도한 변화와 유지할 동작을 구분해야 “전후 결과가 다르다”는 사실을 제대로 해석할 수 있다.
관문과 인계가 변경의 끝을 정한다
관문은 통과 조건을 코드로 표현한 것이다. 이 실습에서는 반품 검증 8개와 기존 조회 비교 1개가 모두 통과해야 한다. 일부만 통과했다면 병합 가능 값이 거짓이 된다. 후보의 실패는 학습을 위한 예상 결과지만, 수정 구현의 실패는 프로그램의 비정상 종료로 이어진다. 실패를 출력하고도 정상 종료하면 실행을 감싼 자동화가 성공으로 오해할 수 있다.
여기서 출력하는 병합은 실제 저장소 조작이 아닌 모형의 상태 전환이다. 컴파일은 독자가 실행하는 javac 명령이 담당하고, 실행 프로그램은 검증 결과를 집계한다. 팀 저장소에서는 같은 실행을 자동화에 연결하고 사람이 요구 충족 여부를 검토한다. 프로그램이 통과했다고 권한 있는 담당자의 판단까지 대신하는 것은 아니다.
인계 기록에는 수정한 내용뿐 아니라 아직 보장하지 않는 범위도 남긴다. 예제는 단일 실행 흐름에서 같은 주문을 순서대로 처리한다. 동시에 도착한 두 반품 요청, 재시작 뒤 중복 요청, 외부 결제 시스템과의 상태 불일치는 다루지 않는다. 이 항목들을 적어 두어야 다음 담당자가 순차 호출 검증을 동시 요청 보장으로 잘못 받아들이지 않는다.
실행 시각은 읽지 않는다. 접수 날짜는 고정값으로 전달한다. 출력에는 임시 경로나 실행 환경 정보도 넣지 않는다. 따라서 같은 소스를 실행하면 같은 순서와 같은 문구가 나온다. 기록의 재현성을 높이는 데 필요한 선택이며, 실제 날짜를 쓰는 서비스에서는 날짜를 공급하는 경계를 따로 두면 같은 원리를 적용할 수 있다.
완성 코드
아래 내용을 Main.java로 저장한다. 후보와 수정 구현을 한 파일에서 비교하기 위해 생성자의 논리값으로 기한 상한 검사를 선택한다. 이 선택지는 실습용 비교 장치다. 실제 서비스에 검증을 끄는 운영 설정으로 남기는 것은 이번 요구에 포함되지 않는다. 테스트는 반환값과 상태를 한 조건으로 묶고 check 함수에서 이름과 PASS 또는 FAIL을 출력한다.
Main.java
import java.time.LocalDate;
public class Main {
private static final LocalDate DELIVERED =
LocalDate.of(2026, 10, 1);
static final class Order {
final String id;
final int quantity;
final boolean paid;
final LocalDate deliveredOn;
boolean returned;
Order(String id, int quantity, boolean paid,
LocalDate deliveredOn) {
this.id = id;
this.quantity = quantity;
this.paid = paid;
this.deliveredOn = deliveredOn;
}
}
static final class Stock {
int quantity = 10;
void restore(int amount) {
quantity += amount;
}
}
static final class Payment {
final boolean acceptsRefund;
int refunds;
Payment(boolean acceptsRefund) {
this.acceptsRefund = acceptsRefund;
}
boolean refund(Order order) {
if (!acceptsRefund) {
return false;
}
refunds++;
return true;
}
}
static final class ReturnService {
final Stock stock;
final Payment payment;
final boolean checksDeadline;
ReturnService(Stock stock, Payment payment,
boolean checksDeadline) {
this.stock = stock;
this.payment = payment;
this.checksDeadline = checksDeadline;
}
boolean request(Order order, int quantity,
LocalDate requestedOn) {
if (order == null || requestedOn == null) {
return false;
}
if (!order.paid || order.deliveredOn == null
|| order.returned) {
return false;
}
if (quantity <= 0 || quantity != order.quantity) {
return false;
}
if (requestedOn.isBefore(order.deliveredOn)) {
return false;
}
if (checksDeadline
&& requestedOn.isAfter(
order.deliveredOn.plusDays(7))) {
return false;
}
if (!payment.refund(order)) {
return false;
}
stock.restore(quantity);
order.returned = true;
return true;
}
}
static final class Fixture {
final Order order;
final Stock stock = new Stock();
final Payment payment;
final ReturnService service;
Fixture(boolean checksDeadline, boolean paid,
boolean acceptsRefund) {
order = new Order("O-100", 2, paid, DELIVERED);
payment = new Payment(acceptsRefund);
service = new ReturnService(
stock, payment, checksDeadline);
}
boolean completed() {
return stock.quantity == 12
&& payment.refunds == 1
&& order.returned;
}
boolean untouched() {
return stock.quantity == 10
&& payment.refunds == 0
&& !order.returned;
}
}
static boolean check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
return condition;
}
static int runSuite(boolean checksDeadline) {
int passed = 0;
Fixture f = new Fixture(checksDeadline, true, true);
boolean accepted = f.service.request(f.order, 2, DELIVERED);
if (check(accepted && f.completed(), "배송일 접수")) {
passed++;
}
f = new Fixture(checksDeadline, true, true);
accepted = f.service.request(
f.order, 2, DELIVERED.plusDays(7));
if (check(accepted && f.completed(), "7일 뒤 접수")) {
passed++;
}
f = new Fixture(checksDeadline, true, true);
accepted = f.service.request(
f.order, 2, DELIVERED.plusDays(8));
if (check(!accepted && f.untouched(), "8일 뒤 거절")) {
passed++;
}
f = new Fixture(checksDeadline, true, true);
accepted = f.service.request(
f.order, 2, DELIVERED.minusDays(1));
if (check(!accepted && f.untouched(), "배송 전 거절")) {
passed++;
}
f = new Fixture(checksDeadline, true, true);
accepted = f.service.request(f.order, 1, DELIVERED);
if (check(!accepted && f.untouched(), "일부 수량 거절")) {
passed++;
}
f = new Fixture(checksDeadline, false, true);
accepted = f.service.request(f.order, 2, DELIVERED);
if (check(!accepted && f.untouched(), "미결제 거절")) {
passed++;
}
f = new Fixture(checksDeadline, true, false);
accepted = f.service.request(f.order, 2, DELIVERED);
if (check(!accepted && f.untouched(), "환불 실패 보존")) {
passed++;
}
f = new Fixture(checksDeadline, true, true);
boolean first = f.service.request(f.order, 2, DELIVERED);
boolean second = f.service.request(f.order, 2, DELIVERED);
if (check(first && !second && f.completed(), "중복 거절")) {
passed++;
}
return passed;
}
static String summaryBefore(Order order) {
return order.id + ":" + order.quantity + ":"
+ (order.paid ? "PAID" : "UNPAID");
}
static String summaryAfter(Order order) {
return String.join(":", order.id,
Integer.toString(order.quantity),
order.paid ? "PAID" : "UNPAID");
}
public static void main(String[] args) {
System.out.println("1. 요구 정리");
System.out.println("전체 반품, 배송일~7일 뒤, 환불 후 재고 복구");
System.out.println("2. 작업 쪼개기");
System.out.println("규칙 확정 / 테스트 준비 / 서비스 변경 / 검토와 기록");
System.out.println("3. 테스트 먼저");
System.out.println("반품 검증 8개와 기존 조회 비교 1개 준비");
System.out.println("4. AI 첫 변경 반영");
int candidatePassed = runSuite(false);
System.out.println("후보 결과: " + candidatePassed + "/8");
System.out.println("후보 병합: "
+ (candidatePassed == 8 ? "가능" : "차단"));
System.out.println("5. 리뷰 대조표");
System.out.println("기한 상한 누락 확인 / 상한 검사 추가");
System.out.println("6. CI 관문");
int fixedPassed = runSuite(true);
Order existing = new Order("O-100", 2, true, DELIVERED);
String before = summaryBefore(existing);
String after = summaryAfter(existing);
System.out.println("이전 조회: " + before);
System.out.println("이후 조회: " + after);
boolean same = check(before.equals(after), "기존 조회 동일");
int totalPassed = fixedPassed + (same ? 1 : 0);
System.out.println("최종 결과: " + totalPassed + "/9");
boolean mergeAllowed = totalPassed == 9;
System.out.println("관문: " + (mergeAllowed ? "통과" : "차단"));
if (!mergeAllowed) {
throw new IllegalStateException("최종 검증 실패");
}
System.out.println("7. 병합과 인계");
System.out.println("모형 병합: 완료");
System.out.println("변경: 반품 접수와 기한 상한 검사");
System.out.println("검증: 반품 8개와 기존 조회 비교 1개 통과");
System.out.println("재현: javac -d out *.java 후 java -cp out Main");
System.out.println("남은 범위: 부분 반품, 동시 요청, 외부 결제 연동");
}
}
줄별 해설
날짜 상수와 Order에서 배송일을 고정한다. Order는 주문 식별자, 주문 수량, 결제 여부, 배송일, 반품 완료 여부만 가진다. 이번 요구에 필요하지 않은 주소나 상품 설명은 생략했다. 반품 완료 여부만 변경할 수 있게 두어 어떤 값이 처리 과정에서 바뀌는지 드러낸다. 배송일이 없으면 배송 완료가 아니라는 뜻으로 사용한다.
Stock과 Payment는 관찰할 부작용을 담당한다. 시작 재고는 10개이며 주문 수량은 2개다. 따라서 정상 반품 뒤 재고는 12개가 된다. Payment의 refunds는 메서드 호출 시도 횟수가 아니라 성공한 환불 횟수다. acceptsRefund가 거짓이면 상태를 바꾸지 않고 실패한다. 금액 계산과 결제 수단은 이 모형에 들어 있지 않다.
ReturnService.request의 앞부분은 거절 조건을 모은다. 없는 주문이나 날짜를 거절한 뒤 결제, 배송, 기존 반품 상태를 확인한다. 다음으로 양수인 전체 주문 수량인지 검사한다. 이 검증들을 먼저 끝내므로 입력이 잘못된 요청은 환불 객체까지 도달하지 않는다. 입력 검증과 상태 변경이 어디서 갈리는지 읽기 쉽다.
날짜 검사 두 곳은 서로 다른 경계를 맡는다. isBefore는 배송일보다 이른 날짜를 막는다. isAfter는 배송일에서 7일 뒤보다 늦은 날짜를 막는다. 두 비교 모두 같은 날짜에서는 거짓이므로 시작일과 마지막 날은 허용된다. 후보 실행에서 checksDeadline이 거짓이면 두 번째 경계만 빠진다. 따라서 날짜 검사가 전혀 없는 구현과 구별된다.
환불 이후의 세 문장은 성공 경로다. 환불이 실패하면 즉시 반환한다. 성공하면 재고를 복구하고 반품 완료 상태를 기록한 뒤 참을 반환한다. 이 순서는 예제의 실패 보존 조건을 충족한다. 다만 환불 성공 뒤 프로세스가 중단되는 상황까지 원자적으로 처리하는 구조는 아니다. 모의 객체의 한계를 인계에 남기는 이유다.
Fixture는 테스트마다 새 상태를 만드는 묶음이다. completed는 성공 후 세 조건을 함께 확인하고 untouched는 실패 후 세 조건을 확인한다. 검증식을 여러 곳에 복사하지 않으면서 업무 상태를 읽을 수 있는 이름으로 묶었다. 재고만 맞아도 통과하거나 반환값만 맞아도 통과하는 느슨한 검증을 피한다.
check와 runSuite는 작은 테스트 실행기다. check는 조건을 출력하고 그대로 반환한다. runSuite는 그 반환값이 참일 때만 통과 수를 늘린다. 중간에 실패해도 다음 사례를 실행하므로 한 번의 실행에서 다른 조건의 결과도 볼 수 있다. 후보의 실패를 수정 구현의 실패 집계에 섞지 않도록 두 실행의 지역 변수도 나누었다.
중복 검증은 첫 호출 성공, 두 번째 호출 거절, 최종 상태 유지의 세 부분으로 구성된다. 두 번째 반환값만 검사하면 첫 호출이 실패한 구현도 통과할 수 있다. completed를 다시 검사하므로 두 번째 요청이 재고나 환불 횟수를 늘렸는지도 드러난다. 검증의 이름이 같더라도 무엇을 관찰하는지에 따라 검출할 수 있는 오류가 달라진다.
조회 비교와 main의 마지막 부분은 기존 동작 보존과 관문 결정을 담당한다. 같은 Order를 두 함수에 전달하고 문자열 내용을 equals로 비교한다. 반품 검증 8개와 이 비교가 모두 통과해야 인계 단계로 간다. 하나라도 실패하면 예외로 종료하므로 실제로 수행하지 못한 병합이나 인계 완료를 출력하지 않는다.
실행 결과
Main.java만 둔 디렉터리에서 다음 명령을 실행한다. 컴파일 명령이 성공한 뒤 실행 명령을 수행한다. 경고까지 확인하려면 첫 명령을 javac -Xlint:all -d out *.java로 바꿀 수 있다. 프로그램은 파일을 만들지 않으며 실행을 마치면 바로 종료한다.
javac -d out *.java
java -cp out Main
예상 출력은 다음과 같다. 후보 실행의 FAIL은 누락한 조건을 찾았다는 결과다. 최종 관문에서는 같은 사례가 PASS로 바뀌어야 한다.
1. 요구 정리
전체 반품, 배송일~7일 뒤, 환불 후 재고 복구
2. 작업 쪼개기
규칙 확정 / 테스트 준비 / 서비스 변경 / 검토와 기록
3. 테스트 먼저
반품 검증 8개와 기존 조회 비교 1개 준비
4. AI 첫 변경 반영
PASS 배송일 접수
PASS 7일 뒤 접수
FAIL 8일 뒤 거절
PASS 배송 전 거절
PASS 일부 수량 거절
PASS 미결제 거절
PASS 환불 실패 보존
PASS 중복 거절
후보 결과: 7/8
후보 병합: 차단
5. 리뷰 대조표
기한 상한 누락 확인 / 상한 검사 추가
6. CI 관문
PASS 배송일 접수
PASS 7일 뒤 접수
PASS 8일 뒤 거절
PASS 배송 전 거절
PASS 일부 수량 거절
PASS 미결제 거절
PASS 환불 실패 보존
PASS 중복 거절
이전 조회: O-100:2:PAID
이후 조회: O-100:2:PAID
PASS 기존 조회 동일
최종 결과: 9/9
관문: 통과
7. 병합과 인계
모형 병합: 완료
변경: 반품 접수와 기한 상한 검사
검증: 반품 8개와 기존 조회 비교 1개 통과
재현: javac -d out *.java 후 java -cp out Main
남은 범위: 부분 반품, 동시 요청, 외부 결제 연동
결과에서 눈여겨볼 부분은 통과 개수 자체보다 실패가 수정으로 이어지는 연결이다. 후보에서 날짜 경계 하나가 실패했고, 리뷰 기록이 그 조건을 지적했으며, 수정 후 같은 테스트가 통과했다. 기존 조회도 동일한 입력에서 같은 문자열을 냈다. 이 연결이 있어야 변경 설명을 실행 근거와 함께 검토할 수 있다.
실무에서 자주 틀리는 것
마지막 날을 거절하는 조건을 쓴다
다음은 각각 독립 실행할 수 있는 작은 비교 프로그램이다. 틀린 예는 “마지막 날보다 이전”만 허용하므로 마지막 날 자체를 거절한다. 배송일이 1일이면 8일도 허용해야 한다는 합의와 맞지 않는다.
class WrongBoundary {
public static void main(String[] args) {
java.time.LocalDate delivered =
java.time.LocalDate.of(2026, 10, 1);
java.time.LocalDate requested = delivered.plusDays(7);
boolean allowed = requested.isBefore(delivered.plusDays(7));
System.out.println(allowed);
}
}
고친 예는 “마지막 날보다 늦지 않다”로 표현한다. 같은 입력에서 틀린 예는 false, 고친 예는 true를 출력한다. 이 코드는 상한만 비교하는 예이며 전체 서비스의 하한 검사는 그대로 필요하다.
class CorrectBoundary {
public static void main(String[] args) {
java.time.LocalDate delivered =
java.time.LocalDate.of(2026, 10, 1);
java.time.LocalDate requested = delivered.plusDays(7);
boolean allowed = !requested.isAfter(delivered.plusDays(7));
System.out.println(allowed);
}
}
거절 결과만 보고 상태를 확인하지 않는다
틀린 예는 환불 성공 여부와 관계없이 재고를 먼저 늘린다. 같은 환불 실패 입력에서 거절을 나타내는 false와 재고 12를 출력한다. 호출자는 거절을 받았지만 창고 수량은 바뀌었다.
class WrongOrder {
public static void main(String[] args) {
int stock = 10;
boolean refunded = false;
stock += 2;
System.out.println(refunded + ":" + stock);
}
}
고친 예는 환불 성공 때만 재고를 바꾸므로 false:10을 출력한다. 이 비교는 반환값이 같아도 상태가 다를 수 있음을 보여 준다. 완성 코드의 untouched 검증이 이 차이를 관찰한다.
class CorrectOrder {
public static void main(String[] args) {
int stock = 10;
boolean refunded = false;
if (refunded) {
stock += 2;
}
System.out.println(refunded + ":" + stock);
}
}
실패를 출력한 실행을 성공으로 끝낸다
틀린 예는 관문 실패를 출력한 뒤 정상 종료한다. 문구를 읽는 사람은 문제를 알 수 있지만 종료 상태만 확인하는 자동화에는 실패가 전달되지 않는다.
class WrongGate {
public static void main(String[] args) {
boolean passed = false;
System.out.println(passed ? "PASS" : "FAIL");
}
}
고친 예는 같은 실패 입력에서 FAIL을 출력한 뒤 예외로 종료한다. 완성 코드에서는 후보 실패를 관찰하는 단계와 최종 관문을 구별하고, 최종 관문 실패에만 이 종료 규칙을 적용한다.
class CorrectGate {
public static void main(String[] args) {
boolean passed = false;
System.out.println(passed ? "PASS" : "FAIL");
if (!passed) {
throw new IllegalStateException("검증 실패");
}
}
}
한눈에 보기
| 단계 | 남기는 것 | 다음 단계의 조건 |
|---|---|---|
| 요구 정리 | 날짜·수량·상태·실패 보존 규칙 | 기대 결과를 말할 수 있다 |
| 작업 쪼개기 | 규칙, 테스트, 변경, 검토의 작업 목록 | 변경 범위를 설명할 수 있다 |
| 테스트 먼저 | 경계 입력과 상태 관찰 조건 | 틀린 구현을 거절할 수 있다 |
| 변경과 리뷰 | 제출 주장과 실행 근거의 대조 | 불일치가 수정되었다 |
| 관문 | 전체 검증 결과와 실패 종료 | 필수 검증이 모두 통과했다 |
| 병합과 인계 | 변경, 재현법, 검증 범위, 남은 작업 | 다음 담당자가 다시 확인할 수 있다 |
이 실습에서는 작업 목록과 인계문을 출력 문자열로 표현했다. 실제 팀에서는 변경 요청의 본문과 저장소 기록에 같은 내용을 남기면 된다. 형식을 늘리는 것보다 요구, 코드, 검증 결과가 서로 가리키는지 확인하는 일이 먼저다. 특히 “테스트 통과”라는 한 문장 대신 어떤 경계와 상태를 검사했는지 남겨야 다음 사람이 검증의 범위를 알 수 있다.
연습 문제
- 접수 날짜가 없을 때와 배송일이 없을 때를 별도 사례로 추가하라. 각 사례는 반환값과 세 상태를 확인해야 한다. 최종 검증 개수와 관문 조건, 인계문도 함께 갱신하라.
- 검토자가 “날짜 조건 두 개를 하나로 줄이자”며 배송 전 검사 삭제를 제안했다. 기존 테스트 중 어떤 사례가 이 변경을 막는지 설명하고, 후보 구현의 기한 상한 누락과 어떤 차이가 있는지 적어라.
- 같은 주문의 반품 요청이 두 실행 흐름에서 동시에 들어올 수 있게 요구가 바뀌었다. 현재 중복 테스트만으로 충분한지 판단하고, 이번 변경과 분리할 후속 작업의 요구와 검증 기준을 작성하라.
- 인계문을 읽은 담당자가 “부분 반품도 수량만 바꾸면 된다”고 말했다. 현재 상태 모델에서 부족한 정보 두 가지와 추가해야 할 실패 사례 한 가지를 설명하라.
정답과 해설
접수 날짜 없음은 새 Fixture의 request에 null을 전달한다. 배송일 없음은 배송일이 null인 새 Order를 만들어 같은 서비스에 전달한다. 두 호출 모두 거짓이어야 하며 재고 10개, 성공한 환불 0회, 해당 주문의 returned가 거짓인지 확인한다. 새 Order를 사용한 사례에서는 기존 Fixture.order의 상태를 대신 검사하면 안 된다. 반품 검증은 10개, 조회 비교를 포함한 최종 검증은 11개가 된다. runSuite를 공유하므로 후보 실행도 9/10으로 바뀌고, 수정 실행은 10/10이어야 한다. 후보 집계 표시, 최종 집계, 관문 기준, 인계문을 함께 바꾼다.
배송 전 거절 사례가 삭제를 막는다. 기한 상한만 검사하면 배송 전날도 “마지막 날보다 늦지 않다”는 조건을 만족한다. 기존 후보는 반대로 하한을 검사하고 상한을 놓쳤다. 두 오류는 서로 다른 입력에서 드러난다. 날짜 검증이 있다는 사실보다 허용 구간의 양 끝을 각각 검사하는지가 중요하다.
현재 테스트는 첫 호출이 끝난 다음 두 번째 호출을 실행하므로 동시에 returned를 읽는 상황을 다루지 않는다. 두 실행 흐름이 모두 거짓을 읽고 환불할 가능성을 이 테스트로 배제할 수 없다. 후속 작업에는 같은 주문에 대한 동시 접수에서도 환불과 재고 복구가 한 번만 성공한다는 요구를 적는다. 검증은 처리 시작을 조율하여 두 요청이 경쟁하게 만들고 최종 환불 횟수, 재고, 주문 상태를 확인해야 한다. 외부 결제까지 포함하면 주문별 중복 처리 방지 방식과 실패 복구 규칙도 함께 정해야 한다.
주문에 반품 완료 여부만 있으므로 누적 반품 수량과 남은 반품 가능 수량을 표현하기 어렵다. 결제 객체에도 수량별 환불 금액이나 누적 환불액이 없다. 이미 1개를 반품한 2개짜리 주문에서 다시 2개를 요청하는 사례를 추가할 수 있다. 이 요청은 거절되고 기존 반품 수량, 환불액, 재고가 보존되어야 한다. 현재의 전체 반품 규칙을 단순히 완화하면 이러한 상태를 기록할 자리가 없다.
책의 마지막 실습에서 남기는 결과는 반품 메서드 하나만이 아니다. 요구의 해석, 잘못된 변경을 막은 테스트, 수정 이유, 통과한 검증과 남은 범위가 함께 남는다. AI가 만든 변경도 이 연결 안에 들어오면 팀이 검토하고 이어받을 수 있는 작업이 된다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.