성능 최적화는 코드를 빠르게 만드는 일이 아니다

vx_developer·2026년 10월 6일

개발하다가

목록 보기
46/49
post-thumbnail

프로그래밍을 처음 배울 때 성능 최적화는 보통 코드를 더 빠르게 실행되도록 개선하는 일이라고 배운다.

const playableTracks = tracks.filter(
  (track) => track.isAvailable
);

같은 결과를 더 적은 연산과 메모리로 얻는 방법을 찾는 것이 대표적인 예이다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 코드 한 부분의 실행 속도보다 더 많은 판단이 필요하다.

  • 사용자는 어느 순간부터 기다리고 있다고 느끼는가?
  • 느린 구간은 프론트엔드, API, 데이터베이스, 네트워크 중 어디에 있는가?
  • 평균 응답 시간이 빨라도 일부 사용자는 계속 느리지 않은가?
  • 캐시로 속도를 높였을 때 오래된 데이터를 보여줘도 되는가?
  • 병렬 처리나 백그라운드 작업이 서비스의 정확성을 해치지 않는가?
  • 개선 효과가 운영 환경에서도 유지되는지 어떻게 확인할 것인가?

성능 최적화는 코드를 빠르게 만드는 일이 아니다.

성능 최적화는 사용자가 목표를 완료하기까지 기다리는 시간을 측정하고, 그 경로에서 가장 큰 지연을 만드는 비용을 줄이는 설계다.


빠른 함수가 반드시 빠른 서비스를 만들지는 않는다

크리스가 음악 스트리밍 서비스를 개발한다고 생각해 보자.

사용자는 아티스트를 검색하고, 앨범을 열고, 재생 버튼을 누른다. 사용자가 기대하는 결과는 배열 필터링이 몇 밀리초 빨리 끝나는 것이 아니라 음악이 빠르게 재생되는 것이다.

다음 코드는 반복문을 줄이기 위해 한 번의 순회로 데이터를 가공한다.

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,
];

평균은 전체 경향을 보여주지만 느린 네트워크나 특정 기기에서 발생하는 긴 대기를 가릴 수 있다.

서비스에서는 보통 백분위수를 함께 확인한다.

  • p50은 사용자의 절반이 이 시간 안에 완료한다는 의미다.
  • p95는 사용자 경험이 느린 쪽에 가까운 구간을 보여준다.
  • p99는 드물지만 매우 느린 요청이나 장애 징후를 찾는 데 도움을 준다.

예를 들어 평균 재생 시작 시간이 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;
};

이 타입은 첫 화면의 계약을 표현한다. 내부 테이블에 필드가 추가되더라도 응답 데이터가 자동으로 커지지 않게 한다.

성능 최적화에서 가장 저렴한 작업은 빠르게 실행하는 작업이 아니라 실행하지 않아도 되는 작업이다.


데이터베이스 문제는 TypeScript 코드만 보고 찾을 수 없다

플레이리스트의 트랙을 조회한 뒤 각 트랙의 앨범을 따로 가져오는 코드를 생각해 보자.

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초 걸리는데 애니메이션만 추가했다고 해서 성능 문제가 해결되는 것은 아니다.

다음 두 질문을 구분해야 한다.

  1. 실제 완료 시간은 줄었는가?
  2. 기다리는 동안 사용자가 상태와 다음 행동을 이해할 수 있는가?

첫 번째는 처리 비용과 지연의 문제이고, 두 번째는 피드백과 지각된 성능의 문제다. 좋은 서비스는 둘 다 다루지만 하나를 다른 하나의 대체물로 사용하지 않는다.


입력을 제한하면 불필요하고 비싼 작업도 줄일 수 있다

크리스가 검색어가 바뀔 때마다 즉시 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 지표와 오류율을 감시해야 한다. 실험실 측정과 운영 측정은 서로 대체하는 것이 아니라 서로 다른 질문에 답한다.


성능을 설계하기 전에 물어봐야 할 질문

어떤 사용자 목표를 개선하려는가

  1. 사용자가 시작한 행동은 무엇인가?
  2. 사용자가 완료라고 인식하는 순간은 언제인가?
  3. 전체 대기 시간을 대표하는 지표가 있는가?
  4. 기술 지표의 개선이 사용자 목표와 연결되는가?
  5. 가장 중요한 흐름의 우선순위가 정해져 있는가?

어디에서 시간이 사용되는가

  1. 프론트엔드, 네트워크, 서버, 데이터베이스 시간을 분리했는가?
  2. 외부 서비스 호출 시간을 별도로 측정하는가?
  3. 순차 실행 중 독립적으로 시작할 수 있는 작업이 있는가?
  4. 필요하지 않은 데이터나 계산이 핵심 경로에 포함되어 있지 않은가?
  5. 실제 실행된 SQL과 실행 계획을 확인했는가?

누구의 환경에서 느린가

  1. 평균뿐 아니라 p50, p95, p99를 확인하는가?
  2. 모바일과 데스크톱을 구분하는가?
  3. 네트워크 속도와 지역별 차이를 확인하는가?
  4. 캐시 적중과 실패를 구분하는가?
  5. 개발 환경이 아니라 실제 사용자 데이터를 수집하는가?

작업량 자체를 줄일 수 있는가

  1. 화면에 필요하지 않은 데이터를 조회하고 있지 않은가?
  2. 한 번에 반환하는 목록의 크기를 제한하는가?
  3. 같은 데이터를 반복해서 가져오고 있지 않은가?
  4. 사용자가 열지 않은 기능을 미리 계산하고 있지 않은가?
  5. N+1 조회나 중복 네트워크 요청이 있는가?

캐시와 Source of Truth를 구분했는가

  1. 원본 데이터는 어디에 있는가?
  2. 캐시가 없어도 원본에서 다시 만들 수 있는가?
  3. 어느 정도의 오래된 데이터를 허용할 수 있는가?
  4. 데이터가 변경되면 캐시를 어떻게 갱신하는가?
  5. 사용자별 권한과 민감한 결과가 캐시 키에 반영되는가?

속도를 위해 서비스의 약속을 깨뜨리지 않는가

  1. 성공 응답 전에 반드시 완료되어야 하는 작업은 무엇인가?
  2. 백그라운드 작업이 실패하면 어떻게 재시도하는가?
  3. 병렬 실행이 데이터 일관성을 해치지 않는가?
  4. 외부 입력의 길이와 범위를 검증하는가?
  5. 속도 개선 후 오류율이나 데이터 불일치가 증가하지 않았는가?

개선 효과를 지속해서 확인하는가

  1. 변경 전 기준값이 있는가?
  2. 변경 후 같은 지표를 다시 측정했는가?
  3. 서버 비용과 데이터 전송량도 함께 확인했는가?
  4. 느린 구간이 다른 시스템으로 이동하지 않았는가?
  5. 배포 후 성능 회귀를 감지할 수 있는가?

흔한 실수는 빠르게 만들 대상을 먼저 정하지 않는 것이다

측정 전에 코드를 다시 작성한다

읽기 쉬운 코드를 복잡한 저수준 구현으로 바꿔도 사용자가 느끼는 시간은 거의 달라지지 않을 수 있다.

먼저 사용자 흐름과 병목을 측정해야 한다.

평균 응답 시간만 확인한다

평균은 매우 느린 일부 사용자를 가릴 수 있다.

백분위수와 사용자 환경별 분포를 함께 확인해야 한다.

모든 작업을 병렬로 실행한다

독립적이지 않은 작업을 동시에 실행하면 잘못된 순서, 과부하, 복잡한 실패 처리가 발생할 수 있다.

의존 관계와 자원 한도를 확인한 뒤 병렬성을 선택해야 한다.

캐시를 추가하면 문제가 끝난다고 생각한다

캐시는 오래된 값, 무효화, 키 범위와 장애 시 복구 문제를 만든다.

캐시와 Source of Truth의 책임을 명확히 구분해야 한다.

로딩 애니메이션을 실제 개선으로 판단한다

피드백은 기다림을 이해하게 하지만 실제 완료 시간을 줄이지는 않는다.

지각된 성능과 실제 처리 시간을 각각 측정해야 한다.

데이터베이스 시간을 ORM 코드만 보고 추측한다

짧은 애플리케이션 코드가 많은 쿼리나 비싼 정렬을 만들 수 있다.

실제 SQL, 실행 계획과 읽은 데이터 양을 확인해야 한다.

개발자의 장비에서 한 번 측정하고 완료한다

실제 사용자는 서로 다른 기기, 네트워크와 지역에서 서비스를 사용한다.

실험실 결과와 실제 사용자 측정을 함께 운영해야 한다.

속도를 위해 정확성을 낮추고도 이를 기록하지 않는다

오래된 캐시나 지연 처리를 허용했다면 그것은 기술 구현이 아니라 제품 정책이기도 하다.

허용 가능한 최신성과 실패 동작을 명시적으로 결정해야 한다.


성능 최적화의 목적은 사용자의 기다림을 줄이는 데 있다

프로그래밍을 처음 배울 때는 성능 최적화를 같은 결과를 더 빠른 코드로 얻는 일이라고 이해해도 충분하다.

const playableTracks = tracks.filter(
  (track) => track.isAvailable
);

그러나 실제 서비스에서는 코드 한 줄의 속도보다 사용자의 목표까지 이어지는 전체 경로를 살펴봐야 한다.

  • 사용자는 어떤 행동의 완료를 기다리는가?
  • 그 대기 시간을 어떤 지표로 표현할 것인가?
  • 프론트엔드, 네트워크, 서버, 데이터베이스 중 어디가 시간을 사용하는가?
  • 실행하지 않거나 전송하지 않아도 되는 작업은 무엇인가?
  • 순차 작업 중 독립적으로 실행할 수 있는 것은 무엇인가?
  • 캐시의 속도와 데이터 최신성 사이에서 어떤 선택을 할 것인가?
  • 평균 뒤에 가려진 느린 사용자는 없는가?
  • 개선 후 정확성, 오류율과 운영 비용은 어떻게 달라졌는가?
  • 실제 사용자 환경에서 효과가 유지되는가?
  • 다음 변경에서 성능이 다시 나빠지는 것을 어떻게 발견할 것인가?

성능 최적화는 코드를 빠르게 만드는 일이 아니다.

성능 최적화는 사용자가 목표를 완료하기까지 기다리는 시간을 측정하고, 그 경로에서 가장 큰 지연을 만드는 비용을 줄이는 설계다.


어떤 자료를 참고했는가

profile
Vision eXperience Developer

0개의 댓글