Welcome to the tech blog of Junior Backend Developer Seulbin Kang!
Backend
(BE 개발자 3개월차) 현업에서 겪은 OSIV OFF에서 Command/Query 분리까지
-
👀 들어가기에 앞서
2025년 10월 중순, 차량 Wallpaper SW 서버 개발 프로젝트를 도맡아 개발하게 되며 감격스러운(?) 나의 서비스 개발이 시작되었다.
Wallpaper를 한 번 조회하는 작업을 겉으로 보면 단순해 보이지만, 실제로는 아래처럼 다양한 데이터를 한 화면에 맞춰 조립해야 한다.
예를 들면 아래와 같은 것이다. (회사 보안 문제로 진짜 ERD는 가져오지 못했다)
예시 ERD
예시를 들기 위해서 직접 만들어본 ERD이다.
위 상황에서 Content를 조회할때 필요한 정보는 아래와 같다.
콘텐츠 기본 정보
옵션별 파일 URL (media/source/preview 등)
카테고리/태그
사용자별 좋아요 여부
좋아요/다운로드/조회수 같은 지표
즉, 한 번의 응답을 만들기 위해 조인이 여러 번 발생하고, 쿼리도 여러 개로 분산되는 구조가 되기 쉽다.
문제는 이 시스템이 “차량 SW”라는 특성상, 차량 판매량 증가 → 설치 대수 증가 → 사용자 트래픽 증가가 어느 정도 예측 가능하다는 점이었다. 처음부터 고트래픽/고부하 환경을 전제로 설계하지 않으면, 나중에 트래픽이 붙는 순간 병목은 거의 확정적으로 터질 것이라 예측했다.
👀 본론 : OSIV ON으로 인한 편리함은 커넥션 점유 시간을 늘린다
OSIV ON
OSIV(Open Session In View)를 켜면, 트랜잭션이 끝난 뒤에도 영속성 컨텍스트가 요청 끝까지 살아있다. 덕분에 Controller/View에서까지 지연 로딩이 자연스럽게 동작한다.
하지만 그 편리함의 반대편에는 항상 이 비용이 붙는다.
요청이 끝날 때까지 DB 커넥션이 반환되지 않을 수 있다.
특히 “조인이 많고 응답 조립이 긴” API는, 단순 조회처럼 보여도 실제론 커넥션 점유 시간이 길어지기 쉽다. 고트래픽 상황에서 이게 의미하는 바는 명확하다.
HikariCP 풀에서 커넥션이 빠르게 소모되고
대기 큐가 길어지고
응답 지연(P95/P99)이 급격히 튄다
결국 타임아웃/에러까지 이어질 수 있다
OSIV OFF
그래서 User 트래픽이 붙는 API 서버 군에서는 OSIV가 OFF일 필요가 있다고 판단했다. 핵심 목표는 단 하나였다.
트랜잭션(=커넥션 점유)을 가능한 짧게 만들고, 빨리 반납하게 만들기
트랜잭션을 “짧게” 만들기: 읽기와 쓰기를 한 트랜잭션에 몰아넣지 않기
처음 구조를 고민할 때 떠올랐던 위험 시나리오는 이거였다.
하나의 요청이 “조회 + 카운트 증가 + 사용자 좋아요 상태 체크 + 응답 조립”까지 모두 수행
이 모든 것을 하나의 @Transactional 안에 묶으면?
조회가 많고 조립이 길수록 커넥션 점유 시간이 늘어난다
게다가 쓰기 작업이 끼면 잠금/플러시/격리 수준 영향도 받는다
고부하에서 병목이 터질 가능성이 커진다
그래서 결론은 아래와 같다.
“읽기 트랜잭션”과 “쓰기 트랜잭션”을 섞지 않고, 범위를 분리하자.
조회는 @Transactional(readOnly = true)로 짧고 명확하게
쓰기(조회수 증가, 다운로드 카운트, 상태 변경 등)는 정말 필요한 최소 범위만 별도 트랜잭션으로
이렇게 하면 요청 전체 시간이 길어져도, DB 커넥션이 트랜잭션 경계에서 빠르게 반환되기 때문에 풀 고갈 위험을 낮출 수 있다.
OSIV OFF에서 생기는 문제와 해결: Command / Query 분리
OSIV를 끄면 “Controller에서 지연 로딩을 해버리는 습관”이 바로 문제로 돌아온다.
트랜잭션 종료
영속성 컨텍스트 종료
Controller/View에서 getName() 같은 접근이 터짐 (LazyInitializationException)
이걸 OSIV 다시 켜기로 덮을 수도 있지만, 트래픽을 생각하고 성능을 생각하여 아래와 같은 방식으로 해결했다.
Query 쪽은 성능 최적화에만 집중할 수 있다 (fetch, projection, batch 전략 등)
그렇게만 하면 Join이 많아지면서 로딩할 데이터가 많아지며 발생하는 Side Effect가 모두 해결되는 것인가?
결론부터 이야기하면, 아니다.
조인이 많고 조건이 복잡한 화면 조회는, 엔티티를 한 번에 다 끌어오려 하면 오히려 위험해진다.
쿼리가 비정상적으로 커지고
DB 옵티마이저 플랜이 흔들리고
중복 row 폭발로 결과가 예상치 못한 결과가 나올 수 있다.
JPA fetch join은 페이징과 같이 쓰기 까다롭다
그래서 QueryService에서 선택한 전략은 아래와 같다.
1) 먼저 content_id만 페이징/정렬 조건에 맞게 뽑는다 2) 그 ID 리스트를 기준으로, 필요한 데이터들을 각각 배치로 가져와 Map으로 적재한다 3) 마지막에 assembler가 DTO를 조립한다
예를 들면:
ID 페이지 조회(정렬/필터/hashtag exists) → Page<Long> ids
files where content_id in (...)
tags where content_id in (...)
categories where content_id in (...)
metrics where content_id in (...)
liked_ids where user_id = ? and content_id in (...)
그리고 마지막에:
Map<contentId, files>
Map<contentId, tags>
Map<contentId, categories>
Set<contentId> likedSet
… 을 기반으로 DTO 조립
이 구조로 가면, 조인은 많아질 수밖에 없는 화면에서도 다음을 확보할 수 있다고 판단하였다.
쿼리 목적이 명확해지고
각 쿼리를 독립적으로 튜닝할 수 있고
트랜잭션 점유 시간도 통제 가능해진다
결과적으로 고부하에서의 안정성이 올라간다
그럼 이렇게 하면 정말 끝인가??
아니다...ㅎㅎ
OSIV를 끄고, Command / Query를 분리하고, ID를 먼저 조회한 뒤 연관 데이터를 배치로 불러오는 구조를 만들었지만,
이로 인해 문제가 발생하였다...
이 패턴이 여러 화면에서 반복되기 시작하면, ReadService는 점점 “쿼리 호출 + Map 조립 + DTO 조립”이 한 파일 안에 뒤섞여 버렸다.
동일한 데이터를 가져오는 코드가 API마다 조금씩 변형되어 중복 생성되고
특정 화면에서 버그가 나면 데이터 로딩에서 깨진 건지, DTO 조립에서 깨진 건지를 빠르게 분리하기 어렵고
튜닝 포인트도 모호해진다 (어느 API가 어떤 쿼리를 몇 번 날리는지 눈에 잘 안 들어온다.)
즉, OSIV OFF를 하며 기대한 부분은 성능적 증가를 위함이었는데 이에 대한 TradeOff로 조회 서비스가 ‘데이터를 가져오고, 적재하고, 조립하는 책임’까지 전부 떠안아버린 것이다.
실제 변경 전 코드 줄 수 (Wallpaper관련 Service로직만 700줄이 넘어갔다...)
그래서 실제로 동일한 데이터를 가져오더라도, 조립 코드가 서비스마다 계속 생겨나게 되었고, Read를 위한 서비스 코드 줄수만 388줄까지 늘어났다. (하나의 Read Service에 있었기에 전체 코드라인은 이거보다 더 길었다.)
이 문제를 해결하기 위해 나는 다시 한 번 책임을 쪼갰다.
“데이터를 가져오는 책임”과 “응답을 조립하는 책임”을 분리하는 것이었다.
DataLoader는 “ID 리스트가 주어졌을 때 필요한 데이터를 한 번에 배치로 불러와 Map으로 정규화”한다. → 여기서 JPA 최적화(프로젝션/IN 쿼리/인덱스/쿼리 튜닝)를 집중시킨다.
ViewAssembler는 “이미 로딩된 Map/Set을 조합해서 DTO를 만든다.” → 여기에는 Repository 접근이 없어야 하고, 가능한 한 순수 조립 로직만 남긴다.
이렇게 분리하면서 다음과 같은 이점을 얻을 수 있었다.
쿼리 수가 ‘화면 개수’에 따라 폭발하지 않는다 → DataLoader의 고정된 배치 쿼리 묶음으로 통제된다.
버그 원인 분리가 쉬워진다 → 데이터가 잘못 로딩됐는지, 조립이 잘못됐는지를 바로 분리 가능하다.
JPA 관점에서 트랜잭션 경계가 깔끔해진다 → 로딩은 readOnly 트랜잭션에서 끝나고 조립은 DB와 무관한 순수 로직이 된다.
유지보수/확장성 → 화면 요구사항이 늘어도 DataLoader는 재사용되고, Assembler는 조합만 추가하면 된다.
결론적으로 160줄로 마무리 지었다...
결과적으로 Read를 위한 코드는 단 160줄로 마무리 지을 수 있었다. (이 이상은 비즈니스 로직이 바뀌어야만 했다.)
👀 결론 !
OSIV를 끄자 트랜잭션의 경계에서 문제가 드러났고, 그 문제를 해결하니 이번에는 많은 데이터를 로딩하면서 발생하는 또 다른 문제가 나타났다. 하나의 조회 API를 개선하는 과정이었지만, 그 안에서 JPA의 동작 방식과 트랜잭션, 영속성 컨텍스트에 대해 깊게 고민할 수밖에 없었다.
이번 경험을 통해 느낀 것은, 결국 성능 최적화나 구조 개선 이전에 기본에 대한 이해가 얼마나 중요한지였다. JPA가 언제 커넥션을 잡고, 언제 반납하는지, 트랜잭션의 범위를 어디까지 가져가야 하는지에 대한 이해 없이는 리팩토링 자체가 불가하였을 것이라 생각된다. 중요한 것은 결국 그 기술의 기본 동작 원리를 얼마나 정확히 이해하고 있는가라는 점이라는 것을 다시 한 번 느끼게 되었다.