이전 게시글에서 설명했듯이, OSIV 설정을 `false`로 변경했더니 오류가 발생했습니다. 따라서, 이번 포스팅에서는 OSIV와 엮인 개념들과 현재 직면한 이슈 해결과정을 작성하려고 합니다.
OSIV란?
`Open Session In View`로, HTTP 요청이 처리되는 동안 영속성 컨텍스트를 유지하는 기능입니다.
영속성 컨텍스트란, 간단하게 이야기 해 어플리케이션과 DB사이에 엔티티를 저장하는 논리적인 영역입니다.
OSIV 활성화
`JpaTransactionManager`의 동작 과정을 파악하기 위해 디버깅 설정을 추가했습니다.
logging:
level:
org.springframework.orm.jpa.JpaTransactionManager: DEBUG
그 후에, `@Transactional`이 붙은 API를 하나 실행시켜서 테스트를 해보면,
이렇게 JPA transaction이 커밋 되었지만, JPA EntityManager는 닫히지 않은 것을 볼 수 있습니다. 이러한 이유 때문에 트랜잭션이 끝나더라도 DB 커넥션 풀이 반납되지 않았던 것 입니다.
따라서, 저희 서비스에서 실시간 알림 기능 API가 한번 호출될 때 커넥션 풀의 `active` 상태가 1 증가하게 되고, 더 이상 반납되지 않아 오류가 발생했던 것을 확인할 수 있습니다.
실시간 알림을 보낼 때 `emitter`의 timeout 설정을 `Long.MAX_VALUE`로 설정했습니다.
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
이 값을 시간으로 환산하면,
2562047788015215 hours, 30 minutes, and 7.999999999 seconds.
결론적으로, 이 시간이 지나야 커넥션 풀이 비로소 반납이 되었던 것 입니다.
이를 해결하기 위해, OSIV를 비활성화 시켜보겠습니다.
OSIV 비활성화
spring:
jpa:
open-in-view: false
해당 설정으로 비활성화 시킬 수 있습니다.
마찬가지로 `@Transactional`이 붙은 API를 실행시켜보면,
이전과는 다르게, JPA transaction이 커밋된 후에 JPA EntityManager도 닫힌 것을 볼 수 있었습니다. 따라서, 트랜잭션이 끝났을 때 DB 커넥션 풀이 반납되고 있습니다.
알람 API를 실행시켜도, 무한 점유하던 이전과는 다르게 바로 커넥션 풀이 반납된 것을 확인했습니다.
그러면, 비활성화 시키면 모든 문제가 해결된 걸까요?
비활성화 시키기에 앞서, OSIV 활성 비활성에 따른 장단점을 고려해보고자 합니다.
OSIV 전략 선택 시 장단점
활성화 시
장점
장점으로는 영속성 컨텍스트가 View 레이어까지 유지되므로 Lazy Loading 사용 시에 더 편리합니다. 위에서 발생했던 Lazy Loading과 관련한 에러를 고민할 필요가 없습니다.
단점
- 리소스 점유 문제: 발생했던 실시간 알람처럼 영속성 컨텍스트가 종료되지 않고 DB 커넥션을 점유할 때 문제가 발생했습니다. 단순히 알람을 `SSE`가 아닌 다른 방식으로 변경하면 어떨까? 고민을 해도 저희 서비스는 결국 실 사용자를 받는 것이 목적이고 실시간 트래픽이 몰리는 경우에 커넥션 풀 고갈의 위험성은 여전히 존재합니다.
비활성화 시
장점
장점으로는 역시 트랜잭션 종료 시 커넥션 풀이 반환되므로 커넥션 사용시간이 최적화 됩니다. 커넥션 풀 고갈 위험도 감소하므로 실시간 트래픽 처리에 더 효과적입니다.
단점
- Lazy Loading 시 발생하는 문제: 저희 서비스에선 `@ManyToOne`, 즉 엔티티끼리 다대일 관계일 때 불필요한 데이터를 조회하는 것을 방지하고자 Lazy Loading을 사용하고 있습니다. 이에 따라서 OSIV 설정을 비활성화 시켰을 때 `LazyInitializationException` 오류가 발생했습니다.
LazyInitializationException
배포된 저희 서비스에 임시적으로 OSIV를 비활성화 시켰더니, 더 이상 커넥션 풀 문제는 발생하지 않았습니다. 👍
하지만, 이전에 없던 문제들이 생기는 것을 확인할 수 있었습니다.
마이 페이지에서 내가 작성한 밸런스 게임 목록들을 조회할 수 있는데, 원래는 잘 동작하던 API가 갑자기 `500`에러를 던져주고 있습니다.
그라파나에서 로그를 확인해보니 다음과 같은 문제가 발생했습니다.
컬렉션을 지연 초기화 하는데 실패했습니다. GameSet.games 프록시를 초기화 할 수 없습니다, 세션이 존재하지 않습니다.
왜 이런 에러가 발생할까요?
트랜잭션이 서비스 계층에서 시작하여 종료되고 있습니다. 이 때, 영속성 컨텍스트도 함께 종료가 되고 컨트롤러로 반환된 엔티티는 준영속 `detached` 상태가 됩니다. 준영속 상태의 엔티티에서 지연 로딩 `Lazy Loading`으로 설정된 엔티티에 접근하려고 할 때, 이미 영속성 컨텍스트가 종료되었기 때문에 DB에 접근할 수 없어 `LazyInitializationException`이 발생합니다.
해결 방법
인터넷에 찾아본 해결 방법들은 다음과 같았습니다.
- 지연 로딩을 사용하지 않고 `Eager Loading`으로 변경
- FetchJoin, @EntityGraph를 사용
- DTO를 만들어 반환하는 방식
1번과 같은 경우는 하나의 엔티티를 조회할 때 관련된 모든 엔티티를 조회합니다.
저희 프로젝트 ERD 구조입니다. `Member` 엔티티를 조회할 때 관련된 모든 엔티티를 조회한다고 생각만 해도 벌써부터 머리가 아픕니다.
쿼리가 너무 복잡해지기 때문에 해당 방법은 제외하겠습니다.
2번을 사용하는 방식보다 3번이 좀 더 간편하게 리팩토링이 가능할 것 같아 해당 방식으로 문제를 해결해 보려고 합니다.
DTO를 사용하여 반환
사실, 저희 서비스에선 이미 서비스 레이어에서 DTO 변환을 해서 컨트롤러에 넘겨주고 있었기 때문에 따로 수정할 게 없었습니다.
서비스에선 핵심 비즈니스 로직을 수행하고, `Entity -> DTO` 변환 로직은 따로 클래스를 만들어 관리했습니다.
컨트롤러에서도 응답을 엔티티가 아닌 dto로 클라이언트에 보내주고 있었기 때문에, 현재 구조에서 변경할 필요는 없었습니다. 하지만, 여전히 Lazy Loading으로 에러가 발생하고 있는 상황이였습니다.
트랜잭션 어노테이션 추가
다시 서비스 로직으로 돌아가 코드를 확인해 보겠습니다.
List<TalkPickMyPageResponse> responses = bookmarks.stream()
.map(bookmark -> {
TalkPick talkPick = bookmark.getTalkPick();
return TalkPickMyPageResponse.from(talkPick, bookmark);
})
.toList();
`TalkPickMyPageResponse` 클래스에 `from` 메서드를 통해 dto 변환이 완료됩니다.
dto 변환을 할 때를 자세히 살펴보면, `talkpick` 엔티티만 가져오는 것 뿐만 아니라 연관되어 있는 `bookmark`, `comment` 엔티티도 참조하고 있습니다. 서비스 메서드에 트랜잭션이 없는 경우에, 레포지토리 호출 후 트랜잭션이 종료됩니다. 따라서, 엔티티는 준영속 `detached` 상태가 되어 dto로 변환하는 과정에서 지연 로딩된 필드에 접근할 때 문제가 발생합니다.
하지만, 트랜잭션 어노테이션을 추가한다면 OSIV를 비활성화 하더라도 적어도 영속 상태가 서비스 계층에선 유지되기 때문에 문제가 발생하지 않습니다!!!
성공적으로 200 성공 메세지를 반환하는 것을 확인할 수 있었습니다. 🫡
@Transactional(readOnly = true)
지금까지 `조회`를 할 때 지연 로딩 문제가 발생했습니다. 저는 무작정 조회 로직에 `@Transactional`을 걸어주기보다 `@Transactional(readOnly = true)`로 걸어주었고 문제없이 동작했습니다.
그러면, readOnly = true로 설정했을 때 얻을 수 있는 장점은 무엇일까요?
Read Only 장점
`더티 체킹` 비활성화
더티 체킹이란 상태 변경 검사를 이야기하며 트랜잭션이 끝나는 시점에서 변화가 있는 모든 엔티티 객체를 DB에 자동으로 반영합니다. JPA 에선 엔티티를 조회할 때 엔티티의 조회 `상태`를 그대로 스냅샷을 만들어 저장하는데, 이 때 상태에 변화가 생긴다면 `update` 쿼리가 생성되며 자동으로 데이터베이스에 반영을 합니다. 그렇기 때문에 업데이트를 할 때, 다시 `repository.save()` 를 실행하지 않아도 값이 변경됩니다.
따라서, 더티 체킹이 비활성화 될 때는 엔티티에 대한 스냅샷을 저장하지 않기 때문에 메모리 측면에서 이점이 있습니다.
지금과 같은 작은 서비스에선 유의미한 차이를 보일 순 없지만, 많은 필드를 가진 수천 개의 엔티티를 처리해야 할 때는 더티 체킹을 비활성함으로써 메모리를 낭비를 줄이고 성능을 개선할 수 있겠죠?
데이터 일관성 보장
readOnly 설정은 DBMS마다 다르다고 합니다. 현재 제가 사용하고 있는 `MySQL`을 예시로 설명하자면, 트랜잭션을 이용하는 경우 SELECT 문에 대해서만 기능을 지원하며 현재 트랜잭션이 시작되기 이전에 커밋된 데이터만 접근할 수 있으며, 트랜잭션이 실행되는 동안 커밋되는 데이터는 결과에 반영되지 않는다고 합니다.
따라서, 이를 통해 트랜잭션 안에서 데이터 일관성을 보장하는 이점을 얻을 수 있겠습니다.
주의점
무작정 `Read Only`가 옳은 것은 아니고, 주의해서 적용해야 합니다. 프로젝트를 진행하면서 이와 관련해서 이슈가 생겼던 적이 있습니다.
게임을 조회하는 로직에서 무작정 성능적인 이점만 생각하며 `Read Only`를 걸어주었더니 게시글을 조회할 때 조회수가 증가하지 않는 문제가 발생했습니다. 처음엔 어디서 문제가 생겼는지 정말 찾기 힘들었습니다. 쿼리를 분석하며 문제를 찾아보니, 조회수를 증가시켜주는 `update` 쿼리가 생성되지 않고 있었습니다.
다시 기존의 트랜잭션 어노테이션으로 바꿔줬더니 성공적으로 업데이트 쿼리가 생성되며 조회수가 증가하는 것을 확인할 수 있었습니다.
결론
꽤 이야기가 길었던 것 같습니다. 처음에 서버가 터지는 문제를 경험했고, 로그를 통해 확인하면서 커넥션 풀 고갈 문제인 것을 확인했습니다. 해당 원인이 실시간 알람 기능에서 비롯된다는 것을 확인한 후에, OSIV 설정을 변경하며 추가적으로 발생했던 지연 로딩 문제를 해결했습니다.
간단한 줄만 알았던 문제가 여러 원인이 얽혀있고, 이를 해결해 나가는 과정에서 OSIV를 활성화 했을 때와 비활성화 했을 때의 장단점을 비교하며 해결할 수 있었습니다. 사실 이번 에러를 경험하기 전엔 OSIV에 대해서 전혀 몰랐는데, 로그에 `WARN` 로그가 찍히는 경우도 유심히 살펴 봐야겠습니다.
참고자료:
https://velog.io/@hyeok_1212/osiv-%EC%84%A4%EC%A0%95%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94
[Spring] OSIV 설정하시나요?
실행만 되면 OK일까요? OSIV에 대해 학습한 내용이에요.
velog.io
Spring Boot의 open-in-view, 그 위험성에 대하여.
실제 서버 장애 해결과정을 중심으로
medium.com
JPA - OSIV(Open Session In View) 정리
OSIV(Open Session In View) OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어두는 기능이다. 영속성 컨텍스트가 유지되면 엔티티도 영속 상태로 유지된다. 뷰까지 영속성 컨텍스트가 살아있다면
ykh6242.tistory.com
https://incheol-jung.gitbook.io/docs/q-and-a/spring/persistence-context
영속성 컨텍스트(Persistence Context) | Incheol's TECH BLOG
영속성 컨텍스트에 대해 알아보자
incheol-jung.gitbook.io
https://lordofkangs.tistory.com/423
[JPA] OSIV ( Open Session In View )
Spring은 3계층으로 나뉜다. JPA는 ORM 프레임워크로 트랜잭션이 시작되는 서비스계층과 데이터 엑세스 계층을 위해 존재한다. 그러나 엔티티(Entity)는 DB로부터 CRUD 데이터를 전달하기 위해, 프레젠
lordofkangs.tistory.com
https://jojoldu.tistory.com/415
더티 체킹 (Dirty Checking)이란?
Spring Data Jpa와 같은 ORM 구현체를 사용하다보면 더티 체킹이란 단어를 종종 듣게 됩니다. 더티 체킹이란 단어를 처음 듣는분들을 몇번 만나게 되어 이번 시간엔 더티 체킹이 무엇인지 알아보겠습
jojoldu.tistory.com
DBMS 별 Transaction Read Only에 대한 동작 방식 -
해당 글은 Notion에 정리되어 있던 Somaeja 프로젝트 관련 정리 글 중 하나입니다. 이번 프로젝트를 진행할 때 Post Service에 Transcation Read only 설정을 적용하게 되었었다. 단순히 read only를 사용하면 read
lob-dev.tistory.com
'프로젝트 > PICK-O' 카테고리의 다른 글
갑자기 프로젝트가 터져버린 이유? (0) | 2025.02.17 |
---|---|
성능 테스트 툴 선정 및 테스트 결과 (0) | 2025.01.10 |
JWT토큰과 RefreshToken (0) | 2024.04.19 |
프로젝트에 JWT를 도입하게 된 과정 (0) | 2024.04.19 |