레거시 시스템 위에서 아키텍처 설계하기

Hunn·2025년 12월 28일

회사

목록 보기
23/28
post-thumbnail

들어가며

입사한지 1달이 조금 넘어가는 시점에서, 처음으로 아키텍처 설계부터 배포까지 하나의 기능을 온전히 혼자 담당해 프로젝트를 진행하게 되었다. 바로 관리자 통계 기능 고도화이다.

기존의 통계는 항목도 얼마 없고 원본 테이블들을 조인 또는 서브쿼리로 조회하는 형식이라 성능이 매우 느렸다. 그래서 이번 업무에서 기획자분이 중요하게 강조하셨던 포인트는 1순위가 성능, 2순위가 실시간이였다.

처음 이 태스크를 받았을 때는 솔직히 막막했다. 요구사항을 듣고 설계해야 될 항목들을 보니 '이게 내 지식으로 가능할까?' 싶었다. 하지만 약 1달간의 삽질과 선배님들과의 토론들을 통해 나름의 해결책을 찾았고, 지금은 실제 서비스로 운영 중이다.

이 시리즈에서는 기술적인 구현 디테일보다는 "왜 이런 선택을 했는가"에 초점을 맞춰서 정리해보려 한다.


1. 프로젝트 배경: 무엇을 왜 만들어야 했나

기존 상황

우리 회사의 플로우(Flow)는 협업 툴이다. 타 기업에서 이 플로우를 사용하고 있는데, 관리자 페이지에서 사용자들의 활동 통계를 보고 싶다는 요구사항이 들어왔다.

  • 사용자별로 오늘 글을 몇 개 썼는지
  • 어떤 프로젝트에 새로 참여했는지
  • 파일 업로드/다운로드는 얼마나 했는지
  • 프로젝트별로 활동량이 어떤지

얼핏 보면 단순해 보인다. 그냥 DB에서 COUNT 해서 보여주면 되는 거 아닌가?

문제는 "실시간"이었다

처음에는 나도 그렇게 생각했다. 기존 테이블들(게시글, 댓글, 채팅 등)에서 그냥 집계 쿼리 날리면 되지 않나?

그런데 요구사항을 자세히 보니 이게 그렇게 단순하지 않았다:

  1. 실시간성: 관리자가 페이지를 열면 "지금 현재" 기준의 통계가 보여야 함
  2. 정렬 기능: 각 컬럼별로 정렬이 가능해야 함 (게시글 많은 순, 파일 업로드 많은 순 등)
  3. 페이징: 사용자가 수천 명일 수 있으니 페이징 필수
  4. 기간 필터: 오늘, 이번 주, 이번 달, 커스텀 기간 등

여기서 문제가 생긴다. 실시간 집계컬럼별 정렬을 동시에 만족시키기가 어렵다.

왜냐하면:

  • 실시간 집계 = 매번 원본 테이블에서 COUNT
  • 컬럼별 정렬 = 집계 결과를 기준으로 ORDER BY
  • 페이징 = LIMIT OFFSET

이걸 매번 하면? 수천 명 사용자 × 여러 테이블 JOIN × 집계 연산 = 조회만 수초 ~ 수십초


2. 첫 번째 선택: PostgreSQL 직접 조회 vs 별도 집계 테이블

옵션 A: 그냥 매번 집계

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;

장점:

  • 구현이 간단함
  • 항상 최신 데이터

단점:

  • 쿼리 성능이 사용자 수, 데이터 양에 비례해서 느려짐
  • 이미 운영 중인 테이블에 부하를 줌
  • 인덱스 추가하면 기존 INSERT/UPDATE 성능에 영향

옵션 B: 별도 집계 테이블 생성

미리 집계된 결과를 저장하는 테이블을 만들어 두는 방식

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)
);

장점:

  • 조회 성능 보장 (이미 집계된 데이터)
  • 기존 테이블 부하 없음
  • 정렬, 페이징이 자유로움

단점:

  • 집계 테이블을 채워넣는 로직이 필요
  • 실시간성을 어떻게 보장할 것인가?

나의 선택: 옵션 B

솔직히 고민을 많이 했다. 옵션 A가 구현은 훨씬 간단하니까.
그래서 개발 RDS를 로컬로 덤프해와서 실제 집계와 조회쿼리들로 성능 측정도 많이 해봤다.

하지만 결정적으로 옵션 B를 선택한 이유는 기존 시스템에 영향을 주고 싶지 않았기 때문이다.

지금 플로우는 이 기업뿐 아니라 여러 고객사에서 운영 중이다. 통계 기능 하나 때문에 기존 핵심 테이블에 인덱스를 추가하거나, 무거운 집계 쿼리가 돌면서 서비스 전체가 느려지는 건 말이 안 된다고 생각했다.

"새로운 기능은 기존 기능을 해치지 않아야 한다"

이게 내가 이 프로젝트에서 가장 중요하게 생각한 원칙이었다.


3. 그럼 실시간성은 어떻게?

집계 테이블을 쓰기로 했다. 그런데 집계 테이블의 단점인 "실시간성"은 어떻게 해결할까?

처음 생각: 배치로 돌리자

가장 단순한 방법. 매일 자정에 배치를 돌려서 어제의 활동을 집계 테이블에 넣는다.

00:00 ~ 23:59 사용자 활동
          ↓
다음날 00:30 배치 실행
          ↓
admin_user_stats_daily에 INSERT

이것만으로도 어느 정도는 해결된다. 어제까지의 통계는 완벽하게 조회 가능하니까.

하지만 문제가 있다.

관리자: "오늘 오후 3시에 A 사용자가 글을 5개 썼는데 왜 안 보이죠?"
나: "내일 새벽에 반영됩니다..."
관리자: "???"

이건 좀 아니다 싶었다. 관리자 입장에서는 "지금" 상황을 알고 싶은 건데, 하루 지연된 데이터를 보여주면 의미가 반감된다.

Redis를 중간에 끼워넣자

이 실시간성을 중심으로 기획자 분을 포함한 선임분들과 많이 토론을 했다.
그래서 결국 결정한 아키택처는

  1. 사용자가 액션을 할 때마다 Redis에 카운트를 증가시킨다
  2. 관리자가 통계 페이지를 열면, Redis 데이터를 PostgreSQL로 즉시 이관한다.
  3. 그 다음 PostgreSQL에서 조회한다
사용자 액션 → Redis (실시간 카운트)
                  ↓
관리자 탭 클릭 → PostgreSQL로 이관 → 조회

이렇게 하면:

  • 실시간성 확보: Redis는 항상 최신 상태
  • 조회 성능 확보: PostgreSQL에서 정렬/페이징
  • 기존 시스템 영향 없음: 원본 테이블 안 건드림

왜 하필 Redis인가?

사실 처음에는 "그냥 PostgreSQL에 바로 UPSERT 하면 되지 않나?" 싶었다.

// 매 액션마다 DB UPSERT
INSERT INTO admin_user_stats_daily (...) 
VALUES (...) 
ON CONFLICT DO UPDATE SET post_cnt = post_cnt + 1;

안 될 건 없다. 근데 이게 초당 수십~수백 건씩 들어오면?

  • 매번 DB 커넥션
  • 매번 트랜잭션
  • PK 충돌 체크 + UPDATE

본 서비스(글 작성, 채팅 등)의 응답 속도에 영향을 줄 수밖에 없다.

반면 Redis는:

  • HINCRBY 하나로 원자적 증가
  • 메모리 기반이라 응답 속도 1ms 미만
  • 실패해도 본 서비스에 영향 최소화 (타임아웃 1~2초면 그냥 스킵)

그래서 Redis를 "버퍼"로 사용하기로 했다.


4. Kafka 써야 하는 거 아닌가요?

이쯤 되면 이런 생각이 들 수 있다.

"이벤트 처리라면서요? 그럼 Kafka나 RabbitMQ 같은 메시지 큐 써야 하는 거 아닌가요?"

나도 처음에 그랬다. 이벤트 드리븐 아키텍처, 메시지 큐, 비동기 처리... 요즘 핫한 키워드들이 머릿속을 스쳤다.

근데 선배분과 이야기를 나눠보니 생각이 바뀌었다.

현재 규모에서 Kafka는 오버엔지니어링이다

선배가 이렇게 말해주셨다.

"학습 곡선과 생산성도 비용이다"

맞는 말이다. Kafka를 도입하면

  • Kafka 클러스터 + Zookeeper 운영 필요
  • 파티션 설계, 컨슈머 그룹 관리
  • 브로커 모니터링, 장애 대응
  • 러닝 커브

이걸 통계 기능 하나 때문에 도입하는 게 맞나? 싶었다.

그럼 이벤트 유실은?

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를 검토해도 늦지 않다는 결론이였다.

"필요할 때 복잡하게 만들자. 미리 복잡하게 만들지 말자."

이게 이번 프로젝트에서 배운 가장 큰 교훈 중 하나다.

5. 아키텍처가 복잡해지는 느낌인데...

여기서 솔직히 고민이 많았다.

"이거 너무 오버엔지니어링 아닌가?"

단순히 통계 보여주는 기능인데:

  • Redis 도입
  • 별도 Node.js 서버(flow-stats)
  • 배치 프로세스
  • 실시간 마이그레이션 로직

복잡도가 확 올라간다.

그럼에도 이 구조를 선택한 이유

1. 레거시 시스템 보호

우리 회사 백엔드는 Tomcat + JSP 기반이다. 여기에 실시간 이벤트 처리 로직을 끼워넣으면:

  • 기존 코드와 섞여서 유지보수 어려움
  • Tomcat 재시작 = 통계 로직 변경도 재시작
  • 장애 발생 시 원인 파악이 힘듦

별도 서버(flow-stats)로 분리하면 통계 쪽에 문제가 생겨도 본 서비스는 멀쩡하다.

2. 이미 Redis를 쓰고 있었다

새로 도입하는 게 아니라 이미 다른 캐시 용도로 Redis가 있었다. 인프라 추가 비용이 거의 없다는 뜻.

3. Kafka/RabbitMQ까지 가기엔 규모가 작다

처음에 메시지 큐 도입도 고민했다. 근데 현재 규모(사용자 수, 액션 빈도)를 생각하면 오버엔지니어링이다.

HTTP 동기 호출 + 실패 시 파일 백업이면 충분했다. 나중에 규모가 커지면 그때 전환해도 된다.


6. 최종 아키텍처

결국 이렇게 정리됐다:

┌─────────────────────────────────────────────────┐
│                  사용자 액션                  │
│    (글 작성, 댓글, 채팅, 파일 업로드 등)        │
└────────────────────────┬────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────┐
│              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. 평상시: 사용자 액션 → Java → Node.js → Redis (실시간 카운트 증가)
  2. 관리자 조회 시: 탭 클릭 → Redis → PostgreSQL 이관 → 조회 응답
  3. 야간 배치: 00:30에 어제 Redis 데이터 → PostgreSQL로 최종 이관
  4. 장애 시: Java에서 전송 실패하면 파일 백업 → 배치에서 복구

7. 이 글을 마치며

1편에서는 프로젝트 배경과 왜 이런 아키텍처를 선택했는지에 대해 정리해봤다.

정리하면서 느낀 건, 결국 모든 기술적 결정에는 트레이드오프가 있다는 것이다.

선택얻은 것잃은 것
별도 집계 테이블조회 성능, 기존 시스템 보호구현 복잡도
Redis 버퍼링쓰기 성능, 실시간 집계데이터 유실 가능성 (TTL)
별도 Node.js 서버장애 격리, 독립 배포인프라 복잡도
배치 + 실시간 이중 구조완벽한 실시간 조회구현/운영 복잡도

완벽한 정답은 없었다. 그냥 현재 상황에서 최선의 균형점을 찾으려고 노력했다.
특히 이제는 진짜 자바스크립트 공부 좀 해야겠다고 느끼게 된 프로젝트였다..

다음 편에서는 Redis 키 설계와 동시성 제어에 대해 다뤄보겠다. Hash와 Sorted Set을 왜 혼용했는지, 분산 락은 왜 필요했고 왜 Redlock 같은 복잡한 알고리즘 대신 단순한 SET NX를 선택했는지 등을 정리해볼 예정이다.


다음 편: Redis 키 설계 & 동시성 제어

profile
명확한 문제 정의를 가장 중요시 여기는 개발자, 채기훈입니다.

8개의 댓글

comment-user-thumbnail
2025년 12월 28일

2번 선택 방안에서 "옵션 A: 그냥 매번 집계"
에서 프론트 화면에서는 user의 간단한 정보만 보여주고
관리자가 해당 유저를 클릭하면 where 조건문에 user_id를 추가 후, 해당 유저의 정보만 조회하는 방식으로는 안되는 상황이었나요?

이렇게 하는 경우에는
1. PK 인덱스인 user_id를 이용해서 페이징된 user의 정보만 가져온 후,
2. 해당 user의 정보 쿼리만 재실행해서 유저의 정보만 view에서 보여준 후,
3. 해당 view에서 관리자가 특정 유저를 클릭하면 그 유저의 집계 쿼리만 진행하면
무거운 쿼리가 아니고, 사용자별로 집계 데이터를 보여주니 요구사항은 만족 될 것 같아서요

결론은 처음부터 모든 유저를 집계하지 않고, 유저의 간단한 정보만 보여준 후 관리자가 특정 유저를 클릭하면 해당 유저만 집계하는 방식은 요구사항을 만족하지 못했을까요?

2개의 답글
comment-user-thumbnail
2025년 12월 30일

문제상황과 왜 요런 결정들을 했는지 기준들을 나눠주셔서 재미있게 읽고 갑니다~!
글 흐름이 좋아선가 고민들이 더 이입되었어요!
기존 주요기능에 미칠 영향과, 오버엔지니어링을 고민한 것도 재미있었어요~!

백엔드 개발의 결정이 오래갈 수 있음을 레거시와 운영부하로 맛보다보면
요런 결정 기준들이 더 소중해지는 것 같아요

덕분에 잘 읽고 가요~! 한 해 수고많으셨습니다~!

1개의 답글