이번 글은 해당 포스트를 작성하다 만난 문제를 해결한 과정에 대해서 설명하는 글입니다.
@GetMapping("/api/allProves/v4/STUDY")
public List<ProveDtoV2>getProvesV4(){
return proveService.getProvesWithCache();
}
해당 요청을 보내니까, 이러한 문제가 발생하였다.
2024-10-05T18:09:20.482+09:00 ERROR 22288 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]
: Servlet.service() for servlet [dispatcherServlet]
in context with path [] threw exception [Request processing failed:
org.hibernate.LazyInitializationException:
could not initialize proxy [com.prove.domain.User.UserEntity#2] - no Session] with root cause
could not initailaize proxy...
딱 이 문구를 보자마자,
"어? 이거 영속성 컨텍스트에 올라간 인스턴스가 있고, 인스턴스.어떤거로 proxy를 DB에서 가져와서 진짜로 대체해줘야하는데 문제가 발생했구나"라는 생각을 했습니다.
그래서 처음에 캐시에 proveList를 올려두는 코드로 돌아갔습니다.
@PostConstruct // Bean이 초기화될 때 호출됨
public void init() {
List<Prove> proves = proveRepository.findTop100ByStudyTagOrderByLikeCountDesc(); // DB에서 100개의 Prove 가져오기
Cache cache = cacheManager.getCache("STUDY");
if (cache != null) {
cache.put("top100Proves", proves); // "STUDY" 캐시에 저장
System.out.println("Prove 데이터가 STUDY 캐시에 저장되었습니다.");
}
}
proveRepository에서 prove들을 가져와서 캐시에다가 올려두고,
컨트롤러를 통해 요청이 들어오면
@GetMapping("/api/allProves/v4/STUDY")
public List<ProveDtoV2>getProvesV4(){
return proveService.getProvesWithCache();
}
public List<ProveDtoV2> getProvesWithCache() {
Cache cache = cacheManager.getCache("STUDY"); // "STUDY" 캐시 사용
List<Prove> cachedProves = cache.get("top100Proves", List.class);
System.out.println("캐시에서 데이터를 반환합니다.");
return makeProveDtos(cachedProves); // 캐시에서 데이터를 반환
}
서비스 메서드에서 캐시된 데이터를 DTO로 변환해서 반환하려 했습니다.
여기서 문제는 makeProveDtos 메서드입니다.
public List<ProveDtoV2> makeProveDtos(List<Prove> proves) {
System.out.println("dot 메서드");
// 각 Prove 객체를 ProveDtoWithId로 변환한 후 리스트로 반환
return proves.stream()
.map(prove -> {
UserEntity userEntity = prove.getUser();
Long likeCount = likeRepository.countLikesByProveId(prove.getId());
// UserDto 생성
UserDto userDto = UserDto.builder()
.username(userEntity.getUsername())
.role(userEntity.getRole())
.build();
...
이 메서드에서는 prove.getUser()를 호출해 User 엔티티를 가져옵니다. 그런데 User를 가져올 때 Hibernate의 지연 로딩(Lazy Loading) 전략에 따라 객체를 즉시 로드하지 않고, 해당 엔티티가 필요할 때 데이터베이스에서 가져오는 방식으로 작동합니다.
하지만 이 상황에서 LazyInitializationException이 발생했습니다. 나는 처음에 "어차피 prove.getUser()를 할 때마다 DB 쿼리가 발생할 거고, 쿼리 로그가 많이 남는 건(N+1문제) 둘째치고, 왜 DB에서 데이터를 못 가져오는 걸까?"라고 생각했습니다. 결국 이유를 찾지 못해 GPT에게 질문을 했습니다.
GPT 대답
getUser() 메서드를 호출할 때, Hibernate의 지연 로딩(Lazy Loading) 전략에 따라 실제 UserEntity를 즉시 로드하지 않고, Prove 객체를 사용하려는 시점에서 세션이 열려 있어야 UserEntity를 로드할 수 있습니다.
이 말을 듣고 "왜 세션이 그 시점에 종료되어 있었을까?"라는 의문이 생겼습니다. 이에 대한 GPT의 설명은 다음과 같습니다:
세션 종료는 Hibernate의 세션 생명 주기와 관련이 있으며, 요청이 완료되면 세션이 닫히고, 이로 인해 Lazy Loading이 필요할 때 세션이 없어서 LazyInitializationException이 발생하게 됩니다. 이를 방지하기 위해서는 Join Fetch를 사용하거나, 필요한 엔티티를 명시적으로 로드하고 @Transactional 어노테이션을 적절히 활용해야 합니다.
세션 종료 후 Lazy Loading: 만약 세션이 종료된 후에 Lazy Loading이 발생하면, Hibernate는 연결할 세션이 없기 때문에 LazyInitializationException이 발생합니다. 이는 UserEntity 같은 연관된 엔티티를 로드하기 위해 DB에 접근하려 하지만, 이미 세션이 닫혀있어서 접근이 불가능하기 때문입니다.
즉, @PostConstruct로 init() 메서드를 호출할 때, proveList를 캐시에 저장하면서, 요청이 완료되어 세션이 종료된 상태가 된 것입니다.
그러나 지연 로딩에 의해 User를 조회하려고 하면 세션이 이미 종료된 상태라 DB에서 데이터를 가져올 수 없어 LazyInitializationException이 발생한 것입니다.
Lazy Loading 전략에 의해 발생한 것이므로, 해결을 위해서는 Join Fetch를 사용하여 처음부터 필요한 엔티티를 명시적으로 로드하면 됩니다.
이전
@Query("SELECT p FROM Prove p JOIN Like l ON p.id = l.prove.id WHERE p.tags = 'STUDY' GROUP BY p.id ORDER BY COUNT(l.id) DESC")
List<Prove> findTop100ByStudyTagOrderByLikeCountDesc();
이후
@Query("SELECT p FROM Prove p Join fetch p.user JOIN Like l ON p.id = l.prove.id WHERE p.tags = 'STUDY' GROUP BY p.id ORDER BY COUNT(l.id) DESC")
List<Prove> findTop100ByStudyTagOrderByLikeCountDesc();
이렇게하면 명시적으로 Prove를 가져올때 User도 같이 가져오므로, makeProveDtos메서드에서 p.getUser를 하더라도 오류가 발생하지 않는다.
그런데 두번째 해결방안으로 GPT가 알려준건 Transaction 애노테이션을 붙이라는 것이다.
@Component
public class CacheInitializer {
private final ProveRepository proveRepository;
private final CacheManager cacheManager;
@Autowired
public CacheInitializer(ProveRepository proveRepository, CacheManager cacheManager) {
this.proveRepository = proveRepository;
this.cacheManager = cacheManager;
}
@PostConstruct // Bean이 초기화될 때 호출됨
@Transactional // 트랜잭션을 적용
public void init() {
List<Prove> proves = proveRepository.findTop100ByStudyTagOrderByLikeCountDesc(); // DB에서 100개의 Prove 가져오기
Cache cache = cacheManager.getCache("STUDY");
if (cache != null) {
cache.put("top100Proves", proves); // "STUDY" 캐시에 저장
System.out.println("Prove 데이터가 STUDY 캐시에 저장되었습니다.");
}
}
}
@Transactional을 붙임으로써 이 메서드가 호출될 때 Hibernate 세션이 열리게 됩니다.
proves 리스트를 가져올 때 Lazy Loading이 필요할 경우, 세션이 열려 있으므로 UserEntity와 같은 Lazy 로딩된 속성을 가져올 수 있습니다.
캐시 초기화가 정상적으로 진행되고 Lazy Initialization Exception이 발생하지 않도록 합니다.
이렇게 설정하면 Prove 엔티티의 Lazy 로딩이 필요한 경우에도 문제가 발생하지 않도록 보장할 수 있습니다.
라고 GPT가 하는데, 처음에 무턱대고 @Transactional 애노테이션을 붙였는데 오류가 그대로 발생하였다.
혹시 이게, 트랜잭션 전파랑 연관이 있나? 싶어서 물어보니까
세션 종료와 트랜잭션 전파
세션과 트랜잭션의 관계: Hibernate의 세션은 트랜잭션이 시작되면 열리고, 트랜잭션이 커밋되거나 롤백되면 닫힙니다. 따라서 트랜잭션의 전파 방식이 세션의 생명 주기와 밀접하게 연결되어 있습니다.
라고 했다.
그런데 세션 종료와 트랜잭션 전파에 관련한 내용은 맞지만, 내 코드에 Transactional을 붙인다고 오류가 해결되지는 않는다.
init메서드에다가 트랜잭션 애노테이션을 붙이는 것과 Lazy Loading으로 발생하는 문제는 트랜잭션 전파와는 전혀 연관성이 없다.
왜냐하면
트랜잭션은 하나의 Connection을 가져와서 사용한뒤에 닫는 사이에 실행된다.
트랜잭션의 시작과 종료는 해당 Connection객체를 통해 이뤄지기 때문이다.
하나의 트랜잭션이 시작하면 commit()또는 rollback()이 호출될때 까지가 하나의 트랜잭션으로 묶인다.
그러나, 작업을 하던 도중 기존에 트랜잭션이 진행중일 때 추가적인 트랜잭션을 진행해야하는 경우가 있다.
이미 트랜잭션이 진행 중에서 추가 트랜잭션 진행을 어떻게 결정할지 결정하는것이 전파 속성이다.
예를들어, 외부 트랜잭션을 시작하고, 메서드 내부에서 또다른 메서드를 호출했을때 해당 메서드를 같은 트랜잭션으로 엮을지 아니면, 다른 트랜잭션으로 분리할지를 결정하는것이다.
그러나 init메서드로 다시 돌아가면,
@PostConstruct // Bean이 초기화될 때 호출됨
@Transactional // 트랜잭션을 적용
public void init() {
List<Prove> proves = proveRepository.findTop100ByStudyTagOrderByLikeCountDesc(); // DB에서 100개의 Prove 가져오기
Cache cache = cacheManager.getCache("STUDY");
if (cache != null) {
cache.put("top100Proves", proves); // "STUDY" 캐시에 저장
System.out.println("Prove 데이터가 STUDY 캐시에 저장되었습니다.");
}
}
나는 init메서드에서 캐시에 결과를 올려놓을때 getCache메서드를 호출한것 이외에는 다름 메서드를 호출한 적이 없다.
이는 그냥 init메서드가 종료가 되면 Transaction이 commit또는 롤백이 이미 된것이다.
그 뒤에 종료된 세션으로 getUser메서드 호출을 하기때문에 오류가 발생하는건데, 트랜잭션 전파와는 어떠한 관련도 없고, 당연히 @Transactional을 붙이더라도 문제가 해결되지 않는다.
만약, 해당 문제를 join fetch를 사용해서 풀지 않으려면,
그냥 init메서드에서 makeProveDto를 호출하는 수밖에 없다.
@PostConstruct // Bean이 초기화될 때 호출됨
@Transactional // 트랜잭션을 적용
public void init() {
List<Prove> proves = proveRepository.findTop100ByStudyTagOrderByLikeCountDesc(); // DB에서 100개의 Prove 가져오기
List<ProveDtoV2> proveDtos = makeProveDtos(proves);
Cache cache = cacheManager.getCache("STUDY");
if (cache != null) {
cache.put("top100Proves", proveDtos); // "STUDY" 캐시에 저장
System.out.println("Prove 데이터가 STUDY 캐시에 저장되었습니다.");
}
}
당연히 prove마다 p.getUser를 할때 쿼리가 날라가겠지만, 문제는 생기지 않는다. 하나의 요청 안에서 메서드를 호출하니까 세션이 종료된 상태가 아니니까 말이다.
또한 @Transactional애노테이션을 당연히 사용하지 않아도 된다.
그러나, Transactional 애노테이션이 붙어있으면 makeProveDtos가 init메서드 내부에서 호출되므로, 전파옵션에 따라 makeProveDtos가 실패한다면 같이 롤백될 수 있다. 이를 통해 데이터의 일관성을 보장할 수 있다.
먼가 서비스단에는 무조건 @Transactional을 적는게 습관이 되었는데, GPT가 해당 문제를 @Transactional을 붙여서 해결하라고 하니까 무작정 붙였는데 안되었다.
GPT가 설명을 트랜잭션 전파를 통해 세션을 살린다는 식으로 말한거 같은데, 나의 경우에는 전혀 관계가 없는 내용이었다.
GPT가 하는 글을 맹목적으로 맹신하기 보다는 내용을 이해하고, 나의 상황에 맞춰서 판단하는것이 중요하다는 생각이 들었다.