들어가기 전…
문제와 개선 방향을 다음과 같이 정리하였습니다.
- RAG(Retrieval-Augmented Generation) 방식을 사용하는 챗봇 프로젝트에서 데이터 서버 요청 시간 지연으로 인해 리소스 점유 시간이 길어지는 문제가 발생하고 있습니다.
- RAG 서버에 요청한 후, 해당 답변을 저장하는 로직이 실행되면서 트랜잭션 지속 시간이 길어지고, 이로 인해 DB 커넥션 리소스 낭비가 초래되고 있습니다.
- 이러한 문제는 특히 입시철에 예상되는 약 3만 명의 지원생 중 수백에서 수천 명에 이르는 사용자 트래픽을 처리하는 데 DB Connection 부족(대표적인 오류: CannotCreateTransactionException)으로 이어질 가능성이 있습니다.
- Connection Pool 수 증가, Timeout 설정 등의 방법이 있지만, 이러한 설정은 운영 과정을 통해 지속적으로 모니터링하면서 적정한 값을 확인하고 조정하는 것이 옳다고 판단했습니다.
따라서 보다 근본적으로 트랜잭션 관리와 코드 구조를 개선하는 방식으로 문제를 해결하였습니다.
상황 (Situation)
최근 프로젝트가 본격적으로 학교 홈페이지에 등록되면서, 서버 성능 최적화와 장애 대비에 대한 필요성이 더욱 중요해졌습니다.
- 입학처 홈페이지: https://iphak.mju.ac.kr
문제 구체화:
최근 융합소프트웨어학부와 청소년지도학과의 에듀테크페어 학술교류제에서 챗봇 프로젝트가 소개되었고, 약 100명의 사용자 피드백을 받는 기회가 있었습니다.
위의 모니터링 지표는 요청이 5분간 약 500개 들어왔을 때 DB Connection Pool(HikariCP) 입니다.
즉, 100명이 분당 5개의 질문을 했을 뿐인데 Connection Pool이 거의 찼던 것입니다.
(과한 걱정일 수도 있고, 이해도가 부족한 것도 있겠지만) 만약 Connection Pool이 가득차게 되어, 대기열의 요청이 대기열(Pending) 상태에 머무르게 될 것이고, 이로인해 서버 멈춤 현상을 겪을 수 있다고 판단했습니다.
이에 대한 원인을 분석했을 때,
- RAG 서버에 요청시 질문이 자세한 경우 4초~5초, 질문이 짧고 충분한 정보가 없는 경우 10초~11초 만에 대답을 하게 됩니다.
- 오류 발생 시, 요청이 30초 이상 소요되는 경우가 빈번히 발생했습니다.
- 이 경우에는 비동기 요청에서 Timeout 시간을 줄였으나, 여전히 트랜잭션에 종속적인 작업인 것이 문제였습니다.
Task
위의 문제를 분석해봤을 때, 우선 트랜잭션 관리에 대한 개선이 필요하다고 느꼈습니다.
이를 바탕으로 정리한 Task는 다음과 같습니다.
- OSIV 비활성화: OSIV 비활성화를 통한 트랜잭션 범위를 Service 계층으로 제한하여, 커넥션이 불필요하게 점유되는 시간을 줄입니다. 즉, EntityManager가 트랜잭션 범위에서만 유지되게 하여 불필요한 DB Connection을 줄입니다.
- 트랜잭션 분리: OSIV 비활성화와 함께 RAG 서버 호출 로직에 `@Transactional(propagation = Propagation.NOT_SUPPORTED)`를 적용하여 트랜잭션을 일시 중단합니다. 이를 통해 RAG 호출이 장시간 걸리더라도 트랜잭션이 유지되지 않아 커넥션 점유를 방지할 수 있었습니다.
- CQRS 적용: OSIV 비활성화 시에 트랜잭션 범위를 Controller 또는 Service 계층으로 한정하기 때문에 View 레이어에서 `LazyInitializationException`이 발생할 수 있습니다. 이를 방지하고자 CQRS(Command and Query Responsibility Segregation)를 적용합니다.
CQRS 적용
CQRS를 통해 패키지를 나눈 후 osiv를 비활성화하여 오류에 대비합니다.
CQRS란?
CQRS(Command and Query Responsibility Segregation)는 명령(Command)과 쿼리(Query)의 역할을 분리하여 데이터의 읽기와 쓰기를 다른 모델로 처리하는 아키텍처 패턴을 말합니다.
이 패턴은 읽기와 쓰기의 요구사항이 비대칭적인 복잡한 애플리케이션에서 설계와 성능을 최적화하는 데 유용합니다.
보통 단순한 프로젝트의 경우에는 CQRS를 적용하는 것이 오히려 서비스 구조의 복잡도를 높일 수 있다는 문제가 있어서 권장되지 않는다고 합니다. 특히, 이벤트 소싱(Event Sourcing)과 함께 사용하는 경우 설계가 훨씬 복잡해질 수 있다고 합니다.
하지만 제 프로젝트에서는 CQRS를 가장 간단한 방식으로 적용했어요. 단일 데이터 저장소를 사용한 단일 애플리케이션 내에서 명령(Command)과 쿼리(Query)를 명확히 분리된 계층으로 나누는 방식으로 진행해서 복잡도를 낮췄습니다.
앞으로 프로젝트가 커진다면, Command와 Query 데이터를 별도의 데이터베이스로 분리하고, 메시지 브로커를 활용해 데이터를 동기화하는 방식으로 확장할 수도 있을 것 같습니다. 지금은 필요한 수준만큼만 적용하는 게 더 합리적이라고 판단했습니다.
작업 내용
1. 명령과 쿼리 분리
- 읽기, 쓰기 작업 내에서도 세부적으로 각 작업별로 인터페이스를 나누어 SRP와 DIP, OCP를 지키도록 구현했습니다.
- 읽기 작업은 DTO 형태로 필요한 데이터만 반환하는 쿼리 전용 서비스를 구현했습니다.
- 쓰기 작업은 도메인 로직과 비즈니스 검증을 포함한 명령 전용 서비스를 통해 처리했습니다.
- 쓰기 작업에서는 return 값을 void를 기본으로 하면서 조회와 구분지었습니다.
2. 테스트
- 명령과 쿼리 각각의 독립적인 테스트 케이스를 작성하여, 명령 작업과 쿼리 작업이 서로 영향을 받지 않도록 검증했습니다.
참고자료: https://learn.microsoft.com/ko-kr/azure/architecture/patterns/cqrs
OSIV 비활성화
OSIV란?
OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어두는 기능입니다.
이 방식에서는 트랜잭션이 끝난 뒤에도 영속성 컨텍스트가 열려 있는 상태로, View나 Controller 계층에서도 지연 로딩(Lazy Loading)이 가능합니다.
OSIV 를 비활성화하는 것이 무작정 좋은 것은 아니고, 장점도 많습니다.
OSIV 활성화의 장점
- 지연 로딩 지원: 트랜잭션이 종료된 후에도 영속성 컨텍스트가 열려 있으므로, View 또는 Controller에서 필요한 연관 엔티티를 추가적으로 조회할 수 있습니다.
- 간단한 구현: 엔티티를 그대로 반환하거나, 뷰 계층에서 직접 데이터를 활용할 수 있어 간단한 CRUD 애플리케이션에 적합합니다.
그러나 저는 앞서 설명했듯이 불필요한 커넥션 시간을 줄이는 것이 목적이므로 OSIV를 비활성화하여 트랜잭션 범위 내에서만 DB Connection을 유지하게 할 것입니다.
설정은 간단합니다. application.yml 파일에서 다음과 같이 설정합니다.
jpa:
open-in-view: false
작업 중 이슈
cqrs가 적용된 RAG 호출을 진행해보면 다음과 같은 오류가 발생했습니다.
{
"error": "JpaSystemException",
"status": 500,
"message": "could not execute statement [Connection is read-only. Queries leading to data modification are not allowed] [insert into questions (admission_category,admission_type,content,content_token,created_at,is_checked,updated_at,view_count) values (?,?,?,?,?,?,?,?)]"
}
오류 원인:
해당 오류는 상위 트랜잭션이 `readOnly=true` 로 설정되어 있기 때문에 발생한 것입니다. `readOnly` 로 설정된 트랜잭션에서는 데이터 수정 작업(insert, update 등)이 허용되지 않습니다. 위의 예시에서는 RAG 서버 호출 후 새로운 답변을 저장하려고 시도했지만, 상위 트랜잭션이 읽기 전용으로 설정되어 있어 INSERT 문이 실행되지 못한 상황입니다.
왜 상위 트랜잭션을 `readOnly=true`
로 설정했는가?
RAG 호출 전 작업에 대해 트랜잭션을 읽기 전용으로 설정한 이유는 다음과 같습니다:
1. 읽기 작업만 수행됨
- RAG 서버 요청 전에 DB 작업은 모두 데이터를 조회하는 읽기 작업으로 이루어지며, 데이터 수정이 필요하지 않습니다.
- 이때, 스프링 프레임워크는 JPA의 세션 플러시 모드를 MANUAL(트랜잭션 내에서 사용자가 수동으로 flush를 호출하지 않으면 flush가 자동으로 수행되지 않는 모드)로 설정합니다.
- 즉, 트랜잭션 내에서 강제로 flush()를 호출하지 않는 한, 수정 내역에 대해 DB에 적용되지 않게 됩니다.
- 이로 인해 트랜잭션 Commit 시 영속성 컨텍스트가 자동으로 flush 되지 않으므로 조회용으로 가져온 Entity의 예상치 못한 수정을 방지할 수 있습니다.
- 해당 작업을 `readOnly = true` 로 설정해 불필요한 쓰기 리소스를 방지했습니다.
- `readOnly = true`를 설정하게 되면 JPA는 해당 트랜잭션 내에서 조회하는 Entity는 조회용임을 인식하고 변경 감지를 위한 Snapshot을 따로 보관하지 않으므로 메모리가 절약됩니다.
2. RAG 호출 시간과 DB Connection 분리
- RAG 서버 요청은 외부 API와의 통신이 포함되어 있어 지연 시간이 길어질 가능성이 큽니다.
- 이 호출 시간 동안 DB Connection을 점유하지 않도록 트랜잭션을 분리하는 것이 목적이었습니다.
3. RAG 서버 응답 처리 트랜잭션 분리
- RAG 서버에서 응답이 도착하면, 이를 처리하고 저장하는 작업은 새로운 트랜잭션을 시작하도록 설계했습니다.
- 이렇게 함으로써 호출 전의 읽기 작업과 응답 저장 작업이 명확히 구분됩니다.
이를 해결하기 위해 RAG 서버 호출 시에 트랜잭션 범위를 `PROPAGATION_NOT_SUPPORTED` 로 변경하여 트랜잭션을 중단하고, 응답된 답변을 저장하는 시점에 `PROPAGATION_REQUIRES_NEW` 을 설정하여 새로운 트랜잭션을 생성하였습니다.
트랜잭션 전파 설정 확인 및 OSIV 비활성화 검증
logging 범위에 transaction을 추가하고 각 로그를 분석하여 위에서부터 작업한 내용이 정상 적용되었는지 확인합니다.
RAG 서버(외부 API) 호출과 관련된 DB 커넥션 리소스 활용에 대해 로그를 바탕으로 판단하겠습니다.
트랜잭션 흐름 및 리소스 활용
초기 트랜잭션 생성
- `ProcessQuestionService.invoke`호출 시 읽기 전용 트랜잭션이 생성됩니다.
- Hibernate가 질문 테이블에서 데이터를 조회하고, `EntityManager`는 열려 있습니다.
DEBUG ... Creating new transaction with name [ProcessQuestionService.invoke]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
DEBUG ... Opened new EntityManager [SessionImpl(135930762<open>)] for JPA transaction
[Hibernate]
select q1_0.id, q1_0.content_token
from questions q1_0
where MATCH (q1_0.content) AGAINST (? IN BOOLEAN MODE)>?
and q1_0.admission_type=?
and q1_0.admission_category=?
- DB 커넥션이 데이터 조회에 사용되고 있습니다.
외부 API 호출 전 트랜잭션 일시 중단
- 데이터 조회 후, 유사한 질문이 없어 외부 API를 호출하기로 결정합니다.
- 트랜잭션이 일시 중단되고, `EntityManager`는 대기 상태로 전환됩니다.
INFO ... 저장된 질문이 없어 새롭게 LLM서버에 질문을 요청합니다.
DEBUG ... Found thread-bound EntityManager [SessionImpl(135930762<open>)] for JPA transaction
DEBUG ... **Suspending current transaction**
- 트랜잭션이 중단되었으므로, Hibernate와 DB 커넥션은 현재 상태에서 더 이상 사용되지 않습니다.
외부 API 호출
- WebClient를 사용해 외부 API 호출이 진행됩니다.
- 트랜잭션이 없는 상태에서 호출이 이루어지므로, DB 커넥션은 점유되지 않습니다.
INFO ... Request: POST RAG 서버 호출 url
INFO ... Response Status: 200 OK
- 이 구간에서 DB 커넥션 리소스는 활용되지 않고, 외부 네트워크 작업만 수행됩니다.
트랜잭션 재개 및 데이터 저장
- 외부 API 호출 완료 후 트랜잭션이 재개됩니다.
- 새로운 트랜잭션(`CreateRAGAnswerService.invoke`)이 생성되어 데이터 저장 작업을 수행합니다.
DEBUG ... Resuming suspended transaction after completion of inner transaction
DEBUG ... Suspending current transaction, creating new transaction with name [CreateRAGAnswerService.invoke]
DEBUG ... Opened new EntityManager [SessionImpl(1254909283<open>)] for JPA transaction
[Hibernate]
insert into questions ...
insert into answers ...
insert into answer_references ...
- 새로운 트랜잭션과 `EntityManager`가 사용되며, 기존 트랜잭션은 대기 상태로 유지됩니다.
외부 API 호출에 따른 DB 커넥션 활용 평가
DB 커넥션 점유 최소화 여부
- 외부 API 호출 동안 트랜잭션이 중단되므로, DB 커넥션은 점유되지 않습니다. (추가 검증 예정)
- 이는 `PROPAGATION.NOT_SUPPORTED` 또는 트랜잭션 중단(`suspend`) 설정이 성공했음을 의미합니다.
- 리소스 낭비는 발생하지 않습니다.
작업의 유효성 검증
해당 작업을 진행하면서 궁금한 점이 있어 HikariCP Connection Pool 로그를 확인하면서 진행했습니다. 특히 외부 API 호출(예: RAG 서버) 시 OSIV(Open Session In View) 활성화 여부가 DB Connection 유지 여부에 미치는 영향을 확인했습니다.
주된 고려사항은 다음과 같습니다.
- OSIV 비활성화 시 외부 API 호출 동안 DB Connection이 반환되지 않고 점유되는지 확인
- OSIV 비활성화 시 Entity Manager가 트랜잭션 종료 시에 반환되는가?
결과 요약
OSIV 활성화 상태:
- 외부 API 호출 시 트랜잭션이 중단되더라도, OSIV로 인해 Hibernate의 영속성 컨텍스트가 요청 범위에서 유지됩니다.
- 결과적으로 DB Connection이 외부 API 호출 동안 반환되지 않고 점유됩니다.
OSIV 비활성화 상태:
- 트랜잭션 중단(`Propagation.NOT_SUPPORTED`) 시 Hibernate의 영속성 컨텍스트가 종료됩니다.
- 외부 API 호출 전에 DB Connection이 반환되어 Connection 리소스 낭비를 방지할 수 있습니다.
최적화 방안:
- OSIV를 비활성화하고 트랜잭션을 명확히 분리(`Propagation.NOT_SUPPORTED`)하여 외부 API 호출 중 DB Connection 점유를 방지합니다.
결과적으로 “OSIV가 비활성화되어 있으면, 트랜잭션이 종료될 때마다 영속성 컨텍스트도 함께 종료” 라는 부분을로그를 통해 확인한 후(사실 로그를 수차례 확인하고 분석했지만 찜찜해서), 여러 문서를 통해 검증했습니다.
- https://vladmihalcea.com/the-open-session-in-view-anti-pattern/
- https://www.baeldung.com/spring-open-session-in-view
- https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/dashboard
영속성 컨텍스트가 종료됨에 따라 DB 커넥션이 중단되는지에 대해 판단하는데 생각만큼 속 시원하게 결과가 나오지 않아(db connection pool 로깅이 원하는 시점에 잘 나오지 않음..) 애를 먹었습니다.
이는 추후 지속적인 모니터링을 통해 확인해나가겠습니다.
그럼에도 해당 작업은 command와 query의 관심사를 분리해 유지보수 및 확장성에 도움이되었다는 점, transaction의 분리를 통해 외부 API와 다른 작업의 분리가 이뤄졌다는 것에서도 충분히 의미있는 작업이었다고 생각합니다.
마무리
결론적으로 다음과 같은 방법으로 문제를 해결했습니다.
1. OSIV 비활성화
- OSIV를 비활성화함으로써 트랜잭션 범위를 명확히 관리하고, 불필요한 DB Connection 점유를 방지했습니다.
- 트랜잭션은 Service 계층 내에서만 유지되며, 외부 API 호출과 같은 작업 중에는 Connection이 반환됩니다.
2. 트랜잭션 분리 (`Propagation`설정 활용)
- 외부 API 호출 시 트랜잭션을 일시 중단(`PROPAGATION_NOT_SUPPORTED`)하여 트랜잭션 없이 작업을 수행했습니다.
- 외부 API 응답 처리 및 데이터 저장 작업은 새로운 트랜잭션(`PROPAGATION_REQUIRES_NEW`)으로 진행해 독립성을 확보했습니다.
3. CQRS(Command Query Responsibility Segregation) 적용
- OSIV 비활성화로 인해 발생할 수 있는`LazyInitializationException`을 방지하고, 트랜잭션 의존성을 줄이기 위해 명령(Command)과 조회(Query)를 분리했습니다.
작업 이후 결과 기록
약 157명의 유저가 질문 관련 요청 + 기타 조회, update 쿼리 요청을 총 1000개 (1초 동안 약 100개의 요청이 몰린 경우도 있었습니다.) 가량 요청했을 때 db connection이 기존보다 안정된 것을 확인해 해당 작업이 유의미 했다는 것을 확인했습니다.
Reference
- https://vladmihalcea.com/the-open-session-in-view-anti-pattern/
- https://www.baeldung.com/spring-open-session-in-view
- https://velog.io/@_koiil/TransactionalreadOnly-true-꼭-써야하나요
- https://velog.io/@gntjd135/OSIV
- https://dodeon.gitbook.io/study/kimyounghan-spring-boot-and-jpa-optimization/04-osiv
- https://learn.microsoft.com/ko-kr/azure/architecture/patterns/cqrs
- https://ykh6242.tistory.com/entry/JPA-OSIVOpen-Session-In-View와-성능-최적화