본문 바로가기
프로그래밍/Spring Boot

@Transactional과 Lazy Loading

by 카프카뮈 2021. 6. 15.

본 글은 아래의 글에서 이어지는 글이다.

https://kafcamus.tistory.com/30

 

@Transactional 어노테이션의 이해

나는 보통 서비스 코드에 @Transactional 어노테이션을 활용해준다. 그런데 사실 뜻도 잘 모르고 좋다고 그래서 쓴거라...지나고 보니 정확히 설명하기가 어려웠다. 그런고로, 해당 어노테이션의 작

kafcamus.tistory.com

앞서 @Transactional 어노테이션을 사용할 경우 어떻게 트랜잭션이 구현되는지를 간단히 다뤘다.

그런데, 이렇게 트랜잭션을 구현할 경우, 예상치 않게 문제가 생길 수 있다.

이제 어떤 문제가 발생할 수 있는지 다뤄보고, 이를 어떻게 해결할지 고민해보자.


@Transactional 사용과 영속성 컨텍스트

앞선 게시물에서, 우리는 다음 사실을 언급한 바 있다.

"스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다."

그리고 트랜잭션은 보통 서비스 계층에서 시작하므로, 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다. 

따라서, 조회한 엔티티가 Service/Repository 계층에서는 영속성 컨텍스트에서 관리되며 영속 상태를 유지하지만,
컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태가 된다!

persistent context에서의 생명주기

 

준영속 상태는 엔티티가 영속성 컨텍스트에서 분리된 것을 이야기한다.

따라서 준영속 상태의 엔티티는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.
그런데 문제는, 그 사용할 수 없는 기능 중 지연 로딩(Lazy Loading)이 있다는 점이다.

 

JPA 구현체들은 프록시 패턴을 통해, 어떤 객체를 조회할때 연관된 객체를 바로 조회하지 않고 실제 사용할 때 조회할 수 있도록 하였다.

만약 어떤 엔티티 A를 조회하는 데 A와 연관관계가 있는 다른 엔티티 B가 있다면 어떨까?

이 경우 엔티티 B 객체를 상속한 프록시 객체를 만들어 해당 프록시 객체를 통해 A가 B에 접근하도록 한다.

그리고 만약 B에 접근하려는 요청(예를 들면 getter)이 생길 경우, 프록시는 B를 초기화한다. 다르게 말하면, DB에 요청을 보내 B에 대한 정보를 가져온다!!

만약 어떤 객체와 연관관계를 맺었지만 잘 사용되지 않는 다른 객체가 있다면, 이는 유효한 전략일 것이다. 

그러나 상황에 따라서는, 해당 객체에서 자주 사용되어 join을 해서 조회하는 것이 더 효율적인 경우도 존재할 것이다.

(이에 대해서는 조만간 n+1 문제 관련 게시글로 찾아뵙겠다)

 

정리하자만, 프록시를 사용해서 조회할 경우, 해당 객체에 접근 시 조회 요청을 보내야 한다.

이를 지연 로딩(Lazy Loading)이라고 한다. 그런데, 앞서 우리는 준영속 상태의 엔티티는 이 기능을 쓰지 못한다고 했다!

영속성 컨텍스트가 이미 종료된 상태라, 엔티티와 DB를 이어줄 매개가 없기 때문이다.


이제 실제로 이런 경우가 발생하는 예시를 보자.

아래는 엔티티 코드이다. 작가와 책이 서로 연관 관계를 맺고 있으며, 지연 로딩 전략을 쓴다고 명시되었다.

@Entity
public class Book {
	@Id @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 전략
    private Author author;
    ...
}

이때 해당 책을 @Transactional이 적용된 서비스에서 가져온다고 해보자. 

다음은 컨트롤러 코드이다. 실행 시 예외가 발생한다!

class BookController {
    public String view(Long bookId) {
    	Book book = bookService.findBook(bookId);
        Author author = book.getAuthor();
        author.getName(); //여기서 예외 발생!!!
        ...
    }
}

왜 실행 도중 예외가 발생할까? 이유는 간단하다.

Book 엔티티는 Service에서 조회한 값인데, 이 때에는 영속성 컨텍스트가 올바르게 수행되었다.

그러나 서비스의 findBook 메서드가 종료되면서 영속성 컨텍스트가 닫혔고, 반환된 book 엔티티가 준영속 상태가 된 것이다.

author는 lazy loading 전략을 사용했으므로 비어있는 프록시 객체로 존재했는데, 해당 객체에서 실제로 값을 뽑아 쓰려고 하니 예외가 발생한다. (만약 준영속이 아니었다면 영속성 컨텍스트를 통해 author를 조회하고 값을 반환했을 것이다)


해결책: DTO와 그 외 방법들

위에서 본 준영속 상태의 lazy loading 이슈를 해결할 방법은 다음과 같다.

  • 글로벌 페치 전략 수정
  • JPQL 페치 조인
  • 강제 초기화
  • FACADE 계층 추가
  • DTO 사용

글로벌 페치 전략 수정

제일 간단한 방법. 위의 코드에서 @ManyToOne(fetch = FetchType.LAZY) 부분을 @ManyToOne(fetch = FetchType.EAGER)로 바꿔준다.

이 경우 전략이 즉시 로딩으로 변경되어, book을 조회 시 author도 무조건 함께 조회한다.

그러나 이 방식에는 문제가 존재한다. 일단 사용하지 않는 엔티티를 조회하는 탓에 비용 낭비가 생기고, N+1 문제가 생길 수 있다. 해당 코드를 JPQL에 넣을 경우, 쿼리가 페치 전략을 참고하지 않고 SQL을 생성하면서 author의 개수만큼 쿼리를 추가 생성하는 것이다.

JPQL 페치 조인

위의 문제를 해결하기 위해, 아예 JPQL로 fetch join이 들어간 코드를 만들어도 된다.

다만 이렇게 하면, 프리젠테이션 계층이 필요할 때마다 JPQL 메서드가 늘어나기 때문에 서로 의존이 생긴다.

강제 초기화

제일 단순무식한 방법.

트랜잭션이 닫히기 전에, 서비스 메서드에서 author.getName()을 호출해준다.

이 경우 author에 참조했으므로 프록시가 영속성 컨텍스트에 author 조회를 요청하고, author 정보가 book 안에 올바르게 들어간다.

하지만 정말 무의미한 코드...를 넣어야 하는 짓이며,

나와 함께 일하는 팀원 모두가(미래에도) 이 코드를 빼먹지 않고 넣는다는 보장이 있어야 한다.

즉 무의미한 전략.

FACADE 계층 추가

퍼사드 패턴을 사용한다.

이 경우 controller -> facade -> service -> repository 순으로 접근하게 된다.

프리젠테이션 계층이 필요로 하는 값은 facade 레이어에서 확인해주고, 

트랜잭션의 범위를 Facade까지 포함하도록 해서 관련 문제를 예방하면 된다.

이 방법의 장점은, 서비스 계층과 프리젠테이션 계층의 논리적 의존을 제거했다는 점이다. 그러나 실용적인 관점에서 볼때 불필요한 객체가 하나 끼어든다는 점에서 마냥 좋은 방법은 아니다.

DTO 사용

사실 나는 이 방법을 써왔고, 이 방법을 지지하고 있다.

https://kafcamus.tistory.com/12?category=912020 

 

Controller에서 Service에 값을 어떻게 전달할까?

바로 직전에, 스프링 부트에서 MVC 패턴을 구현하는 방법에 대해 간단한 글을 올렸다. 고민: SpringBoot에서의 MVC 패턴 이 글은 다음의 고민에서 시작한 글이다. 내가 스프링 부트로 지금까지 만든

kafcamus.tistory.com

이전에 관련된 글을 쓰면서도 고민했던 부분인데, 과연 서비스와 컨트롤러 간에 DTO를 쓰는게 좋을까?

나는 그렇다고 생각한다. 만약 양쪽에서 엔티티를 서로 주고받을 경우, 위의 지연 로딩 이슈 외에도 컨트롤러가 멋대로 엔티티를 수정하는 등의 의도치 않은 동작이 발생할 수 있다.

하지만 DTO를 불변 객체(혹은 그에 아주 가까운 객체)로 만들어 활용하면, 컨트롤러는 이를 전달하는 역할만 수행하며 해당 값의 연산 트랜잭션 보장은 서비스가, 수정/조회 등은 레퍼지토리가 담당하게 된다. 

 

또한 지연 로딩 전략을 사용하더라도, DTO를 사용하면 부담이 줄어든다.

지연 로딩된 값을 컨트롤러에 전달하지 않아도 될 경우, DTO에서 해당 값에 접근하지 않게 하여 로딩 수를 줄일 수 있고. 반대로 컨트롤러에 해당 값을 전달해야 하는 경우 BookDTO 안에 AuthorDTO가 존재하는 식으로 DTO를 구성하여 AuthorDTO를 생성하고, 그 과정에서 참조가 발생하여 author에 대한 lazy loading이 발생하도록 할 수 있다.

 

결론적으로, 난 DTO 방식을 지지한다.


더 알아보기: OSIV

OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어놓는다는 의미이다. OSIV는 하이버네이트에서 만든 용어로 JPA에서는 OEIV(Open EntityManager In View)라고 하지만, 관례상 OSIV라고 부른다고 한다.

영속성 컨텍스트를 닫지 않고, 해당 컨텍스트를 컨트롤러나 뷰에서도 사용할 수 있도록 하는게 OSIV 전략이다. 

이러한 전략이 필요한 이유는, 만약 뷰가 같은 JVM 내에서 실행되고 있다면(예: thymeleaf) 뷰가 엔티티를 조회하며 값 변경을 실시간으로 반영하는 동작을 사용할 수도 있기 때문이다. 이 과정에서 lazy loading 이슈로 예외가 발생하면 안되므로, 뷰까지 영속성 컨텍스트를 여는게 OSIV의 목표이자 전략인 것이다.

 

OSIV는 클라이언트의 요청이 들어온 때 영속성 콘텍스트를 생성하며, 요청이 끝날 때까지 유지한다.

따라서 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지하게 된다.

또한 프리젠테이션 레이어에서 값을 수정하는 등의 사태가 발생할 수 있으므로, 엔티티 수정은 트랜잭션이 있는 계층에서만 동작하도록 한다. 이때 트랜잭션이 없는 프리젠테이션 계층은 (지연 로딩을 포함한) 조회만 수행할 수 있다.

 

OSIV 전략을 사용할 경우 엄격한 계층 구조에서 벗어나 편리하게 엔티티에 접근할 수 있다는 장점이 있다.

그러나 그만큼 단점도 많기에, 여기에 단점을 기재한다.

  • 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다. 이때 롤백이 발생하면 문제가 될 수 있다.
  • 프리젠테이션 계층에서 엔티티를 수정한 뒤 비즈니스 로직을 수행하는 등 우회적인 방법으로 프리젠테이션 계층에서 값을 조정할 수 있다. 특수한 경우이지만 위험성이 존재한다.
  • 지연 로딩 발생 시 프리젠테이션 계층에서도 SQL이 실행된다. 성능 튜닝의 범위가 넓어진다.

분명 OSIV는 강력한 힘을 가진 도구라고 생각된다.

그러나, 앞서 이야기한 것과 같이 DTO를 사용할 때에 비해 컨트롤러/뷰에서 의도치 않은 동작을 할 가능성이 높아 위험하다고 느껴진다.

이는 프로젝트 성격에 따라 다를테니, 만약 작은 프로젝트나 특수한 상황의 프로젝트라면 서비스가 컨트롤러에 엔티티를 그대로 줄 수도 있을까 싶다. 이 경우, OSIV는 매우 유용한 도구가 될 것이다.


저번 글도 그랬지만, 이번 글에서도 김영한님의 자바 ORM 표준 JPA 프로그래밍 책을 많이 참고했다.

영속성 관리를 다룬 3장, 프록시와 지연 로딩 전략을 다룬 8장을 참고했고, 웹 어플리케이션과 영속성 관리를 다루는 13장은 거의 그대로 참고한 듯 싶다.

만약 이 글을 보시는 JPA 초보분이라면 꼭 사야할 책이다.

막힐 때마다 도움을 많이 받았고 그래서 감사할 따름이다.

반응형

댓글