장단점
스프링부트(Spring Boot)와 JPA(Hibernate) 환경에서 **스트리밍 쿼리(Streaming Query)**는 대용량 데이터를 처리할 때 메모리 문제를 해결하기 위해 사용하는 매우 중요한 기술입니다.
일반적으로 데이터를 List<T> 형태로 조회하면 모든 결과가 애플리케이션 메모리(RAM)에 적재되지만, 스트리밍 쿼리는 데이터베이스 커서(Cursor)를 사용하여 필요한 만큼만 조금씩 가져와 처리하는 방식입니다.
요청하신 스트리밍 쿼리의 특징과 장단점을 정리해 드립니다.
1. 스트리밍 쿼리의 주요 특징
스트리밍 쿼리는 Java 8의 Stream<T> API를 활용하여 DB 결과를 물 흐르듯 처리합니다.
- 커서(Cursor) 기반 동작: 데이터베이스 내부의 커서를 이동시키며 데이터를 순차적으로 읽어옵니다.
- 지연 로딩(Lazy Loading)과 유사:
Stream객체를 생성한 시점에는 쿼리만 실행되고, 실제 데이터는stream.forEach()나next()등을 호출할 때 가져옵니다. - 트랜잭션 의존성: 스트림 처리가 끝날 때까지 DB 연결(Connection)을 유지해야 하므로, 반드시 활성화된 트랜잭션 안에서 수행되어야 합니다.
2. 장점 (Pros)
대용량 데이터 처리(Batch 작업, 엑셀 다운로드 등)에서 강력한 이점을 가집니다.
- 메모리 효율성 (OOM 방지):
- 가장 큰 장점입니다. 수백만 건의 데이터를 조회하더라도 전체를 메모리에 올리지 않고, 일정 크기(Fetch Size)만큼만 메모리에 유지하므로
OutOfMemoryError를 예방할 수 있습니다.
- 응답 속도 향상 (초기 지연 감소):
- 전체 데이터를 로딩할 때까지 기다리지 않고, 첫 번째 데이터가 도착하는 즉시 처리를 시작할 수 있어 처리 시간을 단축할 수 있습니다.
- 확장성:
- 애플리케이션 서버의 메모리 스펙을 무리하게 늘리지 않아도 대규모 데이터 처리가 가능합니다.
3. 단점 (Cons)
모든 상황에 스트리밍이 좋은 것은 아닙니다. 잘못 사용하면 오히려 시스템 장애를 유발할 수 있습니다.
- DB 커넥션 점유 시간 증가:
- 스트림 처리가 끝날 때까지 DB 연결을 놓지 않습니다. 만약 애플리케이션 로직(데이터 가공, 외부 API 호출 등)이 느리다면, DB 커넥션 풀(Connection Pool)이 고갈되어 다른 요청을 처리하지 못할 수 있습니다.
- 트랜잭션 관리의 복잡성:
- 반드시
@Transactional(readOnly = true)환경이 필요하며, 사용 후에는 반드시 자원(Stream)을 닫아주어야 합니다(try-with-resources 권장).
- 네트워크 오버헤드 가능성:
- 적절한
Fetch Size를 설정하지 않으면(예: 1건씩 가져오기), DB와 애플리케이션 간의 통신 횟수가 폭증하여 오히려 성능이 저하될 수 있습니다.
- 타임아웃 위험:
- 대용량 처리 시간이 길어지면 트랜잭션 타임아웃이나 DB 세션 타임아웃이 발생할 수 있습니다.
4. 사용 시 주의사항 (Best Practices)
성공적인 스트리밍 쿼리 구현을 위해 다음 사항들을 반드시 고려해야 합니다.
💡 핵심 설정: Fetch Size MySQL이나 PostgreSQL 등 DB 드라이버마다 기본 동작이 다릅니다. 단순히
Stream을 반환한다고 스트리밍이 되는 것이 아닙니다.
- MySQL: 기본적으로 드라이버가 모든 데이터를 메모리에 로드해버립니다. 이를 방지하려면
FetchSize를Integer.MIN_VALUE로 설정하거나, 커스텀 설정을 통해 스트리밍 모드를 강제해야 합니다. - 자원 해제: Stream은
AutoCloseable을 구현하므로 반드시try-with-resources블록을 사용하여 닫아야 합니다.
코드 예시:
@Transactional(readOnly = true)
public void processLargeData() {
// try-with-resources로 스트림 자동 종료 보장
try (Stream<User> userStream = userRepository.streamAllByStatus("ACTIVE")) {
userStream.forEach(user -> {
// 비즈니스 로직 수행 (가벼운 작업 권장)
csvWriter.write(user);
// 주의: 여기서 엔티티를 수정해도 영속성 컨텍스트 관리에 주의가 필요함
// (대량 처리 시 entityManager.detach() 등으로 메모리 비우기 필요)
});
}
}
스트리밍 쿼리는 **“메모리는 아끼지만, DB 연결 시간은 길게 가져가는 전략”**이라고 요약할 수 있습니다.
리액티브 고려한다면…
스프링 부트에서 대용량 데이터를 처리할 때 **리액티브 스택(Spring WebFlux + R2DBC)**은 기존의 블로킹 스트리밍(JPA/JDBC)과는 다른 차원의 접근 방식을 제공합니다.
결론부터 말씀드리면, **“동시 접속자가 많고, 데이터를 클라이언트에게 실시간으로 흘려보내야 하는 경우”**에는 리액티브가 압도적으로 유리하지만, **“단순한 배치 작업이나 복잡한 비즈니스 로직이 얽힌 경우”**에는 학습 곡선과 복잡도 대비 효율이 떨어질 수 있습니다.
리액티브(WebFlux) 환경에서 대용량 DB Fetch 시의 특징과 장단점을 분석해 드립니다.
1. 리액티브 처리의 핵심: Backpressure (배압)
리액티브 스트림의 가장 큰 차별점은 Backpressure(배압) 기능입니다.
- 기존 방식(Push): DB가 데이터를 읽는 속도가 애플리케이션이 처리하는 속도보다 빠르면, 애플리케이션 메모리에 데이터가 쌓이다가
OutOfMemory가 발생할 수 있습니다. - 리액티브 방식(Pull): 애플리케이션(Subscriber)이 자신이 처리할 수 있는 양만큼만 DB(Publisher)에 요청합니다. (“지금 10개만 줘, 다 처리하면 또 달라고 할게”)
- 효과: 대용량 데이터 조회 시 애플리케이션의 메모리 안정성이 훨씬 뛰어납니다.
2. 블로킹(JPA) vs 리액티브(R2DBC) 비교
대용량 처리에 있어 두 방식은 스레드 모델과 데이터 흐름에서 큰 차이가 있습니다.
| 특징 | 기존 JPA 스트리밍 (Blocking) | 리액티브 R2DBC (Non-Blocking) |
|---|---|---|
| 스레드 사용 | Thread-per-Request (요청당 스레드 하나 점유) | Event Loop (소수의 스레드로 처리) |
| 데이터 흐름 | List<T> 또는 Stream<T> |
Flux<T> |
| DB 드라이버 | JDBC (Blocking) | R2DBC (Non-Blocking) |
| 주요 이점 | 구현이 쉽고, 디버깅이 편함 | 적은 리소스로 높은 동시성 처리 가능 |
| ORM 기능 | 강력함 (JPA Dirty Checking, Lazy Loading) | 제한적 (JPA의 고급 기능 사용 불가) |
3. 리액티브 방식의 장점
- 시스템 리소스 효율 극대화 (Non-Blocking I/O):
- JPA 스트리밍은 데이터를 가져오는 동안 스레드가 대기(Block) 상태가 되지만, 리액티브는 DB I/O를 기다리는 동안 스레드가 다른 요청을 처리합니다.
- 따라서 동시에 수많은 사용자가 대용량 조회를 요청해도 서버가 뻗지 않고 버틸 수 있습니다.
- Full Streaming (DB → Server → Client):
- DB에서 읽은 데이터를 서버 메모리에 모으지 않고, 즉시 HTTP Response(예: SSE, NDJSON)로 클라이언트에게 흘려보낼 수 있습니다.
- 사용자는 전체 로딩을 기다리지 않고 첫 번째 데이터부터 바로 화면에서 볼 수 있습니다.
- 메모리 오버플로우 원천 차단:
- Backpressure를 통해 소비 속도에 맞춰 DB 조회를 조절하므로 안정적입니다.
4. 리액티브 방식의 단점 (치명적일 수 있음)
- R2DBC는 JPA가 아닙니다:
- JPA의 편리한 기능(영속성 컨텍스트, 지연 로딩, 1차 캐시 등)이 없습니다.
- 복잡한 연관 관계(Join) 매핑을 직접 처리해야 하거나 구현 난이도가 높습니다.
- 디버깅과 구현의 어려움:
- 비동기 로직 특성상 흐름을 추적하기 어렵고, 기존 명령형 프로그래밍 스타일에 익숙하다면 코드를 짜는 것 자체가 고역일 수 있습니다.
- 생태계 성숙도:
- JDBC 드라이버만큼 성숙하지 않아 특정 DB의 고급 기능을 지원하지 않거나, 안정성 이슈가 드물게 발생할 수 있습니다.
5. 언제 무엇을 써야 할까요? (가이드라인)
상황에 따라 추천하는 기술 스택이 다릅니다.
A. 리액티브(WebFlux + R2DBC)를 써야 하는 경우
- MSA 게이트웨이 또는 중계 서비스: 데이터를 가공 없이 빠르게 토스해야 할 때.
- 실시간 대시보드/피드: 수많은 사용자가 동시에 대량의 로그나 이력 데이터를 조회해야 할 때.
- 메모리가 매우 적은 컨테이너 환경: 적은 리소스로 처리량을 높여야 할 때.
B. 기존 JPA 스트리밍을 유지해야 하는 경우
- 배치(Batch) 애플리케이션: 밤에 혼자 도는 작업이라면 동시성 처리가 필요 없으므로 JPA가 훨씬 생산성이 높습니다.
- 복잡한 비즈니스 로직: 데이터를 조회해서 계산하고, 상태를 변경하고, 다시 저장하는 복잡한 과정이 있다면 JPA의 영속성 관리가 필수적입니다.
- 팀의 숙련도: 팀원들이 Reactive Streams에 익숙하지 않다면 유지보수 비용이 폭발합니다.
요약
대용량 Fetch 시 리액티브는 **“속도 자체를 빠르게 하는 것이 아니라, 동시에 많은 요청을 효율적으로 처리하고 메모리를 보호하는 기술”**입니다.
try-with-resources
try-with-resources는 자바 7부터 도입된 문법으로, **“사용이 끝난 자원(Stream, File, DB Connection 등)을 자동으로 닫아주는(close) 기능”**입니다.
스프링 부트(JPA)에서 스트리밍 쿼리를 사용할 때 DB 연결을 안전하게 반환하기 위해 반드시 사용해야 합니다.
가장 쉬운 사용법과 예시를 보여드리겠습니다.
1. 기본 문법 구조
기존의 try-catch-finally에서 finally 블록에 close()를 넣던 번거로움을 없앴습니다.
// try 옆 괄호 ( ) 안에 자원을 선언합니다.
try (자원_타입 변수명 = 자원_생성()) {
// 여기서 변수를 사용합니다.
// 이 블록( { } )이 끝나면 자동으로 변수.close()가 실행됩니다.
} catch (Exception e) {
// 예외 처리
}
2. JPA 스트리밍 쿼리 실전 예제
JPA에서 Stream<T>을 반환받을 때 가장 전형적인 패턴입니다.
시나리오: MemberRepository에서 회원 전체를 스트림으로 가져와서 처리하는 상황
① Repository (Stream 메서드 정의)
public interface MemberRepository extends JpaRepository<Member, Long> {
// QueryHint로 FetchSize 설정 (선택사항이지만 대용량 처리 시 권장)
@QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE, value = "1000"))
@Query("select m from Member m")
Stream<Member> streamAllMembers();
}
② Service (try-with-resources 사용)
여기가 핵심입니다. Stream을 여는 코드를 try (...) 안에 넣습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(readOnly = true) // 1. 스트림은 트랜잭션 안에서만 살아있습니다.
public void processAllMembers() {
// 2. try ( ) 괄호 안에서 스트림을 엽니다.
try (Stream<Member> memberStream = memberRepository.streamAllMembers()) {
// 3. 스트림 사용 (데이터가 물 흐르듯 들어옵니다)
memberStream.forEach(member -> {
System.out.println("회원 처리: " + member.getName());
// 여기서 엑셀 쓰기나 비즈니스 로직 수행
});
}
// 4. 여기서 자동으로 memberStream.close()가 호출되며 DB 커넥션이 반환됩니다.
// 예외가 터져도 close()는 무조건 보장됩니다.
}
}
3. 왜 이렇게 해야 하나요?
만약 try-with-resources를 쓰지 않고 일반적인 변수처럼 선언해서 쓰면 다음과 같은 끔찍한 일이 벌어집니다.
- DB 커넥션 누수: 스트림을 다 쓰고 나서
close()를 깜빡하면, 해당 DB 연결(Connection)이 반환되지 않고 계속 점유 상태로 남습니다. - 서버 장애: 시간이 지나면 커넥션 풀(HikariCP)의 모든 연결이 고갈되어 서버가 멈춥니다(Deadlock).
4. 주의할 점: “여기서 끝내야 합니다”
스트림을 Service 계층 밖으로(Controller 등으로) 리턴해버리면 안 됩니다.
- 이유:
try블록을 벗어나는 순간close()가 실행되어 DB 연결이 끊깁니다. Controller에서 데이터를 읽으려 하면Session closed에러가 발생합니다. - 해결: Service 메서드 안에서(
try블록 안에서) 데이터 가공, 파일 변환 등 모든 처리를 끝내야 합니다.