Spring OSIV

SeungHoon·2025년 4월 12일

Spring

목록 보기
12/15

서론

  • 오늘도 실습을 하다가, 트랜잭션이 끝났는데도 지연 로딩이 가능한 걸 보게 되었다. 알고 보니 EntityManager가 아직 살아 있어서 그런 거였고, 이는 Spring Boot의 OSIV=true 설정 덕분이라는 걸 알게 되었다.

    Transaction은 TransactionManager가 관리하는 것이고, EntityManager는 Transaction을 직접 시작하거나 커밋하거나 롤백하지 않는다. 대신 EntityManager는 Transaction 관리를 위한 도구로서 사용된다. 그러므로 일반적으로 EntityManager는 Transaction 범위 내에서 사용된다.

EntityTransaction tx = entityManager.getTransaction();

try {
    tx.begin();
    // ... 작업 수행 ...
    tx.commit(); // 정상 종료
} catch (Exception e) {
    tx.rollback(); // 예외 시 롤백
} finally {
    entityManager.close(); // 직접 만든 경우 close 필수
}
  • EntityManager 과 트랜잭션은 생성 시점과 종료 시점이 원래는 다르다는 것을 명심하고 지나가자. (스프링에서@Transactional 을 붙이면 알아서 트랜잭션과 EntityManager 을 같이 관리해준다)

OSIV의 정의?

  • OSIV(Open-Session-In-View)의 줄임말이다. View 까지 영속성 컨텍스트를 유지할 수 있게 해준다. (RestController 의 경우에는 Controller 까지)
  • 실제로 Spring Data JPA를 사용하면 spring.jpa.open-session-in-view=true 값이 기본적으로 활성화된다. 실제로 프로젝트를 실행할 때 콘솔을 보면 아래와 같이WARNING log을 보여준다.
2025-04-17T16:32:13.174+09:00  WARN 1332 --- [           main] 
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.
  • 그래서 서비스 단을 넘어서 컨트롤러 단에서도 EntityManager를 열어둬서 지연 로딩을 할 수 있다.
  • osiv 설정을 true, false로 했을 때 무슨 일이 일어나는지 좀 더 알아보자.

OSIV=true로 하게 되면?

  • 스프링 부트에서 OSIV=true로 설정되면, 요청이 들어올 때 OpenEntityManagerInViewFilter가 동작한다. 다음은 코드 중 일부이다.
try {
		EntityManager em = createEntityManager();
		EntityManagerHolder emHolder = new EntityManagerHolder(em);
		TransactionSynchronizationManager.bindResource(emf, emHolder);
        ...
}
  • 이 필터가 요청의 시작 시점에 EntityManager를 생성하고, 응답이 완료될 때 닫는다.
  • 따라서 서비스 단에서 @Transactional 이 없어도 EntityManager 가 별도로 존재하기 때문에 컨트롤러나 뷰 렌더링 과정에서 Lazy 로딩 등을 수행할 수 있다!
    • 대신 Transaction 이 없기 때문에 읽기 연산 ( 예시로 entityManager.find(Person.class, id))만 가능하며, 그 외 연산(flush, commit, rollback) 을 진행하면 예외가 발생한다.

OSIV=false로 하게 되면?

  • EntityManager의 생명주기가 트랜잭션 범위로 제한된다. Repository 단에서는 JpaRepository를 상속받게 되면 알아서 트랜잭션 적용을 해주기 때문에 상관없지만, Service 단에서는 반드시 @Transactional 어노테이션을 사용해야 EntityManager 를 사용할 수 있다.
  • 그렇기 때문에 OSIV=false인 경우, DB 접근 로직이 있는 곳에 반드시 @Transactional을 명시해야 한다. 안하면 LazyInitializationException, TransactionRequiredException 예외가 발생할 것이다.
    • LazyInitializationException 을 해결하기 위해서는 서비스 계층에서 모든 DB 접근(조회/변경)을 끝내고 DTO로 변환해서 컨트롤러로 넘겨야 한다. (컨트롤러에서 지연로딩을 하려고 하면 예외가 발생함)
    • TransactionRequiredException 의 경우 엔티티 쓰기 (persist, remove, merge) 를 해야 되는 상황에서 트랜잭션이 없기 때문에 발생하는 예외이다. 하지만 JpaRepository 를 상속한 Repository를 사용하면 삽입, 수정, 삭제 메서드에 트랜잭션이 걸려있어 예외가 발생하지 않는다. (EntityManager를 직접 사용할 경우 예외가 발생할 것이다)

OSIV=false 하면 고생하네. 나는 항상 켜놔야지~

  • 마냥 편한 기능인가? 에 대해서는 다시 한번 생각해볼 필요가 있다.
  • 실제로 프로젝트를 진행할 때 SSE (Server Side Event) 을 사용해 채팅방 정보를 주기적으로 클라이언트에게 전달하는 로직을 작성했는데 서버가 처음에는 잘 작동하다가 자꾸 DB 커넥션 풀이 고갈되어 서버가 다운되는 문제가 발생했었다.

    SSE (Server Side Event) 란 서버가 클라이언트와 HTTP 연결을 끊지 않고 계속 유지하며 클라이언트가 요청을 하지 않아도 서버에서 정보를 제공해줄 수 있는 기능이다.

커넥션 풀이 고갈되는 문제의 원인?

  • 스프링에서 SSE (Server Side Event) 을 사용하다보니 하나의 HTTP 연결이 장시간 유지되었고 , OSIV=trueEntityManager가 계속 살아있다보니 커넥션 고갈이 발생한 것이다.
  • EntityManager는 내부적으로 데이터베이스 커넥션을 하나 점유하기 때문에, 커넥션 풀 크기에 따라 동시에 생성 가능한 EntityManager의 수가 제한되는 것이다!
  • 기본적으로 Hikari CP에서 제공하는 커넥션 풀 내에 존재하는 커넥션은 10개이다. 따라서 이론 상 10명을 초과하는 유저와 SSE 연결을 하게 되면 더 이상 커넥션 풀 내에 존재하는 커넥션이 없어서 서버가 다운되는 것이다!

그래서 OSIV=false로 설정하여 EntityManager의 생명주기를 트랜잭션의 생명주기와 같게 만들어 문제를 해결하였다.
하지만 그 후 지연 로딩 예외가 발생하게 된다.

LazyInitializationException 의 원인?

  • OSIV=false 로 두게 되면 EntityManager 은 트랜잭션 내에서만 생성된다. 그런데 트랜잭션이 필요한 서비스에서 @Transactional 을 사용하지 않은 것이 문제의 원인이었다. (아무것도 모르고 코드 쓴 나를 탓해라)
	@Override
    public List<Long> getMemberIds(String roomId) {
        ChatRoom chatRoom = chatRoomRepository.findById(roomId);
                
        List<ChatRoomMember> members = chatRoom.getMembers(); // 지연로딩을 하지 못함!!

        return members.stream()
                .map((chatRoomMember) -> chatRoomMember.getMember().getId())
                .toList();
    }
  • 여기서 chatRoom을 찾는 과정은 repository의 findById() 내에 트랜잭션이 적용되어 있어 정상적으로 찾아올 수 있다. 하지만 chatRoom을 반환되는 순간 더 이상 EntityManager 가 없기 때문에 chatRoom는 detached 상태가 된다.
  • 이 상태에서chatRoom.getMembers() 의 경우는 chatRoom이 detached 상태이기에 지연 로딩을 하지 못한다. 그래서 예외가 발생하게 된다.

프로젝트에서는 어떻게 해결했지?

  • 진행했던 프로젝트에서는 거의 막바지에 발견한 예외였다. 따라서 급한 불을 끄기 위해 지연 로딩 관련 문제가 발생한 Member 엔티티 안에 필드를 모두 즉시 로딩으로 바꿔 해결했지만 절대 맞는 해결책은 아니다.
  • OSIV=false 로 설정했기 때문에, 서비스단에 @Transactional 을 작성해주었다면 즉시 로딩을 하지 않아도 해결할 수 있었던 문제였다.
    • 혹은 지연 로딩을 사용하지 않고, fetch join을 사용해서 member, id를 한 번에 가져오는 것도 해결책이 될 수 있다.

결론?

  • 다음과 같이 SSE를 사용해서 트래픽이 많거나, 실시간성이 중요한 경우에는 OSIV=false로 해야 한다.
  • 하지만 위와 같은 경우가 아니라면 OSIV=true로 해도 큰 상관은 없을 것 같다.
profile
공유하며 성장하는 Spring 백엔드 취준생입니다

0개의 댓글