입사한지 1달이 조금 넘어가는 시점에서, 처음으로 아키텍처 설계부터 배포까지 하나의 기능을 온전히 혼자 담당해 프로젝트를 진행하게 되었다. 바로 관리자 통계 기능 고도화이다.
기존의 통계는 항목도 얼마 없고 원본 테이블들을 조인 또는 서브쿼리로 조회하는 형식이라 성능이 매우 느렸다. 그래서 이번 업무에서 기획자분이 중요하게 강조하셨던 포인트는 1순위가 성능, 2순위가 실시간이였다.
처음 이 태스크를 받았을 때는 솔직히 막막했다. 요구사항을 듣고 설계해야 될 항목들을 보니 '이게 내 지식으로 가능할까?' 싶었다. 하지만 약 1달간의 삽질과 선배님들과의 토론들을 통해 나름의 해결책을 찾았고, 지금은 실제 서비스로 운영 중이다.
이 시리즈에서는 기술적인 구현 디테일보다는 "왜 이런 선택을 했는가"에 초점을 맞춰서 정리해보려 한다.
우리 회사의 플로우(Flow)는 협업 툴이다. 타 기업에서 이 플로우를 사용하고 있는데, 관리자 페이지에서 사용자들의 활동 통계를 보고 싶다는 요구사항이 들어왔다.
얼핏 보면 단순해 보인다. 그냥 DB에서 COUNT 해서 보여주면 되는 거 아닌가?
처음에는 나도 그렇게 생각했다. 기존 테이블들(게시글, 댓글, 채팅 등)에서 그냥 집계 쿼리 날리면 되지 않나?
그런데 요구사항을 자세히 보니 이게 그렇게 단순하지 않았다:
여기서 문제가 생긴다. 실시간 집계와 컬럼별 정렬을 동시에 만족시키기가 어렵다.
왜냐하면:
이걸 매번 하면? 수천 명 사용자 × 여러 테이블 JOIN × 집계 연산 = 조회만 수초 ~ 수십초
SELECT
user_id,
COUNT(CASE WHEN tmpl_type = '1' THEN 1 END) as post_cnt,
COUNT(CASE WHEN tmpl_type = '4' THEN 1 END) as task_cnt,
...
FROM colabo_commt
WHERE created_at BETWEEN ? AND ?
GROUP BY user_id
ORDER BY post_cnt DESC
LIMIT 20 OFFSET 0;
장점:
단점:
미리 집계된 결과를 저장하는 테이블을 만들어 두는 방식
CREATE TABLE admin_user_stats_daily (
stats_date VARCHAR(8), -- YYYYMMDD
user_id VARCHAR(100),
post_cnt INTEGER DEFAULT 0,
task_cnt INTEGER DEFAULT 0,
...
PRIMARY KEY (stats_date, ptl_id, chnl_id, use_intt_id, user_id)
);
장점:
단점:
솔직히 고민을 많이 했다. 옵션 A가 구현은 훨씬 간단하니까.
그래서 개발 RDS를 로컬로 덤프해와서 실제 집계와 조회쿼리들로 성능 측정도 많이 해봤다.
하지만 결정적으로 옵션 B를 선택한 이유는 기존 시스템에 영향을 주고 싶지 않았기 때문이다.
지금 플로우는 이 기업뿐 아니라 여러 고객사에서 운영 중이다. 통계 기능 하나 때문에 기존 핵심 테이블에 인덱스를 추가하거나, 무거운 집계 쿼리가 돌면서 서비스 전체가 느려지는 건 말이 안 된다고 생각했다.
"새로운 기능은 기존 기능을 해치지 않아야 한다"
이게 내가 이 프로젝트에서 가장 중요하게 생각한 원칙이었다.
집계 테이블을 쓰기로 했다. 그런데 집계 테이블의 단점인 "실시간성"은 어떻게 해결할까?
가장 단순한 방법. 매일 자정에 배치를 돌려서 어제의 활동을 집계 테이블에 넣는다.
00:00 ~ 23:59 사용자 활동
↓
다음날 00:30 배치 실행
↓
admin_user_stats_daily에 INSERT
이것만으로도 어느 정도는 해결된다. 어제까지의 통계는 완벽하게 조회 가능하니까.
하지만 문제가 있다.
관리자: "오늘 오후 3시에 A 사용자가 글을 5개 썼는데 왜 안 보이죠?"
나: "내일 새벽에 반영됩니다..."
관리자: "???"
이건 좀 아니다 싶었다. 관리자 입장에서는 "지금" 상황을 알고 싶은 건데, 하루 지연된 데이터를 보여주면 의미가 반감된다.
이 실시간성을 중심으로 기획자 분을 포함한 선임분들과 많이 토론을 했다.
그래서 결국 결정한 아키택처는
사용자 액션 → Redis (실시간 카운트)
↓
관리자 탭 클릭 → PostgreSQL로 이관 → 조회
이렇게 하면:
사실 처음에는 "그냥 PostgreSQL에 바로 UPSERT 하면 되지 않나?" 싶었다.
// 매 액션마다 DB UPSERT
INSERT INTO admin_user_stats_daily (...)
VALUES (...)
ON CONFLICT DO UPDATE SET post_cnt = post_cnt + 1;
안 될 건 없다. 근데 이게 초당 수십~수백 건씩 들어오면?
본 서비스(글 작성, 채팅 등)의 응답 속도에 영향을 줄 수밖에 없다.
반면 Redis는:
HINCRBY 하나로 원자적 증가그래서 Redis를 "버퍼"로 사용하기로 했다.
이쯤 되면 이런 생각이 들 수 있다.
"이벤트 처리라면서요? 그럼 Kafka나 RabbitMQ 같은 메시지 큐 써야 하는 거 아닌가요?"
나도 처음에 그랬다. 이벤트 드리븐 아키텍처, 메시지 큐, 비동기 처리... 요즘 핫한 키워드들이 머릿속을 스쳤다.
근데 선배분과 이야기를 나눠보니 생각이 바뀌었다.
선배가 이렇게 말해주셨다.
"학습 곡선과 생산성도 비용이다"
맞는 말이다. Kafka를 도입하면
이걸 통계 기능 하나 때문에 도입하는 게 맞나? 싶었다.
Kafka의 가장 큰 장점은 이벤트 영속성이다. 컨슈머가 죽어도 이벤트가 브로커에 남아있으니 재처리할 수 있다.
근데 우리는 이미 파일 백업 로직이 있다.
Java에서 HTTP 전송 실패 시 → 로컬 파일에 백업 → 배치에서 복구
물론 Kafka만큼 완벽하진 않지만 현재 규모에서는 충분하다고 생각했다.
| 비교 항목 | 현재 구조 (동기 HTTP) | Kafka 도입 |
|---|---|---|
| 인프라 추가 | 없음 | Kafka 클러스터 + Zookeeper |
| 운영 복잡도 | 낮음 | 높음 |
| 이벤트 유실 대응 | 파일 백업 → 배치 복구 | 자동 재처리 |
| 적합 규모 | DAU 10만 이하 | DAU 10만 이상 |
지금은 동기 HTTP + 파일 백업으로 간다. 클라우드 적용 시 비동기 전환을 고려한다.
이렇게 결론 내렸다. 선배도 동의했고.
그렇다고 확장성을 아예 안 생각한 건 아니다. 현재는 B2B용으로 동기처리 되어있지만 클라우드(DAU 7~8만)에 적용할 때는 이런 방식으로 전환할 계획만 생각해두었다.
// 현재: 동기 호출
public static void publishCommtCreated(...) {
sendToFlowStats("commt_created", payload); // 블로킹
}
// 전환 후: 스레드풀 비동기
private static final ExecutorService executor = new ThreadPoolExecutor(
4, 16, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static void publishCommtCreated(...) {
executor.submit(() -> {
try {
sendToFlowStats("commt_created", payload);
} catch (Exception e) {
backupToFile(...); // 기존 백업 로직 재활용
}
});
}
Kafka까지 갈 필요 없이, Java 내부 스레드풀만으로도 DAU 7~8만은 충분히 커버할 수 있다. 만약 그 이상으로 커지면? 그때 Kafka를 검토해도 늦지 않다는 결론이였다.
"필요할 때 복잡하게 만들자. 미리 복잡하게 만들지 말자."
이게 이번 프로젝트에서 배운 가장 큰 교훈 중 하나다.
여기서 솔직히 고민이 많았다.
"이거 너무 오버엔지니어링 아닌가?"
단순히 통계 보여주는 기능인데:
복잡도가 확 올라간다.
1. 레거시 시스템 보호
우리 회사 백엔드는 Tomcat + JSP 기반이다. 여기에 실시간 이벤트 처리 로직을 끼워넣으면:
별도 서버(flow-stats)로 분리하면 통계 쪽에 문제가 생겨도 본 서비스는 멀쩡하다.
2. 이미 Redis를 쓰고 있었다
새로 도입하는 게 아니라 이미 다른 캐시 용도로 Redis가 있었다. 인프라 추가 비용이 거의 없다는 뜻.
3. Kafka/RabbitMQ까지 가기엔 규모가 작다
처음에 메시지 큐 도입도 고민했다. 근데 현재 규모(사용자 수, 액션 빈도)를 생각하면 오버엔지니어링이다.
HTTP 동기 호출 + 실패 시 파일 백업이면 충분했다. 나중에 규모가 커지면 그때 전환해도 된다.
결국 이렇게 정리됐다:
┌─────────────────────────────────────────────────┐
│ 사용자 액션 │
│ (글 작성, 댓글, 채팅, 파일 업로드 등) │
└────────────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Tomcat (Java Backend) │
│ │
│ StatsEventPublisher.java │
│ - 각 액션 발생 시 이벤트 전송 │
│ - 타임아웃: 연결 1초, 응답 2초 │
│ - 실패 시 로컬 파일에 백업 │
└────────────────────────┬────────────────────────┘
│ HTTP POST
▼
┌─────────────────────────────────────────────────┐
│ flow-stats (Node.js) │
│ │
│ - 이벤트 수신 → Redis에 카운트 증가 │
│ - 매일 00:30 배치로 PostgreSQL 이관 │
│ - 관리자 요청 시 실시간 마이그레이션 │
└────────────────────────┬────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Redis │ │ PostgreSQL │
│ │ │ │
│ Hash: 카운터 │ │ 집계 테이블 │
│ Sorted Set: 목록 │ → │ (정렬/페이징) │
│ TTL: 3일 │ │ 파티션: 연도별 │
└────────────────────┘ └────────────────────┘
1편에서는 프로젝트 배경과 왜 이런 아키텍처를 선택했는지에 대해 정리해봤다.
정리하면서 느낀 건, 결국 모든 기술적 결정에는 트레이드오프가 있다는 것이다.
| 선택 | 얻은 것 | 잃은 것 |
|---|---|---|
| 별도 집계 테이블 | 조회 성능, 기존 시스템 보호 | 구현 복잡도 |
| Redis 버퍼링 | 쓰기 성능, 실시간 집계 | 데이터 유실 가능성 (TTL) |
| 별도 Node.js 서버 | 장애 격리, 독립 배포 | 인프라 복잡도 |
| 배치 + 실시간 이중 구조 | 완벽한 실시간 조회 | 구현/운영 복잡도 |
완벽한 정답은 없었다. 그냥 현재 상황에서 최선의 균형점을 찾으려고 노력했다.
특히 이제는 진짜 자바스크립트 공부 좀 해야겠다고 느끼게 된 프로젝트였다..
다음 편에서는 Redis 키 설계와 동시성 제어에 대해 다뤄보겠다. Hash와 Sorted Set을 왜 혼용했는지, 분산 락은 왜 필요했고 왜 Redlock 같은 복잡한 알고리즘 대신 단순한 SET NX를 선택했는지 등을 정리해볼 예정이다.
다음 편: Redis 키 설계 & 동시성 제어
2번 선택 방안에서 "옵션 A: 그냥 매번 집계"
에서 프론트 화면에서는 user의 간단한 정보만 보여주고
관리자가 해당 유저를 클릭하면 where 조건문에 user_id를 추가 후, 해당 유저의 정보만 조회하는 방식으로는 안되는 상황이었나요?
이렇게 하는 경우에는
1. PK 인덱스인 user_id를 이용해서 페이징된 user의 정보만 가져온 후,
2. 해당 user의 정보 쿼리만 재실행해서 유저의 정보만 view에서 보여준 후,
3. 해당 view에서 관리자가 특정 유저를 클릭하면 그 유저의 집계 쿼리만 진행하면
무거운 쿼리가 아니고, 사용자별로 집계 데이터를 보여주니 요구사항은 만족 될 것 같아서요
결론은 처음부터 모든 유저를 집계하지 않고, 유저의 간단한 정보만 보여준 후 관리자가 특정 유저를 클릭하면 해당 유저만 집계하는 방식은 요구사항을 만족하지 못했을까요?