이번 글에서는 ItemReader의 여러 구현체들에 대해 알아보겠습니다.
먼저 FlatFileItemReader에 대해 알아볼텐데요, DB가 아닌 Resource(주로 txt, csv)에서 데이터를 읽어오는 데 사용됩니다. 값을 한 줄 단위로 읽어오는데, 내부에서 LineMapper 인터페이스의 구현체를 이용해 읽어온 데이터를 Object 타입으로 변환합니다.
바로 실제 코드를 작성해보면서 내부적으로 어떻게 돌아가는 지 확인해보겠습니다. 먼저 우리가 읽어들일 csv 파일을 생성합니다. 경로는 소스 내 resources 바로 아래에 위치합니다. 맨 윗줄은 타이틀(컬럼)을 의미합니다. csv 파일을 생성할 때의 주의점은 빈 줄도 유효한 줄로 인지하기 때문에 공백 줄이 없도록 조심해야 합니다.
name,year,age
user1, 2000, 24
user2, 2001, 23
user3, 2002, 22
user4, 2003, 21
user5, 2004, 20
user6, 2005, 19
user7, 2006, 18
user8, 2007, 17
user9, 2008, 16
user10, 2009, 15
그리고 이 csv 파일을 받을 클래스를 생성해줍니다.
public class Customer {
private String name;
private String year;
private int age;
}
그런 다음 job을 구성합니다. 여기 step1()에서 chunk() API를 사용하여 ItemReader/Writer를 구성한 다음 SimpleStepBuilder롤 통해 TaskletStep을 반환하게 됩니다.
@Bean
public Job batchjob() {
return jobBuilderFactory.get("batchjob")
.start(step1())
.next(step2())
.build();
}
그리고 이제 step1()을 구성해보겠습니다. 위에서 말했듯 chunk() API를 사용하여 ItemReader/Writer를 구성합니다. chunk size는 2이며, 이는 10개의 데이터가 올 경우 2개씩 잘라 총 5개의 chunk를 생성해냄을 의미합니다.
@Bean
public Step step1() {
return stepBuilderFactory.get("step1")
.<Customer, Customer>chunk(2)
.reader(itemReader())
.writer(itemWriter())
.allowStartIfComplete(true)
.build();
}
그리고 이번 글에서는 이 부분이 중요한데요, FlatFileItemReader 구현체를 통해 ItemReader를 구현하게 됩니다. 이 과정에서 여러 객체를 사용하게 됩니다. 크게 FlatFileItemReader 객체를 생성하고 리소스를 설정해주고 LineMapper 인터페이스의 구현체 객체를 생성해 여러 설정을 해줍니다.
@Bean
public ItemReader<Customer> itemReader() {
FlatFileItemReader<Customer> itemReader = new FlatFileItemReader<>(); // 플랫파일아이템리더 인스턴스 생성
itemReader.setResource(new ClassPathResource("/customer.csv")); // 읽어들일 파일 설정
CustomLineMapper<Customer> lineMapper = new CustomLineMapper<>(); // 라인매퍼 인스턴스 생성
lineMapper.setLineTokenizer(new DelimitedLineTokenizer()); // 이용할 구분자 객체 설정
lineMapper.setFieldSetMapper(new CustomerFieldSetMapper()); // 필드셋 객체 설정
itemReader.setLineMapper(lineMapper); // 플랫파일아이템리더의 라인매퍼 설정
itemReader.setLinesToSkip(1); // 첫 번째 라인(컬럼명) 건너뛰기
return itemReader;
}
파일을 읽고 자바 객체로 변환하는 과정을 크게 보면 이렇습니다. 가장 먼저 FlatFileItemReader의 readLine() API를 이용해 파일을 읽어 LineMapper에게 한 줄씩 전달해줍니다. 그러면 LineMapper는 LineTokenizer의 구현체를 이용해 읽은 한 줄을 구분자로 끊어 배열로 만듭니다. 그런 다음 FieldSet에게 배열을 전달하면 FieldSetMapper가 mapFieldSet() API를 이용해 이것을 자바 객체로 변환합니다.
이제 위에서 사용된 LineMapper의 구현체를 만들어보겠습니다. CustomLineMapper는 LineMapper 인터페이스를 구현하며 내부적으로 LineTokenizer와 FieldSetMapper 두 인터페이스를 필드로 가집니다.
public class CustomLineMapper<T> implements LineMapper<T> {
private LineTokenizer lineTokenizer;
private FieldSetMapper<T> fieldSetMapper;
@Override
public T mapLine(String line, int lineNumber) throws Exception {
return fieldSetMapper.mapFieldSet(lineTokenizer.tokenize(line));
}
public void setLineTokenizer(LineTokenizer lineTokenizer) {
this.lineTokenizer = lineTokenizer;
}
public void setFieldSetMapper(FieldSetMapper<T> fieldSetMapper) {
this.fieldSetMapper = fieldSetMapper;
}
}
이어 FieldSetMapper의 구현체를 만들어보겠습니다. 여기서는 FieldSet에 담긴 배열 데이터를 자바 객체로 변환해줍니다.
public class CustomerFieldSetMapper implements FieldSetMapper<Customer> {
@Override
public Customer mapFieldSet(FieldSet fieldSet) throws BindException {
if (fieldSet == null) {
return null;
}
Customer customer = new Customer();
customer.setName(fieldSet.readString(0));
customer.setYear(fieldSet.readString(1));
customer.setAge(fieldSet.readInt(2));
return customer;
}
}
모든 설정을 마친 지금 다시 ItemReader를 보겠습니다. 먼저 FlatFileItemReader 인스턴스를 생성해주고 읽어들일 파일도 설정합니다. 그런 다음 LineMapper 인터페이스를 구현한 CustomLineMapper 인스턴스를 생성해주고 사용할 LineTokenizer 구현체와 FieldSetMapper 구현체를 설정해줍니다. 그리고 모든 설정을 마친 LineMapper를 setLineMapper() API로 설정해줍니다.
@Bean
public ItemReader<Customer> itemReader() {
FlatFileItemReader<Customer> itemReader = new FlatFileItemReader<>(); // 플랫파일아이템리더 인스턴스 생성
itemReader.setResource(new ClassPathResource("/customer.csv")); // 읽어들일 파일 설정
CustomLineMapper<Customer> lineMapper = new CustomLineMapper<>(); // 라인매퍼 인스턴스 생성
lineMapper.setLineTokenizer(new DelimitedLineTokenizer()); // 이용할 구분자 객체 설정
lineMapper.setFieldSetMapper(new CustomerFieldSetMapper()); // 필드셋 객체 설정
itemReader.setLineMapper(lineMapper); // 플랫파일아이템리더의 라인매퍼 설정
itemReader.setLinesToSkip(1); // 첫 번째 라인(컬럼명) 건너뛰기
return itemReader;
}
ItemWriter에서는 그냥 chunk의 데이터를 콘솔창에 출력만 해보겠습니다.
public class CustomItemWriter implements ItemWriter<Customer> {
@Override
public void write(List<? extends Customer> items) throws Exception {
items.forEach(item -> System.out.println("item = " + item));
Thread.sleep(1000);
}
}
그리고 실행해보면 의도한대로 출력되는 것을 확인할 수 있습니다.
item = Customer(name=user1, year=2000, age=24)
item = Customer(name=user2, year=2001, age=23)
item = Customer(name=user3, year=2002, age=22)
item = Customer(name=user4, year=2003, age=21)
item = Customer(name=user5, year=2004, age=20)
item = Customer(name=user6, year=2005, age=19)
item = Customer(name=user7, year=2006, age=18)
item = Customer(name=user8, year=2007, age=17)
item = Customer(name=user9, year=2008, age=16)
item = Customer(name=user10, year=2009, age=15)
단순히 csv 파일을 자바 객체로 변환해주는 매우 쉬운 과정처럼 보이지만, 내부적인 동작 방식이나 설정 방법이 그렇게 단순하지는 않습니다. 결과적으로 사용되는 객체의 관계도는 FlatFileItemReader가 LineMapper를 포함하고, LineMapper가 FieldSetMapper를 포함합니다.
그렇다면 이번에는 DelimetedLineTokenizer와 FixedLengthTokenizer라는 LineTokenizer의 두 가지 구현체에 대해 알아보겠습니다.
먼저 DelimetedLineTokenizer는 한 개 라인의 문자열을 구분자를 기준으로 나누어 토큰화하는데요, 콤마가 기본값입니다. 주로 csv 파일을 읽어들일 때 사용됩니다. 코드로 볼 텐데 이번에는 FlatFileItemReaderBuilder를 이용해 위의 경우와 다르게 LineMapper와 FieldSetMapper를 구현하지 않고 간단하게 만들어보겠습니다.
@Bean
public Job batchjob() {
return jobBuilderFactory.get("batchjob")
.start(step1())
.next(step2())
.build();
}
@Bean
public ItemReader DelimetedTokenizeritemReader() {
return new FlatFileItemReaderBuilder<Customer>()
.name("flatFileReader")
.resource(new ClassPathResource("customer.csv"))
.fieldSetMapper(new BeanWrapperFieldSetMapper<>()) // 배치에서 제공하는 클래스 사용
.targetType(Customer.class) // 변환될 타입
.linesToSkip(1)
.delimited().delimiter(",") // 구분자로 식별 후 읽어들임
.names("name", "year", "age") // 컬럼명 명시
.build();
}
@Bean
public ItemReader<Customer> itemReader() {
FlatFileItemReader<Customer> itemReader = new FlatFileItemReader<>(); // 플랫파일아이템리더 인스턴스 생성
itemReader.setResource(new ClassPathResource("/customer.csv")); // 읽어들일 파일 설정
CustomLineMapper<Customer> lineMapper = new CustomLineMapper<>(); // 라인매퍼 인스턴스 생성
lineMapper.setLineTokenizer(new DelimitedLineTokenizer()); // 이용할 구분자 객체 설정
lineMapper.setFieldSetMapper(new CustomerFieldSetMapper()); // 필드셋 객체 설정
itemReader.setLineMapper(lineMapper); // 플랫파일아이템리더의 라인매퍼 설정
itemReader.setLinesToSkip(1); // 첫 번째 라인(컬럼명) 건너뛰기
return itemReader;
}
@Bean
public Step step1() {
return stepBuilderFactory.get("step1")
.<Customer, Customer>chunk(2)
.reader(DelimetedTokenizeritemReader())
.writer(itemWriter())
.build();
}
이제 FixedLengthTokenizer를 알아보겠습니다. 이 구현체는 문자열을 고정된 길이로 구분해 파일을 읽어들입니다. 주로 txt 파일을 읽을 때 사용됩니다. txt 파일은 아래와 같이 생성해주었습니다.
nam,year,age
user1200024
user2200123
user3200222
user4200321
user5200420
user6200519
user7200618
user8200717
user9200816
Range라는 클래스를 사용해 길이를 설정해주는데요, 이 역시 FlatFileItemReaderBuilder를 이용해 쉽게 작성해보겠습니다.
@Bean
public Job batchjob() {
return jobBuilderFactory.get("batchjob")
.start(step1())
.next(step2())
.build();
}
@Bean
public ItemReader FixedLengthTokenizeritemReader() {
return new FlatFileItemReaderBuilder<Customer>()
.name("flatFileReader")
.resource(new ClassPathResource("customer.txt"))
.fieldSetMapper(new BeanWrapperFieldSetMapper<>()) // 배치에서 제공하는 클래스 사용
.targetType(Customer.class) // 변환될 타입
.linesToSkip(1)
.fixedLength()
.addColumns(new Range(1, 5)) // 길이 지정
.addColumns(new Range(6, 9)) // 길이 지정
.addColumns(new Range(10, 11)) // 길이 지정
.names("name", "year", "age") // 컬럼명 명시
.build();
}
@Bean
public Step step1() {
return stepBuilderFactory.get("step1")
.<Customer, Customer>chunk(2)
.reader(FixedLengthTokenizeritemReader())
.writer(itemWriter())
.build();
}
위에서 new Range(1)로 설정해 시작할 인덱스만 설정하면 시작할 인덱스부터 나머지 끝 문자열까지 모두 다 읽게 됩니다.
그런데 만약 우리는 구분자를 3개로 지정했지만 실제로는 이보다 적거나 클 경우, 혹은 문자열을 1-4, 5-6, 7-10으로 지정했지만 10개보다 적거나 클 경우 각각 IncorrectTokenCountException과 IncorrectTokenCountException가 발생됩니다. 이때 FlatFileItemReaderBuilder의 strict() API를 사용해 false 값을 주어 이러한 검증을 피할 수 있습니다.
@Bean
public ItemReader FixedLengthTokenizeritemReader() {
return new FlatFileItemReaderBuilder<Customer>()
.name("flatFileReader")
.resource(new ClassPathResource("customer.txt"))
.fieldSetMapper(new BeanWrapperFieldSetMapper<>()) // 배치에서 제공하는 클래스 사용
.targetType(Customer.class) // 변환될 타입
.linesToSkip(1)
.fixedLength()
.strict(false) // 검증 회피
.addColumns(new Range(1, 5)) // 길이 지정
.addColumns(new Range(6, 9)) // 길이 지정
.addColumns(new Range(10, 11)) // 길이 지정
.names("name", "year", "age") // 컬럼명 명시
.build();
}
이번 글에서 FlatFileItemReader에 대해 알아봤습니다. 다음 글에서는 XML을 읽어오는 StaxEventItemReader에 대해 알아보겠습니다.