엔티티가 준영속 상태에서 생기는 문제점
스프링은 컨테이너는 트랜잭션 범위의 영속성 콘텍스트 전략을 기본으로 사용한다.
트랜잭션이 같으면 같은 영속성 콘텍스트가 콘텍스트가 공유되고 트랜잭션이 끝나면 영속성 콘텍스트가 닫히는 것이다.
그럼 트랜잭션이 끝나고 엔티티에 지연 로딩으로 연결된 다른 엔티티를 불러오면 어떻게 될까?
보통 트랜잭션은 비즈니스 로직이 있는 서비스단에서 시작하고 종료한다.
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY);
private Team team;
}
Member member = memberService.getMember(1); // 트랜잭션 종료, 준영속 상태
member.getTeam(); // 종료 후 지연로딩으로 연결된 team 정보 조회
당연히 영속성 콘텍스트가 없으므로 team의 정보를 조회하게 되면 예외가 발생한다.
서비스단이 아닌 다른 곳에서 변경 감지(Dirty Checking)는 잘 일어나지 않지만 마찬가지로 준영속 상태에서는 변경 감지도 예외를 발생시킨다.
김영한 님께서 JPA 책에서는 미리 엔티티를 불러오는 방법이 정리되어있다.
OSIV 방법도 정리되어 있지만 나는 사용해본 적도 없고 앞으로도 사용하지 않을 것 같아서 건너뛰겠다.
글로벌 패치 전략 수정
모든 패치 전략은 EAGER로 바꾸는 것은 굉장히 위험하다.
불필요한 데이터도 조회할 뿐만 아니라 OneToMany관계에서는 N+1 문제가 발생할 수 있다.
함부로 바꿨다간 데이터베이스에 엄청난 무리를 줄 수 있으므로 개인적으로 권장하지 않는다.
JPQL 패치 조인
JPQL은 글로벌 패치 전략을 참고하지 않는다. EAGER로 설정했다 하더라도 JPQL에 작성된 SQL되로 작동한다.
패치 조인을 이용하면 N+1 문제를 해결할 수 있다.
하지만 모든 메서드를 JPQL로 작성하게 되면 메서드 수가 늘어나고 유지보수나 가독성도 좋지 않다.
강제로 초기화
트랜잭션 실행 중인 서비스단에서 미리 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다.
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY);
private Team team;
}
@Transactional
public Member getMember(long memberId) {
Member member = memberRepository.findById(memberId);
member.getTeam(); // 미리 team 정보 조회
return member;
}
Member member = memberService.getMember(1); // 트랜잭션 종료, 준영속 상태
member.getTeam(); // team 정보 반환
하지만 이 방법도 다른 계층에 따라 서비스 로직이 바뀌는 것이기 때문에 의존성이 있다.
컨트롤러에서 서비스를 이용해 데이터를 조회한다고 가정해보면 컨트롤러에 따라 서비스 로직에 수정이 일어나는 것이다. 컨트롤러와 서비스와에 의존성을 모두 없애는 것이 바람직하다.
FACADE 계층 추가
다시 컨트롤러에서 서비스를 이용해 데이터를 조회한다고 가정할 때 컨트롤러와 서비스 사이에 Facade 계층을 하나 더 두는 방법이다. 결과적으로 서비스 계층과 프레젠테이션 계층 사이에 논리적인 의존성을 분리할 수 있다.
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY);
private Team team;
}
public class MemberService {
public Member getMember(long memberId) {
return memberRepository.findById(memberId);
}
}
public class MemberFacade {
@Autowired
MemberService memberService;
@Transactional
public Member getMember(long memberId) {
Member member = memberService.getMember(memberId);
member.getTeam();
return member;
}
}
Member member = memberFacade.getMember(1); // 트랜잭션 종료, 준영속 상태
member.getTeam(); // team 정보 반환
프락시를 초기화하는 코드는 Facade 클래스에서 담당하고 서비스에서는 비즈니스 로직에 집중하는 것이다.
하나 이것도 더 많은 코드와 클래스들이 생겨 불필요한 시간을 소비한다.
나의 경우엔
나는 컨트롤러 단에서 엔티티를 절대로 볼 수 없게 만든다. 엔티티는 오직 서비스 로직에서만 이용하고 그 외 계층에서는 DTO 객체를 사용한다. 서비스단에서 컨트롤러로 넘겨줄 때 아예 DTO 객체로 반환하고 넘겨준다.
반환할 때 Getter 메서드를 사용하게 되므로 지연 로딩으로 연결한 정보는 자동으로 조회 후 컨트롤러 단으로 넘어가게 된다.
하지만 목록 조회일 경우 위에서 말한 것처럼 N+1 문제가 발생하므로 목록 조회는 모두 QueryDSL을 사용하고 있다.
강제로 초기화 + 패치 조인 방식이라고 할 수 있다. 이렇게 두 가지 방법을 사용하면 불필요한 파일도 줄일 수 있고 문제도 발생하지 않는다.
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY);
private Team team;
}
public class MemberDTO {
...
private Team team;
}
public class MemberService {
@Transactional
public MemberDTO getMember(long memberId) {
MemberDTO member = memberRepository.findById(memberId);
// MemberDTO.SetTeam(member.GetTeam());
return modelMapper.map(member, MemberDTO.class);
}
}
Member member = memberService.getMember(1); // 트랜잭션 종료, 준영속 상태
member.getTeam(); // team 정보 반환