Repository 설계 표준에서 대량 연산을 Repository 밖으로 빼면서 이렇게 정했다.

대량 연산은 Domain을 거치지 않는다.

그런데 같은 글의 정리에 이 문장도 같이 있다.

조건이 곧 규칙인 것만 배치로 처리하고, 정책 판단이 필요하면 Domain을 하나씩 거친다.

두 문장이 부딪힌다. 뒤 문장을 배치로 읽으면 앞 문장과 정면으로 어긋나고, “배치 말고 일반 경로로 하라”로 읽으면 그 글이 든 예시(등급별 할인 재산정)를 Controller 요청으로 처리하라는 말이 된다. 어느 쪽으로도 성립하지 않는다.

문제를 코드로 세우면 이렇다.

// 안 A — 벌크. 5만 건이 UPDATE 한 방에 끝난다. 대신 규칙을 검증할 자리가 없다
bulkMapper.bulkUpdateStatus(targets);

// 안 B — Domain을 하나씩. 규칙은 지켜지지만 5만 번 돈다
targets.forEach(order -> orderRepository.save(order.expire()));

한 줄씩 따라가기. bulkMapper.bulkUpdateStatus(targets)는 targets에 담긴 여러 건을 UPDATE 문 한 번으로 한꺼번에 바꾼다 — 5만 건이면 보통 5만 번 나갈 SQL이 한 번으로 끝난다는 뜻이다. targets.forEach(order -> orderRepository.save(order.expire()))의 order -> orderRepository.save(order.expire())는 람다다 — targets 목록에서 꺼낸 값 하나하나를 order라는 이름으로 받아, 그 뒤에 적은 일(order.expire()로 상태를 바꾸고 save()로 저장)을 반복한다는 뜻이다. 즉 안 B는 목록을 한 건씩 돌면서 매번 Domain을 거쳐 저장한다.

답은 “둘 다 필요하다”였다. 그런데 표준은 안 A만 정의하고, 안 B는 문장 하나로 존재만 인정한 채 어디서 어떻게 도는지 정하지 않았다. 이 글에서 그 빈자리를 채운다.


먼저: “배치”가 두 가지를 가리키고 있었다

모순의 원인은 규칙이 없어서가 아니라 한 단어가 두 가지를 뜻하고 있어서였다.

  벌크 Domain 순회 잡
하는 일 벌크 UPDATE/INSERT Domain을 하나씩 로드해 정책 적용
예 PENDING 30일 → EXPIRED 등급별 할인 재산정
사는 곳 {도메인}/batch 정한 적 없음
ErrorCode 안 붙임 Domain이 붙여서 던진다

{도메인}/batch와 거기 딸린 예외·로그 규칙은 전부 왼쪽 칸을 위해 쓴 것이다. 그런데 이름이 “배치”라는 이유로 오른쪽 칸에도 그대로 적용된다. 예를 들어 예외의 종류는 “ErrorCode를 배치 실패에 붙이지 않는다”고 못 박았는데, 오른쪽 칸은 Domain을 거치므로 ErrorCode가 붙은 ErrorCodeException이 그냥 올라온다.

답의 절반은 이미 다른 표준에 적혀 있었다

오른쪽 칸이 무엇을 호출하는지는 사실 두 표준이 이미 전제하고 있었다.

  • 계층별 검증 표준 — “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 설정에서만 조립된다”고 적으면서 그 설정이 어디 사는지는 정하지 않았던 자리다.


원칙 2-1. @Version이 있는 표를 벌크로 고칠 때는 version도 함께 올린다

평소에는 JPA가 @Version이 붙은 낙관적 락의 번호 칸(version)을 저장할 때마다 1씩 올린다. 그런데 벌크는 Domain을 거치지 않고 MyBatis로 UPDATE SQL을 직접 쓰니 이 번호도 JPA가 대신 올려 주지 않는다. Domain 영속화 표준 원칙 5는 읽어서 고치는 경로가 있는 Entity에 @Version을 두게 했다. 그런 표를 벌크도 고치면 무슨 일이 생기는지가 이 원칙의 질문이다.

장면은 이렇다.

  1. 관리자가 주문 7번을 화면에 연다. 이때 7번의 version은 3이다
  2. 그사이 벌크가 7번을 EXPIRED로 바꾼다. 벌크 SQL이 version을 건드리지 않아 그대로 3이다
  3. 관리자가 다른 칸을 고쳐 저장한다. JPA는 「번호가 3인 행만 고쳐라」라는 조건을 싣는데, 번호가 그대로 3이라 통과한다. 관리자가 읽었던 낡은 모습이 저장되면서 벌크의 EXPIRED가 지워진다. JPA(Hibernate)는 @DynamicUpdate(바뀐 칸만 UPDATE에 싣게 하는 표시)를 붙이지 않으면 바꾼 칸만이 아니라 엔티티의 모든 칼럼을 UPDATE에 싣기 때문에(open-source/backend/java/hibernate-orm/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorStandard.java 1338행), 관리자가 손대지 않은 status도 읽었던 값 PENDING으로 다시 써진다

잃어버린 수정이다. 예외도 로그도 없다. 벌크가 2단계에서 번호를 4로 올렸다면, 3단계의 「번호가 3인 행」은 0개라 관리자의 저장이 막혔을 것이다. 고친 행이 0개면 JPA가 낙관적 락 충돌 예외를 던지고, 예외 분류 표준 원칙 2가 이를 409(동시 수정)로 번역한다.

정해야 했던 건 이거였다 — 벌크 SQL이 version을 올려야 하는가.

안 A — @Version이 있는 표를 벌크로 고칠 때는 SQL에 version = version + 1을 함께 적는다.

UPDATE orders
SET status     = 'EXPIRED',
    version    = version + 1,   -- 이 줄이 이 원칙이다
    updated_at = ?              -- DB 시간 저장 표준 원칙 5
WHERE status = 'PENDING'
  AND created_at < ?

한 줄씩 보면, UPDATE orders SET ...은 orders 표의 행을 고치는 SQL이고 WHERE 뒤가 고칠 행을 고르는 조건이다 — PENDING이면서 만든 지 일정 시각이 지난 주문이다. status = 'EXPIRED'가 하려던 변경이고, version = version + 1은 고친 행마다 번호를 지금 값보다 1 올린다. updated_at은 MyBatis로 업무 데이터를 고치는 UPDATE라 DB 시간 저장 표준 원칙 5대로 직접 적는다.

얻는 것은 위 장면의 3단계가 409로 막힌다는 점이다(고친 행이 0개라 나는 낙관적 락 충돌 예외를 예외 분류 표준 원칙 2가 409로 번역한다). 벌크가 고친 행은 번호가 바뀌어 있으므로, 그 전에 읽어 둔 낡은 모습으로는 저장이 통과하지 못한다.

안 B — 규칙을 두지 않는다. 벌크는 「조건이 곧 규칙」인 연산이라 지킬 판단이 없으니 따로 정할 것이 없다고 보는 안이다. 얻는 것은 벌크 SQL이 하려던 변경만 담아 짧다는 점이다. 버린 이유는 사람이 읽고 고치는 경로와 벌크가 같은 행을 건드리면 벌크의 수정이 조용히 사라진다는 것이다. 벌크가 남의 수정을 덮지 않는 경우는 WHERE가 남이 바꾼 칸을 볼 때뿐이다. READ COMMITTED의 UPDATE는 도는 도중 남이 고쳐 커밋한 행을 만나면 그 최신 값에 WHERE status = 'PENDING' 조건을 다시 맞춰 보기 때문이다(PostgreSQL 17 문서 13.2.1절, 예외 분류 표준 원칙 2-3에서 정리했다). 그사이 고객이 PAID로 바꾼 주문은 조건에 안 맞아 지나간다. 그 밖에는 벌크도 남의 수정을 덮을 수 있다. 그리고 남이 먼저 읽어 두었다가 나중에 저장해 벌크의 수정을 덮는 반대 방향은 벌크의 WHERE로는 막지 못하고, 이 원칙이 막는다.

골랐다 — 안 A. 안 B가 열어 두는 반대 방향의 덮어쓰기를 벌크 SQL의 한 줄로 막는다. 낙관적 락은 「모든 쓰기가 번호를 올린다」는 약속 위에서만 동작하는데, 벌크가 그 약속에서 빠지면 벌크가 고친 행에서는 락이 그 수정을 지켜 주지 못한다. 대가는 벌크 SQL을 쓸 때마다 이 구절을 잊지 않아야 한다는 것이다. 빠뜨려도 SQL은 그대로 성공하고 테스트도 장면 3을 재현하지 않는 한 잡지 않는다. @Version이 없는 표, 곧 읽어서 고치는 경로가 없는 표(벌크로만 고치거나 아예 안 고치는 표)는 올릴 번호가 없으므로 이 원칙의 대상이 아니다. 위 장면을 실제 DB로 재지는 않았다.


원칙 3. 배치는 Command를 만들어 UseCase를 부른다

graph LR R["ItemReader"] -->|"Repository"| D["domain"] P["Processor / Writer"] -->|"Command"| U["application
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

그림을 읽는 법은 이렇다. 화살표는 “누가 누구를 부르는가”다. ItemReader는 domain의 Repository를 직접 불러 대상을 읽고, Processor와 Writer는 Command를 만들어 application의 XxxUseCase를 부른다. XxxUseCase를 구현한 Service가 다시 XxxDomain을 불러 정책을 판단한다. 아래쪽의 bulk는 점선으로 따로 떨어져 있는데, Domain을 거치지 않고 XxxBulkMapper가 DB로 곧장 간다는 뜻이다.

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);
    }
}

한 줄씩 따라가기. @Component는 이 클래스를 Spring이 관리하는 객체(빈)로 등록하라는 표시이고, @RequiredArgsConstructor는 final 필드를 받는 생성자를 Lombok이 대신 만들어 준다. ItemProcessor · ItemWriter의 <OrderDomain, RecalculateDiscountCommand>는 “OrderDomain을 받아 RecalculateDiscountCommand로 바꾼다”는 뜻으로, process(OrderDomain order)가 그 변환을 실제로 한다. Chunk<? extends RecalculateDiscountCommand>의 ? extends는 RecalculateDiscountCommand 자신이든 그 자식 타입이든 상관없이 chunk로 받아들이겠다는 뜻이다. chunk.forEach(orderCommandUseCase::recalculateDiscount)의 ::는 메서드 참조다 — chunk 안의 값 하나하나를 꺼내 orderCommandUseCase.recalculateDiscount(값)을 부르는 것과 같은 뜻을, 람다 대신 메서드 이름만 적어 줄여 쓴 것이다.

읽기는 가볍게, 쓰기는 규칙을 거친다. 판단이 일어나는 자리만 application을 통과시키면 되고, 대상을 고르는 조회까지 통과시킬 이유는 없다.


원칙 4. 트랜잭션 경계는 Service가 아니라 chunk다

여기가 요청 경로와 다른 유일한 지점이라 반드시 적어둬야 한다. 트랜잭션이 어디서 열리고 닫히는지가 배치에서는 평소와 다르다는 뜻이다.

Spring Batch의 chunk 지향 Step은 chunk 하나를 트랜잭션으로 감싼다. 그 안에서 @Transactional인 Service를 부르면 새 트랜잭션이 열리지 않고 chunk 트랜잭션에 참여한다. REQUIRED는 @Transactional의 기본값으로, “이미 열려 있는 트랜잭션이 있으면 거기 올라타고, 없으면 새로 연다”는 뜻이다. 여기서는 chunk가 이미 트랜잭션을 열어 둔 상태이므로, 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(ErrorCodeException.class).skipLimit(100)

한 줄씩 따라가기. .faultTolerant()는 이 Step이 한 건 실패로 전체가 멈추지 않도록 봐준다는 설정이고, .skip(ErrorCodeException.class)는 “ErrorCodeException이 나면 그 건은 건너뛰고 계속 진행하라”는 뜻이다. .skipLimit(100)은 그렇게 건너뛸 수 있는 최대 건수이고, 100건을 넘으면 Step 자체가 실패로 멈춘다.

그런데 이 경로로 올라오는 것은 전부 ErrorCodeException 하나다. 예외 처리 표준에서 “구체 예외 클래스를 만들지 않고 구분은 ErrorCode로 한다”고 정한 대가가 여기서 나타난다. 위처럼 타입만 보고 건너뛰면 결제사 장애(502·503·504)까지 조용히 넘어간다.

벌크 경로에서 “예외를 번역하지 말고 원본 타입을 올린다”고 정한 이유가 정확히 이것이었는데, 이쪽은 반대로 이미 다 뭉쳐진 상태로 들어온다. 그래서 SkipPolicy를 직접 구현한다.

// order/batch — package-private
class BusinessErrorSkipPolicy implements SkipPolicy {

    @Override
    public boolean shouldSkip(Throwable t, long skipCount) {
        if (t instanceof ErrorCodeException e) {
            return e.getErrorCode().getStatus() < 500;   // 4xx만 건너뛴다
        }
        return false;
    }
}

한 줄씩 따라가기. if (t instanceof ErrorCodeException e)는 instanceof 패턴 매칭이다 — t가 ErrorCodeException 타입이면 그 값을 e라는 변수에 바로 담아, if 블록 안에서 e.getErrorCode()처럼 곧바로 쓸 수 있게 한다. e.getErrorCode().getStatus() < 500은 이 실패에 붙은 상태 코드가 500보다 작은지, 즉 4xx인지를 확인하는 것이다.

4xx는 도메인 규칙 위반이므로 건너뛰고, 5xx는 시스템 문제이므로 Step을 실패시킨다. 5만 건 중 규칙에 안 맞는 몇 건 때문에 전체가 멈추면 안 되지만, 외부 연동이 죽었는데 계속 도는 것은 더 나쁘다.

이 기준은 새로 만든 것이 아니다. 예외 처리 표준의 로그 레벨 기준(4xx는 warn, 5xx는 error)과 같은 축을 그대로 쓴다. 기준이 하나면 나중에 헷갈릴 일도 없다.

건너뛴 항목은 SkipListener에서 warn으로 남긴다. Writer나 Processor에서 로그를 남기지 않는 것은 “로그는 경계에서 한 번”이라는 원칙 그대로이고, 이 경로의 경계가 SkipListener일 뿐이다.


판단 기준 정리

질문 답 결론
이 연산에 도메인의 판단이 개입하는가? 아니다 {도메인}/bulk
  그렇다 {도메인}/batch
벌크는 무엇을 참조하나? domain, common infra와 형제. 자기 타입을 갖는다
@Version이 있는 표를 벌크로 고치면? version = version + 1을 함께 적는다 안 올리면 읽고 고치는 경로가 벌크의 수정을 덮는다
배치는 무엇을 참조하나? application, domain, common api·infra·bulk는 안 본다
배치의 Reader는? domain의 Repository 대상 조회에 UseCase를 거치지 않는다
배치의 쓰기는? Command → XxxUseCase Repository로 직접 저장하지 않는다
트랜잭션 경계는? chunk Service의 @Transactional은 여기에 참여한다
벌크의 예외는? 번역하지 않고 원본 타입 ErrorCode 없음. StepExecutionListener에서 error
배치의 예외는? ErrorCodeException 그대로 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가 예외 타입으로 분기하는데 올라오는 건 전부 ErrorCodeException이다. 벌크 경로에서 “번역하지 말고 원본 타입을 올려라”라고 정한 이유가 바로 이거였는데, 이쪽은 애초에 뭉쳐진 채로 들어온다. SkipPolicy를 직접 구현해 ErrorCode로 분기하는 것으로 풀었고, 분기 기준을 로그 레벨과 같은 축(4xx/5xx)으로 맞춰 새 기준을 만들지 않았다.

마지막으로 이름을 바로잡았다. 기존 {도메인}/batch가 실제로는 벌크 전용이었으므로 bulk로 옮기고, 새로 생긴 자리를 batch가 받았다. 네 표준을 함께 고쳐야 하는 변경이지만, 이름이 실체와 다르면 같은 혼동이 다시 생긴다는 게 이 글의 출발점이었다.

벌크와 낙관적 락이 만나는 자리를 나중에 막았다(2026-10-21). Domain 영속화 표준이 수정 경로가 있는 Entity에 @Version을 두게 한 뒤, 같은 표를 벌크도 고치면 어떻게 되는지가 남았다. 관리자가 version 3으로 읽은 주문을 벌크가 EXPIRED로 바꾸고 번호는 3에 그대로 두면, 관리자의 저장이 「번호가 3인가」를 통과해 벌크의 변경을 지운다. 규칙을 두지 않는 안 B도 놓았다. 안 B는 「벌크에는 판단이 없으니 정할 게 없다」고 봤는데, 판단이 없어도 남이 먼저 읽어 둔 행을 벌크가 바꾸면 그 남의 저장이 벌크를 덮는다 — 그 방향을 막을 장치가 없었다. 그래서 @Version이 있는 표를 벌크로 고칠 때는 version = version + 1을 함께 적게 했다(원칙 2-1).


정리

  • 가르는 질문은 “도메인의 판단이 개입하는가”다. Domain을 거치는지는 결과이지 기준이 아니다
  • 판단이 없으면 {도메인}/bulk, 있으면 {도메인}/batch. 성능을 이유로 판단이 있는 연산을 벌크로 옮기지 않는다
  • @Version이 있는 표를 벌크로 고칠 때는 version = version + 1을 함께 적는다. 안 올리면 그 전에 읽어 둔 낡은 모습이 벌크의 수정을 덮는다
  • 배치는 Command를 만들어 XxxUseCase를 부른다. 읽기만 domain의 Repository를 직접 쓴다
  • 트랜잭션 경계는 chunk다. Service의 @Transactional은 새 트랜잭션을 열지 않고 참여한다
  • 실패는 타입이 아니라 ErrorCode로 가른다. 4xx는 건너뛰고 5xx는 멈춘다
  • Job/Step 설정은 조립 대상과 같은 패키지에 둔다. 컴포넌트가 package-private이기 때문이다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기