fos-blog/study
01 / 홈02 / 카테고리
01 / 홈02 / 카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 커머스/F&B 도메인 설계 면접 대비 — 슬롯 경험을 주문·결제·쿠폰·매장 상태 설계로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
    • stock-notes 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] CJ푸드빌 디지털 채널 Back-end 개발자 직무 분석
    • [초안] CJ푸드빌 디지털 채널 Back-end 면접 답변집 — 슬롯 도메인 경험을 커머스/F&B 설계로 번역하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/java/@StepScope — Step 실행마다 새…
java

@StepScope — Step 실행마다 새로운 빈을 만드는 이유

Spring의 빈 스코프는 기본이 singleton이다. 애플리케이션이 시작될 때 한 번 생성되고, 이후로는 같은 인스턴스를 계속 재사용한다. Spring Batch에는 @StepScope라는 커스텀 스코프가 있다. 이 스코프가 붙은 빈은 Step이 실행될 때마다 새로운 인스턴스가 생성된다. Step이 끝나면 인스턴스도 함께 소멸한다. java @Bean...

2026.03.22·6 min read·63 views

@StepScope가 뭔가

Spring의 빈 스코프는 기본이 singleton이다. 애플리케이션이 시작될 때 한 번 생성되고, 이후로는 같은 인스턴스를 계속 재사용한다.

Spring Batch에는 @StepScope라는 커스텀 스코프가 있다. 이 스코프가 붙은 빈은 Step이 실행될 때마다 새로운 인스턴스가 생성된다. Step이 끝나면 인스턴스도 함께 소멸한다.

java
@Bean
@StepScope
public ConfluencePageItemReader confluencePageItemReader(
    @Value("#{jobParameters['spaceKey']}") String spaceKey
) {
    return new ConfluencePageItemReader(spaceKey, ...);
}

왜 필요한가

1. Job Parameter를 빈 생성 시점에 주입받기 위해

@StepScope 없이 싱글톤 빈으로 만들면, 애플리케이션 컨텍스트가 로딩되는 시점에 빈이 생성된다. 이 시점에는 Job Parameter가 아직 없다. @Value("#{jobParameters['spaceKey']}")를 쓰면 null이 들어온다.

@StepScope를 붙이면 Step이 실제로 실행될 때 빈을 만들기 때문에, 그 시점에 이미 Job Parameter가 확정되어 있어서 값을 주입받을 수 있다.

plaintext
애플리케이션 시작 → Job Parameter 없음 → 싱글톤 빈 생성 → null 주입 ❌
 
배치 실행 요청 (spaceKey=MY_SPACE) → Step 시작 → @StepScope 빈 생성 → "MY_SPACE" 주입 ✅

2. Step 실행마다 상태를 초기화하기 위해

Reader, Processor 같은 Step 컴포넌트는 내부 상태를 가지는 경우가 많다. 예를 들어 페이지네이션 커서, 읽은 데이터 버퍼 같은 것들이다.

싱글톤이면 두 번째 Job 실행 시 이전 실행의 상태가 남아있을 수 있다. @StepScope로 Step마다 새 인스턴스를 만들면 이런 상태 누수를 원천 차단한다.

3. 여러 Job이 동시에 실행될 때 격리하기 위해

Job A와 Job B가 같은 Reader 타입을 쓰는데 싱글톤이라면, 두 Job이 같은 Reader 인스턴스를 공유하게 된다. @StepScope를 쓰면 각 Step 실행마다 독립적인 인스턴스가 생기므로 Job 간 간섭이 없다.

프록시 방식으로 동작한다

@StepScope 빈은 애플리케이션 컨텍스트 로딩 시점에 프록시 객체가 먼저 등록된다. 실제 인스턴스는 Step이 시작될 때 프록시가 생성해서 반환한다.

이 때문에 한 가지 주의할 점이 있다. @StepScope 빈 클래스가 final이면 CGLIB 프록시를 만들 수 없어서 예외가 난다.

java
// ❌ CGLIB 프록시 생성 불가
@StepScope
public final class ConfluencePageItemReader { ... }
 
// ✅
@StepScope
public class ConfluencePageItemReader { ... }

추상 클래스의 메서드가 final인 경우도 마찬가지다. 상속이나 프록시로 오버라이드할 수 없기 때문이다.

@JobScope도 있다

@StepScope와 비슷한 개념으로 @JobScope도 있다. Job이 실행될 때마다 새로운 빈을 생성한다. Step 간에 공유해야 하는 상태가 있을 때 Tasklet에 주로 쓴다.

java
@Bean
@JobScope
public StartIndexingJobTasklet startIndexingJobTasklet(
    @Value("#{jobParameters['physicalIndexName']}") String physicalIndexName
) {
    return new StartIndexingJobTasklet(physicalIndexName, ...);
}

@JobScope 빈으로 Job 단위 인메모리 상태 관리하기

@JobScope의 더 강력한 활용은 Job 실행 중 여러 Step이 공유하는 도메인 데이터를 인메모리로 관리하는 것이다.

예를 들어 배치 Job이 이런 흐름으로 실행된다고 하자.

plaintext
1. getSpaceInfoStep  → Space 정보 수집
2. pageIdCollectStep → 전체 페이지 ID 수집
3. pageIndexingStep  → 페이지 인덱싱 (2에서 수집한 ID 사용)
4. commentIndexingStep → 댓글 인덱싱 (1에서 수집한 Space 정보 사용)

초기 구현에서는 Step 간 데이터를 JobExecutionContext에 저장하기 쉽다.

java
// ❌ JobExecutionContext에 도메인 데이터 저장
jobExecution.getExecutionContext().put("pageIds", pageIds);       // 수백 KB
jobExecution.getExecutionContext().put("pageTitles", pageTitles); // 수 MB

JobExecutionContext는 매 청크 커밋마다 BATCH_JOB_EXECUTION_CONTEXT 테이블에 직렬화된다. 도메인 데이터가 크면 매 커밋마다 수 MB를 DB에 read/write하게 된다. JobExecutionContext는 재시작을 위한 커서 위치 같은 경량 상태를 저장하는 용도로 설계된 것이다.

@JobScope 빈으로 옮기면 이 문제가 해결된다.

java
@Getter
@Component
@JobScope
public class BatchJobDataHolder {
 
    private SpaceInfo space;
    private List<String> pageIds = new ArrayList<>();
    private Map<String, String> pageTitles = new HashMap<>();
 
    public @Nonnull SpaceInfo getSpace() {
        if (space == null) {
            throw new IllegalStateException(
                "Space가 로드되지 않았습니다. getSpaceInfoStep이 실행되었는지 확인하세요.");
        }
        return space;
    }
 
    public void updateSpace(SpaceInfo space) { this.space = space; }
 
    public void updatePageIdsAndTitles(List<String> pageIds, Map<String, String> pageTitles) {
        this.pageIds = pageIds;
        this.pageTitles = pageTitles;
    }
}

이 빈을 싱글톤 @Configuration에 주입해도 안전하다. @JobScope는 내부적으로 proxyMode = ScopedProxyMode.TARGET_CLASS를 포함하기 때문이다. 싱글톤에 주입되는 것은 CGLIB 프록시이고, 실제 호출 시 현재 Job 스코프의 인스턴스로 위임된다.

java
// Spring Batch 소스
@Scope(value = "job", proxyMode = ScopedProxyMode.TARGET_CLASS)
public @interface JobScope { }

재시작 시 @JobScope 빈 초기화 문제

@JobScope 빈을 사용할 때 반드시 챙겨야 할 함정이 있다. Job이 중간에 실패해서 재시작하면, Spring Batch는 새로운 JobExecution을 생성한다. 즉 @JobScope 빈도 새 인스턴스로 초기화된다.

plaintext
1차 실행: JobExecution #1 → BatchJobDataHolder 인스턴스 A (pageIds 로드됨)
실패 발생
재시작:   JobExecution #2 → BatchJobDataHolder 인스턴스 B (pageIds 비어있음!)

이미 COMPLETED 처리된 Step들은 재시작 시 스킵된다. 상태를 로드하는 Step들이 스킵되면 인메모리 빈이 빈 상태로 남아 이후 Step에서 NPE나 IllegalStateException이 발생한다.

해결책은 allowStartIfComplete(true)다. 상태 로더 역할을 하는 Step에 이 옵션을 설정하면, 이전 실행에서 COMPLETED가 되었어도 재시작 시 반드시 다시 실행된다.

java
@Bean
public Step getSpaceInfoStep(GetSpaceInfoTasklet tasklet) {
    return new StepBuilder("getSpaceInfoStep", jobRepository)
        .tasklet(tasklet, transactionManager)
        .listener(tasklet)
        .allowStartIfComplete(true)  // 재시작 시에도 반드시 재실행
        .build();
}
 
@Bean
public Step pageIdCollectStep(PageIdCollectTasklet tasklet) {
    return new StepBuilder("pageIdCollectStep", jobRepository)
        .tasklet(tasklet, transactionManager)
        .listener(tasklet)
        .allowStartIfComplete(true)  // 재시작 시에도 반드시 재실행
        .build();
}

인메모리 상태를 초기화하는 Step들은 멱등성을 갖도록 구현하는 것도 중요하다.

java
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    // 이미 로드된 경우 스킵 (allowStartIfComplete로 재실행되어도 이중 로드 방지)
    if (!jobDataHolder.getPageIds().isEmpty()) {
        log.info("이미 수집된 pageIds가 있습니다. ({} 개)", jobDataHolder.getPageIds().size());
        return RepeatStatus.FINISHED;
    }
    // ... 수집 로직
}

JobExecutionContext 직렬화 버그

JobExecutionContext를 쓸 때 ExecutionContext를 값으로 중첩 저장하면 Jackson이 제대로 직렬화하지 못하는 버그가 있다.

java
// ❌ ExecutionContext를 ExecutionContext 안에 저장
jobExecution.getExecutionContext().put("SHARED_CONTEXT", new ExecutionContext(map));
// → DB에 {"dirty":true,"empty":false} 로만 저장됨

ExecutionContext는 isDirty(), isEmpty() getter만 노출되기 때문에 Jackson이 실제 데이터를 직렬화하지 못한다. 재시작 시 값을 읽으면 빈 컨텍스트가 반환된다.

Map<String, Object>로 저장하고, 읽을 때 ExecutionContext로 변환하는 방식으로 해결한다.

java
// ✅ Map으로 저장
Map<String, Object> innerMap = new HashMap<>(data);
jobExecution.getExecutionContext().put("SHARED_CONTEXT", innerMap);
 
// 읽을 때 변환
Object value = jobExecution.getExecutionContext().get("SHARED_CONTEXT");
if (value instanceof Map<?, ?> map) {
    return new ExecutionContext((Map<String, Object>) map);
}

여러 Job이 같은 타입의 @StepScope 빈을 등록할 때

두 Job Config가 각각 같은 타입의 @StepScope 빈을 정의하면 문제가 생긴다.

plaintext
expected single matching bean but found 2:
confluencePageItemEmbeddingProcessor,
planGymConfluencePageItemEmbeddingProcessor

Spring이 타입으로 의존성을 찾을 때 어느 것을 써야 할지 몰라서 터진다.

해결 방법은 두 가지다.

1. 공용 빈은 @Component @StepScope로 전역 등록하고 @Qualifier로 주입

java
// 공용으로 쓰는 빈은 @Component 붙여서 등록
@Component
@StepScope
public class SharedContextRefreshIndexTasklet implements Tasklet { ... }
 
// 주입할 때 이름으로 찾게 함
@Bean
public Step confluenceIndexRefreshStep(
    @Qualifier("sharedContextRefreshIndexTasklet") SharedContextRefreshIndexTasklet tasklet
) { ... }

2. 잡 전용 빈은 각자 Config에서만 정의하고 @Qualifier로 명시 주입

java
// ConfluenceIndexingJobConfig에서
@Bean
@StepScope
public ConfluencePageItemEmbeddingProcessor confluencePageItemEmbeddingProcessor(
    DefaultConfluenceDocumentMetadataProvider metadataProvider, ...
) { ... }
 
// PlanGymConfluenceSpaceIndexingJobConfig에서
@Bean
@StepScope
public ConfluencePageItemEmbeddingProcessor planGymConfluencePageItemEmbeddingProcessor(
    PlanGymConfluenceDocumentMetadataProvider metadataProvider, ...
) { ... }
 
// Step 정의 시 @Qualifier로 명시
@Bean
public Step confluencePageIndexingStep(
    @Qualifier("confluencePageItemEmbeddingProcessor") ConfluencePageItemEmbeddingProcessor processor, ...
) { ... }

테스트 코드에서도 맞춰줘야 한다

@BatchComponentTest처럼 전체 Spring Context를 띄우는 테스트에서 @Autowired로 빈을 주입받을 때도 @Qualifier를 붙여야 한다. 빠뜨리면 같은 에러가 난다.

java
@Autowired
@Qualifier("confluencePageItemEmbeddingProcessor")  // 필수
private ConfluencePageItemEmbeddingProcessor processor;

정리

스코프인스턴스 생성 시점소멸 시점주 용도
singleton (기본)애플리케이션 시작애플리케이션 종료상태 없는 공용 서비스
@JobScopeJob 시작Job 종료Job Parameter 주입, Job 수준 공유 상태
@StepScopeStep 시작Step 종료Job Parameter 주입, Step 컴포넌트 상태 격리

Reader, Processor, Writer처럼 Step 실행 중 상태를 가지는 컴포넌트, 또는 Job Parameter를 생성 시점에 받아야 하는 컴포넌트라면 @StepScope를 붙이는 게 원칙이다.

on this page
  • 01@StepScope가 뭔가
  • 02왜 필요한가
  • 1. Job Parameter를 빈 생성 시점에 주입받기 위해
  • 2. Step 실행마다 상태를 초기화하기 위해
  • 3. 여러 Job이 동시에 실행될 때 격리하기 위해
  • 03프록시 방식으로 동작한다
  • 04@JobScope도 있다
  • @JobScope 빈으로 Job 단위 인메모리 상태 관리하기
  • 재시작 시 @JobScope 빈 초기화 문제
  • JobExecutionContext 직렬화 버그
  • 05여러 Job이 같은 타입의 @StepScope 빈을 등록할 때
  • 06정리

댓글 (0)