[ Spring ] 통합 테스트 중 JPA Lazy Loading 문제
문제
통합 테스트에서 양방향 매핑 상태인 엔티티가 존재할 때, 하위 엔티티에 대한 Lazy Loading이 정상적으로 조회하지 못하는 문제가 발생.
두 엔티티를 각자의 Repository로 조회하는 경우에는 문제가 되지 않지만, 상위 엔티티 조회만으로 Lazy Loading을 활용하고자 하는 경우에는 필드값이 null로 처리된다는 문제가 발생한다.
예시와 문제 발생 이유
엔티티 구조
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "member")
@Getter
public class Member {
@Id
private String userId;
private String userPw;
private String userName;
@OneToMany(mappedBy = "member")
private final List<Auth> auths = new ArrayList<>();
public void addMemberAuth(Auth auth) {
auths.add(auth);
auth.setMember(this);
}
}
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "auth")
@Getter
public class Auth {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "userId")
private Member member;
private String auth;
public void setMember(Member member) {
this.member = member;
}
@Override
public String toString() {
return "Auth{" +
"id=" + id +
", auth='" + auth + '\'' +
'}';
}
}
두 엔티티는 이렇게 양방향 매핑이며 1 : N 관계를 갖고 있다.
서비스 메서드 및 테스트 클래스
@Service
@RequiredArgsConstructor
public class TestService {
private final MemberRepository memberRepository;
public void testServiceMethod(String userId) {
//JPA Query Method가 아닌 직접 구현한 RepositoryMethod라고 가정.
//fetchJoin 여부는 문제 해결에 도움이 되지 않으므로 단순 조회 및 fetchJoin 모두 상관 x
Member member = memberRepository.findByUserId(userId);
List<Auth> auths = member.getAuths();
System.out.println("auths : " + auths); // [Auth{id=null, auth='null'}]
Auth auth = auths.get(0);
System.out.println("auth : " + auth); // Auth{id=null, auth='null'}
}
}
@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private AuthRepository authRepository;
@Autowired
private TestService testService;
private Member member;
@BeforeEach
void init() {
//MemberFixtureDTO의 필드로 Member member 와 Auth auth가 있다고 가정.
MemberFixtureDTO memberFixture = MemberFixture.createMember();
member = memberFixture.getMember();
memberRepository.save(member);
authRepository.save(memberFixture.getAuth());
}
@Test
void lazyLoadingTest() {
String userId = member.getUserId();
testService.testServiceMethod(userId);
}
}
위 테스트 코드 실행 결과는 서비스 메서드의 주석과 같다.
처음 확인했을 때 특이하다고 생각했는데 emptyList가 아닌 모든 필드가 null이라는 점이었다.
또한, 만약 3개의 auth를 저장했다면 3개의 Auth가 나오지만 동일하게 모든 필드가 null로 나오게 된다.
이 문제는 서비스 메서드의 주석에서 적었듯이 fetchJoin()을 하더라도 동일한 결과를 보인다.
이렇게 처리되는 이유는 Lazy Loading 과정이 어떻게 처리되는가와 테스트 환경에서의 데이터 처리가 어떻게 되느냐에 있다.
우선 LazyLoading의 경우 말 그대로 '지연 로딩'이다.
fetchJoin()이 아니고서는 Member를 조회했을 때 모든 Auth 객체를 같이 조회한 상태가 아니라 프록시 상태로 대기하고 있다가 접근이 발생하는 순간 프록시 객체를 초기화되며 동작하고, Auth를 조회하는 쿼리를 DB에 날리게 된다.
테스트 환경에서는 @BeforeEach를 통해 매 테스트 메서드 실행 전 데이터를 저장하게 되는데, 이때 테스트 클래스 단위로 정의된 @Transactional에 의해 insert 쿼리가 DB로 날아가지 않고, 영속성 컨텍스트 내부의 1차 캐시에만 존재하게 된다.
즉, 테스트 환경에서 insert 쿼리가 발생하지 않고 영속성 컨텍스트 1차 캐시에만 데이터가 존재하기 때문에,
Member는 1차 캐시에서 정상적으로 가져오지만, Auth에 대해서까지는 1차 캐시의 Member에 같이 담겨있지 않으므로
JPA의 프록시 객체가 동작해 Auth를 조회하려고 DB에 쿼리를 날리게 된다.
하지만 DB에는 데이터가 없기 때문에 필드들을 제대로 가져올 수 없는 것이다.
여기까지 다시 정리해보면,
- BeforeEach에서 저장한 데이터는 @Transactional에 의해 DB에 실제 저장된 것이 아닌 영속성 컨텍스트의 1차 캐시에만 저장된 상태다.
- 서비스 메서드에서 Member를 조회하는 경우 영속성 컨텍스트에 해당 데이터가 존재하기 때문에 DB를 통해 조회하는 것이 아니라 캐시에서 바로 가져오게 된다.
- Auth에 접근 시 JPA의 프록시 객체가 동작해 1차 캐시가 아닌 DB에 바로 조회 쿼리를 날린다.
- 하지만 DB에 실제로 저장되지 않았기 때문에 제대로된 Auth 객체를 가져오지 못한다.
그래서 이런 생각을 했다.
'그럼 List<Auth>의 크기를 알고 있기도 한데 왜 필드값만 null인걸까? 애초에 emptyList 혹은 Auth 자체가 null로 나왔어야 하는거 아닐까??'
이건 Hibernate가 내부적으로 하위 엔티티 인스턴스를 new 키워드를 통해 넣는것이 아니라 bytecode instrumentation을 통해 프록시 객체를 생성한 뒤 프록시 객체를 넣기 때문이다.
마치 Lazy Loading을 위해 대기하는 것 처럼 하위 엔티티는 '프록시 객체'가 존재할 뿐, 필드가 초기화된 상태가 아니므로 모든 필드가 null이라는 값을 갖게 되는 것이다.
그럼 리스트의 크기는 어떻게 알 수 있는지가 궁금해진다.
이것도 Hibernate의 내부 컬렉션 매핑 구조와 1차 캐시 구조 때문이다.
Member 엔티티 내부에서 Auth를 담는 addMemberAuth()와 같은 코드가 @BeforeEach(Fixture 내부에서 생성 시 수행)에서 수행되었을 때, JPA는 Member 내부에 3의 크기를 갖는 List<Auth>가 존재한다는 정보를 1차 캐시에서 유지하고 있게 되는 것이다.
그래서 바로 직전에 말한 것 처럼 각 Auth들에 대해 프록시 객체를 생성하게 되고 3개의 프록시 객체를 Collection에 넣어두기 때문에 1차 캐시에서 조회하더라도 해당 리스트의 크기는 알 수 있지만, 필드가 초기화 되지 않은 상태이므로 각 필드값들이 null로 나오게 된다.
그리고 BeforeEach에서 저장할 때 Transactional에 의해 DB에 실제 저장된 것이 아니라 1차 캐시에 저장된다고 했다.
그럼 @Transactional이 없다면 잘 될까??
그것도 아니라고 한다. @Transactional이 없는 경우 save() 시 flush()가 발생할 '수도' 있지만 그렇지 않을수도 있기 때문이다.
즉, 테스트의 일관성이 사라져 언제는 되고, 언제는 안되는 문제가 발생할 수 있다.
해결방안을 정리하기 전 원인에 대해 다시 정리.
- @Transactional이 정의된 통합 테스트에서 데이터를 저장하는 경우 DB에 실제 저장되는 것이 아니라 영속성 컨텍스트의 1차 캐시에만 저장된다.
- 이때, 하위 엔티티인 Auth의 경우 객체 인스턴스 자체가 생성되며 저장되는 것이 아닌 bytecode instrumentation을 통해 프록시 객체로 생성된 뒤 이 프록시 객체가 담기게 된다. 이때, 프록시 객체가 생성될 뿐, 필드가 초기화되는 것은 아니기 때문에 모든 필드의 값은 null이 된다.
- Auth가 여러개인 경우 그 크기만큼 프록시 객체가 생성되어 담기게 된다.
- 서비스 메서드에서 Member를 조회할때는 1차 캐시에 데이터가 있기 때문에 DB로 요청을 보내는 것이 아닌 1차 캐시에서 데이터를 가져오게 된다.
- Auth에 접근하는 순간 JPA는 프록시 객체를 초기화시켜 DB로 요청을 보내게 된다. 하지만 1차 캐시에만 데이터가 존재하므로 데이터를 정상적으로 조회하지 못하고, 1차 캐시에 담긴 프록시 객체 자체를 참조해 모든 필드가 null 상태인 그대로 가져오게 된다.
문제 해결 방안
문제 해결은 간단하다.
데이터 저장 이후 flush(), clear()를 통해 강제로 DB에 반영되도록 해주기만 하면 된다.
@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private AuthRepository authRepository;
@Autowired
private TestService testService;
@Autowired
private EntityManager em;
private Member member;
@BeforeEach
void init() {
//MemberFixtureDTO의 필드로 Member member 와 Auth auth가 있다고 가정.
MemberFixtureDTO memberFixture = MemberFixture.createMember();
member = memberFixture.getMember();
memberRepository.save(member);
authRepository.save(memberFixture.getAuth());
em.flush();
em.clear();
}
@Test
void lazyLoadingTest() {
String userId = member.getUserId();
testService.testServiceMethod(userId);
}
}
문제 원인의 마지막 부분에서 '@Transactional이 없더라도 flush()가 발생할수도, 그렇지 않을 수도 있다.' 라고 했다.
그 말은 즉, flush()가 발생하면 문제가 해결된다는 의미이다.
그래서 정말 간단하게 entityManager.flush()와 entityManager.clear()를 강제로 발생시켜 DB에 반영 되도록 해주기만 한다면 JPA가 프록시 객체를 초기화시켜 DB에 조회 요청을 보내더라도 정상적인 조회가 가능해지게 된다.