알고리즘을 처음 배울 때 최적화는 같은 결과를 더 적은 시간이나 공간으로 계산하도록 알고리즘을 개선하는 일이라고 배운다.
강의 목록을 최신순으로 정렬하는 코드를 생각해보자.
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 |
이 결과라면 응답 객체를 만드는 반복문보다 진도 요약 조회를 먼저 살펴봐야 한다.
측정할 때는 시간만 남겨서는 부족하다.
다음 입력 크기도 함께 기록해야 한다.
입력 크기 없이 응답 시간만 보면 어떤 조건에서 느려지는지 알기 어렵다.
‘내 강의’ 첫 화면에 필요한 정보가 무엇인지 확인해보자.
화면에는 다음 정보만 필요할 수 있다.
그런데 기존 구현은 각 강의에 속한 모든 강좌와 완료 기록을 가져온다.
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,
});
}
);
같은 사용자의 같은 강좌 완료 기록은 한 번만 생성된다.
새 기록이 생성되었을 때만 요약 개수를 증가시킨다.
읽기 시간을 줄이기 위해 계산 결과를 저장하면 다음 책임이 생긴다.
미리 계산한 데이터는 시간을 줄이는 대신 상태 관리의 복잡성을 늘린다.
사용자별 진도 요약에 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,
};
첫 화면은 제목, 대표 이미지와 진도율만 사용할 수 있다.
그런데 사용하지 않는 데이터 때문에 다음 비용이 생긴다.
목록 화면에 필요한 응답만 만들 수 있다.
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 |
전체 평균만 보면 서비스가 충분히 빠르게 보일 수 있다.
그러나 강의를 많이 수강한 사용자에게는 지속적으로 느린 화면이 제공된다.
다음 지표를 함께 확인해야 한다.
최적화 대상은 가장 많은 사용자가 실행하는 경로일 수도 있고, 적은 사용자에게 큰 피해를 주는 경로일 수도 있다.
빈도와 피해 크기를 함께 봐야 한다.
강의 10개를 가진 테스트 데이터에서 여러 구현을 비교할 수 있다.
const startedAt = performance.now();
await loadMyCourses(testUserId);
const elapsedMs =
performance.now() - startedAt;
한 번 실행한 결과가 20밀리초라고 해서 운영 환경에서도 같은 성능이 나온다고 볼 수 없다.
실제 환경에는 다음 차이가 있을 수 있다.
대표적인 입력 구간을 나누어 테스트해야 한다.
const scenarios = [
{
name: "small",
courseCount: 5,
},
{
name: "typical",
courseCount: 20,
},
{
name: "large",
courseCount: 100,
},
{
name: "maximum-supported",
courseCount: 500,
},
];
각 구간에서 여러 번 실행하고 평균뿐 아니라 느린 실행도 확인해야 한다.
또한 캐시가 있는 경우 다음 경로를 구분해야 한다.
벤치마크의 입력과 환경이 실제 서비스와 다르면 더 빠른 구현을 선택하고도 운영 문제를 해결하지 못할 수 있다.
강의 진도 계산을 빠르게 만들기 위해 다음 요소를 모두 추가할 수 있다.
각 요소는 특정 병목을 줄일 수 있다.
그러나 새로운 실패 경로도 만든다.
현재 사용자가 1,000명이고 ‘내 강의’ API가 100밀리초 안에 끝난다면 이런 구조는 지나칠 수 있다.
복잡한 구조를 도입하기 전에는 다음을 설명할 수 있어야 한다.
최적화는 코드 속도와 함께 이해 가능성, 변경 비용과 복구 가능성도 고려해야 한다.
느린 API를 개선하면서 인덱스, 캐시, 쿼리 변경과 응답 축소를 한 번에 적용할 수 있다.
응답이 빨라질 수는 있지만 어떤 변경이 효과를 만들었는지 알기 어렵다.
문제가 발생했을 때 원인을 찾거나 일부 변경만 되돌리기도 어렵다.
작은 단계로 진행할 수 있다.
각 단계에서는 변경 전후를 비교해야 한다.
| 지표 | 변경 전 | 변경 후 |
|---|---|---|
| p50 응답 시간 | 420ms | 210ms |
| p95 응답 시간 | 2,400ms | 580ms |
| 요청당 쿼리 수 | 81회 | 3회 |
| 읽은 행 수 | 12,000개 | 430개 |
| 응답 크기 | 1.8MB | 90KB |
| 서버 메모리 | 140MB | 70MB |
| 오류 비율 | 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);
두 구현의 성능과 오류를 비교할 수 있다.
문제가 발생하면 기능 플래그를 끄고 기존 경로로 돌아갈 수 있다.
다음 지표를 함께 비교해야 한다.
최적화 코드를 배포하는 것만으로 작업이 끝나지 않는다.
운영 환경에서 예상한 효과가 나타나는지 확인하고, 예상하지 못한 문제가 생기면 안전하게 되돌릴 수 있어야 한다.
서비스의 성능을 개선하기 전에 다음 질문을 확인할 수 있다.
이 질문은 모든 코드를 측정 도구와 캐시로 복잡하게 만들기 위한 목록이 아니다.
현재 응답이 목표 안에 있고 데이터 증가에도 충분하다면 읽기 쉬운 구현을 유지하는 것이 더 좋은 판단일 수 있다.
성능 문제가 실제로 나타났다면 피해가 큰 사용자 경로부터 측정하고 가장 큰 비용을 만드는 작업을 개선해야 한다.
최적화는 중요하다.
데이터와 사용자가 증가하면 단순한 구현이 서비스 목표를 만족하지 못하는 시점이 올 수 있다.
하지만 가장 낮은 시간 복잡도를 가진 알고리즘이나 가장 짧은 실행 시간을 기록한 코드가 자동으로 가장 좋은 구현이 되는 것은 아니다.
온라인 학습 서비스에서 ‘내 강의’ 화면이 느리다면 다음 순서로 생각해야 한다.
실제 서비스에서 최적화란 코드를 가장 빠른 형태로 바꾸는 일이 아니다. 사용자가 기다리는 이유를 측정하고, 서비스가 요구하는 정확성과 규모 안에서 불필요한 작업을 제거하며, 개선으로 이동한 비용과 복잡성까지 검증하는 과정이다.
강의 진도율을 계산하는 함수가 눈에 띈다는 이유로 그 함수부터 바꾸어서는 안 된다.
실제 지연의 대부분이 반복되는 데이터베이스 조회에 있을 수 있다. 모든 강좌 객체를 가져오지만 화면은 개수만 사용할 수도 있다. 응답에 사용하지 않는 데이터가 대부분일 수도 있다. 특정 사용자 구간에서만 문제가 발생할 수도 있다.
좋은 최적화는 빠른 코드를 자랑하는 데서 끝나지 않는다.
어떤 사용자의 어떤 문제가 얼마나 개선되었고, 그 과정에서 정확성, 저장 공간, 쓰기 비용과 유지보수성이 어떻게 달라졌는지 설명할 수 있어야 한다.
다음 글에서는 배열이 단순히 여러 값을 순서대로 담는 구조가 아닌 이유와 연속된 데이터를 탐색하고 정렬하고 변환하는 방식에 맞춰 저장 구조를 선택하는 방법을 살펴본다.