
이전 포스팅에서는 유저의 사업자 검수 요청 요구사항에 관한 설명과
이와 관련된 ApplicationEventPublisher
활용에 대해 설명했다.
2025.01.15 - [프로젝트/StudyWithMe] - [StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 1
[StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 1
이번 포스팅 및 다음 포스팅에서는 유저의 사업자 검수 요청과 관련된`StudyWithMe` 프로젝트에서의 `ApplicationEventPublisher` 사용`ApplicationEventPublisher` 사용과 관련된 테스트 코드에서 `@Transactional`를
hdbstn3055.tistory.com
이번 포스팅에서는 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
을 한다.
- 외부 API인 공공데이터 API 포털 사용에 관해서는
- 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] 멀티 스레드에서 트랜잭션 작업 간 정보 불일치 문제
[StudyWithMe] 멀티 스레드에서 트랜잭션 작업 간 정보 불일치 문제
이번 포스팅에서는 `StudyWithMe`에서 부족했던 나의 트랜잭션의 작동 지식에 관해 포스팅 하려고 한다. `StudyWithMe`에서 좋아요, 이용후기 작업 시,`Database Lock`으로 스터디 룸에 관한 동시성 문제
hdbstn3055.tistory.com
테스트 코드에서의 @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 사용하는 것에 대한 생각
얼마 전에 2개의 핫한 컨텐츠가 공유되었다. 존경하는 재민님의 유튜브 - 테스트에서 @Transactional 을 사용해야 할까? 존경하는 토비님의 페이스북 2개의 컨텐츠에서 테스트 데이터 초기화에 @Transa
jojoldu.tistory.com
향로님의 포스팅의 내용을 간략하게 요약하자면,
- 영한님, 토비님은 여러 방식의 장단점이 존재하지만,
@Transactional
롤백 테스트가 주는 장점이 크다고 생각 @Transactional
롤백 테스트는 공식 스프링 팀에서도 권장하는 방식- 하지만,
@Transactional
사용시 발생할 수 있는 문제가 존재- 의도치 않은 트랜잭션 적용
- 실제 코드에서는
@Transactional
이 없는데, 테스트 코드에 의해 트랜잭션이 걸림(이상한 상황)
- 실제 코드에서는
- 트랜잭션 전파 속성을 조절한 테스트 롤백 실패
- 비즈니스 로직에 새로운 트랜잭션을 생성하는 경우 롤백이 안됨
- 비동기 메서드 테스트 롤백 실패
- 비동기 메서드가 설정에 따라 새롭게 스레드를 생성한다면 새로운 트랜잭션이 생기므로 롤백이 안됨
- 경우에 따라 이전에 포스팅한 멀티 스레드 동시성 이슈와 비슷
TransactionalEventListener
동작 실패(나의 문제와 동일)- 트랜잭션의 커밋이 끝난 후 처리가 필요한 이벤트 발행/리스너 코드에서 테스트에
@Transactional
이 있으면
테스트의 트랜잭션 커밋이 끝나지 않았기 때문에TransactionalEventListener
가 수행될 수 없다. - 이때, 테스트 코드에서
@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] 프로젝트 전체 코드의 품질 향상을 위한 ListCheckUtils 적용 (0) | 2025.02.16 |
---|---|
[StudyWithMe] 스터디 윗 미 프로젝트에서 n8n 사용해보기 (0) | 2025.02.03 |
[StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 1 (0) | 2025.01.23 |
[StudyWithMe] Async Thread Pool과 CompletableFuture (0) | 2025.01.12 |
[StudyWithMe] 스터디 윗 미 dev 무중단 배포 (0) | 2024.12.24 |