처음에 실시간 알림을 구현하기 위해 여기저기 글들을 보았다.
이전에 웹소켓 하면서 실시간 알림을 구현하기에는 SSE가 적당하다는 글을 보아서
SSE에 대해서 공부하려고 했다. SSE를 사용하면 문제가 있는데 그 문제를 해결하기 위해서
검색해보고 해당 문제를 보다가 그 문제에 관한 또 다른 문제가 엮이고 엮여서 정리하기로 했다.
이 글은 SSE에 대한 내용보다는 SSE를 사용하면서 나타나는 문제에 대한 해결을 어떻게 해야할지에 관한 글이다.
관련 글
- Websocket
- Websocket + 부가기능
- Websocket (채팅 기록 json 파일 저장하기)
- Sse
- Sse 문제점 👈🏻
- Websocket + jwt
- Websocket test
- Jmh - 채팅 파일 refactoring
들어가기 전
SSE에 대해서 알아보기 전에 먼저 알아야 할 부분에 대해서 작성해봤다.
트랜잭션
여러 쿼리를 논리적으로 하나의 작업으로 묶어 주는 것
예시)
거래가 일어날 때 실행되는 쿼리는 다음과 같다.
UPDATE : 구매자 계좌에서 10000원 ➖
UPDATE : 판매자 계좌에서 10000원 ➕
그런데 이 과정에서 오류가 발생해서 구매자 계좌에서는 10000원이 빠졌는데
판매자의 계좌에서는 10000원이 들어오지 않는 경우가 생길 수도 있다.
1
2
3
4
5
UPDATE : 구매자 계좌에서 10000원 ➖
⬇️ 오류 발생
UPDATE : 판매자 계좌에서 10000원 입금 ❌ (???)
이러한 상황을 방지 하고자 나온 것이 트랜잭션이다.
하나의 작업으로 이루어지는 쿼리들을 트랜잭션이라는 논리적인 하나의 작업 단위로 묶어서
쿼리들이 한꺼번에 모두 실행되거나 아예 아무 쿼리도 실행되지 않게 해주는 것이다.
1
2
3
4
5
6
— — — — — — — 트랜잭션 — — — — — — — — — —
¦ ¦
¦ UPDATE : 구매자 계좌에서 10000원 ➖ ¦
¦ UPDATE : 판매자 계좌에서 10000원 ➕ ¦
¦ ¦
— — — — — — — — — — — — — — — — — — — — — — —
@Transactional
@Transactional은 클래스나 메서드에 붙여줄 경우, 해당 범위 내 메서드가 트랜잭션이 되도록 보장해준다.
@Transactional이 클래스 혹은 메서드에 붙일 때, Spring은 해당 메서드에 대한 프록시를 만든다.
프록시 패턴은 디자인 패턴 중 하나로, 어떤 코드를 감싸면서 추가적인 연산을 수행하도록 강제하는 방법이다.
트랜잭션의 경우, 트랜잭션의 시작과 연산 종료시의 커밋 과정이 필요하므로,
프록시를 생성해 해당 메서드의 앞뒤에 트랜잭션의 시작과 끝을 추가하는 것이다.
스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
Service에서 @Transactional을 사용할 경우, 해당 코드 내의 method를 호출할 때 영속성 컨텍스트가 생긴다는 뜻이다.
영속성 컨텍스트는 프록시가 트랜잭션을 시작할 때 생겨나고,
메서드가 종료되어 프록시가 트랜잭션을 커밋할 경우 영속성 컨텍스트가 flush되면서 해당 내용이 반영된다.
이후 영속성 컨텍스트 역시 종료되는 것이다.
*AOP에 바탕을 두고 설계되었기 때문에, 프록시는 트랜잭션 AOP로 명칭
☑️ 만약 같은 트랜잭션 내에서 여러 EntityManager를 쓰더라도, 이는 같은 영속성 컨텍스트를 사용한다.
☑️ 같은 EntityManager를 쓰더라도, 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.
영속성 컨텍스트
영속성 컨텍스트란 눈에 보이지 않으며 Entity를 영구히 저장하는 환경이라는 뜻이다.
영속성 컨텍스트는 사용자의 요청 시점에서 생성이 되지만,
데이터를 쓰거나 수정할 수 있는 트랜잭션은 비즈니스 계층에서만 사용할 수 있도록 트랜잭션이 일어난다.
SSE의 문제점
SSE를 사용하면 JPA 사용시 Connection 고갈문제가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
SSE 통신을 하는 동안은 HTTP Connection이 계속 열려있습니다.
만약 SSE 연결 응답 API에서 JPA를 사용하고 open-in-view 속성을 true로 설정했다면,
HTTP Connection이 열려있는 동안 DB Connection도 같이 열려있게 됩니다.
즉 DB Connection Pool에서 최대 10개의 Connection을 사용할 수 있다면,
10명의 클라이언트가 SSE 연결 요청을 하는 순간 DB 커넥션도 고갈되게 됩니다.
따라서 이 경우 open-in-view 설정을 반드시 false로 설정해야 합니다.
Spring에서 Server-Sent-Events 구현하기
그래서 open-in-view에 대해 찾아보게 되었다.
open-in-view는 관례상 OSIV라고 한다.
OSIV(Open-Session-In-View)
OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어두는 기능이다.
영속성 컨텍스트가 유지되면 엔티티도 영속 상태로 유지된다.
뷰까지 영속성 컨텍스트가 살아있다면 뷰에서도 지연 로딩을 사용할 수가 있다.
JPA에서는 OEIV(Open EntityManager In View), 하이버네이트에선 OSIV(Open Session In View)라고 한다.
하지만 관례상 둘 다 OSIV로 부른다.
SpringBoot는 기본적으로 OSIV를 허용한다.
false로 설정할 경우에만 application.properties(yml)에서 spring.jpa.open-in-view: false
로 설정하면 된다.
여기서 예시로 연관관계를 많이 들고 있다. 이유는 LAZY 로딩때문이다.
TRUE
true일 경우 영속성 컨텍스트가 트랜잭션 범위를 넘어선 레이어까지 살아있다.
Api라면 클라이언트에게 응답될 때까지, View라면 View가 렌더링될 때까지 영속성컨텍스트가 살아있다.
그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다.
지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다.
동작원리
☑️ 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다.
단 이 시점에서 트랜잭션은 시작하지 않는다.
☑️ 서비스 계층에서 @Transeactional로 트랜잭션을 시작할 때
1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
☑️ 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다.
이 시점에 트랜잭션은 끝내지만 영속성 컨텍스트는 종료되지 않는다.
☑️ 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
☑️ 서블릿 필터나, 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다.
이때 플러시를 호출하지 않고 바로 종료한다.
서비스 계층에서 트랜잭션이 끝나면 컨트롤러와 뷰에는 트랜잭션이 유지되지 않는 상태이다.
엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 동작하는데,
이것을 트랜잭션 없이 읽기(Nontransactional reads)라 한다.
하여 만약 프록시를 뷰 렌더링하는 과정에 초기화(Lazy loading)가 일어나게 되어도
조회 기능이므로 트랜잭션이 없이 읽기가 가능하다.
☑️ 영속성 컨텍스트는 기본적으로 트랜잭션 범위 안에서 엔티티를 조회하고 수정할 수 있다.
☑️ 영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회만 할 수 있다.
이것을 트랜잭션 없이 읽기(Nontransactional reads)라 한다.
FALSE
트랜잭션을 종료할 때 영속성 컨텍스트 또한 닫힌다.
그러므로 Service에서 끝나기 때문에 영속성컨텍스트가 Transaction 범위 바깥인 Controller에서 Lazy loading 을 시도하면 에러가 뜬다.
영속성 컨텍스트가 닫혔다면 Lazy loading 또한 할 수 없다.
ex) Registry와 Comment의 1:N 관계에서
게시글 페이지에서 Registry를 가져올 때는 Comment는 지연로딩이기 때문에
영속성 컨텍스트에 proxy 객체만 있고 실제 객체는 없다.
여기서 Service가 Controller로 가게 되면
영속성 컨텍스트는 닫히게 되고 view에서 comment를 호출할 경우
Controller는 comment에 접근하고 싶어도 영속성 컨텍스트가 이미 닫혀있어서 error를 내게 된다.
*LazyInitializationException이 발생
OSIV 장단점
sping.jpa.open-in-view : true
- 장점
- 뷰 렌더링 종료시까지 영속성 컨텍스트가 유지
- 지연 로딩을 하나의 트랜잭션 안에서 처리하지 않아도 되기 때문에 코드 구현에 있어서 상대적으로 편하고, 관리해야 할 코드도 줄어듬
- 단점
- DB 커넥션을 뷰 렌더링 종료시까지 유지
- 상황에 따라 커넥션 부족 문제가 발생할 수 있음
sping.jpa.open-in-view : false
- 장점
- DB 커넥션 리소스의 효율적인 사용
- 트랜잭션을 종료할 때 영속성 컨텍스트를 닫으면서 DB 커넥션을 반환
- 단점
- 모든 지연 로딩을 트랜잭션 안에서 처리
- 이 문제를 해결하기 위해 추가적인 Service Layer를 관리/생성해야함. 코드의 구현량 증가
보통 고객 서비스의 실시간 API는 OSIV를 끄고, ADMIN처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 키는 것을 권장한다.
문제 해결하기
나는 채팅과 실시간 알림을 사용한다.
그러면 sping.jpa.open-in-view : false
로 설정후에 오류가 안나게 하려면 어떻게 해야할까?
OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다.
따라서 커넥션 리소스를 낭비하지 않는다.
OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다.
따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있다.
그리고 view template에서 지연로딩이 동작하지 않는다.
결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.
Command와 Query 분리
실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다.
바로 Command와 Query를 분리하는것이다.
Command : 결과를 반환하지 않고 시스템의 상태를 변화시킨다.
Query : 결과 값을 반환하고, 시스템의 관찰 가능한 상태를 변화시키지 않는다. (free of side effect)
- Service
- MainService: 핵심 비즈니스 로직
- QueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)
INSERT : ID만 반환
UPDATE : void
조회는 내부 변경 로직이 없는 메서드로 설계
reference
[JPA]open-session-in-view 를 알아보자
[JPA] OSIV (Open Session In View)
Spring에서 Server-Sent-Events 구현하기
[JPA] Open In View
[JPA] 영속성 컨텍스트와 OSIV(Open Session In View)
JPA - OSIV(Open Session In View) 정리
[JPA] 성능 최적화하기 (읽기 전용으로 변경, OSIV)
[10분 테코톡] 🌼 예지니어스의 트랜잭션
@Transactional 어노테이션의 이해