공공부하자개발 · 영어 학습 노트
자바
미니 프로젝트쇼핑몰 정산 시스템 4단계0/4 완료
  • 011단계: 콘솔 주문 관리 (초급 기술)
  • 022단계: 객체지향 리팩터링 (중급 기술)
  • 033단계: 모던 자바 적용 (고급 기술)
  • 044단계: 일마감 정산 배치 (배치 기술)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 미니 프로젝트 › 04 / 4

4단계: 일마감 정산 배치 (배치 기술)

섹션 6진행 0 / 4
1이번 단계의 목표2설계3구현 따라가기4실행 결과와 해설5확장 과제6다음 단계로‹ 이전다음 ›

5. 확장 과제

과제 1: 스킵 로그 파일

스킵된 행을 data/skipped_<날짜>.csv에 lineNo,reason,detail 형식으로 남겨라. 단, 롤백된 청크의 스킵은 파일에 남으면 안 된다. 즉 스킵 기록도 트랜잭션 안에 있어야 한다. JobState에 스킵 목록을 추가하고 merge(커밋) 시점에 파일에 append하라. 재시작 시 파일은 이어 쓴다.

정답 보기
java
// JobState에 추가
public record SkipRecord(long lineNo, String reason, String detail) {}
public final java.util.List<SkipRecord> skips = new java.util.ArrayList<>();   // 체크포인트에는 저장하지 않음

public void recordSkip(long lineNo, String reason, String detail) {
    skipped++;
    skipReasons.merge(reason, 1L, Long::sum);
    skips.add(new SkipRecord(lineNo, reason, detail));
}

// SettlementJob.run의 커밋 부분
committed.merge(work);
appendSkips(work.skips);                 // 커밋과 함께 — 롤백된 work의 skips는 여기 도달하지 않는다
checkpoint.save(committed);

private void appendSkips(List<JobState.SkipRecord> skips) throws IOException {
    if (skips.isEmpty()) return;
    try (BufferedWriter w = Files.newBufferedWriter(skipLog, StandardCharsets.UTF_8,
            StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
        for (JobState.SkipRecord s : skips) {
            w.write(s.lineNo() + "," + s.reason() + "," + s.detail().replace(',', ';'));
            w.newLine();
        }
    }
}
// processChunk의 catch: work.recordSkip(raw.lineNo(), e.reason(), e.getMessage());

merge에서 work.skips를 committed.skips에 합치지 않는 것에 주의하라. 합치면 committed가 스킵 1,277건을 메모리에 계속 들고 있게 된다. 파일에 쓰고 버리는 것이 청크 처리의 원칙이다.

엄밀히는 "append 성공 후 checkpoint.save 전에 죽으면" 재시작 시 그 청크의 스킵이 두 번 기록된다 — 파일 append와 체크포인트 저장은 하나의 트랜잭션이 아니기 때문이다. 재시작 시 lineNo로 중복을 제거하거나, 스킵 로그를 체크포인트와 같은 파일에 넣는 것이 완전한 해법이다.

과제 2: 멱등 게이트웨이

4.3에서 지적한 이중 결제를 막아라. PaymentGateway에 "이미 승인한 주문 ID → 승인 코드" 맵을 두고, 같은 주문 ID가 다시 오면 게이트웨이 호출 없이 저장된 승인 코드를 돌려주게 하라(멱등키). 실제로 이중 호출이 몇 건 방지됐는지 세어 리포트에 출력하라. 게이트웨이 인스턴스는 1차·2차 실행이 공유해야 한다(생성자로 주입).

정답 보기
java
// PaymentGateway 수정
private final java.util.Map<String, String> approved = new java.util.HashMap<>();
private int duplicatesPrevented = 0;

public String charge(OrderRecord order, int attempt) throws TransientFailure, Declined {
    String cached = approved.get(order.orderId());
    if (cached != null) {                          // 멱등키 적중: 재호출 없이 같은 결과
        duplicatesPrevented++;
        return cached;
    }
    calls++;
    // ... (기존 해시 검사) ...
    String code = "APV-" + Long.toHexString(a & 0xFFFFFF);
    approved.put(order.orderId(), code);
    return code;
}

public int duplicatesPrevented() { return duplicatesPrevented; }

// SettlementJob 생성자에 PaymentGateway gateway 매개변수 추가, Main에서 하나를 만들어 두 실행에 전달
// 출력 예:
//   이중 결제 방지: 약 493건   (청크 40의 500행 중 검증 통과 + 승인된 건수)

실제 시스템에서는 이 맵이 게이트웨이 서버 쪽에 있다(요청 헤더 Idempotency-Key). 클라이언트가 재시작하든 재시도하든 같은 키를 보내면 서버가 첫 결과를 돌려준다. 재시도(RetryTemplate)도 같은 원리로 보호된다 — 타임아웃이 "요청은 도달했지만 응답이 유실된" 경우일 수 있기 때문이다.

과제 3: 청크 크기 실험

Main이 args[0]으로 청크 크기를 받게 하고, 100 / 1,000 / 10,000으로 각각 돌려 (a) 체크포인트 저장 횟수, (b) 소요 시간, (c) 청크 40에서 장애가 났을 때 폐기되는 행 수를 표로 정리하라. crashAtChunk도 청크 크기에 맞춰 조정하라(전체의 약 40% 지점).

정답 보기
java
// Main 수정
int chunkSize = args.length > 0 ? Integer.parseInt(args[0]) : 1_000;
int totalChunks = ROWS / chunkSize;
int crashAt = Math.max(1, (int) (totalChunks * 0.4));
// ... new SettlementJob(input, chunkSize, checkpoint, crashAt) ...
// 리포트에 "체크포인트 저장 %d회" 추가: result.chunksCommitted 그대로

// 결과 예 (환경에 따라 다름):
// 청크 크기 | 체크포인트 저장 | 소요 시간 | 장애 시 폐기 행
// ---------|---------------|----------|--------------
//     100  |        1,000  |  가장 김  |          50
//   1,000  |          100  |   중간    |         500
//  10,000  |           10  |  가장 짧음 |       5,000

청크 크기는 "커밋 비용"과 "재작업 비용"의 절충이다. 커밋이 비싸면(원격 DB, 네트워크 파일) 크게, 재작업이 비싸면(외부 API 호출, 이중 처리 위험) 작게 잡는다. 정답은 없고 측정이 있을 뿐이다. 배치 레슨 03의 메모리 관점(청크가 클수록 메모리 사용 증가)도 함께 고려한다.

확장 과제
  • 과제 1: 스킵 로그 파일
  • 과제 2: 멱등 게이트웨이
  • 과제 3: 청크 크기 실험
이전 섹션4 실행 결과와 해설5 / 6다음 섹션6 다음 단계로