스프링 컨테이너의 영속성 컨텍스트 전략

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

해당 전략에서 영속성 컨텍스트의 생존범위는 트랜잭션 범위와 같습니다
트랜잭션을 종료하면 영속성 컨텍스트도 종료합니다
트랜잭션을 시작하면 새로운 영속성 컨텍스트를 생성합니다.

따라서 같은 트랜잭션 안에서는 영속성 컨텍스트를 공유하지만
트랜잭션이 다른 경우 영속성 컨텍스트를 공유하지 않습니다

해당 전략의 장점은?

스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당합니다
따라서 같은 Entity Manager를 호출해도 영속성 컨텍스트가 다르기 때문에
충돌이 발생하지 않습니다.

따라서 멀티스레드 환경에서 안전하다는 장점을 가집니다.

해당 전략의 문제점은?

이 전략은 프레젠테이션 계층에서 지연 로딩 기능을 사용할 때 문제됩니다.

트랜잭션 범위에서 영속 상태였던 엔티티가 프레젠테이션 계층부터는 준영속 상태가 됩니다

이때 초기화되지 않은 프록시 객체의 실제 데이터를 불러오려고 초기화를 시도하는데,
이 작업을 준영속 상태에서 진행하므로 데이터를 불러올 수 없습니다

준영속 상태는 영속성 컨텍스트가 없습니다.
따라서 영속상태에서 초기화되지 않은 경우 지연로딩으로 실제 데이터를 불러올 수 없습니다
만약 시도한다면 다음과 같은 에러가 발생할 것입니다.

해결책은?

해당 문제를 해결하는 방법은 크게 두가지가 있습니다.

  • 엔티티를 미리 로딩해두는 방법
  • OSIV를 활용하는 방법

엔티티를 미리 로딩해두는 방법

해당 방법은 크게 3가지로 더 세분화할 수 있습니다

  • 지연로딩 -> 즉시 로딩으로 변경
  • 강제 초기화
  • JPQL 페치 조인

즉시로딩으로 변경

엔티티를 불러올 때, 지연로딩은 프록시 객체로 가져오지만
즉시로딩은 연관된 엔티티를 미리 로딩해서 그대로 가져옵니다.

하지만 해당 방식을 사용할 경우, 사용하지 않는 엔티티를 로딩할 수 있고,
N+1 문제가 발생한다는 단점이 존재합니다.

강제 초기화

영속 상태일 때, 프록시 객체를 강제로 초기화해서 반환하는 방법입니다

contestRepository.findById(1L).get().getEvents().getFirst().getName()

위와같이 영속상태일 때 미리 프록시 객체를 초기화한다면, 준영속 상태일때도 사용할 수 있습니다!

JPQL Fetch join

지연로딩을 유지하면서도 fetch join으로 JPQL을 호출하는 시점에 연관된 엔티티 데이터를 한번에 불러옵니다
해당 방식을 사용하면 SQL Join을 사용해서 페치 조인 대상까지 함께 조회하므로
N+1문제가 발생하지 않습니다!

번거로운 해결책

하지만 위 해결책 모두 엔티티를 미리 초기화해서 준영속 상태에서도 사용하겠다는 방법입니다
번거로운 작업을 추가해야하고, 해당 엔티티가 초기화되었는지도 확인해야합니다.

결국 준영속 상태 때문에 발생하는 문제니까
영속상태를 프레젠테이션 계층까지 유지한다면 번거로운 작업없이 간단하게 해결되지 않을까요?
그런 관점에서 나온 개념이 OSIV입니다.

OSIV란?

OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻입니다.
OSIV는 hibernate에서 사용하는 용어로 JPA는 OEIV(Open EntityManager In View)라 하지만
관례상 모두 OSIV라고 부릅니다

요청 당 트랜잭션 방식의 OSIV

OSIV를 사용하면 프레젠테이션 계층에서도 영속상태를 유지하며, 지연로딩이 가능합니다


위와같이 요청이 발생한 시점부터 영속성 컨텍스트를 만들고, 트랜잭션을 시작합니다
그리고 요청이 종료되면 트랜잭션과 영속성 컨텍스트를 종료합니다.

이 방식을 이용한다면, 영속상태가 요청동안 유지되므로 준영속 상태일때의 문제점을 고민하지 않아도 됩니다.
프레젠테이션 계층에서도 자유롭게 지연로딩을 할 수 있습니다

해당 방식의 문제점

하지만 해당방식의 치명적인 문제점이 존재합니다.

바로 프레젠테이션 계층에서 화면출력만을 위한 쓰기 작업을 할 경우,
요청 종료 시점에 변경감지로 플러시가 호출되면서 의도치 않은 변경사항이 DB에 반영된다는 점입니다

Contest contest = contestRepository.findById(1L).get();  
updateNameService(RequestName);  
  
contest.setName("securityName");  
return ResponseEntity.ok(contest);

updateNameService로 기존의 이름을 요청받은 이름으로 변경합니다.

응답할 때는 보안의 이유로 securityName으로 응답해야합니다
따라서 위와같이 프레젠테이션 계층에서 setter로 엔티티 이름만 변경했습니다

요청 당 OSIV 방식의 경우, 해당 지점에서 문제가 발생합니다.
프레젠테이션 계층에서 잠깐 변경한 이름이 요청이 끝날 때 트랜잭션도 끝나면서
DB에 반영됩니다

즉, 보안상 이유로 프레젠테이션 계층에서 변경한 이름이 DB에 반영됩니다

스프링이 제공하는 OSIV

스프링 프레임워크에서 제공하는 OSIV는 이런 문제점을 어느정도 해결했습니다


영속상태와 영속성 컨텍스트는 요청단위로 유지되지만,
트랜잭션의 범위는 비즈니스 계층부터 시작합니다.
즉 프레젠테이션 계층은 트랜잭션 범위에 포함되지 않습니다

트랜잭션 범위의 변화로 플러시를 호출하는 시점이 변경되었습니다
비즈니스 계층의 작업이 끝났을 때 트랜잭션이 끝나면서 플러시를 호출하고 DB에 반영합니다.
요청이 종료되는 시점에서 영속성 컨텍스트를 종료하지만 플러시는 호출하지 않습니다

트랜잭션 없이 어떤 작업이 가능할까?

영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 합니다.

만약 트랜잭션 없이 엔티티를 변경하면 DB에는 반영되지 않고,
강제 flush를 호출하면 아래와 같은 예외가 발생합니다

트랜잭션 범위 밖에서는 영속성 컨텍스트의 엔티티를 조회만 할 수 있습니다.
수정은 오로지 트랜잭션 범위 안에서만 가능합니다.

따라서 스프링이 제공하는 OSIV를 활용한다면,
프레젠테이션 계층에서 영속상태를 유지하기 때문에 지연로딩 데이터를 조회할 때 문제되지 않습니다

또한 프레젠테이션 계층에서 데이터를 수정할 수 없기 때문에,
의도치 않은 DB 데이터 변경을 피할 수 있습니다

스프링 OSIV 사용 주의사항

스프링 OSIV를 사용하면 프레젠테이션 계층에서 데이터를 수정해도 DB에 반영되지 않고,
강제 Flush를 호출해도 예외를 만나기 때문에 안전합니다

하지만 다음과 같은 경우는 문제가 발생합니다

Contest contest = contestRepository.findById(contestId).get();  
contest.updateContest(new ContestRequestDto("", LocalDateTime.now(), LocalDateTime.now(), ContestCons.EVENT_FINISH));  
  
contestCRUDService.updateContest(contestId, contestRequestDto);

프레젠테이션 계층에서 먼저 엔티티를 수정하고,
이후 트랜잭션이 적용된 서비스를 실행하면 프레젠테이션 계층에서 수정한 정보가 DB에 반영됩니다.


DB를 확인했을 때, 데이터가 반영된 것을 확인할 수 있습니다.

이 문제는 같은 영속성 컨텍스트를 사용하기 때문에 발생합니다.
프레젠테이션 계층에서 수정 이후 바로 return하면
스프링 OSIV에서는 더이상 플러시를 호출하지 않아 데이터가 업데이트되지 않습니다

하지만 return전에 트랜잭션이 포함된 서비스를 실행하면, 해당 서비스 종료전에
커밋하면서 flush를 호출합니다.
이때 같은 영속성 컨텍스트를 사용하므로 프레젠테이션 계층에서 업데이트한 데이터가 DB에 반영됩니다

해당 문제를 해결하기 위해서는 순서를 바꾸면 됩니다.
프레젠테이션 계층의 수정은 모든 서비스 로직 이후, return전에 변경하도록 설정하면 됩니다!

OSIV 비활성화 선택

스프링 OSIV를 설정하면 무조건 합리적으로 사용할 수 있을 것처럼 보입니다.

하지만 스프링 부트를 실행하면 다음과 같은 Warn 로그를 확인할 수 있습니다


스프링 OSIV를 사용하면 지연 로딩 데이터를 프레젠테이션 계층에서도 조회할 수 있고,
프레젠테이션 계층에서 변경도 막기 때문에, DB에 잘못된 데이터가 저장되는 것을 차단할 수 있습니다

그런데 왜 스프링 부트는 OSIV 사용을 경고할까요?


스프링 OSIV는 영속성 컨텍스트를 호출이 종료될때까지 유지합니다.
또한 영속성 컨텍스트는 기본적으로 DB와 커넥션을 유지합니다

즉 요청마다 DB 커넥션을 점유하고 있다는 것이고, 이것은 서버의 장애로 이어질 수 있습니다
만약 요청이 많은 상황일 경우 OSIV를 비활성화해서 DB 커넥션을 효율적으로 사용해야합니다

해당 프로젝트는 많은 사용자들이 동시 접속할 것을 고려하고 개발했습니다.
따라서 프로젝트의 OSIV 설정은 비활성화하기로 결정했습니다

무조건 OSIV를 비활성화는 것이 좋을까?

그렇다면 역으로 무조건 OSIV를 비활성화하는 것이 좋을까요?

API 요청이 적어 DB 커넥션 고갈부담이 적은 환경이고, 트랜잭션 범위 밖에서
지연로딩을 수행해야한다면 스프링 OSIV를 활성화하는 것이 더 적절합니다

또한 OSIV를 비활성화하면, 프레젠테이션 계층에서 사용하던 지연로딩 로직을
모두 트랜잭션 범위로 가져와야하기 때문에 코드 복잡도가 증가합니다

다만 이 문제는 Command-Query 패턴을 사용해서 분리하면 복잡도를 줄이면 됩니다

  • Command: CREATE/UPDATE/DELETE 로직 위주
  • Query: READ 로직 위주

테스트

OSIV옵션을 true로 설정할 경우, DB 커넥션 고갈 문제가 발생한다고 정리했습니다
테스트를 통해서 옵션의 유무에 따른 차이가 어느정도인지 확인했습니다

테스트 환경 설정

먼저 다음과 같은 코드를 구현했습니다

@GetMapping("contests/v1")  
public ResponseEntity<List<ContestInfoResponseDto>> readContestInfo(  
        @RequestParam(value = "offset", required = false)  
        Integer offset,  
        @RequestParam(value = "limit", required = false)  
        Integer limit  
){  
  
    List<ContestInfoResponseDto> contestInfoResponseDtos = commonPageService  
            .readContestInfo(PageRequest.of(offset-1, limit));  
    try {  
        Thread.sleep(1000);  
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }  
    return ResponseEntity.ok(contestInfoResponseDtos);  
}

강제로 스레드를 1초 sleep하는 로직을 추가했습니다
트랜잭션 범위 밖에서 DB커넥션을 점유하는 특별한 상황을 가정하고
스레드 지연 로직을 추가했습니다

OSIV 옵션을 설정하면 요청이 끝날 때까지 DB 커넥션을 유지할 것이고,
OSIV 옵션을 설정하지 않으면 앞선 서비스 로직까지만 DB 커넥션을 유지할 것입니다.

maximum-pool-size: 10

DB 최대 커넥션풀 크기는 10으로 설정했습니다

테스트 툴은 Apache Jmeter를 사용했습니다.
테스트 환경은 다음과 같습니다

  • 스레드 수: 100
  • Ramp-up: 1
  • 루프 카운트: 30

100명의 사용자가 매초마다 해당 API를 동시에 호출하며, 이것이 30초동안 반복되는 상황입니다

OSIV false



대체적으로 Thread 지연시간 1초 + 쿼리 조회시간이 포함된 응답시간을 확인할 수 있습니다
1.1s가 평균 응답시간입니다!

OSIV true



평균 응답시간이 5.6s입니다! 모든 응답을 받는데도 4분이나 걸렸습니다

커넥션 풀을 반납하지 않은 상황에서 모든 요청이 1초씩 지연되다보니
DB 커넥션 고갈문제가 발생했고,
톰캣에서 미처리한 요청을 대기큐에 넣으면서 지연되다보니
전부 처리하는데 걸리는 시간과 평균 응답시간도 압도적으로 증가한 것을 확인했습니다

결론

따라서 API요청이 많은 환경에서 DB 커넥션 풀 고갈문제가 걱정된다면,
서비스 계층에서만 DB 커넥션을 사용하고 반납하도록 OSIV옵션을 끄는 것이 좋습니다!

참고:

profile
Software Developer

0개의 댓글