콘텐츠로 이동

시스템 설계는 어떤 순서로 결정해야 할까

유명한 구성 요소를 먼저 배치하지 않고, 업무 불변식과 수치화된 목표에서 출발해 실제 병목과 장애 범위에 필요한 구조만 추가합니다.

예상 읽기 시간 8분 · 핵심 질문: 지금 한 서버로 충분한데 무엇을 언제 나눠야 하는가

새 주문 서비스를 설계하면서 로드 밸런서, Redis, Kafka, 샤딩과 다중 리전을 먼저 그리면 그럴듯한 그림은 나온다. 하지만 주문이 중복되면 안 된다는 규칙, 초당 쓰기 수, 허용 지연과 복구 시간을 모르면 어느 구성 요소도 크기와 보장을 결정할 수 없다.

시스템 설계는 제품 목록이 아니라 요구를 수치와 실패 조건으로 바꾸고, 가장 단순한 구조에서 목표를 깨는 병목만 분리하는 결정 과정이다.

flowchart TB
  I["업무 불변식<br/>절대 깨지면 안 되는 규칙"] --> R["사용자 흐름과 품질 목표<br/>지연·가용성·복구"]
  R --> C["최대 트래픽과 데이터 증가 계산"]
  C --> S["가장 단순한 요청·저장 구조"]
  S --> M["실제 부하에서 지연·대기열·장애 범위 측정"]
  M --> Q{"목표를 깨는 지점이 확인됐는가?"}
  Q -->|"아니요"| O["구조 유지 · 관측과 복구 절차 운영"]
  Q -->|"예"| E["캐시·대기열·복제·분할 중<br/>그 문제를 직접 줄이는 것만 추가"]
  E --> M

첫 장에는 구성 요소가 아니라 업무와 실패 조건을 쓴다

기능 요구는 사용자가 무엇을 할 수 있는지를 말한다. 비기능 요구는 그 기능이 얼마나 빨리, 얼마나 자주 성공하고, 장애 뒤 어느 정도 데이터로 언제 복구돼야 하는지를 말한다.

“주문 API가 빨라야 한다” 대신 “정상 주문 요청의 99%가 300ms 안에 끝난다”처럼 SLO(Service Level Objective, 서비스 수준 목표)를 적는다. 주문 중복 금지, 결제 합계와 원장 일치처럼 절대 깨지면 안 되는 불변식도 따로 적는다. 일부 상품 추천이 늦을 때는 결과를 생략할 수 있지만 결제 금액이 확실하지 않으면 요청을 거절해야 한다. 실패 시 기능 축소가 가능한 범위를 먼저 나눠야 한다.

복구에는 두 목표가 있다. RTO(Recovery Time Objective, 복구 시간 목표)는 장애 뒤 서비스를 다시 사용할 수 있을 때까지 허용 시간이고, RPO(Recovery Point Objective, 복구 시점 목표)는 복구 과정에서 허용할 데이터 손실 범위다. RTO 5분과 4시간은 필요한 대기 자원과 자동화가 다르고, RPO 0은 원격 동기 복제로 쓰기 지연을 늘릴 수 있다.

용량 추정은 정답보다 결론을 바꾸는 변수를 찾는다

평균 사용자 수보다 가장 붐비는 구간의 RPS(Requests Per Second, 초당 요청 수), 조회와 쓰기 비율, 요청 한 건의 데이터 크기와 동시에 열린 연결 수를 본다. 요청 하나가 데이터베이스 질의를 다섯 번 실행하면 RPS와 QPS(Queries Per Second, 초당 질의 수)는 다르다.

하루 주문 1천만 건, 주문 한 건과 인덱스·복제 로그를 합쳐 4KiB라면 원본만 하루 약 40GiB가 늘어난다. 보관 기간, 복제 수, 인덱스와 백업을 곱하면 저장 용량의 자릿수가 바뀐다. 최대 RPS가 평균의 열 배인지, 한 요청이 다른 서비스 수백 곳으로 퍼지는지 같은 변수가 구조를 바꾼다.

정밀한 미래 예측이 목적은 아니다. 한 애플리케이션과 한 데이터베이스가 여유 있게 감당하는지, 어느 자원이 먼저 한계에 닿는지 알 수 있을 정도면 된다. 추정값 옆에는 실제 운영에서 확인할 지표와 다시 설계할 기준을 적는다.

부하 분산과 캐시는 서로 다른 병목을 줄인다

로드 밸런서는 정상 서버 사이에 요청을 나눠 애플리케이션 처리량과 장애 대응을 늘린다. 그러나 뒤의 데이터베이스 하나가 병목이면 애플리케이션 서버만 늘려도 해결되지 않는다. 상태 점검이 “프로세스가 살아 있다”만 확인하면 데이터베이스 연결을 못 얻는 서버에도 계속 요청을 보낼 수 있다.

캐시는 자주 조회하는 결과나 비싼 계산을 재사용한다. 적중 시 지연과 원본 부하는 줄지만, 언제 오래된 값을 버릴지 정해야 한다. 캐시가 원본인지, 원본에서 다시 만들 수 있는 사본인지 명확히 하고 TTL, 데이터 변경 시 무효화와 원본 장애 때 오래된 값을 허용할 범위를 적는다.

적중률 하나만으로는 부족하다. 적중하지 않은 요청이 어느 키에 몰리고 원본에 얼마나 큰 부하를 주는지를 본다.

핫 키와 동시 만료는 평균이 숨기는 집중 부하다

핫 키는 일부 사용자·상품·캐시 키에 요청이 집중돼 한 파티션이나 데이터베이스 행만 포화되는 현상이다. 전체 CPU 평균이 낮아도 인기 상품 한 개의 잠금 대기가 주문 p99를 만들 수 있다.

캐시 스탬피드는 인기 키가 만료되는 순간 많은 요청이 동시에 원본 값을 다시 계산하는 현상이다. 같은 키의 재계산을 하나로 합치는 요청 병합, 만료 시점을 흩뜨리는 무작위 TTL, 이전 값을 잠시 제공하면서 뒤에서 새로 계산하는 방식과 핫 키 복제를 사용할 수 있다.

잠금 하나로 모든 요청을 기다리게 하면 재계산 실패 때 전체가 멈출 수 있다. 오래된 값을 얼마 동안 허용할지, 잠금 대기 제한과 실패 시 응답을 함께 정한다. 재고처럼 최신성이 중요한 값에는 같은 전략을 그대로 적용하지 않는다.

데이터베이스는 불변식과 주된 접근 경로로 고른다

주문·결제처럼 여러 변경이 함께 성공해야 하면 관계형 데이터베이스와 트랜잭션이 자연스럽다. 기본 키 조회, 기간 검색, 상태별 목록처럼 주된 접근 경로를 적고 그 순서로 인덱스를 설계한다. 제품 이름보다 한 트랜잭션이 지키는 경계와 실패 시 복구가 먼저다.

조회 부하가 크면 복제본으로 분리할 수 있지만 복제 지연 때문에 방금 바꾼 값이 보이지 않을 수 있다. 한 노드의 저장 용량과 쓰기 처리량이 실제 한계에 닿으면 데이터 분할을 검토한다. 분할은 용량을 나누는 대신 조각을 건너는 트랜잭션, 보조 인덱스와 재분배를 어렵게 한다. 가장 고르게 보이는 키가 업무 조회와 핫 테넌트까지 잘 나누는지 확인한다.

대기열은 폭증을 흡수하지만 처리량을 만들지는 않는다

이미지 변환이나 이메일처럼 사용자 응답과 분리할 수 있는 작업은 대기열에 넣어 생산자와 소비자의 시간을 분리할 수 있다. 짧은 요청 폭증을 버퍼링하고 소비자가 자신의 속도로 처리한다.

도착률이 장시간 처리율보다 크면 대기 건수는 계속 증가한다. 대기열이 있다고 과부하가 해결된 것은 아니다. 허용할 최대 지연과 대기 건수, 넘었을 때 어떤 작업을 거절하거나 버릴지 정한다. 작업은 재전달될 수 있으므로 멱등성과 중복 제거도 필요하다.

역압은 소비자가 감당하지 못하는 속도를 생산자에게 전달해 수락량을 줄이는 정책이다. 무제한 대기열로 숨기지 않고 입구에서 부하를 제한해야 회복할 여지가 생긴다.

시간 제한·재시도·회로 차단기는 하나의 실패 예산이다

시간 제한은 기다림의 상한이다. 재시도는 일시적인 연결 실패를 회복할 수 있지만 같은 작업을 중복 실행하고 부하를 늘린다. 지수 백오프와 무작위 지연, 전체 재시도 예산과 멱등성 없이 자동 재시도를 켜면 장애 중인 하위 서비스를 더 세게 두드린다.

회로 차단기는 최근 실패가 기준을 넘었을 때 새 호출을 잠시 줄여 하위 서비스가 회복할 시간을 준다. 어떤 기능은 캐시된 결과로 축소하고, 결제처럼 정확한 결과가 없으면 실패시킨다. 사용자의 전체 마감 시각보다 각 하위 서비스의 시간 제한 합이 길어지지 않도록 남은 시간을 전달한다.

요청률 제한은 시스템 보호와 사용자 간 공정성을 만든다

토큰 버킷은 일정 속도로 토큰을 채우고 요청이 토큰을 사용하게 해 짧은 순간 폭증을 허용하면서 장기 평균을 제한한다. 리키 버킷은 요청을 비교적 일정한 속도로 내보낸다.

제한 알고리즘보다 중요한 것은 키와 실패 정책이다. 사용자, API 키, IP, 테넌트 중 무엇을 기준으로 할지, 무료·유료 사용자의 한도가 다른지 정한다. 여러 서버가 카운터를 공유할 때 정확성을 위해 중앙 저장소에 의존할지, 약간의 초과를 허용하고 로컬에서 빠르게 판단할지도 선택한다. 제한 장치가 실패했을 때 모든 요청을 통과시키면 보호가 사라지고, 모두 막으면 자체 장애가 된다.

셀 기반 구조는 부하와 장애를 반복 가능한 작은 단위에 가둔다

셀은 애플리케이션, 데이터 저장소와 대기열의 일부 용량을 묶은 독립적인 서비스 단위다. 사용자나 테넌트를 특정 셀에 배치하면 한 셀의 과부하·배포 실패·데이터베이스 장애가 전체 사용자로 퍼지는 범위를 줄일 수 있다. 새 셀을 추가해 용량을 확장하기도 쉽다.

이름만 셀로 나누고 핵심 데이터베이스와 메시지 브로커를 모두 공유하면 실제 격리가 생기지 않는다. 사용자를 셀로 안내하는 디렉터리, 큰 테넌트를 옮기는 절차, 셀 사이 조회와 공통 제어 영역이 새 복잡도다. 셀을 운영할 규모와 장애 격리 목표가 분명할 때 선택한다.

다중 리전은 복구 절차까지 완성돼야 가용성이 된다

활성-대기 구조는 주 지역 장애 때 대기 지역을 승격한다. 활성-활성은 여러 지역이 동시에 요청을 받아 사용자 거리를 줄일 수 있지만 같은 데이터를 동시에 바꿀 때 충돌과 전역 순서를 다뤄야 한다. 모든 서비스가 활성-활성을 필요로 하지는 않는다.

데이터베이스만 복제해도 DNS·인증·메시지·비밀 관리와 외부 결제 서비스가 한 지역에 묶여 있으면 전체 전환은 실패한다. 백업 복원, 트래픽 전환, 데이터 정합성 확인, 원래 지역으로 돌아가는 절차까지 실제로 동작해야 RTO와 RPO를 믿을 수 있다.

다중 리전 전에 백업에서 복구해 본 적이 있는지, 대기 지역의 용량이 실제 트래픽을 감당하는지부터 확인한다. 복잡한 지역 쓰기 충돌보다 검증된 단일 쓰기 지역과 빠른 장애 전환이 더 맞는 서비스도 많다.

좋은 설계 문서는 다시 판단할 조건을 남긴다

설계 그림에는 각 경계의 상태, 시간 제한, 재시도, 대기열과 담당자를 표시한다. 조회와 쓰기 경로를 따로 그리고, 배포·백업·장애 조치 같은 제어 경로도 그린다. 정상 경로만 있는 그림은 운영 설계가 아니다.

결정 기록은 다음처럼 문제와 포기한 비용을 함께 남긴다.

현재 최대 2천 RPS와 데이터 증가량은 단일 PostgreSQL 주 서버의 목표 안에 있다. 읽기 복제본을 추가하면 최신성 경로가 복잡해지므로 보류한다. 인기 상품 캐시 만료 때 원본 QPS가 여섯 배 증가하므로 요청 병합과 TTL 분산을 먼저 적용한다. 주 서버 저장 대역폭이 70%를 30분 이상 넘거나 디스크 소진 예상이 6개월 안으로 들어오면 분할을 다시 검토한다.

많은 구성 요소가 아니라 현재 목표를 만족하는 가장 단순한 구조와, 다음 구조가 필요해지는 측정 가능한 신호가 좋은 시스템 설계의 결과다.

참고 자료