Theme:

시스템 설계에는 정답이 없다. 그래서 오히려 더 어렵다. 트레이드오프를 이해하고 있는지, 병목을 찾아낼 수 있는지, 그게 핵심이다. 여기선 자주 나오는 구성 요소와 설계 패턴을 한 곳에 정리한다.


시스템 설계, 어떻게 접근할 것인가

"URL 단축기를 설계해주세요"라는 문제가 주어졌다고 해볼게요. 바로 아키텍처를 그리기 시작하면 십중팔구 망합니다. 요구사항도 모르는데 설계를 할 수 있을 리가 없어요.

접근 순서는 대체로 이렇습니다.

1단계: 요구사항 정리

기능적 요구사항과 비기능적 요구사항을 분리해서 질문해야 합니다.

  • 하루에 요청이 얼마나 들어오나? (트래픽 규모)
  • 읽기와 쓰기 비율은? (읽기 집중인지, 쓰기 집중인지)
  • 데이터는 얼마나 오래 보관하나?
  • 가용성과 일관성 중 뭐가 더 중요한가?

이 단계를 건너뛰는 경우가 정말 많은데, 요구사항을 먼저 정리하지 않으면 "실무에서도 요구사항 안 물어보고 바로 코딩하는 사람"이 되기 쉽습니다.

2단계: 개략 설계 (High-Level Design)

핵심 컴포넌트를 배치하고 데이터 흐름을 그립니다. 너무 디테일에 들어가지 않는 게 포인트예요.

  • 클라이언트 → 로드 밸런서 → 웹 서버 → DB
  • 캐시는 어디에 둘 건지
  • 메시지 큐가 필요한 부분이 있는지

3단계: 상세 설계 (Deep Dive)

핵심 컴포넌트 중 병목이 될 만한 부분이나 확장이 필요한 부분을 깊이 파고듭니다.

4단계: 병목 분석 및 개선

설계한 시스템에서 어디가 터질 수 있는지 스스로 짚어보고, 개선 방안을 제시합니다. SPOF(Single Point of Failure)는 없는지, 트래픽이 10배 늘면 어떤 컴포넌트가 먼저 한계에 도달하는지 생각해봐야 해요.


수평 확장 vs 수직 확장

이건 가장 기본적인 질문이면서 동시에 설계의 출발점이에요.

수직 확장 (Scale Up)

  • 서버 한 대의 스펙을 올립니다. CPU 추가, RAM 증설, SSD 교체.
  • 간단해요. 코드 변경이 필요 없습니다.
  • 한계가 명확합니다. 아무리 좋은 서버라도 물리적 한계가 있고, 단일 장애점(SPOF)이 돼요.

** 수평 확장 (Scale Out)**

  • 서버를 여러 대 늘립니다.
  • 이론적으로 무한 확장이 가능해요.
  • 대신 복잡도가 올라갑니다. 세션 공유, 데이터 정합성, 로드 밸런싱 같은 문제를 풀어야 해요.

실무에서는 거의 항상 수평 확장을 전제로 설계합니다. 수직 확장만으로 버틸 수 있는 서비스는 규모가 작다는 뜻이고, 대규모 시스템을 다룬다면 수평 확장이 전제예요.

다만 DB는 수평 확장이 애플리케이션 서버에 비해 훨씬 까다롭습니다. 이건 뒤에서 샤딩을 다루면서 이야기할게요.


로드 밸런서

서버가 여러 대면 트래픽을 누구한테 보낼지 결정하는 녀석이 필요합니다. 그게 로드 밸런서예요.

L4 vs L7

구분L4 (전송 계층)L7 (애플리케이션 계층)
** 분류 기준**IP, Port, TCP/UDPHTTP 헤더, URL, 쿠키
** 속도**빠름 (패킷 레벨)상대적으로 느림 (페이로드 파싱)
** 유연성**낮음높음 (URL 기반 라우팅 가능)
SSL 종료불가가능
** 예시**AWS NLBAWS ALB, Nginx

L4는 단순히 IP와 포트만 보고 빠르게 분배하는 반면, L7은 요청 내용을 까서 /api/users는 A 서버군으로, /api/orders는 B 서버군으로 보내는 식의 라우팅이 가능합니다. 유연한 대신 처리 오버헤드가 있어요.

로드 밸런싱 알고리즘

Round Robin

  • 순서대로 돌아가면서 분배합니다. 제일 단순해요.
  • 서버 스펙이 다 같다면 잘 동작하지만, 한 대가 느린 서버라면 그 서버에도 똑같이 요청이 가니까 문제가 생깁니다.

Weighted Round Robin

  • Round Robin에 가중치를 줍니다. 성능 좋은 서버에 더 많은 요청을 보내요.

Least Connection

  • 현재 연결 수가 가장 적은 서버에 보냅니다.
  • 요청 처리 시간이 들쑥날쑥할 때 유용해요. 오래 걸리는 요청을 처리 중인 서버에는 새 요청이 안 가니까요.

IP Hash

  • 클라이언트 IP를 해시해서 항상 같은 서버로 보냅니다.
  • 세션 기반 인증을 쓸 때 같은 서버로 가야 하는 경우에 쓸 수 있어요. 다만 세션을 Redis 같은 외부 저장소로 분리하는 게 더 나은 방법이긴 합니다.

캐싱

DB에 매번 쿼리를 날리는 건 비용이 큽니다. 자주 읽히면서 잘 안 바뀌는 데이터는 캐시에 올려두면 응답 시간이 극적으로 줄어들어요.

캐시가 존재하는 위치

캐시는 한 군데만 있는 게 아닙니다. 여러 계층에 걸쳐 존재해요.

  1. ** 브라우저 캐시** — Cache-Control, ETag 헤더로 제어합니다. 서버까지 요청이 가지 않아요.
  2. CDN 캐시 — Edge 서버에 정적 리소스를 캐싱합니다. 사용자와 가까운 위치에서 응답해요.
  3. ** 애플리케이션 캐시** — Redis, Memcached 같은 인메모리 캐시입니다. DB 앞에서 읽기 요청을 흡수해요.
  4. DB 캐시 — MySQL의 쿼리 캐시(8.0에서 제거됨), InnoDB 버퍼 풀 등이 있습니다. DB 엔진 자체가 내부적으로 관리해요.

"캐시를 어디에 두겠냐"는 질문이 나오면 이 계층 구조를 떠올리면 됩니다. 보통 얘기하는 건 3번, 즉 애플리케이션 레벨의 캐시예요.

Redis 활용

캐시 하면 Redis가 거의 표준입니다. 왜 Redis인지 살펴볼게요.

  • 인메모리라서 빠릅니다. 평균 읽기 레이턴시가 1ms 이하예요.
  • 단순 key-value뿐 아니라 해시, 리스트, 셋, 정렬된 셋 등 다양한 자료구조를 지원합니다.
  • TTL 설정이 가능해서 캐시 만료를 자동으로 처리할 수 있어요.
  • Pub/Sub도 되고, Lua 스크립트도 돌릴 수 있습니다.
  • 클러스터 모드로 수평 확장이 가능해요.

세션 저장소, 랭킹 보드(Sorted Set), Rate Limiter(INCR + EXPIRE), 분산 락(Redlock) 등 캐시 외에도 활용처가 다양합니다.


캐시 전략

캐시를 "언제 읽고 언제 쓸 것인가"에 대한 전략입니다. 이건 깊이 있게 알아둘 필요가 있는 주제예요.

Cache Aside (Lazy Loading)

가장 흔한 패턴입니다.

  1. 애플리케이션이 캐시에서 먼저 조회합니다.
  2. 캐시에 있으면(Cache Hit) 바로 반환해요.
  3. 없으면(Cache Miss) DB에서 읽어와서 캐시에 넣고 반환합니다.
PLAINTEXT
요청 → 캐시 조회 → Miss → DB 조회 → 캐시에 저장 → 응답

장점은 실제로 요청되는 데이터만 캐시에 올라간다는 거예요. 메모리를 효율적으로 씁니다. 단점은 최초 요청은 항상 Cache Miss이고, DB 데이터가 바뀌어도 캐시는 모른다는 점이에요. TTL을 걸어서 주기적으로 만료시키거나 별도로 무효화해야 합니다.

Write Through

데이터를 쓸 때 캐시와 DB에 동시에 씁니다.

PLAINTEXT
쓰기 요청 → 캐시에 저장 → DB에 저장 → 응답

캐시와 DB가 항상 동기화되어 있으니 일관성이 좋습니다. 하지만 쓰기 요청마다 캐시 + DB 두 곳에 쓰니까 쓰기 레이턴시가 증가해요. 그리고 한 번도 읽히지 않을 데이터도 캐시에 올라가니까 메모리 낭비가 발생할 수 있습니다.

Write Behind (Write Back)

쓰기를 캐시에만 먼저 하고, DB에는 나중에 비동기로 반영합니다.

PLAINTEXT
쓰기 요청 → 캐시에 저장 → 응답 (바로 반환)
                      → (비동기) DB에 저장

쓰기 성능이 매우 좋습니다. 배치로 모아서 DB에 한 번에 쓸 수도 있어요. 대신 캐시가 날아가면 아직 DB에 반영 안 된 데이터가 유실될 수 있습니다. 리스크가 있는 전략이라 쓰기 성능이 극도로 중요한 경우에만 써요.

캐시 무효화 (Cache Invalidation)

캐시에서 가장 어려운 문제 중 하나입니다. Phil Karlton이 "컴퓨터 과학에서 어려운 것 두 가지: 캐시 무효화와 이름 짓기" 라고 했을 정도예요.

  • **TTL 기반 **: 일정 시간이 지나면 자동 삭제됩니다. 간단하지만 TTL 동안은 stale 데이터가 노출돼요.
  • ** 이벤트 기반 **: 데이터가 변경되면 해당 캐시 키를 명시적으로 삭제하거나 갱신합니다. 정확하지만 어디서 캐시를 지워야 하는지 추적해야 해요.
  • ** 버전 기반 **: 캐시 키에 버전 번호를 포함시켜서 데이터가 바뀌면 키 자체가 바뀌게 만듭니다.

실무에서는 TTL + 이벤트 기반을 조합해서 쓰는 경우가 많습니다. TTL은 안전망, 이벤트는 실시간 무효화 역할이에요.


DB 확장

애플리케이션 서버는 stateless하게 만들면 수평 확장이 쉽습니다. 서버 앞에 로드 밸런서 붙이고 인스턴스 늘리면 끝이에요. 근데 DB는 상태(데이터)를 갖고 있으니까 훨씬 복잡합니다.

레플리카 (읽기 분산)

Master-Slave 구조입니다. 최근엔 Primary-Replica라고 부르는 추세예요.

  • Primary: 쓰기 담당
  • Replica: 읽기 담당 (Primary의 데이터를 복제)

읽기 트래픽이 쓰기보다 훨씬 많은 서비스라면 (대부분의 웹 서비스가 그렇죠) Replica를 여러 대 붙여서 읽기를 분산시킬 수 있습니다. 단, Primary → Replica로의 복제에 지연(Replication Lag)이 생길 수 있어서, 방금 쓴 데이터를 바로 읽으면 아직 반영이 안 돼 있을 수 있어요.

이 문제를 해결하려면 "자기가 방금 쓴 데이터는 Primary에서 읽기"(Read-your-writes consistency) 같은 전략을 씁니다.

샤딩 (수평 분할)

데이터를 여러 DB 서버에 나눠서 저장합니다. 각 서버가 전체 데이터의 일부만 가지고 있어요.

예를 들어 사용자 ID를 기준으로 샤딩한다면:

  • ID % 4 == 0 → 샤드 A
  • ID % 4 == 1 → 샤드 B
  • ID % 4 == 2 → 샤드 C
  • ID % 4 == 3 → 샤드 D

** 샤딩 키(Partition Key)** 선정이 핵심입니다. 잘못 고르면 특정 샤드에 데이터가 몰리는 핫스팟 문제가 생겨요. 유명인의 데이터가 다 한 샤드에 몰린다거나 하는 경우죠.

샤딩의 어려운 점들:

  • ** 조인이 힘들다 **: 서로 다른 샤드에 있는 데이터를 조인하려면 애플리케이션 레벨에서 처리해야 합니다.
  • ** 리밸런싱 **: 샤드를 추가하거나 뺄 때 데이터를 재분배해야 해요. 이걸 잘못하면 서비스 중단이 생깁니다. Consistent Hashing을 쓰면 이 문제를 완화할 수 있어요.
  • ** 유니크 ID 생성 **: AUTO_INCREMENT가 샤드별로 따로 돕니다. 글로벌하게 유니크한 ID를 만들려면 별도 전략이 필요해요 (Snowflake ID, UUID 등).

파티셔닝

샤딩과 혼동하기 쉬운데, 파티셔닝은 하나의 DB 내에서 테이블을 논리적으로 분할하는 것입니다.

  • ** 수평 파티셔닝 **: 행 기준으로 나눕니다. 날짜별로 파티션을 나누면 오래된 데이터를 빠르게 삭제하거나 아카이빙할 수 있어요.
  • ** 수직 파티셔닝 **: 열 기준으로 나눕니다. 자주 접근하는 컬럼과 그렇지 않은 컬럼을 분리해요.

샤딩은 여러 물리적 서버에 분산하는 거고, 파티셔닝은 단일 서버 내에서 나누는 겁니다. 공부하다 보니 이 차이에서 많이 헷갈렸어요.


CDN (Content Delivery Network)

사용자가 서울에 있는데 서버가 미국에 있으면, 아무리 네트워크가 빨라도 물리적 거리 때문에 레이턴시가 생깁니다. CDN은 전 세계에 분산된 Edge 서버에 콘텐츠를 캐싱해서 사용자에게 가장 가까운 서버에서 응답하게 해줘요.

동작 방식

  1. 사용자가 이미지를 요청합니다.
  2. DNS가 가장 가까운 CDN Edge 서버로 라우팅해요.
  3. Edge 서버에 캐시가 있으면 바로 응답합니다.
  4. 없으면 원본 서버(Origin)에서 가져와서 캐시하고 응답해요.

뭘 캐싱하나

  • ** 정적 콘텐츠 **: JS, CSS, 이미지, 폰트, 비디오. 이건 기본이에요.
  • ** 동적 콘텐츠 **: API 응답도 캐싱할 수 있습니다. 다만 캐시 무효화가 복잡해져요.

CloudFront, Cloudflare, Akamai 같은 서비스가 대표적입니다. 소규모 서비스에서는 Cloudflare의 무료 플랜만 써도 체감 효과가 커요.

CDN을 쓸 때 주의할 점은 캐시 만료 전략입니다. Cache-Control 헤더를 제대로 설정하지 않으면 업데이트된 파일이 안 내려가는 문제가 발생해요. 프론트엔드 배포 시 파일명에 해시를 붙이는 것도 이런 문제를 피하기 위한 겁니다. (main.abc123.js 같은 식)


Rate Limiting

API를 무한정 호출할 수 있게 두면 안 됩니다. 악의적인 사용자가 API를 폭주시키거나, 버그로 인한 과도한 요청이 시스템을 다운시킬 수 있어요. Rate Limiting은 일정 시간 내 허용되는 요청 수를 제한하는 메커니즘입니다.

토큰 버킷 (Token Bucket)

직관적이고 널리 쓰이는 알고리즘입니다.

  • 일정한 속도로 버킷에 토큰이 채워집니다.
  • 요청이 올 때마다 토큰을 하나씩 소비해요.
  • 토큰이 없으면 요청을 거부합니다 (429 Too Many Requests).
  • 버킷 크기가 곧 버스트 허용량이에요.

예를 들어 초당 10개씩 토큰이 채워지고 버킷 크기가 50이라면, 평상시엔 초당 10개까지 처리하다가 잠깐 동안은 최대 50개까지 버스트를 허용합니다. 순간적인 트래픽 스파이크에 유연하게 대응할 수 있어요.

슬라이딩 윈도우 (Sliding Window)

고정 윈도우 방식은 윈도우 경계에서 문제가 있습니다. 예를 들어 1분에 100개 제한인데, 0:59에 100개, 1:00에 100개를 보내면 2초 사이에 200개가 통과돼요.

슬라이딩 윈도우는 이걸 해결합니다. 현재 시점에서 과거 1분간의 요청 수를 계산해서 제한을 걸어요. Redis의 Sorted Set으로 구현할 수 있는데, 요청 타임스탬프를 score로 넣고 ZRANGEBYSCORE로 윈도우 내 요청 수를 셉니다.

구현이 좀 더 복잡하지만 정확도가 높아요. API Gateway나 Rate Limiter에서 많이 씁니다.


실제 설계 예시

자주 다뤄지는 시스템들의 설계 포인트를 간략히 짚어볼게요. 각각이 별도의 글이 될 만큼 깊은 주제이긴 한데, 여기선 어떤 걸 고려해야 하는지 감을 잡는 정도로 정리합니다.

URL 단축기

** 기능 **: 긴 URL을 짧은 URL로 변환하고, 짧은 URL로 접근하면 원래 URL로 리다이렉트합니다.

** 핵심 고려사항 **:

  • 짧은 URL 생성: Base62 인코딩 (a-z, A-Z, 0-9). 7자리면 62^7 = 약 3.5조 개 조합이 가능합니다.
  • 해시 충돌 처리: MD5/SHA-256의 앞 7자리를 쓰면 충돌 가능성이 있어요. 충돌 시 재생성하거나, DB의 유니크 ID를 Base62로 변환하는 방식이 안전합니다.
  • 301 vs 302: 301(Permanent Redirect)은 브라우저가 캐싱해서 서버 부하가 줄지만 클릭 추적이 안 돼요. 302(Temporary Redirect)는 매번 서버를 거칩니다.
  • 읽기 비율이 압도적으로 높으니 캐시가 중요해요.

채팅 시스템

** 핵심 고려사항 **:

  • 클라이언트-서버 간 실시간 양방향 통신이 필요합니다. WebSocket이 적합해요. HTTP 폴링은 비효율적이고, SSE(Server-Sent Events)는 단방향입니다.
  • 메시지 저장: 채팅 데이터는 쓰기가 매우 많고, 최근 데이터 위주로 읽히며, 순서가 중요해요. 시계열 특성을 가지고 있어서 Cassandra 같은 wide-column DB가 잘 맞습니다.
  • 온라인 상태 관리: 하트비트 방식으로 주기적으로 살아있는지 체크해요.
  • 그룹 채팅: 팬아웃 문제가 있습니다. 1000명짜리 그룹에 메시지 하나 보내면 1000개의 쓰기가 발생해요.

뉴스 피드

** 핵심 고려사항 **:

  • ** 팬아웃 온 라이트 (Push 모델)**: 게시물을 작성하면 팔로워들의 피드에 미리 써둡니다. 읽을 때 빠르지만, 팔로워가 많으면 쓰기 부하가 커요.
  • ** 팬아웃 온 리드 (Pull 모델)**: 피드를 볼 때 팔로잉하는 사용자들의 게시물을 실시간으로 조합합니다. 쓰기는 가볍지만 읽기가 느려질 수 있어요.
  • 보통 하이브리드를 씁니다. 팔로워가 적은 일반 사용자는 Push, 팔로워가 수백만인 유명인은 Pull이에요. 트위터가 이 방식을 쓴다고 알려져 있습니다.

주의할 점

설계를 마치고 나면 꼬리 질문이 이어질 수 있습니다. 자주 나오는 주제들을 정리해볼게요.

CAP 정리

분산 시스템에서 다음 세 가지를 동시에 만족시킬 수 없다는 정리입니다.

  • Consistency (일관성): 모든 노드가 같은 데이터를 보여줍니다.
  • Availability (가용성): 모든 요청에 응답해요 (에러가 아닌 응답).
  • Partition Tolerance (분단 내성): 네트워크 파티션이 발생해도 시스템이 동작합니다.

네트워크 파티션은 분산 시스템에서 피할 수 없으니까, 실질적으로는 CP (일관성 우선) vs AP (가용성 우선) 중 선택하는 문제가 돼요.

  • CP 예시: 금융 시스템. 잔액이 일관되지 않으면 큰일 납니다.
  • AP 예시: SNS의 좋아요 수. 잠깐 다르게 보여도 큰 문제가 아니에요.

"왜 이 DB를 선택했나?"라는 질문에는, 해당 시스템이 일관성과 가용성 중 뭘 더 중시하는지를 근거로 답하면 됩니다.

서킷 브레이커

마이크로서비스 환경에서 하나의 서비스가 응답을 안 하면 호출하는 쪽도 같이 죽을 수 있습니다. 연쇄 장애(Cascading Failure)예요.

서킷 브레이커는 전기 차단기처럼 동작합니다.

  • Closed (정상): 요청을 그대로 전달해요. 실패율을 모니터링합니다.
  • Open (차단): 실패율이 임계치를 넘으면 요청을 아예 보내지 않고 즉시 실패를 반환해요. 장애가 전파되는 걸 막습니다.
  • Half-Open (시험): 일정 시간 후 일부 요청만 보내보고, 성공하면 Closed로 복귀, 실패하면 다시 Open으로 돌아가요.

Resilience4j, Hystrix(deprecated) 같은 라이브러리로 구현합니다. Spring Cloud를 쓴다면 Resilience4j가 표준이에요.

API Gateway

클라이언트가 각 마이크로서비스에 직접 요청하면 관리가 힘듭니다. API Gateway는 모든 요청의 진입점 역할을 해요.

  • 라우팅: /api/users는 User 서비스로, /api/orders는 Order 서비스로 보냅니다.
  • 인증/인가: JWT 검증 같은 공통 로직을 게이트웨이에서 처리해요. 각 서비스마다 인증 로직을 넣을 필요가 없습니다.
  • Rate Limiting: 게이트웨이 레벨에서 요청 수를 제한합니다.
  • 로깅, 모니터링: 모든 요청이 한 곳을 지나니까 로그 수집이 편해요.
  • 프로토콜 변환: 외부는 REST, 내부는 gRPC 같은 구성도 가능합니다.

Kong, Spring Cloud Gateway, AWS API Gateway 등이 있어요.


파생 개념 맵

시스템 설계를 공부하다 보면 계속 가지를 치면서 파고들어야 하는 주제들이 있습니다. 여기서 다 다루진 못하지만 어떤 것들이 연결되는지 정리해둘게요.

  • ** 메시지 큐 **: Kafka vs RabbitMQ, 이벤트 드리븐 아키텍처 → 이미 작성한 글 참고
  • MSA (Microservice Architecture): 서비스 분리 기준, 서비스 디스커버리, 분산 트랜잭션 (Saga 패턴), 데이터 오너십
  • ** 분산 시스템 **: 합의 알고리즘 (Raft, Paxos), 분산 락, Consistent Hashing, 벡터 클럭
  • ** 모니터링 **: 메트릭 수집 (Prometheus), 로그 집계 (ELK Stack), 분산 트레이싱 (Jaeger, Zipkin), 알림 (Grafana)
  • ** 컨테이너/오케스트레이션 **: Docker, Kubernetes → 이미 작성한 글Kubernetes 글 참고

정리

시스템 설계에서 중요한 건 외워서 답하는 게 아닙니다. "이 컴포넌트를 왜 여기에 뒀는지", "이 선택의 트레이드오프가 뭔지"를 설명할 수 있어야 해요.

모든 설계는 트레이드오프입니다. 캐시를 넣으면 속도는 빨라지지만 일관성 관리가 복잡해지고, 샤딩을 하면 용량은 늘어나지만 조인이 어려워져요. 비동기로 처리하면 처리량은 올라가지만 순서 보장이 까다로워집니다.

결국 핵심은 "트레이드오프를 이해하고 있고, 상황에 맞는 판단을 내릴 수 있는가?" 이 한 가지예요.

댓글 로딩 중...