
프로그래밍을 처음 배울 때 로그는 보통 코드가 실행되는 동안 값을 확인하기 위해 출력하는 메시지라고 배운다.
console.log("Rental started");
이 코드는 자전거 대여가 시작되는 지점까지 프로그램이 실행되었다는 사실을 개발자에게 보여준다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 운영하면 메시지를 출력하는 것보다 더 많은 판단이 필요하다.
로그는 console.log를 남기는 것이 아니다.
로그는 운영 중인 서비스에서 언제, 어디서, 누구에 의해, 어떤 사건이 발생했고 그 결과가 무엇이었는지를 다시 추적할 수 있게 만드는 구조화된 기록이다.
크리스가 공유 자전거 대여 서비스를 개발한다고 생각해 보자.
사용자가 앱에서 자전거 잠금 해제를 요청하면 서버는 대여 가능 여부를 확인하고, 잠금 장치에 명령을 보내고, 대여 기록을 생성한다.
처음에는 다음과 같이 로그를 남길 수 있다.
console.log("Unlock requested");
console.log("Bike unlocked");
개발 중에는 실행 순서를 확인할 수 있다. 그러나 운영 환경에서 수천 명이 동시에 요청하면 이 문장만으로는 어떤 사용자의 어떤 자전거에 관한 기록인지 알 수 없다.
실패한 경우도 비슷하다.
console.log("Unlock failed");
이 로그에는 원인을 판단할 정보가 없다.
운영 로그는 개발자의 현재 화면을 위한 메모가 아니다. 문제가 발생한 뒤 당시 상황을 재구성할 수 있는 기록이어야 한다.
다음 로그는 조금 더 많은 정보를 포함한다.
console.log(
`User ${userId} unlocked bike ${bikeId}`
);
하지만 하나의 문자열 안에 정보가 섞여 있다. 사용자별 검색, 실패 원인별 집계, 특정 버전 비교를 하려면 문장을 다시 해석해야 한다.
구조화된 로그는 사건과 문맥을 필드로 나눈다.
logger.info({
event: "rental.unlock_succeeded",
requestId,
accountId,
bikeId,
rentalId,
service: "rental-api",
serviceVersion: "2.4.1",
durationMs: 184,
});
이 기록은 다음 질문에 직접 답할 수 있다.
rental.unlock_succeededrequestIdaccountId, bikeIdrentalId필드 이름이 일정하면 로그 수집 시스템에서 조건을 조합해 검색하고 집계할 수 있다.
event = rental.unlock_succeeded
AND serviceVersion = 2.4.1
AND durationMs > 1000
사람이 읽기 좋은 문장도 필요할 수 있다. 그러나 자동 검색에 필요한 의미를 문장 안에만 숨겨서는 안 된다.
크리스가 함수 이름을 그대로 로그에 남긴다고 생각해 보자.
logger.info({
event: "handleRequest.completed",
});
이 이름은 코드 실행이 끝났다는 사실만 알려준다. 사용자가 무엇을 하려 했고 서비스에서 무엇이 바뀌었는지는 드러나지 않는다.
도메인 사건을 이름으로 사용하면 의미가 분명해진다.
logger.info({
event: "rental.started",
rentalId,
bikeId,
accountId,
});
rental.started는 함수나 파일 구조가 바뀌어도 유지할 수 있다. 운영자도 코드를 열어보지 않고 사건의 의미를 이해할 수 있다.
사건 이름은 일관된 규칙을 따르는 편이 낫다.
rental.unlock_requested
rental.unlock_rejected
rental.started
rental.ended
payment.authorization_failed
bike.lock_connection_failed
이름이 안정적이면 대시보드, 검색 조건, 알림 규칙이 로그 문구 변경 때문에 깨지지 않는다.
로그는 코드가 어느 줄을 통과했는지만 보여주는 것이 아니라 서비스에서 일어난 변화를 설명해야 한다.
자전거 잠금 해제는 하나의 서버 안에서 끝나지 않을 수 있다.
API 서버가 계정 상태를 확인하고, 잠금 장치 서비스에 명령을 보내고, 결제 서비스에서 보증금을 승인할 수 있다.
각 서비스가 별도의 로그를 남기면 시간만으로 기록을 연결하기 어렵다.
const requestId =
request.headers["x-request-id"] ??
crypto.randomUUID();
서버는 기존 요청 ID를 검증해 사용하거나 새로운 ID를 생성할 수 있다. 이 값을 후속 요청에도 전달한다.
await lockService.unlock({
bikeId,
requestId,
});
await paymentService.authorizeDeposit({
accountId,
requestId,
});
각 서비스도 같은 식별자를 기록한다.
logger.info({
event: "bike.unlock_command_sent",
requestId,
bikeId,
});
요청 흐름은 다음처럼 연결된다.
flowchart TD
A[잠금 해제 요청] --> B[대여 API]
B --> C[잠금 장치 서비스]
B --> D[결제 서비스]
C --> E[중앙 로그 저장소]
D --> E
B --> E
requestId나 분산 추적의 traceId를 사용하면 서로 다른 서비스의 기록을 하나의 사용자 행동으로 묶을 수 있다.
다만 외부에서 받은 요청 ID도 검증 전까지 신뢰할 수 없다. 길이와 문자 형식을 제한하지 않으면 검색 방해나 로그 주입에 사용될 수 있다.
import { z } from "zod";
const RequestIdSchema = z
.string()
.uuid();
const suppliedRequestId =
RequestIdSchema.safeParse(
request.headers["x-request-id"]
);
const requestId = suppliedRequestId.success
? suppliedRequestId.data
: crypto.randomUUID();
이 코드는 올바른 UUID만 이어받고 나머지는 서버가 새로 생성한다.
모든 기록을 error로 남기면 중요한 장애가 평범한 사건 속에 묻힌다. 반대로 실제 실패를 info로 남기면 경보가 필요한 상황을 놓칠 수 있다.
로그 레벨은 팀 안에서 의미가 합의되어야 한다.
| 레벨 | 공유 자전거 서비스에서의 의미 |
|---|---|
debug | 개발 또는 제한된 진단에 필요한 내부 처리 정보 |
info | 대여 시작·종료처럼 정상적으로 완료된 중요한 사건 |
warn | 요청은 처리했지만 비정상 징후나 복구 가능한 문제가 있음 |
error | 한 작업이 실패했으며 조사 또는 복구가 필요함 |
fatal | 프로세스가 정상적으로 계속 실행될 수 없음 |
예를 들어 사용자가 이미 대여 중인 자전거를 선택한 것은 시스템 장애가 아니다.
logger.info({
event: "rental.unlock_rejected",
requestId,
bikeId,
accountId,
reason: "bike_already_rented",
});
서비스가 예상한 비즈니스 규칙에 따라 요청을 거절했으므로 info로 기록할 수 있다.
반면 잠금 장치 서비스가 응답하지 않았다면 기술적 실패다.
logger.error({
event: "bike.lock_connection_failed",
requestId,
bikeId,
errorCode: "LOCK_GATEWAY_TIMEOUT",
retryable: true,
});
이 기록은 외부 장치 연결 실패이며 재시도 가능하다는 사실을 설명한다.
레벨은 “좋은 일인가, 나쁜 일인가”를 표시하는 장식이 아니다. 누가 언제 확인하고 어떤 대응을 해야 하는지 결정하는 운영 신호다.
다음 코드는 예외 객체만 출력한다.
try {
await lockService.unlock(bikeId);
} catch (error) {
console.error(error);
}
스택 트레이스는 코드 위치를 찾는 데 도움이 된다. 하지만 사용자가 무엇을 시도했는지, 어떤 자전거가 대상이었는지, 재시도가 가능한지는 알기 어렵다.
서비스 문맥을 함께 기록하는 편이 낫다.
try {
await lockService.unlock({
bikeId,
requestId,
});
} catch (error) {
logger.error({
event: "bike.unlock_failed",
requestId,
bikeId,
retryable: isRetryableLockError(error),
error: serializeSafeError(error),
});
throw error;
}
이 코드는 예외를 삼키지 않고 상위 계층으로 다시 전달한다. 로그에는 진단에 필요한 문맥과 안전하게 정리된 오류 정보가 남는다.
같은 예외를 여러 계층에서 반복해서 기록하면 동일한 실패가 여러 건처럼 보일 수 있다.
어느 계층이 사건을 소유하고 기록할지 정해야 중복 로그를 줄일 수 있다.
잠금 해제 요청의 본문 전체를 기록하면 문제를 빠르게 찾을 수 있을 것처럼 보인다.
logger.info({
event: "rental.unlock_requested",
body: request.body,
});
그러나 요청 본문에는 예상하지 못한 필드, 매우 긴 문자열, 줄바꿈 문자, 개인정보가 포함될 수 있다.
외부 입력은 검증 전까지 신뢰할 수 없다. 로그에 남긴다고 예외가 되지 않는다.
import { z } from "zod";
const UnlockRequestSchema = z.object({
bikeId: z.string().uuid(),
stationId: z.string().uuid(),
});
const result =
UnlockRequestSchema.safeParse(request.body);
if (!result.success) {
logger.warn({
event: "rental.input_validation_failed",
requestId,
invalidFields: result.error.issues.map(
(issue) => issue.path.join(".")
),
});
throw new InvalidUnlockRequestError();
}
const input = result.data;
이 로그는 검증 실패가 발생한 필드만 기록한다. 검증되지 않은 원본 본문은 남기지 않는다.
사용자 입력을 자유 형식 메시지에 이어 붙이면 줄바꿈과 구분 문자를 이용한 로그 주입 위험도 생길 수 있다.
console.log(
`Unlock failed: ${request.body.reason}`
);
구조화된 로거를 사용하고, 필드 길이와 허용 형식을 제한하며, 출력 형식에 맞게 인코딩해야 한다.
로그는 조사를 위해 충분한 정보를 가져야 하지만 모든 데이터를 담아서는 안 된다.
다음 코드는 과도한 정보를 기록한다.
logger.info({
event: "rental.started",
user: currentUser,
headers: request.headers,
paymentMethod,
});
객체 전체에는 이메일, 전화번호, 세션 쿠키, 액세스 토큰, 결제 정보가 포함될 수 있다.
필요한 식별자와 결과만 선택해야 한다.
logger.info({
event: "rental.started",
requestId,
accountId: currentUser.id,
bikeId,
rentalId,
paymentResult: "authorized",
});
이 기록은 사건을 추적할 수 있지만 사용자 객체와 인증 정보를 복사하지 않는다.
일반적으로 다음 값은 원문 그대로 로그에 남기지 않아야 한다.
사용자 식별이 필요하더라도 이름이나 이메일 대신 내부 계정 ID 또는 목적에 맞게 가명 처리한 값을 사용할 수 있다.
로그는 문제를 해결할 만큼 자세해야 하지만 새로운 개인정보 저장소가 되어서는 안 된다.
애플리케이션 로그에는 다음과 같은 기록이 들어갈 수 있다.
logger.info({
event: "rental.ended",
rentalId,
durationSeconds,
});
이 기록은 운영 상태를 확인하고 문제를 진단하는 데 유용하다. 그러나 사용자의 요금 분쟁을 판단하는 유일한 근거로 사용하기에는 부족할 수 있다.
대여의 Source of Truth는 데이터베이스에 저장된 대여 상태와 정산 데이터다.
type Rental = {
id: string;
accountId: string;
bikeId: string;
startedAt: Date;
endedAt: Date | null;
status: "active" | "completed" | "cancelled";
};
로그가 유실되거나 보존 기간이 끝나도 현재 대여 상태는 유지되어야 한다.
관리자가 요금을 수정하거나 대여를 강제로 종료한 기록처럼 책임 추적이 필요한 사건은 별도의 감사 기록으로 관리할 수 있다.
type RentalAuditEvent = {
eventId: string;
rentalId: string;
actorId: string;
action: "force_end" | "fare_adjusted";
reasonCode: string;
occurredAt: Date;
};
감사 기록은 누가 어떤 권한으로 어떤 변경을 했는지 증명하는 목적을 가진다. 일반 디버그 로그와 접근 권한, 보존 기간, 변경 방지 수준이 다를 수 있다.
모든 기록을 하나의 로그 스트림으로 처리하면 목적과 보존 책임이 흐려진다.
모든 함수 진입과 모든 반복 처리를 기록하면 정보는 많아진다.
for (const bike of nearbyBikes) {
logger.debug({
event: "bike.distance_calculated",
bikeId: bike.id,
distanceMetres: bike.distanceMetres,
});
}
주변에 자전거가 많고 요청도 많다면 로그 건수가 빠르게 증가한다.
과도한 로그는 다음 비용을 만든다.
개별 계산이 아니라 요청 전체의 결과를 기록할 수 있다.
logger.info({
event: "bike.nearby_search_completed",
requestId,
resultCount: nearbyBikes.length,
durationMs,
});
이 기록은 사용자가 경험한 결과와 처리 시간을 설명하면서 로그 양을 제한한다.
상세 진단이 필요하면 특정 환경이나 일부 요청에만 debug 로그를 활성화하거나 샘플링할 수 있다. 다만 보안 사건과 필수 감사 기록까지 임의로 제외해서는 안 된다.
모든 로그를 영구히 보관하면 나중에 유용할 것처럼 보인다. 그러나 오래된 로그에는 개인정보와 내부 시스템 정보가 계속 남는다.
로그 종류마다 목적과 기간을 정해야 한다.
| 로그 종류 | 주된 목적 | 보존 판단 |
|---|---|---|
| 상세 디버그 로그 | 단기 장애 분석 | 짧게 보관하거나 제한적으로 수집 |
| 요청·오류 로그 | 운영 분석과 장애 대응 | 운영 요구에 맞는 기간 |
| 보안 사건 로그 | 침해 탐지와 조사 | 보안·법적 요구를 반영 |
| 감사 기록 | 중요한 변경의 책임 추적 | 정책과 규제에 맞게 별도 관리 |
| 성능 원시 로그 | 병목 분석 | 집계 후 원본을 더 빨리 삭제 가능 |
보존 정책에는 저장 기간뿐 아니라 다음 내용도 포함된다.
로그는 생성 순간부터 삭제까지 생명주기를 가진 데이터다.
중앙 로그 저장소가 잠시 응답하지 않을 수 있다.
모든 요청이 로그 저장 완료를 기다리게 하면 로그 시스템 장애가 대여 서비스 장애로 이어질 수 있다.
await remoteLogStorage.write(logRecord);
await rentalService.start(input);
이 구조에서는 로그 저장소가 느릴 때 대여도 시작되지 않는다.
일반 운영 로그는 표준 출력이나 비동기 전송 경로로 보내고 실행 환경이 수집하도록 구성할 수 있다.
logger.info({
event: "rental.start_requested",
requestId,
bikeId,
});
애플리케이션 코드는 로그 수집 시스템의 원격 저장 완료를 직접 기다리지 않는다.
그렇다고 로그 유실을 무시해도 된다는 뜻은 아니다.
일반 로그, 보안 로그, 감사 기록은 필요한 전달 보장이 다를 수 있다.
대여 실패 건수를 알아보기 위해 매번 로그를 검색할 수 있다.
logger.error({
event: "rental.start_failed",
errorCode: "LOCK_GATEWAY_TIMEOUT",
});
하지만 운영 상태를 지속적으로 판단하려면 로그 외의 신호도 필요하다.
| 신호 | 주로 답하는 질문 |
|---|---|
| 로그 | 특정 사건에서 무슨 일이 발생했는가? |
| 메트릭 | 실패율과 응답 시간은 얼마나 변했는가? |
| 트레이스 | 하나의 요청이 어느 서비스에서 지연되었는가? |
| 감사 기록 | 누가 중요한 변경을 수행했는가? |
예를 들어 잠금 해제 실패율은 메트릭으로 집계할 수 있다.
rentalStartFailures.add(1, {
reason: "lock_gateway_timeout",
});
경보는 일정 시간 동안 실패율이 임계값을 넘었을 때 발생시킬 수 있다. 담당자는 알림을 받은 뒤 관련 traceId나 requestId로 상세 로그를 찾는다.
로그, 메트릭, 트레이스는 서로 경쟁하는 기술이 아니다. 서로 다른 질문에 답하며 함께 운영 상황을 설명한다.
requestId 또는 traceId가 있는가?rentalId, bikeId, accountId처럼 필요한 식별자가 포함되는가?warn과 error가 필요한 운영 대응과 연결되는가?"Something went wrong"만 기록한다대상, 요청, 실패 원인이 없어 사건을 재구성할 수 없다.
안정적인 사건 이름과 필요한 문맥을 함께 기록해야 한다.
검색과 집계를 위해 문장을 다시 해석해야 한다.
시간, 사건 이름, 식별자, 결과, 오류 코드를 구조화된 필드로 분리해야 한다.
error로 남긴다예상된 비즈니스 거절과 실제 시스템 장애가 섞인다.
레벨을 운영 대응 기준으로 정의해야 한다.
토큰, 개인정보, 결제 정보가 로그 저장소에 복제될 수 있다.
목적에 필요한 필드만 허용 목록 방식으로 선택해야 한다.
로그 유실이나 보존 종료가 서비스 상태 손실로 이어질 수 있다.
데이터베이스의 원본 사실, 감사 기록, 운영 로그를 구분해야 한다.
비용과 검색 잡음이 증가하고 중요한 사건이 묻힌다.
환경, 레벨, 샘플링, 보존 기간을 함께 설계해야 한다.
관측 시스템의 장애가 핵심 서비스 장애로 전파될 수 있다.
기록의 중요도에 따라 비동기 수집과 강한 전달 보장을 구분해야 한다.
프로그래밍을 처음 배울 때는 로그를 실행 중인 값을 확인하는 출력문이라고 이해해도 충분하다.
console.log("Rental started");
하지만 실제 서비스를 운영할 때는 메시지를 출력했는지보다 사건을 다시 구성할 수 있는지가 중요하다.
로그는 console.log를 남기는 것이 아니다.
로그는 운영 중인 서비스에서 언제, 어디서, 누구에 의해, 어떤 사건이 발생했고 그 결과가 무엇이었는지를 다시 추적할 수 있게 만드는 구조화된 기록이다.