소개 배경
최근 주변 개발자가 SpringBoot를 처음시작하며, 강의 영상을 따라하는 것 같아 오랜만에 김영한님의 강의를 볼겸 레포지토리를 구경하다가 이상한 점을 포착하여 리뷰를 남기었는데 나 또한 정작 OSIV에 대해 제대로 알지못한다고 생각해 포스팅을 해보고자 한다.
문제점
내가 이상하다고 느꼈던 부분은 Entity에서 지연로딩 전략을 이용하고 있는데 OSIV 설정값을 false로 해놓을 경우 지연로딩에 한해서 LazyInitializationException이 발생하기에 지양한는 것으로 알고있었기 때문에 의문을 가지게 되었다.
엥 OSIV가 뭐지?
Spring Boot에서 애플리케이션을 실행시킬 경우 아래와 같은 실행 로그를 본적이 있을 것이다.
2023-03-14 11:26:08.544 WARN 16900 --- [ restartedMain] JpaBaseConfiguration$JpaWebConfiguration :
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.
Explicitly configure spring.jpa.open-in-view to disable this warning
해당 로그는 JPA 설정 중 open-in-view 설정 값이 true로 되어있을경우 view 렌더링 도중에도 데이터베이스 쿼리가 수행될 수 있다는 의미로 즉시 OSIV의 설정을 비활성화 즉, false로 변경하라는 경고창이다.
맨 처음엔 나도 "이게 뭔데 비활성화해야하지?"라는 생각을 했다.
OSIV는 대게 다음과 같이 정의된다.
💡 OSIV(Open-Session-In-View)
OSIV는 영속성 컨텍스트의 생명주기를 뷰 랜더링 시점까지 지속하는 기능이다. 영속성 컨텍스트가 유지되면 엔티티도 영속 상태로 유지된다. 뷰까지 영속성 컨텍스트가 살아있다면 뷰에서도 지연 로딩을 사용할 수가 있다.
참고: JPA에서는 OEIV(Open EntityManager In View), 하이버네이트에선 OSIV(Open Session In View)라고 하나 관례적으로 OSIV로 통일한다.
한마디로 DB에 관련한 작업 요청이 들어올 경우 하나의 하이버네이트 세션이 연결되게 되고 이에 관련한 영속성 컨텍스트를 이를 요청한 클라이언트에게 응답(MVC에서의 html에 반영 또는 Response Entity)할 때까지 유지시킨다는 뜻이다.
하지만 이를 Application의 OSIV 설정값을 TRUE로 두었을 때의 이야기이다.
이를 좀더 자세히 이해하기 위해선 동작원리를 이해해보자
OSIV 동작원리
기본적으로 Spring Framework에서 제공하는 OSIV는 비지니스 계층(Service Layer,Repository Layer)에서 트랜잭션을 이용한다.
또한 OSIV는 일종의 패턴으로 Session Per Request 패턴의 개념을 사용한다.
해당 패턴은 영속성 세션과 요청 라이프사이클을 같이 묶기 위한 트랜잭션 패턴으로 이를 자체적으로 구현하여 OpenSessionInViewInterceptor을 제작하여 우리는 OSIV를 사용한다.
동작원리는 다음과 같다.
- 클라이언트의 요청을 파악하여 서블릿 필터 또는 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 이 시점에서는 트랜잭션이 시작되지 않는다.
- Service Layer에서 @Transeactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
- Service Layer의 작업이 끝나면, 트랜잭션을 Commit하고 영속성 컨텍스트를 flush한다. 이 시점에서 트랜잭션은 끝나지만, 영속성 컨텍스트는 아직 살아있게된다.
- 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다
- 서블릿 필터나, 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.
위의 과정을 보면 알 수 있듯 트랜잭션 자체의 생명주기는 Service Layer에서 종료되어 Controller와 View에서는 유지되지 않는다. 하지만 OSIV를 이용하기에 연산을 제외한 단순조회는 가능하게 되는데 이를 Non-transactional reads(트랜잭션 없이 읽기)라고한다.
그래서 만약, 프록시를 뷰 렌더링하는 과정에 초기화(Lazy loading)가 일어나게 되어도 조회 기능이므로 트랜잭션이 없이 읽기가 가능하다
Session Per Request
해당 패턴은 가방 일반적인 Transactional 패턴으로 하나의 요청당 Session을 열고 Transaction을 시작해 모든 데이터 작업을 수행한 후 Transaction을 종료한 다음 Session을 닫는다. 이때 Hibernate를 쓰고 있다면 Hibernate Session이다.
여기서 말하는 요청은 웹 어플리케이션에 말하는 request와 동일할 수 있지만 꼭 웹 어플리케이션에 국한해서 말하는 것은 아니므로 하나의 어플리케이션의 기능 단위로 해석된다.
그러면 사용했을 때 무조건 좋은거 아니에요?
늘 그렇듯, 프로그래밍에는 이점만 존재하는 방식은 없다.
OSIV를 TRUE로 사용하는 경우
OSIV를 사용하게되면 뷰와 컨트롤러에서의 Lazyinitializationexception을 고려하지 않아도 되어 개발자의 편의성 증가와 하나의 세션에서 모든 작업의 처리가 가능하다는 이점이 있다.
하지만, HTTP 세션을 뷰 랜더링까지 열어놓기 때문에 메모리 누수를 발생시킬 수 있다. 또한 조회한 기준 엔티티를 리스트 형태로 조회한 경우 연관 매핑된 엔티티의 리스트까지 전부 조회하여 애플리케이션의 성능저하를 유발하기도 한다.
또한, 실시간 트래픽이 중요한 어플리케이션의 경우 데이터베이스의 커넥션을 오랫동안 사용하기에 장애 수준의 에러가 발생할 수도 있어 해당 방법을 기피하는 것이 좋다.
예시로 컨트롤러에서 외부 API를 호출하여 응답까지 5초라는 시간이 걸린다면, 그 5초 동안 리소스 커넥션의 반환없이 유지해야하기에 커넥션 관리에 있어 좋은 방법이 아니다.
OSIV를 FALSE로 사용하는 경우
그럼 OSIV를 사용하지 않는 경우 어떤 이점이 있을까?
위에 말한것과 반대로 메모리 누수와 성능 문제가 발생하지 않는다. 하지만 지연로딩에 대한 처리를 해야하기 때문에, 코드의 복잡성 증가와 세션 종료이후 연관 엔티티 조회시 Lazyinitializationexception이 발생할 수 있다.
한마디로 커넥션 리소스 낭비가 일어나지 않지만 지연로딩의 사용에 제약이 생긴다.
또한 모든 지연로딩 관련 코드를 하나의 트랜잭션 내부에서 처리를 해야한다. 이전 OSIV를 활성화 할 경우에는 트랜잭션이 종료되어도 세션에서 읽기 작업이 가능했으나, OSIV를 비활성화 할경우에는 이를 하나의 트랜잭션 내부에서 처리해야한다.
이를 위해 트랜잭션 종료 직전 지연로딩을 강제 호출 해야하는 이슈도 생겨난다.(이러한 점은 앞서 말한대로 하나의 트랜잭션에서 로직을 완료하거나, fetch join을 이용해야한다)
🤔 그럼 뭐가 좋나요?
가장 좋은 것은 프로젝트의 관심사에 맞추어 사용하는 것이다.
OSIV의 장점인 간편성은 사실 성능이슈를 제외하고도 간편하기에 많이 사용된다. 하지만 실시간 트래픽을 사용해야하는 경우에는 응답성을 제1목표로 두어야하기에 지양하는 편이 좋다.
그래도 좀 응답 속도가 느린게 거슬리는데..
그래서 이를 효율적으로 관리할 수 있는 방법은 Command와 Query를 분리하는 것이다.
보통 비즈니스 로직은 특정 엔티티 몇 개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다. 그런데 복잡한 화면을 출력하기 위한 쿼리는 이를 구성하기 위한 성능 최적화가 중요하게 다가온다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.
그래서 크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 가치가 있다.
단순하게 설명해서 다음처럼 분리하는 것이다.
ToDoService
- ToDoService: 핵심 비즈니스 로직
- ToDorQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)
코드로 입장비교
@Entity
public class Member {
@Id
private Long seq;
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private List<ToDo> todos = new ArrayList<>();
}
@Service
@AllArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository MemberRepository;
public Optional<User> findMemberBySeq(Long seq) {
return MemberRepository.findByMemberSeq(seq);
}
}
위와 같이 Domain과 Service Layer가 구성되어 있다면, OSIV가 비활성화 되어있다는 가정하에 기본적으로 findMemberBySeq 메서드를 실행한 후 트랜잭션이 커밋 후 종료된다면 Client 코드에서는 Member 객체의 todos를 로딩하여 사용하지 못한다.(준영속상태로 변경된다.) 이는 곧 LazyInitializationException을 일으킨다.
결론
프로젝트의 성향에 맞게 사용하는 것이 좋다고는 하였으나, OSIV를 비활성화 시켜 응답성을 중시하고 성능속도를 증가시켜 지연로딩에 대한 별도의 대비를 구성하는 방법이 좋다고 생각한다.
'Spring-boot' 카테고리의 다른 글
[JPA] Data JPA에서 Projection 활용기 (0) | 2023.06.28 |
---|---|
[JPA] @Transaction 읽기 전용 (0) | 2023.03.29 |
profile을 적용하여 yaml 파일을 목적에 맞게 분리 (0) | 2023.03.06 |
CustomAnnotation을 이용하여 가독성과 불필요한 Import 줄이기 (0) | 2023.03.05 |
엔티티의 생명주기와 변경감지, @Transactional 이슈 (0) | 2023.02.26 |