백엔드 개발을 하다 보면 문제가 생겼을 때 가장 먼저 확인하는 것 중 하나가 로그다.
로그는 서버가 실행되는 동안 어떤 일이 있었는지를 남기는 기록이다. 요청이 언제 들어왔는지, 처리가 성공했는지, 외부 API 호출이 실패했는지, 어떤 예외가 발생했는지 등을 로그로 확인할 수 있다.
쉽게 말하면 로그는 서버가 작성하는 업무 일지와 비슷하다.
사람이 하루 동안 있었던 일을 다음과 같이 기록한다고 생각해보자.
09:00 회원가입 기능 배포 완료
10:15 사용자 로그인 실패
11:30 주문 생성 중 결제 API 오류 발생
이런 기록이 있으면 나중에 문제가 생겼을 때 당시 상황을 되짚어볼 수 있다. 서버의 로그도 같은 역할을 한다.
서버가 언제 시작됐는지
어떤 요청이 실패했는지
어떤 외부 API 호출이 오래 걸렸는지
어떤 예외가 발생했는지
권한이 없는 요청이 들어왔는지
로그가 잘 남아 있으면 장애가 발생했을 때 추측이 아니라 기록을 보고 원인을 찾을 수 있다.

Spring Boot 공식 문서에서는 Spring Boot가 기본적인 로깅 설정을 제공한다고 설명한다.
일반적으로 Spring Boot Starter를 사용하면 Logback이 기본 로깅 구현체로 사용되며, 별도의 복잡한 설정 없이 콘솔에서 로그를 확인할 수 있다.
Spring Boot의 기본 로그에는 보통 다음과 같은 정보가 포함된다.
날짜와 시간
로그 레벨
프로세스 ID
스레드 이름
Logger 이름
로그 메시지
실제 로그는 다음과 비슷한 형태로 출력된다.
2026-07-24 09:13:12 INFO 12345 --- [main] c.example.Application : Started Application
처음 보면 복잡해 보이지만 중요한 부분은 다음과 같다.
2026-07-24 09:13:12 → 로그가 발생한 시간
INFO → 로그의 중요도
c.example.Application → 로그를 남긴 위치
Started Application → 실제 로그 메시지
Spring Boot에서는 주로 다음과 같은 로그 레벨을 사용한다.
ERROR
WARN
INFO
DEBUG
TRACE
각 레벨의 차이는 뒤에서 예시와 함께 살펴보자.
Spring Boot에서 로그를 작성하다 보면 SLF4J와 Logback이라는 이름을 자주 보게 된다.
처음에는 둘의 차이가 헷갈릴 수 있다.
SLF4J는 애플리케이션에서 로그를 작성할 때 사용하는 공통 인터페이스다. 실제로 로그를 콘솔이나 파일에 출력하는 일은 Logback과 같은 로깅 구현체가 담당한다.
구조를 간단히 표현하면 다음과 같다.
애플리케이션 코드
↓
SLF4J
↓
Logback
↓
콘솔 또는 로그 파일
개발자는 코드에서 SLF4J를 사용해 로그를 남기고, Spring Boot에서는 기본적으로 Logback이 그 로그를 출력한다고 이해하면 된다.
System.out.println()으로 찍으면 안 될까?처음 로그를 배울 때 이런 생각이 들 수 있다.
그냥
System.out.println()으로 값을 출력하면 되는 것 아닌가?
로컬에서 잠깐 값을 확인할 때는 System.out.println()도 도움이 된다.
System.out.println("userId = " + userId);
하지만 실제 운영 환경에서 문제를 추적하기에는 부족한 점이 많다.
운영 중에는 단순한 값 하나보다 다음과 같은 정보가 필요하기 때문이다.
언제 발생했는가?
어떤 클래스에서 발생했는가?
어떤 요청과 관련된 문제인가?
문제의 심각도는 어느 정도인가?
어떤 예외가 발생했는가?
System.out.println()은 이런 정보를 일정한 형식으로 관리하기 어렵다. 중요한 로그와 단순 확인용 메시지를 구분하는 것도 쉽지 않다.
반면 로깅 프레임워크를 사용하면 로그의 시간, 위치, 레벨 등을 함께 기록할 수 있다.
log.info("User created. userId={}", userId);
그래서 실제 백엔드 애플리케이션에서는 System.out.println()보다 로깅 프레임워크를 사용하는 것이 일반적이다.
Spring Boot에서는 다음과 같이 Logger를 선언할 수 있다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private static final Logger log =
LoggerFactory.getLogger(UserService.class);
public Long createUser(String email) {
log.info("Create user request received");
Long userId = 1L;
log.info("User created. userId={}", userId);
return userId;
}
}
다음 코드가 현재 클래스에서 사용할 Logger를 생성하는 부분이다.
private static final Logger log =
LoggerFactory.getLogger(UserService.class);
그다음 필요한 위치에서 로그를 남긴다.
log.info("User created. userId={}", userId);
이렇게 작성하면 UserService에서 발생한 로그라는 정보와 함께 메시지가 출력된다.
Lombok을 사용하고 있다면 @Slf4j를 사용할 수도 있다.
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserService {
public Long createUser(String email) {
log.info("Create user request received");
Long userId = 1L;
log.info("User created. userId={}", userId);
return userId;
}
}
@Slf4j를 사용하면 Logger를 직접 선언하지 않아도 log 변수를 사용할 수 있다.
{}를 사용하자로그에 값을 포함할 때 다음과 같이 문자열을 직접 더할 수도 있다.
log.info("User created. userId=" + userId);
하지만 SLF4J에서는 {} placeholder를 사용하는 방식이 일반적이다.
log.info("User created. userId={}", userId);
값이 여러 개라면 {}도 여러 개 작성한다.
log.info(
"Order created. orderId={}, userId={}, amount={}",
orderId,
userId,
amount
);
각 값은 {}가 작성된 순서대로 들어간다.
첫 번째 {} → orderId
두 번째 {} → userId
세 번째 {} → amount
placeholder를 사용하면 로그 메시지와 값을 구분하기 쉽고, 로그 형식도 일정하게 유지할 수 있다.

외부 API를 호출하는 중 예외가 발생했다고 가정해보자.
try {
paymentClient.pay(orderId);
} catch (Exception e) {
log.error("Payment request failed. orderId={}", orderId);
throw e;
}
이 코드만으로도 결제 요청이 실패했다는 사실은 알 수 있다.
하지만 어떤 예외가 발생했고, 어느 코드에서 문제가 시작됐는지는 자세히 확인하기 어렵다.
이럴 때는 예외 객체 e도 함께 전달하는 것이 좋다.
try {
paymentClient.pay(orderId);
} catch (Exception e) {
log.error("Payment request failed. orderId={}", orderId, e);
throw e;
}
예외 객체를 마지막 인자로 넘기면 로그에 stack trace가 함께 기록된다.
stack trace는 예외가 어떤 메서드를 거쳐 발생했는지를 보여주는 정보다. 장애 원인을 찾을 때 매우 중요한 단서가 된다.
다음처럼 예외 메시지만 문자열로 남기는 것보다,
log.error("Payment failed. message={}", e.getMessage());
예외 객체 자체를 함께 전달하는 편이 원인 추적에 더 도움이 된다.
log.error("Payment failed. orderId={}", orderId, e);
로그를 남길 때는 상황에 맞는 레벨을 선택해야 한다.
ERROR
WARN
INFO
DEBUG
TRACE
처음에는 다음과 같이 이해하면 쉽다.
ERROR:
요청 처리가 실패했고 확인이 필요한 문제
WARN:
당장 서버가 멈추지는 않지만 주의가 필요한 상황
INFO:
서버의 주요 정상 흐름을 확인하기 위한 기록
DEBUG:
개발하거나 문제를 분석할 때 필요한 자세한 정보
TRACE:
DEBUG보다 더 세밀한 실행 과정
하나씩 예시를 살펴보자.
ERROR는 요청 처리가 실패했거나 개발자와 운영자가 확인해야 하는 문제에 사용한다.
log.error(
"Unexpected error while creating order. orderId={}",
orderId,
e
);
예상하지 못한 예외가 발생해 주문 생성에 실패한 상황이 예시가 될 수 있다.
다만 모든 실패를 무조건 ERROR로 남길 필요는 없다.
예를 들어 사용자가 비밀번호를 잘못 입력했거나, 이미 사용 중인 이메일로 회원가입을 시도한 것은 충분히 예상할 수 있는 상황이다. 이런 경우까지 모두 ERROR로 남기면 정말 중요한 장애 로그를 찾기 어려워질 수 있다.
WARN은 처리는 계속할 수 있지만 주의가 필요한 상황에 사용한다.
log.warn(
"Payment API timeout. retryCount={}, orderId={}",
retryCount,
orderId
);
결제 API 호출이 일시적으로 실패했지만 다시 시도할 예정이라면 WARN을 고려할 수 있다.
외부 API 호출이 일시적으로 실패함
재시도가 발생함
권장하지 않는 설정값이 사용됨
대체 로직으로 처리가 계속됨
이런 상황이 WARN에 해당할 수 있다.
INFO는 서버가 정상적으로 동작하는 동안 주요 흐름을 확인하기 위해 사용한다.
log.info("User signup completed. userId={}", userId);
회원가입 완료, 주문 생성 완료, 배치 작업 완료처럼 운영 중 확인할 가치가 있는 사건을 남길 수 있다.
다만 모든 메서드의 시작과 끝을 INFO로 남기면 로그가 너무 많아질 수 있다.
log.info("createUser start");
log.info("validateEmail start");
log.info("validateEmail end");
log.info("saveUser start");
log.info("saveUser end");
log.info("createUser end");
이렇게 남기면 중요한 로그가 많은 메시지 사이에 묻힐 수 있다.
INFO에는 운영 중 실제로 확인할 가치가 있는 주요 사건을 남기는 것이 좋다.
DEBUG는 개발하거나 장애를 분석할 때 필요한 자세한 정보를 남기는 데 사용한다.
log.debug(
"Discount calculated. userId={}, discount={}",
userId,
discount
);
계산 중간값이나 조건문의 처리 결과처럼 평소에는 볼 필요가 없지만, 문제를 분석할 때 도움이 되는 정보를 남길 수 있다.
운영 환경에서는 로그가 너무 많이 쌓이지 않도록 DEBUG 로그를 비활성화하는 경우가 많다.
TRACE는 DEBUG보다 더 세밀한 실행 흐름을 기록한다.
메서드 내부에서 값이 어떻게 바뀌는지 매우 자세하게 확인할 때 사용할 수 있다. 로그 양이 크게 늘어날 수 있기 때문에 일반적인 운영 환경에서는 자주 사용하지 않는다.
초보자라면 처음에는 INFO, WARN, ERROR, DEBUG의 차이를 이해하는 것부터 시작해도 충분하다.
사용자가 고객센터에 다음과 같이 문의했다고 가정해보자.
사용자: 결제가 안 됐어요.
개발자: 언제 발생했나요?
사용자: 조금 전이요.
개발자: 어떤 주문이었나요?
사용자: 잘 모르겠어요.
로그가 없다면 개발자는 문제가 발생한 시간을 추측하거나 데이터베이스를 직접 확인해야 한다.
하지만 다음과 같은 로그가 남아 있다면 상황이 달라진다.
2026-07-24 09:13:12 WARN Payment failed. orderId=1004, reason=TIMEOUT
이 한 줄만으로도 다음 정보를 알 수 있다.
문제가 발생한 시간: 2026-07-24 09:13:12
실패한 작업: 결제
관련 주문: 1004
실패 원인: 시간 초과
여기에 사용자 ID나 결제 ID가 함께 있다면 관련된 다른 로그와 데이터도 빠르게 찾을 수 있다.
로그 한 줄이 장애를 해결해주지는 않지만, 원인을 찾는 시간을 크게 줄여줄 수 있다.
다음과 같은 로그는 나중에 봤을 때 의미를 알기 어렵다.
log.info("success");
무엇이 성공했는지 알 수 없기 때문이다.
조금 더 구체적으로 작성하는 편이 좋다.
log.info("User created. userId={}", userId);
실패 로그도 마찬가지다.
log.warn("failed");
이보다는 어떤 작업이 실패했는지 알 수 있도록 작성해야 한다.
log.warn(
"Payment approval failed. orderId={}, reason={}",
orderId,
reason
);
로그 메시지를 작성할 때는 다음 내용을 생각해보면 좋다.
어떤 작업에서 발생했는가?
성공했는가, 실패했는가?
어떤 대상을 처리하고 있었는가?
원인을 구분할 수 있는 값이 있는가?
userId, orderId, paymentId 같은 식별자를 함께 남기면 나중에 관련 로그를 검색하기 쉽다.
운영 환경에서는 여러 사용자의 요청이 동시에 처리된다.
다음과 같은 로그가 있다고 해보자.
Order request received
Payment requested
Order created
로그만 보면 세 줄이 같은 요청에서 발생한 것인지 알기 어렵다.
요청을 구분할 수 있는 값이 포함되면 흐름을 연결해서 볼 수 있다.
requestId=abc-123 Order request received
requestId=abc-123 Payment requested
requestId=abc-123 Order created
실무에서는 다음과 같은 식별자를 로그에 포함할 수 있다.
requestId
userId
orderId
paymentId
초보자 단계에서는 우선 userId나 orderId처럼 현재 처리 중인 대상을 구분할 수 있는 값을 로그에 함께 남기는 습관부터 들이면 좋다.
log.info(
"Payment completed. orderId={}, userId={}",
orderId,
userId
);
requestId나 traceId를 모든 로그에 자동으로 넣는 방법도 있지만, 이 부분은 요청 추적과 MDC를 함께 알아야 하므로 별도의 글에서 자세히 다루는 편이 좋다.
로그를 남길 때 가장 주의해야 할 것 중 하나가 민감한 정보다.
다음과 같은 정보는 로그에 남기지 않아야 한다.
비밀번호
주민등록번호
카드 번호
인증 토큰
Authorization 헤더
세션 정보
개인정보 원문
예를 들어 다음 코드는 매우 위험하다.
log.info(
"Login request. email={}, password={}",
email,
password
);
비밀번호는 어떤 상황에서도 로그에 남기면 안 된다.
Authorization 헤더도 그대로 남기면 위험하다.
log.info("Authorization header={}", authorizationHeader);
Authorization 헤더에는 로그인 상태를 증명하는 토큰이 포함될 수 있다. 로그가 유출되면 다른 사람이 해당 토큰을 사용할 가능성이 있다.
이메일이나 전화번호 같은 개인정보도 꼭 필요한 것이 아니라면 그대로 남기지 않는 편이 좋다.
가능하면 내부 식별자를 사용한다.
log.info("Login failed. userId={}", userId);
일부 정보가 반드시 필요하다면 마스킹을 적용할 수 있다.
email=ab***@example.com
cardNumber=1234-****-****-5678
하지만 가장 안전한 방법은 필요하지 않은 정보를 처음부터 로그에 남기지 않는 것이다.
다음과 같은 코드가 있다고 해보자.
try {
orderService.createOrder(request);
} catch (Exception e) {
log.error("Order create failed", e);
}
예외가 발생하면 로그는 남지만, 그 뒤에 아무 처리도 하지 않는다.
이 경우 호출한 쪽에서는 주문 생성이 실패했다는 사실을 모를 수 있다. 이후 코드가 계속 실행되면서 다른 문제가 발생할 수도 있다.
로그를 남기는 것과 예외를 처리하는 것은 서로 다른 일이다.
상황에 따라 다음과 같은 처리가 필요할 수 있다.
예외를 다시 던진다.
사용자에게 적절한 실패 응답을 전달한다.
트랜잭션을 롤백한다.
재시도한다.
실패 상태를 데이터베이스에 저장한다.
예를 들어 예외를 다시 호출한 쪽으로 전달하려면 다음과 같이 작성할 수 있다.
try {
orderService.createOrder(request);
} catch (Exception e) {
log.error("Order create failed", e);
throw e;
}
로그는 실패를 기록해줄 뿐, 실패한 작업을 자동으로 해결해주지는 않는다.
로그가 많으면 문제를 더 쉽게 찾을 수 있을 것 같지만, 항상 그런 것은 아니다.
모든 메서드의 시작과 끝에 로그를 남기면 하나의 요청에서도 수십 줄의 로그가 만들어질 수 있다.
log.info("createOrder start");
log.info("validateOrder start");
log.info("validateOrder end");
log.info("saveOrder start");
log.info("saveOrder end");
log.info("createOrder end");
이런 로그가 수많은 요청에서 반복되면 중요한 오류 로그가 정상 로그 사이에 묻힐 수 있다.
로그를 저장하는 공간도 더 많이 필요해진다.
따라서 로그를 작성할 때는 다음 기준을 생각해보는 것이 좋다.
운영 중 실제로 확인할 가치가 있는가?
문제가 발생했을 때 원인을 찾는 데 도움이 되는가?
이미 다른 위치에서 같은 내용을 남기고 있지는 않은가?
로그 레벨은 적절한가?
민감한 정보는 포함되지 않았는가?
정상적인 주요 흐름은 INFO, 자세한 내부 과정은 DEBUG, 주의가 필요한 상황은 WARN, 실제 처리 실패는 ERROR처럼 구분하면 로그를 관리하기 쉬워진다.
로그를 남길 때는 다음 네 가지를 먼저 생각해볼 수 있다.
log.info("Order created. orderId={}", orderId);
success, complete, failed처럼 맥락이 없는 단어만 작성하지 말고, 어떤 작업인지 함께 남긴다.
log.warn(
"Payment timeout. orderId={}, paymentId={}",
orderId,
paymentId
);
나중에 검색할 수 있도록 관련 식별자를 포함한다.
정상적으로 예상할 수 있는 실패를 모두 ERROR로 남기면 정말 중요한 장애를 구분하기 어렵다.
반대로 실제 장애를 DEBUG로만 남기면 운영 환경에서 로그가 출력되지 않을 수 있다.
비밀번호와 인증 토큰은 물론이고, 이메일이나 전화번호 같은 개인정보가 불필요하게 포함되지 않았는지도 확인해야 한다.

로그는 단순히 콘솔에 값을 출력하는 기능이 아니다.
서버가 실행되는 동안 발생한 중요한 사건을 기록하고, 문제가 발생했을 때 당시 상황을 다시 확인할 수 있도록 도와주는 도구다.
요청이 들어왔다.
처리가 완료되었다.
검증에 실패했다.
외부 API 호출이 지연되었다.
예외가 발생했다.
권한이 없는 접근이 시도되었다.
좋은 로그는 장애 원인을 찾는 시간을 줄여준다.
반대로 로그가 너무 적으면 문제가 발생해도 확인할 정보가 없다. 로그가 너무 많으면 중요한 내용을 찾기 어려워진다. 비밀번호나 인증 토큰 같은 민감한 정보가 포함되면 로그 자체가 보안 문제가 될 수도 있다.
나는 로그를 다음과 같이 이해하고 있다.
로그는 서버가 남기는 업무 일지이고, 장애가 발생했을 때 당시의 상황을 되돌아볼 수 있게 해주는 기록이다.
API를 개발할 때는 기능이 정상적으로 동작하는지만 확인하지 말고 다음 질문도 함께 생각해보면 좋다.
이 기능이 운영 환경에서 실패한다면, 나는 어떤 로그를 보고 원인을 찾을 수 있을까?
처음부터 완벽한 로그를 작성하기는 어렵다.
우선은 주요 작업의 성공과 실패를 구분하고, 관련된 식별자를 함께 남기며, 민감한 정보가 포함되지 않도록 확인하는 것부터 시작하면 된다.
이런 습관을 조금씩 쌓아가면 단순히 동작하는 코드를 넘어, 운영 중에도 문제를 추적할 수 있는 코드를 작성하는 데 도움이 된다.