[시스템 설계] 1-1. 실제 사례로 보는 아키텍처 설계의 현실

11 분 소요

태그: , ,

카테고리:

업데이트:

1. 시스템 설계의 중요성을 엿볼 수 있는 실제 사례

이번 장에서는 내가 경험했던 서비스들의 아키텍처를 예로 들어, 각 시스템 패턴별 장단점을 정리하고 설계 관점에서의 인사이트를 남기려 한다. 실제 운영 중인 서비스 구조를 모두 공개할 수 없으므로, 일부는 간략화하여 설명한다.

2. Microservice Architecture with Polling Queue

MSA Diagram
MSA 예시

아키텍처 개요

해당 서비스는 초기 단계부터 도메인별 MSA 구조로 구성되었다. 요청은 Envoy -> gRPC Gateway -> Authentication -> 각 도메인 서비스 (User, Account, Transfer, External 등 로 전달되며, 각 서비스는 독립된 데이터 접근 계층과 Redis를 통한 캐시/Task Queue 를 사용한다.

  • Envoy: 외부 트래픽 진입점으로, gRPC Gateway 로의 라우팅 담당
  • gRPC Gateway: HTTP 요청을 gRPC로 변환하여 내부 서비스와 통신
  • Authentication: 인증 토큰 검증 및 사용자 세션 관리
  • Microservices: 도메인 단위로 나눈 서비스 프로세스 (User, Account, Transfer, External, TaskScheduler, Admin)
  • Redis: 캐시와 단순 Task Queue(Polling 기반) 역할
  • Database: 각 서비스별 트랜잭션 데이터 저장소

설계 배경

당시 서비스는 송금 트랜잭션 처리량 증가에 대응하기 위해 빠른 확장성 확보가 필요했다. 개발 인원이 적었기 때문에 도메인별로 독립 배포가 가능한 MSA를 채택했으나, 서비스 간 호출 비용이 높고 트랜잭션 정합성 관리가 어려운 구조적 한계가 있었다.

언어는 Golang을 채택했다. 송금 서비스 특성상 트랜잭션 단위 요청이 많고, I/O 바운드 중심의 구조였기 때문에 경량 스레드 모델(Goroutine)낮은 런타임 오버헤드가 유리했다. 또한 초기 인프라가 컨테이너 기반이었기 때문에, Go의 단일 바이너리 배포 구조는 운영 측면에서도 효율적이었다.

외부 지급결제망과의 연동은 파트너사별 API 스펙이 상이했다. 일부 파트너는 이벤트 푸시를 지원했지만, 대부분은 상태 조회(Polling) 방식만 제공했다. 이에 전체 시스템의 일관성을 유지하기 위해 Polling 기반 상태 동기화 구조로 통합했다.

Polling 주기 동안 동일한 상태 요청이 중복 발생할 수 있었지만, 멱등성을 보장하기 위해 상태 기반 업데이트 로직을 적용했다. 즉, 이미 완료된 송금 상태를 다시 갱신하지 않도록 하여 데이터 정합성을 유지했다.

다만 반복 호출로 인해 DB와 메모리 리소스가 불필요하게 소모되는 비효율성은 남아 있었다.

핵심 설계 포인트

  • 도메인 분리: User, Account, Transfer, External 등 주요 도메인을 서비스 단위로 분리
  • 통신 방식: gRPC 내부 통신으로 호출 오버헤드를 줄이고, 외부 API 는 REST로 노출
  • 비동기 처리: Redis를 이용한 Task Queue + Scheduler Polling
  • 데이터 캐싱: Redis 캐시를 통해 사용자/송금 상태 조회 부하 완화

문제점 및 개선방향

  1. Polling Queue의 비효율성
    • 짧은 주기(1~5분)로 반복 호출이 발생하며 DB I/O 부하가 증가했다.
    • 송금 상태 변경은 상태 기반 업데이트 로직으로 멱등성이 보장되어 비즈니스 로직상 오류는 없었지만, Scheduler가 이미 완료된 송금 건까지 반복 조회하면서 불필요한 호출이 지속되었다.
    • 이로 인해 DB와 메모리 리소스 낭비, 불필요한 로그 누적, 디버깅 복잡도 증가 등의 문제가 발생했다.
  2. MSA 복잡도 증가
    • 초기 도입 목적이었던 독립 배포 이점보다, 서비스 간 트랜잭션 정합성 관리 비용이 커짐
    • 공통 기능 (Authentication, Admin 등)이 중복 구현되어 유지보수성 저하
  3. 아키텍처 성숙도
    • MSA의 형태를 띄고 있지만, 서비스 경계와 데이터 모델 정의가 불명확하여 실질적으로 “분리된 모놀리식(MSA 형태의 Monolithic)” 구조에 가까움

시스템 설계 관점의 인사이트

  • MSA는 팀 조직 구조, 배포 전략, 트랜잭션 관리 수준이 뒷받침되지 않으면 오히려 복잡성을 키운다.
  • Polling 기반 처리는 단순성과 빠른 개발에 유리하지만, 실시간성과 효율성의 트레이드오프를 반드시 인지해야 한다.
  • 향후 시스템 확장 시에는 이벤트 기반 비동기 처리(Kafka, gRPC Stream 등) 로 전환하는 것이 필수적이다.

3. Monolithic Architecture with Asynchronous Worker Queue

Mono Diagram
Monolithic 예시


Async Worker Queue
비동기 Worker Queue 예시

아키텍처 개요

해당 시스템은 gRPC 기반 Monolithic 구조를 중심으로 구성되었다. 요청은 Envoy -> gRPC Gateway -> Authentication -> gRPC Monolithic Service 순으로 전달되며, 비동기 처리가 필요한 송금 실행 및 상태 추적 로직은 Task Consumer Service에서 분리 실행된다.

  • Envoy: 외부 트래픽 진입점으로, gRPC Gateway로의 라우팅 담당
  • gRPC Gateway: HTTP 요청을 gRPC로 변환하여 내부 서비스와 통신
  • Authentication: 인증 및 권한 검증 담당
  • gRPC Monolithic Service: 송금 생성, 실행, 상태 추적 등 핵심 도메인 로직을 처리
  • Task Consumer Service: gocraft/work 기반의 Redis Worker Queue를 사용하여, 송금 실행(Execute) 및 추적(Tracking) 작업을 비동기로 처리한다. Worker는 각 Task의 상태를 Redis에 저장하며, 재시도 횟수와 실패 임계값(threshold)을 Config 단위로 관리한다. 실패 임계값을 초과한 Task는 별도의 Failed Queue로 분리되어 운영자가 수동으로 재처리할 수 있다.
  • Redis: Task Queue의 스토리지로 사용된다. gocraft/work에서 Job 상태, Retry 메타데이터, 스케줄링 정보를 저장하는 백엔드 역할을 수행하며, Worker 자체의 Failover는 Redis가 아닌 Worker 프레임워크에서 관리된다.
  • Database: 트랜잭션, 송금 상태, 로그 등 주요 데이터 관리

설계 배경

서비스 초기에는 다수의 외부 지급결제망(API 파트너사)과 연동해야 했기 때문에 송금 요청의 신뢰성과 상태 추적의 일관성이 가장 중요한 요구사항이었다.

MSA 구조를 유지하기에는 각 송금 단계별 트랜잭션 경계가 복잡하고, 서비스 간 네트워크 호출로 인한 장애 전파 위험이 컸다. 이제 단일 프로세스 내에서 송금 요청부터 상태 변경까지 하나의 트랜잭션 맥락에서 제어 가능한 Monolithic 구조를 채택했다.

언어는 Golang을 그대로 유지했다. 송금 처리가 I/O 중심 구조로 구성되어 있고, gRPC 통신과 Worker Queue 소비 로직에서 경량 스레드(Goroutine) 의 효율성이 높았기 때문이다.

또한 gRPC 기반 내부 호출로 RPC 단위 모듈화를 유지하면서도, 서비스 전체는 단일 배포 아티팩트로 관리하여 운영 복잡도를 줄였다.

핵심 설계 포인트

  • gRPC 기반 내부 호출: Monolithic 내부에서도 서비스 간 호출 구조를 gRPC 인터페이스로 유지하여 확장성 확보
  • 비동기 Worker Queue 구조: 송금 실행(Execute)과 상태 추적(Tracking)을 각각 Consumer로 분리하여 병렬 처리했다. Worker는 gocraft/work 기반으로 동작하며, 실패 시 최대 3회까지 자동 재시도 후에도 처리가 완료되지 않으면 Failed Archive Queue로 이동시켜 후속 조치가 가능하도록 했다.
  • Delay Queue 재시도 설계: 외부 지급결제망의 송금 상태가 특정 조건(예: Completed)에 도달할 때까지 주기적으로 상태를 재조회하도록 설계되었다. 이는 Failover 목적이 아닌 비즈니스 로직상 상태 전이 확인을 위한 반복 처리 메커니즘이다.
  • 상태 기반 업데이트 로직: 중복 실행이나 재시도 상황에서도 멱등성을 유지하기 위해 송금 상태를 기준으로 업데이트를 수행했다. 동시에 여러 Worker가 동일 송금 건을 처리하지 않도록, Redis를 활용한 분산 락(distributed lock) 을 적용해 경쟁 상태(race condition)를 방지했다.
  • 모듈 간 경계 명확화: Core 송금 로직은 Monolithic 내부에 두되, Worker는 독립 서비스로 관리

문제점 및 개선방향

  1. Worker Queue 병목
    • Task Consumer가 단일 프로세스로 동작했기 때문에 처리량이 증가하면 Queue 대기 시간이 늘어났다.
    • 특정 Consumer가 장애로 중단될 경우, Redis에 남은 Task가 즉시 재처리되지 못하는 문제가 있었다.
    • 향후 Worker 수평 확장을 통해 처리량을 늘리고, Kafka 기반 분산 큐 구조로의 전환을 검토했다.
    • 또한 메시지 유실에 대비하기 위해 Redis AOF(Append Only File) 옵션을 사용했으며, 향후에는 Redis Replication/Sentinel/Cluster 구성을 통해 장애 복구 및 데이터 안정성을 강화하는 것이 더 적합한 방향이었다.
  2. Monolithic의 확장성 한계
    • 모든 송금 단계가 단일 서비스 내에서 실행되므로 배포 단위가 커지고, 특정 기능만 수정해도 전체 서비스를 재배포 해야 했다.
    • 향후 송금 생성, 실행, 정산 로직을 독립 모듈화할 필요성이 있었다.
  3. 트랜잭션/사이드이펙트 관리 복잡도
    • 단일 DB 트랜잭션 내에서 송금 요청을 기록하더라도, 외부 지급결제망 호출은 DB 트랜잭션 경계 밖에서 수행되므로 부분 실패가 발생할 수 있다.
    • Redis 분산락은 동일 송금건의 동시 처리(race)를 방지하는 용도이며, 외부 호출로 인한 부분 커밋/불일치를 해결하지는 않는다.
    • 이를 보완하기 위해 Idempotency Key + 상태 기반 업데이트 + Transactional Outbox를 적용하고, 주기적 Reconcile 잡으로 외부/내부 상태를 동기화했다. (보상이 가능한 경우에는 보상 트랜잭션을 별도 정의)

시스템 설계 관점의 인사이트

  • Monolithic 구조는 도메인 간 경계가 명확하고 트랜잭션 일관성이 중요한 서비스에 유리하다. 송금 서비스처럼 “한 번의 상태 전이가 치명적인 결과를 낳을 수 있는 도메인”에서는, 오히려 Monolithic 구조가 안정적인 선택이었다.
  • 비동기 Worker Queue는 외부 의존성이 큰 송금 처리의 안정성을 높였지만, Worker 병목과 장애 대응 자동화가 추가 과제로 남았다.
  • 이후 시스템 고도화 과정에서는 Worker 분산화(Kafka, Asynq, Pub/Sub) 를 검토했으며, 이는 MSA와 Monolithic의 중간 형태인 Hybrid Event-driven Architecture 로 발전할 수 있는 기반이 되었다.

MSA + Polling 대비 관찰

  MSA + Polling Monolith + Async Worker Queue
트랜잭션 일관성 서비스/DB 경계가 많아 분산 트랜잭션·정합성 관리 비용이 큼 단일 경계 내 제어가 쉬워 핵심 전이(송금 상태) 일관성 확보 용이
실시간성/지연 상태 반영은 Polling 주기에 종속(최소 지연 존재) Worker 이벤트 주기/즉시 큐잉으로 실시간성 유리(설계에 따라 거의 실시간)
운영 복잡도 서비스 수↑, 배포/모니터링/장애 전파 경로 복잡 배포 단위 단순, 디버깅 경로 짧음(단, 배포 영향 범위 큼)
스케일 전략 서비스별 독립 확장 용이(이상적 MSA일 때) 핵심 로직은 수직/수평 확장 제약, Worker 수평 확장으로 완충
실패 모드 Polling 과다로 DB/캐시 I/O 증가, 중복 조회·로그 폭증 Worker 병목/재시도 누적, 실패 큐 관리 필요(Archive·재처리 체계 필수)
멱등성/동시성 서비스 간 멱등성 규약 정합 필요 상태기반 업데이트 + Redis 분산락으로 단일 경계 내 제어 용이
팀/조직 적합성 팀 규모 큼, 서비스 경계 명확, 플랫폼 성숙 시 적합 팀 규모 작거나 도메인 결합도 높고 트랜잭션 중요 시 적합
비용/속도 초기 학습·플랫폼 비용 큼, 장기 확장 지향 초기 구축·운영 단순, 빠른 안정성 확보 유리

요약

  • 트랜잭션 일관성·디버깅·운영 단순성이 최우선이면 Monolith + Worker가 유리하다.
  • 서비스 경계가 명확하고 팀/플랫폼이 성숙했으며 독립 확장 이득이 크다면 MSA가 적합하다.
  • Polling은 표준 이벤트가 없는 외부 연동을 빠르게 수용하기엔 실용적이지만, 주기 지연/중복 조회/리소스 낭비가 구조적으로 따른다.
  • Worker 기반 비동기는 Delay Queue(상태 전이 완료까지 반복 확인), Retry/Failed Archive, Idempotency + 분산락 등을 갖춰야 실운영 품질이 확보된다.

실무 결론: 본 사례의 도메인 특성(송금 상태 전이의 치명도, 외부 연동 이질성, 소규모 팀)에서는
Monolith + Async Worker가 초기 안정성과 운영 효율에서 우위였고, 이후 Worker 분산화(Kafka 등)로 점진적 확장이 자연스러운 경로였다.

4. Microservice Architecture for Real-Time Trading (Polling + WebSocket)

Service API Architecture
서비스 API 아키텍처 예시


Market API Architecture
Market API 아키텍처 예시

아키텍처 개요

현재 운영 중인 플랫폼은 Microservice 구조를 기반으로 하되, Polling 방식으로 데이터를 동기화하는 아키텍처다. 표면적으로는 도메인별로 분리된 MSA 형태를 띠고 있지만, 실제로는 서비스 간 데이터 종속성과 단일 DB 중심 구조가 강한 레거시 형태에 가깝다.

아키텍처는 개념적으로 세 개의 플레인(Plane)으로 구성되어 있다.

  1. Service Plane – 주문, 정산, 알림 등 트랜잭션 처리
  2. Market Data Plane – 외부 거래소로부터 시세 및 차트 데이터 수집·가공
  3. Settlement / On-Chain Plane – 거래 결과를 블록체인 네트워크와 동기화

이 세 플레인은 코드와 서비스 레벨에서는 분리되어 있으나, 운영 당시에는 모두 단일 MySQL 인스턴스를 공유하는 형태로 구성되어 있었다. 외부 거래소의 데이터는 주기적인 Polling 스케줄러를 통해 수집되었으며, 실시간 데이터 전송은 내부 서비스의 WebSocket 채널을 통해 처리되는 구조였다. 이러한 구조는 초기 개발 단계에서 빠른 MVP 구축에는 적합했지만, 이후 운영 과정에서는 확장성과 데이터 정합성 측면에서 여러 제약이 드러났다.

설계 배경

이 아키텍처는 내가 합류하기 이전에 이미 구축되어 있던 구조로, 설계 및 개발 당시 팀은 짧은 개발 기간 안에 프로토타입을 빠르게 출시하기 위해 도메인별 서비스를 나누고 외부 거래소 연동 로직을 조합하는 방식으로 구현을 진행했다.

당시의 기술적 방향성은 다음과 같았다.

  • 외부 거래소(CEX/DEX)로부터 시세 데이터를 수집해 내부 기준 가격을 생성하고,
  • 이를 WebSocket으로 클라이언트에 실시간 전달하며,
  • 거래 결과를 온체인 정산 프로세스로 연계한다.

이 구조는 단기적인 MVP 런칭에는 효과적이었으나, 시스템 전체가 단일 MySQL 인스턴스를 중심으로 구성되어 있었고 외부 거래소의 데이터 수집도 Polling 기반 스케줄러에 의존하고 있었다.

내가 합류했을 당시에는 이미 이 구조가 서비스 운영 단계에 있었으며, 운영을 이어받는 과정에서 데이터 정합성 관리·성능 부하·확장성 부족 같은 이슈들이 구조적 한계에서 비롯된 것임을 파악할 수 있었다. 현재는 이러한 부분을 점진적으로 개선하기 위한 방안을 검토하고 있다.

핵심 설계 포인트

  • 데이터 플레인 분리 시도
    • 거래 트랜잭션과 시장 시세 데이터의 특성이 달라, 서비스 레벨에서는 경로를 분리했지만 실제 저장소는 동일한 MySQL 인스턴스를 사용했다.
    • 결과적으로 논리적 책임은 구분되어 있었지만, 물리적 분리는 미완성 상태였다.
  • Polling + WebSocket 하이브리드 구조
    • 외부 거래소 API 특성상 일부는 Webhook, 일부는 Polling 방식만 지원.
    • Polling을 통해 최신 데이터를 수집하고, 내부 서비스에서 WebSocket으로 프런트엔드에 실시간 전송.
    • 결과적으로 사용자는 실시간 체결·시세 데이터를 비교적 빠르게 수신할 수 있었다.
  • 데이터 저장 및 접근 구조
    • 모든 주요 데이터(주문, 시세, 차트)가 단일 DB 인스턴스에 저장되었고, 서비스별 스키마 레벨에서만 구분되었다.
    • 읽기·쓰기 부하가 한곳으로 집중되어, 장기적으로는 캐시나 리드 레플리카 분리의 필요성이 높았다.
  • 데이터 정합성 개선 필요
    • 상태 기반 업데이트 로직이 부재해 중복 요청에 대한 보호가 제한적이었다.
    • 이후 운영 단계에서 멱등성 확보, 상태 전이 기반 업데이트, 락 처리 등 정합성 제어를 별도 레이어로 분리할 필요성을 확인했다.

문제점 및 개선방향

  1. 단일 DB 의존 구조
    • 모든 서비스가 하나의 MySQL 인스턴스를 공유하며, 스키마만 논리적으로 분리된 상태였다.
    • 거래 트랜잭션, 시세, 차트, 로그 데이터가 한곳에 집중되면서 읽기·쓰기 부하가 쉽게 병목으로 이어졌다.
    • 데이터베이스가 사실상 시스템 전체의 단일 장애점(SPOF)이었으며, 장애 전파 범위가 넓었다.
    • (개선 방향) 읽기/쓰기 분리, 리드 레플리카 또는 캐시 도입, 도메인 단위 DB 분리를 장기 목표로 검토 중이다.
  2. Polling 기반 동기화의 구조적 한계
    • 거래소마다 Polling 주기와 응답 지연이 달라 전체 시세 반영 타이밍에 편차가 발생했다.
    • 외부 API 오류나 응답 누락 시 반복 요청이 쌓이면서 CPU·메모리 리소스 낭비가 발생했다.
    • (개선 방향) 거래소별 Polling 정책을 세분화하고, WebSocket 지원 거래소부터 이벤트 기반으로 점진 전환을 추진 중이다.
  3. 운영 및 장애 추적의 복잡성
    • 서비스 간 호출 흐름이 명확히 정리되지 않아 장애 발생 시 원인 분석에 많은 시간이 소요되었다.
    • 로그와 모니터링 시스템이 통합되지 않아, 장애를 재현하거나 트랜잭션 단위로 추적하기 어려웠다.
    • (개선 방향) 요청 단위 Trace ID 적용과 공통 로깅 포맷 통일을 통해, 장애 감지 및 원인 추적 체계를 단순화 중이다.
  4. 확장성과 운영 리소스의 불균형
    • 초기 설계는 확장성을 고려했지만, 실제 트래픽 규모와 운영 인력 수준에서는 구조가 과도하게 복잡했다.
    • 분리된 서비스들이 사실상 단일 인스턴스에 의존하고 있어, 독립 배포의 장점이 제한적이었다.
    • (개선 방향) 서비스 경계를 단순화하고, 데이터 흐름 중심으로 구조를 재편하는 리팩터링을 진행 중이다.

시스템 설계 관점의 인사이트

이 구조를 직접 설계하진 않았지만, 운영을 맡아보며 “아키텍처는 기술의 문제가 아니라 조직과 운영의 문제”라는 점을 명확히 체감했다.

초기에는 빠른 서비스 확장을 위해 도메인을 나누는 것이 우선이었지만, 실제 운영 과정에서는 서비스 경계보다 데이터 흐름과 운영 복잡도 관리가 더 중요하다는 사실을 배웠다.

관점 기존 구조 (MSA + Polling) 운영 중 인식한 개선 방향
확장성 도메인 분리로 구조는 유연했으나, 인프라 관리와 배포가 복잡 실제 트래픽 패턴에 맞춰 핵심 서비스만 모듈화하고 나머지는 통합 유지
실시간성 거래소별 Polling 주기에 따라 데이터 반영 지연 발생 WebSocket 지원 범위를 확대하고, Polling 주기 최적화를 통해 준실시간 구조 유지
유지보수 서비스 수가 늘어날수록 디버깅과 배포 경로가 복잡해짐 공통 로깅·모니터링 체계 통합 및 장애 경로 단순화
데이터 정합성 단일 DB에 의존해 트랜잭션 충돌·중복 업데이트 발생 가능 멱등성 로직 추가, 데이터 락 관리 개선 등 운영 단위에서 보완 필요

결국, 확장 가능한 구조보다 “유지 가능한 구조”가 더 현실적인 목표임을 깨달았다. 시스템은 기술적 이상보다 운영 주체가 감당할 수 있는 복잡도의 범위 안에서 설계되어야 하며, 그 경계를 명확히 이해하는 것이 안정적인 아키텍처의 출발점이다.

5. 마치며

시스템 설계는 교과서적 정답이 존재하지 않는다.
같은 기술 스택을 사용하더라도, 팀의 규모·운영 환경·도메인의 성격에 따라 완전히 다른 해법이 도출된다.

이번 글에서 살펴본 세 가지 사례는 이러한 현실을 잘 보여준다.

  • MSA + Polling 구조는 빠른 확장성과 도메인 분리를 가능하게 했지만, 운영 복잡도와 트랜잭션 정합성 관리의 부담을 초래했다.
  • Monolith + Async Worker 구조는 트랜잭션 일관성과 안정성을 확보했지만, Worker 병목과 배포 단위의 비대화라는 한계를 가졌다.
  • MSA + Polling + WebSocket 구조는 실시간성과 기능 확장을 모두 노렸으나, 단일 DB 의존과 데이터 일관성 관리 측면에서 현실적 제약이 뚜렷했다.

이 세 가지를 관통하는 공통점은 “아키텍처의 성공 여부는 기술보다 운영이 결정한다”는 것이다. 즉, 설계는 이상을 위한 청사진이지만, 운영은 그것이 현실에서 지속 가능한지를 검증하는 과정이다. 트래픽, 데이터 정합성, 장애 대응, 배포 안정성 등 실제 상황 속에서만 아키텍처의 진정한 가치는 드러난다.

결국 시스템 설계의 핵심은 확장 가능한 구조가 아니라 유지 가능한 구조를 만드는 것이다. 지속 가능한 구조란 복잡도를 통제할 수 있고, 장애 발생 시 원인을 추적할 수 있으며, 팀의 역량 안에서 개선이 가능한 구조를 의미한다.

앞으로의 글에서는 이러한 현실적 관점에서, 분산 캐싱·발행/구독 모델·데이터베이스 샤딩·CQRS 패턴 등 구체적 설계 기법들을 다루며 “확장성과 운영성을 어떻게 균형 잡을 것인가”를 중심으로 살펴볼 예정이다.

댓글남기기