-
트랜잭션을 유의하여 DB작업과 메일알림을 처리하는 Spring Batch Job 작성Spring 2024. 8. 7. 23:41
SSAFY 11기 공통 프로젝트를 진행하며 배치 작업을 담당하게 되었다.
이전까지 배치처리가 어떤 것인지 왜 사용하는 것인지에 대한 이해는 어느 정도 있었으나, 실제로 Spring Batch를 사용하여 배치처리하는 것은 이번이 처음이었다.
프로젝트에서 처리되는 배치작업들을 스케줄러가 아닌 Spring Batch를 선택한 근거와 Spring Batch 공식문서를 보며 기본적인 사용법과 개념들을 정리하였고 팀원들도 Spring Batch를 사용해본 적이 없었기에 정리한 내용에 대해 발표도 진행했다.
많이 모자란 자료와 발표였지만 경청해주었던 팀원들 고맙습니다.
모든 코드는 깃허브에서 확인할 수 있습니다.
https://github.com/kwondh5217/springbatch
GitHub - kwondh5217/springbatch: 우주도서 배치 어플리케이션
우주도서 배치 어플리케이션. Contribute to kwondh5217/springbatch development by creating an account on GitHub.
github.com
https://h961014.tistory.com/67
Spring Batch 도입기
SSAFY 11기 공통 프로젝트로 우주도서(우리 주변의 도서)라는 도서 인프라 취약 지역의 사람들을 위한 도서 대여 공유 서비스를 개발하는 중이다.도서 대여가 이루어지고, 반납일자가 되었음에도
h961014.tistory.com
프로젝트의 요구사항
- 회원이 도서를 대여하면 대여라는 엔티티가 생성된다.
- 대여 엔티티는 반납일자가 정해져있으며, 반납일에 반납 완료가 되어있지 않으면 연체가 발생한다.
- 연체가 발생된다면 회원은 경험치와 포인트가 차감되며 대여가 연체되었다는 메일을 받아야 한다.
- 연체가 3일을 경과하였거나 경험치와 포인트가 음수가 되는 경우에는 회원은 이용이 불가하며, 회원탈퇴 처리되고 테이블에서 물리적으로 삭제된다.
- 회원은 삭제되지만 사용자 경험을 위해 연관된 도서에 대한 엔티티 등은 삭제되지 않아야 한다.
- 만약 A 사용자가 등록한 책을 B 사용자가 빌렸고 A 사용자가 B가 아닌 다른 사용자의 도서를 연체하여 A가 회원탈퇴 처리되어 모든 연관된 테이블이 삭제된다면 B 사용자의 대여했던 정보에 영향이 끼치는 것을 원하지 않았기 때문에 연관된 엔티티들은 삭제처리 하지 않기로 하였다.
문제 상황
- ItemReader로 연체가 발생한 대여 목록을 가져와 ItemProcessor에서 경험치와 포인트를 차감한 뒤, 메일을 발송하고 ItemWriter에서 변경사항을 업데이트 하는 방식으로 구현하였습니다.
- 하지만 이 방법은 문제가 있었습니다. 바로 DB작업에서 에러가 발생하여 롤백이 되면 메일 알림은 이미 발송되버린 뒤라 롤백이 되지 않는다는 것이었습니다.
해결법
- 사실 배치 서버가 아닌 API 서버에서는 이를 유의하여 DB작업과 외부 API를 분리하여 트랜잭션이 커밋되면 이벤트를 발행하여 메일을 보내게 처리를 하긴 하였습니다.
- 따라서 이와 같은 방식으로 배치 작업에서도 적용하면 되지 않을까 하여 공식문서를 찾아보기 시작했습니다.(저는 문제가 생기면 공식문서에서 키워드로 검색하는 것을 선호합니다. 대개의 경우에 이미 공식문서에 기술되어있는 경우가 많더라구요.)
공식문서의 ChunkListener 설명 - 공식문서에 현재 상황에 사용할 수 있는 인터페이스의 정보를 찾을 수 있었습니다.
- ChunkListener 는 Chunk의 트랜잭션 처리 전, 처리 후, 에러가 발생하였을 때에 대한 메서드를 정의할 수 있도록 제공하고 있습니다.
- aterChunk를 정의하여 청크의 트랜잭션이 커밋되면 메일을 발송하도록 하면 되겠다고 생각하고 코드 작업에 들어갔습니다.
ItemProcessor
@Bean @StepScope public ItemProcessor<Rental, UserPersonal> overdueProcessor( @Value("#{jobParameters['currentDate']}") LocalDate currentDate) { return rental -> { User user = rental.getUser(); // 메일을 발송해야할 회원 목록을 실행 정보를 담고 있는 execution 에 저장 ExecutionContext executionContext = StepSynchronizationManager.getContext() .getStepExecution() .getExecutionContext(); List<User> users = (List<User>)executionContext.get("users"); if (users == null) { users = new ArrayList<>(); executionContext.put("users", users); } users.add(user); Point point = Point.builder() .user(user) .history(PointHistory.OVERDUE) .amount(PointHistory.OVERDUE.getAmount()) .build(); Experience experience = Experience.builder() .user(user) .history(ExperienceHistory.OVERDUE) .amount(ExperienceHistory.OVERDUE.getAmount()) .build(); UserPersonal userPersonal = new UserPersonal(point, experience); return userPersonal; }; }
- ItemProcessor에서 StepExecutionContext에 메일을 발송해야할 사용자들의 목록을 저장해주었습니다.
- StepExecution은 Step에 대한 실행 정보들을 담고 있는 곳이며, 이 곳에 저장되어있는 정보들을 참고하여 중단되었을 경우 재시도 시점을 파악할 수 있습니다.
ChunkListener
@Bean public ChunkListener chunkListener(String subject, String text) { return new ChunkListener() { @Override public void afterChunk(ChunkContext context) { StepExecution stepExecution = context.getStepContext().getStepExecution(); ExecutionContext stepExecutionContext = stepExecution.getExecutionContext(); List<User> users = (List<User>)stepExecutionContext.get("users"); if (users != null) { emailSender.sendEmails(users, subject, text); stepExecutionContext.remove("users"); // 메일 전송 후 사용자 목록 초기화 } } }; }
- Chunk의 트랜잭션이 커밋이 된다면 afterChunk가 호출이 되고, ItemProcessor에서 저장한 유저 정보를 StepExecutionContext에서 조회하여 메일을 발송합니다.
ItemWriter
- Jpa를 사용중이라면 Spring Batch에서도 영속성 컨텍스트를 사용할 수 있습니다.
- JpaItemWriter 의 소스코드를 확인해보면 write 메서드를 호출하여 영속성 컨텍스를 사용해 변경사항을 반영하는 것을 확인할 수 있었습니다.
- 따라서 하나의 엔티티가 아닌 여러개의 엔티티를 수정해야하는 상황(연체가 발생하면 회원의 포인트와 경험치가 변경되는 상황)에는 변경이 필요한 클래스를 정의하여 커스텀한 ItemWriter를 정의하여 처리할 수 있습니다. 또한 삭제가 필요한 상황(회원탈퇴) 에서도 영속성 컨텍스트를 사용하여 삭제 처리를 할 수 있습니다.
JpaItemWriter의 write 소스코드
테스트코드
평소 TDD를 실천하기 위해 노력하는 편이지만, 처음 사용하는 Spring Batch에서 TDD는 너무 막막했습니다. 어느 정도 기술에 대한 숙련도가 있다면 TDD를 통하여 얻는 장점이 분명하다고 생각하지만, 처음 사용하는 기술에서는 시간이 많이 지체되었습니다...
따라서 코드를 완성한 후 테스트를 작성하였습니다.
- @SpringBatchTest 어노테이션을 적용하면 Spring Batch 테스트 환경에서 Job을 실행시킬 JobLauncherTestUtils와 JobRepositoryTestUtils 을 의존성으로 사용할 수 있습니다.
@SpringBatchTest - Spring Batch의 테스트 코드는 향로님의 블로그를 참고하여 작성하였습니다.
- 연체가 발생한 대여 목록을 가져와 포인트, 경험치를 차감, 메일 발송하는 작업을 하나의 Step으로 정의하였고, 포인트나 경험치가 음수거나 연체가 3일이 경과한 회원을 탈퇴하는 것을 다른 하나의 Step으로 정의하였습니다. 따라서 Step 별로 테스트 코드를 작성하고 전체에 대한 테스트 코드를 작성하였습니다.
https://jojoldu.tistory.com/455
10. Spring Batch 가이드 - Spring Batch 테스트 코드
배치 애플리케이션이 웹 애플리케이션 보다 어려운 점을 꼽자면 QA를 많이들 얘기합니다. 일반적으로 웹 애플리케이션의 경우 전문 테스터 분들 혹은 QA 분들이 전체 기능을 검증을 해주시는 반
jojoldu.tistory.com
연체처리 테스트 코드
@DisplayName("연체가 발생한 대여를 조회해, 회원의 포인트와 경험치를 차감한다") @Test void overduePersonalStep() { // given LocalDateTime startDate = LocalDateTime.of(2024, 07, 1, 12, 0); LocalDateTime endDate = LocalDateTime.of(2024, 07, 2, 12, 0); User owner = createOwner(); Userbook userbook = createUserbook(owner); User user = createUser(); createRental(user, userbook, startDate, endDate); // when JobExecution jobExecution = this.jobLauncherTestUtils.launchStep("overduePersonalStep"); // then assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); Optional<PointView> optionalPointView = this.pointViewRepository.findByUserId(user.getId()); Optional<ExperienceView> optionalExperienceView = this.experienceViewRepository.findByUserId(user.getId()); assertThat(optionalPointView.isPresent()).isTrue(); assertThat(optionalExperienceView.isPresent()).isTrue(); assertThat(optionalPointView.get().getTotalPoint()).isEqualTo(PointHistory.OVERDUE.getAmount()); assertThat(optionalExperienceView.get().getTotalExperience()).isEqualTo(ExperienceHistory.OVERDUE.getAmount()); }
회원탈퇴 테스트 코드
@DisplayName("연체가 3일 이상 발생하였거나, 포인트가 음수라면 회원은 탈퇴처리 된다") @Test void overdueDeleteUserStep() { // given User owner = createOwner(); User user = createUser(); Userbook ownerUserbook = createUserbook(owner); Userbook userbook = createUserbook(user); WishBook wishBook = createWishBook(user, ownerUserbook); LocalDateTime startDate = LocalDateTime.of(2024, 07, 1, 12, 0); LocalDateTime endDate = LocalDateTime.of(2024, 07, 2, 12, 0); createRental(user, ownerUserbook, startDate, endDate); Point point = Point.builder() .user(user) .history(PointHistory.OVERDUE) .amount(PointHistory.OVERDUE.getAmount()) .build(); pointRepository.save(point); Optional<PointView> optionalPointView = this.pointViewRepository.findByUserId(user.getId()); assertThat(optionalPointView.isPresent()).isTrue(); assertThat(optionalPointView.get().getTotalPoint()).isEqualTo(PointHistory.OVERDUE.getAmount()); // when JobExecution jobExecution = this.jobLauncherTestUtils.launchStep("overdueDeleteUserStep"); // then Userbook userbookById = this.userbookRepository.findById(userbook.getId()).get(); WishBook wishBookById = this.wishBookRepository.findById(wishBook.getId()).get(); Optional<User> userById = this.userRepository.findById(user.getId()); assertThat(userById.isEmpty()).isTrue(); assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); assertThat(userbookById.getUser()).isNull(); assertThat(wishBookById.getUser()).isNull(); }
Job 테스트 코드
@DisplayName("연체가 발생한 회원의 포인트와 경험치를 차감 후, 연체가 3일 경과 또는 포인트가 음수이면 회원은 탈퇴된다") @Test void overdueJobTest() throws Exception { // given LocalDateTime startDate = LocalDateTime.of(LocalDate.now(), LocalTime.now()).minusDays(10); LocalDateTime endDate = LocalDateTime.of(LocalDate.now(), LocalTime.now()).minusDays(1); User owner = createOwner(); Userbook userbook = createUserbook(owner); User willDeleteuser = createUser(); User overdueUser = createOverdueUser(); createRental(willDeleteuser, userbook, startDate, endDate); createRental(overdueUser, userbook, startDate, endDate); JobParameters jobParameters = new JobParametersBuilder() .addLocalDate("requestDate", LocalDate.now()) .toJobParameters(); // when JobExecution jobExecution = this.jobLauncherTestUtils.launchJob(jobParameters); // then assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); assertThat(this.userRepository.findById(willDeleteuser.getId()).isEmpty()).isTrue(); assertThat(this.userRepository.findById(overdueUser.getId()).isPresent()).isTrue(); assertThat(this.pointViewRepository.findByUserId(overdueUser.getId()).isPresent()).isTrue(); assertThat(this.pointViewRepository.findByUserId(willDeleteuser.getId()).isEmpty()).isTrue(); }
후기
다른 Spring 프레임워크에 비해 검색하였을 때 나오는 자료의 양이 생각보다 적어서 어려움이 조금 있었으며 글을 작성한 이유도 누군가에게 조금이나마 도움이 되었으면 하여 작성하게 되었습니다.
간단한 배치 작업이었음에도 불구하고 많은 시간이 걸렸고, 많은 에러도 마주했습니다.(Spring Batch 의 트랜잭션 시작 시점을 몰라서 엔티티 삭제 시점에 제약조건 에러를 많이 마주하였습니다..)
긴 글 읽어주셔서 감사합니다.
'Spring' 카테고리의 다른 글
Spring Batch 도입기 (5) 2024.07.23 Spring security test 에러 (0) 2024.04.02 JDBC API, JDBC Template (0) 2024.01.10 @ModelAttribute를 사용하여 직렬화 (0) 2023.11.29 HttpMessageConverter (0) 2023.10.05