실습 프로젝트 셋업

학습 목표

  • Gradle(Groovy DSL) + Spring Boot 3.2 + Spring Batch 5.1 프로젝트를 처음부터 만든다
  • docker compose 로 MySQL 8 메타데이터 DB(batchdb)를 기동한다
  • 실습 도메인 스키마(customers 1,000행 · orders 100,000행 · settlement)를 결정론적으로 생성한다
  • 배치가 왜 별도의 메타데이터 DB 를 필요로 하는지 이해한다
  • ./gradlew bootRun 이 정상 종료되는 것까지 확인한다

선행 스텝: 없음 (이 문서가 출발점입니다) 예상 소요: 30분


이 문서는 Step 01 ~ Step 14 전체가 공유하는 단 하나의 프로젝트를 만듭니다. 각 스텝은 이 프로젝트 안에 com.example.batch.step01, step02 … 처럼 패키지를 하나씩 추가하는 방식으로 진행합니다. 스텝마다 프로젝트를 새로 만들지 않습니다.


P-1. 왜 배치는 DB 를 하나 더 쓰는가

Spring Batch 는 "어디까지 처리했는지"를 기억하는 프레임워크입니다. 10만 건을 처리하다 7만 건째에서 죽었다면, 다시 돌렸을 때 7만 건째부터 이어가야 합니다. 그러려면 다음이 어딘가에 영속되어야 합니다.

  • 이 Job 이 언제 시작해서 언제 끝났는가
  • 어떤 파라미터로 실행됐는가 (같은 파라미터로 또 돌리면 안 되는가)
  • 몇 번째 청크까지 커밋됐는가
  • 읽은 건수 / 쓴 건수 / 건너뛴 건수는 몇인가

Spring Batch 는 이 정보를 BATCH_ 로 시작하는 9개의 메타데이터 테이블에 저장합니다. 이 테이블 묶음을 관리하는 컴포넌트가 JobRepository 입니다.

     ┌──────────────────┐
     │   여러분의 Job    │
     └────────┬─────────┘
              │ "나 시작했어요" / "청크 12 커밋했어요" / "끝났어요"

     ┌──────────────────┐
     │   JobRepository   │       ← 배치의 기억장치
     └────────┬─────────┘
              │ JDBC

     ┌──────────────────────────────────────────┐
     │  BATCH_JOB_INSTANCE / BATCH_JOB_EXECUTION │
     │  BATCH_STEP_EXECUTION / ..._CONTEXT / ... │   ← 9개 테이블
     └──────────────────────────────────────────┘

💡 실무 팁 — 메타데이터 DB 는 업무 DB 와 분리하는 편이 좋습니다 메타데이터는 배치 인프라의 자산이고, 업무 데이터는 서비스의 자산입니다. 운영에서는 스키마를 분리하거나 아예 다른 인스턴스로 두는 경우가 많습니다. 다만 한 트랜잭션으로 묶고 싶다면 같은 DataSource 여야 합니다(그래야 청크 커밋과 메타데이터 갱신이 원자적입니다). 이 트레이드오프는 Step 11 에서 다시 다룹니다. 학습 편의를 위해 이 코스는 하나의 MySQL 인스턴스, 하나의 batchdb 스키마에 메타데이터와 업무 데이터를 함께 둡니다.


P-2. 디렉터리 구조

먼저 최종 형태를 봅니다.

spring-batch5-lab/
├── docker/
│   ├── docker-compose.yml
│   └── initdb/
│       ├── 01-schema.sql          ← 업무 테이블 DDL
│       └── 02-seed.sql            ← 결정론적 시드 데이터
├── build.gradle
├── settings.gradle
├── gradle/wrapper/...
├── gradlew
└── src/main/
    ├── java/com/example/batch/
    │   ├── BatchLabApplication.java
    │   ├── domain/
    │   │   ├── Order.java
    │   │   ├── Customer.java
    │   │   └── Settlement.java
    │   ├── step01/ ... step14/     ← 스텝별 Job 설정
    └── resources/
        ├── application.yml
        └── logback-spring.xml

P-3. docker compose — MySQL 8 기동

docker/docker-compose.yml:

services:
  mysql:
    image: mysql:8.0.36
    container_name: batch-mysql8
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_0900_ai_ci
      - --default-time-zone=+09:00
      - --local-infile=1
      - --log-bin-trust-function-creators=1
      - --innodb-buffer-pool-size=512M
    environment:
      MYSQL_ROOT_PASSWORD: root1234
      MYSQL_DATABASE: batchdb
      MYSQL_USER: batch
      MYSQL_PASSWORD: batch1234
      TZ: Asia/Seoul
    ports:
      - "3308:3306"
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d
      - batch-mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot1234"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  batch-mysql-data:

⚠️ 함정 — 포트 3306 을 쓰지 마세요 로컬에 MySQL 이 이미 떠 있으면 Bind for 0.0.0.0:3306 failed: port is already allocated 로 죽습니다. 이 코스는 3308 을 씁니다. (mysql8 코스가 3307 을 쓰므로 겹치지 않게 한 칸 옮겼습니다.) 그리고 컨테이너 안쪽은 항상 3306 입니다. 3308:3306 의 왼쪽만 바꾸는 것이며, application.yml 의 URL 은 3308 이어야 합니다.

기동합니다.

cd docker
docker compose up -d
docker compose ps

결과

NAME           IMAGE          COMMAND                  SERVICE   STATUS                    PORTS
batch-mysql8   mysql:8.0.36   "docker-entrypoint.s…"   mysql     Up 32 seconds (healthy)   33060/tcp, 0.0.0.0:3308->3306/tcp

(healthy) 가 뜰 때까지 20~30초 걸립니다. (health: starting) 이면 아직 초기화 중입니다.

접속 확인:

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -e "SELECT VERSION(), @@time_zone;"

결과

+-----------+-------------+
| VERSION() | @@time_zone |
+-----------+-------------+
| 8.0.36    | +09:00      |
+-----------+-------------+

P-4. 업무 스키마 DDL — docker/initdb/01-schema.sql

/docker-entrypoint-initdb.d 에 넣은 .sql컨테이너 볼륨이 비어 있을 때 딱 한 번 파일명 순서대로 실행됩니다.

-- 01-schema.sql
USE batchdb;

-- 데이터 생성 보조용 숫자 테이블 (1 ~ 300000)
DROP TABLE IF EXISTS tally;
CREATE TABLE tally (
  n INT NOT NULL PRIMARY KEY
) ENGINE=InnoDB;

DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
  customer_id  INT           NOT NULL PRIMARY KEY,
  name         VARCHAR(50)   NOT NULL,
  grade        VARCHAR(10)   NOT NULL,          -- BRONZE / SILVER / GOLD / VIP
  fee_rate     DECIMAL(5,4)  NOT NULL,          -- 등급별 정산 수수료율
  city         VARCHAR(20)   NOT NULL,
  joined_at    DATE          NOT NULL
) ENGINE=InnoDB;

DROP TABLE IF EXISTS products;
CREATE TABLE products (
  product_id   INT           NOT NULL PRIMARY KEY,
  name         VARCHAR(60)   NOT NULL,
  category     VARCHAR(20)   NOT NULL,
  unit_price   DECIMAL(12,2) NOT NULL
) ENGINE=InnoDB;

DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
  order_id     BIGINT        NOT NULL PRIMARY KEY,
  customer_id  INT           NOT NULL,
  amount       DECIMAL(12,2) NOT NULL,
  status       VARCHAR(12)   NOT NULL,          -- COMPLETED / CANCELLED / PENDING / REFUNDED
  ordered_at   DATETIME      NOT NULL,
  KEY idx_orders_status_date (status, ordered_at),
  KEY idx_orders_customer (customer_id)
) ENGINE=InnoDB;

DROP TABLE IF EXISTS order_items;
CREATE TABLE order_items (
  order_item_id BIGINT       NOT NULL PRIMARY KEY,
  order_id      BIGINT       NOT NULL,
  product_id    INT          NOT NULL,
  quantity      INT          NOT NULL,
  unit_price    DECIMAL(12,2) NOT NULL,
  KEY idx_items_order (order_id)
) ENGINE=InnoDB;

-- 배치가 채우는 결과 테이블. 처음에는 비어 있습니다.
DROP TABLE IF EXISTS settlement;
CREATE TABLE settlement (
  settlement_id BIGINT        NOT NULL AUTO_INCREMENT PRIMARY KEY,
  order_id      BIGINT        NOT NULL,
  customer_id   INT           NOT NULL,
  settle_date   DATE          NOT NULL,
  gross_amount  DECIMAL(12,2) NOT NULL,
  fee_rate      DECIMAL(5,4)  NOT NULL,
  fee_amount    DECIMAL(12,2) NOT NULL,
  net_amount    DECIMAL(12,2) NOT NULL,
  created_at    DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_settlement_order (order_id),
  KEY idx_settlement_date (settle_date)
) ENGINE=InnoDB;

⚠️ 함정 — settlement.order_id 의 UNIQUE 제약은 의도적입니다 정산은 한 주문에 한 번만 되어야 합니다. 배치를 두 번 돌려서 정산이 두 배가 되는 사고는 실무에서 정말 흔합니다. 이 UNIQUE 키가 없으면 Step 03 의 재실행 실습에서 조용히 중복 데이터가 쌓이고, 여러분은 에러 없이 틀린 정산서를 얻게 됩니다. 제약을 걸어 두면 최소한 DuplicateKeyException 으로 시끄럽게 실패합니다. 조용히 틀리느니 시끄럽게 실패하는 편이 낫습니다.


P-5. 시드 데이터 — docker/initdb/02-seed.sql

RAND() 를 쓰지 않습니다. 모든 값을 order_id 의 나머지 연산으로 만듭니다. 그래서 누가 몇 번을 실행하든 완전히 동일한 데이터가 나오고, 이 교재의 모든 실행 결과가 여러분 화면과 일치합니다.

-- 02-seed.sql
USE batchdb;

-- ── tally: 1 ~ 300000 (재귀 CTE)
SET SESSION cte_max_recursion_depth = 400000;
INSERT INTO tally (n)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 300000
)
SELECT n FROM seq;

-- ── customers: 1,000행
INSERT INTO customers (customer_id, name, grade, fee_rate, city, joined_at)
SELECT
  n,
  CONCAT('CUST-', LPAD(n, 4, '0')),
  ELT(n % 4 + 1, 'BRONZE', 'SILVER', 'GOLD', 'VIP'),
  ELT(n % 4 + 1, 0.0350,  0.0300,   0.0250, 0.0200),
  ELT(n % 5 + 1, 'SEOUL', 'BUSAN', 'DAEGU', 'INCHEON', 'GWANGJU'),
  DATE_ADD('2023-01-01', INTERVAL (n % 730) DAY)
FROM tally WHERE n <= 1000;

-- ── products: 200행
INSERT INTO products (product_id, name, category, unit_price)
SELECT
  n,
  CONCAT('PROD-', LPAD(n, 4, '0')),
  ELT(n % 5 + 1, 'FASHION', 'BEAUTY', 'DIGITAL', 'LIVING', 'FOOD'),
  1000 + (n % 97) * 500
FROM tally WHERE n <= 200;

-- ── orders: 100,000행
INSERT INTO orders (order_id, customer_id, amount, status, ordered_at)
SELECT
  n,
  ((n - 1) % 1000) + 1,
  1000 + (n % 977) * 100,
  CASE
    WHEN n % 10 <= 6 THEN 'COMPLETED'
    WHEN n % 10 = 7  THEN 'CANCELLED'
    WHEN n % 10 = 8  THEN 'PENDING'
    ELSE                  'REFUNDED'
  END,
  DATE_ADD(DATE_ADD('2025-01-01 00:00:00', INTERVAL ((n - 1) % 180) DAY),
           INTERVAL (n % 1440) MINUTE)
FROM tally WHERE n <= 100000;

-- ── order_items: 300,000행 (주문당 정확히 3건)
INSERT INTO order_items (order_item_id, order_id, product_id, quantity, unit_price)
SELECT
  n,
  ((n - 1) / 3) + 1,
  (n % 200) + 1,
  (n % 3) + 1,
  1000 + (n % 97) * 500
FROM tally WHERE n <= 300000;

검증합니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT status, COUNT(*) cnt, SUM(amount) total FROM orders GROUP BY status ORDER BY status;"

결과

+-----------+-------+---------------+
| status    | cnt   | total         |
+-----------+-------+---------------+
| CANCELLED | 10000 |  497830000.00 |
| COMPLETED | 70000 | 3485250000.00 |
| PENDING   | 10000 |  497890000.00 |
| REFUNDED  | 10000 |  497370000.00 |
+-----------+-------+---------------+

정산 대상은 COMPLETED 70,000건입니다. 이 숫자는 코스 내내 반복해서 나옵니다. 청크 크기 1,000이면 정확히 70청크입니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT MIN(ordered_at) first_order, MAX(ordered_at) last_order,
       COUNT(DISTINCT DATE(ordered_at)) days FROM orders;"

결과

+---------------------+---------------------+------+
| first_order         | last_order          | days |
+---------------------+---------------------+------+
| 2025-01-01 00:01:00 | 2025-06-29 23:59:00 |  181 |
+---------------------+---------------------+------+

180일치(2025-01-01 ~ 2025-06-29) 주문이 균등하게 깔려 있습니다. 하루치는 약 555건이고, 그중 COMPLETED 는 약 389건입니다. Step 03 이후의 "일자 파라미터로 하루치만 정산" 실습이 여기에 기댑니다.

💡 결정론적 데이터의 가치 RAND() 를 쓰면 "내 결과가 교재와 다른데 내가 틀린 건가, 데이터가 다른 건가?"를 구분할 수 없습니다. 나머지 연산만 쓰면 다르면 무조건 내가 틀린 것입니다. 학습에서 이 확실성은 굉장히 큽니다.


P-6. build.gradle — 전문

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.5'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '1.0.0'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // ── Spring Batch 5.1.x (Boot 3.2.5 가 관리)
    implementation 'org.springframework.boot:spring-boot-starter-batch'

    // ── JDBC / JPA — Step 06 의 JpaPagingItemReader 에 필요
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // ── Step 14 의 Micrometer 지표 노출
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus'

    // ── Step 14 의 Quartz 스케줄링
    implementation 'org.springframework.boot:spring-boot-starter-quartz'

    runtimeOnly 'com.mysql:mysql-connector-j'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.batch:spring-batch-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

// Job 이름을 커맨드라인으로 넘기기 편하게 (Step 03, Step 14 에서 사용)
bootRun {
    if (project.hasProperty('args')) {
        args project.property('args').split(',')
    }
}

버전을 확인합니다.

./gradlew dependencies --configuration runtimeClasspath | grep -E 'spring-batch|spring-boot-starter-batch|mysql'

결과

+--- org.springframework.boot:spring-boot-starter-batch -> 3.2.5
|    +--- org.springframework.batch:spring-batch-core:5.1.1
|    |    \--- org.springframework.batch:spring-batch-infrastructure:5.1.1
\--- com.mysql:mysql-connector-j -> 8.3.0

Spring Batch 5.1.1 입니다. 이 코스의 모든 API 설명은 이 버전을 기준으로 합니다.

⚠️ 함정 — Spring Batch 4 예제를 그대로 붙여 넣으면 컴파일이 안 됩니다 인터넷의 Spring Batch 예제 대부분은 아직 4.x 기준입니다. 5.0 에서 다음이 깨지는 변경으로 바뀌었습니다.

4.x5.x비고
JobBuilderFactory / StepBuilderFactory삭제됨. new JobBuilder(name, jobRepository) / new StepBuilder(name, jobRepository)Step 02
javax.persistence.*, javax.sql.*jakarta.*Jakarta EE 9+ 전환
@EnableBatchProcessing 필수선택. Boot 의 자동설정이 대신함Step 02
JobBuilder.start(step)동일하나 .repository() 는 생성자로 이동
ItemWriter.write(List<T>)ItemWriter.write(Chunk<? extends T> chunk)Step 08
JobParameter 원시 생성자타입 파라미터 JobParameter<T>(value, Class, identifying)Step 03
@EnableBatchProcessing 없어도 JobLauncherApplicationRunner 자동 실행동일

이 표는 코스 전체에서 계속 참조합니다. 지금 이해가 안 돼도 괜찮습니다.


P-7. application.yml

spring:
  application:
    name: spring-batch5-lab

  datasource:
    url: jdbc:mysql://127.0.0.1:3308/batchdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&rewriteBatchedStatements=true
    username: batch
    password: batch1234
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20        # Step 13 의 멀티스레드/파티셔닝에서 필요
      pool-name: batch-pool

  jpa:
    hibernate:
      ddl-auto: none               # 스키마는 initdb 가 만듭니다. 절대 update 로 두지 마세요.
    properties:
      hibernate:
        jdbc:
          batch_size: 1000
        format_sql: true
    open-in-view: false

  batch:
    jdbc:
      initialize-schema: always    # BATCH_* 테이블 자동 생성
      table-prefix: BATCH_
    job:
      enabled: true                # 부팅 시 Job 자동 실행 (Step 01 에서 관찰)
      # name: settlementJob        # 특정 Job 만 실행하고 싶을 때 (Step 14)

logging:
  level:
    org.springframework.batch: INFO
    org.springframework.batch.core.step.item.ChunkOrientedTasklet: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: INFO
    com.example.batch: DEBUG

management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus

⚠️ 함정 — initialize-schema: always 는 학습용입니다 always 는 애플리케이션이 뜰 때마다 schema-mysql.sql 을 실행해 BATCH_* 테이블을 만듭니다(이미 있으면 CREATE TABLE 이 실패하지만 무시). 편하긴 한데 운영에서는 never 로 두고 DDL 을 형상관리하는 것이 정석입니다. 배치 메타데이터 테이블이 애플리케이션 기동 타이밍에 만들어지는 건, 스키마 변경 이력을 추적할 수 없다는 뜻이기 때문입니다. never 로 바꿀 경우 DDL 원본은 jar 안의 org/springframework/batch/core/schema-mysql.sql 에 있습니다.

💡 rewriteBatchedStatements=true 를 빼먹지 마세요 MySQL JDBC 드라이버는 이 옵션이 꺼져 있으면 addBatch() 한 1,000건을 진짜로 1,000번 왕복시킵니다. 켜면 하나의 멀티 VALUES INSERT 로 합칩니다. Step 08 에서 이 옵션 하나로 쓰기 성능이 약 8배 달라지는 것을 실측합니다. Spring Batch 튜닝에서 가장 가성비 좋은 한 줄입니다.


P-8. 애플리케이션 클래스와 도메인

src/main/java/com/example/batch/BatchLabApplication.java:

package com.example.batch;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BatchLabApplication {

    public static void main(String[] args) {
        // 배치는 웹 서버가 아닙니다. Job 이 끝나면 JVM 도 끝나야 합니다.
        // exit() 로 감싸면 종료 코드(0/1)가 Job 결과를 반영합니다 — Step 14 에서 활용합니다.
        System.exit(SpringApplication.exit(SpringApplication.run(BatchLabApplication.class, args)));
    }
}

💡 spring-boot-starter-web 을 넣지 않았으므로 애플리케이션은 웹 서버 없이 뜨고, JobLauncherApplicationRunner 가 Job 을 다 돌린 뒤 컨텍스트가 닫히며 종료됩니다. starter-web 을 실수로 넣으면 Job 이 끝나도 톰캣이 살아 있어 프로세스가 안 죽습니다. 배치 프로젝트에서 자주 나는 사고입니다.

도메인 레코드 세 개. Java 21 의 record 를 씁니다.

package com.example.batch.domain;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public record Order(
        Long order_id,
        Integer customerId,
        BigDecimal amount,
        String status,
        LocalDateTime orderedAt
) {}
package com.example.batch.domain;

import java.math.BigDecimal;
import java.time.LocalDate;

public record Settlement(
        Long orderId,
        Integer customerId,
        LocalDate settleDate,
        BigDecimal grossAmount,
        BigDecimal feeRate,
        BigDecimal feeAmount,
        BigDecimal netAmount
) {}

⚠️ 함정 — recordBeanPropertyRowMapperBeanPropertyItemSqlParameterSourceProvider 에 안 맞습니다 record 의 접근자는 getAmount() 가 아니라 amount() 입니다. 자바빈 규약이 아닙니다.

  • 읽기: BeanPropertyRowMapper 대신 DataClassRowMapper 를 쓰면 생성자 기반으로 매핑됩니다(Spring 5.3+).
  • 쓰기: BeanPropertyItemSqlParameterSourceProvider동작하지 않습니다. 람다로 직접 MapSqlParameterSource 를 만드세요.

이 차이 때문에 "왜 모든 컬럼이 NULL 로 들어가지?" 하며 몇 시간 날리는 일이 흔합니다. Step 06, Step 08 에서 정면으로 다룹니다.

돈 계산은 반드시 BigDecimal 입니다.

⚠️ double 로 정산 금액을 다루면 0.1 + 0.2 != 0.3 이 되어 누적 오차가 정산서를 어긋나게 합니다. 100건이면 안 보이고 7만 건이면 보입니다. DECIMAL(12,2)BigDecimal 을 고정으로 씁니다.


P-9. 첫 기동 — 정상 종료 확인

이 시점에는 Job 이 하나도 없습니다. 그래도 떠야 정상입니다.

./gradlew bootRun

결과

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.5)

INFO 41211 --- [           main] c.e.batch.BatchLabApplication            : Starting BatchLabApplication using Java 21.0.2
INFO 41211 --- [           main] c.e.batch.BatchLabApplication            : No active profile set, falling back to 1 default profile: "default"
INFO 41211 --- [           main] com.zaxxer.hikari.HikariDataSource       : batch-pool - Starting...
INFO 41211 --- [           main] com.zaxxer.hikari.pool.HikariPool        : batch-pool - Added connection com.mysql.cj.jdbc.ConnectionImpl@6b7d1df8
INFO 41211 --- [           main] com.zaxxer.hikari.HikariDataSource       : batch-pool - Start completed.
INFO 41211 --- [           main] c.e.batch.BatchLabApplication            : Started BatchLabApplication in 1.842 seconds (process running for 2.103)

BUILD SUCCESSFUL in 4s

BUILD SUCCESSFUL 로 끝나야 합니다. 프로세스가 안 죽고 매달려 있다면 starter-web 이 딸려 들어온 것입니다. ./gradlew dependencies 로 확인하세요.

이제 메타데이터 테이블이 생겼는지 봅니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -e "SHOW TABLES LIKE 'BATCH%';"

결과

+---------------------------------+
| Tables_in_batchdb (BATCH%)      |
+---------------------------------+
| BATCH_JOB_EXECUTION             |
| BATCH_JOB_EXECUTION_CONTEXT     |
| BATCH_JOB_EXECUTION_PARAMS      |
| BATCH_JOB_EXECUTION_SEQ         |
| BATCH_JOB_INSTANCE              |
| BATCH_JOB_SEQ                   |
| BATCH_STEP_EXECUTION            |
| BATCH_STEP_EXECUTION_CONTEXT    |
| BATCH_STEP_EXECUTION_SEQ        |
+---------------------------------+

9개. 이 테이블 하나하나가 무슨 일을 하는지는 Step 01 에서 열어 봅니다.


P-10. 초기화 방법

실습이 꼬이면 언제든 되돌립니다.

# (a) 메타데이터만 지우기 — 업무 데이터는 유지
mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb <<'SQL'
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM BATCH_STEP_EXECUTION_CONTEXT;
DELETE FROM BATCH_STEP_EXECUTION;
DELETE FROM BATCH_JOB_EXECUTION_CONTEXT;
DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
DELETE FROM BATCH_JOB_EXECUTION;
DELETE FROM BATCH_JOB_INSTANCE;
SET FOREIGN_KEY_CHECKS = 1;
TRUNCATE TABLE settlement;
SQL

# (b) 완전 초기화 — 볼륨까지 날리고 시드부터 다시
cd docker
docker compose down -v
docker compose up -d

(a) 는 3초, (b) 는 약 90초 걸립니다(10만 행 시드 생성 포함).

💡 실무 팁 — (a) 를 셸 별칭으로 만들어 두세요 이 코스에서 (a) 를 수십 번 실행하게 됩니다. 특히 Step 03JobInstanceAlreadyCompleteException 실습과 Step 11 의 재시작 실습에서 그렇습니다.

alias batchreset='mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb < ~/batch-reset.sql'

정리

항목값 / 핵심
Java21 (toolchain 고정)
Spring Boot3.2.5
Spring Batch5.1.1 (Boot 가 관리)
빌드Gradle Groovy DSL
DBMySQL 8.0.36, 127.0.0.1:3308, batchdb
계정batch / batch1234 (관리: root / root1234)
메타데이터BATCH_* 9개 테이블, initialize-schema: always
업무 데이터customers 1,000 · products 200 · orders 100,000 · order_items 300,000 · settlement 0
정산 대상status='COMPLETED' 70,000건 (청크 1,000 → 정확히 70청크)
기간2025-01-01 ~ 2025-06-29 (180일)
결정론RAND() 금지, 나머지 연산만 사용
필수 JDBC 옵션rewriteBatchedStatements=true (Step 08 에서 8배 차이)
금액 타입DECIMAL(12,2)BigDecimal 고정

다음 단계

프로젝트가 뜨고 메타데이터 테이블 9개가 만들어졌습니다. 하지만 아직 Job 이 하나도 없어서 그 테이블들은 전부 비어 있습니다. 다음 스텝에서 가장 단순한 Job 을 하나 만들어 실행하고, 그 한 번의 실행이 9개 테이블에 어떤 흔적을 남기는지 행 단위로 추적합니다.

Step 01 — 환경 구축과 첫 Job