최근에 한 프로젝트에서 테스트 코드를 짜다가 트러블 슈팅 과정에서 새롭게 안 사실이 있어서 블로그를 작성하게 되었다.
"동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성되는지"에 대한 테스트 코드를 짰다. 이를 위해 멀티스레드 환경에서 동시에 같은 이메일로 유저를 저장했을 때, 하나의 계정만 생성되었는지 확인하고자 했다.
처음 구성한 코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
@DataJpaTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
private static Member member;
@BeforeEach
void setUser() {
member = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null);
}
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
memberRepository.save(member);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail(member.getEmail());
assertAll(
() -> assertThat(exceptions).hasSize(2),
() -> assertThat(savedMember).hasSize(1)
);
}
}
|
cs |
이렇게 작성한 후에 테스트가 잘 통과하여, PR을 올렸는데 "@SpringBootTest가 아닌 @DataJpaTest를 사용한 이유가 있냐는 코드리뷰를 받았다. (이 코드리뷰 안 받았으면 그냥 아무 생각없이 넘어갔을텐데, 짚어줘서 너무 고마웠다.)
사실 특별한 이유는 없었고, 해당 테스트가 데이터 엑세스 레이어(repository)에 대한 테스트여서, SpringBootTest에 비해 가벼운 DataJpaTest를 사용했는데 좀 더 차이를 제대로 알고 써보자는 생각이 들었다!
그래서 차이를 찾아보니 트랜잭션 관련돼서 차이가 있었다!
구글링을 해본결과 차이는 아래와 같았다.
@SpringBootTest
- 전체 스프링부트 애플리케이션 컨텍스트 로드
- 실제 애플리케이션 환경과 유사한 테스트 환경 제공
- @Controller, @Service, @Repository 등 모든 빈 로드
- ✅ @Transactional을 포함하지 않으므로 테스트 후 롤백을 하고 싶다면 해당 어노테이션을 붙여줘야 한다.
@DataJpaTest
- JPA 관련 bean들만 bean으로 등록한다.
- @Repository 어노테이션을 가진 빈들을 자동 스캔
- 빠른 테스트 실행 속도
- ✅ @DataJpaTest는 @Transactional 애노테이션을 포함하기 때문에, 따로 붙여주지 않아도 각 테스트 종료시 DB가 자동으로 롤백된다.
- @AutoConfigureTestDatabase: 기본적으로 in-memory DB를 활용해서 테스트가 실행된다. -> 따라서 테스트 환경에서 MySQL과 같은 DB를 사용하기 위해서 아래와 같이 설정을 해주어야 한다.
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
위에서 ✅ 표시한 트랜잭션 관련 동작을 실제로 테스트 코드를 작성하여 확인해보았다.
1. @SpringBootTest로 테스트 했을때
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
@SpringBootTest
class SimpleTest {
@Autowired
private MemberRepository memberRepository;
private static Member member;
@BeforeEach
void setUser() {
member = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null);
}
@Test
void contextLoads() {
memberRepository.save(member);
}
}
|
cs |
트랜잭션 롤백이 되지 않아 실제로 insert 쿼리가 나가고, DB에도 값이 잘 저장되었다.
1-1. @SpringBootTest + @Transactional을 함께 사용할 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
@SpringBootTest
class SimpleTest {
@Autowired
private MemberRepository memberRepository;
private static Member member;
@BeforeEach
void setUser() {
member = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null);
}
@Test
@Transactional
void contextLoads() {
memberRepository.save(member);
}
}
|
cs |
insert 쿼리가 찍혔지만, 트랜잭션 롤백이 되어해당 트랜잭션에서 수행된 모든 변경 사항이 취소되어, DB에 반영되지 않은 걸 확인할 수 있었다.
2. @DataJpaTest로 테스트 했을때
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class SimpleTest {
@Autowired
private MemberRepository memberRepository;
private static Member member;
@BeforeEach
void setUser() {
member = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null);
}
@Test
void contextLoads() {
memberRepository.save(member);
}
}
마찬가지로 insert 쿼리가 찍혔지만, DB에 반영되지 않은 걸 확인할 수 있었다.
두 어노테이션의 차이를 알아보았고, 이제 앞에서 말했던 테스트코드에서의 이를 위해 멀티스레드 환경에서의 테스트코드를 짜다가 발생한 트러블 슈팅을 기록해보겠다.
코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
private static Member member;
@BeforeEach
void setUser() {
member = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null);
}
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
@Transactional
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
memberRepository.save(member);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail(member.getEmail());
assertAll(
() -> assertThat(exceptions).hasSize(2),
() -> assertThat(savedMember).hasSize(1)
);
}
}
|
cs |
내 예상은 @SpringBootTest와 @Transactional을 함께 사용했으므로, DB에 아무 값도 저장이 안 될 줄 알았다.
하지만 예상과 달리, 하나의 유저가 저장되었다.
쿼리는 아래와 같이 실행되었다.
Hibernate:
/* insert for
com.favoriteplace.app.member.domain.Member */insert
into
member (alarm_allowance,birthday,created_at,description,email,fcm_token,login_type,modified_at,nickname,password,point,profile_icon_id,profile_image_url,profile_title_id,refresh_token,status)
values
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate:
/* insert for
com.favoriteplace.app.member.domain.Member */insert
into
member (alarm_allowance,birthday,created_at,description,email,fcm_token,login_type,modified_at,nickname,password,point,profile_icon_id,profile_image_url,profile_title_id,refresh_token,status)
values
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate:
/* insert for
com.favoriteplace.app.member.domain.Member */insert
into
member (alarm_allowance,birthday,created_at,description,email,fcm_token,login_type,modified_at,nickname,password,point,profile_icon_id,profile_image_url,profile_title_id,refresh_token,status)
values
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
02-10 20:53:42 WARN [o.h.e.jdbc.spi.SqlExceptionHelper] SQL Error: 1062, SQLState: 23000
02-10 20:53:42 WARN [o.h.e.jdbc.spi.SqlExceptionHelper] SQL Error: 1062, SQLState: 23000
02-10 20:53:42 ERROR [o.h.e.jdbc.spi.SqlExceptionHelper] Duplicate entry 'sss@naver.com' for key 'member.UK_mbmcqelty0fbrvxp1q58dn57t'
02-10 20:53:42 ERROR [o.h.e.jdbc.spi.SqlExceptionHelper] Duplicate entry 'sss@naver.com' for key 'member.UK_mbmcqelty0fbrvxp1q58dn57t'
Hibernate:
/* <criteria> */ select
m1_0.member_id,
m1_0.alarm_allowance,
m1_0.birthday,
m1_0.created_at,
m1_0.description,
m1_0.email,
m1_0.fcm_token,
m1_0.login_type,
m1_0.modified_at,
m1_0.nickname,
m1_0.password,
m1_0.point,
m1_0.profile_icon_id,
m1_0.profile_image_url,
m1_0.profile_title_id,
m1_0.refresh_token,
m1_0.status
from
member m1_0
where
m1_0.email=?
@Transactional을 붙이던, 안 붙이던 같은 결과가 발생해서 오잉?! 싶었다.
트랜잭션 동작을 파악하고 싶어서, email필드에 unique제약조건을 없애고 아래와 같이 돌려보았다. 결과는 롤백이 일어나지 않았고, 3개의 유저 모두 저장되었다.
package com.favoriteplace.app.repository;
import com.favoriteplace.app.member.domain.Member;
import com.favoriteplace.app.member.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
@Transactional
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
Member newMember = Member.create( // ★ 새로운 객체 생성
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null
);
memberRepository.save(newMember);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail("sss@naver.com");
}
}
그렇다면 메인 스레드인 테스트 메서드와 내부에서 생성된 스레드들이 각각 다른 트랜잭션을 타게 되는 건가?! 싶어서 코드를 찍어봤다.
System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());
찍어보니까, 메인 스레드인 테스트 메서드에서는 트랜잭션이 작동했고, 내부 스레드에서는 트랜잭션이 작동되고 있지 않았다.
🟢 Current Thread: main
🟢 Transaction Name: com.favoriteplace.app.repository.MemberRepositoryTest.joinMemberWithConcurrent
🟢 Is Actual Transaction Active? true
Hibernate:
/* insert for
com.favoriteplace.app.member.domain.Member */insert
into
member (alarm_allowance,birthday,created_at,description,email,fcm_token,login_type,modified_at,nickname,password,point,profile_icon_id,profile_image_url,profile_title_id,refresh_token,status)
values
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate:
/* insert for
com.favoriteplace.app.member.domain.Member */insert
into
member (alarm_allowance,birthday,created_at,description,email,fcm_token,login_type,modified_at,nickname,password,point,profile_icon_id,profile_image_url,profile_title_id,refresh_token,status)
values
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate:
/* insert for
com.favoriteplace.app.member.domain.Member */insert
into
member (alarm_allowance,birthday,created_at,description,email,fcm_token,login_type,modified_at,nickname,password,point,profile_icon_id,profile_image_url,profile_title_id,refresh_token,status)
values
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
🟢 Current Thread: pool-4-thread-2
🟢 Transaction Name: null
🟢 Current Thread: pool-4-thread-1
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-3
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Is Actual Transaction Active? false
Hibernate:
/* <criteria> */ select
m1_0.member_id,
m1_0.alarm_allowance,
m1_0.birthday,
m1_0.created_at,
m1_0.description,
m1_0.email,
m1_0.fcm_token,
m1_0.login_type,
m1_0.modified_at,
m1_0.nickname,
m1_0.password,
m1_0.point,
m1_0.profile_icon_id,
m1_0.profile_image_url,
m1_0.profile_title_id,
m1_0.refresh_token,
m1_0.status
from
member m1_0
where
m1_0.email=?
정리하자면, joinMemberWithConcurrent() 테스트 메서드는 @Transactional을 가지고 있어서, 해당 main 스레드에서는 트랜잭션이 시작되고, 테스트가 끝나면 롤백되게 된다. 하지만, executorService.execute() 내부에서 실행된 코드는 새로운 스레드에서 실행되며 이 스레드들은 @Transactional이 적용되지 않은 상태에서 실행되었다.
따라서, 내부 스레드에서는 트랜잭션이 없기 때문에, JPA가 기본 설정에 따라 자동 커밋(Auto Commit)을 수행하게 되게 되는 것이다.
🤔 여기서, 자동 커밋이란?
- Hibernate는 트랜잭션이 없는 상태에서 엔티티를 저장할 경우 자동 커밋(Auto Commit)을 수행한다.
- memberRepository.save(newMember);가 실행될 때 트랜잭션이 없으면, Hibernate가 자체적으로 각 INSERT 쿼리를 개별적인 트랜잭션으로 실행하고 즉시 커밋해버린다.
그렇다면 왜 메인 스레드의 롤백이 내부 스레드에 영향을 주지 못했을까?
1️⃣ 메인 스레드의 트랜잭션은 내부 스레드와 다르다.
- Spring의 트랜잭션(@Transactional)은 스레드 로컬(ThreadLocal) 기반으로 관리됨.
- 즉, main 스레드에서 시작된 트랜잭션은 다른 스레드에서는 접근할 수 없음.
📌 테스트 메서드에서 트랜잭션을 롤백하더라도, 내부 스레드에서는 애초에 트랜잭션이 없었기 때문에 영향을 받지 않음.
2️⃣ 내부 스레드에서는 트랜잭션 없이 실행되었기 때문에 롤백할 트랜잭션이 없었다.
- 내부 스레드에서 실행된 save()는 트랜잭션 없이 실행되었으므로, Hibernate가 Auto Commit을 수행.
- 따라서 @Transactional이 붙은 main 스레드의 롤백과는 아무 관련이 없음.
'백엔드 > SpringBoot' 카테고리의 다른 글
[페이징] offset pagination vs cursor pagination (0) | 2025.02.17 |
---|---|
스프링 AOP와 트랜잭션 (0) | 2025.02.12 |
② 실전! 스프링 부트와 JPA 활용1 (1) | 2023.11.14 |
① 실전! 스프링 부트와 JPA 활용1 (2) | 2023.11.06 |
② JPA 공부 By 실전! 스프링 데이터 JPA (0) | 2023.03.14 |