프로그래밍을 처음 배울 때 성능 최적화는 보통 코드를 더 빠르게 실행되도록 개선하는 일이라고 배운다.
const playableTracks = tracks.filter(
(track) => track.isAvailable
);
같은 결과를 더 적은 연산과 메모리로 얻는 방법을 찾는 것이 대표적인 예이다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 코드 한 부분의 실행 속도보다 더 많은 판단이 필요하다.
성능 최적화는 코드를 빠르게 만드는 일이 아니다.
성능 최적화는 사용자가 목표를 완료하기까지 기다리는 시간을 측정하고, 그 경로에서 가장 큰 지연을 만드는 비용을 줄이는 설계다.
크리스가 음악 스트리밍 서비스를 개발한다고 생각해 보자.
사용자는 아티스트를 검색하고, 앨범을 열고, 재생 버튼을 누른다. 사용자가 기대하는 결과는 배열 필터링이 몇 밀리초 빨리 끝나는 것이 아니라 음악이 빠르게 재생되는 것이다.
다음 코드는 반복문을 줄이기 위해 한 번의 순회로 데이터를 가공한다.
const playableTrackNames: string[] = [];
for (const track of tracks) {
if (track.isAvailable) {
playableTrackNames.push(track.title);
}
}
이 코드는 별도의 filter와 map을 사용하는 코드보다 배열 순회 횟수가 적을 수 있다.
그러나 트랙이 20개인 앨범 화면에서 이 변경이 절약하는 시간은 사용자가 거의 느끼지 못할 수 있다. 반면 재생 권한 확인에 300밀리초, 미디어 주소 발급에 500밀리초, 오디오 연결에 1초가 걸린다면 실제 대기 시간은 그대로 남는다.
성능 문제를 발견했을 때 곧바로 짧은 코드부터 고치면 측정하기 쉬운 부분만 최적화하게 된다. 먼저 사용자가 완료하려는 행동을 정의해야 한다.
| 사용자 행동 | 시작 | 완료 |
|---|---|---|
| 앱 첫 화면 열기 | 탐색 시작 | 주요 콘텐츠가 보이고 조작 가능 |
| 아티스트 검색 | 검색 입력 또는 제출 | 의미 있는 검색 결과가 표시 |
| 앨범 열기 | 앨범 선택 | 트랙 목록을 확인하고 조작 가능 |
| 음악 재생 | 재생 버튼 선택 | 실제 오디오가 들리기 시작 |
| 플레이리스트 저장 | 저장 선택 | 저장 성공 여부를 확실히 확인 |
코드 실행 시간은 이 흐름을 구성하는 하나의 비용이다. 최적화의 출발점은 함수가 아니라 사용자의 목표이다.
재생 버튼을 누른 뒤 음악이 들리기까지 하나의 함수만 실행되는 것은 아니다.
flowchart TD
A["사용자가 재생 선택"] --> B["프론트엔드 요청"]
B --> C["재생 API"]
C --> D["권한·트랙 정보 확인"]
D --> E["미디어 전송 준비"]
E --> F["오디오 재생 시작"]
사용자가 경험하는 대기 시간에는 브라우저 이벤트 처리, 네트워크 왕복, 서버 인증, 데이터베이스 조회, 미디어 주소 발급, CDN 연결과 오디오 버퍼링이 함께 포함될 수 있다.
전체 시간을 다음처럼 생각할 수 있다.
재생 시작 시간
= 프론트엔드 처리
+ 네트워크 왕복
+ 서버 처리
+ 데이터 조회
+ 미디어 준비
+ 초기 버퍼링
이 식은 정확한 성능 모델이라기보다 조사 범위를 보여준다. 어느 한 구간만 빠르게 만들어도 다른 구간이 대부분의 시간을 차지하면 사용자가 느끼는 변화는 작다.
따라서 “재생 API가 느리다”라는 말만으로는 충분하지 않다. 각 구간에서 얼마나 시간이 사용되었는지 분리해야 한다.
크리스가 재생 버튼부터 오디오 시작까지 걸린 시간을 브라우저에서 측정한다고 생각해 보자.
performance.mark("playback-requested");
await audioPlayer.play(trackId);
performance.mark("playback-started");
performance.measure(
"playback-start-latency",
"playback-requested",
"playback-started"
);
이 코드는 재생을 요청한 시점과 실제 재생이 시작된 시점 사이에 이름을 붙인다. 이제 막연히 “느린 것 같다”고 판단하는 대신 playback-start-latency라는 사용자 중심 지표를 수집할 수 있다.
서버에서도 요청 전체와 주요 작업을 나눠 측정할 수 있다.
const requestStartedAt = performance.now();
const playbackAccess =
await accessService.checkPlaybackAccess({
userId,
trackId,
});
const accessCheckedAt = performance.now();
const playbackUrl =
await mediaService.createPlaybackUrl(trackId);
logger.info("playback_prepared", {
trackId,
accessCheckMs:
accessCheckedAt - requestStartedAt,
totalDurationMs:
performance.now() - requestStartedAt,
});
이 로그는 서버 전체 시간뿐 아니라 권한 확인이 얼마나 차지했는지도 보여준다. 단, 사용자 ID나 재생 URL처럼 민감하거나 악용될 수 있는 값은 로그에 그대로 남기지 않아야 한다.
데이터베이스 조회가 의심된다면 실제 실행 계획을 확인할 수 있다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, duration_seconds
FROM tracks
WHERE album_id = $1
ORDER BY track_number;
이 명령은 인덱스 사용 여부, 실제 처리한 행 수, 정렬과 버퍼 작업을 확인하는 데 도움을 준다. ANALYZE는 쿼리를 실제로 실행하므로 변경 쿼리나 운영 환경에서는 영향을 먼저 검토해야 한다.
측정 결과는 최소한 다음 세 수준으로 연결되어야 한다.
| 수준 | 대표 질문 | 예시 지표 |
|---|---|---|
| 사용자 경험 | 사용자가 얼마나 기다리는가? | 재생 시작 시간, 검색 결과 표시 시간 |
| 서비스 요청 | 어느 요청이 시간을 사용하는가? | API 응답 시간, 오류율 |
| 내부 작업 | 요청 안에서 무엇이 느린가? | 쿼리 시간, 외부 API 시간, 직렬화 시간 |
하위 지표만 보면 사용자가 실제로 개선을 느꼈는지 알기 어렵다. 상위 지표만 보면 느린 원인을 찾기 어렵다. 두 수준을 연결해야 한다.
재생 시작 시간이 다음과 같다고 가정해 보자.
const playbackStartSamples = [
420,
450,
470,
510,
530,
560,
610,
1800,
2400,
5200,
];
평균은 전체 경향을 보여주지만 느린 네트워크나 특정 기기에서 발생하는 긴 대기를 가릴 수 있다.
서비스에서는 보통 백분위수를 함께 확인한다.
예를 들어 평균 재생 시작 시간이 700밀리초로 개선되었더라도 p95가 4초라면 적지 않은 사용자가 여전히 긴 대기를 경험한다.
측정값은 환경에 따라서도 나눠야 한다.
개발자의 빠른 장비와 사내 네트워크에서 얻은 한 번의 결과는 전체 사용자의 Source of Truth가 아니다. 운영 환경에서 수집한 사용자 경험 지표가 성능 판단의 중요한 원본이 된다.
웹 화면에서는 로딩, 상호작용 반응성, 레이아웃 안정성을 나타내는 지표를 사용할 수 있다.
음악 스트리밍 서비스에서는 여기에 서비스 고유 지표가 필요하다.
| 지표 | 설명 |
|---|---|
| 앨범 화면 표시 시간 | 앨범 정보와 주요 트랙이 보일 때까지의 시간 |
| 검색 결과 표시 시간 | 검색 행동 후 유효한 결과가 나타날 때까지의 시간 |
| 재생 시작 시간 | 재생 선택 후 오디오가 들리기까지의 시간 |
| 재생 중단 비율 | 버퍼 부족으로 재생이 멈춘 세션의 비율 |
| 플레이리스트 저장 확인 시간 | 저장 선택 후 결과를 확실히 알기까지의 시간 |
일반적인 화면 지표만 개선하면 앨범 표지는 빨리 보이지만 오디오 재생은 여전히 느릴 수 있다. 반대로 재생 API만 개선하면 첫 화면이 늦게 표시되는 문제를 놓칠 수 있다.
성능 지표는 기술 구성요소의 이름보다 사용자의 행동에 맞춰 정의하는 편이 낫다.
재생을 준비하면서 여러 작업을 차례대로 실행한다고 생각해 보자.
const access =
await accessService.checkPlaybackAccess({
userId,
trackId,
});
const track =
await trackRepository.findById(trackId);
const recommendations =
await recommendationService.getForTrack(trackId);
const playbackUrl =
await mediaService.createPlaybackUrl(track.id);
추천 목록은 오디오 재생을 시작하는 데 필요하지 않지만 앞의 코드에서는 추천 계산이 끝날 때까지 재생 URL을 만들지 못한다.
권한 확인과 트랙 조회가 서로 독립적이라면 동시에 시작하고, 추천은 핵심 경로에서 분리할 수 있다.
const [access, track] = await Promise.all([
accessService.checkPlaybackAccess({
userId,
trackId,
}),
trackRepository.findById(trackId),
]);
const playbackUrl =
await mediaService.createPlaybackUrl(track.id);
void recommendationQueue.enqueue({
userId,
trackId,
});
이 코드는 재생에 필요한 두 작업을 함께 시작하고, 추천 갱신을 사용자의 재생 대기 경로 밖으로 이동한다.
하지만 모든 작업을 무조건 병렬이나 백그라운드로 보내서는 안 된다.
권한 확인이 끝나기 전에 재생 URL을 제공하거나, 실제 저장 전에 성공을 응답하면 속도를 위해 서비스의 약속을 깨뜨리게 된다.
앨범 화면에서 필요한 정보가 트랙 제목과 재생 시간뿐인데 모든 데이터를 반환한다고 생각해 보자.
const album = await albumRepository.findById(albumId, {
includeTracks: true,
includeLyrics: true,
includeListeningHistory: true,
includeRecommendations: true,
});
이 조회는 당장 보이지 않는 가사, 사용자 이력, 추천 데이터까지 읽는다. 데이터베이스 작업, 서버 직렬화, 네트워크 전송, 브라우저 파싱 비용이 모두 증가할 수 있다.
화면이 처음 표시되는 데 필요한 데이터만 요청할 수 있다.
const albumSummary =
await albumRepository.findSummaryById(albumId);
const tracks =
await trackRepository.findPage({
albumId,
limit: 20,
cursor,
});
이 코드는 앨범 요약과 첫 번째 트랙 페이지에 필요한 데이터만 조회한다. 가사나 추천은 사용자가 해당 기능을 열 때 별도로 가져올 수 있다.
프론트엔드에 반환하는 형태도 명시적으로 제한할 수 있다.
type AlbumPageResponse = {
album: {
id: string;
title: string;
artistName: string;
coverImageUrl: string;
};
tracks: Array<{
id: string;
title: string;
durationSeconds: number;
}>;
nextCursor: string | null;
};
이 타입은 첫 화면의 계약을 표현한다. 내부 테이블에 필드가 추가되더라도 응답 데이터가 자동으로 커지지 않게 한다.
성능 최적화에서 가장 저렴한 작업은 빠르게 실행하는 작업이 아니라 실행하지 않아도 되는 작업이다.
플레이리스트의 트랙을 조회한 뒤 각 트랙의 앨범을 따로 가져오는 코드를 생각해 보자.
const tracks =
await playlistRepository.findTracks(playlistId);
const trackCards = await Promise.all(
tracks.map(async (track) => {
const album =
await albumRepository.findById(track.albumId);
return {
trackId: track.id,
title: track.title,
albumTitle: album.title,
};
})
);
코드에서는 목록 변환처럼 보이지만 트랙 수만큼 데이터베이스 조회가 추가될 수 있다. 트랙이 100개라면 처음 조회 뒤에 100개의 앨범 조회가 발생할 수 있다.
필요한 관계를 한 번의 조회나 제한된 배치 조회로 가져오는 편이 나을 수 있다.
SELECT
t.id,
t.title,
a.title AS album_title
FROM playlist_tracks pt
JOIN tracks t ON t.id = pt.track_id
JOIN albums a ON a.id = t.album_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
LIMIT 100;
이 쿼리는 플레이리스트 화면에 필요한 트랙과 앨범 제목을 하나의 조회 경로로 가져온다.
그렇다고 조인을 늘리는 것이 항상 정답은 아니다. 결과 행이 과도하게 중복되거나 한 번에 너무 많은 데이터를 읽는다면 배치 조회나 페이지네이션이 더 적절할 수 있다.
중요한 것은 ORM 코드의 줄 수가 아니라 실제로 실행된 SQL, 읽은 행 수, 네트워크 왕복 횟수와 실행 계획이다.
앨범 정보는 많은 사용자가 반복해서 읽고 자주 바뀌지 않는다. 이런 데이터는 캐시 후보가 될 수 있다.
async function getAlbumSummary(albumId: string) {
const cacheKey = `album-summary:${albumId}:v2`;
const cachedAlbum = await cache.get(cacheKey);
if (cachedAlbum) {
return cachedAlbum;
}
const album =
await albumRepository.findSummaryById(albumId);
await cache.set(cacheKey, album, {
ttlSeconds: 300,
});
return album;
}
이 코드는 캐시에 데이터가 있으면 빠르게 반환하고, 없으면 데이터베이스에서 읽어 일정 시간 동안 저장한다.
여기서 앨범 데이터의 Source of Truth는 데이터베이스이다. 캐시는 원본에서 만든 파생된 복사본이다.
따라서 캐시를 추가할 때는 속도뿐 아니라 다음 질문에 답해야 한다.
특히 재생 권한을 단순한 공용 키로 캐시하면 한 사용자의 권한 결과가 다른 사용자에게 적용될 수 있다. 캐시 키에는 데이터의 실제 범위가 반영되어야 하며, 정확성이 중요한 정보는 오래된 값을 허용할 수 있는지 별도로 판단해야 한다.
프론트엔드는 필요한 리소스의 우선순위를 조절할 수 있다.
function AlbumPage({
album,
}: {
album: AlbumPageResponse["album"];
}) {
return (
<main>
<img
src={album.coverImageUrl}
alt={`${album.title} 앨범 표지`}
width={320}
height={320}
fetchPriority="high"
/>
<RelatedAlbumList loading="lazy" />
</main>
);
}
첫 화면의 주요 앨범 이미지는 높은 우선순위로 가져오고, 화면 아래의 관련 앨범은 나중에 불러올 수 있다. 이미지 크기를 지정하면 로딩 중 레이아웃이 움직이는 문제도 줄일 수 있다.
스켈레톤 화면이나 즉각적인 버튼 피드백도 사용자가 현재 상태를 이해하는 데 도움을 준다. 그러나 실제 작업이 5초 걸리는데 애니메이션만 추가했다고 해서 성능 문제가 해결되는 것은 아니다.
다음 두 질문을 구분해야 한다.
첫 번째는 처리 비용과 지연의 문제이고, 두 번째는 피드백과 지각된 성능의 문제다. 좋은 서비스는 둘 다 다루지만 하나를 다른 하나의 대체물로 사용하지 않는다.
크리스가 검색어가 바뀔 때마다 즉시 API를 호출한다고 생각해 보자.
async function handleSearchInput(query: string) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
사용자가 다섯 글자를 입력하면 짧은 시간에 여러 요청이 겹칠 수 있다. 앞선 요청의 응답이 늦게 도착하면 최신 결과를 덮어쓸 수도 있다.
짧은 지연을 두고 이전 요청을 취소할 수 있다.
let searchController: AbortController | null = null;
async function searchMusic(query: string) {
searchController?.abort();
searchController = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: searchController.signal,
}
);
return response.json();
}
이 함수는 새로운 검색이 시작되면 이전 네트워크 요청을 취소한다. 실제 화면에서는 입력 지연 처리와 함께 사용해 의미 없는 중간 요청을 더 줄일 수 있다.
그러나 프론트엔드에서 요청 수를 줄이는 것만으로 서버 입력 검증을 대신할 수 없다. 외부 입력은 검증 전까지 신뢰할 수 없다.
import { z } from "zod";
const MusicSearchSchema = z.object({
q: z.string().trim().min(2).max(80),
limit: z.coerce.number().int().min(1).max(30)
.default(20),
});
const searchInput =
MusicSearchSchema.parse(request.query);
이 스키마는 지나치게 짧거나 긴 검색어와 과도한 결과 수를 제한한다.
검증은 잘못된 입력을 막는 기능이면서 서비스 자원을 보호하는 성능 경계이기도 하다. 다만 제한값은 임의로 선택하기보다 실제 제품 요구와 예상 사용 방식을 반영해야 한다.
같은 지연을 줄이더라도 방법마다 다른 비용이 생긴다.
| 선택 | 얻는 것 | 함께 검토할 비용 |
|---|---|---|
| 캐시 사용 | 반복 조회 감소 | 오래된 데이터, 무효화 복잡성 |
| 병렬 실행 | 독립 작업의 전체 대기 감소 | 순간 부하, 실패 처리 복잡성 |
| 사전 로딩 | 다음 화면의 대기 감소 | 사용하지 않을 데이터 전송 |
| 데이터 축소 | 전송과 파싱 비용 감소 | 추가 요청 가능성 |
| 백그라운드 처리 | 사용자 응답 경로 단축 | 재시도, 추적, 결과 일관성 |
| 인덱스 추가 | 특정 조회 경로 개선 | 저장 공간, 쓰기 비용 |
| CDN 사용 | 사용자와 가까운 위치에서 전달 | 캐시 정책, 비용, 갱신 지연 |
성능 개선은 비용을 없애기보다 다른 시점이나 시스템으로 옮기는 경우가 많다.
예를 들어 다음 곡을 미리 내려받으면 곡 전환은 빨라질 수 있지만 사용자가 재생하지 않을 데이터를 소비할 수 있다. 캐시 만료 시간을 늘리면 데이터베이스 부하는 줄지만 변경된 앨범 정보가 늦게 반영될 수 있다.
따라서 “더 빠른가?”와 함께 “어떤 비용을 어디로 옮겼는가?”를 물어야 한다.
성능은 기능이 추가될 때 다시 느려질 수 있다. 목표를 숫자로 표현하면 변경의 영향을 지속적으로 확인하기 쉬워진다.
음악 서비스는 다음과 같은 성능 예산을 정할 수 있다.
| 사용자 흐름 | 예시 목표 |
|---|---|
| 주요 앨범 콘텐츠 표시 | 실제 사용자 p75 기준 2.5초 이내 |
| 검색 결과 표시 | p95 기준 1초 이내 |
| 재생 시작 | p95 기준 1.5초 이내 |
| 재생 API 서버 처리 | p95 기준 300밀리초 이내 |
| 첫 화면 JavaScript | 압축 후 정한 크기 이하 |
| 검색 결과 수 | 요청당 최대 30개 |
이 숫자는 모든 서비스에 그대로 적용하는 정답이 아니다. 제품 기대치, 사용자 환경, 기술 제약과 현재 측정값을 바탕으로 정해야 한다.
예산을 정한 뒤에는 다음 과정이 필요하다.
기준값 측정
→ 가장 큰 지연 구간 선택
→ 한 가지 변경
→ 같은 조건에서 다시 측정
→ 사용자 지표와 비용 확인
→ 회귀 감시
이 흐름은 여러 최적화를 한꺼번에 적용해 어떤 변경이 효과를 냈는지 알 수 없게 되는 문제를 줄인다.
테스트 환경에서는 성능 예산을 넘는 번들 크기나 특정 쿼리를 발견할 수 있다. 운영 환경에서는 실제 사용자의 p75·p95 지표와 오류율을 감시해야 한다. 실험실 측정과 운영 측정은 서로 대체하는 것이 아니라 서로 다른 질문에 답한다.
읽기 쉬운 코드를 복잡한 저수준 구현으로 바꿔도 사용자가 느끼는 시간은 거의 달라지지 않을 수 있다.
먼저 사용자 흐름과 병목을 측정해야 한다.
평균은 매우 느린 일부 사용자를 가릴 수 있다.
백분위수와 사용자 환경별 분포를 함께 확인해야 한다.
독립적이지 않은 작업을 동시에 실행하면 잘못된 순서, 과부하, 복잡한 실패 처리가 발생할 수 있다.
의존 관계와 자원 한도를 확인한 뒤 병렬성을 선택해야 한다.
캐시는 오래된 값, 무효화, 키 범위와 장애 시 복구 문제를 만든다.
캐시와 Source of Truth의 책임을 명확히 구분해야 한다.
피드백은 기다림을 이해하게 하지만 실제 완료 시간을 줄이지는 않는다.
지각된 성능과 실제 처리 시간을 각각 측정해야 한다.
짧은 애플리케이션 코드가 많은 쿼리나 비싼 정렬을 만들 수 있다.
실제 SQL, 실행 계획과 읽은 데이터 양을 확인해야 한다.
실제 사용자는 서로 다른 기기, 네트워크와 지역에서 서비스를 사용한다.
실험실 결과와 실제 사용자 측정을 함께 운영해야 한다.
오래된 캐시나 지연 처리를 허용했다면 그것은 기술 구현이 아니라 제품 정책이기도 하다.
허용 가능한 최신성과 실패 동작을 명시적으로 결정해야 한다.
프로그래밍을 처음 배울 때는 성능 최적화를 같은 결과를 더 빠른 코드로 얻는 일이라고 이해해도 충분하다.
const playableTracks = tracks.filter(
(track) => track.isAvailable
);
그러나 실제 서비스에서는 코드 한 줄의 속도보다 사용자의 목표까지 이어지는 전체 경로를 살펴봐야 한다.
성능 최적화는 코드를 빠르게 만드는 일이 아니다.
성능 최적화는 사용자가 목표를 완료하기까지 기다리는 시간을 측정하고, 그 경로에서 가장 큰 지연을 만드는 비용을 줄이는 설계다.