이전 포스팅에서는 유저의 사업자 검수 요청 요구사항에 관한 설명과
이와 관련된 `ApplicationEventPublisher` 활용에 대해 설명했다.
2025.01.15 - [프로젝트/StudyWithMe] - [StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 1
이번 포스팅에서는 `ApplicationEventPublisher`를 사용하며 발생한 테스트 코드에서의 문제와
테스트 코드에서의 `@Transactional`의 사용에 관하여 포스팅하려고 한다.
ApplicationEventPublisher 테스트 코드
`ApplicationEventPublisher`를 사용하는 비즈니스 로직에 관한 테스트 코드는 아래와 같다.
@Test
@DisplayName("유저 사업자 등록 상태조회 및 검수 요청에 성공한다.")
void user_validateBusinessStatus_Success(){
//given
given(businessStatusService.validateBusinessStatus(any(UpgradeRoomAdminRequest.class))).willReturn(true);
given(discordNotificationService.sendBusinessVerificationNotification(any(BusinessVerificationRequestEvent.class)))
.willReturn(CompletableFuture.completedFuture(true));
User user = UserFixture.createUserWithUUID();
userRepository.save(user);
UserCredential userCredential = UserCredential.builder()
.loginId("test@gmail.com")
.user(user)
.value("test")
.build();
userCredentialRepository.save(userCredential);
UpgradeRoomAdminRequest request = UpgradeRoomAdminRequest.builder()
.businessName("test")
.businessNumber("0123456789")
.businessRegistrationDate("20250101")
.businessOwnerName("owner")
.build();
//when
commandService.validateBusinessStatus(request, user.getId());
//then
BusinessVerificationRequest findBusinessVerificationRequest
= businessVerificationRequestRepository.findById(1L).get();
assertThat(findBusinessVerificationRequest).isNotNull();
assertThat(findBusinessVerificationRequest.getUser().getId()).isEqualTo(user.getId());
assertThat(findBusinessVerificationRequest.getBusinessNumber()).isEqualTo("0123456789");
verify(discordNotificationService, times(1))
.sendBusinessVerificationNotification(any(BusinessVerificationRequestEvent.class));
}
코드를 간략하게 설명하자면,
- given - Stubbing
- 외부 API인 공공데이터 API 포털 사용에 관해서는 `given()`를 사용해 `stubbing`을 한다.
- 디스코드 알림 전송에 관해서는 `given()`를 사용해 `stubbing`을 한다.
- then
- 사업자 검수 요청 테이블에 관련 정보가 저장되어있는지 확인한다.
- verify(discordNotificationService, times(1))
- `ApplicationEventPublisher`로 전달된 이벤트에 의해 디스코드 알림 서비스가 호출되었는지 검증한다.
위의 테스트 코드만을 놓고 본다면 `ApplicationEventPublisher`에 의해 디스코드 알림 서비스가 잘 호출되어야 한다.
하지만..
오류 발생
테스트를 실행했을 때 아래 오류가 발생했다.
위 오류는 `Mockito`를 사용한 테스트 코드에서 발생하며,
원하는 메소드 호출이 실제로 일어나지 않았다는 것을 나타낸다.
즉, 내가 작성한 코드에서는 `verify()` 메소드를 통해 한번 호출된 것이 검증되어야 하는데 그걸 검증하지 못한 것이다.
왜..?
그래서, 부랴부랴 이벤트를 받아 처리하는 `BusinessVerificationEventHandler`에 로그를 찍어보았다.
BusinessVerificationEventHandler
@Slf4j
@Service
@RequiredArgsConstructor
public class BusinessVerificationEventHandler {
private final DiscordNotificationService discordNotificationService;
private final EmailService emailService;
@TransactionalEventListener(classes = BusinessVerificationRequestEvent.class, phase = TransactionPhase.AFTER_COMMIT)
public void businessVerificationRequestEventAfterCommitHandler(BusinessVerificationRequestEvent event) {
log.error("너 왜 호출 안되니..?");
discordNotificationService.sendBusinessVerificationNotification(event).thenAccept(success -> {
if(!success){
log.error("디스코드 알림 전송 오류, time: {}", LocalDateTime.now(ZoneId.of("Asia/Seoul")));
} else {
log.info("디스코드 알림 전송 성공, time: {}", LocalDateTime.now(ZoneId.of("Asia/Seoul")));
}
});
}
...
}
`AFTER_COMMIT`이 일어난다면 `BusinessVerificationEventHandler`에서 내가 작성한 로그인 "너 왜 호출 안되니..?" 가 출력되어야 한다.
하지만, 역시나 출력되지 않는다.
이렇다면 `AFTER_COMMIT`이 일어나지 않는다는 의미인데 왜 그런 것일까 잘 생각해보았다.
..
..
아..! `@Transactional` 때문이구나..!
이유는 테스트 코드에서의 `@Transactional` 사용 때문이었다.
IntegrationContainerSupporter
나는 테스트 코드를 작성할 때 `TestContainers`를 활용하기 위해서 추상 클래스를 사용한다.
원래는 `DB`와 통신하는 테스트 코드가 아니라 주로 `Mocking` 및 `Stubbing`을 활용하는 테스트를 작성했다.
과거 테스트 코드
@ExtendWith(MockitoExtension.class)
class StudyRoomCommandServiceTest {
@Mock private StudyRoomRepository studyRoomRepository;
@Mock private UserRepository userRepository;
@Mock private StudyRoomDayOffRepository dayOffRepository;
@Mock private StudyRoomImageRepository imageRepository;
@Mock private StudyRoomOptionInfoRepository optionInfoRepository;
@Mock private StudyRoomTypeInfoRepository typeInfoRepository;
@Mock private StudyRoomReserveTypeRepository reserveTypeRepository;
@Mock private StudyRoomTagRepository tagRepository;
@InjectMocks private StudyRoomCommandService studyRoomCommandService;
private StudyRoom studyRoom;
private User user;
@BeforeEach
void setUp() {
user = UserFixture.createUser();
studyRoom = StudyRoomFixture.create(user);
}
@Test
@DisplayName("스터디 룸을 생성할 수 있다.")
void createStudyRoom_Success() throws Exception{
//given
StudyRoomCreateRequest request = StudyRoomCreateRequestFixture.create();
given(userRepository.findByIdAndUserRole(UserFixture.uuid, RoleType.ROOM_ADMIN))
.willReturn(Optional.ofNullable(user));
given(studyRoomRepository.save(any(StudyRoom.class))).willReturn(studyRoom);
//when
studyRoomCommandService.create(request, UserFixture.uuid);
//then
verify(studyRoomRepository, times(1)).save(any(StudyRoom.class));
verify(dayOffRepository, times(1)).batchInsert(request.getDayOffs(), studyRoom);
verify(tagRepository, times(1)).batchInsert(request.getTags(), studyRoom);
verify(imageRepository, times(1)).batchInsert(request.getImageUrls(), studyRoom);
verify(optionInfoRepository, times(1)).batchInsert(request.getOptions(), studyRoom);
verify(typeInfoRepository, times(1)).batchInsert(request.getTypes(), studyRoom);
verify(reserveTypeRepository, times(1)).batchInsert(request.getReservationTypes(), studyRoom);
}
@Test
@DisplayName("존재하지 않는 UUID라면 예외를 반환한다.")
void createStudyRoom_FailByUserUUIDNotFound() throws Exception{
//given
StudyRoomCreateRequest request = StudyRoomCreateRequestFixture.create();
UUID uuid = UUID.randomUUID();
given(userRepository.findByIdAndUserRole(uuid, RoleType.ROOM_ADMIN))
.willReturn(Optional.empty());
//when & then
assertThrows(GlobalException.class, () -> studyRoomCommandService.create(request, uuid));
}
@Test
@DisplayName("스터디 룸 생성 시 모든 List가 empty인 경우를 수행한다.")
void createStudyRoom_WhenConditionListEmpty_Success() throws Exception{
//given
StudyRoomCreateRequest request = StudyRoomCreateRequestFixture.createListEmpty();
given(userRepository.findByIdAndUserRole(UserFixture.uuid, RoleType.ROOM_ADMIN))
.willReturn(Optional.ofNullable(user));
given(studyRoomRepository.save(any(StudyRoom.class))).willReturn(studyRoom);
//when
studyRoomCommandService.create(request, UserFixture.uuid);
//then
verify(studyRoomRepository, times(1)).save(any(StudyRoom.class));
verify(dayOffRepository, never()).batchInsert(request.getDayOffs(), studyRoom);
verify(tagRepository, never()).batchInsert(request.getTags(), studyRoom);
verify(imageRepository, times(1)).batchInsert(request.getImageUrls(), studyRoom);
verify(optionInfoRepository, times(1)).batchInsert(request.getOptions(), studyRoom);
verify(typeInfoRepository, times(1)).batchInsert(request.getTypes(), studyRoom);
verify(reserveTypeRepository, times(1)).batchInsert(request.getReservationTypes(), studyRoom);
}
@Test
@DisplayName("스터디 룸 생성 시 모든 List가 null인 경우를 수행한다.")
void createStudyRoom_WhenConditionListNull_Success() throws Exception{
//given
StudyRoomCreateRequest request = StudyRoomCreateRequestFixture.createListNull();
given(userRepository.findByIdAndUserRole(UserFixture.uuid, RoleType.ROOM_ADMIN))
.willReturn(Optional.ofNullable(user));
given(studyRoomRepository.save(any(StudyRoom.class))).willReturn(studyRoom);
//when
studyRoomCommandService.create(request, UserFixture.uuid);
//then
verify(studyRoomRepository, times(1)).save(any(StudyRoom.class));
verify(dayOffRepository, never()).batchInsert(request.getDayOffs(), studyRoom);
verify(tagRepository, never()).batchInsert(request.getTags(), studyRoom);
verify(imageRepository, times(1)).batchInsert(request.getImageUrls(), studyRoom);
verify(optionInfoRepository, times(1)).batchInsert(request.getOptions(), studyRoom);
verify(typeInfoRepository, times(1)).batchInsert(request.getTypes(), studyRoom);
verify(reserveTypeRepository, times(1)).batchInsert(request.getReservationTypes(), studyRoom);
}
@Test
@DisplayName("스터디 룸을 수정할 수 있다.")
void updateStudyRoom_Success() throws Exception{
//given
given(studyRoomRepository.findByIdAndUserId(1L, UserFixture.uuid))
.willReturn(Optional.ofNullable(studyRoom));
StudyRoomUpdateRequest updateRequest = StudyRoomUpdateRequestFixture.create();
StudyRoomImageModifyRequest imageModifyRequest = updateRequest.getImageModification();
StudyRoomImage imageId1 = StudyRoomImageFixture.createWithId(studyRoom, 1L);
doNothing().when(imageRepository).batchInsert(
imageModifyRequest.getImagesToAdd(), studyRoom
);
given(imageRepository.findAllByIdInAndStudyRoom(List.of(1L), studyRoom)).willReturn(List.of(imageId1));
given(imageRepository.countStudyRoomImageByIdInAndStudyRoom(List.of(2L, 3L), studyRoom)).willReturn(2);
doNothing().when(imageRepository).deleteAllByIdInBatch(List.of(2L, 3L));
//when
studyRoomCommandService.update(updateRequest, UserFixture.uuid);
//then
assertThat(studyRoom.getTitle()).isEqualTo(updateRequest.getTitle());
assertThat(studyRoom.getSubtitle()).isEqualTo(updateRequest.getSubtitle());
assertThat(imageId1.getImageUrl()).isEqualTo(imageModifyRequest.getImagesToUpdate().getFirst().getImageUrl());
verify(studyRoomRepository, times(1)).findByIdAndUserId(1L, UserFixture.uuid);
verify(imageRepository, times(1)).findAllByIdInAndStudyRoom(List.of(1L), studyRoom);
verify(imageRepository, times(1)).countStudyRoomImageByIdInAndStudyRoom(List.of(2L, 3L), studyRoom);
verify(imageRepository, times(1)).deleteAllByIdInBatch(List.of(2L, 3L));
}
@Test
@DisplayName("스터디 룸 이미지 수정값이 올바르지 않다면 오류를 반환한다.")
void updateStudyRoom_FailByNotValidImageUpdate() throws Exception{
//given
given(studyRoomRepository.findByIdAndUserId(1L, UserFixture.uuid))
.willReturn(Optional.ofNullable(studyRoom));
StudyRoomUpdateRequest updateRequest = StudyRoomUpdateRequestFixture.createOnlyImageUpdate();
given(imageRepository.findAllByIdInAndStudyRoom(List.of(1L), studyRoom)).willReturn(List.of());
//when & then
assertThrows(GlobalException.class, () -> studyRoomCommandService.update(updateRequest, UserFixture.uuid));
}
@Test
@DisplayName("스터디 룸 이미지 삭제값이 올바르지 않다면 오류를 반환한다.")
void updateStudyRoom_FailByNotValidImageRemove() throws Exception{
//given
given(studyRoomRepository.findByIdAndUserId(1L, UserFixture.uuid))
.willReturn(Optional.ofNullable(studyRoom));
StudyRoomUpdateRequest updateRequest = StudyRoomUpdateRequestFixture.createOnlyImageRemove();
given(imageRepository.countStudyRoomImageByIdInAndStudyRoom(List.of(2L, 3L), studyRoom)).willReturn(1);
//when & then
assertThrows(GlobalException.class, () -> studyRoomCommandService.update(updateRequest, UserFixture.uuid));
}
@Test
@DisplayName("스터디 룸 태그를 수정할 수 있다.")
void updateStudyRoomTag_Success() throws Exception{
//given
given(studyRoomRepository.findByIdAndUserId(1L, UserFixture.uuid))
.willReturn(Optional.ofNullable(studyRoom));
StudyRoomTagModifyRequest tagModifyRequest = StudyRoomTagModifyRequestFixture.create();
doNothing().when(tagRepository).batchInsert(tagModifyRequest.getTagsToAdd(), studyRoom);
given(tagRepository.findAllByIdInAndStudyRoom(List.of(1L), studyRoom)).willReturn(List.of(imageId1));
given(imageRepository.countStudyRoomImageByIdInAndStudyRoom(List.of(2L, 3L), studyRoom)).willReturn(2);
//when
//then
}
}
하지만, 어느 순간 단순하게 `Mocking`, `Stubbing`만을 반복하는 테스트 코드에 살짝 좀 회의감이 들었다.
(뭐 매우 매우 주니어인 내가 뭘 안다고 회의감도 웃기지만)
그때 `Mocking`과 `Stubbing`을 반복하며 들었던 나의 생각은 아래와 같다.
- 답지에 적혀있는 것을 그대로 받아적는 것 같다.
- 동시성 테스트와 같은 경우에는 DB의 상태가 중요한데 `Stubbing`을 해버리면 이건 동시성 테스트인가?
- DB에 실제로 저장하지 않고 그냥 `Repository`를 `Stubbing`하는 테스트가 올바른 검증이 가능할까?
- 등
위의 생각 끝에 나는 기존의 테스트 코드를 전부 지우고`TestContainers`를 활용한
`IntegrationTest` 형식의 테스트를 주로 작성하자고 생각했다.
따라서, DB 커넥션이 필요한 모든 테스트의 경우에서 `IntegrationContainerSupporter`를 상속받게 하였다.
IntegrationContainerSupporter
@SpringBootTest
@Transactional // 요 놈 때문
@ActiveProfiles("test")
@Testcontainers
@Import(TestConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class IntegrationContainerSupporter {
private static final String REDIS_IMAGE = "bitnami/valkey:8.0.1";
private static final int REDIS_PORT = 6379;
private static final String REDIS_PASSWORD = "password";
private static final String POSTGRES_IMAGE = "postgres:17";
private static final GenericContainer REDIS_CONTAINER;
static GenericContainer POSTGRES_CONTAINER = new PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE))
.withDatabaseName("swm")
.withExposedPorts(5432)
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.withReuse(true);
@BeforeAll
public static void beforeAll(){
POSTGRES_CONTAINER.start();
}
static {
REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(REDIS_IMAGE))
.withExposedPorts(REDIS_PORT)
.withReuse(true)
.withEnv("VALKEY_PASSWORD", "password");
REDIS_CONTAINER.start();
}
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry registry){
// Redis
registry.add("redis.host", REDIS_CONTAINER::getHost);
registry.add("redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(REDIS_PORT)));
registry.add("redis.password", () -> REDIS_PASSWORD);
// PostgreSQL
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://localhost:" + POSTGRES_CONTAINER.getMappedPort(5432) + "/swm");
registry.add("spring.datasource.username", () -> "test");
registry.add("spring.datasource.password", () -> "test");
}
}
`Application Context`가 띄워져 테스트 속도가 살짝 느려지긴 하지만 좀 더 확실한 테스트가 가능해졌다고 생각한다.
- 이 부분은 `Context Caching`을 적용해 하나의 `Application Context` 위에서 전부 동작할 수 있게 했다.
그래서..? `@Transactional` 얘기는 언제 나오는데? 이제 나온다.
`IntegrationContainerSupporter` 초기 설계에서 나는 `@Transactional`의 자동 롤백을 기대하며 어노테이션을 달았다.
하지만, `@Transactional`의 사용은 `TransactionalEventListener` 동작 과정에서 문제가 일으킨다.
문제가 발생하는 이유는 간략하게 설명하면 아래와 같다.(추후, 이해 그림 추가 예정)
문제 발생 이유
테스트 코드의 동작 순서를 생각하면 문제 발생 이유를 바로 알아차릴 수 있다.
1. `IntegrationContainerSupporter`에서 `@Transactional`를 걸었기 때문에 상속한 클래스의 테스트 시작시 트랜잭션이 시작된다.
2. when절에서 `validateBusinessStatus()` 메소드 수행시 기본 전파 옵션인 `REQUIRED`에 의해 기존 트랜잭션이 전파된다.
3. 기존 트랜잭션 내에서 `ApplicationEventPublisher`로 이벤트를 전송하고 핸들러의 `AFTER_COMMIT` 후처리를 기대한다.
4. 모든 비즈니스 로직 처리 후 다시 1번의 테스트 코드로 돌아와 `verify()`를 수행하고 끝낸다.(오류 발생)
문제 발생 이유를 알겠는가?
- 현재 트랜잭션은 테스트 메서드에서 시작되었다.
- 즉, 비즈니스 로직에서 커밋이 일어나는 것이 아니라 테스트 코드가 끝나야 `COMMIT`이 발생한다.
- 그렇기 때문에 `BusinessVerificationEventHandler`에서 `AFTER_COMMIT`을 감지하지 못한다.
- TransactionalEventListener가 동작하지 않는다.
결국 테스트 코드에서 트랜잭션이 시작되고 전파되면서 `AFTER_COMMIT`을 받지 못하는 것이다.
이 상황에서 우리는 아래의 방법으로 해결이 가능하다.
- 테스트 코드에서의 트랜잭션과 비즈니스 로직의 트랜잭션을 달리한다.
- 테스트 코드에서 `@Transactional`을 사용하지 않는다.
즉, 테스트 코드의 동일 트랜잭션 전파 방지 or 트랜잭션을 활용하지 않음으로 `AFTER_COMMIT`을 정확한 시점에 발생하고
정상적으로 `BusinessVerificationEventHandler`가 해당 비즈니스 로직을 처리하게 한다는 것이다.
1. 테스트 코드에서의 트랜잭션과 비즈니스 로직의 트랜잭션을 달리한다.
1번의 경우에는 테스트 코드 때문에 비즈니스 로직이 수정되어야 하므로, 옳지 않다고 판단했다.
2. 테스트 코드에서 `@Transactional`을 사용하지 않는다.
2번의 경우에는 아래와 같이 전파 옵션을 `NOT_SUPPORTED`로 하면 될 것이다.
결과는 성공
위 해결 방법은 내가 이전에 포스팅 했던 `StudyWithMe` 동시성 문제와 거의 똑같다.
2024.12.08 - [프로젝트/StudyWithMe] - [StudyWithMe] 멀티 스레드에서 트랜잭션 작업 간 정보 불일치 문제
테스트 코드에서의 @Transactional
즉, `@Transactional`을 사용하지 않으면 `ApplicationEventPublisher` 관련 테스트 코드 문제가 해결이 된다.
그러나, 여기서 하나 의구심이 들었다.
"자꾸... `@Transactional` 때문에 좋아요 멀티 스레드 동시성 상황, `TransactionalEventListener` 등에서 오류가 발생하네"
"내가 꼭 `@Transactional`을 사용하는게 맞을까?"
였다.
테스트 메서드가 종료될 때마다 롤백을 시켜준다는 것에서 `@Transactional`은 아주 용이하다.
하지만, 실제로 내가 작성한 코드는 모든 테스트 메서드가 끝난 이후 명시적으로 관련 데이터를 모두 삭제한다.
cleanup.sql
DELETE FROM STUDY_ROOM_RESERVE_TYPE;
DELETE FROM STUDY_ROOM_TAG;
DELETE FROM STUDY_ROOM_IMAGE;
DELETE FROM STUDY_ROOM_OPTION_INFO;
DELETE FROM STUDY_ROOM_TYPE_INFO;
DELETE FROM STUDY_ROOM_DAY_OFF;
DELETE FROM STUDY_ROOM_LIKE;
DELETE FROM STUDY_ROOM_BOOKMARK;
DELETE FROM STUDY_ROOM_REVIEW_IMAGE;
DELETE FROM STUDY_ROOM_REVIEW_REPLY;
DELETE FROM STUDY_ROOM_REVIEW;
DELETE FROM STUDY_ROOM_QNA;
DELETE FROM STUDY_ROOM;
DELETE FROM USERS;
SELECT setval('study_room_image_id_seq', 1, false);
SELECT setval('study_room_tag_id_seq', 1, false);
SELECT setval('study_room_day_off_id_seq', 1, false);
SELECT setval('study_room_option_info_id_seq', 1, false);
SELECT setval('study_room_type_info_id_seq', 1, false);
SELECT setval('study_room_reserve_type_id_seq', 1, false);
SELECT setval('study_room_bookmark_id_seq', 1, false);
SELECT setval('study_room_review_reply_id_seq', 1, false);
SELECT setval('study_room_review_image_id_seq', 1, false);
SELECT setval('study_room_review_id_seq', 1, false);
SELECT setval('study_room_id_seq', 1, false);
테스트 코드 예시
@Sql(executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, scripts = "classpath:cleanup/studyroom.sql")
public class StudyRoomCommandServiceIntegrationTest extends IntegrationContainerSupporter {
...
}
처음에 명시적 초기화를 사용하지 않고 통합 테스트 코드를 작성하다보니,
기본키 생성 전략이 `IDENTITY` 임에 따라 테스트 코드 작성마다 ID값이 변화했다.
나는 다른 테스트에 영향 받지 않는 테스트를 작성하고 싶어 이에 `DB`에 저장된 데이터의 ID를 `1`로 고정하고 싶었다.
그래서 명시적 초기화를 사용했다.
즉, 사실상 `@Transactional`이 수행해주는 롤백의 도움을 사실상 받지 않는다.
(초기 설정이 이후 계속 상속받다보니 이렇게 된 것 같다..)
그렇기에 사실 `@Transactional`을 빼도 별 문제가 없어서,
기존의 `@Transactional`를 제거하고 명시적 초기화를 하는 것으로 코드를 수정했다.
"그러던 중 과연 `@Transactional` 사용하지 않고 저렇게 명시적 초기화를 하는게 맞는 판단일까?"
라는 생각이 들어 관련해서 여러 정보를 찾던 중 향로님의 포스팅을 발견했다.
좀 화두에 올랐던 주제였던 것 같다.
https://jojoldu.tistory.com/761
향로님의 포스팅의 내용을 간략하게 요약하자면,
- 영한님, 토비님은 여러 방식의 장단점이 존재하지만, `@Transactional` 롤백 테스트가 주는 장점이 크다고 생각
- `@Transactional` 롤백 테스트는 공식 스프링 팀에서도 권장하는 방식
- 하지만, `@Transactional` 사용시 발생할 수 있는 문제가 존재
- 의도치 않은 트랜잭션 적용
- 실제 코드에서는 `@Transactional`이 없는데, 테스트 코드에 의해 트랜잭션이 걸림(이상한 상황)
- 트랜잭션 전파 속성을 조절한 테스트 롤백 실패
- 비즈니스 로직에 새로운 트랜잭션을 생성하는 경우 롤백이 안됨
- 비동기 메서드 테스트 롤백 실패
- 비동기 메서드가 설정에 따라 새롭게 스레드를 생성한다면 새로운 트랜잭션이 생기므로 롤백이 안됨
- 경우에 따라 이전에 포스팅한 멀티 스레드 동시성 이슈와 비슷
- `TransactionalEventListener` 동작 실패(나의 문제와 동일)
- 트랜잭션의 커밋이 끝난 후 처리가 필요한 이벤트 발행/리스너 코드에서 테스트에 `@Transactional`이 있으면
테스트의 트랜잭션 커밋이 끝나지 않았기 때문에 `TransactionalEventListener`가 수행될 수 없다. - 이때, 테스트 코드에서 `@Transactional`을 제거하면, 서비스 코드에 있는 트랜잭션만 적용되어 정상적으로 결과를 볼 수 있다.
- 트랜잭션의 커밋이 끝난 후 처리가 필요한 이벤트 발행/리스너 코드에서 테스트에 `@Transactional`이 있으면
- 의도치 않은 트랜잭션 적용
뭔가 내가 혼자 뚝딱뚝딱 되며 생각했던 고민들을 유명하신 분들도 하셨다는게 신기했다.
(물론, 나의 경우와 깊이가 다르겠지만)
암튼.. 향로님의 의견처럼 현재 내 상황에서는 `@Transactional`을 활용한 롤백 처리대신 명시적 처리를 진행하는 것으로 정했다.
- 여기서 중요한게 향로님은 모든 테이블간에 FK 제약 조건을 사용하지 않지만, 나는 FK 제약 조건을 사용한다.
- 따라서, 명시적 처리를 할 때에도 부모 테이블의 데이터가 삭제 전 순서가 중요하다.
따라서, 기존의 `cleanup.sql`를 사용했던 방식대신 반복문으로 순서에 맞게
자동화된 삭제를 할 수 있도록 `CleanUp` 클래스를 생성했다.
CleanUp
@Component
public class CleanUp {
private final JdbcTemplate jdbcTemplate;
private final EntityManager entityManager;
@Autowired
public CleanUp(JdbcTemplate jdbcTemplate, EntityManager entityManager) {
this.jdbcTemplate = jdbcTemplate;
this.entityManager = entityManager;
}
@Transactional
public void all() {
entityManager.getMetamodel().getEntities().forEach(e -> {
String tableName = e.getJavaType().getAnnotation(Table.class).name();
if(!tableName.split("_")[0].startsWith("external") && !tableName.startsWith("users")) {
jdbcTemplate.execute("TRUNCATE table " + tableName + " CASCADE");
jdbcTemplate.execute("ALTER SEQUENCE " + tableName + "_id_seq RESTART WITH 1");
}
});
jdbcTemplate.execute("TRUNCATE table users CASCADE");
}
}
수정된 IntegrationContainerSupporter
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
@Import(TestConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class IntegrationContainerSupporter {
private static final String REDIS_IMAGE = "bitnami/valkey:8.0.1";
private static final int REDIS_PORT = 6379;
private static final String REDIS_PASSWORD = "password";
private static final String POSTGRES_IMAGE = "postgres:17";
private static final GenericContainer REDIS_CONTAINER;
static GenericContainer POSTGRES_CONTAINER = new PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE))
.withDatabaseName("swm")
.withExposedPorts(5432)
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.withReuse(true);
@Autowired private CleanUp cleanUp;
// Mock Bean
@MockitoBean protected EmailService emailService;
@MockitoBean protected BusinessStatusService businessStatusService;
@MockitoBean protected DiscordNotificationService discordNotificationService;
@BeforeAll
public static void beforeAll(){
POSTGRES_CONTAINER.start();
}
@AfterEach
void tearDown() {
cleanUp.all();
}
static {
REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(REDIS_IMAGE))
.withExposedPorts(REDIS_PORT)
.withReuse(true)
.withEnv("VALKEY_PASSWORD", "password");
REDIS_CONTAINER.start();
}
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry registry){
// Redis
registry.add("redis.host", REDIS_CONTAINER::getHost);
registry.add("redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(REDIS_PORT)));
registry.add("redis.password", () -> REDIS_PASSWORD);
// PostgreSQL
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://localhost:" + POSTGRES_CONTAINER.getMappedPort(5432) + "/swm");
registry.add("spring.datasource.username", () -> "test");
registry.add("spring.datasource.password", () -> "test");
}
}
이렇게 하므로, 전체 테스트 코드에 `@Transactional`을 적용하는 방식 대신
`@Transactional`을 사용하지 않고 명시적 초기화를 하는 코드로 변경되었다.
내가 생각하는.. 테스트 코드에서 @Transactional이 필요한 경우
위처럼 변경했을 때 한번에 모든 테스트가 성공한다면 참 행복하겠지만,
현실은 당연히 그렇지 않다.
기존 테스트 코드가 실패하는 경우는 작성되어 있는 코드가 `@Transactional`에 의해 `Dirty Check`되어
자동으로 `UPDATE` 쿼리가 나가는 것을 기대하는 코드들에서 발생했다.
관련 경우
public class StudyRoomQueryServiceIntegrationTest extends IntegrationContainerSupporter {
...
/**
* 스터디 룸 5개 생성
* 스터디 룸 옵션 => ELECTRICAL
* 스터디 룸 이용후기 => 5, 4, 3, 2, 1 -> 5, 4, 3, 2 -> 5, 4, 3....
* 스터디 룸 좋아요 개수 => 5개, 4개, 3개, 2개, 1개
*/
@BeforeEach
void setUp(){
User roomAdmin = userRepository.saveAndFlush(UserFixture.createRoomAdmin());
users = createTestUsers();
studyRooms = new ArrayList<>();
for(int i = 5; i >= 1; i--){
StudyRoom studyRoom = StudyRoomFixture.createStudyRoom(roomAdmin);
studyRoom = studyRoomRepository.save(studyRoom);
StudyRoomOptionInfo optionInfo = StudyRoomOptionInfoFixture.createOptionInfo(studyRoom);
optionInfoRepository.save(optionInfo);
for(int j = i; j >= 1; j--){
StudyRoomReview review = StudyRoomReviewFixture.createReview(studyRoom, i, users.get(j - 1));
studyRoom.addReviewStudyRoom(i);
reviewRepository.save(review);
}
for(int j = 1; j <= i; j++){
StudyRoomLike like = StudyRoomLikeFixture.createLike(studyRoom, users.get(j - 1));
studyRoom.likeStudyRoom();
likeRepository.save(like);
}
studyRooms.add(studyRoom);
}
}
...
}
위 경우에 `@Transactional`를 사용하지 않는다면, 수정될 때마다 `DB`에 다시 저장해줘야 한다.
이는 매우 불편하고 휴먼 폴트가 발생하기 쉽다.
따라서, 나는 `Dirty Check`로 자동으로 업데이트가 되는 코드에서는 `@Transactional`를 쓰는게 낫다고 판단했다.
마무리하며
이전 포스팅부터 이번 포스팅까지 유저 사업자 검수 요청처리 관련 작업을 하며,
- `ApplicationEventPublisher`의 사용부터
- `ApplicationEventPublisher` 테스트 코드 문제 확인 및 해결
- `@Transactional`에 관한 고민 및 나의 결정
등을 알아보았다.
정답은 없는 내용들이겠지만, 좀 이것저것 많이 고민을 하게 된 계기가 된 것 같다.
또한, `Context Caching`이나 트랜잭션에 관해서도 더욱 깊이 학습할 수 있게 된 것 같다.
'프로젝트 > StudyWithMe' 카테고리의 다른 글
[StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 1 (0) | 2025.01.23 |
---|---|
[StudyWithMe] Async Thread Pool과 CompletableFuture (0) | 2025.01.12 |
[StudyWithMe] 스터디 윗 미 dev 무중단 배포 (0) | 2024.12.24 |
[StudyWithMe] 영속성 컨텍스트와 관련된 퀴즈 풀어보실 분? (0) | 2024.12.23 |
[StudyWithMe] 프로젝트에서의 쿼리 고민 일기(계속 추가 예정) (0) | 2024.12.10 |