
현재 회사는 온라인 학습을 서비스하는 회사이다.
20년 넘은 회사인데다 B2B위주로 돌아가다보니 고객의 니즈를 충족시키기위한 커스텀으로 얼룩져있어 레거시 코드가 많이 잔존해있다.
매년 개선 사항으로 거론되는 것이 현재 진도 프로세스를 어떻게 개선할 것인가인데, 몇년동안 거론돼 왔음에도 불구하고 뾰족한 수를 찾지 못했다.
그러다 레디스와 카프카같은, 혹은 그에 준하는 좋은 기술들을 사용해볼 수 있지 않을까 하여 지피티와 여러방면으로 소통을 해 보았다.
.Net Framework 4.8
C# 7.3
동시 접속자 최대 14,000~15,000명
클라이언트(브라우저) AJAX OR 페이지 호출 > 진도관련 프로시저 직접 호출 > 로그 테이블 INSERT
현재 카프카를 쓰고있기도 하고, 대용량의 데이터를 일괄로 처리하기 위해 카프카도 괜찮을 것 같다는 생각을 했다.
우선 장단점을 살펴보았다.
운영 복잡(Broker, ZooKeeper, 클러스터 구성 필요)
컨슈머가 늘어나면 파티션 수 증가 필요 → 인프라 자원 증가
Kafka 자체는 무료; but 운영 / 인프라 / 학습 비용 높음
난잡하게 존재하는 프로세스를 하나로 합치는게 우선 큰 문제였고,
프로세스를 개선하려면 혼자서는 못하는 큰 작업인데 운영이 복잡하고 학습비용과 인프라 비용이 높다는 단점에서 당장의 해결책으로 쓰일 수 있을까라는 고민이 있었다.
내가 알고있는건 Redis였는데, GPT는 Redis Streams라는 것을 추천해 주었다.
💡
내가 하려고 했던 방식은
polling방식으로 계속 로그를 쌓은 뒤 일정 시간이 지나거나, 학습 종료 이벤트가 발생하면 로그성으로 저장된 학습데이터들을 집계하여 학습시간을 쌓는 것이었다.
적은 리소스로 운영이 가능하고 메모리 기반이어서 빠르다는 장점이 가장 내가 원하는 방식에 맞다고 생각이 들었다.
- Pub/Sub
일반적으로 Redis를 이용해 메시지를 Broadcasting 할 때는 pub/sub을 많이 사용한다.
하지만 이 방식은 publicsher가 메시지를 발행했을 때 subscriber가 존재하지 않거나 애플리케이션에 이슈가 발생하면 수신 여부에 관계없이 메시지가 휘발되는 단점이 있다.
여러개의 subscriber를 구동하면 모두에게 동일한 메시지를 발행해 데이터가 중복되는 이슈가 발생한다.
- Stream
휘발성이 아닌, Kafka의 offset개념처럼 마지막으로 수신한 record id를 저장하고 XADD, XREADGROUP, XACK, XPENDING, XCLAIM으로 이어지는 처리 프로세스를 통해 메시지를 컨트롤 할 수 있는 다양한 방법을 제공한다.
Consumer Group을 지원하기 때문에 producer가 발행한 메시지를 여러개의 consumer가 하나의 그룹을 형성해서 중복없이 순차적으로 병렬처리를 할 수 있다.
XACK명령어를 사용해 메시지 처리 여부를 확인할 수 있다. 일정시간 처리되지 못한 메시지들도 Pending Entries List를 이용해서 재처리할 수 있는 방법을 제공한다.
지금까지 나온 세가지 방식을 비교해 보자면,
| 항목 | 유실가능성 | 처리 성능 | 운영 복잡도 | 확장성 | 비용 |
|---|---|---|---|---|---|
| DB 직접 저장(현재방식) | 있음(네트워크 실패 등) | 낮음(DB 병목 발생) | 낮음 | 낮음 | DB부하로 간접비용 많음 |
| Redis Streams | 거의 없음(ACK, 재처리가능) | 높음(수만 TPS처리가능) | 중간 | 중간 | 저렴(단일 인스턴스로 충분) |
| Kafka | 없음 수준(복제, 로그저장) | 매우 높음(수십만 TPS이상) | 높음 | 높음 | 높음(클러스터/모니터링 등) |
Redis Streams의 유실가능성이 조금 걱정되긴하지만 재처리 기능이 있기에 구조만 단단하게 만들어 놓으면 유실가능성이 적을 것이라 예상되었다.
무엇보다 비용적인 측면에서 직접적으로 DB를 호출하는 것 보다 별개의 비동기방식으로 계산하는게 운영, 비용적인 측면에서 유리할거라고 생각했다.
자, 그럼 어떻게 전환해 가면 좋을까?
DB직접저장 (현재)
▶️ Redis Streams로 전환 (동접 14,000명 수준은 Redis Streams로 커버가능 / 메시지 유실도 거의 없음 XACK, XPENDING활용)
▶️ TPS 2~3만 이상, 장기 보존이나 분석 필요한 경우, 컨슈머 병렬성 증가 시점에 Kafka로 전환
Redis Streams 적용 구조
[Client]
⬇ (30초마다 요청)
[XADD] → Redis Stream
⬇
[Redis Consumer]
⬇
[DB INSERT] → day_log 테이블
⬇
[XACK] (정상 처리 완료)
🔅 클라이언트 요청은 Redis에 넣기만 하고 끝
→ Consumer가 나중에 DB비동기로 저장
→ 빠르고 유실없이 확장성도 높다.
Redis에서 Kafka로 전환하는 과정에서 일을 두번만드는 것은 아닐까 걱정이 된다.
회사가 성장세라고는 하지만 동접 2만의 시대는 아직 많이 멀은 것 같다.
그리고 1차 작업으로 레디스를 사용해 놓으면 카프카로 전환하는 작업은 어렵지 않게 할 수 있을 것이라 예상되었다.
나는 이 분석 문서들을 들고 회의에 들어갔다.
To Be Continue.....
TPS (Transactions Per Second) : 초당 처리되는 트랜잭션(작업, 요청)의 수
ex) 1초에 사용자 10명이 ‘진도 저장’ 요청 > 10TPS
ex 2) 1초에 Kafka가 50개의 메시지 수신 > 50TPS
ex 3) 1초에 Redis Streams에 200개의 XADD요청 > 200TPS
cf) QPS(Queries Per Second) : 초당 쿼리 처리 건수 => DB성능 측정에 사용
cf2) RPS (Requests Per Second) : 초당 HTTP 요청 수 => 웹 서버나 API 측정 시 사용
cf3) Throughput : 단위 시간당 처리량 => TPS와 유사하지만 넓은 개념
👉🏼 TPS가 높을수록 성능이 좋은 시스템
TPS 가 너무 낮으면 동시 사용자 증가 시 지연, 타임아웃, 장애가 발생할 수 있음.
TTL : 일반 Redis키에 적용되는 만료. Streams에는 직접 적용 불가. => 지정된 시간이 지나면 해당 키는 자동 삭제 됨.
XTRIM : Streams의 길이 제한 (메시지 자동 삭제용)
XACK : 메시지 소비 완료를 명시적으로 알림
샤딩 : Stream키를 나누거나 Redis Cluster구성으로 수평 확장
| 용어 | 의미 | 쉽게 설명 | 예시 |
|---|---|---|---|
| XADD | 메시지 추가 | Stream에 메시지를 넣는다 | XADD mystream * field1 value1 |
| XREAD | 메시지 읽기 | 단순 읽기 (비동기 아님) | XREAD COUNT 1 STREAMS mystream 0 |
| XREADGROUP | 메시지 읽기 (그룹 기반) | "소비자 그룹으로 읽는다 (Kafka의 Consumer Group과 유사)" | XREADGROUP GROUP mygroup myconsumer STREAMS mystream > |
| XACK | 메시지 처리 완료 | "메시지 잘 받았다고 알리기 (Kafka의 commit 비슷)" | XACK mystream mygroup 169...-0 |
| XPENDING | 처리 안 된 메시지 확인 | "아직 ACK 안 한 메시지들 목록 보기" | XPENDING mystream mygroup |
| XCLAIM | 메시지 재할당 | "다른 컨슈머가 처리 못한 메시지를 내가 가져오기" | XCLAIM mystream mygroup new-consumer 60000 169...-0 |
| XTRIM | 오래된 메시지 삭제 | "Stream 길이를 제한하여 오래된 것 잘라내기" | XTRIM mystream MAXLEN ~ 1000 |
| Consumer Group | 소비자 그룹 | 여러 컨슈머가 메시지를 나눠 처리할 수 있도록 묶은 그룹 | XGROUP CREATE mystream mygroup $ |
| Stream Key | Stream 이름 | 데이터가 들어가는 큐 같은 공간의 이름 | mystream, lecture_progress |
| 용어 | 의미 | 쉽게 설명 | 예시 |
|---|---|---|---|
| Producer | 메시지 발행자 | 메시지를 Kafka에 보내는 역할 | C# 클라이언트에서 Kafka에 메시지 전송 |
| Consumer | 메시지 소비자 | 메시지를 Kafka에서 읽어오는 역할 | 백엔드 서비스에서 메시지 읽음 |
| Topic | 메시지 카테고리 | 메시지를 구분하는 '폴더 이름' 같은 역할 | lecture-progress, video-log |
| Partition | 메시지 분산 단위 | 하나의 Topic을 나눠서 여러 Consumer가 병렬로 처리 가능 | Topic lecture-progress → 3개의 Partition |
| Offset | 메시지 위치 | 메시지가 Topic에서 몇 번째인지 위치값 | offset = 15면 15번째 메시지 |
| Consumer Group | 컨슈머 묶음 | 메시지를 나눠 처리할 수 있게 여러 Consumer를 하나로 묶은 구조 | |
| group_id = mygroup | |||
| Broker | Kafka 서버 | 메시지를 저장하고 전송하는 Kafka 인스턴스 | 최소 3개로 구성 권장 |
| ZooKeeper | 클러스터 관리 도구 | Kafka 2.x까지 필요, 클러스터 리더 선출 등 | Kafka 3.x부터는 선택 사항 |
| Retention | 메시지 보존 기간 | 메시지를 Kafka에 얼마나 오래 저장할지 설정 | 기본 7일, 설정 가능 |
| acks | 저장 확인 옵션 | 메시지를 Kafka에 보낼 때 "몇 개의 복제본에 저장되었는지" 확인하는 설정 | acks=all은 가장 안전함 |
| commit | 처리 완료 표시 | 메시지를 읽고 나서 "처리 완료"했다고 표시 (수동 또는 자동) | enable.auto.commit=false일 경우 직접 commit |
✔️ Kafka : 대형 물류 센터 (견고한 구조)
물건(메시지)을 입고 → 무조건 저장소(디스크)에 저장.
모든 입고 물품에 기록(로그)를 남기고 영구보관도 가능.
직원들(Consumer)이 박스를 나눠서, 파트별로 병렬로 처리
직원이 일을 안하면, 다른사람이 대신 처리할 수도 있음.
기록을 못 찾는 일은 거의 없음(복제, 로그 저장, 디스크 기반)
✔️ Redis : 빠른 택배 보관소 (가벼운 구조)
빠르게 물건(메시지)를 받고 → 메모리에 저장
오래 두면 자동으로 지워짐(보관기간 짧음)
단순하지만 빠르게 꺼내쓸 수 있음
속도는 매우 빠르나 오래 보관하거나 대규모 작업에는 부적합