시간과 공간은 따로 최적화할 수 있는 대상이 아니다

vx_developer·2026년 9월 19일

코테보다가

목록 보기
19/25
post-thumbnail

알고리즘을 처음 배울 때 시간 복잡도는 실행 횟수의 증가를 나타내고, 공간 복잡도는 추가로 사용하는 메모리의 증가를 나타낸다고 배운다.

게시물 목록에서 중복된 ID가 있는지 확인한다고 생각해보자.

function hasDuplicatePost(posts) {
  const seen = new Set();

  for (const post of posts) {
    if (seen.has(post.id)) {
      return true;
    }

    seen.add(post.id);
  }

  return false;
}

게시물이 n개라면 각 게시물을 한 번씩 확인하므로 평균적인 시간은 O(n)이고, 확인한 ID를 저장하므로 추가 공간은 O(n)이다.

모든 게시물 쌍을 비교하면 추가 공간을 거의 사용하지 않는 대신 시간은 O(n²)까지 증가할 수 있다.

이처럼 시간을 줄이기 위해 공간을 사용하거나 공간을 줄이기 위해 계산을 늘릴 수 있다는 설명은 알고리즘의 선택을 이해하는 데 유용하다.

하지만 실제 소셜 피드 서비스에서는 시간과 공간을 함수 하나의 실행 비용만으로 나누기 어렵다.

  • 사용자가 피드를 열 때마다 팔로우한 사람들의 게시물을 모을 것인가?
  • 게시물이 작성될 때 팔로워별 피드에 미리 복사해둘 것인가?
  • 빠른 읽기를 위해 저장 공간과 쓰기 비용을 얼마나 늘릴 수 있는가?
  • 팔로워가 수백만 명인 사용자의 게시물도 같은 방식으로 복사할 것인가?
  • 캐시를 사용하면 데이터베이스 비용은 얼마나 줄고 캐시 비용은 얼마나 늘어나는가?
  • 게시물이 삭제되거나 공개 범위가 변경되면 복사된 피드 항목을 어떻게 수정할 것인가?
  • 서버 메모리를 줄인 결과 데이터베이스와 네트워크 비용이 늘어나지는 않는가?
  • 평균 응답 시간은 빨라졌지만 게시물 작성이 지나치게 느려지지는 않는가?
  • 저장 공간을 줄이기 위해 다시 계산한 결과가 서버 비용을 더 크게 만들지는 않는가?

한 자원의 비용을 줄이면 다른 자원이나 다른 실행 시점으로 비용이 이동할 수 있다.

실제 서비스에서 시간과 공간을 최적화한다는 것은 둘 중 하나를 최소화하는 일이 아니라, 계산 시점과 저장 위치를 조절하여 응답 속도, 메모리, 저장 공간, 쓰기 비용과 운영 비용이 서비스 목표 안에서 함께 유지되도록 만드는 일이다.

공간을 쓰면 시간이 줄어들지만 비용이 사라지는 것은 아니다

중복 게시물을 찾기 위해 모든 게시물 쌍을 비교할 수 있다.

function hasDuplicatePost(posts) {
  for (
    let first = 0;
    first < posts.length;
    first += 1
  ) {
    for (
      let second = first + 1;
      second < posts.length;
      second += 1
    ) {
      if (
        posts[first].id ===
        posts[second].id
      ) {
        return true;
      }
    }
  }

  return false;
}

추가 자료구조를 만들지 않으므로 보조 공간은 거의 일정하다.

하지만 중복이 없으면 모든 게시물 쌍을 비교해야 한다.

게시물이 n개일 때 비교 횟수는 약 n² / 2까지 증가한다.

앞서 사용한 Set 방식은 게시물 ID를 추가로 저장한다.

function hasDuplicatePost(posts) {
  const seen = new Set();

  for (const post of posts) {
    if (seen.has(post.id)) {
      return true;
    }

    seen.add(post.id);
  }

  return false;
}

이 구현은 공간 O(n)을 사용하는 대신 평균적인 탐색 시간을 O(n)으로 줄인다.

두 구현 중 어느 것이 더 좋은지는 기호만으로 결정되지 않는다.

  • 게시물이 최대 10개라면 단순한 쌍 비교로 충분할 수 있다.
  • 게시물이 100만 개라면 Set에 필요한 메모리를 부담하고 시간을 줄이는 편이 적절할 수 있다.
  • 서버 메모리가 매우 제한되어 있다면 데이터를 묶음으로 처리해야 할 수 있다.
  • 같은 ID를 반복해서 검사한다면 조회 구조를 재사용할 수 있다.
  • 결과가 데이터베이스에 있다면 애플리케이션에서 중복 검사 자체를 하지 않을 수도 있다.

공간을 사용해 시간을 줄였다는 말은 비용을 제거했다는 뜻이 아니다.

비교 연산에 쓰던 비용을 자료구조 생성과 메모리 점유로 옮긴 것이다.

피드를 열 때 계산하면 저장 공간은 적지만 읽기가 무거워진다

크리스가 팔로우한 사용자들의 최신 게시물을 시간순으로 보고 싶다고 생각해보자.

가장 직접적인 방법은 피드를 요청할 때 게시물을 모으는 것이다.

async function loadFeed({
  userId,
  limit,
}) {
  const followedUserIds =
    await followRepository
      .findFollowedUserIds(userId);

  const posts =
    await postRepository
      .findRecentByAuthorIds({
        authorIds: followedUserIds,
        limit: 1000,
      });

  return posts
    .sort(
      (first, second) =>
        second.publishedAt -
        first.publishedAt
    )
    .slice(0, limit);
}

이 코드는 사용자가 피드를 열 때 팔로우 관계를 조회하고, 여러 작성자의 게시물을 가져와 정렬한 뒤 필요한 개수만 반환한다.

별도의 사용자별 피드 데이터를 장기간 저장하지 않아도 된다.

게시물과 팔로우 관계가 원본 저장소에 있으면 언제든 피드를 다시 계산할 수 있다.

그러나 팔로우한 사용자가 많을수록 다음 비용이 커진다.

  • 팔로우 관계 조회
  • 여러 작성자의 게시물 조회
  • 서버로 전송되는 후보 게시물 수
  • 후보 정렬에 필요한 CPU
  • 정렬 중 사용하는 메모리
  • 피드 요청마다 반복되는 계산
  • 여러 사용자가 동시에 접속할 때의 데이터베이스 부하

사용자가 2,000명을 팔로우하고 각 작성자의 최근 게시물을 확인해야 한다면 최종적으로 20개만 보여주더라도 훨씬 많은 후보를 읽을 수 있다.

저장 공간을 아끼는 대신 피드를 읽을 때마다 계산 비용을 부담하는 방식이다.

이 방식을 흔히 읽는 시점에 계산하는 방식이라고 볼 수 있다.

게시물과 팔로우 관계 저장
→ 사용자가 피드를 요청
→ 후보 게시물 조회
→ 정렬과 필터링
→ 피드 반환

구현은 비교적 단순하고 게시물 삭제나 공개 범위 변경도 원본 조회에 바로 반영하기 쉽다.

하지만 읽기 요청이 많아지면 같은 계산이 계속 반복된다.

피드를 미리 저장하면 읽기는 빨라지지만 쓰기가 무거워진다

크리스가 팔로우한 사람이 게시물을 작성할 때 크리스의 피드에 해당 게시물 ID를 미리 추가할 수 있다.

async function publishPost({
  authorId,
  content,
}) {
  const post =
    await postRepository.create({
      authorId,
      content,
      publishedAt: clock.now(),
    });

  const followerIds =
    await followRepository
      .findFollowerIds(authorId);

  for (const followerId of followerIds) {
    await feedRepository.addItem({
      userId: followerId,
      postId: post.id,
      publishedAt: post.publishedAt,
    });
  }

  return post;
}

게시물이 작성될 때 모든 팔로워의 피드에 게시물 ID를 추가한다.

사용자가 피드를 열 때는 자신의 피드 항목만 읽으면 된다.

async function loadFeed({
  userId,
  limit,
}) {
  return feedRepository
    .findRecentItems({
      userId,
      limit,
    });
}

읽기 경로는 단순해지고 빨라질 수 있다.

대신 게시물 작성 경로에 큰 비용이 생긴다.

팔로워가 f명이라면 게시물 하나를 작성할 때 최대 f개의 피드 항목을 저장해야 한다.

게시물 한 건 저장
+ 팔로워 f명 조회
+ 피드 항목 f개 생성

작성자의 팔로워가 100명이면 피드 항목 100개가 생긴다.

팔로워가 1,000만 명이면 게시물 하나가 1,000만 개에 가까운 저장 작업을 만들 수 있다.

저장 공간도 다음과 같이 증가한다.

전체 피드 항목 수
≈ 각 게시물의 수신자 수를 모두 더한 값

게시물 수만으로 공간을 계산할 수 없다.

게시물마다 몇 명의 피드에 복제되는지가 실제 저장량을 결정한다.

이 방식은 읽기 시간을 줄이기 위해 다음 비용을 추가한다.

  • 사용자별 피드 항목 저장 공간
  • 피드 항목 인덱스 공간
  • 게시물 작성 시 대량의 쓰기
  • 배포 작업을 처리할 작업자와 대기열
  • 중복 배포를 막는 제약
  • 실패한 배포를 다시 시도하는 비용
  • 삭제나 공개 범위 변경을 반영하는 비용

피드를 미리 저장하면 계산이 사라지는 것이 아니다.

읽기 시점의 계산을 게시물 작성 시점으로 옮기고, 그 결과를 저장 공간에 보관하는 것이다.

가장 빠른 읽기가 가장 좋은 서비스라는 뜻은 아니다

사용자는 피드를 자주 읽고 게시물은 상대적으로 적게 작성할 수 있다.

이 경우 게시물 작성 시 미리 배포하면 전체 비용을 줄일 수 있다.

하지만 모든 사용자의 행동이 같지는 않다.

사용자 유형게시물 작성피드 읽기팔로워 수
일반 사용자적음많음수십~수백 명
활발한 사용자많음많음수천 명
공식 계정보통적음수백만 명
비활성 사용자없음거의 없음다양함
자동 게시 계정매우 많음없음많을 수 있음

비활성 사용자의 피드를 계속 미리 저장하면 거의 읽히지 않는 데이터에 공간과 쓰기 비용을 사용할 수 있다.

팔로워가 수백만 명인 공식 계정의 게시물을 모든 피드에 복사하면 게시물 작성 하나가 거대한 작업이 된다.

반대로 모든 피드를 요청 시 계산하면 사용자가 몰리는 시간에 데이터베이스와 서버 계산이 집중될 수 있다.

따라서 다음 문장은 항상 성립하지 않는다.

읽기가 빠르면 더 좋은 설계다.

읽기를 빠르게 만들기 위해 게시물 작성이 수분씩 지연되거나 저장 비용이 지나치게 커진다면 전체 서비스에는 적절하지 않을 수 있다.

성능은 사용자 동작 하나가 아니라 게시, 배포, 조회, 삭제와 복구의 전체 흐름에서 판단해야 한다.

한 가지 방식으로 모든 사용자를 처리할 필요는 없다

일반 사용자의 게시물은 팔로워 피드에 미리 배포하고, 팔로워가 매우 많은 계정의 게시물은 읽을 때 합칠 수 있다.

async function distributePost(post) {
  const followerCount =
    await followRepository
      .countFollowers(post.authorId);

  if (
    followerCount <=
    PRECOMPUTE_FOLLOWER_LIMIT
  ) {
    await feedDistributionQueue
      .enqueue({
        postId: post.id,
        authorId: post.authorId,
      });

    return;
  }

  await largeAuthorPostRepository
    .record({
      postId: post.id,
      authorId: post.authorId,
      publishedAt: post.publishedAt,
    });
}

팔로워 수가 정해진 기준보다 적으면 사용자별 피드에 미리 배포한다.

팔로워가 매우 많은 작성자의 게시물은 별도의 최신 게시물 목록에 기록하고 모든 팔로워에게 복제하지 않는다.

피드를 읽을 때 두 종류의 데이터를 합친다.

async function loadFeed({
  userId,
  limit,
}) {
  const preparedItems =
    await feedRepository
      .findRecentItems({
        userId,
        limit,
      });

  const largeAuthorIds =
    await followRepository
      .findFollowedLargeAuthorIds(
        userId
      );

  const recentLargeAuthorPosts =
    await postRepository
      .findRecentByAuthorIds({
        authorIds: largeAuthorIds,
        limit,
      });

  return mergeFeedItems({
    preparedItems,
    dynamicItems:
      recentLargeAuthorPosts,
    limit,
  });
}

일반 게시물은 미리 계산된 피드에서 읽는다.

팔로워가 많은 작성자의 게시물은 요청 시 조회해 결과에 합친다.

이 방식은 읽기 시점과 쓰기 시점의 계산을 함께 사용한다.

다만 구조가 복잡해지면서 다음 책임이 생긴다.

  • 어떤 작성자를 미리 배포할지 기준을 정한다.
  • 기준을 넘거나 다시 내려온 계정을 처리한다.
  • 두 경로에서 같은 게시물이 들어오면 중복을 제거한다.
  • 서로 다른 목록의 정렬 기준을 일치시킨다.
  • 한 경로가 실패했을 때 일부 피드만 보여줄지 결정한다.
  • 성능 개선이 복잡성 증가를 정당화하는지 측정한다.

균형을 찾는다는 것은 모든 비용을 중간값으로 맞추는 일이 아니다.

데이터 분포와 사용 패턴에 따라 서로 다른 전략을 적용하는 일이다.

미리 계산한 피드는 원본 게시물이 아니다

사용자별 피드 항목에는 게시물 전체를 복사할 수도 있고 게시물 ID만 저장할 수도 있다.

다음 구조는 게시물 내용을 사용자별 피드에 복사한다.

await feedRepository.addItem({
  userId,
  postId: post.id,
  authorName: author.name,
  content: post.content,
  visibility: post.visibility,
  publishedAt: post.publishedAt,
});

피드를 읽을 때 추가 조회 없이 내용을 바로 반환할 수 있다.

하지만 작성자가 이름을 바꾸거나 게시물 내용을 수정하면 수많은 복사본을 갱신해야 한다.

공개 게시물이 비공개로 변경되었을 때 오래된 피드 복사본이 계속 노출될 수도 있다.

피드에는 조회에 필요한 최소 정보만 저장할 수 있다.

await feedRepository.addItem({
  userId,
  postId: post.id,
  publishedAt: post.publishedAt,
});

피드 항목은 “이 사용자에게 이 게시물을 보여줄 후보가 있다”는 관계만 표현한다.

실제 콘텐츠는 원본 게시물에서 읽는다.

SELECT
  p.id,
  p.author_id,
  p.content,
  p.visibility,
  p.published_at
FROM feed_items AS f
JOIN posts AS p
  ON p.id = f.post_id
WHERE f.user_id = $1
  AND p.status = 'PUBLISHED'
ORDER BY
  f.published_at DESC,
  f.post_id DESC
LIMIT $2;

이 쿼리는 피드 항목과 현재 게시물 상태를 함께 확인한다.

삭제되거나 게시 상태가 바뀐 게시물은 결과에서 제외된다.

소셜 피드에서 Source of Truth는 다음처럼 나눌 수 있다.

데이터책임
posts게시물 내용, 작성자, 공개 범위와 삭제 상태
follows현재 팔로우 관계
feed_items빠른 후보 조회를 위한 파생 데이터
피드 캐시빠른 응답을 위한 일시적인 복사본
API 응답특정 요청 시점에 계산된 출력

피드 항목과 캐시는 원본에서 다시 만들 수 있는 계산 데이터다.

잘못된 피드 항목 하나가 원본 게시물의 공개 범위보다 강한 권한을 가져서는 안 된다.

저장 공간을 줄이기 위해 원본 검증을 제거해서는 안 된다

피드 조회를 빠르게 만들기 위해 feed_items의 내용을 그대로 반환할 수 있다.

const items =
  await feedRepository.findRecentItems({
    userId,
    limit,
  });

return items;

데이터베이스 조회가 한 번으로 끝나고 구현도 간단하다.

그러나 피드 항목이 만들어진 뒤 다음 상태가 바뀔 수 있다.

  • 게시물이 삭제된다.
  • 작성자가 계정을 비공개로 변경한다.
  • 크리스가 작성자를 언팔로우한다.
  • 작성자가 크리스를 차단한다.
  • 운영자가 게시물을 숨긴다.
  • 게시물의 공개 대상이 변경된다.

읽기 속도를 위해 현재 권한 검사를 생략하면 사용해서는 안 되는 데이터를 노출할 수 있다.

후보 범위를 피드 인덱스로 줄인 뒤 현재 공개 조건을 확인해야 한다.

const candidateIds =
  await feedRepository
    .findRecentPostIds({
      userId,
      limit: limit * 2,
    });

const visiblePosts =
  await postRepository
    .findVisiblePosts({
      viewerId: userId,
      postIds: candidateIds,
      limit,
    });

return visiblePosts;

첫 번째 조회는 빠르게 후보를 줄인다.

두 번째 조회는 현재 사용자에게 실제로 보일 수 있는 게시물만 반환한다.

두 번의 조회와 추가 계산이 필요하지만 권한과 상태를 지키기 위한 비용이다.

시간과 공간의 최적화는 정확성과 보안이 보장되는 범위 안에서 수행해야 한다.

외부 입력이 시간과 공간 사용량을 결정하게 두어서는 안 된다

피드 API가 사용자가 요청한 개수를 그대로 사용할 수 있다.

const limit = Number(
  request.query.limit
);

return loadFeed({
  userId: session.user.id,
  limit,
});

정상적인 앱은 limit=20을 보낼 수 있다.

그러나 외부 사용자가 limit=1000000을 보내면 다음 비용이 커질 수 있다.

  • 데이터베이스가 읽는 피드 항목 수
  • 서버가 생성하는 게시물 객체 수
  • 정렬과 중복 제거에 사용하는 시간
  • 응답을 만들기 위한 메모리
  • JSON 변환 시간
  • 네트워크 응답 크기
  • 클라이언트가 처리하는 데이터 양

형식과 범위를 검증해야 한다.

const requestedLimit = Number(
  request.query.limit
);

if (
  !Number.isInteger(requestedLimit) ||
  requestedLimit < 1
) {
  throw new Error(
    "조회 개수는 양의 정수여야 한다."
  );
}

const limit = Math.min(
  requestedLimit,
  100
);

한 번에 최대 100개만 반환한다.

입력 상한은 시간과 공간 사용량의 상한을 함께 만든다.

또한 사용자 ID는 외부 요청에서 받지 않고 인증된 세션에서 가져온다.

const query = {
  viewerId: session.user.id,
  limit,
  cursor: parseFeedCursor(
    request.query.cursor
  ),
};

이 코드는 피드를 볼 사용자를 인증 상태에서 결정하고 조회 조건을 검증한다.

외부 입력은 단순히 값의 형식만 바꾸는 요소가 아니다.

알고리즘이 사용할 CPU, 메모리, 저장소와 네트워크의 양을 결정하는 요소이므로 경계에서 제한해야 한다.

모든 중간 결과를 저장하면 메모리 사용량이 빠르게 커진다

두 피드 목록을 합친 뒤 정렬할 수 있다.

function mergeFeedItems({
  preparedItems,
  dynamicItems,
  limit,
}) {
  return [
    ...preparedItems,
    ...dynamicItems,
  ]
    .sort(
      (first, second) =>
        second.publishedAt -
        first.publishedAt
    )
    .slice(0, limit);
}

코드는 이해하기 쉽고 작은 목록에서는 충분할 수 있다.

그러나 두 목록이 크다면 합친 배열과 정렬 과정이 추가 메모리를 사용한다.

최종적으로 20개만 필요해도 후보 수천 개를 모두 합치고 정렬할 수 있다.

두 목록이 이미 최신순으로 정렬되어 있다면 앞에서부터 비교하며 필요한 개수만 선택할 수 있다.

function mergeFeedItems({
  preparedItems,
  dynamicItems,
  limit,
}) {
  const result = [];
  const seenPostIds = new Set();

  let preparedIndex = 0;
  let dynamicIndex = 0;

  while (
    result.length < limit &&
    (
      preparedIndex <
        preparedItems.length ||
      dynamicIndex <
        dynamicItems.length
    )
  ) {
    const next =
      takeNewerItem({
        preparedItems,
        dynamicItems,
        preparedIndex,
        dynamicIndex,
      });

    preparedIndex =
      next.preparedIndex;

    dynamicIndex =
      next.dynamicIndex;

    if (
      seenPostIds.has(
        next.item.postId
      )
    ) {
      continue;
    }

    seenPostIds.add(
      next.item.postId
    );

    result.push(next.item);
  }

  return result;
}

두 목록을 모두 하나의 배열로 합치지 않고 앞쪽 항목부터 비교한다.

필요한 결과 수에 도달하면 처리를 멈춘다.

중복 제거를 위한 Set에는 최종 결과와 확인한 후보의 ID가 저장된다.

이 방식은 전체 정렬과 큰 중간 배열을 피할 수 있지만 코드가 더 복잡해진다.

다음 조건도 보장되어야 한다.

  • 두 입력 목록이 같은 정렬 기준을 사용한다.
  • 게시 시각이 같을 때 사용할 동점 규칙이 있다.
  • 중복 게시물이 두 목록에 나타날 수 있음을 처리한다.
  • 필요한 결과를 채우지 못하면 추가 후보를 가져오는 방법이 있다.

메모리를 줄이는 구현이 복잡성과 오류 가능성을 늘릴 수 있다.

후보가 항상 40개 이하라면 단순한 배열 병합과 정렬이 더 나은 선택일 수 있다.

실제 후보 수와 메모리 사용량을 확인한 뒤 선택해야 한다.

캐시는 시간을 줄이지만 저장 공간과 오래된 데이터 문제를 만든다

크리스가 피드를 반복해서 열 때마다 같은 첫 페이지를 계산할 필요는 없을 수 있다.

const cacheKey =
  `feed:${userId}:first-page`;

const cachedFeed =
  await cache.get(cacheKey);

if (cachedFeed) {
  return cachedFeed;
}

캐시에 값이 있으면 데이터베이스 조회와 병합 계산을 줄일 수 있다.

값이 없으면 피드를 계산한 뒤 저장한다.

const feed =
  await buildFeed({
    userId,
    limit: 20,
  });

await cache.set(
  cacheKey,
  feed,
  { ttlSeconds: 30 }
);

return feed;

30초 동안 같은 결과를 재사용한다.

응답 시간과 데이터베이스 부하는 줄어들 수 있지만 다음 비용이 생긴다.

  • 사용자별 캐시 저장 공간
  • 캐시 서버 비용
  • 직렬화와 역직렬화
  • 캐시 서버까지의 네트워크 요청
  • 새로운 게시물을 반영하는 지연
  • 공개 범위 변경 뒤 남아 있는 오래된 값
  • 캐시 미스가 동시에 발생할 때의 원본 조회
  • 캐시 키와 제거 규칙 관리

피드 첫 페이지를 모든 사용자에게 미리 캐시하면 비활성 사용자의 데이터도 공간을 차지한다.

활성 사용자에게 요청이 들어왔을 때만 캐시하면 불필요한 저장을 줄일 수 있지만 첫 요청은 느려진다.

캐시의 목적은 무조건 많은 데이터를 저장하는 것이 아니다.

반복 계산을 줄일 가능성이 높은 데이터를 제한된 기간 동안 유지하는 것이다.

서버 메모리를 늘리면 서버 수만큼 복사본이 늘어날 수 있다

피드 결과를 API 서버의 메모리에 저장할 수 있다.

const feedCache = new Map();

function getCachedFeed(userId) {
  return feedCache.get(userId);
}

같은 프로세스에서는 네트워크 없이 빠르게 읽을 수 있다.

하지만 서버가 여러 대라면 각 서버가 자신의 복사본을 가진다.

사용자 수를 n, 서버 수를 s라고 하면 최악의 경우 전체 복사 공간은 다음처럼 증가할 수 있다.

O(n × s)

크리스의 첫 요청은 서버 A에 도착하고 다음 요청은 서버 B에 도착할 수 있다.

두 서버의 캐시 내용과 만료 시점이 다를 수도 있다.

원격 캐시를 사용하면 여러 서버가 데이터를 공유할 수 있다.

const feed =
  await sharedCache.get(
    `feed:${userId}`
  );

복제 수를 줄이고 서버가 재시작되어도 캐시를 유지할 수 있다.

대신 네트워크 지연과 외부 캐시 장애를 고려해야 한다.

저장 위치를 선택할 때는 다음을 함께 봐야 한다.

선택줄어드는 비용새로 생기는 비용
요청마다 계산장기 저장 공간반복 조회와 계산
서버 메모리 캐시원격 호출 시간서버별 복사와 동기화
원격 캐시데이터베이스 조회네트워크와 캐시 운영
사용자별 피드 테이블읽기 계산저장 공간과 쓰기
데이터베이스 인덱스검색 범위디스크 공간과 갱신
응답에 최소 필드만 포함메모리와 전송량필요 시 추가 조회

메모리를 늘릴지 줄일지보다 어느 위치의 어떤 비용을 다른 위치로 이동시키는지 설명해야 한다.

게시물을 미리 배포하면 실패와 재시도까지 저장해야 한다

팔로워가 많은 사용자의 게시물 배포를 HTTP 요청 안에서 모두 끝내기 어렵다.

작업 대기열을 사용할 수 있다.

const post =
  await postRepository.create({
    authorId,
    content,
    publishedAt: clock.now(),
  });

await feedDistributionQueue.enqueue({
  postId: post.id,
  authorId,
});

return post;

사용자 요청에서는 원본 게시물을 저장하고 배포 작업만 등록한다.

별도의 작업자가 팔로워 피드에 항목을 추가한다.

async function distributePost({
  postId,
  authorId,
}) {
  const followerIds =
    await followRepository
      .findFollowerIds(authorId);

  await feedRepository
    .addItems(
      followerIds.map((userId) => ({
        userId,
        postId,
      }))
    );
}

배포 작업도 한 번에 모든 팔로워를 메모리에 올리면 문제가 될 수 있다.

팔로워를 작은 묶음으로 처리해야 한다.

let cursor = null;

do {
  const page =
    await followRepository
      .findFollowerPage({
        authorId,
        cursor,
        limit: 1000,
      });

  await feedRepository.addItems(
    page.items.map((follower) => ({
      userId: follower.userId,
      postId,
    }))
  );

  cursor = page.nextCursor;
} while (cursor !== null);

이 코드는 한 번에 최대 1,000명의 팔로워만 메모리에 올린다.

전체 작업량은 줄어들지 않지만 작업 한 번이 사용하는 메모리와 트랜잭션 크기를 제한한다.

작업이 중간에 실패하면 다시 실행될 수 있다.

중복 피드 항목을 막아야 한다.

CREATE UNIQUE INDEX
  feed_items_user_post_idx
ON feed_items (
  user_id,
  post_id
);

같은 배포 작업이 다시 실행되어도 동일한 사용자와 게시물 조합은 하나만 저장된다.

공간을 사용해 읽기 시간을 줄이는 설계는 배포 작업, 진행 상태, 재시도와 중복 방지 데이터까지 필요하게 한다.

알고리즘의 비용이 자료구조 하나를 넘어 시스템의 상태 관리로 확장되는 순간이다.

삭제와 관계 변경은 미리 만든 데이터의 유지 비용을 드러낸다

크리스가 사용자를 언팔로우했다고 생각해보자.

미리 만든 피드에는 과거 게시물 항목이 남아 있을 수 있다.

모든 항목을 즉시 삭제할 수 있다.

DELETE FROM feed_items
WHERE user_id = $1
  AND author_id = $2;

현재 피드 데이터를 깨끗하게 유지할 수 있지만 언팔로우 요청에 많은 삭제 작업이 생길 수 있다.

피드 항목은 그대로 두고 조회할 때 현재 팔로우 관계를 확인할 수도 있다.

SELECT
  p.id,
  p.content,
  p.published_at
FROM feed_items AS f
JOIN posts AS p
  ON p.id = f.post_id
JOIN follows AS r
  ON r.follower_id = f.user_id
  AND r.followed_id = p.author_id
WHERE f.user_id = $1
  AND p.status = 'PUBLISHED'
ORDER BY
  f.published_at DESC,
  f.post_id DESC
LIMIT $2;

삭제 작업은 줄지만 피드 조회 시 추가 조인이 필요하다.

또한 더 이상 사용되지 않는 피드 항목이 저장 공간을 계속 차지한다.

두 방식을 결합할 수도 있다.

  1. 조회 시 현재 관계를 확인해 잘못된 노출을 즉시 막는다.
  2. 백그라운드 정리 작업으로 오래된 피드 항목을 제거한다.

이 방식은 정확성을 읽기 경로에서 보장하고 공간 회수는 나중에 수행한다.

즉시 삭제와 지연 삭제 중 어느 쪽이 적절한지는 다음 조건에 따라 달라진다.

  • 잘못된 노출의 위험
  • 사용자당 누적 피드 항목 수
  • 언팔로우 빈도
  • 삭제 쿼리의 비용
  • 저장 공간 가격
  • 정리 작업이 따라갈 수 있는 처리량
  • 삭제가 늦어져도 되는 최대 시간

공간을 미리 사용한 순간 그 데이터를 언제 폐기할지에 대한 결정도 필요해진다.

비용을 비교하려면 시간과 공간을 같은 서비스 흐름에 놓아야 한다

피드 설계의 비용을 단순화해볼 수 있다.

다음 기호를 사용한다고 생각해보자.

  • P: 일정 시간 동안 작성되는 게시물 수
  • F: 게시물 작성자 한 명의 평균 팔로워 수
  • R: 피드 읽기 요청 수
  • C: 읽을 때 확인하는 후보 게시물 수
  • L: 실제 반환할 게시물 수

읽을 때 계산하는 방식의 주요 비용은 다음처럼 볼 수 있다.

저장 공간
≈ 원본 게시물과 팔로우 관계

읽기 계산
≈ R × 후보 조회·병합 비용(C)

쓰기 계산
≈ P × 게시물 저장 비용

미리 피드를 만드는 방식은 다음처럼 달라진다.

추가 저장 공간
≈ P × F개의 피드 항목

읽기 계산
≈ R × 피드 L개 조회 비용

쓰기 계산
≈ P × F개의 배포 비용

정확한 인프라 비용을 계산하는 공식은 아니다.

어떤 값이 커질 때 어느 비용이 함께 증가하는지 보여주는 모델이다.

다음 질문에 따라 적절한 방식이 달라진다.

  • R이 P보다 훨씬 큰가?
  • F의 평균과 최댓값은 얼마인가?
  • 팔로워 수가 일부 계정에 집중되어 있는가?
  • 비활성 사용자의 미리 계산된 피드가 얼마나 남는가?
  • 피드 항목 한 개가 차지하는 실제 크기는 얼마인가?
  • 게시물이 작성된 뒤 피드에 보일 때까지 허용할 지연은 얼마인가?
  • 읽기와 쓰기에 각각 얼마의 서버 비용을 사용할 수 있는가?

시간과 공간을 같은 입력 분포와 요청 흐름 안에서 비교해야 실제 설계 판단이 가능하다.

평균값만으로 균형을 정하면 큰 계정을 놓칠 수 있다

평균 팔로워 수가 200명이라고 생각해보자.

게시물 하나를 평균 200개의 피드에 저장하는 비용은 감당할 수 있어 보인다.

하지만 실제 분포는 다음과 같을 수 있다.

계정 유형팔로워 수
일반 사용자10~500명
소규모 창작자5,000명
유명 창작자500,000명
공식 계정20,000,000명

대부분의 계정은 팔로워 수가 적지만 일부 계정이 전체 배포 작업의 대부분을 만들 수 있다.

평균만 기준으로 모든 게시물을 미리 배포하면 공식 계정의 게시물 하나가 작업 대기열을 오랫동안 점유할 수 있다.

그동안 일반 사용자의 게시물까지 피드에 늦게 반영될 수 있다.

다음 지표를 따로 확인해야 한다.

  • 평균 팔로워 수
  • 중앙값 팔로워 수
  • 상위 1% 계정의 팔로워 수
  • 최대 팔로워 수
  • 팔로워 수 구간별 게시 빈도
  • 계정별 피드 항목 생성량
  • 배포 작업의 p95와 p99 완료 시간
  • 배포 대기열 길이

균형은 평균 사용자만 만족시키는 지점이 아니다.

큰 계정과 이벤트 트래픽에서도 나머지 사용자의 핵심 기능이 유지되는 지점이어야 한다.

서버 비용은 CPU나 저장 공간 하나로 결정되지 않는다

두 피드 구현의 실행 시간을 비교해 한쪽이 50밀리초 더 빠르다고 생각해보자.

그 차이만으로 비용을 판단하기는 어렵다.

실제 서버 비용에는 여러 자원이 포함된다.

자원피드 서비스에서 발생하는 비용
CPU후보 병합, 정렬, 필터링과 직렬화
메모리후보 배열, 중복 제거 구조와 캐시
데이터베이스 읽기게시물, 팔로우 관계와 피드 조회
데이터베이스 쓰기피드 항목 배포와 삭제
저장 공간피드 항목, 인덱스와 캐시
네트워크서버·캐시·데이터베이스 사이의 데이터 이동
작업자비동기 배포와 정리 작업
운영 복잡성재시도, 복구, 지표와 장애 대응

메모리를 더 사용하면 CPU와 데이터베이스 읽기를 줄일 수 있다.

저장 공간을 줄이면 피드를 열 때마다 계산과 네트워크 비용이 늘 수 있다.

사용자별 피드를 미리 만들면 읽기 서버는 단순해지지만 쓰기 작업자와 데이터베이스 저장량이 늘어난다.

따라서 “메모리를 30% 줄였다”는 결과만으로 최적화에 성공했다고 말할 수 없다.

그 결과 CPU 사용량, 데이터베이스 조회와 응답 시간이 어떻게 변했는지 함께 봐야 한다.

성능 목표가 있어야 균형점도 결정할 수 있다

“피드는 빠르고 비용은 적어야 한다”는 요구사항만으로는 설계를 선택할 수 없다.

측정 가능한 목표가 필요하다.

항목목표 예시
첫 페이지 게시물 수20개
최대 조회 개수100개
일반 피드 응답 시간300ms 이내
p99 피드 응답 시간1초 이내
새 게시물 반영 시간10초 이내
일반 게시물 작성 응답500ms 이내
배포 작업 완료 시간30초 이내
사용자별 피드 보관최근 1,000개
삭제·차단 반영즉시 노출 차단
캐시 데이터 허용 지연최대 30초

이 숫자는 모든 소셜 서비스에 적용되는 표준이 아니다.

제품의 사용자 경험, 데이터 규모, 정확성 요구와 인프라 예산을 기준으로 정해야 하는 예시다.

목표가 있으면 다음 판단이 가능해진다.

  • 피드를 요청 시 계산해도 충분히 빠른가?
  • 사용자별 피드를 미리 만들어야 하는가?
  • 몇 개의 피드 항목을 보관할 것인가?
  • 오래된 항목을 언제 삭제할 것인가?
  • 캐시의 만료 시간을 얼마로 정할 것인가?
  • 큰 계정에는 다른 배포 방식을 적용해야 하는가?
  • 게시와 조회 중 어느 경로에 더 많은 비용을 허용할 것인가?
  • 현재 방식이 한계에 도달하는 시점은 언제인가?

최적화는 시간이나 공간의 최솟값을 찾는 문제가 아니다.

서비스가 약속한 응답 시간과 정확성을 지키면서 감당할 수 있는 비용 범위를 찾는 문제다.

시간과 공간의 이동을 측정해야 한다

피드 API의 구간별 비용을 기록할 수 있다.

const startedAt = performance.now();

const preparedItems =
  await feedRepository
    .findRecentItems({
      userId,
      limit,
    });

const preparedAt =
  performance.now();

const dynamicItems =
  await loadDynamicItems({
    userId,
    limit,
  });

const dynamicAt =
  performance.now();

const result =
  mergeFeedItems({
    preparedItems,
    dynamicItems,
    limit,
  });

const completedAt =
  performance.now();

logger.info("Feed timing", {
  preparedQueryMs:
    preparedAt - startedAt,
  dynamicQueryMs:
    dynamicAt - preparedAt,
  mergeMs:
    completedAt - dynamicAt,
  preparedCount:
    preparedItems.length,
  dynamicCount:
    dynamicItems.length,
  resultCount:
    result.length,
});

이 코드는 미리 계산된 피드 조회, 동적 게시물 조회와 병합 시간을 구분해 기록한다.

공간과 비용에 관한 지표도 함께 필요하다.

  • 사용자별 피드 항목 수
  • 전체 피드 항목 수
  • 피드 테이블과 인덱스 크기
  • 캐시 사용량과 적중률
  • 후보 배열의 평균·최대 크기
  • API 서버의 메모리 사용량
  • 게시물 하나당 생성되는 피드 항목 수
  • 배포 작업 처리 시간
  • 배포 대기열 길이
  • 중복 삽입 충돌 수
  • 삭제된 게시물이 후보에 남은 수
  • 권한 필터링으로 제외된 게시물 수
  • 피드 조회당 데이터베이스 읽기량
  • 게시물 작성당 데이터베이스 쓰기량
  • 사용자 한 명당 월간 저장 비용

읽기 시간이 줄었지만 피드 항목 수가 예상보다 빠르게 증가할 수 있다.

저장량을 줄였지만 데이터베이스 읽기와 CPU 비용이 더 크게 늘어날 수도 있다.

한 지표의 개선이 다른 지표에 만든 변화를 함께 관찰해야 한다.

시간과 공간의 균형을 설계할 때 물어봐야 할 질문

알고리즘과 서비스 구조를 선택할 때 다음 질문을 확인할 수 있다.

  1. 현재 줄이려는 시간은 어느 사용자 동작에서 발생하는가?
  2. 시간을 줄이기 위해 어떤 데이터를 추가로 저장하는가?
  3. 추가 저장 데이터는 원본인가, 다시 만들 수 있는 파생 데이터인가?
  4. 추가 공간을 사용하면 어떤 계산이나 조회가 줄어드는가?
  5. 계산 비용을 제거한 것인가, 다른 시점으로 옮긴 것인가?
  6. 읽기 시점과 쓰기 시점 중 어느 쪽에 비용을 두고 있는가?
  7. 읽기 요청 수와 쓰기 요청 수의 비율은 얼마인가?
  8. 평균값뿐 아니라 가장 큰 팔로워 수와 요청량을 확인했는가?
  9. 사용자별 데이터 분포가 크게 다른가?
  10. 모든 사용자에게 같은 알고리즘을 적용해야 하는가?
  11. 큰 계정이나 비활성 사용자에게 다른 전략이 필요한가?
  12. 미리 계산한 데이터가 실제로 몇 번 조회되는가?
  13. 거의 읽히지 않는 데이터도 계속 저장하고 있지는 않은가?
  14. 파생 데이터 한 항목의 실제 크기는 얼마인가?
  15. 데이터베이스 인덱스와 캐시 공간까지 계산했는가?
  16. 서버가 여러 대일 때 메모리 복사본은 몇 벌 생기는가?
  17. 메모리를 줄인 결과 데이터베이스와 네트워크 비용이 늘어나지는 않는가?
  18. 모든 후보를 메모리에 올리지 않고 묶음으로 처리할 수 있는가?
  19. 전체 정렬 대신 이미 정렬된 목록을 합칠 수 있는가?
  20. 단순한 구현을 복잡하게 바꿀 만큼 실제 데이터가 큰가?
  21. 외부 사용자가 조회 크기를 무제한으로 늘릴 수 있는가?
  22. 입력 크기, 결과 크기와 응답 크기에 안전한 상한이 있는가?
  23. 원본 데이터와 캐시·피드 인덱스의 Source of Truth를 구분했는가?
  24. 삭제, 차단과 공개 범위 변경을 즉시 반영해야 하는가?
  25. 성능을 위해 현재 권한과 상태 검증을 생략하고 있지는 않은가?
  26. 파생 데이터가 오래되거나 누락되었을 때 다시 만들 수 있는가?
  27. 배포 작업이 중복 실행되어도 같은 피드 항목이 하나만 남는가?
  28. 작업이 부분적으로 실패하면 어디서 다시 시작하는가?
  29. 저장한 데이터는 언제 만료되고 누가 제거하는가?
  30. 읽기 속도를 높인 결과 게시물 작성이 지나치게 느려지지는 않는가?
  31. 저장 공간을 줄인 결과 피드 조회 비용이 지나치게 커지지는 않는가?
  32. CPU, 메모리, 데이터베이스, 네트워크와 작업자 비용을 함께 측정하는가?
  33. 평균 응답뿐 아니라 p95, p99와 최대 작업량을 관찰하는가?
  34. 서비스가 지켜야 하는 응답 시간, 최신성, 정확성과 비용 목표가 있는가?
  35. 현재 선택이 어느 조건에서 더 이상 적절하지 않은지 설명할 수 있는가?

이 질문은 모든 서비스에 복잡한 혼합 전략과 캐시를 도입하기 위한 목록이 아니다.

사용자 수와 게시물 수가 작다면 피드를 요청할 때 계산하는 단순한 방식으로 충분할 수 있다.

읽기 요청이 많아지면 사용자별 피드나 캐시를 검토할 수 있다.

팔로워가 매우 많은 계정이 등장하면 일부 게시물만 읽을 때 합치는 혼합 방식이 필요할 수 있다.

중요한 것은 현재 방법이 충분한 이유와 다시 검토해야 할 조건을 설명할 수 있는가이다.

시간과 공간의 최적화는 비용을 어디에 둘지 정하는 일이다

시간과 공간은 서로 독립된 점수가 아니다.

소셜 피드에서 읽기 시간을 줄이기 위해 사용자별 피드 항목을 저장하면 공간과 쓰기 비용이 늘어난다.

저장 공간을 줄이기 위해 피드를 요청 시 계산하면 데이터베이스 읽기, CPU, 메모리와 네트워크 비용이 늘어난다.

서버 메모리에 캐시하면 원격 조회를 줄일 수 있지만 서버 수만큼 복사본이 생길 수 있다.

원격 캐시를 사용하면 여러 서버가 데이터를 공유할 수 있지만 네트워크와 캐시 운영 비용이 추가된다.

피드 내용을 전부 복사하면 조회는 빨라질 수 있지만 게시물 수정, 삭제와 권한 변경을 반영하기 어려워진다.

게시물 ID만 저장하면 중복 공간과 갱신 비용은 줄지만 조회 시 원본 게시물을 확인해야 한다.

따라서 설계 판단은 다음 흐름으로 이어져야 한다.

  1. 사용자가 기다리는 경로와 데이터가 증가하는 경로를 구분한다.
  2. 읽기와 쓰기의 빈도와 최대 크기를 측정한다.
  3. 추가 공간이 줄여주는 계산을 밝힌다.
  4. 줄인 비용이 어느 시점과 시스템으로 이동하는지 확인한다.
  5. 원본과 파생 데이터를 구분한다.
  6. 저장 위치와 데이터 생명주기를 정한다.
  7. 삭제, 권한 변경과 동시 실행에서도 정확성을 유지한다.
  8. 평균뿐 아니라 큰 계정과 최대 요청량을 고려한다.
  9. 시간, 공간과 실제 인프라 비용을 함께 측정한다.
  10. 서비스 목표를 만족하는 가장 단순한 방법을 선택한다.

실제 서비스에서 시간과 공간의 균형을 찾는다는 것은 더 빠른 알고리즘과 더 적은 메모리 중 하나를 고르는 일이 아니다. 계산을 언제 실행하고 결과를 어디에 얼마나 오래 저장할지 결정하여 사용자 응답 시간, 데이터 정확성, 서버 자원과 운영 비용을 함께 통제하는 일이다.

피드를 요청할 때 계산하는 방식도 올바른 선택일 수 있다.

게시물을 작성할 때 미리 배포하는 방식도 올바른 선택일 수 있다.

두 방식을 사용자 규모에 따라 결합하는 방식도 가능하다.

선택을 결정하는 것은 가장 낮은 복잡도 기호가 아니다.

게시물 수, 팔로워 분포, 읽기와 쓰기의 비율, 허용할 최신성 차이, 저장 공간 가격과 서비스가 약속한 응답 시간이다.

좋은 설계는 시간을 줄였다는 사실만 말하지 않는다.

그 시간을 줄이기 위해 어떤 공간과 쓰기 비용을 사용했으며, 그 교환이 실제 서비스에서 감당 가능한 이유까지 설명한다.

다음 글에서는 최적화가 가장 빠른 알고리즘으로 바꾸는 일이 아닌 이유와 측정된 병목, 사용자 경험과 실제 운영 목표를 기준으로 개선 순서를 결정하는 방법을 살펴본다.

profile
Vision eXperience Developer

0개의 댓글