Devin.KR

설계 질문 - 작은 서비스를 키운다면

개발자KR 조회 1

이 장에서 배우는 것

앞 장에서 화면이 사용자에게 빠르게 보이도록 만드는 방법을 살펴보았다. 이번에는 그 화면 뒤의 중고 거래 서비스가 커질 때 무엇을 먼저 바꿀지 설명한다. 설계 면접에서 중요한 것은 구성 요소의 개수가 아니다. 주어진 조건을 확인하고, 예상되는 병목을 근거로 선택하며, 그 선택이 해결하지 못하는 문제까지 말하는 능력이다.

신입 지원자에게 실제 대규모 서비스를 운영한 경험을 요구하는 것은 아니다. 상품 상세 조회와 알림 발송처럼 익숙한 기능에서 출발해, 어떤 조건에서 구조를 바꿀지 설명하면 된다. 이 장의 작은 실험은 캐시가 줄이는 데이터베이스 요청 수와 큐에 쌓이는 작업 수를 직접 비교한다.

  • 기능 요구사항과 성능·정확성 조건을 질문하고 설계 범위를 정한다.
  • 사용자 수를 요청 수로 바꾸고 평균과 집중 시간대의 부하를 구분한다.
  • 캐시, 큐, 복제, 샤딩을 각각 어떤 문제에 적용하는지 설명한다.
  • 단일 장애 지점과 일관성·가용성의 절충을 구체적인 기능에 연결한다.
  • 화이트보드에서 가정, 계산, 구조, 실패 상황의 순서로 답한다.

문제 상황

가상의 중고 거래 서비스 팀 면접에서 면접관이 묻는다.

“지금은 애플리케이션 서버 하나와 데이터베이스 하나로 서비스하고 있습니다. 사용자가 늘어난다면 어떻게 바꾸겠습니까?”

지원자는 캐시와 메시지 큐를 떠올린다. 그러나 아직 상품 조회가 많은지, 사진 업로드가 느린지, 거래 확정에 충돌이 있는지 알지 못한다. 이 상태에서 구성 요소를 늘리면 해결할 문제와 선택한 도구가 어긋날 수 있다.

먼저 범위를 좁힌다. 이 면접에서는 상품 상세 조회와 관심 상품 알림 발송을 설계한다고 합의한다. 결제와 검색 순위는 제외한다. 상품 설명은 짧은 지연을 허용하지만, 판매 완료 여부는 거래 확정 시점에 최신 상태로 검증해야 한다. 알림은 요청을 받은 즉시 발송을 끝낼 필요가 없지만, 접수한 작업을 조용히 잃어서는 안 된다.

면접관은 계산을 위한 가정도 제공한다. 하루 활동 사용자는 2만 명이고, 한 사람이 하루에 상품 상세를 12번 조회한다. 집중 시간대에는 하루 평균의 10배가 들어온다고 가정한다. 이 숫자는 특정 서비스의 통계가 아니라 설계 연습을 위한 입력이다. 지원자는 이를 칠판에 적고, 이후 답변이 이 가정에 의존한다는 점을 밝힌다.

요구사항을 먼저 묻고 숫자로 범위를 잡는다

질문은 설계를 멈추는 행동이 아니다

“요구사항을 알려 주십시오”라고 넓게 묻는 것보다 선택을 바꾸는 질문을 하는 편이 낫다. 조회와 쓰기의 비율, 허용할 응답 시간, 오래된 정보를 허용하는 범위, 장애 시 지켜야 할 기능을 확인한다. 대답을 받지 못한 항목은 가정을 제안하고 진행한다. 모든 조건이 정해질 때까지 기다릴 필요는 없다.

기능 요구사항은 서비스가 하는 일이다. 상품을 보여 주고 알림을 발송하는 일이 여기에 속한다. 비기능 요구사항은 그 일을 어느 정도로 수행해야 하는지다. 예를 들어 상품 상세 응답의 95%를 300밀리초 이내에 제공한다는 목표와, 알림 대부분을 1분 안에 처리한다는 목표를 제안할 수 있다. 이 역시 합의할 예시이지 모든 서비스의 기준은 아니다.

설계 면접에서 확인하는 질문과 답변의 연결
질문의도답변 핵심꼬리 질문
사용자가 늘면 무엇부터 바꾸겠는가?문제 정의 능력주요 기능과 병목 가정을 먼저 확인한다.측정값이 없다면 어떻게 시작하는가?
초당 요청 수는 어느 정도인가?어림 계산 능력일일 요청을 초로 나누고 집중 배수를 적용한다.평균만 쓰면 무엇을 놓치는가?
캐시는 어디에 두겠는가?조회 특성과 정확성 판단반복 조회되고 지연을 허용하는 데이터를 고른다.판매가 끝나면 어떻게 갱신하는가?
알림에 큐가 필요한가?동기·비동기 경계 판단사용자 응답에서 발송 대기를 분리한다.작업이 계속 쌓이면 어떻게 되는가?
복제와 샤딩은 어떻게 다른가?확장 방식의 구분복제는 사본, 샤딩은 데이터 분할이다.읽기 복제본이 오래된 값을 주면?
샤딩 기준을 무엇으로 정하겠는가?접근 패턴과 편중 이해주요 조회 경로와 데이터 분포를 함께 본다.인기 판매자에게 부하가 몰리면?
서버를 두 대로 늘리면 충분한가?실패 경로 추적공유 데이터베이스와 진입점도 확인한다.캐시가 중단되면 어디로 부하가 가는가?
일관성과 가용성 중 무엇이 중요한가?기능별 절충 판단분할 상황에서 기능별 허용 범위를 정한다.거래 확정과 상품 설명은 같은가?
구조를 어떤 순서로 그리겠는가?설명과 우선순위 설정기본 경로를 그리고 병목별로 확장한다.시간이 부족하면 무엇을 남기는가?
이 설계의 한계는 무엇인가?가정과 검증 계획 인식부하 편중, 실패, 비용과 검증 대상을 말한다.어떤 수치가 바뀌면 설계를 바꾸는가?

깊게 보기: “얼마나 많은 요청을 처리해야 합니까?”

나쁜 답은 숫자의 근거 없이 규모를 단정한다.

“사용자가 2만 명이면 데이터베이스를 여러 개로 나눠야 합니다.”

보통 답은 평균을 계산하지만 집중과 편중을 빠뜨린다.

“하루 조회가 24만 번이므로 초당 약 2.8번입니다. 서버 하나로 시작하겠습니다.”

좋은 답은 계산이 뒷받침하는 범위까지 결론을 내린다.

“이 가정만으로는 샤딩을 먼저 도입할 근거가 부족합니다. 하루 상세 조회는 2만 곱하기 12로 24만 건이고, 86,400초로 나누면 평균 초당 약 2.8건입니다. 집중 배수 10을 적용하면 약 28건입니다. 예를 들어 인기 상품 하나에 조회가 몰리는 상황을 별도로 두고 단순한 구조에서 부하를 확인하겠습니다. 다만 이 계산에는 사진 전송량과 다른 API 요청이 빠져 있습니다. 요청당 비용과 응답 시간을 확인한 뒤 확장 방식을 정하겠습니다.”

면접관이 “28건이면 충분히 처리할 수 있지 않습니까?”라고 묻는다면 요청 수와 처리 비용을 구분한다. 한 요청이 데이터베이스를 여러 번 조회하거나 큰 결과를 만들면 같은 요청 수에서도 비용이 달라진다. 계산은 후보를 좁히는 출발점이며 처리 능력의 증명은 아니다.

직접 해 보지 않은 부하 시험을 경험으로 꾸미지 않는다. 경험이 없다면 위 답변처럼 “예를 들어”라고 말하고 검증 계획을 제시한다. 프로젝트 경험이 있다면 실제로 확인한 사실만 덧붙인다.

캐시·큐·복제·샤딩은 해결할 문제에 맞춰 꺼낸다

화이트보드에는 먼저 사용자, 애플리케이션, 데이터베이스의 기본 경로를 그린다. 그다음 반복 조회에는 캐시(cache), 요청 중 오래 기다릴 필요가 없는 작업에는 큐(queue)를 검토한다. 단일 데이터베이스의 읽기 처리량이나 장애 대응이 문제라면 복제(replication)를 검토한다. 한 노드의 저장량이나 쓰기 처리량이 실제 제약이 되면 샤딩(sharding)을 검토한다.

이 순서는 생각을 정리하는 기본 경로다. 도입해야 할 고정 순서는 아니다. 알림 지연이 먼저 문제라면 큐가 앞서고, 장애 시 데이터베이스 복구 시간이 요구를 만족하지 못하면 복제를 먼저 논의할 수 있다. 각 단계에는 운영 비용과 새로운 실패 상황이 생긴다.

상품 조회의 캐시와 알림 작업의 큐는 서로 다른 병목을 다룬다

깊게 보기: “캐시를 넣으면 어떤 점이 좋아집니까?”

나쁜 답은 캐시의 효과를 모든 요청에 적용한다.

“캐시가 빠르므로 모든 데이터를 캐시에 넣으면 됩니다.”

보통 답은 적중 시 효과를 설명하지만 갱신 문제를 남긴다.

“상품 정보를 캐시에 저장하면 데이터베이스 조회가 줄어듭니다. 만료 시간을 설정하겠습니다.”

좋은 답은 데이터의 성격과 실패 경로를 함께 설명한다.

“반복 조회가 많은 상품 설명부터 캐시를 검토하겠습니다. 캐시를 먼저 조회하고 값이 없을 때 데이터베이스에서 읽어 채우면, 같은 상품의 반복 조회를 줄일 수 있습니다. 이 장의 예시처럼 12번의 조회가 네 상품에 집중되면 최초 조회 네 번만 데이터베이스에 도달합니다. 다만 수정 직후 오래된 값이 보일 수 있으므로 변경 시 캐시 제거와 짧은 만료 시간을 함께 검토하겠습니다. 거래 확정에 필요한 판매 상태는 권한 있는 저장소에서 다시 검증하겠습니다.”

면접관이 “변경 시 제거하면 항상 최신입니까?”라고 묻는다면 아니라고 답한다. 다른 요청이 변경 전 값을 읽은 뒤, 제거된 캐시에 그 값을 다시 넣는 경쟁이 가능하다. 최신성을 더 엄격히 요구하면 버전 확인이나 캐시 우회 같은 대안을 검토한다. 캐시 제거 한 번으로 모든 경쟁을 해결했다고 말하지 않는다.

적중률은 전체 조회 중 캐시에서 처리한 비율이다. 단순히 조회마다 데이터베이스를 한 번 읽는 모델에서는 데이터베이스 요청 수를 전체 요청 수 곱하기 미적중률로 어림할 수 있다. 그러나 인기 상품이 처음 노출되거나 여러 항목이 동시에 만료되면 미적중이 짧은 시간에 몰릴 수 있다. 평균 적중률과 순간 부하는 별도로 살핀다.

깊게 보기: “큐를 두면 처리량이 늘어납니까?”

나쁜 답은 대기 공간과 처리 능력을 혼동한다.

“큐에 넣으면 많은 요청을 모두 빠르게 처리할 수 있습니다.”

보통 답은 응답과 작업을 분리하지만 한도를 설명하지 않는다.

“알림은 큐에 넣고 작업자가 처리하면 사용자가 기다리지 않아도 됩니다.”

좋은 답은 유입과 소비의 차이를 설명한다.

“큐는 알림 접수와 실제 발송을 분리하고 짧은 유입 집중을 흡수하는 데 쓰겠습니다. 초당 네 건이 들어오고 작업자가 두 건을 처리하면 남은 두 건은 대기합니다. 이 장의 실험에서도 초반에 작업이 쌓이고 유입이 줄어든 뒤에야 해소됩니다. 큐 자체가 작업자의 처리 능력을 늘리지는 않습니다. 유입이 계속 더 많으면 작업자 확장, 접수 제한, 덜 중요한 작업의 지연 중 하나를 선택해야 합니다.”

면접관이 “큐에 넣고 성공을 응답하면 됩니까?”라고 물으면 성공의 의미를 나눈다. 접수 성공과 발송 완료는 다르다. 내구성 있게 작업을 맡겼다는 확인 뒤에 접수 결과를 알리고, 발송 결과는 별도로 관리한다. 상품 변경과 알림 접수가 함께 성립해야 한다면 데이터 변경과 발행 사이의 실패도 검토해야 한다.

작업은 중복 전달될 수 있다. 같은 작업을 다시 처리해도 의도한 결과가 한 번만 생기도록 만드는 멱등성을 검토한다. 또한 대기 개수만 보지 않고 가장 오래 기다린 작업의 시간도 본다. 작업마다 처리 비용이 다르면 같은 열 개라도 지연은 다르다.

복제는 사본이고 샤딩은 분할이다

읽기 복제본은 같은 데이터의 사본으로 일부 읽기를 받는다. 원본의 읽기 부담을 줄일 수 있지만, 변경이 전달되기 전에는 이전 값을 반환할 수 있다. 상품을 수정한 직후 자신의 변경을 확인하는 요청은 원본으로 보내는 등 읽기 경로를 구분할 수 있다. 복제본을 두었다는 사실만으로 자동 장애 전환이 완성되지는 않는다.

샤딩은 상품 식별자 같은 기준으로 데이터를 여러 저장소에 나눈다. 전체 데이터를 한 노드에 담거나 모든 쓰기를 한 노드에서 처리하기 어려울 때 후보가 된다. 대신 여러 조각을 걸치는 조회, 데이터 이동, 특정 조각에 몰리는 부하를 다뤄야 한다. 판매자 기준으로 나누면 판매자별 상품 조회는 편해질 수 있지만, 인기 판매자 한 명의 부하는 한곳에 남을 수 있다.

신입 지원자는 복잡한 분산 저장소를 즉석에서 구현할 필요가 없다. 복제와 분할의 목적이 다르고, 각각 어떤 새 문제를 만드는지 구분하면 된다. “나중에 샤딩하겠습니다”보다 “한 노드의 쓰기 처리량이 요구를 충족하지 못하고 다른 개선도 부족할 때 검토하겠습니다”가 판단 기준을 더 잘 드러낸다.

실패와 절충을 설명하고 화이트보드를 마무리한다

서버 개수보다 요청 경로를 본다

단일 장애 지점(single point of failure)은 그 구성 요소 하나의 실패가 필요한 기능을 멈추게 하는 지점이다. 애플리케이션을 두 대로 늘려도 둘이 데이터베이스 하나에만 의존하면 저장소 장애에 취약하다. 앞단의 부하 분산기, 공유 캐시, 큐도 실패 경로에 포함해 본다.

캐시는 실패해도 원본을 읽도록 설계할 수 있다. 그러나 평소 요청 대부분을 캐시가 받았다면 캐시 중단 순간 데이터베이스 부하가 크게 늘어난다. 우회 경로가 존재한다는 사실과 그 경로가 전체 요청을 감당한다는 사실은 다르다. 필요한 경우 요청을 제한하거나 일부 부가 정보를 생략하는 방식을 검토한다.

애플리케이션 두 대가 데이터베이스 하나를 공유하면 저장소의 단일 장애 지점은 남는다

깊게 보기: “일관성과 가용성 중 무엇을 택하겠습니까?”

나쁜 답은 서비스 전체에 하나의 답을 붙인다.

“사용자는 기다리면 안 되므로 가용성을 선택하겠습니다.”

보통 답은 기능을 나누지만 장애 조건을 생략한다.

“거래에는 일관성이 중요하고 상품 조회에는 가용성이 중요합니다.”

좋은 답은 어떤 실패에서 무엇을 허용할지 말한다.

“서버 사이 통신이 끊겨 최신 상태를 확인할 수 없다면, 거래 확정은 거절하거나 복구 뒤 재시도하게 하겠습니다. 이미 판매된 상품을 다시 확정하지 않는 조건이 중요하기 때문입니다. 반면 상품 설명 조회는 오래된 정보임을 허용하는 범위에서 캐시 응답을 제공할 수 있습니다. 예를 들어 설명은 보이더라도 거래 확정 버튼을 누른 결과는 별도로 검증하겠습니다. 다만 이 정책은 설명의 지연을 허용한다는 가정에 의존하므로 서비스 요구가 바뀌면 다시 정해야 합니다.”

일관성은 여러 의미로 쓰이므로 여기서는 여러 사본을 읽더라도 최신의 한 상태를 관찰하는 강한 일관성을 뜻한다고 범위를 밝힌다. 네트워크 분할 상황에서는 이 조건과, 실패하지 않은 노드에 도달한 요청에 계속 응답한다는 가용성을 함께 보장할 수 없는 경우가 생긴다. 이를 평상시에도 무조건 둘 중 하나만 고르는 규칙으로 외우면 답변이 흐려진다.

면접관이 “거래 확정을 거절하면 사용자 경험이 나쁘지 않습니까?”라고 물으면 비용을 인정한다. 잠시 거래하지 못하는 불편과 충돌한 거래를 나중에 정리하는 비용을 비교한 결정이라고 설명한다. 단순히 데이터가 중요하다고 끝내지 말고, 사용자가 어떤 결과를 받는지까지 연결한다.

화이트보드 설명의 순서

  1. 상품 조회와 알림을 범위로 정하고 제외 기능을 적는다.
  2. 조회량, 집중 배수, 허용 지연과 최신성 조건을 적는다.
  3. 기본 요청 경로를 그린 뒤 읽기와 알림 경로를 나눈다.
  4. 문제에 맞춰 캐시와 큐를 추가하고 도입 근거를 말한다.
  5. 구성 요소 하나씩 실패시켜 남는 기능과 중단할 기능을 설명한다.
  6. 가정의 한계와 다음 검증 항목을 한두 개 남긴다.

신입에게 기대하는 수준은 모든 기술 이름을 아는 것이 아니다. 계산의 단위를 맞추고, 데이터가 흐르는 경로를 설명하며, 질문을 받았을 때 가정을 고칠 수 있는 수준이다. 면접관이 유입량을 두 배로 바꾸면 그림 전체를 다시 그리기보다 어느 경로가 먼저 영향을 받는지 설명한다. 시간 안에 기본 설계와 주요 절충을 전달하는 것도 설계 능력이다.

완성 코드

다음 내용을 main.py로 저장한다. 외부 패키지 없이 Python 3.12 이상에서 실행한다. 요청 순서와 모의 시각을 고정했으며 난수를 사용하지 않는다. 실제 시계, 대기 함수, 네트워크에도 의존하지 않아 매번 같은 결과를 낸다.

캐시 실험은 상품 조회 한 번이 데이터베이스 읽기 한 번에 대응한다고 가정한다. 캐시 용량 제한, 만료, 변경, 동시 요청은 제외한다. 큐 실험은 한 틱을 1초로 보고, 틱 시작에 작업을 받은 뒤 최대 두 개를 즉시 처리한다. 대기 길이는 각 틱의 처리가 끝난 직후 큐에 남은 개수다.

from collections import deque
from math import ceil


def traffic_estimate():
    daily_users = 20_000
    views_per_user = 12
    peak_factor = 10
    daily_reads = daily_users * views_per_user
    average = daily_reads / 86_400
    peak = ceil(average * peak_factor)
    return daily_reads, average, peak


def compare_cache():
    requests = (
        "A", "B", "A", "A", "C", "B",
        "A", "D", "A", "B", "C", "A",
    )
    database = {
        "A": "의자",
        "B": "책상",
        "C": "조명",
        "D": "책장",
    }
    cache = {}
    database_reads = 0
    hits = 0

    for product_id in requests:
        if product_id in cache:
            hits += 1
        else:
            cache[product_id] = database[product_id]
            database_reads += 1

    return len(requests), database_reads, hits


def simulate_queue():
    arrivals = (4, 4, 1, 0, 0)
    capacity = 2
    pending = deque()
    rows = []
    next_job_id = 1
    total_processed = 0
    max_pending = 0

    for tick, count in enumerate(arrivals):
        for _ in range(count):
            pending.append(next_job_id)
            next_job_id += 1

        processed = min(capacity, len(pending))
        for _ in range(processed):
            pending.popleft()

        total_processed += processed
        max_pending = max(max_pending, len(pending))
        rows.append((tick, count, processed, len(pending)))

    return rows, total_processed, max_pending, len(pending)


def main():
    daily, average, peak = traffic_estimate()
    print("[트래픽 가정]")
    print(f"하루 상세 조회: {daily:,}건")
    print(f"평균 초당 조회: {average:.2f}건")
    print(f"집중 시간대 초당 조회: {peak}건")

    total, database_reads, hits = compare_cache()
    print("\n[캐시 비교]")
    print(f"전체 조회: {total}건")
    print(f"캐시 없음 DB 요청: {total}건")
    print(f"캐시 있음 DB 요청: {database_reads}건")
    print(f"캐시 적중: {hits}건 ({hits / total:.1%})")

    rows, processed, maximum, remaining = simulate_queue()
    print("\n[큐: 1틱=1초, 틱당 최대 2건 처리]")
    print("시각(초) 유입 처리 대기")
    for tick, count, done, waiting in rows:
        print(f"{tick:8d} {count:4d} {done:4d} {waiting:4d}")
    print(f"총 처리: {processed}건")
    print(f"최대 대기 길이(틱 종료): {maximum}건")
    print(f"종료 시 대기: {remaining}건")


if __name__ == "__main__":
    main()

줄별 해설

deque는 먼저 넣은 작업을 앞에서 꺼내는 용도로 쓴다. ceil은 집중 시간대 요청 수의 소수 부분을 올린다. 어림 계산에서 소수점 아래를 버려 필요한 규모를 낮춰 말하지 않도록 한 선택이다. 다만 28이라는 결과가 실제 관측된 최댓값이라는 뜻은 아니다.

traffic_estimate()의 앞 세 줄은 면접에서 합의한 가정을 변수로 옮긴다. daily_reads는 사람 수와 사람당 조회 수를 곱한다. average는 하루의 초 수로 나눈 값이며, peak는 평균에 집중 배수를 곱한 뒤 올린다. 단위가 사람, 하루 요청, 초당 요청으로 바뀌는 과정을 구분해서 읽는다.

compare_cache()의 requests는 시간 순서대로 들어온 12번의 상품 조회다. 상품 식별자는 네 종류다. database는 외부 저장소를 대신하는 사전이며 실제 데이터베이스 성능을 재현하지 않는다. 이 실험은 속도가 아니라 접근 횟수를 센다.

cache = {}는 비어 있는 상태에서 시작한다는 뜻이다. 반복문에서 키가 있으면 적중 수만 늘린다. 키가 없으면 원본 값을 가져와 캐시에 저장하고 데이터베이스 요청 수를 하나 늘린다. 따라서 A, B, C, D의 첫 조회만 원본에 접근하고, 이후 여덟 번은 캐시에서 처리한다. 캐시가 없는 비교군은 모든 요청이 원본을 읽으므로 별도 반복문 없이 전체 요청 수로 계산한다.

simulate_queue()의 arrivals는 각 틱에 들어오는 작업 수다. capacity는 작업자 전체가 한 틱에 끝낼 수 있는 최대 작업 수다. 작업자 수와 개별 작업 시간을 따로 모델링하지 않고 합산 처리 능력으로 표현한다.

각 틱에서 append()로 새 작업을 넣은 뒤 처리량을 정한다. min()은 작업이 하나뿐일 때 두 개를 꺼내지 않도록 제한한다. popleft()는 먼저 접수한 작업부터 제거한다. rows에는 출력용 기록을 저장하고, max_pending에는 처리 후 대기 길이의 최댓값을 남긴다.

첫 틱에는 네 개를 받고 두 개를 처리해 두 개가 남는다. 다음 틱에는 기존 두 개와 신규 네 개 중 두 개를 처리해 네 개가 남는다. 이때 처리 직전에는 여섯 개가 있었지만 코드가 기록하는 최대 대기는 틱 종료 기준 네 개다. 숫자를 설명할 때 측정 시점을 함께 말해야 한다.

main()은 세 실험의 결과를 정해진 형식으로 출력한다. :.2f는 평균을 소수 둘째 자리까지, :.1%는 적중률을 백분율 소수 첫째 자리까지 표시한다. 마지막 조건문은 이 파일을 직접 실행했을 때만 main()을 호출한다.

실행 결과

python3 main.py
[트래픽 가정]
하루 상세 조회: 240,000건
평균 초당 조회: 2.78건
집중 시간대 초당 조회: 28건

[캐시 비교]
전체 조회: 12건
캐시 없음 DB 요청: 12건
캐시 있음 DB 요청: 4건
캐시 적중: 8건 (66.7%)

[큐: 1틱=1초, 틱당 최대 2건 처리]
시각(초) 유입 처리 대기
       0    4    2    2
       1    4    2    4
       2    1    2    3
       3    0    2    1
       4    0    1    0
총 처리: 9건
최대 대기 길이(틱 종료): 4건
종료 시 대기: 0건

캐시가 있는 경우 원본 요청은 12건에서 4건으로 줄었다. 이는 같은 상품이 반복되고 캐시가 사라지지 않는 입력의 결과다. 서로 다른 상품만 조회하면 이 실험의 캐시는 원본 요청을 줄이지 못한다. 여기서 나온 66.7%를 실제 서비스의 예상 적중률로 사용해서는 안 된다.

큐는 아홉 작업을 모두 처리했지만 초반 유입을 즉시 끝내지는 못했다. 유입이 줄어들어야 대기가 사라졌다. 또한 이 코드는 메모리에만 작업을 저장하므로 프로세스가 끝나면 작업도 사라진다. 내구성, 재전달, 작업 실패와 작업자 장애를 검증하는 실험은 아니다.

실무에서 자주 틀리는 것

평균 요청 수를 너무 일찍 정수로 바꾼다

다음 계산은 평균 2.78을 2로 버린 뒤 집중 배수를 곱해 20을 만든다. 어림 계산이라도 중간 값을 버리면 판단이 달라질 수 있다.

# 틀린 코드
def estimate_peak(daily_reads, peak_factor):
    return (daily_reads // 86_400) * peak_factor

중간 값은 유지하고 마지막에 필요한 방향으로 반올림한다. 집중 배수 자체가 가정이라는 점도 함께 기록한다.

# 고친 코드
from math import ceil


def estimate_peak(daily_reads, peak_factor):
    return ceil(daily_reads / 86_400 * peak_factor)

캐시 값의 참·거짓으로 적중을 판단한다

관심 수가 0인 상품도 정상적으로 캐시할 수 있다. 값을 조건식에 바로 넣으면 0을 미적중으로 오해한다.

# 틀린 코드
def read_count(cache, key, load):
    value = cache.get(key)
    if not value:
        value = load(key)
        cache[key] = value
    return value

이 모델에서는 키의 존재로 적중을 판단한다. 실제 만료 기능을 추가한다면 존재 여부 외에 만료 조건도 확인해야 한다.

# 고친 코드
def read_count(cache, key, load):
    if key not in cache:
        cache[key] = load(key)
    return cache[key]

유입량만으로 대기 길이를 계산한다

이전 틱의 대기를 빼면 집중이 누적되는 모습을 놓친다. 다음 함수는 이전 대기 수를 받아도 사용하지 않는다.

# 틀린 코드
def next_waiting(previous, incoming, capacity):
    return max(0, incoming - capacity)

기존 대기와 신규 유입을 합친 뒤 처리 가능량을 뺀다. 작업 수와 처리량은 음수가 아니라는 입력 조건을 전제로 한다.

# 고친 코드
def next_waiting(previous, incoming, capacity):
    return max(0, previous + incoming - capacity)

접수만 하고 발송 완료로 응답한다

큐에 넣었다는 사실은 알림이 전달되었다는 뜻이 아니다. 다음 코드는 사용자에게 실제보다 강한 약속을 한다.

# 틀린 코드
def request_notice(job, enqueue):
    enqueue(job)
    return {"status": "sent"}

접수 함수가 내구성 있는 저장 확인 뒤에 작업 식별자를 반환한다고 가정하고, 응답도 접수 상태로 표현한다. 발송 결과는 별도로 조회할 수 있어야 한다. 메모리 큐에 넣는 것만으로 이 가정을 충족하지는 못한다.

# 고친 코드
def request_notice(job, enqueue_durably):
    job_id = enqueue_durably(job)
    return {"status": "accepted", "job_id": job_id}

한눈에 보기

문제 증상별 설계 후보와 남는 질문
문제검토할 선택기대 효과남는 질문
같은 상품을 반복 조회한다.캐시원본 읽기 요청을 줄인다.갱신 지연과 동시 미적중은?
알림 발송 대기가 응답을 늦춘다.큐와 작업자접수와 실행을 분리한다.누적, 유실, 중복 처리는?
한 저장소에 읽기가 집중된다.읽기 복제본읽기 부하를 나눈다.복제 지연을 어디까지 허용하는가?
한 노드의 저장량·쓰기가 한계다.샤딩데이터와 부하를 분할한다.편중과 여러 조각 조회는?
구성 요소 하나가 멈추면 기능이 중단된다.다중화와 전환 경로다른 경로에서 기능을 이어 간다.전환 판단과 남은 처리 능력은?
최신 상태를 확인할 수 없다.기능별 허용 정책정확성과 응답의 기준을 정한다.거절할 요청과 허용할 조회는?

면접에서는 선택 하나마다 “어떤 조건 때문에 도입했는가”와 “어떤 문제는 남는가”를 붙인다. 코드에서 확인한 요청 감소와 대기 누적을 근거로 쓰되, 작은 실험의 결과를 실제 처리 성능으로 확대하지 않는다. 다음 장에서는 설계한 서비스의 상태를 어떻게 관찰하고 배포와 장애 상황에 대응할지 살펴본다.

연습 문제

  1. 하루 활동 사용자 3만 명이 상품 상세를 각각 8번 조회하고, 집중 배수가 6이라고 가정한다. 하루 요청 수, 평균 초당 요청 수, 올림한 집중 시간대 초당 요청 수를 구한다. 이 계산만으로 서버 수를 확정할 수 없는 이유도 쓴다.
  2. 캐시 실험의 요청을 A, B, C, D, A, B, C, D로 바꾼다. 빈 캐시에서 시작하고 만료가 없을 때 원본 요청 수와 적중률을 구한다. 캐시가 B의 첫 재조회 직전에 비워진다면, 즉 다섯 번째 요청 A를 처리한 다음 비워진다면 결과가 어떻게 바뀌는가?
  3. 큐의 유입을 4, 4, 4, 4, 4로 바꾸고 틱당 처리량은 2로 유지한다. 각 틱 종료 후 대기 길이를 구한다. 유입이 같은 속도로 계속될 때 유한한 큐 용량만 늘리는 방법의 한계를 설명한다.
  4. 면접관이 “애플리케이션 두 대와 캐시 하나, 데이터베이스 하나면 가용성이 충분합니까?”라고 묻는다. 결론, 근거, 예시, 한계와 대안의 순서로 답한다. 캐시 중단과 데이터베이스 중단을 구분한다.

정답과 해설

  1. 하루 요청은 30,000 곱하기 8로 240,000건이다. 평균은 약 2.78건이고 집중 배수 6을 적용하면 약 16.67건이므로 올림 결과는 17건이다. 요청당 처리 비용, 응답 시간 목표, 데이터 크기와 부하 편중을 모르므로 서버 수를 확정할 수 없다. 같은 일일 요청 수라도 집중 배수가 다르면 필요한 여유가 달라진다.

  2. 캐시를 유지하면 네 상품의 첫 조회만 원본을 읽는다. 원본 요청은 4건, 적중은 4건으로 적중률은 50%다. 다섯 번째 A를 처리한 뒤 비우면 첫 네 번은 미적중, 다섯 번째만 적중, 뒤의 B·C·D는 다시 미적중이다. 따라서 원본 요청은 7건, 적중률은 12.5%다. 적중률은 조회 대상뿐 아니라 캐시가 유지되는 조건에도 의존한다.

  3. 대기 길이는 2, 4, 6, 8, 10건이다. 매 틱 순유입이 두 건이므로 같은 조건이 이어지면 대기가 계속 늘어난다. 큐 용량 증가는 가득 차는 시점을 늦출 뿐이다. 지속 가능한 처리를 위해 소비 능력을 높이거나 유입을 제한해야 한다. 작업자를 늘릴 때도 발송 대상 시스템이 추가 부하를 받을 수 있는지 확인한다.

  4. “두 대라는 정보만으로 충분하다고 판단할 수 없습니다. 두 서버가 같은 데이터베이스에 의존하면 그 저장소가 멈췄을 때 둘 다 주요 기능을 수행하지 못합니다. 예를 들어 캐시가 중단되면 원본 조회로 우회할 수 있지만, 데이터베이스가 중단되면 캐시 미적중과 쓰기는 처리하기 어렵습니다. 원본 우회도 부하를 감당한다는 검증이 필요합니다. 요구하는 중단 허용 시간에 맞춰 저장소 복제와 장애 전환을 검토하고, 장애 중 제공할 조회와 거절할 거래를 정하겠습니다.”라고 답할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.