서비스 런칭을 위한 Spring Batch 분리 전략과 그 이유
이번 글에서는 신규 서비스인 '수강후기 공유 시스템' 런칭을 위해 교내 학생 및 강의 데이터를 데이터베이스에 주입하는 Spring Batch 프로젝트를 어떻게 설계하고 개발했는지에 대한 과정을 공유하고자 합니다.
이 과정에서 겪었던 도메인 일관성 문제(앞선 글 참고)를 해결하는 동시에, 왜 Spring Batch Job들을 독립적으로 분리하여 개발했는지 그 배경과 목표를 중점적으로 다룹니다.
배경 : 서비스 런칭과 데이터 일관성
수강후기 시스템은 학교의 핵심 데이터(학생 정보, 수강 정보, 강의 정보)를 기반으로 동작해야 합니다. 이 데이터들은 학과에서 정기적으로 제공하는 엑셀 파일 형태로 전달됩니다.
배치 프로젝트의 목표:
엑셀 파일을 전달받았을 때, 배치를 실행하여 데이터베이스의 데이터 일관성을 맞추는 것입니다. 기존 데이터는 수정하고, 존재하지 않는 데이터는 새로 추가하는 것이 주요 작업입니다.
이러한 정기적인 데이터 갱신 사이클을 처리하기 위해 다음과 같이 4개의 독립적인 Spring Batch 프로젝트를 구성했습니다.
├── batch-dummy (초기 설정용 등)
├── batch-enrollment (수강 신청 정보)
├── batch-lecture (강의 정보)
├── batch-student (학생 정보)

Job 분리 전략 : 독립적인 갱신 사이클 보장
가장 먼저 고민한 것은 이 네 가지 데이터 갱신 작업을 하나의 큰 Job으로 묶을 것인가, 아니면 개별 Job으로 분리할 것인가였습니다. 저희는 개별 Job 분리 전략을 선택했는데, 그 이유는 다음과 같습니다.
- 불필요한 DB I/O 회피 및 성능 최적화
데이터 갱신은 따로따로 하나씩 돌아갈 수 있도록 설계하고 싶었습니다.
만약 모든 갱신 로직을 하나의 Job에 Step으로 묶어두면, 일부 데이터(학생 정보)만 갱신이 필요할 때도 전체 Job을 실행하게 될 가능성이 높습니다. 이때 불필요한 Step은 건너뛰는 로직을 추가할 수도 있지만, 근본적으로 Job 실행 컨텍스트와 메타데이터 관리에 있어서 복잡도가 증가합니다.
개별 Job으로 분리하면, 필요한 데이터만 선택적으로 갱신하여 불필요한 DB I/O 및 데이터 확인 과정을 근본적으로 없애고 갱신 사이클의 효율성을 높일 수 있습니다.
- Job 간의 종속성 최소화
데이터 주입 작업 간에 Job 간의 종속성이 발생하지 않도록 설계하는 것이 중요했습니다.
예시:
batch-student가 실패하더라도batch-lecture는 성공해야하는 경우입니다.- Step 기준으로 한 Job에 묶어두면 Step 실패 시 전체 Job이 실패하게 되므로, 데이터들 간의 주입이 서로 종속적이지 않은 상황에서는 개별 Job이 더 유연합니다.
- 전체 갱신 사이클의 명확환 관리
분리된 Job들을 관리하기 위해 데이터 갱신 사이클을 명확히 정의했습니다.
이 과정을 원활히 진행하기 위해 전체 배치를 순차적으로 돌려야 할 때만 사용할 수 있도록 간단한 쉘 스크립트를 작성하여 관리 편의성을 확보했습니다.
- 구현: Flat File 처리와 ItemWriter 커스터마이징
교내 데이터는 엑셀에서 추출한 CSV 파일 형태로 제공됩니다. 데이터 구조가 비교적 Flat했기 때문에, Spring Batch의 표준 컴포넌트들을 활용했습니다.

- Reader :
FlatFileItemReader를 사용하여 CSV 파일을 읽고 DTO(ClassCsv) 객체에 매핑했습니다.
@Getter @Setter
@ToString
public class ClassCsv {
private Long year;
private String term; // 학기
// ... 기타 필드
}
- Writer : JPA를 통해 DB에 반영하는
ItemWriter커스텀 구현체를 사용했습니다. 기존 데이터의 존재 여부를 확인하고(Read), 데이터가 존재하면 업데이트(Update), 없으면 추가(Insert) 하는 로직입니다.
@Override
public void write(Chunk<? extends ClassCsv> chunk) throws Exception {
chunk.getItems().forEach(item -> {
try {
Lecture lecture = lectureDataRepository.findByLectureId(item.getClassNumber()).orElse(null);
if (lecture != null) {
// UPDATE 로직: 데이터가 존재하면 교수명 등을 갱신
lecture.updateProfessorName(item.getProfessor());
lectureDataRepository.save(lecture);
} else {
// INSERT 로직: (해당 예시에서는 WARN 처리. 실제로는 여기서 새로 생성)
log.warn("강의 ID {}를 찾을 수 없어 스킵.", item.getClassNumber());
}
} catch (Exception e) {
log.error("데이터 처리 실패: {}", item, e);
}
});
}
특히, Lecture 엔티티와 같이 연관관계가 2개 이상 존재하는 복잡한 엔티티를 제어할 때는 ItemWriter 내부에 로직의 분기점이 많이 구성되었습니다.
후기
예외 처리 구조를 추가적으로 구성하여 로직 처리 중의 문제 발생에 대한 재시도 로직과 같은 것을 구성하고 싶었으나, 빠른 런칭을 위해 데이터를 빨리 주입해야 하는 상황이어서 추가 개발에는 차질이 있었습니다.
최초의 데이터 주입과 연관관계가 존재하는 데이터 주입은 복잡도가 확연히 달라진다는 것을 체감한 프로젝트였습니다.