SiliconValley_survivor

SiliconValley_survivor

LinkedIn Ranking, System & ML

링크드인의 랭킹과 ML

SiliconValley_survivor's avatar
SiliconValley_survivor
Sep 16, 2026
∙ Paid

Introduction & Requirements

백엔드 엔지니어가 LinkedIn을 열었다고 하겠습니다. 어제는 함께 일했던 동료의 이직 소식을 읽었고, 오늘은 데이터베이스 성능 분석 글을 저장했습니다. 같은 사람이 작성한 게시물이어도 제품 출시 소식에는 관심이 있지만 반복되는 홍보 글은 건너뛸 수 있습니다.

서버는 이 사용자가 연결한 사람들의 최신 글만 가져오면 될까요. 그렇게 구현하면 알고 지내는 사람들의 소식은 전달할 수 있습니다. 그러나 사용자가 모르는 전문가의 유용한 글을 찾기 어렵고, 연결한 사람이 많아질수록 최근에 작성됐다는 이유만으로 관련성이 낮은 글이 앞에 놓입니다.

가장 단순한 구현은 관계 테이블에서 팔로우한 작성자를 찾고, 게시물 테이블을 작성 시각 역순으로 조회하는 방식입니다. 여기에 최근 반응 수를 추가하면 초기 피드를 만들 수 있습니다. 이후에는 관계 조회, 게시물 조회, 읽은 글 제외, 공개 범위 검사, 개인화 점수 계산이 요청마다 누적됩니다.

더 어려운 문제는 추천 결과가 다음 학습 데이터를 바꾼다는 점입니다. 어떤 글을 보여주지 않았다면 사용자가 그 글을 좋아했을지 알 수 없습니다. 이미 인기 있는 작성자의 글만 노출하면 그 작성자에게 반응이 더 모이고, 다음 모델도 그 작성자를 선호할 수 있습니다.

이 시스템에서는 게시물 상태, 관계와 권한, 사용자의 현재 관심, 추천 당시 관측할 수 있었던 정보를 함께 다뤄야 합니다. 이 정보들은 갱신 주기와 정합성 요구가 서로 다릅니다.

공개된 구현과 이 글의 설계 가정

2026년 3월 LinkedIn Engineering은 LLM으로 회원과 게시물의 표현을 만드는 통합 Retrieval과, 행동 이력을 순서대로 처리하는 Generative Recommender를 설명했습니다. 해당 글은 GPU에서의 exhaustive nearest-neighbor search도 소개합니다. 따라서 공개된 검색 경로를 모두 ANN이라고 부르거나, 매 요청마다 범용 LLM에 게시물 전체의 순서를 물어본다고 설명하면 정확하지 않습니다. LinkedIn의 2026년 피드 아키텍처 설명

2026년 2월 Feed-SR 논문은 Transformer 기반 순차 Ranking과 당시 배포 경험을 다룹니다. 별도로 2025년 11월에는 기존 FollowFeed를 대체하는 Rust 기반 Retrieval 엔진 FishDB를 공개했습니다. 논문과 블로그의 발표 시점, 담당 경로가 다르므로 이를 하나의 현재 배포도로 단정하지 않겠습니다. Feed-SR 논문, FishDB 기술 설명

아래는 이 공개 자료를 참고해 구성한 교육용 설계입니다. PostgreSQL schema, API, 후보 개수, TTL, SLO, shard key는 이 글의 제안입니다. LinkedIn의 실제 내부 구현 전체와 정확한 배포 설정은 공개 자료만으로 확인할 수 없습니다.

Functional Requirements

  • 사용자는 연결한 사람과 팔로우한 작성자의 글, 관심 분야와 관련된 외부 작성자의 글을 한 피드에서 볼 수 있습니다.

  • 사용자는 게시물을 작성하고 삭제하며 공개 범위를 변경할 수 있습니다.

  • 사용자는 반응, 댓글, 저장, 공유를 남기고, 관심 없는 게시물이나 작성자를 숨길 수 있습니다.

  • 시스템은 사용자 관계, 직업적 관심, 이전 행동, 게시물의 최신성을 바탕으로 후보를 찾고 순서를 결정합니다.

  • 시스템은 이미 본 게시물과 동일 원문을 가리키는 중복 활동을 줄입니다.

  • 시스템은 삭제, 차단, 공개 범위 제한을 추천 점수보다 우선해서 적용합니다.

  • 시스템은 실제 노출과 후속 행동을 추천 결정에 연결하고, 학습과 평가에 사용할 수 있도록 기록합니다.

  • 사용자는 네트워크 연결이 불안정해도 작성 중인 내용을 유지하고, 저장이나 게시 요청의 처리 상태를 확인할 수 있습니다.

Non-Functional Requirements

다음 수치는 실제 LinkedIn의 SLA가 아닌 설계를 위한 가정입니다.

  • Feed API의 서버 처리 시간은 p95 250ms, p99 500ms 이내를 목표로 둡니다. 모바일 통신 시간과 이미지 다운로드 시간은 별도로 측정합니다.

  • 월간 피드 조회 성공률은 99.95%를 목표로 둡니다. 기본 추천으로 응답한 비율을 따로 집계해 개인화 품질 저하가 성공률에 가려지지 않게 합니다.

  • 정상 게시물은 게시 승인 후 60초 이내에 추천 후보로 검색 가능해지는 것을 p95 목표로 둡니다. 사용자에게 보여줄 순위까지 보장하는 목표는 아닙니다.

  • 수집이 완료된 최근 행동은 경량 session feature에 p95 5초 이내로 반영합니다. LLM embedding 갱신은 별도로 p95 5분을 목표로 둡니다.

  • 성공 응답한 게시와 사용자 제어 변경은 정상적인 단일 노드 장애에서 보존합니다. 권한 상태를 확인할 수 없으면 해당 콘텐츠를 응답에서 제외합니다.

  • 숨김과 차단은 성공 응답 이후 시작하는 관련 권한 검사에서 반영합니다. 이미 기기로 전달된 데이터까지 회수한다고 약속하지 않습니다.

  • 회원별 행동 이력과 권한 데이터는 인증된 요청에만 제공하고, 학습 데이터에는 목적에 필요한 정보만 포함합니다.

  • 모델 품질과 함께 피드 요청 1,000건당 추론 비용, 학습 비용, 작성자별 노출 집중도를 평가합니다.

Out of Scope

  • 광고 경매와 과금, 광고 전용 Ranking 모델

  • 채용 공고 추천과 사람 추천의 독립적인 추천 시스템

  • 이미지와 동영상의 업로드, 인코딩, CDN 내부 구현

  • 댓글 트리 전체의 정렬과 메시징 시스템

  • 신고 심사 모델 자체의 학습과 운영자 심사 도구

이 시스템은 미디어 처리 완료 상태와 안전성 판정, 관계 서비스의 권한 결과를 입력으로 받습니다. 광고를 나중에 합치더라도 유기적 게시물의 추천 점수와 광고 입찰가를 그대로 비교하지 않고 별도의 노출 정책을 정의해야 합니다.

Traffic & Capacity Assumptions

아래 계산은 모두 설계를 위한 가정이며, GB와 TB는 십진 단위를 사용합니다.

  • 사용자 수와 요청 빈도를 DAU 5천만 명, 사용자당 하루 8회의 피드 페이지 요청으로 두겠습니다. 하루 요청은 4억 건입니다.

이 요청에는 처음 열기와 추가 스크롤이 포함됩니다. 한 번의 로그인과 한 번의 추천 계산을 같은 단위로 취급하지 않아야 서버 부하를 과소평가하지 않습니다.

  • 평균 QPS와 Peak QPS는 하루 4억 건을 86,400초로 나누어 구합니다. 평균 약 초당 4,630건이고, 피크를 평균의 6배로 두면 약 초당 2만 8천 건입니다.

피드 조합 서비스는 수평 확장할 수 있지만 모든 요청이 같은 GPU pool과 Feature Store를 호출합니다. API 인스턴스만 늘리면 하위 서비스의 대기열이 길어질 수 있습니다.

  • 노출과 이벤트 수는 응답당 게시물 20개, 응답한 항목 중 실제 화면 노출 비율 60%로 가정합니다. 하루 전달 항목은 80억 개, 실제 impression은 48억 건입니다. impression당 노출과 최종 행동 요약을 합쳐 평균 2개 이벤트를 보내면 하루 96억 이벤트, 평균 초당 약 11만 건, 피크 초당 약 67만 건입니다.

응답에 포함됐다는 사실과 실제 화면에서 봤다는 사실을 분리해야 합니다. 이 규모에서는 모든 관측 이벤트를 OLTP RDBMS에 개별 INSERT하기보다 append-only stream으로 수집하는 설계가 적합합니다.

  • 이벤트 저장량은 압축 전 평균 200바이트로 가정하면 하루 약 1.92TB입니다. 원본 이벤트 30일은 약 57.6TB이며 복제, 인덱스, 상세 feature log는 포함하지 않은 값입니다.

모든 후보의 전체 feature를 모든 요청에서 남기면 이보다 훨씬 커질 수 있습니다. 전체 요청의 결정 식별자와 버전은 작게 기록하고, 상세 재현 데이터는 목적에 맞춰 표본 저장합니다.

  • 게시물과 벡터 저장량은 하루 신규 게시물 500만 개, 최근 30일을 기본 후보 집합으로 가정하겠습니다. 최대 1억 5천만 개에 대해 768차원 FP16 벡터만 저장하면 약 230GB이며, 3개 복제본은 약 691GB입니다.

이는 벡터 본문만 계산한 값입니다. 검색 구조, 필터 데이터, 문서 식별자, 업데이트 여유 공간이 추가됩니다. 게시물당 원본 메타데이터를 평균 1KB로 보면 신규 메타데이터는 하루 약 5GB이므로 행동 이벤트와 다른 증가 속도를 보입니다.

  • 응답 대역폭과 추론량은 항목당 1KB의 응답 메타데이터를 가정하면 하루 약 8TB입니다. 평균 약 93MB/s, 피크 약 556MB/s이며 미디어 전송은 제외합니다. 매 요청에서 후보 300개를 정밀 평가하면 피크에 초당 약 840만 건의 사용자와 게시물 조합을 평가합니다.

840만 번의 독립적인 모델 호출을 보내겠다는 뜻은 아닙니다. 후보 batch와 공통 사용자 이력을 함께 처리해야 하며, GPU 수량은 이 workload의 실제 처리량과 p99 측정으로 결정합니다.

Core Entities & Data Modeling

게시물 삭제와 공개 범위 변경은 원본 상태를 기준으로 결정해야 합니다. 반면 반응 수가 몇 초 늦거나 관심 벡터가 몇 분 전 상태여도 피드를 제공할 수 있습니다. 이 차이부터 저장소를 나누겠습니다.

RDBMS에는 게시물 소유권, 공개 범위, 게시 상태, 사용자 제어, 멱등성 정보, Outbox를 둡니다. 조건부 갱신과 고유 제약을 통해 중복 작성이나 잘못된 상태 변경을 방어해야 하는 데이터입니다.

관계 원본은 관계 서비스가 관리하고, 추천용 인접 목록과 작성자별 최근 활동은 별도의 조회 구조로 제공합니다. 팔로우 관계를 추천에 활용하는 것과 비공개 글을 읽을 권한을 확인하는 것은 같은 조회가 아닙니다.

Event Stream에는 노출, 읽기, 클릭, 반응 관측과 원본 상태 변경 이벤트를 둡니다. 여러 consumer가 서로 다른 속도로 읽으며 session feature, 추천 후보, 집계 데이터를 각각 갱신할 수 있습니다.

Object Storage에는 미디어, 학습 데이터 snapshot, 모델 파일, embedding backfill 결과를 둡니다. 큰 데이터를 저렴하게 보관하고 같은 학습 입력을 다시 사용할 수 있어야 합니다.

Cache에는 게시물 표시용 데이터, 짧게 유지하는 페이지 결과, 최근에 응답한 게시물 집합을 둡니다. Cache 자체를 삭제나 차단의 최종 판단 근거로 사용하지 않습니다.

Search와 Vector Index에는 작성자별 후보 목록, 텍스트와 주제 검색 구조, 사용자 벡터에 가까운 게시물 벡터를 둡니다. Graph Index에는 연결 관계와 제한된 범위의 이웃 정보를 둡니다. 요청마다 관계 전체를 여러 단계 탐색하지 않도록 조회 비용을 제한합니다.

Online Feature Store에는 최근 관심 분야, 작성자와의 상호작용 집계, 읽기 패턴과 같은 모델 입력을 둡니다. 각 값에는 계산 시각과 원본 이벤트 기준 시각을 함께 보관합니다.

Warehouse에는 실험 분석, 작성자 노출 분석, 장기 제품 지표를 둡니다. 이 경로의 큰 집계가 사용자 피드 조회의 DB 자원을 점유하지 않도록 분리합니다.

게시물과 Outbox schema

다음은 PostgreSQL 문법으로 작성한 교육용 schema입니다. 계정과 미디어의 세부 테이블은 생략합니다.

CREATE TABLE posts (
    author_id UUID NOT NULL,
    post_id UUID NOT NULL,
    idempotency_key TEXT NOT NULL,
    request_hash TEXT NOT NULL,
    body TEXT NOT NULL,
    visibility TEXT NOT NULL
        CHECK (visibility IN (’public’, ‘connections’)),
    status TEXT NOT NULL DEFAULT ‘pending_review’
        CHECK (status IN (
            ‘pending_review’, ‘published’, ‘restricted’, ‘deleted’
        )),
    version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ,
    PRIMARY KEY (author_id, post_id),
    UNIQUE (author_id, idempotency_key)
);
CREATE INDEX posts_author_recent
    ON posts (author_id, published_at DESC, post_id)
    WHERE status = ‘published’;
CREATE TABLE outbox_events (
    event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    author_id UUID NOT NULL,
    aggregate_id UUID NOT NULL,
    event_type TEXT NOT NULL,
    idempotency_key TEXT NOT NULL,
    aggregate_version BIGINT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ,
    UNIQUE (
        author_id, aggregate_id, event_type, idempotency_key
    )
);
CREATE INDEX outbox_unpublished
    ON outbox_events (created_at, event_id)
    WHERE published_at IS NULL;

`idempotency_key`는 같은 게시 요청이 재전송됐을 때 동일한 결과를 찾기 위해 필요합니다. `request_hash`는 같은 키로 다른 본문을 보내는 오류를 구분합니다. 서버가 본문과 공개 범위를 정규화해 hash를 계산하고, 최초 요청의 hash는 게시물을 수정하더라도 바꾸지 않습니다.

`version`은 검토 결과나 수정 요청이 오래된 상태를 덮어쓰지 않도록 사용합니다. 승인 worker가 version 1을 검사하는 동안 작성자가 내용을 수정했다면, 그 검사 결과로 version 2를 승인해서는 안 됩니다.

`status`를 boolean으로 만들지 않은 이유는 검토 중, 공개됨, 제한됨, 삭제됨이 다른 전이 규칙을 갖기 때문입니다. 삭제한 게시물을 늦게 도착한 승인 이벤트가 다시 공개하도록 허용하지 않습니다.

`created_at`은 원본이 만들어진 시각이고, `published_at`은 공개된 시각입니다. 검토에 시간이 걸린 글의 최신성을 계산할 때 둘을 구분해야 합니다. `updated_at`만 보고 최신 게시물로 취급하면 작은 수정으로 피드를 계속 다시 점유할 수도 있습니다.

재게시와 댓글 활동은 원본 게시물의 사본을 만들기보다 원본을 가리키는 참조를 보관합니다. 추천 후보에는 활동 자체의 ID와 원본 ID를 모두 넣습니다. 서로 다른 지인이 같은 글에 반응했다는 이유로 동일 원문을 여러 번 보여주는 일을 줄일 수 있습니다.

추천 결정과 노출을 연결하는 데이터

`request_id` , 하나의 서버 요청 시도

`decision_id` , 특정 입력과 배포 버전으로 결정한 추천 결과

`impression_id` , 실제 화면에 노출된 한 번의 게시물 표시

`event_id` , 재전송에서도 바뀌지 않는 관측 이벤트

`root_post_id` , 재게시와 댓글 활동이 가리키는 원본 게시물

Decision Log에는 사용자 가명 키, 후보 출처, 응답 순서, 모델과 정책 버전, feature snapshot 참조를 남깁니다. 후보가 선택됐다는 기록과 실제 노출은 별개입니다.

{
  “decision_id”: “dec_7f91”,
  “bundle_id”: “feed_bundle_042”,
  “feature_schema_version”: “fs_12”,
  “retrieval_embedding_version”: “ret_09”,
  “index_generation”: “idx_09_318”,
  “ranker_version”: “rank_21”,
  “policy_version”: “policy_08”,
  “decision_at”: “2026-09-15T10:00:00Z”,
  “history_available_before”: “2026-09-15T09:59:59.800Z”,
  “candidate_snapshot_ref”: “snap_62ab”,
  “served_items”: [
    {”post_id”: “post_a”, “position”: 1, “source”: “semantic”}
  ]
}

이 기록은 추천을 재현하기 위한 내부 데이터입니다. 클라이언트가 보낸 모델 버전이나 사용자 ID를 그대로 신뢰하지 않고, 서버가 결정한 값과 노출 token을 연결합니다.

RDBMS에서 제외할 데이터

  • impression과 dwell 관측은 반복적으로 발생하고 여러 집계에 사용되므로 Event Stream에 기록한 뒤 Object Storage로 옮깁니다. 실제 반응 원본의 생성과 반응 수 집계를 구분해, 게시물 한 row의 counter를 매번 갱신하지 않습니다.

  • 사용자 행동 sequence는 사용자별로 최근 구간을 읽는 KV 저장소와 Feature Store에 둡니다. 전체 과거 이력은 학습 저장소에 보관합니다. 피드 요청마다 원본 이벤트 테이블을 정렬하지 않습니다.

  • ML feature vector와 embedding은 버전을 가진 Feature Store와 Vector Index에 둡니다. 대규모 유사도 탐색을 원본 게시물 transaction과 같은 자원에서 수행하지 않습니다.

  • 검색 후보와 중간 점수는 요청 메모리에서 처리하고 필요한 표본만 기록합니다. 모든 후보를 영구적인 비즈니스 엔티티로 만들 필요가 없습니다.

  • 큰 이미지와 동영상은 Object Storage에 두고 원본 DB에는 소유권과 참조만 보관합니다.

  • 페이지 cursor와 짧은 session 상태는 TTL이 있는 KV 저장소에 둡니다. 다만 영구적인 저장, 숨김, 차단 의사는 별도의 원본 상태로 보존합니다.

API Signatures

피드 조회

POST /v1/feed/query
Authorization: Bearer <token>
Content-Type: application/json
{
  “limit”: 2,
  “cursor”: null
}
{
  “decision_id”: “dec_7f91”,
  “items”: [
    {”post_id”: “post_a”, “item_token”: “signed_a”},
    {”post_id”: “post_b”, “item_token”: “signed_b”}
  ],
  “next_cursor”: “opaque_cursor_02”,
  “has_more”: true
}

이 API는 후보 검색, Ranking, 권한 확인, 표시용 데이터 조합까지 기다립니다. 이미지 원본 다운로드나 embedding 재생성, 모델 학습은 기다리지 않습니다. 회원은 인증 token에서 결정하며 요청 본문의 임의 사용자 ID로 대체하지 않습니다.

cursor에는 서버가 관리하는 session, 페이지 번호, 만료 시각을 연결하고 서명합니다. 추천 순서는 계속 바뀌므로 SQL offset만 담지 않습니다. 다음 페이지에서는 아직 반환하지 않은 후보를 현재 관측 가능한 관심 상태로 다시 평가합니다.

같은 cursor의 동시 요청은 하나의 페이지 생성으로 합치고, 결과를 session 저장소에서 원자적으로 확정합니다. 재시도에는 그 페이지를 재사용하되 삭제와 권한은 다시 검사합니다. 새 페이지부터 새로운 행동을 반영하고, 이미 반환한 페이지의 순서를 매번 바꾸지 않습니다.

session을 2분 유지하는 것을 초기 가정으로 둡니다. 만료되면 `410 CURSOR_EXPIRED`를 반환하고 클라이언트가 새 피드를 시작합니다. 특정 순간의 전체 피드를 끝까지 보존하는 기능까지 제공하는 설계는 아닙니다.

session에서는 호환되는 모델 배포 묶음을 유지하되 사용자 제어와 안전성 상태는 최신으로 확인합니다. 긴급하게 배포 묶음을 회수해야 한다면 해당 cursor를 만료시키고 새 session으로 전환합니다.

게시물 작성

POST /v1/posts
Authorization: Bearer <token>
Idempotency-Key: 7363d7e0-b21f-4d15-b551-27a9078f1111
Content-Type: application/json
{
  “body”: “데이터베이스 인덱스 실험 결과를 공유합니다”,
  “visibility”: “public”
}
HTTP/1.1 201 Created
Content-Type: application/json
{
  “post_id”: “97289278-a35d-4597-88e7-88969fdb6684”,
  “status”: “pending_review”,
  “version”: 1
}

원본 게시물과 Outbox가 commit된 뒤 성공 응답합니다. 검토와 추천 인덱스 반영은 비동기입니다. 앱은 원본 저장 완료와 추천 가능 상태를 구분하고, 재시도에는 동일한 멱등성 키를 유지합니다. 같은 키로 본문이 달라지면 `409`를 반환합니다.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2026 실리콘밸리_생존자 · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture