최적화는 가장 빠른 알고리즘으로 바꾸는 일이 아니다

vx_developer·2026년 9월 21일

코테보다가

목록 보기
20/25
post-thumbnail

알고리즘을 처음 배울 때 최적화는 같은 결과를 더 적은 시간이나 공간으로 계산하도록 알고리즘을 개선하는 일이라고 배운다.

강의 목록을 최신순으로 정렬하는 코드를 생각해보자.

const sortedLectures = lectures.sort(
  (first, second) =>
    second.createdAt -
    first.createdAt
);

강의가 n개라면 일반적인 정렬 비용은 O(n log n)으로 생각할 수 있다.

이미 정렬된 데이터를 사용하거나 전체 정렬 없이 필요한 항목만 선택하면 처리 비용을 줄일 수도 있다.

이처럼 더 나은 시간 복잡도와 공간 복잡도를 찾는 연습은 데이터가 증가할 때 어떤 구현이 유리한지 이해하는 데 유용하다.

하지만 실제 온라인 학습 서비스가 느릴 때 정렬 알고리즘부터 바꾸는 것이 올바른 최적화인지는 알 수 없다.

  • 사용자가 느끼는 지연은 어느 화면에서 발생하는가?
  • 서버 계산이 느린가, 데이터베이스 조회가 느린가?
  • 강의 목록 정렬은 전체 응답 시간 중 얼마나 차지하는가?
  • 브라우저가 사용하지 않는 데이터까지 반환하고 있지는 않은가?
  • 사용자마다 데이터베이스 쿼리를 수백 번 실행하고 있지는 않은가?
  • 평균 응답은 빠르지만 강의가 많은 사용자만 느린 것은 아닌가?
  • 캐시를 추가했을 때 오래된 학습 진도가 표시되어도 되는가?
  • 응답 시간을 줄이기 위해 권한 확인이나 최신 상태 검증을 생략하고 있지는 않은가?
  • 더 복잡한 알고리즘이 유지보수와 장애 대응 비용을 늘리지는 않는가?
  • 사용자가 실제로 필요로 하는 결과가 무엇인지 정의되어 있는가?

실행 시간이 긴 코드와 사용자가 느끼는 병목은 같지 않을 수 있다.

실제 서비스에서 최적화는 가장 빠른 알고리즘으로 교체하는 일이 아니라, 서비스가 지켜야 하는 정확성과 사용자 경험을 먼저 정의하고 측정된 병목에서 불필요한 작업을 제거한 뒤 개선 효과와 새로 생긴 비용을 검증하는 과정이다.

무엇이 느린지 정의하지 않으면 최적화할 대상도 없다

크리스가 온라인 학습 플랫폼의 ‘내 강의’ 화면을 열었다고 생각해보자.

화면이 3초 뒤에 나타난다면 “느리다”고 말할 수 있다.

하지만 3초라는 결과만으로 원인은 알 수 없다.

브라우저 요청
→ 인증 확인
→ 수강 중인 강의 조회
→ 진도 계산
→ 응답 데이터 생성
→ 네트워크 전송
→ 화면 렌더링

각 단계에서 시간이 사용된다.

예를 들어 전체 3초가 다음처럼 구성될 수 있다.

구간걸린 시간
브라우저와 서버 사이의 네트워크180ms
인증과 권한 확인40ms
강의 데이터베이스 조회1,900ms
서버의 진도 계산80ms
JSON 변환과 응답 전송300ms
브라우저 렌더링500ms

이 상황에서 서버의 정렬 코드를 80밀리초에서 40밀리초로 줄여도 화면은 여전히 느리다.

가장 큰 지연은 데이터베이스 조회와 브라우저 렌더링에 있다.

반대로 데이터베이스 조회가 20밀리초인데 서버가 수백만 개의 학습 기록을 정렬하고 있다면 알고리즘 개선이 중요할 수 있다.

최적화의 첫 질문은 다음과 같아야 한다.

어느 사용자가 어떤 작업을 할 때 무엇을 기다리고 있으며, 그 시간은 어느 구간에서 사용되는가?

“API가 느리다”보다 구체적인 문제 정의가 필요하다.

강의를 100개 이상 수강한 사용자가 ‘내 강의’ 첫 화면을 열 때 p95 응답 시간이 2.5초이며, 그중 사용자별 진도 조회가 1.8초를 차지한다.

이 정도로 정의되어야 개선 대상을 선택할 수 있다.

코드 모양이 복잡해 보여도 실제 병목이 아닐 수 있다

강의별 진도율을 계산하는 코드를 살펴보자.

function calculateProgress({
  completedLectureCount,
  totalLectureCount,
}) {
  if (totalLectureCount === 0) {
    return 0;
  }

  return Math.floor(
    (
      completedLectureCount /
      totalLectureCount
    ) * 100
  );
}

나눗셈과 반올림이 있으므로 계산을 줄이고 싶을 수 있다.

하지만 강의 100개의 진도율을 계산하는 비용은 일반적으로 매우 작다.

문제는 필요한 숫자를 가져오는 과정에 있을 수 있다.

async function loadMyCourses(userId) {
  const courses =
    await enrollmentRepository
      .findCoursesByUserId(userId);

  const result = [];

  for (const course of courses) {
    const lectures =
      await lectureRepository
        .findByCourseId(course.id);

    const completedLectures =
      await progressRepository
        .findCompletedByCourse({
          userId,
          courseId: course.id,
        });

    result.push({
      course,
      progress: calculateProgress({
        completedLectureCount:
          completedLectures.length,
        totalLectureCount:
          lectures.length,
      }),
    });
  }

  return result;
}

이 코드는 결과를 올바르게 만들 수 있다.

하지만 강의 하나마다 전체 강의 목록과 완료 기록을 각각 조회한다.

수강 중인 강의가 n개라면 다음과 같은 쿼리가 발생한다.

수강 강의 조회 1번
+ 강의별 강좌 목록 조회 n번
+ 강의별 완료 기록 조회 n번

강의를 100개 수강한 사용자라면 최대 201번의 데이터베이스 조회가 실행될 수 있다.

서버의 진도율 계산 공식을 아무리 빠르게 바꿔도 반복되는 데이터베이스 왕복은 그대로 남는다.

코드에서 눈에 띄는 반복문보다 그 안에서 실행하는 작업이 무엇인지 확인해야 한다.

구간을 나눠 측정하면 추측을 병목으로 바꾸지 않게 된다

‘내 강의’ API의 처리 시간을 구간별로 기록할 수 있다.

const startedAt = performance.now();

const courses =
  await enrollmentRepository
    .findCoursesByUserId(userId);

const coursesLoadedAt =
  performance.now();

const progressByCourse =
  await progressRepository
    .findSummaries({
      userId,
      courseIds:
        courses.map(
          (course) => course.id
        ),
    });

const progressLoadedAt =
  performance.now();

const response =
  buildCourseSummaries({
    courses,
    progressByCourse,
  });

const completedAt =
  performance.now();

logger.info("My courses timing", {
  courseCount: courses.length,
  coursesQueryMs:
    coursesLoadedAt - startedAt,
  progressQueryMs:
    progressLoadedAt -
    coursesLoadedAt,
  responseBuildMs:
    completedAt -
    progressLoadedAt,
  totalMs:
    completedAt - startedAt,
});

이 코드는 강의 조회, 진도 조회와 응답 생성 시간을 분리해서 기록한다.

다음과 같은 결과를 얻을 수 있다.

구간측정 결과
수강 강의 조회120ms
진도 요약 조회1,600ms
응답 객체 생성18ms
전체 서버 처리1,738ms

이 결과라면 응답 객체를 만드는 반복문보다 진도 요약 조회를 먼저 살펴봐야 한다.

측정할 때는 시간만 남겨서는 부족하다.

다음 입력 크기도 함께 기록해야 한다.

  • 수강 강의 수
  • 강의별 강좌 수
  • 학습 완료 기록 수
  • 실행된 데이터베이스 쿼리 수
  • 반환한 응답 항목 수
  • 응답 본문 크기
  • 캐시 적중 여부
  • 데이터베이스에서 읽은 행 수
  • 실패와 재시도 횟수

입력 크기 없이 응답 시간만 보면 어떤 조건에서 느려지는지 알기 어렵다.

필요한 결과를 다시 정의하면 가장 큰 작업을 없앨 수 있다

‘내 강의’ 첫 화면에 필요한 정보가 무엇인지 확인해보자.

화면에는 다음 정보만 필요할 수 있다.

  • 강의 제목
  • 대표 이미지
  • 마지막 학습 시각
  • 전체 강좌 수
  • 완료한 강좌 수
  • 진도율
  • 이어서 학습할 강좌 ID

그런데 기존 구현은 각 강의에 속한 모든 강좌와 완료 기록을 가져온다.

const lectures =
  await lectureRepository
    .findByCourseId(course.id);

const completedLectures =
  await progressRepository
    .findCompletedByCourse({
      userId,
      courseId: course.id,
    });

화면은 개수만 사용하지만 서버는 전체 객체 목록을 읽는다.

저장소에서 필요한 값만 집계할 수 있다.

SELECT
  e.course_id,
  COUNT(DISTINCT l.id)
    AS total_lecture_count,
  COUNT(DISTINCT lp.lecture_id)
    FILTER (
      WHERE lp.completed_at IS NOT NULL
    )
    AS completed_lecture_count,
  MAX(lp.updated_at)
    AS last_studied_at
FROM enrollments AS e
JOIN lectures AS l
  ON l.course_id = e.course_id
LEFT JOIN lecture_progress AS lp
  ON lp.lecture_id = l.id
  AND lp.user_id = e.user_id
WHERE e.user_id = $1
  AND e.status = 'ACTIVE'
GROUP BY e.course_id;

이 쿼리는 강좌 객체와 완료 기록 전체를 애플리케이션으로 보내지 않고 강의별 개수와 마지막 학습 시각을 계산한다.

서버는 집계된 결과로 진도율만 계산하면 된다.

function buildCourseSummary(
  course,
  progress
) {
  return {
    id: course.id,
    title: course.title,
    thumbnailUrl:
      course.thumbnailUrl,
    completedLectureCount:
      progress.completedLectureCount,
    totalLectureCount:
      progress.totalLectureCount,
    progressPercentage:
      calculateProgress(progress),
    lastStudiedAt:
      progress.lastStudiedAt,
  };
}

이 코드는 첫 화면에 필요한 필드만 반환한다.

최적화는 계산 공식을 더 빠르게 만드는 일에서 시작하지 않았다.

화면이 실제로 필요로 하지 않는 전체 강좌와 완료 기록을 조회하지 않는 데서 시작했다.

가장 빠른 작업은 더 빠르게 실행한 작업이 아니라 실행하지 않은 작업이다.

하나로 합친 쿼리가 언제나 더 좋은 것은 아니다

반복 조회를 줄이기 위해 모든 데이터를 하나의 거대한 쿼리로 합칠 수 있다.

그러나 조인이 많아지면 같은 행이 여러 번 중복되어 읽힐 수 있고 실행 계획을 이해하기 어려워질 수 있다.

다음 두 단계로 나누는 편이 더 명확할 수도 있다.

const courses =
  await enrollmentRepository
    .findCourseSummaries(userId);

const progressRows =
  await progressRepository
    .findCourseProgress({
      userId,
      courseIds:
        courses.map(
          (course) => course.id
        ),
    });

첫 번째 쿼리는 사용자가 수강할 수 있는 강의를 조회한다.

두 번째 쿼리는 해당 강의들의 진도를 한 번에 조회한다.

애플리케이션에서는 강의 ID별로 연결한다.

const progressByCourseId =
  new Map(
    progressRows.map((progress) => [
      progress.courseId,
      progress,
    ])
  );

return courses.map((course) =>
  buildCourseSummary(
    course,
    progressByCourseId.get(
      course.id
    ) ?? {
      completedLectureCount: 0,
      totalLectureCount:
        course.totalLectureCount,
      lastStudiedAt: null,
    }
  )
);

강의 목록과 진도 요약을 각각 한 번 조회하고 메모리에서 연결한다.

쿼리 수는 두 번으로 제한되고 코드의 책임도 비교적 분명하다.

“쿼리는 적을수록 좋다”는 규칙만으로 하나의 거대한 쿼리를 만들면 안 된다.

  • 조인 결과가 지나치게 커지지 않는가?
  • 쿼리 실행 계획을 이해할 수 있는가?
  • 저장소가 집계 작업을 효율적으로 처리하는가?
  • 메모리에서 연결하는 데이터 크기는 제한되어 있는가?
  • 두 조회 사이의 상태 차이가 허용되는가?

최적화는 쿼리 수 하나를 최소화하는 일도 아니다.

전체 읽기량, 실행 시간, 메모리와 코드 복잡성을 함께 줄여야 한다.

인덱스는 느린 쿼리가 아니라 조회 조건을 보고 추가해야 한다

진도 조회가 느리다는 이유만으로 인덱스를 많이 추가할 수 있다.

CREATE INDEX
  lecture_progress_user_idx
ON lecture_progress (user_id);

사용자 ID로 진행 기록을 찾는 데 도움이 될 수 있다.

하지만 실제 쿼리가 사용자와 강의 ID를 함께 사용한다면 조건에 맞는 인덱스가 더 적절할 수 있다.

CREATE INDEX
  lecture_progress_user_course_idx
ON lecture_progress (
  user_id,
  course_id,
  completed_at
);

이 인덱스는 사용자와 강의별 완료 상태를 조회하는 패턴에 맞춘 것이다.

인덱스를 추가하기 전에는 데이터베이스 실행 계획을 확인해야 한다.

EXPLAIN ANALYZE
SELECT
  course_id,
  COUNT(*)
FROM lecture_progress
WHERE user_id = $1
  AND course_id = ANY($2)
  AND completed_at IS NOT NULL
GROUP BY course_id;

실행 계획은 데이터베이스가 어떤 인덱스를 사용하고 몇 개의 행을 읽으며 어느 단계에서 시간이 걸렸는지 보여준다.

인덱스도 무료 최적화가 아니다.

  • 저장 공간을 사용한다.
  • 학습 기록을 추가할 때 함께 갱신된다.
  • 인덱스가 많으면 쓰기 비용이 증가한다.
  • 실제 쿼리 조건과 맞지 않으면 사용되지 않을 수 있다.
  • 데이터 분포가 바뀌면 기대한 효과가 달라질 수 있다.

느린 쿼리를 발견했다는 이유만으로 인덱스를 추가하는 것이 아니라, 실행 계획에서 불필요하게 많은 행을 확인한다는 근거를 찾은 뒤 추가해야 한다.

캐시는 반복되는 계산을 줄이지만 최신성 요구를 먼저 정해야 한다

강의 제목, 대표 이미지와 전체 강좌 수는 자주 바뀌지 않을 수 있다.

이 데이터는 캐시하기 쉽다.

const structure =
  await courseStructureCache.get(
    courseId
  );

if (structure) {
  return structure;
}

캐시에 값이 없으면 데이터베이스에서 조회한 뒤 저장한다.

const structure =
  await courseRepository
    .findPublishedStructure(courseId);

await courseStructureCache.set(
  courseId,
  structure,
  { ttlSeconds: 600 }
);

return structure;

강의 구조는 10분 동안 재사용한다.

반면 크리스의 완료 강좌 수와 이어서 학습할 위치는 계속 바뀐다.

const progress =
  await progressRepository
    .findCurrent({
      userId,
      courseId,
    });

사용자가 강좌를 완료한 직후에는 최신 진도가 보여야 할 수 있다.

따라서 모든 데이터를 같은 캐시 정책으로 다루어서는 안 된다.

데이터변경 빈도캐시 판단
강의 제목낮음비교적 길게 캐시 가능
대표 이미지 URL낮음캐시 가능
전체 강좌 수강의 편집 시 변경변경 시 제거 필요
완료 강좌 수학습할 때마다 변경짧은 캐시 또는 원본 조회
마지막 학습 위치자주 변경최신성이 중요
수강 권한결제·취소에 따라 변경최종 요청에서 확인
수료 상태진도에 따라 변경정확한 상태 전환 필요

캐시는 응답 시간을 줄이기 위한 파생 데이터다.

강의 원본과 학습 진도의 Source of Truth는 데이터베이스다.

캐시가 빠르다는 이유로 수강 권한이나 수료 여부를 최종 판단해서는 안 된다.

미리 계산한 진도율은 읽기를 줄이지만 갱신 책임을 만든다

강의별 진도율을 매번 집계하는 대신 사용자별 요약을 저장할 수 있다.

const summary = {
  userId,
  courseId,
  completedLectureCount,
  totalLectureCount,
  progressPercentage,
  lastStudiedAt,
};

‘내 강의’ 화면은 이 요약만 읽으면 된다.

const summaries =
  await progressSummaryRepository
    .findByUserId(userId);

읽기는 빨라질 수 있지만 강좌 완료 상태가 변경될 때 요약도 갱신해야 한다.

다음 구현은 완료 요청이 들어올 때마다 개수를 증가시킨다.

await progressSummaryRepository
  .incrementCompletedCount({
    userId,
    courseId,
  });

같은 완료 요청이 네트워크 재시도로 두 번 전달되면 완료 개수가 두 번 증가할 수 있다.

먼저 강좌 완료 기록을 중복 없이 저장해야 한다.

CREATE UNIQUE INDEX
  one_progress_per_user_lecture
ON lecture_progress (
  user_id,
  lecture_id
);

그다음 새 완료 기록이 실제로 생성된 경우에만 요약을 변경해야 한다.

await database.transaction(
  async (transaction) => {
    const created =
      await progressRepository
        .completeIfNotCompleted({
          userId,
          lectureId,
          completedAt: serverNow,
          transaction,
        });

    if (!created) {
      return;
    }

    await progressSummaryRepository
      .incrementCompletedCount({
        userId,
        courseId,
        transaction,
      });
  }
);

같은 사용자의 같은 강좌 완료 기록은 한 번만 생성된다.

새 기록이 생성되었을 때만 요약 개수를 증가시킨다.

읽기 시간을 줄이기 위해 계산 결과를 저장하면 다음 책임이 생긴다.

  • 원본이 변경될 때 함께 갱신한다.
  • 중복 요청에서도 한 번만 반영한다.
  • 일부 변경만 성공하는 상황을 막는다.
  • 원본과 요약이 달라졌을 때 다시 계산한다.
  • 강의에 강좌가 추가되면 전체 개수를 갱신한다.

미리 계산한 데이터는 시간을 줄이는 대신 상태 관리의 복잡성을 늘린다.

최적화 때문에 Source of Truth를 바꾸어서는 안 된다

사용자별 진도 요약에 progressPercentage: 100이 저장되어 있다고 생각해보자.

이 값을 보고 바로 수료증을 발급할 수 있다.

if (
  summary.progressPercentage === 100
) {
  await certificateService.issue({
    userId,
    courseId,
  });
}

조회는 빠르지만 요약이 오래되었거나 잘못 갱신되었다면 수료하지 않은 사용자에게 수료증을 발급할 수 있다.

수료증 발급처럼 중요한 상태 변경에서는 원본 조건을 다시 확인해야 한다.

const completion =
  await progressRepository
    .verifyCourseCompletion({
      userId,
      courseId,
    });

if (!completion.isComplete) {
  throw new Error(
    "수료 조건을 만족하지 않았다."
  );
}

진도 요약은 화면에 빠르게 상태를 보여주는 데 사용할 수 있다.

수료 확정은 현재 강의 구조와 사용자별 완료 기록을 기준으로 검증해야 한다.

최적화된 조회 데이터가 비즈니스 상태의 최종 권한을 갖게 하면 잘못된 상태를 빠르게 확정할 수 있다.

최적화 전후에도 다음 조건은 유지되어야 한다.

  • 수강 권한이 있는 사용자만 강의를 볼 수 있다.
  • 실제 완료된 강좌만 진도에 포함된다.
  • 같은 강좌는 한 번만 완료 처리된다.
  • 필수 강좌를 모두 완료해야 수료할 수 있다.
  • 삭제되거나 비공개인 강좌는 정책에 따라 계산한다.
  • 수료증은 사용자와 강의당 한 번만 발급된다.

성능 개선은 이 규칙을 지키는 범위 안에서 이루어져야 한다.

외부 입력의 크기를 제한하는 것도 최적화다

‘내 강의’ API가 페이지 크기를 입력받는다고 생각해보자.

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

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

정상적인 화면은 20개를 요청할 수 있다.

하지만 외부 사용자가 매우 큰 값을 보내면 데이터베이스 조회, 응답 메모리와 네트워크 전송량이 함께 증가한다.

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 = {
  userId: session.user.id,
  limit,
  cursor: parseCourseCursor(
    request.query.cursor
  ),
};

입력 검증은 잘못된 값을 거부하는 기능이면서 요청 한 건의 최대 비용을 제한하는 방법이다.

알고리즘을 바꾸지 않고도 처리할 데이터의 상한을 정하면 응답 시간과 메모리를 예측하기 쉬워진다.

반환하지 않아도 되는 데이터를 제거하면 여러 비용이 함께 줄어든다

강의 목록 API가 모든 상세 정보를 반환할 수 있다.

return {
  id: course.id,
  title: course.title,
  description: course.description,
  instructorBiography:
    course.instructor.biography,
  modules: course.modules,
  lectures: course.lectures,
  reviews: course.reviews,
  progressHistory:
    course.progressHistory,
};

첫 화면은 제목, 대표 이미지와 진도율만 사용할 수 있다.

그런데 사용하지 않는 데이터 때문에 다음 비용이 생긴다.

  • 더 많은 데이터베이스 행과 열을 읽는다.
  • 서버가 더 많은 객체를 만든다.
  • JSON 변환 시간이 증가한다.
  • 서버 메모리 사용량이 증가한다.
  • 네트워크 응답이 커진다.
  • 브라우저가 큰 응답을 해석해야 한다.
  • 민감한 내부 데이터가 노출될 가능성이 커진다.

목록 화면에 필요한 응답만 만들 수 있다.

return {
  id: course.id,
  title: course.title,
  thumbnailUrl:
    course.thumbnailUrl,
  progressPercentage:
    course.progressPercentage,
  nextLectureId:
    course.nextLectureId,
};

상세 설명과 전체 강좌 목록은 사용자가 강의 상세 화면을 열 때 가져온다.

이 변경은 특정 정렬 알고리즘을 교체하지 않는다.

그럼에도 데이터베이스, 서버, 네트워크와 브라우저의 작업을 동시에 줄일 수 있다.

최적화의 중요한 출발점은 필요한 작업을 더 빠르게 수행하는 것이 아니라 필요하지 않은 작업을 발견하는 것이다.

병렬 실행은 무조건 시간을 줄이지 않는다

서로 독립된 조회를 동시에 실행할 수 있다.

const [
  courses,
  notifications,
  recommendations,
] = await Promise.all([
  loadCourses(userId),
  loadNotifications(userId),
  loadRecommendations(userId),
]);

세 작업을 순서대로 기다리는 것보다 응답 시간이 줄어들 수 있다.

하지만 각 함수가 무거운 데이터베이스 조회를 실행하면 한 요청이 동시에 세 개의 연결을 사용할 수 있다.

사용자가 몰릴 때 데이터베이스 연결 풀이 빠르게 소진될 수 있다.

또한 추천 목록이 ‘내 강의’ 첫 화면에 반드시 필요하지 않다면 사용자 요청과 분리할 수 있다.

const courses =
  await loadCourses(userId);

return {
  courses,
  recommendations: null,
};

추천은 별도 API로 늦게 가져오거나 화면의 핵심 내용이 표시된 뒤 요청할 수 있다.

병렬 처리는 작업 시간을 없애지 않는다.

같은 시간에 더 많은 자원을 사용해 대기 시간을 줄이는 선택이다.

다음 항목을 확인해야 한다.

  • 작업들이 실제로 서로 독립적인가?
  • 동시에 실행할 때 데이터베이스 연결이 몇 개 필요한가?
  • 한 의존성이 느리면 전체 응답이 함께 늦어지는가?
  • 일부 결과가 없어도 핵심 화면을 보여줄 수 있는가?
  • 동시 요청량이 증가해도 저장소가 감당할 수 있는가?

개별 요청의 응답 시간이 줄어도 전체 처리량과 안정성이 나빠질 수 있다.

평균 응답만 개선하면 느린 사용자는 그대로 남을 수 있다

대부분의 사용자는 수강 강의가 5개 이하일 수 있다.

이들의 응답은 이미 빠르다.

느린 요청은 강의를 100개 이상 수강한 일부 사용자에게 집중될 수 있다.

수강 강의 수사용자 비율평균 응답 시간
1~5개70%180ms
6~20개24%350ms
21~100개5%1,200ms
100개 초과1%4,800ms

전체 평균만 보면 서비스가 충분히 빠르게 보일 수 있다.

그러나 강의를 많이 수강한 사용자에게는 지속적으로 느린 화면이 제공된다.

다음 지표를 함께 확인해야 한다.

  • 평균 응답 시간
  • 중앙값 응답 시간
  • p95와 p99 응답 시간
  • 타임아웃 비율
  • 수강 강의 수 구간별 응답 시간
  • 데이터베이스 쿼리 수 구간
  • 응답 크기별 전송 시간
  • 캐시 적중과 미적중 경로의 시간

최적화 대상은 가장 많은 사용자가 실행하는 경로일 수도 있고, 적은 사용자에게 큰 피해를 주는 경로일 수도 있다.

빈도와 피해 크기를 함께 봐야 한다.

실제 데이터와 다른 벤치마크는 잘못된 결론을 만들 수 있다

강의 10개를 가진 테스트 데이터에서 여러 구현을 비교할 수 있다.

const startedAt = performance.now();

await loadMyCourses(testUserId);

const elapsedMs =
  performance.now() - startedAt;

한 번 실행한 결과가 20밀리초라고 해서 운영 환경에서도 같은 성능이 나온다고 볼 수 없다.

실제 환경에는 다음 차이가 있을 수 있다.

  • 강의가 100개 이상인 사용자
  • 강좌가 수천 개인 대형 강의
  • 수년간 쌓인 학습 기록
  • 동시에 접속하는 사용자
  • 데이터베이스와 서버 사이의 네트워크
  • 비어 있거나 가득 찬 캐시
  • 다른 요청이 사용하는 데이터베이스 연결
  • 오래 실행되어 메모리 상태가 달라진 서버

대표적인 입력 구간을 나누어 테스트해야 한다.

const scenarios = [
  {
    name: "small",
    courseCount: 5,
  },
  {
    name: "typical",
    courseCount: 20,
  },
  {
    name: "large",
    courseCount: 100,
  },
  {
    name: "maximum-supported",
    courseCount: 500,
  },
];

각 구간에서 여러 번 실행하고 평균뿐 아니라 느린 실행도 확인해야 한다.

또한 캐시가 있는 경우 다음 경로를 구분해야 한다.

  • 캐시 적중
  • 캐시 미스
  • 캐시 만료 직후
  • 캐시 서버 장애
  • 많은 사용자의 동시 캐시 미스

벤치마크의 입력과 환경이 실제 서비스와 다르면 더 빠른 구현을 선택하고도 운영 문제를 해결하지 못할 수 있다.

복잡한 최적화에는 유지보수 비용도 포함해야 한다

강의 진도 계산을 빠르게 만들기 위해 다음 요소를 모두 추가할 수 있다.

  • 사용자별 진도 요약 테이블
  • 원격 캐시
  • 변경 이벤트
  • 비동기 요약 갱신 작업
  • 재시도 대기열
  • 정합성 점검 작업
  • 캐시 제거 이벤트
  • 읽기 전용 데이터베이스
  • 별도의 분석 저장소

각 요소는 특정 병목을 줄일 수 있다.

그러나 새로운 실패 경로도 만든다.

  • 이벤트가 누락된다.
  • 요약이 원본과 달라진다.
  • 캐시 제거가 실패한다.
  • 작업 대기열이 밀린다.
  • 재시도가 중복 갱신을 만든다.
  • 여러 저장소의 장애를 함께 처리해야 한다.
  • 문제 발생 시 원인을 추적하기 어려워진다.

현재 사용자가 1,000명이고 ‘내 강의’ API가 100밀리초 안에 끝난다면 이런 구조는 지나칠 수 있다.

복잡한 구조를 도입하기 전에는 다음을 설명할 수 있어야 한다.

  • 현재 방식이 어느 데이터 크기에서 목표를 넘는가?
  • 단순한 인덱스나 조회 범위 축소로 해결할 수 없는가?
  • 추가 구성 요소가 줄이는 비용은 얼마인가?
  • 새 구조의 운영 책임을 감당할 수 있는가?
  • 원본에서 파생 데이터를 다시 만들 수 있는가?
  • 장애가 발생했을 때 안전한 기본 경로가 있는가?

최적화는 코드 속도와 함께 이해 가능성, 변경 비용과 복구 가능성도 고려해야 한다.

작은 단계로 변경해야 원인과 효과를 구분할 수 있다

느린 API를 개선하면서 인덱스, 캐시, 쿼리 변경과 응답 축소를 한 번에 적용할 수 있다.

응답이 빨라질 수는 있지만 어떤 변경이 효과를 만들었는지 알기 어렵다.

문제가 발생했을 때 원인을 찾거나 일부 변경만 되돌리기도 어렵다.

작은 단계로 진행할 수 있다.

  1. 구간별 측정과 쿼리 수를 추가한다.
  2. 사용하지 않는 응답 필드를 제거한다.
  3. 반복 조회를 묶음 조회로 변경한다.
  4. 쿼리 실행 계획을 확인한다.
  5. 필요한 인덱스를 추가한다.
  6. 실제 입력 크기로 다시 측정한다.
  7. 반복 계산이 남아 있다면 캐시나 요약 데이터를 검토한다.
  8. 운영 지표를 확인한 뒤 다음 병목을 선택한다.

각 단계에서는 변경 전후를 비교해야 한다.

지표변경 전변경 후
p50 응답 시간420ms210ms
p95 응답 시간2,400ms580ms
요청당 쿼리 수81회3회
읽은 행 수12,000개430개
응답 크기1.8MB90KB
서버 메모리140MB70MB
오류 비율0.2%0.2%

성능만 좋아지고 오류 비율이 증가했다면 성공한 최적화라고 보기 어렵다.

정확성과 안정성 지표도 함께 비교해야 한다.

최적화 전후의 결과가 같은 의미인지 검증해야 한다

기존 구현과 개선된 구현의 결과를 비교할 수 있다.

const expected =
  await loadMyCoursesReference(
    userId
  );

const actual =
  await loadMyCoursesOptimized(
    userId
  );

expect(actual).toEqual(expected);

느리지만 규칙이 명확한 기존 구현을 기준으로 사용할 수 있다.

다양한 작은 입력을 만들어 두 구현의 결과를 비교하면 빠뜨린 조건을 발견할 수 있다.

확인해야 하는 입력에는 다음이 포함된다.

  • 수강 강의가 없는 사용자
  • 강좌가 없는 강의
  • 아직 학습하지 않은 강의
  • 일부 강좌만 완료한 강의
  • 모든 강좌를 완료한 강의
  • 비공개로 변경된 강좌
  • 수강이 취소된 강의
  • 같은 완료 요청이 중복된 상태
  • 선택 강좌와 필수 강좌가 섞인 강의
  • 강좌가 추가되거나 삭제된 강의

성능 개선 전후에 유지해야 하는 조건을 테스트할 수 있다.

expect(summary.completedLectureCount)
  .toBeLessThanOrEqual(
    summary.totalLectureCount
  );

expect(summary.progressPercentage)
  .toBeGreaterThanOrEqual(0);

expect(summary.progressPercentage)
  .toBeLessThanOrEqual(100);

최적화는 같은 의미를 더 적은 비용으로 만드는 변경이어야 한다.

빠르게 다른 결과를 만드는 것은 최적화가 아니라 기능 변경이나 오류다.

배포 후 관찰과 되돌릴 방법까지 있어야 최적화가 완성된다

테스트 환경에서 빨라진 변경도 운영 데이터에서는 다른 결과를 만들 수 있다.

새 구현을 일부 사용자에게만 적용할 수 있다.

const useOptimizedQuery =
  featureFlags.isEnabled(
    "optimized-course-summary",
    userId
  );

return useOptimizedQuery
  ? loadMyCoursesOptimized(userId)
  : loadMyCoursesReference(userId);

두 구현의 성능과 오류를 비교할 수 있다.

문제가 발생하면 기능 플래그를 끄고 기존 경로로 돌아갈 수 있다.

다음 지표를 함께 비교해야 한다.

  • 응답 시간
  • 쿼리 수
  • 데이터베이스 CPU
  • 읽은 행 수
  • 서버 메모리
  • 오류 비율
  • 결과가 비어 있는 비율
  • 기존 구현과 결과가 다른 비율
  • 사용자 화면 이탈률
  • 강의 이어보기 성공률

최적화 코드를 배포하는 것만으로 작업이 끝나지 않는다.

운영 환경에서 예상한 효과가 나타나는지 확인하고, 예상하지 못한 문제가 생기면 안전하게 되돌릴 수 있어야 한다.

최적화를 결정할 때 물어봐야 할 질문

서비스의 성능을 개선하기 전에 다음 질문을 확인할 수 있다.

  1. 사용자가 느리다고 경험하는 구체적인 작업은 무엇인가?
  2. 현재 응답 시간과 목표 응답 시간은 얼마인가?
  3. 평균뿐 아니라 p95, p99와 타임아웃 비율을 확인했는가?
  4. 어떤 사용자와 입력 크기에서 문제가 커지는가?
  5. 전체 요청 흐름을 구간별로 측정했는가?
  6. 서버 계산, 데이터베이스, 캐시, 네트워크와 렌더링 시간을 구분했는가?
  7. 현재 병목이라고 판단한 근거는 무엇인가?
  8. 반복문 모양만 보고 병목을 추측하고 있지는 않은가?
  9. 반복문 안에서 데이터베이스나 외부 API를 호출하는가?
  10. 요청 한 건에서 실제 쿼리가 몇 번 실행되는가?
  11. 데이터베이스가 읽는 행 수와 반환하는 행 수는 얼마인가?
  12. 화면이 사용하지 않는 데이터까지 조회하고 있지는 않은가?
  13. 필요한 결과를 더 작게 정의할 수 있는가?
  14. 작업을 빠르게 만들기 전에 실행하지 않아도 되는 작업을 찾았는가?
  15. 전체 객체 대신 개수나 요약만 조회할 수 있는가?
  16. 여러 개별 조회를 안전하게 묶을 수 있는가?
  17. 하나의 거대한 쿼리가 오히려 중복 행과 복잡성을 늘리지는 않는가?
  18. 쿼리 실행 계획을 확인한 뒤 인덱스를 추가하는가?
  19. 인덱스가 저장 공간과 쓰기 성능에 만드는 비용을 확인했는가?
  20. 캐시할 데이터의 변경 빈도와 허용 가능한 지연은 얼마인가?
  21. 캐시와 요약 데이터의 Source of Truth를 구분했는가?
  22. 중요한 상태 변경에서 최신 원본을 다시 검증하는가?
  23. 미리 계산한 데이터를 누가 언제 갱신하는가?
  24. 중복 요청과 동시 실행에서도 요약이 정확하게 유지되는가?
  25. 외부 입력의 형식과 최대 크기를 제한했는가?
  26. 성능을 위해 권한, 검증이나 비즈니스 규칙을 제거하지 않았는가?
  27. 병렬 실행이 데이터베이스 연결과 전체 처리량에 미치는 영향을 확인했는가?
  28. 핵심 화면에 필요하지 않은 작업을 나중으로 분리할 수 있는가?
  29. 실제 데이터 분포와 최대 지원 크기로 테스트했는가?
  30. 캐시 적중, 미스와 장애 경로를 각각 측정했는가?
  31. 더 복잡한 구조가 현재 문제 규모에 비해 지나치지는 않은가?
  32. 변경 전후의 결과가 같은 비즈니스 의미를 갖는지 검증했는가?
  33. 성능뿐 아니라 오류율과 정확성 지표도 비교했는가?
  34. 작은 단계로 변경하여 각 개선의 효과를 구분할 수 있는가?
  35. 운영에서 문제가 생기면 이전 구현으로 되돌릴 수 있는가?
  36. 최적화 이후 새 병목이 어디로 이동했는지 확인했는가?
  37. 개선으로 절약한 시간이나 비용을 숫자로 설명할 수 있는가?
  38. 현재 구현을 다시 검토해야 할 데이터 규모와 지표가 정해져 있는가?

이 질문은 모든 코드를 측정 도구와 캐시로 복잡하게 만들기 위한 목록이 아니다.

현재 응답이 목표 안에 있고 데이터 증가에도 충분하다면 읽기 쉬운 구현을 유지하는 것이 더 좋은 판단일 수 있다.

성능 문제가 실제로 나타났다면 피해가 큰 사용자 경로부터 측정하고 가장 큰 비용을 만드는 작업을 개선해야 한다.

최적화는 더 빠른 코드가 아니라 더 나은 서비스 결과를 만드는 과정이다

최적화는 중요하다.

데이터와 사용자가 증가하면 단순한 구현이 서비스 목표를 만족하지 못하는 시점이 올 수 있다.

하지만 가장 낮은 시간 복잡도를 가진 알고리즘이나 가장 짧은 실행 시간을 기록한 코드가 자동으로 가장 좋은 구현이 되는 것은 아니다.

온라인 학습 서비스에서 ‘내 강의’ 화면이 느리다면 다음 순서로 생각해야 한다.

  1. 사용자가 기다리는 구체적인 경로를 정의한다.
  2. 구간별 시간과 입력 크기를 측정한다.
  3. 실제 병목이 계산, 저장소, 네트워크와 렌더링 중 어디에 있는지 찾는다.
  4. 필요하지 않은 조회와 응답 데이터를 제거한다.
  5. 반복되는 원격 호출을 묶는다.
  6. 실행 계획을 확인하고 필요한 인덱스를 추가한다.
  7. 반복 계산이 실제로 남아 있을 때만 캐시나 요약 데이터를 검토한다.
  8. 원본과 파생 데이터의 책임을 구분한다.
  9. 권한, 상태와 동시성 규칙을 유지한다.
  10. 변경 전후의 성능, 정확성과 비용을 함께 비교한다.
  11. 일부 사용자에게 안전하게 적용하고 운영 지표를 관찰한다.
  12. 문제가 생겼을 때 되돌릴 경로를 준비한다.

실제 서비스에서 최적화란 코드를 가장 빠른 형태로 바꾸는 일이 아니다. 사용자가 기다리는 이유를 측정하고, 서비스가 요구하는 정확성과 규모 안에서 불필요한 작업을 제거하며, 개선으로 이동한 비용과 복잡성까지 검증하는 과정이다.

강의 진도율을 계산하는 함수가 눈에 띈다는 이유로 그 함수부터 바꾸어서는 안 된다.

실제 지연의 대부분이 반복되는 데이터베이스 조회에 있을 수 있다. 모든 강좌 객체를 가져오지만 화면은 개수만 사용할 수도 있다. 응답에 사용하지 않는 데이터가 대부분일 수도 있다. 특정 사용자 구간에서만 문제가 발생할 수도 있다.

좋은 최적화는 빠른 코드를 자랑하는 데서 끝나지 않는다.

어떤 사용자의 어떤 문제가 얼마나 개선되었고, 그 과정에서 정확성, 저장 공간, 쓰기 비용과 유지보수성이 어떻게 달라졌는지 설명할 수 있어야 한다.

다음 글에서는 배열이 단순히 여러 값을 순서대로 담는 구조가 아닌 이유와 연속된 데이터를 탐색하고 정렬하고 변환하는 방식에 맞춰 저장 구조를 선택하는 방법을 살펴본다.

profile
Vision eXperience Developer

0개의 댓글