Repository 설계 표준에서 대량 연산을 Repository 밖으로 빼면서 이렇게 정했다.
대량 연산은
Domain을 거치지 않는다.
그런데 같은 글의 정리에 이 문장도 같이 있다.
조건이 곧 규칙인 것만 배치로 처리하고, 정책 판단이 필요하면
Domain을 하나씩 거친다.
두 문장이 부딪힌다. 뒤 문장을 배치로 읽으면 앞 문장과 정면으로 어긋나고, “배치 말고 일반 경로로 하라”로 읽으면 그 글이 든 예시(등급별 할인 재산정)를 Controller 요청으로 처리하라는 말이 된다. 어느 쪽으로도 성립하지 않는다.
문제를 코드로 세우면 이렇다.
// 안 A — 벌크. 5만 건이 UPDATE 한 방에 끝난다. 대신 규칙을 검증할 자리가 없다
bulkMapper.bulkUpdateStatus(targets);
// 안 B — Domain을 하나씩. 규칙은 지켜지지만 5만 번 돈다
targets.forEach(order -> orderRepository.save(order.expire()));
답은 “둘 다 필요하다”였다. 그런데 표준은 안 A만 정의하고, 안 B는 문장 하나로 존재만 인정한 채 어디서 어떻게 도는지 정하지 않았다. 이 글에서 그 빈자리를 채운다.
먼저: “배치”가 두 가지를 가리키고 있었다
모순의 원인은 규칙이 없어서가 아니라 한 단어가 두 가지를 뜻하고 있어서였다.
| 벌크 | Domain 순회 잡 |
|
|---|---|---|
| 하는 일 | 벌크 UPDATE/INSERT | Domain을 하나씩 로드해 정책 적용 |
| 예 | PENDING 30일 → EXPIRED |
등급별 할인 재산정 |
| 사는 곳 | {도메인}/batch |
정한 적 없음 |
ErrorCode |
안 붙임 | Domain이 붙여서 던진다 |
{도메인}/batch와 거기 딸린 예외·로그 규칙은 전부 왼쪽 칸을 위해 쓴 것이다. 그런데 이름이 “배치”라는 이유로 오른쪽 칸에도 그대로 적용된다. 예를 들어 예외의 종류는 “ErrorCode를 배치 실패에 붙이지 않는다”고 못 박았는데, 오른쪽 칸은 Domain을 거치므로 ErrorCode가 붙은 BusinessException이 그냥 올라온다.
답의 절반은 이미 다른 표준에 적혀 있었다
오른쪽 칸이 무엇을 호출하는지는 사실 두 표준이 이미 전제하고 있었다.
- 계층별 검증 표준 — “HTTP가 아닌 진입점(배치, 스케줄러, CLI 등)에서
Command를 만들 때도 그 진입점이 자기 방식으로 형식 검증을 마쳐야 한다” - Service 계층의 assert — “계약을 지키는 책임은 각 진입점(Controller, 배치, CLI)에 있고”
두 글 모두 “배치가 Command를 만들어 Service에 넣는다”를 당연하게 깔고 있다. 그런데 패키지 구조 표준은 정반대로 정해놨다 — “batch는 domain과 common만 참조한다.” application이 안 보이니 Command도 UseCase도 쓸 수 없다.
검증·assert 쪽은 오른쪽 칸을 상상하며 썼고, 패키지·Repository 쪽은 왼쪽 칸만 보고 썼다. 그래서 두 규칙이 같은 단어를 붙들고 서로를 부정하고 있었다.
원칙 1. 가르는 질문은 “도메인의 판단이 개입하는가”다
경로를 고를 때 묻는 것은 성능이 아니다.
"PENDING이 30일 지나면 EXPIRED" → 판단이 없다. 조건이 곧 규칙이다 → 벌크
"등급별 할인을 재산정" → Domain이 판단할 정책이 있다 → 배치
Domain을 거치는지 여부는 이 답에 따라오는 결과이지, 고를 때 보는 기준이 아니다. 순서를 뒤집으면 “빠르니까 벌크로 하자”가 되고, 그 순간 판단이 필요한 연산에서 규칙을 검증할 자리가 사라진다.
거래는 이렇다.
- 벌크 — 빠르다. 대신 비즈니스 규칙을 검증할 자리가 구조적으로 없다
- 배치 — 느리다. 대신 요청 경로와 똑같이 규칙이 보장된다
느린 것이 문제라면 애그리거트 경계나 정책 자체를 다시 볼 신호다. 검증을 건너뛰어 속도를 사는 것은 답이 아니라는 판단은 계층별 검증 표준에서 이미 세웠고, 여기서도 그대로 간다.
원칙 2. 이름을 실체에 맞춘다
기존 {도메인}/batch는 실제로는 벌크 전용이었다. 이름을 바로잡고 자리를 하나 늘린다.
order/
├── api/
├── application/
├── domain/
├── infra/
├── bulk/ ← 기존 batch. Domain을 거치지 않는 벌크 연산
└── batch/ ← 신설. Domain을 하나씩 거치는 잡
bulk에 들어가는 것(XxxBulkMapper, XxxBulk{의도}Writer, XxxBulkMapperParam)과 그 규칙은 Repository 설계 표준에서 정한 그대로다. 바뀐 것은 이름뿐이고, Repository 구현체로부터 격리한다는 이유도 그대로다.
두 패키지 모두 클래스는 package-private이다. 그래서 Job/Step 설정 클래스도 조립 대상과 같은 패키지에 둔다 — 밖에 두면 애초에 조립이 안 된다. 기존 표준이 “Step 설정에서만 조립된다”고 적으면서 그 설정이 어디 사는지는 정하지 않았던 자리다.
원칙 3. 배치는 Command를 만들어 UseCase를 부른다
XxxUseCase"] U --> DOM["XxxDomain
정책 판단"] B["bulk
XxxBulkMapper"] -.->|"Domain 없음"| DB[("DB")] style R fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#ed8936,stroke-width:2px,color:#e2e8f0 style P fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style U fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style DOM fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0 style B fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0 style DB fill:#1a202c,stroke:#a0aec0,stroke-width:2px,color:#e2e8f0
batch는 application, domain, common을 본다. api와 infra, 그리고 bulk는 보지 않는다.
ItemReader는domain의 Repository로 대상을 페이징 조회한다. 읽기까지UseCase를 거치게 하면 대량 조회에ServiceResult봉투가 끼고, 배치 때문에 생기는 조회UseCase가application에 늘어난다- 처리는
XxxCommand를 만들어XxxUseCase를 호출한다. Repository로 직접 저장하지 않는다
// order/batch — package-private
@Component
@RequiredArgsConstructor
class OrderDiscountProcessor implements ItemProcessor<OrderDomain, RecalculateDiscountCommand> {
@Override
public RecalculateDiscountCommand process(OrderDomain order) {
return RecalculateDiscountCommand.of(order.getId(), order.getUserId());
}
}
@Component
@RequiredArgsConstructor
class OrderDiscountWriter implements ItemWriter<RecalculateDiscountCommand> {
private final OrderCommandUseCase orderCommandUseCase; // application의 공개 표면
@Override
public void write(Chunk<? extends RecalculateDiscountCommand> chunk) {
chunk.forEach(orderCommandUseCase::recalculateDiscount);
}
}
읽기는 가볍게, 쓰기는 규칙을 거친다. 판단이 일어나는 자리만 application을 통과시키면 되고, 대상을 고르는 조회까지 통과시킬 이유는 없다.
원칙 4. 트랜잭션 경계는 Service가 아니라 chunk다
여기가 요청 경로와 다른 유일한 지점이라 반드시 적어둬야 한다.
Spring Batch의 chunk 지향 Step은 chunk 하나를 트랜잭션으로 감싼다. 그 안에서 @Transactional(기본 REQUIRED)인 Service를 부르면 새 트랜잭션이 열리지 않고 chunk 트랜잭션에 참여한다.
// chunk(100)이면 100건이 한 트랜잭션이다
// 99번째가 실패하면 앞의 98건도 함께 롤백된다
.<OrderDomain, RecalculateDiscountCommand>chunk(100, txManager)
요청 경로에서는 “한 요청 = 한 트랜잭션”이라 Service의 @Transactional이 곧 경계였다. 배치에서는 그 경계가 Service 손을 떠난다. Service 코드를 그대로 재사용한다는 것이 트랜잭션까지 그대로라는 뜻은 아니다.
한 건 단위 커밋이 필요하면 chunk 크기를 1로 둘 수 있다. 다만 커밋이 건수만큼 일어나므로, 그건 배치로 얻으려던 것을 상당 부분 되돌려주는 선택이다.
원칙 5. 실패는 타입이 아니라 ErrorCode로 가른다
Spring Batch의 skip/retry는 예외 타입으로 분기한다.
.faultTolerant().skip(BusinessException.class).skipLimit(100)
그런데 이 경로로 올라오는 것은 전부 BusinessException 하나다. 예외 처리 표준에서 “구체 예외 클래스를 만들지 않고 구분은 ErrorCode로 한다”고 정한 대가가 여기서 나타난다. 위처럼 타입만 보고 건너뛰면 결제사 장애(502·503·504)까지 조용히 넘어간다.
벌크 경로에서 “예외를 번역하지 말고 원본 타입을 올린다”고 정한 이유가 정확히 이것이었는데, 이쪽은 반대로 이미 다 뭉쳐진 상태로 들어온다. 그래서 SkipPolicy를 직접 구현한다.
// order/batch — package-private
class BusinessErrorSkipPolicy implements SkipPolicy {
@Override
public boolean shouldSkip(Throwable t, long skipCount) {
if (t instanceof BusinessException e) {
return e.getErrorCode().getStatus() < 500; // 4xx만 건너뛴다
}
return false;
}
}
4xx는 도메인 규칙 위반이므로 건너뛰고, 5xx는 시스템 문제이므로 Step을 실패시킨다. 5만 건 중 규칙에 안 맞는 몇 건 때문에 전체가 멈추면 안 되지만, 외부 연동이 죽었는데 계속 도는 것은 더 나쁘다.
이 기준은 새로 만든 것이 아니다. 예외 처리 표준의 로그 레벨 기준(4xx는 warn, 5xx는 error)과 같은 축을 그대로 쓴다. 기준이 하나면 나중에 헷갈릴 일도 없다.
건너뛴 항목은 SkipListener에서 warn으로 남긴다. Writer나 Processor에서 로그를 남기지 않는 것은 “로그는 경계에서 한 번”이라는 원칙 그대로이고, 이 경로의 경계가 SkipListener일 뿐이다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 이 연산에 도메인의 판단이 개입하는가? | 아니다 | {도메인}/bulk |
| 그렇다 | {도메인}/batch |
|
| 벌크는 무엇을 참조하나? | domain, common |
infra와 형제. 자기 타입을 갖는다 |
| 배치는 무엇을 참조하나? | application, domain, common |
api·infra·bulk는 안 본다 |
| 배치의 Reader는? | domain의 Repository |
대상 조회에 UseCase를 거치지 않는다 |
| 배치의 쓰기는? | Command → XxxUseCase |
Repository로 직접 저장하지 않는다 |
| 트랜잭션 경계는? | chunk | Service의 @Transactional은 여기에 참여한다 |
| 벌크의 예외는? | 번역하지 않고 원본 타입 | ErrorCode 없음. StepExecutionListener에서 error |
| 배치의 예외는? | BusinessException 그대로 |
ErrorCode 있음. 4xx warn, 5xx error |
| 한 건이 실패하면? | SkipPolicy가 ErrorCode로 분기 |
4xx는 skip, 5xx는 Step 실패 |
| 외부 입력 검증은? | JobParametersValidator |
두 경로 공통. assert를 쓰지 않는다 |
Job/Step 설정은? |
조립 대상과 같은 패키지 | package-private이라 밖에서는 조립 불가 |
이 표준을 정하기까지
시작은 모순을 찾은 것이었다. 표준 전체를 훑다가 Repository 설계 표준 안에서 두 문장이 부딪히는 걸 발견했다. “대량 연산은 Domain을 거치지 않는다”와 “정책 판단이 필요하면 Domain을 하나씩 거친다”가 같은 목록에 나란히 있었다.
원인이 규칙이 아니라 단어라는 걸 알았다. 처음에는 둘 중 하나가 틀렸다고 생각했는데, 따져보니 둘 다 맞고 가리키는 대상이 달랐다. “배치”라는 한 단어가 벌크 연산과 Domain 순회 잡을 동시에 뜻하고 있었고, {도메인}/batch 패키지와 그 예외·로그 규칙은 전부 앞의 것만 보고 쓴 것이었다.
답의 절반이 이미 다른 표준에 있다는 걸 발견했다. 검증 표준과 assert 표준이 “배치가 Command를 만들어 Service에 넣는다”를 전제로 쓰여 있었다. 그런데 패키지 구조 표준은 “batch는 domain과 common만 참조한다”고 정반대로 못 박아뒀다. 네 표준이 두 진영으로 갈려 있었던 셈이고, 이걸 보고 나서는 어느 쪽으로 갈지가 아니라 어느 쪽이 이미 다수인지의 문제가 됐다.
경로를 정하고 나서 트랜잭션 경계가 Service를 떠난다는 걸 알았다. Service를 재사용하면 트랜잭션도 그대로 따라올 줄 알았는데, chunk가 이미 트랜잭션이라 @Transactional이 거기 참여만 한다. chunk 크기가 곧 롤백 단위가 되고, 이건 요청 경로와 다른 유일한 지점이라 규칙으로 적어두기로 했다.
실패 정책에서 예외 클래스를 하나만 둔 대가가 나타났다. Spring Batch의 skip/retry가 예외 타입으로 분기하는데 올라오는 건 전부 BusinessException이다. 벌크 경로에서 “번역하지 말고 원본 타입을 올려라”라고 정한 이유가 바로 이거였는데, 이쪽은 애초에 뭉쳐진 채로 들어온다. SkipPolicy를 직접 구현해 ErrorCode로 분기하는 것으로 풀었고, 분기 기준을 로그 레벨과 같은 축(4xx/5xx)으로 맞춰 새 기준을 만들지 않았다.
마지막으로 이름을 바로잡았다. 기존 {도메인}/batch가 실제로는 벌크 전용이었으므로 bulk로 옮기고, 새로 생긴 자리를 batch가 받았다. 네 표준을 함께 고쳐야 하는 변경이지만, 이름이 실체와 다르면 같은 혼동이 다시 생긴다는 게 이 글의 출발점이었다.
정리
- 가르는 질문은 “도메인의 판단이 개입하는가”다.
Domain을 거치는지는 결과이지 기준이 아니다 - 판단이 없으면
{도메인}/bulk, 있으면{도메인}/batch. 성능을 이유로 판단이 있는 연산을 벌크로 옮기지 않는다 - 배치는
Command를 만들어XxxUseCase를 부른다. 읽기만domain의 Repository를 직접 쓴다 - 트랜잭션 경계는 chunk다. Service의
@Transactional은 새 트랜잭션을 열지 않고 참여한다 - 실패는 타입이 아니라
ErrorCode로 가른다. 4xx는 건너뛰고 5xx는 멈춘다 Job/Step설정은 조립 대상과 같은 패키지에 둔다. 컴포넌트가 package-private이기 때문이다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/batch-execution-standard/SKILL.md
---
name: batch-execution-standard
description: 대량 처리 경로 선택 규칙. 벌크 연산과 Domain 순회 배치를 나누고 Job, Step, Reader, Writer, SkipPolicy를 작성하거나 리뷰할 때 반드시 적용한다.
---
# 배치 실행 표준
대량 처리 경로는 둘이고, 가르는 질문은 **이 연산에 도메인의 판단이 개입하는가**다.
## 경로 선택
- 조건이 곧 규칙인 연산(`PENDING`이 30일 지나면 `EXPIRED`)은 **벌크**로 처리한다.
- 도메인의 정책 판단이 필요한 연산(등급별 할인 재산정)은 벌크로 하지 않는다. `Domain`을 하나씩 거치는 **배치**로 처리한다.
- 성능을 이유로 판단이 개입하는 연산을 벌크로 옮기지 않는다. 느린 것이 문제라면 애그리거트 경계나 정책 자체를 다시 본다.
- `Domain`을 거치는지 여부는 결과이지 선택 기준이 아니다. 판단이 개입하는지를 먼저 묻는다.
- 두 경로 모두 일반 요청 경로(Controller → Service)에서 호출하지 않는다.
## 벌크 — `{도메인}/bulk`
- `XxxBulkMapper`, `XxxBulk{의도}Writer` / `XxxBulk{의도}Reader`, `XxxBulkMapperParam`, `XxxBulkMapperResult`를 `{도메인}/bulk`에 둔다.
- 모두 package-private으로 만든다. `Repository` 접미사를 붙이지 않는다.
- `bulk`는 `domain`과 `common`만 참조한다. `infra`를 참조하지 않으며 `infra`도 `bulk`를 참조하지 않는다. 형제 패키지다.
- `infra`의 `XxxMapperParam`을 재사용하지 않는다. `public`으로 열면 `application`까지 MyBatis 타입을 알게 된다.
- `Domain`을 거치지 않는다. 그래서 비즈니스 규칙을 검증할 자리가 없다.
- 예외를 번역하지 않고 원본 타입을 그대로 올린다. Spring Batch의 skip/retry 정책이 예외 타입으로 분기하기 때문이다.
- `ErrorCode`를 붙이지 않는다. `ErrorCode`는 HTTP 상태를 앞자리로 들고 있어 이 경로에서는 의미가 없다.
- 실패 로그는 `StepExecutionListener` 한 곳에서 `error`로 남긴다.
## 배치 — `{도메인}/batch`
- `Domain`을 하나씩 거치는 잡은 `{도메인}/batch`에 두고 package-private으로 만든다.
- `batch`는 `application`, `domain`, `common`을 참조한다. `api`, `infra`, `bulk`는 참조하지 않는다.
- `ItemReader`는 `domain`의 Repository로 대상을 페이징 조회한다.
- 처리는 `XxxCommand`를 만들어 `XxxUseCase`를 호출한다. Repository로 직접 저장하지 않는다.
- 예외를 번역하지 않는다. Service가 던진 `BusinessException`을 그대로 올린다.
- `ErrorCode`가 붙어 있으므로 로그 레벨 규칙(4xx는 `warn`, 5xx는 `error`)이 그대로 적용된다. exception-handling-standard를 따른다.
## 트랜잭션
- chunk 하나가 트랜잭션이다. `@Transactional` Service는 새 트랜잭션을 열지 않고 chunk 트랜잭션에 참여한다.
- 따라서 **chunk 크기가 곧 롤백 단위**다. 요청 경로의 "한 요청 = 한 트랜잭션"이 여기서는 성립하지 않는다.
- 한 건 단위 커밋이 필요하면 chunk 크기를 1로 두되, 커밋이 건수만큼 일어남을 감수한다.
## 실패 처리
- `SkipPolicy`를 직접 구현하고 `BusinessException`의 `ErrorCode`로 분기한다.
- 4xx는 도메인 규칙 위반이므로 건너뛴다. 5xx는 시스템 문제이므로 Step을 실패시킨다.
- `.skip(BusinessException.class)`로 타입만 보고 건너뛰지 않는다. 외부 연동 장애(502·503·504)까지 조용히 넘어간다.
- 건너뛴 항목은 `SkipListener`에서 `warn`으로 남긴다. Writer나 Processor에서 로그를 남기지 않는다.
## 검증과 조립
- 두 경로 모두 외부 입력은 `JobParameters` 하나이며 `JobParametersValidator`에서 Job 시작 전에 검증한다. validation-standard를 따른다.
- `ItemReader`가 DB에서 읽은 행은 재검증하지 않는다. 저장될 때 이미 검증을 통과했다.
- 입력 검증에 `assert`를 쓰지 않는다. 운영에서 `-ea`를 켜지 않으므로 한 줄도 실행되지 않는다.
- `Job`/`Step` 설정 클래스는 조립 대상과 같은 패키지에 둔다. 컴포넌트가 package-private이라 밖에서는 조립할 수 없다.
GitHub Copilot — .github/instructions/batch-execution-standard.instructions.md
---
description: 벌크 연산과 Domain 순회 배치의 경로 선택 규칙
applyTo: "**/bulk/**/*.java, **/batch/**/*.java"
---
- 대량 처리 경로를 고를 때는 "이 연산에 도메인의 판단이 개입하는가"를 먼저 묻는다.
- 조건이 곧 규칙인 연산은 벌크로 처리하고, 도메인의 정책 판단이 필요한 연산은 `Domain`을 하나씩 거치는 배치로 처리한다.
- 성능을 이유로 판단이 개입하는 연산을 벌크로 옮기지 않는다.
- 벌크와 배치 모두 일반 요청 경로(Controller → Service)에서 호출하지 않는다.
- 벌크 컴포넌트(`XxxBulkMapper`, `XxxBulk{의도}Writer`, `XxxBulkMapperParam`, `XxxBulkMapperResult`)는 `{도메인}/bulk`에 package-private으로 둔다.
- `bulk`는 `domain`과 `common`만 참조한다. `infra`와 서로 참조하지 않고 각각 자기 데이터 타입을 갖는다.
- 벌크는 `Domain`을 거치지 않으며, 예외를 번역하지 않고 원본 타입을 올린다. `ErrorCode`를 붙이지 않는다.
- 벌크 실패 로그는 `StepExecutionListener`에서 `error`로 남긴다.
- `Domain`을 하나씩 거치는 잡은 `{도메인}/batch`에 package-private으로 둔다.
- `batch`는 `application`, `domain`, `common`을 참조하고 `api`, `infra`, `bulk`는 참조하지 않는다.
- 배치의 `ItemReader`는 `domain`의 Repository로 대상을 페이징 조회한다.
- 배치의 처리는 `XxxCommand`를 만들어 `XxxUseCase`를 호출한다. Repository로 직접 저장하지 않는다.
- 배치는 예외를 번역하지 않고 Service가 던진 `BusinessException`을 그대로 올린다. 로그 레벨은 `ErrorCode`의 상태 코드로 정한다.
- chunk 하나가 트랜잭션이므로 `@Transactional` Service는 chunk 트랜잭션에 참여한다. chunk 크기가 롤백 단위다.
- `SkipPolicy`를 직접 구현해 `ErrorCode`로 분기한다. 4xx는 건너뛰고 5xx는 Step을 실패시킨다.
- `.skip(BusinessException.class)`로 타입만 보고 건너뛰지 않는다.
- 건너뛴 항목은 `SkipListener`에서 `warn`으로 남기고 Writer나 Processor에서 로그를 남기지 않는다.
- 두 경로의 외부 입력은 `JobParameters` 하나이며 `JobParametersValidator`에서 검증한다.
- `ItemReader`가 DB에서 읽은 행은 재검증하지 않고, 입력 검증에 `assert`를 쓰지 않는다.
- `Job`/`Step` 설정 클래스는 조립 대상과 같은 패키지에 둔다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기