Spring WebFlux + R2DBC를 운영하며 겪은 5가지 문제
WebFlux와 R2DBC를 같이 쓰면 요청부터 DB까지 논블로킹 흐름으로 맞출 수 있다. 외부 API 호출이 많은 서버에서는 모델이 일관된다. 그런데 운영하며 부딪힌 문제는 “비동기라서 어렵다”보다 다른 쪽에 가까웠다.
이 글을 쓰는 이유는 스택 전환을 검토하기 전에 이전 내용을 정리해두기 위해서다. 팀 안에서는 TypeScript와 JavaScript에 익숙한 사람이 늘었고, AI 도구가 코드를 읽고 고치는 비중도 커졌다. Spring 기반 코드를 계속 더 AI 친화적으로 다듬을지, 팀이 더 빠르게 다룰 수 있는 런타임으로 옮길지 보려면 먼저 지금 스택에서 겪은 문제를 기록해야 했다.
그래서 이 글은 WebFlux와 R2DBC가 좋다거나 나쁘다는 결론이 아니다. 운영 데이터, 외부 응답, DB 제약이 코드의 기대와 어긋났던 순간을 남기는 기록에 가깝다.
문제가 드러난 경계
Spring WebFlux
외부 API 호출과 응답 처리의 기본 흐름
R2DBC
MySQL row를 Kotlin 객체로 읽는 비동기 데이터 접근
MySQL
JSON 값, 인덱스, nullable 제약이 실제 규칙을 드러낸 곳
Kotlin
nullable 타입과 !! 사용이 운영 데이터와 충돌한 곳
1. JSON 컬럼은 코드보다 오래 산다
설정값을 JSON 컬럼에 저장하고, R2DBC에서 읽을 때 Jackson converter로 Kotlin 데이터 클래스로 바꾸는 구조가 있었다. 운영 데이터 안에는 예전 필드가 남아 있었고, 현재 데이터 클래스에서는 그 필드를 제거한 뒤였다. row를 읽는 순간 UnrecognizedPropertyException이 나면서 API가 실패했다.
처음에는 “필드 하나 없어진 것”처럼 보였다. 하지만 DB의 JSON은 배포와 함께 자동으로 바뀌지 않는다. 오래된 값과 새 값이 꽤 오래 섞일 수 있다.
이후 JSON 컬럼 구조를 바꿀 때는 새 코드가 예전 데이터를 읽을 수 있는지 먼저 본다. 필요하면 ignoreUnknown 같은 완충 장치를 두고, 데이터 정리는 별도 작업으로 뺀다.
2. !!는 운영 규칙이 아니다
회차형 결제 데이터에서 총 회차 수는 반드시 있어야 했다. 그런데 DB 컬럼은 nullable이었고, 코드에서는 record.sessionCount!!처럼 non-null이라고 가정했다. 운영 데이터에 null이 남아 있자 DTO를 만드는 시점에 실패했다.
이 문제는 null 처리 한 줄로 끝낼 수도 있었다. ?: 0을 넣으면 당장 에러는 멈춘다. 하지만 회차형 데이터에서 null이 나오면 안 된다는 규칙이 분명하다면 기본값으로 삼키는 건 다른 문제를 남긴다.
// 보기에 편한 코드
sessionCount = record.sessionCount!!
// 규칙을 드러내는 코드
val sessionCount = requireNotNull(record.sessionCount) {
"session count is required"
}쓰기 경로에서 회차형이면 세션 수를 반드시 받게 만들고, 읽기 경로에서는 null을 의미 있는 예외로 바꿨다. Kotlin의 타입은 코드 안의 약속일 뿐이다. 운영 데이터까지 같은 약속을 지키게 하려면 저장 경로와 DB 제약까지 같이 봐야 했다.
3. 200 OK 이후에도 다운로드는 실패할 수 있다
외부 API에서 파일을 받아 다시 저장하는 흐름도 있었다. 응답 상태는 200 OK였는데 WebClient가 본문을 읽다가 DataBufferLimitException을 냈다. 기본 버퍼 제한인 256KB보다 응답 파일이 컸기 때문이다.
처음에는 버퍼 크기를 올리는 방법이 가장 빨랐다. 작은 파일만 온다면 그것도 충분하다. 하지만 파일 다운로드라면 언젠가 더 큰 응답이 온다. 버퍼를 올려도 파일을 메모리에 한 번에 모으는 구조는 그대로 남는다.
- 1외부 API는 200 OK 반환
- 2WebClient가 body를 메모리에 수집
- 3기본 버퍼 256KB 초과
- 4다운로드 후속 작업 실패
결론은 용도에 따라 갈렸다. 크기가 제한된 JSON 응답은 버퍼 상향으로 충분할 수 있다. 파일은 DataBuffer 흐름을 그대로 흘려보내는 쪽이 맞다.
4. JWT 길이는 스키마 밖에서 변한다
토큰 저장소에서도 문제가 있었다. access token 길이가 늘면서 기존 문자열 컬럼이 부족해졌고, TEXT로 바꾸려 하자 MySQL이 인덱스 길이 문제를 냈다. 원문 토큰 컬럼에 unique index가 걸려 있었기 때문이다.
여기서 선택지는 세 가지였다. 급하면 문자열 컬럼 상한을 넓힌다. 원문 토큰으로 조회하지 않는다면 unique index를 제거한다. 정확한 조회나 중복 방지가 필요하면 원문 대신 해시 컬럼을 둔다.
이 이슈는 WebFlux나 R2DBC만의 문제는 아니다. 다만 인증 방식이나 외부 정책 변화가 DB 스키마까지 밀고 들어온다는 걸 다시 확인했다. 토큰을 저장한다면 “얼마나 긴가”보다 “원문으로 조회해야 하는가”를 먼저 물어야 했다.
5. R2DBC는 가볍지만 가드레일도 적다
R2DBC를 쓰며 좋았던 점은 분명했다. WebFlux, 코루틴, 외부 API 호출, DB 접근이 같은 방향으로 맞는다. 블로킹 JPA를 섞지 않고 요청 흐름을 끝까지 비동기로 유지하기 쉽다.
대신 익숙한 가드레일 일부가 사라졌다. IntelliJ의 JPA 기반 스키마 검증은 Spring Data R2DBC 엔티티에 그대로 기대기 어렵다. 운영 DB에는 없는 컬럼을 코드가 읽으려 할 때도 실행 중 쿼리에서 드러났다. 로컬에서는 MySQL R2DBC 드라이버가 runtimeClasspath에 없고 pool 드라이버만 잡히는 일도 있었다.
그래서 다른 안전망을 의식적으로 둬야 했다. 마이그레이션 순서를 배포와 분리해 확인하고, 가능하면 스키마 기반 코드 생성이나 최소 쿼리 스모크 테스트를 둔다.
돌아보면 다섯 가지 문제는 서로 다른 에러처럼 보였지만 모양은 비슷했다. 코드는 새 규칙을 알고 있었고, 운영 데이터나 외부 시스템은 아직 그 규칙을 몰랐다.
스택을 바꾼다고 이런 문제가 사라지지는 않는다. JSON 스키마, DB 제약, 외부 API 응답, 토큰 저장 방식은 런타임이 바뀌어도 따라온다. 그래서 이 정리는 WebFlux와 R2DBC를 떠나기 위한 변명이 아니라, 옮기기 전에 잃지 말아야 할 운영 지식에 가깝다.