
개요
프로젝트가 한창이다. 프로젝트하며, 테스트 환경에서 JPA와 관련하여 발생한 이슈에 대해 글로 작성하면 좋을 것 같아 정리하였다.
이슈 1. 더티 체킹 (Dirty Checking)
바로 코드로 간다.
다음은 예제 코드로 시나리오는 다음과 같다.
- user 정보를 더티 체킹을 통해 업데이트 한다
- 업데이트가 잘 되었는지 검증한다.
@SpringBootTest
@AutoConfigureMockMvc
public class ExampleTest extends TestContainerSupport {
@Autowired
private UserRepository userRepository;
@Autowired
private TokenProvider tokenProvider;
@Autowired
MockMvc mockMvc;
private User loginUser;
private String accessToken;
@BeforeEach
void setup() {
User saveUser = userRepository.save(UserFixture.getUserFixture2("providerId", "test"));
loginUser = saveUser;
Token token = tokenProvider.createToken(saveUser.getId(), Role.USER);
accessToken = "Bearer " + token.accessToken();
}
@Test
void updateTest() throws Exception {
//given
String updateNickname = "업데이트된 닉네임";
List<Category> categories = List.of(Category.CUSTOMIZABLE, Category.PARTY);
String subwayLine = "2호선";
String subwayStation = "사당역";
String image = "update image";
//when
loginUser.updateUser(
updateNickname,
categories,
subwayLine,
subwayStation,
image);
//then
mockMvc.perform(get("/api/v1/auth")
.header(AUTHORIZATION, accessToken))
.andExpect(jsonPath("$.nickname").value(updateNickname))
.andExpect(jsonPath("$.subwayLine").value(subwayLine))
.andExpect(jsonPath("$.subwayStation").value(subwayStation))
.andExpect(jsonPath("$.categories.size()").value(2));
}
}

해당 테스트의 결과는 실패이다. UPDATE 쿼리가 나가지 않았다. 어찌보면 당연한 것이었다.
왜냐하면 현재 loginUser 객체는 영속성 컨텍스트 안에 있지 않다. 그렇기에 우리가 바라는 것처럼 더티 체킹(Dirty Checking)이 발생하지 않는다.
1. 더티 체킹은 영속성 컨텍스트 안에서 일어난다.
2. 영속성 컨텍스트의 생명주기는 트랜잭션의 생명주기와 같다.
3. 해당 테스트는 트랜잭션 안에서 일어나지 않기에 더티 체킹이 발생하지 않는다.
UPDATE 쿼리를 날리기 위해서는 다음 선택사항이 있다.
- @Transactional 어노테이션을 추가 후, 더티 체킹 사용
- save() 메서드를 사용하여 명시적으로 update
이슈 2. 지연 로딩 (Lazy loading)
1번의 방법으로 간다면 편하겠지만, 컨트롤러 테스트에서 @Transactional을 사용하지 말자는 컨벤션이 있었기에 2번의 방법을 선택하여 save() 메서드를 사용하였다. 1번이든 2번이든 테스트는 잘 통과할 줄 알았다.
@Test
void updateTest() throws Exception {
//given
String updateNickname = "업데이트된 닉네임";
List<Category> categories = List.of(Category.CUSTOMIZABLE, Category.PARTY);
String subwayLine = "2호선";
String subwayStation = "사당역";
String image = "update image";
//when
loginUser.updateUser(
updateNickname,
categories,
subwayLine,
subwayStation,
image);
//명시적으로 update
userRepository.save(loginUser);
//then
mockMvc.perform(get("/api/v1/auth")
.header(AUTHORIZATION, accessToken))
.andExpect(jsonPath("$.nickname").value(updateNickname))
.andExpect(jsonPath("$.subwayLine").value(subwayLine))
.andExpect(jsonPath("$.subwayStation").value(subwayStation))
.andExpect(jsonPath("$.categories.size()").value(2));
}

하지만 5개 중 딱 하나의 데이터가 UPDATE 되지 않았다. 그렇다면 왜 요건 되고 저건 안 되는 걸까?
잠시 User Entity의 일부를 보겠다.
@Entity
@NoArgsConstructor
@Getter
@Table(name = "USER_TABLE")
public class User implements UserDetails {
.
.
@Column(name = "USER_NICKNAME")
private String nickname;
@BatchSize(size = 8)
@OneToMany(mappedBy = "user", cascade = {PERSIST, REMOVE}, orphanRemoval = true)
private final List<UserCategory> userCategories = new ArrayList<>();
public void updateUser(
String nickname,
List<Category> categories,
String line,
String station,
String profileImageUrl
) {
this.nickname = nickname;
this.line = line;
this.station = station;
this.userCategories.clear();
categories.stream()
.map(category -> UserCategory.of(this, category))
.forEach(this.userCategories::add);
this.profileImageUrl = profileImageUrl;
}
우리의 의도는 이렇다.
- Category 객체를 UserCategory Entity로 변환
- userCategories 리스트를 지연로딩
- userCategories 리스트의 UserCategory Entity를 담아주고 casecade를 통해 저장
저장이 되지 않은 이유는 userCategories 리스트가 지연로딩 되지 않았기 때문이다.
지연로딩 또한 영속성 컨텍스트 안에서 일어난다. 현재 User 객체는 영속성 컨텍스트 안에 있지 않았다
(다른 말로 하면 트랜잭션 안에 있지 않다.)
그렇기에 해당 트랜잭션 밖에서 일어나는 저 로직을 비유한다면, 빈 깡통 리스트에 Entity 객체를 넣고 UserCategory Entity 에 대한 INSERT 쿼리가 나가길 바라는 것과 같다
UserCategory Entity를 저장하기 위해선 다음과 같은 선택사항이 있다.
- @Transactional 어노테이션을 추가
- UserCategory Repository를 생성 후, save() 메서드를 통해 명시적으로 저장
결국...
컨벤션은 '컨트롤러 테스트에서 @Transactional 어노테이션을 사용하지 말자'였지만...
테스트 하나 때문에 Repository를 생성하는 건 좀 그렇기도 하였고, @Transactional 하나면 더티 체킹과 지연 로딩을 동시에 해결할 수 있었다.
그래서 다음과 같이 @Transactional 어노테이션이 불가피하다면 사용하기로 팀원과 합의하였다!
정리
이번이 아니였다면 그저 온실 속 화초처럼, 트랜잭션 안에서 지연 로딩과 더티 체킹을 사용하며 버릇처럼 코드를 짰을 것이다
JPA와 영속성 컨텍스트가 제공해주는 기능들을 직접 몸으로 느껴본 것 같아 좋았다.
'JPA' 카테고리의 다른 글
| [JPA] saveAll() 문제점과 JDBC를 통한 해결 (0) | 2026.01.14 |
|---|---|
| [JPA] @SQLRestriction으로 soft delete 구현하기 (0) | 2025.12.21 |
| [JPA] 프록시 객체와 OneToOne 양방향 관계 이슈 (0) | 2025.12.21 |
| [JPA] 영속성 컨텍스트와 flush 시점 (0) | 2025.12.20 |
| [Query DSL] Expressions를 사용하여 여러 값에 대한 존재 여부 확인하기 (0) | 2023.12.14 |