팀에서 클린 아키텍처와 유지보수를 위해 전반적으로 헥사고날 아키텍처를 적용하기로 했다. 이는 Spring Batch가 포함된 프로젝트도 포함이 되었는데, 헥사고날 아키텍처가 제대로 적용된 것 같지 않아서 내가 다시 설계해보자는 의미로 구현했다. 구글에 검색해도 Spring MVC 예시는 많이 나오는데, Spring Batch에 헥사고날 아키텍처를 적용한 예시는 잘 찾아볼 수 없는 것 같다. 마침 "직접 구현하며 배우는 클린 아키텍처"를 읽어서 바로 적용할 수 있었다. 책에 대한 후기는 다른 게시글에 남길 것 이다.
그래서 이번 포스팅은 batch 자체의 성능 개선, 기능 구현, 인프라 구성보다는 아키텍처 구성에 초점을 맞출 것이다.
또한 헥사고날 아키텍처의 기본적인 개념은 다루지 않으므로 기본 개념은 다른 게시글을 참고하는 것이 좋을 것 같다.
구현하면서 고려했던 점은 다음과 같다.
- 왜 헥사고날 아키텍처를 사용해야 하는가
- reader, processor, writer는 각각 어떤 역할/책임 분리를 가져가야 할까
- 각 모델에 대한 validation은 어디에서 처리해야 할까
당연히 내 생각이 정답이 아니고, 상황에 따라 더 좋은 구성은 각각 다르다. 나는 현재 내가 진행하고 있는 프로젝트에서 어떻게 적용하면 좀 더 깔끔한 역할 분리와 유지보수가 가능할지 고려해서 구현했다.
왜 헥사고날을 선택했는가?
Batch 로직과 비즈니스 로직을 분리하고 싶었다.
도메인이 정책사항 검증이 많이 필요한 비즈니스 로직을 가지고 있었고, 이를 배치 로직에 적용하지 않으려고 했다.
또한 DDD를 적용해서 도메인 모듈이 분리되어 존재하기 때문에 비즈니스 로직은 모듈 도메인에서 처리하고 싶었다.
다양한 In/Out 인프라스트럭처를 사용하고 있었다.
outgoing port로 kafka, database, sqs 등 다양한 인프라 스트럭처를 사용하고 있었다. 이를 batch 로직, 도메인 로직과 구분하여 책임분리를 명확하게 해야 했다.
Reader, Processor, Writer는 각각 어떤 역할/책임 분리를 가져가야 할까
├─ adapter
│ ├── in
│ │ └── batch
│ │ ├── converter
│ │ ├── item
│ │ ├── processor
│ │ └── writer
│ └── out
│ └── persistence
│ ├── converter
│ ├── entity
│ └── repository
│
├─ domain
│ ├── command
│ ├── query
│ ├── model
│ └── service
│
└─ port
├ in
└ out
내가 적용한 아키텍처의 전체구조는 다음과 같다. 하나씩 살펴보도록 하자.
Incoming Adapter
ItemReader
@Bean
ItemReader<StudentReadItem> excelStudentReader(Environment environment) {
PoiItemReader<StudentReadItem> reader = new PoiItemReader<>();
reader.setLinesToSkip(1);
reader.setResource(new ClassPathResource(environment.getRequiredProperty(PROPERTY_EXCEL_SOURCE_FILE_PATH)));
reader.setRowMapper(excelRowMapper());
return reader;
}
item reader는 excel 파일을 읽는 책임만 가진다.
파일을 읽을 수 있다/없다의 책임만 reader에게 주어진다.
읽은 값이 정책 조건에 맞는지 아닌지에 대한 검증은 reader에게 주어지지 않는다.
Processor
@Validated
@RequiredArgsConstructor
@Component
public class StudentProcessor implements ItemProcessor<StudentReadItem, StudentWriteItem> {
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M/d/yy HH:mm");
private final GetStudentUseCase getStudentUseCase;
@Override
public StudentWriteItem process(final StudentReadItem item) {
Long id = getStudentUseCase.execute(new GetStudentQuery(item.getStudentNumber())).getId();
return StudentWriteItem.builder()
.id(id)
.studentNumber(item.getStudentNumber())
.name(item.getName())
.gender(GenderType.valueOf(item.getGender()))
.graduation(GraduationType.valueOf(item.getGraduation()).equals(GraduationType.Y))
.admission(LocalDateTime.parse(item.getAdmission(), formatter))
.build()
.validate();
}
}
processor는 reader가 읽은 item이 정책(비즈니스 요구사항)에 맞는 값인지 검증하고, writer의 item 형식으로 반환한다.
컨버터를 따로 만들어서 Converter.from(item)과 같은 코드로 구현할까 했는데, processor가 item을 변환하는 책임을 가지고 있기 때문에 processor에서 로직을 구현하는 것이 더 코드를 파악하기 쉬울 것 같았다.
자료형이 시간이라면 미래/과거에 맞게 입력이 되었는지, enum type이라면 enum 값에 맞게 입력이 되었는지 검증한다.
studentNumber 같은 경우는 unique 값으로, 기존에 입력된 데이터가 있으면 생성이 아닌 update를 하도록 id를 추가해주었다. (비즈니스 요구사항)
코드를 보면 reader의 item과 writer의 item의 모델을 따로 정의해주었다. 어떻게 다른지 살펴보자.
ReadItem
public class StudentReadItem {
private String studentNumber;
private String name;
private String gender;
private String graduation;
private String admission;
}
위에서 reader는 값을 읽는 책임만 가지게 한다고 했다.
그래서 자료형이 맞는지 아닌지에 대한 책임은 없기 때문에 모두 string으로 선언해주었다.
WriteItem
public class StudentWriteItem extends SelfValidatable<StudentWriteItem> {
@Min(1)
private Long id;
@NotBlank
private String studentNumber;
@NotBlank
private String name;
@NotNull
private GenderType gender;
@NotNull
private Boolean graduation;
@PastOrPresent
private LocalDateTime admission;
@Override
public StudentWriteItem validate() {
super.validateSelf(this);
return this;
}
}
writer의 item에선 validation을 추가해주었다.
원래는 readItem에 추가하여 @Valid StudentReadItem item과 같이 검증하려고 했으나, reader에겐 read의 책임만 가지고 가는 것이 맞다고 생각해서 변경해주었다.
ItemWriter
@RequiredArgsConstructor
@Component
public class StudentWriter implements ItemWriter<StudentWriteItem> {
private final SaveStudentUseCase saveStudentUseCase;
@Override
public void write(final List<? extends StudentWriteItem> items) {
Collection<SaveStudentCommand> commands = items.stream()
.map(StudentItemConverter::from)
.collect(Collectors.toUnmodifiableList());
saveStudentUseCase.execute(commands);
}
}
writer는 processor에서 받은 item들을 데이터베이스에 저장해준다.
각 useCase는 incoming port이며, domain에 구현되어 있다.
Domain
Service
@Validated
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class GetStudentService implements GetStudentUseCase {
private final StudentDataProvider dataProvider;
@Override
public Student execute(final @Valid GetStudentQuery query) {
return dataProvider.findByStudentNumber(query.getStudentNumber()).orElse(new Student());
}
}
@Validated
@RequiredArgsConstructor
@Transactional
@Service
public class SaveStudentService implements SaveStudentUseCase {
private final StudentDataProvider dataProvider;
@Override
public Collection<Student> execute(final @Valid Collection<SaveStudentCommand> command) {
Collection<Student> target = command.stream()
.map(SaveStudentCommand::getStudent)
.collect(Collectors.toUnmodifiableList());
return dataProvider.saveAll(target);
}
}
헥사고날 아키텍처에서 도메인 로직은 in/out에 대한 정보를 전혀 몰라야 한다.
예를 들어 현재 도메인 로직에선 spring batch에 대한 정보, 즉 reader가 어떤 것인지, writer는 무엇을 사용하는지, batch 설정이 어떻게 되어 있는지에 대한 정보가 담겨서는 안된다.
CQRS 패턴에 따라 getUseCase는 query, saveUseCase는 command를 사용해 요청을 처리한다. 또한 각 command와 query가 형식에 맞게 데이터가 입력이 되었는지 ( e.g. name이 두 글자 이상이다 ) 검증하는 역할도 한다.
해당 코드엔 구현이 안되었지만, 데이터베이스에 저장하려는 데이터와 같은 이름이 있는지 와 같은 비즈니스 검증 로직 또한 서비스의 역할이 된다.
데이터가 알맞은 형식이라는 검증이 되었다면, outgoing port를 통해 영속성 계층을 호출한다.
Outgoing Adapter
@RequiredArgsConstructor
@Component
public class StudentPersistenceAdapter implements StudentDataProvider {
private final StudentRepository repository;
@Override
public Collection<Student> saveAll(final Collection<Student> data) {
Collection<StudentEntity> entities = data.stream()
.map(StudentEntityConverter::to)
.collect(Collectors.toUnmodifiableList());
return repository.saveAll(entities).stream()
.map(StudentEntityConverter::from)
.collect(Collectors.toUnmodifiableList());
}
@Override
public Optional<Student> findByStudentNumber(final String studentNumber) {
return repository.findByStudentNumber(studentNumber)
.map(StudentEntityConverter::from);
}
}
outgoing adapter는 outgoing port를 구현한다.
여기에선 JPA를 사용하여 데이터베이스의 데이터를 관리하도록 구현해주었다.
보는바와 같이 데이터를 관리하는 CRUD 역할 외에는 하지않는다.
각 DTO에 대한 Valdation은 어디에서 이루어져야 할까?
각 계층의 DTO는 다음과 같다. (영속성 계층은 제외)
- batch
- readItem
- writeItem
- domain
- command, query
- model
batch
itemReader가 사용하는 DTO로 readItem에 대한 검증은 이루어지지 않는다. reader는 파일을 읽는 데에만 책임을 가져간다.
반면 writeItem은 itemWriter가 사용하는 DTO이다. processor가 readItem을 writeItem으로 변환하며, processor가 write 할 수 있는 item인지 검증한 후 변환해준다.
domain
command와 query는 service에서 검증하게된다. 검증 후 save 또는 update command일 경우 저장할 model로 변환해준다.
이로써 각 계층의 역할과 책임 분리를 명확하게 나눌 수 있었다. 물론 헥사고날이 아닌 다른 아키텍처가 좀 더 적합할 수도 있고 내 해석이 잘못되었을 수도 있다. 필자의 경우엔 프로젝트에서 일관되지 않은 검증 로직이 각 계층에 흩뿌려져 있었고, 이를 각 계층에 맞게 분리하고 싶었다. 또한 spring batch에서 DDD의 여러 도메인 모듈을 사용하고 있었기 때문에 각 도메인에 대한 역할 분리도 필요했기 때문에 위와 같이 설계하게 되었다. spring batch에 헥사고날을 적용하고 싶은 누군가에게 도움이 되길 바란다.🙏
모든 코드는 깃허브에 저장해두었다!
'Java > spring' 카테고리의 다른 글
[Spring] WireMock 을 사용한 HTTP Client 유닛테스트 (0) | 2022.12.11 |
---|---|
[Spring] Log4j2를 이용해 로깅해보자 (0) | 2022.05.29 |
[Spring Security] FormLogin에서 Custom Filter 처리 이슈 (0) | 2022.02.21 |
[Spring Security] LoginSuccessHandler와 FailureHandler 호출 원리 (0) | 2022.02.19 |
[Spring Cloud] FeignClient Logging 방법 정리 (0) | 2022.02.16 |