제목, 태그, 카테고리로 검색

모든 글
약 7분 분량 프로젝트/빌려조잉

Coroutine에서 JPA가 401을 뱉었다

목차

WebSocket Handler에 Coroutine을 적용하고 나서, Redis 캐시 미스 시 MongoDB 병렬 조회에도 async를 활용했어요. 잘 되는 줄 알았는데, 이 suspend fun을 REST API에서 호출하는 순간 예상치 못한 에러가 터졌어요.


배경: 왜 Spring MVC에서 Coroutine을 썼나?

채팅방 목록을 조회할 때 각 채팅방의 안읽은 메시지 개수를 Redis에서 가져와요. 캐시 미스가 발생하면 MongoDB에서 계산해야 하는데, 채팅방이 10개고 캐시 미스가 5개면 MongoDB 조회를 5번 해야 합니다.

Coroutine의 async를 쓰면 5개의 MongoDB 조회를 병렬로 처리할 수 있어요. 순차 처리하면 500ms 걸릴 작업이 100ms로 줄어들죠.

문제는suspend fun을 호출하려면 코루틴 컨텍스트가 필요하다는 거예요. 그래서 REST API에서도 코루틴을 도입했는데, 여기서 문제가 시작됐습니다.


문제 상황

채팅방 생성 API: 정상 동작
채팅방 목록 조회 API: 401 에러

같은 토큰으로 채팅방 생성은 되는데 목록 조회만 안 됐어요.


원인 분석

로그를 자세히 보니 401이 아니라 LazyInitializationException이었어요. Spring Security 필터 체인에서 인증 처리 중 예외가 발생하면, ExceptionTranslationFilter가 이를 AuthenticationException으로 간주하고 401을 반환한다. 즉, LazyInitializationException → Security 필터에서 catch → 인증 실패로 처리 → 401 응답이 된 거였습니다.

org.hibernate.LazyInitializationException:
could not initialize proxy - no Session

코드를 확인해봤습니다.


Coroutine과 Hibernate Session

Kotlin Coroutine의 withContext(Dispatchers.IO)는 스레드를 전환해요. Hibernate Session은 스레드 로컬에 바인딩되어 있어서, withContext 블록을 벗어나면 Session이 종료됩니다.

왜 채팅방 생성은 됐을까?

두 API의 차이를 보니 명확했어요.

Spring의 @Transactional은 Thread-Local 기반이라 Coroutine에서 스레드가 바뀌면 제대로 작동하지 않아요.


해결 방법 검토

네 가지 방법을 검토했어요.

1. Eager Loading

안 쓰는 데이터도 매번 로딩해야 해서 비효율적이에요.

2. Batch Query

동작은 하지만 코드가 복잡해져요.

3. Fetch Join

1개 쿼리로 모든 데이터를 조회해요. N+1 문제도 함께 해결됩니다.

4. runBlocking으로 스레드 전환 방지

runBlocking으로 감싸면 스레드 전환이 발생하지 않아서 Hibernate Session과 SecurityContext가 유지돼요.


최종 선택: runBlocking + Fetch Join

runBlocking + Fetch Join을 함께 적용했어요.

  1. runBlocking: 스레드 전환 없이 Session/SecurityContext 유지
  2. Fetch Join: 혹시 모를 Lazy Loading 문제 방지 (이중 안전장치)

“runBlocking이면 Coroutine 쓰는 의미가 없는 거 아니야?”

맞는 지적이다. REST API 경로에서는 runBlocking으로 동기 실행하므로 Coroutine의 비동기 이점이 없다. 하지만 Coroutine을 도입한 이유는 REST API가 아니라 WebSocket Handler에서 MongoDB 병렬 조회를 위해서였다(Inbound Thread 최적화 참고). REST API에서 같은 suspend fun을 호출할 때만 runBlocking으로 감싸는 것이고, WebSocket 경로에서는 Coroutine의 async 병렬 처리가 그대로 동작한다.

Virtual Thread(Java 21)가 더 깔끔한 해결책이라는 것도 인지하고 있었다. Virtual Thread는 ThreadLocal 기반 코드(Hibernate, Security)와 호환되면서 경량 스레드의 이점을 얻을 수 있다. 하지만 프로젝트가 Java 17 기반이었고, 6주 프로젝트에서 Java 21 업그레이드 + Spring Boot 버전 변경은 리스크가 컸다.

우리 경우는 ChatRoom → Product, ChatRoom → Buyer, ChatRoom → Seller가 모두 N:1 관계거든요. N:1 관계에서는 Fetch Join이 가장 효율적이에요.

한 번의 쿼리로 채팅방, 상품, 구매자, 판매자 정보를 모두 가져옵니다.


1:N 관계는 왜 Batch Query를 유지했나

ProductFile은 1개 상품에 여러 이미지가 있는 1:N 관계예요. Fetch Join을 쓰면 카테시안 곱이 발생합니다.

그래서 ProductFile은 Batch Query를 유지했어요.


최종 구현


쿼리 수 비교

Before (Lazy Loading)

  1. SELECT chat_room (50ms)
  2. SELECT product WHERE id = 1 (5ms)
  3. SELECT product WHERE id = 2 (5ms)
  4. SELECT product WHERE id = 10 (5ms)
  5. SELECT member WHERE id = … (여러 번)
    총: 20-30개 쿼리, 200ms+

After (Fetch Join + Batch Query)

  1. SELECT chat_room + product + member (Fetch Join, 50-80ms)
  2. SELECT product_file WHERE product_id IN (…) (Batch, 10ms)
  3. Redis MGET (안읽은 개수, 5ms)
    총: 3개 쿼리, 65-95ms

결과

지표BeforeAfter
쿼리 수20-30개3개
응답 시간200ms+65-95ms
에러LazyInitializationException없음

정리

Spring MVC에서 Coroutine을 사용할 때는 Hibernate Session과 SecurityContext의 생명주기를 신경 써야 해요.

withContext로 스레드 전환 → Session/SecurityContext 유실 → 예외 발생

해결 방법

  • runBlocking 사용: withContext 없이 직접 호출하면 스레드 전환 없음
  • Fetch Join: N:1 관계는 한 번에 조회 (이중 안전장치)
  • Batch Query: 1:N 관계는 IN 절로 분리 조회

스프링 MVC에 코루틴을 도입해보면서 배운 점이 있어요.

  1. Spring MVC + Coroutines 조합은 Thread-Local 기반 인프라(Hibernate, Security)와 충돌해요. 이 조합을 쓰려면 스레드 전환을 세밀하게 통제해야 합니다.
  2. 같은 목적이라면 Virtual Thread가 더 자연스러워요. Virtual Thread는 기존 Thread-Local 기반 코드와 호환되면서 경량 스레드의 이점을 얻을 수 있거든요.

After applying Coroutines to the WebSocket Handler and using async for parallel MongoDB queries on Redis cache misses, an unexpected error occurred when calling this suspend fun from a REST API.


Background: Why Use Coroutines in Spring MVC?

When querying the chatroom list, the unread message count for each chatroom is fetched from Redis. On cache miss, MongoDB must be queried — with 10 chatrooms and 5 cache misses, that’s 5 MongoDB queries.

Using Coroutine’s async, these 5 MongoDB queries can run in parallel. What takes 500ms sequentially drops to 100ms.

The problem was that calling this suspend fun requires a coroutine context, so coroutines were introduced to the REST API as well — and that’s where trouble began.


The Problem

Chatroom creation API: Works fine
Chatroom list API: 401 error

Same token, but only the list query failed.


Root Cause Analysis

Looking at the logs more carefully, it wasn’t actually 401 but a LazyInitializationException. When an exception occurs during authentication processing in Spring Security’s filter chain, ExceptionTranslationFilter treats it as an AuthenticationException and returns 401. So: LazyInitializationException → caught by Security filter → treated as auth failure → 401 response.


Coroutines and Hibernate Session

Kotlin Coroutine’s withContext(Dispatchers.IO) switches threads. Hibernate Session is bound to ThreadLocal, so when leaving the withContext block, the Session is lost.

Why Did Chatroom Creation Work?

Spring’s @Transactional is ThreadLocal-based, so it doesn’t work properly when coroutines switch threads.


Solution Options Reviewed

  1. Eager Loading — Inefficient, loads unused data
  2. Batch Query — Works but complex code
  3. Fetch Join — Single query for all data, also solves N+1
  4. runBlocking — Prevents thread switching, maintains Session/SecurityContext

Final Choice: runBlocking + Fetch Join

  1. runBlocking: Maintains Session/SecurityContext without thread switching
  2. Fetch Join: Prevents any Lazy Loading issues (double safety net)

“Doesn’t runBlocking defeat the purpose of Coroutines?”

Fair point. In REST API paths, runBlocking runs synchronously, losing Coroutine’s async benefit. But Coroutines were introduced for WebSocket Handler’s parallel MongoDB queries (Inbound Thread Optimization), not REST APIs. Only REST paths wrap the same suspend fun with runBlocking — WebSocket paths still leverage async parallelism.

Virtual Threads (Java 21) would be cleaner — compatible with ThreadLocal-based code (Hibernate, Security) while providing lightweight threads. However, the project was on Java 17, and upgrading Java + Spring Boot was too risky for a 6-week project.

For N:1 relationships (ChatRoom → Product, ChatRoom → Buyer, ChatRoom → Seller), Fetch Join is most efficient.

For 1:N relationships (ProductFile), Batch Query was maintained to avoid Cartesian products.


Results

MetricBeforeAfter
Query count20-303
Response time200ms+65-95ms
ErrorsLazyInitializationExceptionNone

Takeaways

When using Coroutines in Spring MVC, you must be mindful of Hibernate Session and SecurityContext lifecycles.

Thread switching via withContext → Session/SecurityContext lost → Exception

Key learnings:

  1. Spring MVC + Coroutines conflicts with ThreadLocal-based infrastructure (Hibernate, Security). Thread switching must be carefully controlled.
  2. Virtual Threads are more natural for the same purpose. They’re compatible with existing ThreadLocal-based code while providing lightweight thread benefits.
Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.