테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

설계 사례: 웹 서비스

URL 단축, 웹 크롤러, 알림, 뉴스 피드처럼 읽기와 쓰기 비율이 서로 다른 웹 서비스를 설계하며 부품을 고르는 법이다.

목차 (4)
  1. URL 단축기
  2. 웹 크롤러
  3. 알림 시스템
  4. 뉴스 피드

네 사례 모두 같은 틀로 본다. 먼저 요구와 규모를 정하고 숫자를 직접 어림한다. 그 숫자가 핵심 설계의 모양을 정하고, 설계를 밀어붙이면 드러나는 어려운 점과 선택지를 따진 뒤, 실제 회사가 공개한 사례로 확인한다. 숫자는 이 노트가 세운 가정이니 그대로 외울 것이 아니라 계산하는 순서를 보면 된다. 어림 방법은 설계 면접과 개략 추정 노트, 로드 밸런서·캐시·ID 생성기 같은 부품은 설계의 부품 노트에 있다.

URL 단축기

요구와 규모

긴 주소를 받아 짧은 코드를 돌려주고, 누가 짧은 주소를 열면 원래 주소로 보낸다. 한 번 만든 코드는 몇 년이고 살아 있어야 하며, 같은 코드가 두 주소를 가리켜서는 안 된다. 하루에 새 링크가 2천만 개 생기고, 링크 하나를 평균 50번 연다고 하자.

  • 쓰기: 20,000,000 ÷ 86,400초 ≈ 초당 230건. 읽기는 50배인 초당 약 1만 1,600건이고, 저녁 시간대에 세 배로 몰리면 초당 3만 5천 건쯤이다. 읽기가 압도적이므로 설계의 중심은 읽기 길이다.
  • 5년 동안 쌓이는 링크: 2천만 × 365 × 5 = 365억 개. 코드에 숫자 10개와 영문 대소문자 52개, 모두 62가지 글자(base62)를 쓰면 5글자는 62⁵ ≈ 9억 개라 모자라고, 6글자는 62⁶ ≈ 568억 개라 넉넉하다.
  • 저장: 링크 하나에 원래 주소와 메타데이터를 합쳐 300바이트로 잡으면 365억 × 300B ≈ 11TB. 한 대에 담기는 버겁지만 몇 조각으로 나누면 충분한 크기다.

핵심 설계

코드를 만드는 방법은 크게 둘이다. 하나는 긴 주소를 해시해 앞 몇 글자를 쓰는 것이다. 같은 주소는 늘 같은 코드가 되지만, 앞 6글자만 자르면 서로 다른 주소가 부딪힐 수 있어 DB에 있는지 확인하고 부딪히면 소금(salt)을 붙여 다시 해시해야 한다. 다른 하나는 겹치지 않는 숫자 ID를 받아 base62로 바꾸는 것이다. 숫자가 겹치지 않으니 코드도 겹치지 않고 확인이 필요 없다. 여러 서버가 ID를 겹치지 않게 내는 법(데이터베이스 시퀀스 묶음 배분, 시각·기계 번호·순번을 합친 64비트 ID)은 설계의 부품 노트에 있다.

URL 단축기의 두 길: 쓰기는 클라이언트가 긴 URL을 보내면 API 서버가 ID 생성기에서 숫자 ID를 받아 base62 코드로 바꾸고 DB에 저장한다. 읽기는 브라우저가 짧은 코드로 요청하면 API 서버가 캐시와 DB에서 원래 URL을 찾아 301 또는 302 응답의 Location 헤더로 돌려보낸다쓰기: 짧은 코드 만들기클라이언트API 서버ID 생성기DB긴 URLID 요청코드 저장예: ID 31415926535 → base62 코드 yi61Cv (6글자)읽기: 원래 주소로 보내기브라우저API 서버캐시DBGET /코드조회없을 때301(영구) 또는 302(임시) + Location: 원래 URL
쓰기는 ID 생성기에서 받은 숫자를 base62로 바꿔 저장한다. 읽기는 캐시에서 먼저 찾고 없을 때만 DB로 가며, 찾은 주소를 Location 헤더에 담아 리다이렉트한다.

읽기 길은 단순한 키-값 조회다. 코드가 키이고 원래 주소가 값이며, 범위 질의나 조인이 없으니 키-값 저장소나 코드로 샤딩한 관계형 DB 어느 쪽도 맞는다. 자주 열리는 링크는 소수에 몰리므로 메모리 캐시의 효과가 크다. 하루 읽기 약 10억 번 중 뜨거운 링크 1천만 개만 캐시에 올려도 300B × 1천만 = 3GB로 서버 한 대 메모리에 들어간다.

어려운 점과 선택지

  • 301과 302: HTTP 의미론 표준(RFC 9110)에서 301은 "영구히 옮겼다"는 뜻이라 따로 막지 않으면 브라우저와 중간 캐시가 기억해 둔다. 두 번째 클릭부터는 단축기 서버에 오지 않으니 부하는 줄지만 클릭 수를 셀 수 없다. 302는 "지금은 저쪽에 있다"는 뜻이라 기본으로 캐시하지 않아 클릭마다 서버를 거친다. 클릭 통계가 상품이면 302, 비용이 우선이면 301이다. 둘 다 POST를 GET으로 바꿔 다시 보낼 수 있어서, 메서드를 지켜야 하면 307·308을 쓴다.
  • 순서대로 늘어나는 코드: ID를 그대로 base62로 바꾸면 코드가 1씩 늘어 남의 링크를 차례로 긁어 갈 수 있다. 비공개 문서 링크가 섞이는 서비스라면 ID를 되돌릴 수 있는 방식으로 섞거나(뒤섞은 순열, 블록 암호) 무작위 코드를 쓰고 충돌을 확인한다.
  • 통계를 읽기 길에서 빼기: 클릭 기록을 리다이렉트 응답 전에 DB에 쓰면 읽기 지연이 쓰기 지연을 따라간다. 응답은 바로 보내고 클릭 이벤트는 메시지 큐로 흘려 따로 모은다. 모으는 쪽은 데이터 파이프라인 사례 노트의 광고 클릭 집계와 같은 문제다.
  • 악용: 단축 주소는 피싱 주소를 숨기기 좋다. 만들 때 알려진 악성 주소 목록과 대조하고, 신고된 코드는 경고 페이지로 바꿀 수 있어야 한다.

실제 사례

Google은 goo.gl 단축기의 새 링크 생성을 2018년부터 단계적으로 멈췄고, 2024년 기존 링크도 2025년 8월에 끊겠다고 공지했다가, 2025년 8월에 최근까지 쓰이던 링크는 남기고 활동 없던 링크만 끄는 쪽으로 바꿨다. 단축 주소는 책, 논문, 인쇄물에 박혀 수십 년을 살기 때문에, 설계의 진짜 요구는 처리량보다 오래 지키겠다는 약속이라는 점을 보여 준다. 겹치지 않는 64비트 ID를 서버마다 따로 내는 방식의 원형은 Twitter가 공개한 Snowflake다.

웹 크롤러

요구와 규모

검색 색인을 채우려고 웹 페이지를 계속 내려받는다. 일주일에 3억 쪽을 새로 받거나 다시 확인하고, 한 쪽은 평균 150KB라고 하자.

  • 처리량: 300,000,000 ÷ 604,800초 ≈ 초당 약 500쪽. 대역폭은 500 × 150KB ≈ 75MB/s, 약 600Mbps다.
  • 저장: 일주일에 300,000,000 × 150KB = 45TB. 압축하면 몇 분의 일로 줄지만, 여러 주를 보관하면 금세 PB 단위가 된다.
  • 예의: 한 사이트에 2초에 한 번만 요청한다면 초당 500쪽을 채우려면 동시에 1,000개 넘는 사이트를 돌아가며 받아야 한다. 크롤러의 병목은 대역폭보다 이 "얼마나 고르게 퍼뜨리나"다.

핵심 설계

크롤러는 큐 하나를 도는 순환이다. 아직 받지 않은 URL을 담는 프런티어(frontier)가 중심이고, 페처가 거기서 꺼내 받고, 파서가 본문과 링크를 나눈다. 링크는 정규화(상대 경로를 절대 경로로, 호스트 소문자로, 쓸모없는 추적 파라미터 제거)한 뒤 이미 본 URL인지 확인해 처음 본 것만 프런티어에 다시 넣는다.

웹 크롤러의 순환: 시드 URL이 URL 프런티어로 들어가고, 프런티어는 우선순위 큐에서 호스트별 큐로 나눠 한 호스트에 천천히 요청하게 한다. 페처가 robots.txt와 DNS 캐시를 보며 페이지를 받고, 파서가 본문과 링크를 나눈다. 본문은 내용 해시로 중복을 걸러 저장하고, 링크는 정규화해 블룸 필터로 처음 본 URL만 프런티어에 다시 넣는다시드 URLURL 프런티어높음보통낮음a.exampleb.examplec.example우선순위 큐호스트별 큐페처robots.txt·DNS 캐시파서본문과 링크 분리호스트마다 간격을 두고 요청본문내용 중복 검사해시·심해시새 내용만페이지 저장소링크URL 정규화상대 경로·대소문자본 URL 필터블룸 필터 + 정확한 집합처음 본 URL만이미 본 URL은 버린다
프런티어는 두 층이다. 앞쪽 우선순위 큐가 무엇을 먼저 받을지 정하고, 뒤쪽 호스트별 큐가 한 사이트에 요청이 몰리지 않게 간격을 둔다. 본문은 내용 해시로, 링크는 본 URL 필터로 중복을 거른다.

프런티어가 두 층인 이유는 목표가 둘이기 때문이다. 앞쪽은 중요도(링크 인기, 갱신 주기, 사이트 품질)로 우선순위를 나누고, 뒤쪽은 예의(politeness)를 지킨다. 호스트 하나가 뒤쪽 큐 하나에 매이고, 큐마다 "다음에 요청해도 되는 시각"을 두어 워커는 시각이 지난 큐에서만 꺼낸다. 이렇게 하면 워커 수와 상관없이 한 사이트에 동시 요청이 하나를 넘지 않는다.

어려운 점과 선택지

  • robots.txt: 사이트 주인이 크롤러에 거절 의사를 밝히는 규약이 2022년 RFC 9309로 표준이 됐다. 크롤러는 사이트마다 이 파일을 받아 캐시하되 24시간보다 오래 쓰지 않아야 하고, 파일이 없으면(4xx) 모두 허용으로 보지만 서버 오류(5xx)로 받지 못하면 모두 금지로 봐야 한다. 페처 앞에 robots 캐시를 두는 이유다. 구글의 해석은 Google 검색 센터 문서에 있다.
  • 본 URL인지 확인: URL이 수백억 개면 정확한 집합을 메모리에 다 두기 어렵다. 블룸 필터는 "없다"는 답이 늘 맞고 "있다"는 답만 가끔 틀리므로, 처음 본 URL을 대부분 메모리에서 바로 걸러 낸다. 대가는 처음 보는 URL을 본 것으로 착각해 놓치는 것이라, 놓치면 안 되는 URL은 디스크의 정확한 집합으로 다시 확인한다.
  • 같은 내용, 다른 주소: 웹의 상당수는 거울 사이트, 인쇄용 페이지, 세션 ID만 다른 주소처럼 내용이 같거나 거의 같다. 완전히 같은 것은 본문 해시로, 거의 같은 것은 비슷한 문서끼리 비슷한 지문이 나오는 심해시(simhash)로 거른다.
  • 함정: 끝없이 다음 달로 넘어가는 달력, 링크마다 새 세션 ID를 붙이는 사이트는 URL을 무한히 만든다. 경로 깊이와 사이트당 쪽 수에 상한을 두고, 렌더링이 필요한 자바스크립트 페이지는 비싼 별도 대기열로 보낸다.

실제 사례

Google은 2007년 논문 Detecting Near-Duplicates for Web Crawling에서 수십억 쪽 규모에서 64비트 심해시 지문 중 몇 비트만 다른 문서를 빠르게 찾는 방법을 보였다. 지문을 비트 블록으로 나눠 여러 순열 표에 넣으면, 몇 비트만 다른 지문은 적어도 한 표에서 앞부분이 일치하므로 그 표의 좁은 범위만 뒤지면 된다. 크롤러는 받을 때마다 이 질의를 던져 거의 같은 쪽이면 색인하지 않는다.

알림 시스템

요구와 규모

주문 상태, 채팅, 마케팅처럼 여러 서비스가 사용자에게 모바일 푸시, 이메일, 문자를 보낸다. 사용자는 알림 종류별로 끌 수 있고, 같은 알림을 두 번 받으면 안 된다. 기기 2천만 대에 하루 5천만 건을 보낸다고 하자.

  • 평균은 50,000,000 ÷ 86,400 ≈ 초당 580건으로 작다.
  • 그런데 마케팅 발송은 1천만 명에게 10분 안에 보내려 한다. 10,000,000 ÷ 600초 ≈ 초당 1만 7천 건, 평균의 서른 배다. 알림 시스템의 설계는 평균이 아니라 이런 몰림을 받아 내는 쪽에 맞춘다.

핵심 설계

서버가 휴대폰에 직접 연결해 두지 않는다. iOS는 Apple의 APNs, Android는 Google의 FCM이 기기와 연결을 유지하고, 우리 서버는 기기 토큰과 내용을 이 중계 서비스에 HTTP로 넘긴다. 그 앞에 알림 서비스를 두어 수신 설정 확인, 템플릿 채우기, 사용자별 속도 제한, 중복 제거를 한 곳에서 하고, 채널마다 메시지 큐를 따로 둔다.

알림 시스템: 주문·채팅·마케팅 같은 서비스가 알림 서비스에 요청한다. 알림 서비스는 수신 설정 확인, 템플릿 채우기, 속도 제한, 중복 제거를 거쳐 iOS·Android·메일과 문자 큐에 넣는다. 워커가 큐에서 꺼내 APNs, FCM, 메일과 문자 업체로 보내고, 실패하면 재시도 큐로 돌린다. 기기 토큰과 설정은 따로 DB에 둔다보내는 쪽채널별 큐외부 전달 업체주문 서비스iOS 큐APNs채팅 서비스Android 큐FCM마케팅 도구메일·문자 큐메일·문자 업체알림 서비스수신 설정 확인템플릿 채우기속도 제한중복 제거 키워커기기 토큰·설정 DB실패재시도 큐(백오프)
큐를 채널별로 나누면 문자 업체가 느려져도 푸시가 막히지 않는다. 워커는 실패한 건을 재시도 큐로 보내 간격을 늘려 가며 다시 시도한다.

큐가 몰림을 받아 낸다. 마케팅 도구가 1천만 건을 한꺼번에 넣어도 워커는 자기 속도로 꺼내 가고, 중계 서비스가 허락하는 속도보다 빨리 보내지 않는다. 기기 토큰은 앱을 지우거나 다시 깔면 바뀌므로, 중계 서비스가 "더는 유효하지 않은 토큰"이라고 답하면 토큰 DB에서 지워야 한다. 그대로 두면 죽은 토큰에 계속 보내 처리량을 낭비한다.

어려운 점과 선택지

  • 재시도: 중계 서비스가 잠시 응답하지 않으면 다시 보내야 한다. 모든 워커가 같은 간격으로 다시 보내면 복구되는 순간 한꺼번에 몰리므로, 간격을 지수로 늘리고 무작위 흔들림(jitter)을 섞는다. 이 원리는 AWS의 Timeouts, retries, and backoff with jitter가 잘 정리했다.
  • 중복: 재시도는 "보냈는데 응답만 잃어버린" 경우에도 일어나므로, 큐는 보통 최소 한 번 전달이다. 그래서 알림마다 사건 ID로 중복 제거 키를 만들어, 이미 보낸 키면 건너뛴다. 기기 쪽에서도 겹침을 줄일 수 있다. APNs의 apns-collapse-id나 FCM의 접을 수 있는 메시지(collapse key)는 같은 키의 새 알림이 오면 옛 알림을 대신하게 한다.
  • 쓸모가 지난 알림: "택시가 도착했습니다"는 10분 뒤에 오면 해롭다. 중계 서비스에 만료 시각(TTL)을 넘겨, 기기가 꺼져 있다 늦게 켜지면 버리게 한다.
  • 사용자 보호: 한 사람에게 하루 몇 건 이상 보내지 않는 속도 제한, 밤 시간 조용히 하기, 수신 거부 존중은 기능이 아니라 요구다. 마케팅 알림을 막지 않으면 사용자는 알림을 통째로 끈다.

실제 사례

알림 시스템의 바깥 경계는 모두 공개 문서로 정해져 있다. APNs 문서는 요청마다 우선순위(apns-priority)와 만료 시각(apns-expiration)을 지정하고, 토큰이 무효하면 410 상태로 답한다고 적는다. FCM 문서는 접을 수 있는 메시지와 접을 수 없는 메시지를 나누고, 기기가 오프라인일 때 쌓아 둘 수 있는 메시지 수에 상한을 둔다. 우리 쪽 설계는 이 경계 안에서 큐, 재시도, 중복 제거를 어떻게 두느냐다.

뉴스 피드

요구와 규모

사용자가 따르는 사람들의 새 글을 모아 시간순(또는 추천순)으로 보여 준다. 하루 활성 사용자 3천만 명이 평균 0.5개 글을 쓰고, 팔로워는 평균 200명이며, 하루에 열 번 피드를 연다고 하자.

  • 글 쓰기: 1,500만 ÷ 86,400 ≈ 초당 174건. 피드 열기: 3억 ÷ 86,400 ≈ 초당 3,500건.
  • 글마다 팔로워 타임라인에 넣는다면 1,500만 × 200 = 하루 30억 번, 초당 약 3만 5천 번의 쓰기다. 읽기보다 쓰기가 열 배 많아진다.
  • 팔로워가 5천만인 계정은 글 하나가 5천만 번의 쓰기다. 초당 10만 번 넣을 수 있어도 500초, 8분이 넘게 걸린다.

핵심 설계

피드를 만드는 시점에 따라 두 방식이 있다. 쓸 때 팬아웃(fan-out on write, push)은 글이 올라오면 팔로워마다 미리 만들어 둔 타임라인 캐시에 글 ID를 밀어 넣는다. 읽기는 캐시 하나만 보면 되어 빠르지만 쓰기가 팔로워 수만큼 불어난다. 읽을 때 팬아웃(fan-out on read, pull)은 피드를 열 때 따르는 사람들의 최근 글을 모아 합친다. 쓰기는 한 번이지만 읽기마다 수백 명의 글을 모아야 한다. 대부분의 서비스는 둘을 섞는다.

뉴스 피드 혼합 팬아웃 단계 그림: 일반 사용자 A의 글은 게시물 저장소에 저장한 뒤 팔로워 셋의 타임라인 캐시에 글 ID를 밀어 넣는다. 팔로워가 5천만인 계정 C의 글은 저장만 한다. 독자 3이 피드를 열면 캐시의 A 글과 C의 최근 글을 끌어와 시간순으로 합친다사용자 A팔로워 3명A#1 저장유명 계정 C팔로워 5천만 명C#1 저장, 팬아웃 안 함게시물 저장소+ 팬아웃 워커독자 1 타임라인 캐시(비어 있음)A#1독자 2 타임라인 캐시(비어 있음)A#1독자 3 타임라인 캐시(비어 있음)A#1글 ID만 밀어 넣는다독자 3의 피드아직 열지 않음C#1 · A#1 (시간순)C 글 끌어옴캐시 읽음
1 / 4

일반 사용자 A(팔로워 셋)가 글을 쓴다. 글 본문은 게시물 저장소에 한 번만 저장한다.

혼합 팬아웃. 보통 계정은 쓸 때 밀어 넣어 읽기를 싸게 하고, 팔로워가 아주 많은 계정만 읽을 때 끌어와 쓰기 폭주를 피한다.

캐시에는 글 본문이 아니라 글 ID만 넣는다. 본문과 작성자 정보는 따로 캐시해 두고 피드를 그릴 때 채운다(hydration). 그래야 글을 고치거나 지울 때 수백만 개의 타임라인을 고치지 않는다. 타임라인 캐시는 최근 수백 개까지만 두고, 그보다 오래된 쪽을 넘겨 보는 사람은 드물므로 그때만 저장소에서 끌어온다. 한동안 접속하지 않은 사용자에게는 아예 밀어 넣지 않고 돌아왔을 때 다시 만든다.

어려운 점과 선택지

  • 어디서 나누나: 팔로워 수에 문턱을 두어 그 위는 끌어오기로 돌린다. 문턱이 낮으면 읽기가 비싸지고, 높으면 유명 계정 하나가 팬아웃 큐를 몇 분씩 막는다. 읽기와 쓰기 비용을 같이 재며 정한다.
  • 그래프 조회: 팬아웃은 "누가 이 사람을 따르나"를 초당 수만 번 묻는다. 관계형 DB의 조인보다 팔로우 관계를 키-값처럼 꺼내는 전용 저장소와 캐시가 맞는다.
  • 순서와 추천: 시간순이면 글 ID가 시각 순서를 담고 있어 ID로 정렬하면 된다. 추천순은 후보를 모은 뒤 점수를 매겨 다시 줄 세우는 단계가 하나 더 붙고, 이때는 미리 만든 타임라인이 "후보 목록" 역할을 한다.

실제 사례

Twitter는 2012년 QCon 강연 Timelines at Scale에서 바로 이 구조를 설명했다. 트윗이 올라오면 팔로워마다 메모리에 둔 홈 타임라인에 ID를 밀어 넣어 읽기를 싸게 만들었고, 팔로워가 아주 많은 계정은 팬아웃이 몇 분씩 걸리는 문제가 있어 그런 계정의 글은 읽을 때 합치는 방향을 이야기했다. Facebook은 친구 관계와 좋아요 같은 소셜 그래프를 읽기 위해 MySQL 앞에 그래프 전용 캐시를 두었고, 그 구조를 USENIX ATC 2013 논문 TAO: Facebook's Distributed Data Store for the Social Graph로 공개했다. 읽기가 쓰기보다 압도적으로 많은 그래프에서 캐시가 사실상 저장소 역할을 한다는 점이 피드 설계와 같다.