알고리즘을 처음 배울 때는 평균적인 경우와 최악의 경우를 구분해 성능을 분석한다.
업로드 기록에서 특정 파일을 찾는 선형 탐색을 생각해보자.
function findUpload(uploads, uploadId) {
for (const upload of uploads) {
if (upload.id === uploadId) {
return upload;
}
}
return null;
}
찾는 파일이 목록의 중간쯤에 있다면 평균적으로 전체 데이터의 절반 정도를 확인한다.
파일이 없거나 마지막에 있다면 모든 데이터를 확인해야 한다.
평균적인 입력과 가장 오래 걸리는 입력을 나누어 생각하는 연습은 알고리즘의 성능을 이해하는 데 유용하다.
하지만 실제 파일 업로드 서비스에서 최악의 경우는 단순히 배열의 마지막 데이터를 찾는 상황만을 의미하지 않는다.
서비스는 평균적인 요청만 처리하지 않는다.
실제 서비스에서 좋은 알고리즘은 평균적인 입력에서 빠른 알고리즘이 아니라, 지원하기로 한 가장 큰 입력과 실패 경로와 동시 요청에서도 시간과 자원의 상한을 예측하고 서비스의 핵심 기능을 유지할 수 있는 알고리즘이다.
사진 업로드 API의 응답 시간을 측정했다고 생각해보자.
다섯 요청의 처리 시간이 다음과 같을 수 있다.
80ms, 90ms, 100ms, 110ms, 9,620ms
평균은 2초 정도다.
이 숫자만 보면 모든 사용자가 약 2초를 기다린 것처럼 느껴질 수 있다.
하지만 실제로는 네 명이 빠르게 응답받았고 한 명은 거의 10초를 기다렸다.
다른 데이터에서는 평균이 더 낮아도 일부 요청이 매우 느릴 수 있다.
99건: 100ms
1건: 20,000ms
평균은 약 299밀리초다.
평균만 보면 빠른 서비스처럼 보이지만 100명 중 한 명은 20초를 기다린다.
따라서 다음 값을 구분해 봐야 한다.
| 지표 | 의미 |
|---|---|
| 평균 | 모든 처리 시간을 합해 요청 수로 나눈 값 |
| 중앙값 | 요청의 절반이 이 시간 안에 끝난다는 값 |
| p95 | 요청의 95%가 이 시간 안에 끝난다는 값 |
| p99 | 요청의 99%가 이 시간 안에 끝난다는 값 |
| 최댓값 | 관찰한 요청 중 가장 오래 걸린 값 |
| 타임아웃 비율 | 제한 시간 안에 끝나지 못한 요청 비율 |
평균은 전체 경향을 이해하는 데 유용하다.
하지만 사용자 경험과 장애 위험을 판단하려면 느린 요청의 분포와 실패 비율도 함께 확인해야 한다.
크리스가 프로필 사진을 업로드한다고 생각해보자.
서버는 파일 크기를 확인한 뒤 이미지를 처리한다.
if (file.size > 10 * 1024 * 1024) {
throw new Error(
"파일은 10MB 이하여야 한다."
);
}
const image = await decodeImage(file);
10MB 제한은 네트워크 전송량과 저장 공간을 통제하는 데 도움이 된다.
하지만 이미지 처리 비용은 압축된 파일 크기만으로 결정되지 않는다.
예를 들어 해상도가 매우 큰 이미지는 압축 파일 자체는 작아도 디코딩한 뒤 많은 메모리를 사용할 수 있다.
가로 width, 세로 height, 색상 채널 수를 channels라고 하면 대략적인 픽셀 데이터 크기는 다음처럼 증가한다.
width × height × channels
20,000 × 20,000 픽셀의 이미지가 4개 채널을 사용한다면 단순 픽셀 데이터만으로도 매우 큰 메모리가 필요하다.
따라서 파일 바이트 수뿐 아니라 해상도에도 상한을 두어야 한다.
const metadata =
await readImageMetadata(file);
if (
metadata.width > 8000 ||
metadata.height > 8000 ||
metadata.width * metadata.height >
40_000_000
) {
throw new Error(
"이미지 해상도가 너무 크다."
);
}
이 코드는 가로와 세로의 개별 최댓값뿐 아니라 전체 픽셀 수도 제한한다.
현실의 “사진 한 장”은 코드에서 다음과 같은 여러 입력으로 변환된다.
평균적인 사진 크기만 기준으로 메모리를 설계하면 큰 입력 하나가 서버 프로세스를 종료시킬 수 있다.
업로드된 파일에서 금지된 바이트 패턴을 찾는다고 생각해보자.
function containsBlockedPattern(
chunks,
blockedPattern
) {
for (const chunk of chunks) {
if (chunk.includes(blockedPattern)) {
return true;
}
}
return false;
}
금지 패턴이 파일 앞부분에 자주 나타난다면 평균적으로 빠르게 끝날 수 있다.
하지만 패턴이 없거나 마지막 청크에 있다면 모든 데이터를 확인해야 한다.
파일 크기를 n이라고 하면 최악의 경우 전체 파일을 읽는 비용이 필요하다.
평균적인 조기 종료에 의존하려면 실제 데이터 분포가 안정적인지 확인해야 한다.
다음 상황에서는 평균이 쉽게 달라질 수 있다.
return이나 break가 있다는 사실은 빠른 종료의 가능성을 보여준다.
지원하는 모든 입력에서 빠르게 끝난다는 보장은 아니다.
파일 한 개의 최대 크기를 안전하게 제한했다고 생각해보자.
const MAX_FILE_SIZE =
10 * 1024 * 1024;
10MB 파일 하나는 서버가 처리할 수 있다.
하지만 1,000명이 동시에 10MB 파일을 업로드하면 약 10GB의 데이터가 짧은 시간에 들어올 수 있다.
각 요청이 디코딩, 리사이즈와 바이러스 검사를 동시에 수행하면 다음 자원이 함께 사용된다.
한 요청의 최악의 입력과 서비스 전체의 최악의 동시성은 서로 다른 조건이다.
요청 한 건의 최대 비용
× 동시에 처리하는 요청 수
각 요청이 개별 제한을 지켜도 동시에 너무 많이 실행되면 서비스가 멈출 수 있다.
따라서 다음 제한을 함께 고려해야 한다.
최악의 경우는 비정상적인 파일 하나만이 아니다.
정상적인 최대 요청이 같은 순간에 많이 들어오는 상황도 포함한다.
사진을 받은 즉시 모든 처리를 완료할 수 있다.
async function uploadPhoto(file) {
const original =
await objectStorage.save(file);
const thumbnail =
await resizeImage(file, {
width: 400,
});
const scanResult =
await virusScanner.scan(file);
const labels =
await imageAnalyzer.detectLabels(
file
);
return {
original,
thumbnail,
scanResult,
labels,
};
}
작은 사진과 빠른 외부 응답에서는 잘 동작한다.
그러나 큰 이미지, 느린 바이러스 검사나 외부 분석 서비스 장애가 발생하면 HTTP 요청이 오래 유지된다.
요청을 받은 뒤 원본을 안전하게 저장하고 비동기 처리 작업을 등록할 수 있다.
async function acceptPhotoUpload({
file,
userId,
}) {
const upload =
await uploadRepository.create({
userId,
status: "RECEIVED",
});
const original =
await objectStorage.save({
uploadId: upload.id,
file,
});
await processingQueue.enqueue({
uploadId: upload.id,
objectKey: original.key,
});
return {
uploadId: upload.id,
status: "PROCESSING",
};
}
이 코드는 원본 저장과 작업 등록까지만 요청 흐름에서 처리한다.
리사이즈와 분석은 별도의 작업자가 수행한다.
사용자는 즉시 최종 결과를 받는 대신 처리 중 상태를 받는다.
이 설계는 작업을 사라지게 하지 않는다. 무거운 처리를 사용자 요청과 분리하고 동시에 실행할 수 있는 양을 통제하게 한다.
다음 질문으로 이어져야 한다.
최악의 실행 시간을 통제하려면 작업의 위치와 생명주기도 설계해야 한다.
이미지 분석 서비스에 제한 시간을 설정할 수 있다.
const labels =
await imageAnalyzer.detectLabels(
file,
{ timeoutMs: 3000 }
);
3초 안에 응답하지 않으면 현재 요청은 더 이상 기다리지 않는다.
하지만 타임아웃이 발생했다고 외부 서비스나 백그라운드 작업이 즉시 중단된다는 보장은 없다.
또한 실패한 요청을 바로 재시도하면 부하를 더 늘릴 수 있다.
for (
let attempt = 0;
attempt < 3;
attempt += 1
) {
try {
return await imageAnalyzer
.detectLabels(file);
} catch {
// 즉시 다시 시도
}
}
외부 서비스가 느린 상황에서 모든 요청이 즉시 세 번씩 재시도하면 원래 요청보다 더 많은 트래픽이 발생한다.
재시도 사이에 간격을 두고 최대 횟수를 제한해야 한다.
for (
let attempt = 0;
attempt < 3;
attempt += 1
) {
try {
return await imageAnalyzer
.detectLabels(file, {
timeoutMs: 3000,
});
} catch (error) {
if (!isRetryable(error)) {
throw error;
}
await wait(
calculateRetryDelay(attempt)
);
}
}
이 코드는 다시 시도할 수 있는 오류만 제한된 횟수로 처리하고, 재시도 사이에 대기 시간을 둔다.
최악의 요청 시간은 대략 다음 요소의 합이 된다.
요청당 타임아웃
× 최대 시도 횟수
+ 재시도 대기 시간
타임아웃과 재시도 정책을 따로 설정하면 예상보다 훨씬 긴 작업이 생길 수 있다.
최대 실행 시간을 계산할 때는 정상 경로뿐 아니라 모든 재시도 경로를 포함해야 한다.
처리된 이미지 메타데이터를 캐시할 수 있다.
const cachedMetadata =
await cache.get(`upload:${uploadId}`);
if (cachedMetadata) {
return cachedMetadata;
}
대부분의 요청이 캐시에 적중하면 평균 응답 시간은 매우 빠를 수 있다.
하지만 캐시가 비어 있으면 데이터베이스와 객체 저장소를 조회하고 메타데이터를 다시 계산해야 할 수 있다.
const upload =
await uploadRepository.findById(
uploadId
);
const metadata =
await objectStorage.readMetadata(
upload.objectKey
);
await cache.set(
`upload:${uploadId}`,
metadata
);
return metadata;
캐시 적중 경로와 미적중 경로의 비용은 다르다.
특히 다음 상황에서는 캐시 미스가 한꺼번에 늘어날 수 있다.
평소에는 빠르던 서비스가 캐시 장애 순간 데이터베이스와 객체 저장소에 모든 요청을 보내며 느려질 수 있다.
따라서 캐시를 사용할 때는 평균 적중률뿐 아니라 캐시가 없을 때 원본 시스템이 감당할 수 있는 요청량도 확인해야 한다.
캐시는 성능을 높이는 계층이지 원본 저장소의 한계를 잊게 만드는 근거가 아니다.
서버가 동시에 처리할 수 있는 작업 수에는 한계가 있다.
이미지 디코딩 작업이 CPU와 메모리를 오래 점유하면 작은 파일 요청도 기다려야 할 수 있다.
큰 이미지 처리 10건
→ 작업 공간과 메모리 사용
→ 작은 이미지 요청이 대기
→ 응답 시간이 함께 증가
→ 클라이언트 재시도
→ 요청량이 더 증가
느린 요청 하나의 문제는 그 사용자에게만 끝나지 않을 수 있다.
공유된 자원을 오래 점유하면 다른 요청의 응답 시간도 늘어난다.
이런 상황을 줄이기 위해 작업을 분리할 수 있다.
| 작업 | 처리 위치 | 이유 |
|---|---|---|
| 파일 형식과 크기 검증 | API 경계 | 잘못된 입력을 일찍 거부한다 |
| 원본 파일 저장 | 객체 저장소 | 큰 파일을 애플리케이션 메모리에 오래 두지 않는다 |
| 이미지 디코딩과 리사이즈 | 제한된 작업자 | CPU·메모리 동시 사용량을 통제한다 |
| 바이러스 검사 | 별도 검사 작업 | 느린 외부 의존성을 요청에서 분리한다 |
| 업로드 상태 저장 | 데이터베이스 | 처리 생명주기의 Source of Truth가 된다 |
| 미리보기 제공 | CDN 또는 객체 저장소 | 반복 다운로드 비용을 분산한다 |
| 실패 재시도 | 작업 대기열 | 횟수와 간격을 관리한다 |
모든 작업을 비동기로 바꾸라는 뜻은 아니다.
사용자가 즉시 받아야 하는 결과와 오래 걸려도 되는 작업을 구분하고, 무거운 작업의 동시 실행 수를 통제해야 한다는 뜻이다.
클라이언트가 이미지 크기와 형식을 함께 보낼 수 있다.
const requestBody = {
fileType: "image/jpeg",
width: 1200,
height: 800,
};
정상적인 앱은 실제 이미지 정보와 같은 값을 보낼 수 있다.
하지만 외부 입력은 조작할 수 있다.
클라이언트가 1200 × 800이라고 주장해도 실제 파일은 훨씬 큰 이미지를 포함할 수 있다.
서버가 파일의 실제 헤더와 메타데이터를 확인해야 한다.
const metadata =
await readImageMetadata(file);
validateImage({
actualType: metadata.type,
width: metadata.width,
height: metadata.height,
byteSize: file.size,
});
클라이언트가 보낸 값은 사용자 화면의 사전 안내에 사용할 수 있다.
최종 검증의 Source of Truth는 서버가 실제 파일에서 읽은 정보다.
다음 항목을 검증할 수 있다.
최악의 입력은 실수로 생성될 수도 있고 의도적으로 만들어질 수도 있다.
입력 제한은 성능 최적화이면서 서비스 자원을 보호하는 보안 경계다.
업로드 처리가 오래 걸리면 여러 상태를 거친다.
RECEIVED
→ STORED
→ SCANNING
→ PROCESSING
→ READY
중간에 실패할 수도 있다.
SCANNING → REJECTED
PROCESSING → FAILED
현재 상태를 작업자 메모리에만 저장하면 서버가 재시작될 때 어떤 파일이 처리 중이었는지 잃을 수 있다.
데이터베이스에 상태를 저장해야 한다.
await uploadRepository.updateStatus({
uploadId,
from: "STORED",
to: "SCANNING",
updatedAt: serverNow,
});
상태 변경은 예상한 이전 상태에서만 허용해야 한다.
UPDATE uploads
SET
status = 'SCANNING',
updated_at = $2
WHERE id = $1
AND status = 'STORED'
RETURNING id, status;
이 쿼리는 같은 작업이 중복 실행되거나 순서가 뒤바뀌었을 때 잘못된 상태 전환을 막는다.
업로드 상태의 Source of Truth는 데이터베이스다.
작업 대기열의 메시지는 실행해야 할 작업을 전달하는 데이터이고, 작업자 메모리의 상태는 현재 실행 중인 복사본이다.
최악의 경우를 설계한다는 것은 오래 걸리는 입력만 계산하는 일이 아니다.
서버 종료, 중복 실행과 부분 실패 이후에도 업로드의 상태를 복구할 수 있게 만드는 일이다.
작업 대기열은 전달한 작업이 처리되었다는 응답을 받지 못하면 같은 작업을 다시 전달할 수 있다.
다음 코드가 재실행되면 같은 썸네일이 여러 번 생성될 수 있다.
const thumbnail =
await resizeImage(original);
await objectStorage.saveThumbnail({
uploadId,
thumbnail,
});
같은 입력으로 반복 실행되어도 최종 결과가 하나만 남도록 설계할 수 있다.
const thumbnailKey =
`uploads/${uploadId}/thumbnail.webp`;
await objectStorage.put({
key: thumbnailKey,
body: thumbnail,
});
업로드 ID에서 결정되는 같은 키를 사용하면 재시도 시 새로운 파일을 계속 추가하지 않고 같은 결과 위치를 갱신할 수 있다.
데이터베이스 상태 변경도 현재 상태를 조건으로 실행해야 한다.
const completed =
await uploadRepository.completeIfProcessing({
uploadId,
thumbnailKey,
completedAt: serverNow,
});
중복 실행은 드문 예외가 아니라 네트워크와 작업 대기열이 있는 서비스에서 자연스럽게 발생할 수 있는 경로다.
최악의 경우를 판단할 때는 한 작업이 몇 번 재실행될 수 있는지도 포함해야 한다.
모든 파일이 항상 빠르게 처리되도록 보장하기는 어렵다.
외부 서비스는 느려질 수 있고, 예상하지 못한 파일 형식이 들어올 수 있으며, 이벤트로 업로드가 급증할 수 있다.
중요한 것은 문제가 발생해도 피해가 무제한으로 커지지 않게 만드는 것이다.
| 위험 | 제한 방법 |
|---|---|
| 지나치게 큰 파일 | 바이트 크기 상한 |
| 압축 후 매우 큰 이미지 | 해상도와 픽셀 수 상한 |
| 한 요청에 많은 파일 | 파일 개수 상한 |
| 동시 업로드 급증 | 사용자별·서버별 동시성 제한 |
| CPU 작업 과부하 | 작업자 수 제한과 대기열 |
| 외부 분석 지연 | 타임아웃과 제한된 재시도 |
| 대기열 무한 증가 | 최대 대기량과 유입 제어 |
| 처리 중 서버 종료 | 지속 가능한 상태 저장 |
| 같은 작업의 중복 실행 | 멱등한 결과 키와 조건부 상태 변경 |
| 캐시 전체 미스 | 원본 저장소 보호와 요청 제한 |
최악의 상황을 설계한다는 것은 모든 실패를 없애는 일이 아니다.
한 요청이 사용할 수 있는 자원, 기다릴 수 있는 시간과 재시도 횟수에 상한을 두고 실패 후 복구 경로를 만드는 일이다.
업로드 처리 시간을 다음처럼 기록할 수 있다.
logger.info("Upload processing completed", {
uploadId,
byteSize: file.size,
pixelCount:
metadata.width * metadata.height,
decodeMs,
resizeMs,
scanMs,
totalMs,
retryCount,
});
이 로그는 처리 시간뿐 아니라 입력 크기와 재시도 횟수를 함께 남긴다.
운영 지표에서는 다음 값을 확인할 수 있다.
p99가 느려졌다고 무조건 알고리즘 문제라고 단정할 수는 없다.
대기열 증가, 캐시 미스, 객체 저장소 지연이나 외부 검사 서비스 장애가 원인일 수 있다.
입력 크기와 처리 구간을 함께 기록해야 느린 요청이 어떤 조건에서 발생하는지 알 수 있다.
서비스의 알고리즘과 처리 흐름을 검토할 때 다음 질문을 사용할 수 있다.
이 질문은 모든 요청을 최악의 상황에 맞춰 지나치게 복잡하게 만들기 위한 목록이 아니다.
발생 가능성, 피해 크기와 복구 비용을 기준으로 중요한 경로부터 제한과 관찰 수단을 추가해야 한다.
평균 성능은 중요하다.
대부분의 사용자가 얼마나 빠르게 결과를 받는지 보여주고 서버 자원의 일반적인 사용량을 이해하게 해준다.
하지만 실제 서비스의 안정성은 평균만으로 판단할 수 없다.
따라서 성능을 판단할 때는 다음 세 범위를 함께 봐야 한다.
실제 서비스에서 좋은 알고리즘은 평균 시간을 가장 작게 만드는 알고리즘이 아니다. 큰 입력, 느린 의존성, 캐시 미스, 재시도와 동시 요청에서도 처리 시간과 자원 사용이 무제한으로 증가하지 않도록 피해의 상한을 설계한 알고리즘이다.
모든 최악의 상황을 빠르게 처리할 수는 없다.
그러나 너무 큰 입력은 일찍 거부할 수 있다. 오래 걸리는 작업은 대기열로 분리할 수 있다. 동시 실행 수와 재시도 횟수에는 상한을 둘 수 있다. 처리 상태를 저장해 실패 후 다시 시작할 수 있다. 평균 뒤에 숨은 느린 요청은 p95, p99와 실패 지표로 발견할 수 있다.
서비스가 감당할 수 없는 상황을 모르는 것과, 알고도 안전하게 제한하는 것은 다르다.
다음 글에서는 공간 복잡도가 단순히 메모리 사용량을 세는 개념이 아니라, 조회와 계산을 빠르게 만들기 위해 어떤 데이터를 미리 저장하고 그 생명주기를 어떻게 관리할지 결정하는 방법인 이유를 살펴본다.