수신자 선택 목록을 Redis Draft로 관리한 이유
스마트 메시징 시스템에서 여러 검색 결과를 오가며 선택한 수신자를 Redis Draft로 관리하고, Oracle과 하이브리드 페이징으로 조회한 구조를 정리했습니다.
스마트 메시징 시스템의 수신자 선택 화면에서는 검색 조건을 바꿔가며 여러 고객을 발송 대상으로 모을 수 있어야 했습니다. 예를 들어 태그로 고객을 검색한 뒤 일부를 선택하고, 다른 조건으로 다시 검색해 추가로 선택하는 흐름입니다.
문제는 이 선택 목록이 현재 검색 결과와 같은 기준으로 조회되지 않는다는 점이었습니다. 화면의 필터 조건은 계속 바뀌지만, 사용자가 누적해서 선택한 고객은 하나의 목록으로 남아야 합니다. 이 목록을 브라우저 상태나 Oracle 테이블에만 저장하는 방식은 선택 상태를 유지하고 페이지 단위로 보여주기에 적합하지 않았습니다.
실제 수신자 선택 화면은 다음 영상에서 확인할 수 있습니다.
이번 프로젝트에서는 Redis를 단순 캐시가 아니라 발송 준비 과정에서만 사용하는 임시 선택 저장소로 사용했습니다. 브라우저에는 전체 고객 ID를 보관하지 않고 draftId만 전달하고, Redis에는 선택된 고객 ID와 처리 상태를 저장했습니다. 고객의 상세 정보와 필터 검색은 기존 Oracle 조회를 사용하고, 선택 목록의 순서와 페이지 범위는 Redis가 담당하도록 역할을 나눴습니다.
선택 목록을 브라우저나 RDB에만 저장하기 어려웠던 이유
수신자 선택은 한 번의 검색으로 끝나지 않습니다. 사용자는 필터를 바꾸고, 여러 페이지를 이동하고, 이미 선택한 고객을 해제할 수 있습니다. 따라서 현재 검색 결과의 페이지를 그대로 저장하는 것만으로는 누적된 선택 목록을 표현할 수 없습니다.
선택된 고객 ID 전체를 브라우저에 보관하면 페이지 이동과 API 요청마다 큰 배열을 전송하거나 클라이언트 상태를 동기화해야 합니다. 반대로 선택된 고객을 RDB에 바로 저장하면 아직 발송이 확정되지 않은 임시 데이터가 영속 데이터와 섞이고, 사용자가 중간에 이탈했을 때 정리해야 할 데이터가 남습니다.
그래서 이번 기능에서는 다음과 같이 책임을 분리했습니다.

그림의 핵심은 저장소를 하나로 통합하는 것이 아니라, 목록의 위치와 순서는 Redis가, 고객의 상세 정보는 Oracle이 맡도록 나눈 것입니다. 브라우저는 전체 고객 ID를 들고 있지 않고 draftId만 전달합니다.
Redis에 저장하는 Draft는 발송이 확정되기 전까지의 작업 상태입니다. 발송 준비가 끝난 뒤에도 고객의 기준 정보까지 Redis에 옮기는 것은 목적에 맞지 않기 때문에, Redis에는 선택 목록을 구성하는 데 필요한 ID와 상태만 저장했습니다.
Redis 데이터 구조를 어떻게 나눴는가
1. Sorted Set: 선택된 고객 ID
선택된 수신자 목록에는 Redis Sorted Set을 사용했습니다. 프로젝트의 키 형식은 다음과 같습니다.
draft:recipient:{userId}:{draftId}
Sorted Set의 member에는 고객 ID를 저장하고, score에는 선택 순서를 나타내는 값을 저장했습니다.
member: 101 score: 0
member: 205 score: 1
member: 319 score: 2
Sorted Set을 선택한 이유는 하나의 자료구조로 여러 조회 요구를 처리할 수 있기 때문입니다.
ZADD: 고객 ID를 추가하고 순서 값을 저장합니다.ZRANGE: 순위 범위로 현재 페이지의 ID만 가져옵니다.ZCARD: 선택된 전체 고객 수를 확인합니다.ZREM: 선택 해제한 고객 ID를 제거합니다.ZSCORE: 현재 페이지의 고객이 Draft에 포함되어 있는지 확인합니다.
Redis 공식 문서에서도 Sorted Set을 score에 따라 정렬된 member 집합으로 설명하며, ZRANGE는 인덱스 범위의 member를 반환합니다. 이 특성 덕분에 별도의 목록 정렬이나 전체 데이터 로딩 없이 선택 목록의 일부를 조회할 수 있었습니다. Sorted sets 공식 문서, ZRANGE 명령어
2. Hash: Draft 처리 상태
대량 필터 결과를 Draft에 추가하는 작업은 한 번에 끝나지 않을 수 있습니다. 그래서 선택 목록과 처리 상태를 같은 자료구조에 섞지 않고 별도의 Hash로 저장했습니다.
draft:recipient:status:{userId}:{draftId}
status: PROCESSING | READY | FAILED
expectedCount: ...
totalCount: ...
고객 ID를 저장하는 Sorted Set은 목록 자체를 담당하고, Hash는 현재 목록이 추가되는 중인지, 처리가 끝났는지, 실패했는지를 나타냅니다. 프론트엔드는 Draft 생성 API가 반환한 draftId로 상태를 조회하고, READY가 된 뒤 선택 목록을 표시합니다.
이렇게 분리하면 목록 데이터와 처리 상태를 서로 다른 기준으로 읽을 수 있습니다. 목록의 전체 개수는 Sorted Set에서 가져오고, 비동기 적재 여부는 상태 Hash에서 확인합니다.
Redis와 Oracle을 함께 사용한 선택 목록 페이징
선택된 수신자 상세 화면은 Redis와 Oracle을 두 단계로 조회합니다.
- Redis Sorted Set에서 현재 페이지에 해당하는 고객 ID만 가져옵니다.
- 가져온 ID 목록으로 Oracle에서 고객 상세 정보를 조회합니다.
예를 들어 2페이지, 페이지 크기 20이라면 Redis에서 20번째부터 39번째에 해당하는 ID만 가져옵니다. 이후 이 20개의 ID를 customerIds로 전달해 Oracle 상세 정보를 조회합니다. 전체 선택 수는 DB 조회 결과가 아니라 Redis의 ZCARD 결과를 사용합니다.
// 1. Redis에서 현재 페이지의 고객 ID와 전체 개수를 조회합니다.
List<Long> pagedIds = draftService.getPagedIds(
userId,
draftId,
request.getPage(),
request.getSize()
);
long totalCount = draftService.getTotalCount(userId, draftId);
// 2. 이미 잘라낸 ID 목록만 고객 조회 API에 전달합니다.
request.setCustomerIds(pagedIds);
request.setPage(1);
PagedCustomerResponse response =
customerService.getCustomers(userId, request);
return ResponseEntity.ok(response.withTotalCount(totalCount));
이 코드에서 page=1은 Oracle에서 다시 1페이지를 조회한다는 의미가 아닙니다. Redis에서 이미 현재 화면에 필요한 ID 범위를 잘라냈기 때문에, 고객 조회 API에는 ID 목록만 전달하고 추가 페이지 계산은 하지 않도록 고정한 값입니다.
이 구조는 선택된 전체 고객의 상세 정보를 매번 Oracle에서 읽는 대신, 화면에 필요한 페이지 단위만 조회하도록 만듭니다. Redis는 선택 목록의 인덱스와 개수를 관리하고, Oracle은 고객의 실제 상세 정보를 제공하는 방식입니다.
대량 선택 결과를 Redis에 적재하는 과정
필터 결과가 많을 때 모든 고객 ID를 하나씩 Redis에 쓰면 Redis와 애플리케이션 사이의 네트워크 왕복 대기가 반복됩니다. 이를 줄이기 위해 프로젝트에서는 ID를 1,000개 단위로 나누고 executePipelined를 사용했습니다.
private static final int ZSET_WRITE_CHUNK_SIZE = 1000;
redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) {
ZSetOperations<String, Object> zset = operations.opsForZSet();
for (int offset = 0; offset < customerIds.size(); offset += 1000) {
int end = Math.min(offset + 1000, customerIds.size());
Set<TypedTuple<Object>> tuples = new LinkedHashSet<>();
for (int i = offset; i < end; i++) {
tuples.add(new DefaultTypedTuple<>(customerIds.get(i), startScore + i));
}
zset.add(key, tuples);
}
return null;
}
}, redisTemplate.getStringSerializer());
Pipeline은 여러 명령을 요청할 때 각 명령의 응답을 기다린 뒤 다음 명령을 보내는 흐름을 줄이는 데 사용합니다. Redis 공식 문서도 pipelining을 여러 명령을 묶어 네트워크 왕복 대기 비용을 줄이는 방식으로 설명합니다. 다만 Pipeline이 명령을 원자적으로 실행하거나 모든 작업을 하나의 트랜잭션으로 만든다는 뜻은 아닙니다. Redis pipelining 공식 문서
프로젝트에서는 Draft 생성 요청을 받은 뒤 다음 흐름으로 적재했습니다.
POST /api/campaigns/draft
└─ draftId 반환, status=PROCESSING
│
├─ 고객 ID 조회
├─ 1,000개 단위로 Redis Sorted Set 적재
├─ totalCount 계산
└─ status=READY 또는 FAILED
API가 대량 적재가 끝날 때까지 요청을 붙잡지 않고 먼저 draftId를 반환하기 때문에, 프론트엔드는 상태 API를 조회하며 준비 과정을 확인할 수 있습니다. 이때 Redis는 결과를 영구 보관하는 저장소가 아니라, 비동기 작업과 클라이언트 상태를 연결하는 임시 작업 공간으로 사용됩니다.
TTL과 명시적 삭제를 함께 적용한 이유
Draft는 발송이 확정되지 않으면 계속 보관할 이유가 없습니다. 프로젝트에서는 고객 ID Sorted Set과 상태 Hash에 모두 30분 TTL을 적용하고, 발송 취소나 완료 시에는 deleteDraft로 두 키를 함께 삭제합니다.
private static final Duration TTL = Duration.ofMinutes(30);
redisTemplate.expire(recipientKey, TTL);
redisTemplate.expire(statusKey, TTL);
TTL은 사용자가 이탈했을 때 남은 임시 데이터를 자동으로 정리하는 장치입니다. 동시에 사용자가 오래 작성한 Draft를 다시 열었을 때 만료될 수 있다는 UX 조건이기도 합니다. Redis의 EXPIRE는 키에 만료 시간을 설정하고, 만료된 키는 더 이상 일반적인 조회 대상이 되지 않습니다. EXPIRE 공식 문서
따라서 실제 서비스에서 Draft를 30분 이상 유지해야 한다면 조회나 수정 시 TTL을 갱신하는 touch API를 별도로 고려할 수 있습니다. 현재 프로젝트에서는 30분의 임시 보관과 명시적 삭제를 기본 정책으로 두었습니다.
현재 구조에서 확인한 한계
Redis를 도입했다고 해서 모든 문제가 사라지는 것은 아닙니다. 이번 구현에서 특히 확인한 부분은 선택 순서의 동시성입니다.
현재 추가 로직은 Sorted Set의 가장 큰 score를 조회한 뒤 1을 더해 다음 score를 만들고, 이후 ZADD를 실행합니다.
max score 조회 → +1 계산 → ZADD
두 요청이 같은 Draft에 동시에 들어오면 두 요청이 같은 max score를 읽을 수 있습니다. 고객 ID 자체는 Sorted Set에서 중복 저장되지 않지만, 선택 순서를 엄격하게 보장해야 하는 요구가 있다면 이 구간을 원자적으로 처리할 필요가 있습니다. 예를 들어 Redis 내 원자 연산, Lua 스크립트, 별도 sequence 관리, 요청 직렬화 등을 검토할 수 있습니다.
또한 현재 selected 탭에서 요청한 페이지가 비어 있을 때 응답의 totalCount도 0으로 반환하는 분기가 있습니다. 실제 선택 인원은 남아 있지만 잘못된 페이지를 요청한 경우 화면이 전체 선택 인원을 0명으로 오해할 수 있으므로, 빈 콘텐츠와 전체 개수를 분리해 반환하는 방향을 후속 개선 항목으로 남겼습니다.
이런 한계는 Redis의 Sorted Set이 동작하지 않아서 생긴 문제가 아니라, 현재 애플리케이션에서 score를 발급하고 빈 페이지를 응답하는 방식에서 발생한 문제입니다. 자료구조를 선택한 뒤에도 동시성, 만료 정책, 경계 조건을 애플리케이션 흐름과 함께 검토해야 했습니다.
마무리
이번 프로젝트에서 Redis의 역할은 “빠른 DB를 하나 더 추가하는 것”이 아니었습니다. 여러 검색 조건을 오가며 모은 수신자 선택 상태를 발송 확정 전까지 임시로 보관하고, 선택 목록을 페이지 단위로 다루기 위한 저장소가 필요했습니다.
그 결과 다음처럼 책임을 나눴습니다.
- Redis Sorted Set: 선택된 고객 ID, 선택 순서, 현재 페이지, 전체 개수
- Redis Hash: Draft 적재 상태와 진행 정보
- Oracle: 고객 상세 정보와 일반 필터 검색
draftId: 브라우저와 서버가 임시 선택 목록을 식별하는 키- TTL과 명시적 삭제: 사용하지 않는 Draft 정리
- Pipeline과 청크 처리: 대량 ID 적재 시 네트워크 대기와 메모리 사용량 조절
이 구조의 핵심은 Redis를 데이터베이스의 대체재로 사용한 것이 아니라, 선택 목록이라는 별도의 작업 상태에 맞는 자료구조를 선택했다는 점입니다. Sorted Set이 제공하는 범위 조회와 개수 조회를 이용하고, 상세 정보는 기존 RDB에서 가져오면서 각 저장소가 잘하는 일을 나누었습니다.
참고 자료
프로젝트 코드
- CampaignDraftServiceImpl.java
- CampaignDraftApiController.java
- CustomerApiController.java
- RedisConfig.java
공식 문서와 설계 기록
- Redis Sorted Sets 공식 문서
ZRANGE명령어ZADD명령어ZCARD명령어ZREM명령어EXPIRE명령어- Redis pipelining 공식 문서
- Redis Draft 기반 수신자 임시 저장 아키텍처
- Selected 탭 페이징: Redis-RDB 하이브리드 조회
관련 프로젝트: 스마트 메시징 시스템 저장소
