JPA 프로그래밍 - LazyInitializationException
[ 문제 상황 ]
문제 상황에 대해서는 기본적으로 JPA 코드를 Entity부터 Controller까지 작성할 줄 안다고 가정하고 작성하였다. 이 부분을 잘 모른다면 글을 읽기 전에 공부하기를 바란다.
복습을 하던 도중 이전에 잘됐던 Controller 내부의 코드가 잘 작동하지 않았다. 해당 API를 PostMan으로 돌려보면 500번 에러와 함께 제목에 적힌 LazyInitializationException 에러가 뜨는데, 이 현상이 일어난 이유와 해결방안에 대해서 알아보고자 한다.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
[ 에러 상황 재현 ]
해당 에러에 관해 검색을 해보니, JPA에서 Transactional 범위 바깥에서 코드를 실행해 지연 로딩 값을 가져오지 못하기 때문이라는 검색 결과가 나왔다. 그래서 당장 생각난 방법은 페치 조인으로 데이터를 한꺼번에 가져와버리는 방식이었는데, 여러 다른 방법이 없을까 고민을 해보았다. 에러 상황을 재현해야 하니 해당 에러 상황을 재현할 코드를 작성해보자. 코드를 작성하기 전, 반드시 아래의 옵션을 false로 놔두어야지 된다.
application.yml
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
# 한꺼번에 땡겨올 데이터 양 선정 N+1 문제 해결
default_batch_fetch_size: 100
# 영속성 컨텍스트가 커넥션을 어디까지 물고 있냐를 결정
open-in-view: false
에러 확인용 코드 작성:
Entity 코드 작성
Entity 코드는 정말 간단하게 이름만 있는 Member와 Team을 일대다로 Member에서 Team으로 가는 단방향 연관관계를 걸어놓았다. 편의상 Getter와 Setter는 모두 열어둔다.
Member.java
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
Team.java
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
예제용 데이터 코드 작성
Repository 코드는 MemberRepository로 스프링 데이터 JPA를 사용해서 JpaRepository를 상속 받은 걸 사용하는 방식으로 만들었기 때문에 생략하였다. 또한 Service 코드는 Controller에서 바로 로직을 사용할 것이기 때문에 DB를 실행 전 미리 데이터를 넣는 코드만 작성을 할 예정이다.
InitDb.java
@Component
@RequiredArgsConstructor
public class InitDb {
private final InitService initService;
@PostConstruct
public void init(){
initService.dbInit1();
}
@Component
@Transactional
@RequiredArgsConstructor
static class InitService {
private final MemberRepository memberRepository;
private final TeamRepository teamRepository;
public void dbInit1(){
Team teamA = createTeam("TeamA");
Member member1 = createMember("member1", teamA);
Member member2 = createMember("member2", teamA);
teamRepository.save(teamA);
memberRepository.save(member1);
memberRepository.save(member2);
Team teamB = createTeam("TeamB");
Member member3 = createMember("member3", teamB);
Member member4 = createMember("member4", teamB);
teamRepository.save(teamB);
memberRepository.save(member3);
memberRepository.save(member4);
}
private Member createMember(String name, Team team){
Member member = new Member();
member.setName(name);
member.setTeam(team);
return member;
}
private Team createTeam(String name){
Team team = new Team();
team.setName(name);
return team;
}
}
}
Controller 코드 작성
이제 마지막으로 Controller 코드만 작성해주면 된다. Controller 코드 내부에서는 간단하게 MemberRepository에서 모든 데이터를 조회하고 Member 객체를 생으로 노출 시키는게 아니라 Dto로 내보내기 위해 다음과 같이 작성을 하였다. API가 많지 않으니 MemberDto는 내부 InnerClass로 간단하게 놓았다.
MemberApiController.java
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberRepository memberRepository;
@GetMapping("/api/getMembers")
public List<MemberDto> getMembers(){
List<Member> members = memberRepository.findAll();
List<MemberDto> result = members.stream()
.map(m -> new MemberDto(m))
.collect(Collectors.toList());
return result;
}
@Data
static class MemberDto {
private String memberName;
private String teamName;
MemberDto(Member member){
this.memberName = member.getName();
this.teamName = member.getTeam().getName();
}
}
}
이제 윗 코드를 본인이 설정한 DB를 켜놓고 PostMan 등을 통해서 확인해보면 LazyInitializationException이라면서 500번 에러 코드가 날아오는 것을 확인할 수 있다!
[ 문제 해결 방법 제시 ]
원인 결론을 간단하게 이야기하면, Transactional 생명주기가 open-in-view 옵션을 false로 해놓았기 때문에 Controller에서는 적용되지 않아 영속성 컨텍스트가 더이상 해당 객체에 대한 추적을 하지 않아서 일어진 일이다. 그렇기 때문에 Lazy로 @ManyToOne에 걸어둔 조건이 변경감지가 알아서 추가 쿼리를 날려서 채우는 일이 벌어지지 않은 것이다. 해결하고 싶으면 영속성 컨텍스트가 관리할 수 있는 공간으로 코드를 이동 시키거나 강제로 한번에 테이블을 싹 다 당겨오면 된다.
해결 방안 1. 페치 조인( 가장 권장함 )
가장 쉬운 방법은 지연로딩으로 가져올 데이터들을 안 가져오는 방법이 있을 것이다. 하지만 API에서 해당 값을 넣었다는 것은 분명히 쓸 것이니까 반드시 필요한 값이기 때문에 넣었을 것이다. 그렇기 때문에 페치 조인으로 지연로딩 없이 한방에 가져오는 것이다. 나머지 구조를 크게 바꾸지 않아도 되며, 본인 설계상에 문제가 없었다면 가장 좋은 방법이라고 생각한다.
다음과 같이 MemberRepository에 코드를 추가해주자. 그리고 Controller에서 데이터를 가져오는 부분을 apiGetMembers로 바꿔주면 문제가 해결된다.
MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m join fetch m.team t")
public List<Member> apiGetMembers();
}
해결 방안 2. Dto를 외부 클래스로 분리
Dto에서 값을 채우는 부분이 영속성 컨텍스트에서 관리하지 않는 부분이기 때문에 문제가 생긴 것이니, Dto를 채우는 코드를 Controller 계층이 아닌 Service 계층으로 내려버리면 해결된다. 하지만 단점은 아래의 코드로 작성하면 N+1 문제가 터지니 이 부분은 본인이 해결방법을 찾아서 해결하길 바란다.
MemberSerivce.java
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public List<MemberDto> getMembers() {
List<Member> members = memberRepository.findAll();
List<MemberDto> result = members.stream()
.map(m -> new MemberDto(m))
.collect(Collectors.toList());
return result;
}
}
MemberApiController.java
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberRepository memberRepository;
private final MemberService memberService;
@GetMapping("/api/getMembers")
public List<MemberDto> getMembers(){
return memberService.getMembers();
}
}
[ 느낀 점과 추가정보 ]
여러 방법을 고민해보면서 여러 계층에서 @Transactional를 써봤는데 해결방안으로 생각한 부분이 많이들 에러가 났다. @Transactional 키워드에 관해서 정확하게 어떻게 사용하는지 공부할 필요가 있음을 느꼈고 AOP와 관련된 개념이라는 걸 알게 되었으니 이 부분에 관해서 정리해보아야겠다.
[ 참고 자료 ]
실전! 스프링 부트와 JPA 활용2 - 김영한 강의