실무에서 가장 간단하면서 표준적이고 강력한 포맷 중 하나는 JSON일 것이다.
단순 API를 만들때도, 그 명세(RequestBody, ResponseBody)를 보통 JSON으로 표현할 만큼 JSON은, 특히나 금융권에서, 가장 많이 접하고 그만큼 잘 숙지하고 있어야 하는 형태이다.
flatFile 형태와 별개로 JSON에 대해 섹션을 구성하고 이에 대해 살펴보는 이유도, flatFile과 JSON은 애초에 별개의 개념이기도 하고 JSON에 대해 구체적으로 살펴보고 부가적인 개념들을 알아가는 것만해도 실무적으로 유연하게, 확장하여 적용할 수 있는 부분이 너무나도 많이 때문이다.
이러한 이유로, Json에 대해 Batch Reader/Writer를 구성하고 동작하는 과정에 대해 분석한 과정을 기록하여 남긴다.
json 형태와 관련한 3가지 포멧에 대해 먼저 알아보자.
Json이란 JavaScript Object Notation'의 약자로, 데이터를 쉽게 저장하고 교환하기 위한 텍스트 기반의 표준 포맷이다.
데이터 교환 관점에서, 복잡한 데이터를 사람이 읽기 쉬운 형식으로 표현하할 뿐만 아니라 이를 경량화하여, 웹 서버와 클라이언트 간의 데이터 전송 등 다양한 프로그래밍 언어에서 간결하게 손쉽게, 구조화된 데이터를 주고받을 때 주로 사용한다.
보통 중괄호({})를 사용할때, 대괄호 쌍 하나를 Json 객체라 표현한다.
이때,
{
"id": 1,
"name": "Alice",
"hobbies": ["reading", "gaming"]
}
이와 같이, Json은 객체 혹은 객체+배열로 이루어진 구조인데, Json은 중괄호가 하나의 쌍, 즉 하나의 객체로 구조화된 형태를 의미한다.
API 응답, 설정 파일 등에 가장 자주 사용되며(REST API), 문서 전체가 하나의 JSON(객체) 구조이다.
내부에 배열 포함이 가능하지만, 최상위는 위와 같이 하나의 객체로 되어있다.
json객체 여러개가 배열로 이루어진 형태로, 대괄호([])안에 중괄호({})가 여러 개, 즉 하나의 배열에 여러개의 Json 객체가 구성되어 있는 형태이다.
[
{
"id": 1,
"name": "Alice"
},
{
"id": 2,
"name": "Bob"
}
]
위와 같은 형태이며, 배열 내부에 여러 json 객체를 넣어 하나의 파일로 관리할 수 있다.
json 자체가 간결하고 구조화된 데이터 교환을 지향하기에, 대용량 데이터 교환 시 json array형태는 사실 그리 많이 사용하는 형태는 아니다(파싱이 쉽지 않다).
json lines, 한 줄에 하나의 json 객체가 이루어진 형태이다.
전체 파일이 하나의 json 객체가 아닌 여러개로 이루어져 있으며, 파싱이 쉽고(이를 파싱해주는 도구를 Spring/Spring Batch에서 지원) 한 줄씩 읽으면서 처리하면 되기에 대용량 처리 시 적합한 구조이기도 하다.
{"id":1,"name":"Alice"}
{"id":2,"name":"Bob"}
{"id":3,"name":"Charlie"}
위와 같은 형태이며, 줄단위로 처리하기에 메모리에 모든 데이터를 로드할 필요가 없으므로 메모리 과부하 발생여지가 상당부분 줄어든다.
쉼표, 배열이 없고 단지 한 줄에 하나의 json 객체만 존재할 뿐이다.
이를 정리하면 다음과 같다.
| 형식 | 구조 | 예시 형태 | 장점 | 단점 | 주요 용도 |
|---|---|---|---|---|---|
| JSON 배열 | 파일 전체가 [ ] 로 감싸진 배열 | [ {...}, {...} ] | 직관적, API에서 많이 씀 | 전체 파싱 필요 | 일반 데이터 응답 |
| JSON(객체) | 단일 JSON 구조 | {...} | 가장 범용적 | 대용량에는 부적합 | 설정, API 응답 |
| JSONL | 한 줄 = 하나의 JSON 객체 | {...}\n{...} | 대용량 스트리밍 처리 최적 | 사람에게는 덜 읽기 좋음 | 로그, ML 데이터, 빅데이터 |
이제 본격적으로 json을 활용한 spring batch reader/writer 과정에 대해 분석해보도록 하겠다.
{"command":"destroy","cpu":99,"status":"memory overflow"}
{"command":"explode","cpu":100,"status":"cpu meltdown"}
{"command":"collapse","cpu":95,"status":"disk burnout"}
위와 같은 jsonl 파일을 batch로 읽는 job을 구성해보고자 한다.
먼저 step을 아래와 같이 reader, writer로 구성하도록 하고 chunk지향처리로 처리하며 Input type과 Output type모두 동일한 객체로 구성한다.
@Bean
public Step systemDeathStep(
FlatFileItemReader<SystemDeath> systemDeathReader
) {
return new StepBuilder("systemDeathStep", jobRepository)
.<SystemDeath, SystemDeath>chunk(10, transactionManager)
.reader(systemDeathReader)
.writer(items -> items.forEach(System.out::println))
.build();
}
이후, reader에서 lineMapper의 객체화를 기존의 fieldSetMapper가 아닌 ObjectMapper로 구성해주도록 해야 한다.
@Bean
@StepScope
public FlatFileItemReader<SystemDeath> systemDeathReader(
@Value("#{jobParameters['inputFile']}") String inputFile) {
return new FlatFileItemReaderBuilder<SystemDeath>()
.name("systemDeathReader")
.resource(new FileSystemResource(inputFile))
.lineMapper((line, lineNumber) -> objectMapper.readValue(line, SystemDeath.class))
.build();
}
위와 같이 fieldsetMapper가 아닌, json "객체"를 읽기 위한 objectMapper로 설정해주도록 하며 objectMapper는 spring에서 제공하는 내장 모듈(jackson)이자 파싱 도구이다(참고로 JsonLineMapper로 json 객체매핑을 지원하지만, 범용적인 objectMapper로 대체적용이 가능하다).
./gradlew batch:json:bootRun --args='--spring.batch.job.name=systemDeathJob inputFile=C:\study\kill-batch-system-boot\system_death.jsonl'
batch를 위 스크립트를 통해 실행하면

위와 같이 job을 실행하며 Step 내부의 json reader를 실행하는 것을 확인할 수 있다.

항상 상기하는 부분이지만, 인코딩은 반드시 UTF-8(BOM없이) 해야한다. 이를 위해선 윈도우의 경우 수동으로 인코딩 형태를 바꿔야 하는데, jsonl파일은 따로 에디터가 없기 때문에 메모장에서 더블쿼터("")를 활용해서 확장자명은 그대로 유지하되 인코딩은 UTF-8로 바꾸도록 하자.
또한, 만약 writer 로그가 비정상적으로 찍히거나 중복으로 나타난다면, 내부적으로 job명이 중복되는지 확인하도록 한다.
jsonl 파일이 위의 경우처럼 한 줄씩 처리되어있다면 lineMapper로만 파싱이 가능하지만,
{
"command" : "destroy"
}
{
"command" : "explode"
}
이처럼 하나의 json객체가 한 줄이 아닌 여러 줄에 걸쳐 처리되어있다면, lineMapper로는 파싱을 할 수 없고 Json 객체를 구분할 recordSeperatorPolicy메서드를 추가적으로 사용하여 객체간 경계를 구분해주어야 한다.
@Bean
@StepScope
public FlatFileItemReader<SystemDeath> systemDeathReader(
@Value("#{jobParameters['inputFile']}") String inputFile) {
return new FlatFileItemReaderBuilder<SystemDeath>()
.name("systemDeathReader")
.resource(new FileSystemResource(inputFile))
.lineMapper((line, lineNumber) -> objectMapper.readValue(line, SystemDeath.class))
.recordSeparatorPolicy(new JsonRecordSeparatorPolicy())
.build();
}
이처럼 lineMapper를 그대로 구성하되, 이 "한 줄"에 대해 사용자정의대로 혹은 spring batch가 제공해주는 구현체를 통해 명확히 지정해줄 수 있다.
./gradlew batch:json:bootRun --args='--spring.batch.job.name=systemDeathPrettyJsonJob inputFile=C:\study\kill-batch-system-boot\system_death2.jsonl'
이 batch를 실행해주면

이와 같이 정상적으로 파싱해주었음을 확인할 수 있다.
이러한 lineMapper와 혼용할 수 있는 RecordSeperatorPolicy 인터페이스의 구현체는 그리 많지는 않은데, 지금 사용한 JsonRecordSeperatorPolicy와 SimpleRecordSeperatorPolicy에 대해서만 일단 숙지하고 있으면 될 것이다.
Customized한 policy도 있겠지만, 연식이 매우 오래된 증권시스템과 같은 legacy에서는 xml파싱을 위한 XMLRecordSeperatorPolicy 정도를 쓸 수도 있다.
| 구현체명 | 제공 여부 | 주요 목적 | 실무 사용성 | 특징 |
|---|---|---|---|---|
| SimpleRecordSeparatorPolicy | Spring Batch 공식 제공 | 빈 줄 또는 특정 구분 기반으로 레코드 경계 판단 | 매우 높음 | recordSeparator = "" 기본. 텍스트 기반 구조에 가장 범용적 |
| JsonRecordSeparatorPolicy | Spring Batch 공식 제공 (Spring Batch 4.3+) | JSON 구조( { ... }, [ ... ] )의 중괄호 깊이를 기반으로 하나의 JSON 객체/배열을 완성 레코드로 판단 | 매우 높음 | 파일 전체가 JSON Lines(JSONL) 아닌 경우에 유용 (depth 기반) |
| DefaultRecordSeparatorPolicy | Spring Batch 공식 제공 | (Simple과 비슷하나) prefix/suffix trimming 지원 중심 | 보통 | Simple 기반 동작을 커스터마이즈한 형태 |
| CompositeRecordSeparatorPolicy (직접 구현) | 사용자 정의 | 여러 정책을 합성하여 판단 | 특정 도메인에서만 | CSV+메타라인, 고정길이+헤더 등 복잡한 구조 |
| RegexRecordSeparatorPolicy (직접 구현) | 사용자 정의 | 정규식으로 레코드 종료 판단 | 중간 | “END”, “###”, “<EOF>” 같은 명시적 종료 패턴 |
| XMLRecordSeparatorPolicy (직접 구현) | 사용자 정의 | XML 태그의 depth 기반 레코드 분리 | 낮음 | XML 파싱 자체가 StAX 기반 Reader로 더 자주 처리 |
위 표를 참고하면서 사용하면 도움이 될 것이다.
참고로 JsonRecordSeperatorPolicy 구현체와 SimpleRecordSeperatorPolicy 구현체는 별개의 구현체이며, simpleRecordSeperatorPolicy의 경우 클래스명에서 추측할 수 있듯이 줄 단위로 객체를 파싱한다.
| 항목 | SimpleRecordSeparatorPolicy | JsonRecordSeparatorPolicy |
|---|---|---|
| 상속 구조 | RecordSeparatorPolicy 직접 구현 | RecordSeparatorPolicy 직접 구현 |
| 목적 | 단순 줄 단위 텍스트 분리(빈 줄 등) | JSON 개체/배열의 중괄호 깊이 기반 분리 |
| 구조 판단 방식 | line.equals(recordSeparator) | { } 개수 count 및 depth 관리 |
| 적합한 파일 | TXT/CSV/로그 파일 | JSON, JSON 배열, JSON 단일 객체 반복 |
RecordSeparatorPolicy는 아래와 같이 크게 3가지 기능을 가진다.
public interface RecordSeparatorPolicy {
boolean isEndOfRecord(String line);
String preProcess(String line);
String postProcess(String record);
}
이때 구현체들이 사용한 기능은 isEndOfRecord로, 읽어올 객체의 시작과 끝을 구분하기 위해 true의 조건을 customized하였다고 볼 수 있다.
JsonRecordSeperatorPolicy의 경우,
@Override
public boolean isEndOfRecord(String line) {
updateDepthCount(line);
return depth == 0; // { } depth가 0이 되어야 하나의 JSON 개체 완성
}
위와 같이 중괄호의 한 쌍이 맞는지에 대해 boolean으로 판단하여 한 줄(한 객체)의 여부를 판단하며
일반적인 한 줄, SimpleRecordSeperatorPolicy의 경우,
@Override
public boolean isEndOfRecord(String line) {
return !line.trim().isEmpty();
}
이와 같이 개행을 객체의 기준으로 판단한다.
이외 Customized 한다면,
return line.endsWith("END");
return line.length() >= fixedLength;
끝문자 및 한줄의 고정길이 등을 기준으로 true를 전달해주어 한 객체로 인식할 수 있도록 구현해주면 되겠다.
preProcess나 postProcess의 경우, 이 객체 한 덩어리를 읽고 이를 레코드 객체화하기 전/후로 각각 전/후처리를 하는 용도이다.
만약 실무에서 단순 jsonl파일이 아니라면, 해당 파싱 요구사항을 만족하기 위한 복잡한 정책을 직접 Cusotmized하여 구현해주어야 할 것이다.
json객체들이 배열형태로 이루어질 수도 있다.
[
{"command":"destroy","cpu":99,"status":"memory overflow"},
{"command":"explode","cpu":100,"status":"cpu meltdown"},
{"command":"collapse","cpu":95,"status":"disk burnout"}
]
위에서 살펴보았던 json array 형태인데, 보통은 jsonl로 가능하지만 REST API의 응답형태나 몇몇의 경우에는 json 배열 형태로 파일이 저장될 것이다.
이를 위한 파싱도구를 spring batch에서는 JsonObjectReader 인터페이스를 제공하며, 이에 대한 구현체로 JacksonJsonObjectReader(Jackson), GsonJsonObjectReader(Gson)을 제공하여 준다.
이에 대한 자세항 사항은 밑에 후술하며, 쉽게 말하면 Jackson자체가 Spring framework에서 제공하는 가장 대표적인 json 파싱도구이기에, target class가 무엇인지(객체화)만 생성자 매개변수로 전달해주면 된다.
이 json array 형태의 경우, 일전의 flatFileItemReader와는 달리 JsonItemReader의 형태로 구성해주어야 한다.
@Bean
@StepScope
public JsonItemReader<SystemDeath> systemDeathReader(
@Value("#{jobParameters['inputFile']}") String inputFile) {
return new JsonItemReaderBuilder<SystemDeath>()
.name("systemDeathReader")
.jsonObjectReader(new JacksonJsonObjectReader<>(SystemDeath.class))
.resource(new FileSystemResource(inputFile))
.build();
}
이처럼 JsonItemReader를 구성해주는 것 이외에는 별다른 특이사항은 없다.
./gradlew batch:json:bootRun --args='--spring.batch.job.name=JacksonObjectJob inputFile=C:\study\kill-batch-system-boot\system_death3.jsonl'
이를 실행해주면

정상적인 파싱이 이루어졌음을 확인할 수 있다.
마지막으로 파일을 json 배열 형식으로 write하는 과정을 살펴보도록 한다.
여기서 핵심적으로 사용하는 컴포넌트는 JsonObjectMarshaller 인터페이스이다.
이 인터페이스에 대한 구현체는 JacksonJsonObjectMarshaller, GsonJsonObjectMarshaller이며, 이 두 구현체 모두 json 배열 형식의 write 형태를 제공한다.
두 구현체의 차이점은 내부적으로 Jackson 라이브러리(ObjectMapper)를 사용하는지, Gson 라이브러리를 사용하는지, 나아가 동작원리가 스트리밍 방식의 처리인지 리플렉션 기반의 처리인지에 대한 차이일 뿐이다.
이외의 형식이 필요하면 JsonObjectMarshaller 인터페이스를 Customized하게 구현하면 된다.
@Bean
@StepScope
public JsonFileItemWriter<SystemDeath> systemDeathWriter(
@Value("#{jobParameters['outputDir']}") String outputDir
){
return new JsonFileItemWriterBuilder<SystemDeath>()
.jsonObjectMarshaller(new JacksonJsonObjectMarshaller<>())
.resource(new FileSystemResource(outputDir + "/death_notes.json"))
.name("logEntryJsonWriter")
.build();
}
이와 같이 jsonFileItemWriter를 구성하여주고, writer 컴포넌트로 jsonObjectMarshaller 구현체를 등록하면 된다.
./gradlew batch:json:bootRun --args='--spring.batch.job.name=JsonArrayWriteJob inputFile=C:\study\kill-batch-system-boot\system_death3.jsonl outputDir=C:\Users\gyrbs\OneDrive\Desktop '
이를 실행하면

이와 같이 reader step을 통해 읽은 필드들을 json 배열형태로 write한 결과를 볼 수 있다.
지금까지 flatFile/Json을 Read, Write하는 일련의 과정과 특징에 대해 분석해보았다.
flatFile은 .csv, .txt, fixedLength, log file, jsonl 등 특히 금융권에서 많이 접해볼 수 있는 다양한 파일에 대해, Spring job으로 이를 어떻게 읽고 쓸 수 있을지 다양한 방법에 대해 알아보았다.
단순히 알아본 정도가 아니라 관련 개념 및 동작 원리 등에 대해 면밀하게 살펴보면서, 실무적으로 즉각 적용할 수 있을 정도로 단계적으로 찬찬히 분석하였다.
사실 flatFile에 대한 부분도 중요하지만, Database와 함께 Spring Job을 다루는 것 역시 상당히 중요한 부분으로, 다음 과정이 드디어 DB를 Spring Job으로 다루고 처리하는 것에 대한 내용이다.
이 부분은 특히, 지금까지 단순히 cron을 활용한 스케쥴링으로 내부 온프레미스 DB에 대한 일괄 분석작업으로 알고 있었던 기존의 batch 개념을 완전히 뜯어고치고, 스케쥴링과 배치 개념을 구별해가면서 실무적으로 유연하게 활용가능한 Spring job을 비로소 이해할 수 있는 기회로 바라보아야 한다.
Database 기반의 Spring Job을 다루는 방법에 대해 나아가보도록 하자.
Reader와 Writer에서 활용하는 핵심적인 요소와 과정에 대해 표로 정리해보았다.
LineMapper로 읽고 객체화, 내부적으로 LinkeTokenizer 및 FieldSet 객체 및 FieldSetMapper가 존재한다(Json의 경우 FieldSetMapper대신, ObjectMapper로 한 줄 읽을때마다 지정해준 class 객체로 읽는 것).
| 키워드 | 역할 | 동작 요약 | 위치 |
|---|---|---|---|
LineMapper<T> | 한 줄(String) → 객체(T) 매핑 | 한 줄을 받아서 Tokenize → FieldSet → 객체 변환 전체 과정 Orchestrate | Reader 내부 |
| LineTokenizer | 한 줄 → FieldSet 으로 나누기 | CSV, Fixed-Length 등 다양한 Tokenizer 제공 (DelimitedLineTokenizer 등) | LineMapper 내부 구성 |
| FieldSet | Tokenized 데이터의 집합 | FieldSet은 스키마 기반 데이터 그룹 (getString, getDate 등 제공) | LineTokenizer 결과 |
FieldSetMapper<T> | FieldSet → 객체(T) 변환 | FieldSet 속성 값들을 받아서 도메인 객체 생성 | LineMapper 내부 |
| RecordSeparatorPolicy | 레코드 단위 분리 논리 | 기본은 개행(\n) 기준, multi-line record 처리도 가능 | Reader 옵션 |
| SkippedLinesCallback | 헤더 등 Skip된 줄 처리 | skipLines(예: 1) 시 해당 줄을 Callback로 전달 | Reader 옵션 |
| LineCallbackHandler | 각 라인 읽을 때 후처리 | 각 라인 읽고 특정 비즈니스 처리 시 사용 | Reader 옵션 |
| Encoding | 파일의 문자 인코딩 설정 | UTF-8, EUC-KR 등 지정 가능 | Reader 옵션 |
| BufferedReaderFactory | Reader 생성 전략 | 디폴트는 BufferedReader 사용, 성능/인코딩 조정 가능 | Reader 옵션 |
fieldSetMapper를 통해 읽어온 객체를 다시 필드로 역직렬화, 이를 LineAggregator를 통해 진행하며 내부적으로 FieldExtractor를 통해 Write를 진행한다.
| 키워드 | 역할 | 동작 요약 | 위치 |
|---|---|---|---|
LineAggregator<T> | 객체(T) → 문자열 라인 변환 | Writer에서 한 객체를 문자열 라인으로 만들어 파일에 기록 | Writer 핵심 내부 |
FieldExtractor<T> | 객체(T) → 필드 배열(Object[]) | 객체에서 특정 필드만 추출해 LineAggregator로 전달 | LineAggregator 내부 구성 |
| FormattedLineAggregator | format 기반 라인 생성 | printf style 포맷 사용 가능 | Writer 내부 |
| DelimitedLineAggregator | CSV 등 구분자 기반 라인 생성 | FieldExtractor 결과를 delimiter로 join | Writer 내부 |
| HeaderCallback | 파일 헤더 작성 | Writer 오픈 시 가장 처음 한 번 실행 | Writer 옵션 |
| FooterCallback | 파일 푸터 작성 | Writer 닫힐 때 가장 마지막에 실행 | Writer 옵션 |
| AppendAllowed | 기존 파일에 이어쓰기 | 파일에 append 모드 활성화 여부 | Writer 옵션 |
| Encoding | 파일 문자 인코딩 | UTF-8 등 지정 | Writer 옵션 |
json, jackson, gson은 모두 json에서 파생되어 나온 개념들이며, 본질적으로 접근하기보다는 json을 "어떻게" 다루는지, 그 사용법에 대해 정리한 기능 혹은 라이브러리의 관점에서 바라보는 것이 적절하다.
json은 가장 기본적인 데이터 구조화 방법이자, 그만큼 오래된 json 라이브러리이다. 단순 json 객체구조부터 json array/jsonl 등 여러 형태를 제공하여 준다.
이에 대한 파싱도구를 제공해주는 가장 대표적인 두가지 라이브러리가 Jackson, Gson이다.
가장 범용적·기업 실무에서 가장 많이 쓰이는 JSON 라이브러리이다.
스트리밍 API(Streaming / Event 기반) 제공(하나의 파일을 메모리에 모두 올려서 reading하는 것이 아닌, 한줄 한줄 record를 읽어가면서 reading하는 방식)이기에 대용량 JSON 처리 가능하고, 속도가 빠르다.
어노테이션 기반 커스터마이징(@JsonProperty, @JsonIgnore 등)이 가능하며, Spring Framework에서 기본적으로 Jackson을 선호한다.
위에서 사용한 json 객체 분석도구(ObjectMapper)처럼, 기능 풍부(ObjectMapper, JsonParser, JsonGenerator)하며 Record, Kotlin 지원도 좋다.
더불어 Mapper를 통한 Data Binding 기능이 압도적이지만, Customized해야 한다는 단점이 있고 제공하는 기능이 그만큼 매우 많고 복잡해서 실무적으로 적절하게 잘 사용해야 한다.
Google에서 제공하는 JSON 매퍼로, 코드가 가볍고 그만큼 직렬화/역직렬화가 단순하다.
다만 객체화(파싱) 시 Reflection 기반으로 작동하여(target Class와 비교검색하면서 객체화), Jackson보다는 매핑과정이 느리고 대규모 데이터 스트리밍에 비효율적이다.
그래도 사용법이 매우 단순하며 가볍고 모듈 구조가 쉽기에, 용량이 작은 JSON에는 성능 좋은 편이다.