이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 오류 응답은 사용자에게 무엇을 알려 주고 무엇을 숨겨야 하는가?
- SQL 오류, 인증 실패, validation 실패, 서버 예외를 같은 방식으로 응답하면 어떤 문제가 생기는가?
- Spring에서는 클라이언트 응답과 서버 로그를 어떻게 분리해야 하는가?
개요
Error Message 정보 노출은 오류 자체보다 오류를 설명하는 방식에서 생긴다. 클라이언트 응답에 stack trace, SQL syntax, class name, 내부 file path, 서버 버전, 계정 존재 여부가 나오면 공격자는 다음 payload를 더 정확히 만들 수 있다.
좋은 오류 처리는 “사용자가 고칠 수 있는 정보”와 “운영자가 조사해야 하는 정보”를 분리한다. 클라이언트에는 안정된 오류 코드와 짧은 안내를 주고, 서버 로그에는 trace id로 상세 원인을 추적할 수 있게 남긴다.
모든 오류를 무조건 500으로 숨기는 것도 좋은 설계가 아니다. validation 실패는 400, 인증 필요는 401, 권한 없음은 403, 리소스 없음은 404, rate limit 초과는 429처럼 의미 있는 상태를 유지해야 클라이언트와 운영자가 대응할 수 있다.
이 문서의 핵심은 “숨길 정보와 남길 증거를 분리한다”는 것이다.
공격 시나리오
- 공격자는 잘못된 id, enum, SQL 문자, 너무 큰 payload, 만료 token을 보내며 오류 응답 차이를 관찰한다.
- SQL 오류 메시지에서 DB 제품, 테이블명, 컬럼명, query fragment를 얻는다.
- 인증 실패 메시지에서 계정 존재 여부, 잠김 여부, MFA 등록 여부를 추론한다.
- stack trace에서 package name, framework version, 내부 endpoint, cloud path를 파악한다.
- validation 오류가 내부 정규식이나 정책명을 그대로 보여 주면 우회 가능한 경계값을 찾는다.
- 같은 오류가 서비스마다 다른 형식으로 나오면 공격자는 내부 라우팅과 microservice 구성을 추정한다.
취약한 요청/응답 예시
GET /api/orders?sort=' HTTP/1.1
Host: api.example.com
Cookie: SESSION=validHTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"exception": "org.postgresql.util.PSQLException",
"message": "ERROR: unterminated quoted string at or near \"' order by created_at\"",
"path": "/app/service/OrderSearchService.java:88",
"trace": "com.example.order.OrderSearchService.search(OrderSearchService.java:88)"
}이 응답은 DB 제품, SQL 조각, 내부 class, 파일 경로를 한 번에 노출한다. 공격자에게는 디버그 콘솔과 비슷한 정보다.
주제별 핵심 판단
- 클라이언트 오류 메시지는 행동을 안내하되 내부 구조를 설명하지 않는다.
- 서버 로그는 상세 원인을 남기되 민감값과 secret을 남기지 않는다.
- 오류 응답 형식은 endpoint와 service마다 일관되어야 한다.
- 인증 관련 메시지는 계정 존재 여부를 직접 구분하지 않는다.
- validation 오류는 필드 단위 안내를 제공할 수 있지만 내부 정규식 전체와 정책 구현명은 숨긴다.
traceId또는requestId는 응답과 로그를 연결하는 안전한 열쇠다.- 운영 환경에서 debug error page와 stack trace 응답은 꺼져 있어야 한다.
개선된 요청/응답 예시
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": "INVALID_REQUEST",
"message": "요청 값을 확인해 주세요.",
"traceId": "af81c1d4"
}level=WARN event=request_rejected result=blocked code=INVALID_REQUEST endpoint=GET /api/orders principal=member:1004 reason=invalid_sort_key traceId=af81c1d4클라이언트는 요청을 고쳐야 한다는 사실과 문의에 사용할 trace id를 받는다. 운영자는 로그에서 실제 원인을 확인한다.
방어 설계
- API 공통 오류 응답 schema를 정한다.
- 예외 type을 외부 코드로 직접 노출하지 않는다.
- client message와 server log message를 분리한다.
- trace id를 모든 요청에 부여하고 응답 header 또는 body에 포함한다.
- 인증 실패, 권한 실패, validation 실패, rate limit, 서버 오류의 status와 code를 표준화한다.
- 서버 로그에는 원인, endpoint, principal, client id, trace id를 남긴다.
- 로그에는 비밀번호, token, 인증번호, 전체 카드번호, 주민번호, secret을 남기지 않는다.
- 운영 profile에서 error page, debug property, stack trace 옵션이 꺼져 있는지 배포 검증에 포함한다.
Spring/HTTP 예시
public record ApiError(
String code,
String message,
String traceId
) {}@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> invalidRequest(MethodArgumentNotValidException ex,
HttpServletRequest request) {
String traceId = traceIdFrom(request);
log.warn("event=request_rejected code=INVALID_REQUEST endpoint={} reason=validation_failed traceId={}",
request.getRequestURI(), traceId);
return ResponseEntity.badRequest().body(
new ApiError("INVALID_REQUEST", "요청 값을 확인해 주세요.", traceId)
);
}
@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> unexpected(Exception ex, HttpServletRequest request) {
String traceId = traceIdFrom(request);
log.error("event=unexpected_error endpoint={} traceId={}",
request.getRequestURI(), traceId, ex);
return ResponseEntity.internalServerError().body(
new ApiError("INTERNAL_ERROR", "일시적인 오류가 발생했습니다.", traceId)
);
}
}예상 가능한 client error와 예상하지 못한 server error를 나누되, 둘 다 내부 class 이름을 응답에 넣지 않는다.
운영 로그와 감사 지점
level=WARN event=auth_failure result=blocked code=AUTHENTICATION_FAILED endpoint=POST /login client=web reason=bad_credentials_or_unknown_account traceId=d0a21fd9level=ERROR event=unexpected_error result=error code=INTERNAL_ERROR endpoint=GET /api/orders exception=OrderQueryException traceId=d0a21fd9- 인증 실패 로그의 내부 reason은 운영자에게만 보이고 응답 메시지는 일반화한다.
- validation 실패는 공격과 사용자 실수를 구분할 수 있게 field와 constraint group 중심으로 남긴다.
- SQL 오류는 queryId와 sqlState를 남기되 전체 SQL과 parameter 원문을 남기지 않는다.
- stack trace는 서버 로그와 error tracking 시스템으로만 보낸다.
- trace id는 고객 문의, 관제 알림, 서버 로그를 연결할 수 있어야 한다.
실전 판단 기준
- 응답 본문에
exception,trace,stack,sql,java.lang, package name이 있으면 위험하다. - 로그인에서 “존재하지 않는 계정”과 “비밀번호 틀림”을 구분하면 계정 enumeration에 쓰일 수 있다.
- validation 메시지가 내부 정책을 지나치게 자세히 설명하면 공격 payload 조정에 도움을 준다.
- 모든 오류를
200 OK로 감싸면 모니터링과 클라이언트 처리가 깨진다. - 모든 오류를
500으로 처리하면 사용자가 고칠 수 있는 요청과 서버 장애가 섞인다. - 로그에 상세 원인이 없으면 사고 대응 때 안전한 응답을 유지할 수 없다.
테스트 포인트
- SQL syntax를 유발하는 입력에도 응답에 SQL 조각이 없는지 확인한다.
- 존재하지 않는 계정과 틀린 비밀번호가 같은 메시지로 응답되는지 확인한다.
- validation 실패가 field 안내를 제공하되 class name과 정규식 전체를 숨기는지 확인한다.
- 운영 profile에서 stack trace 응답과 debug error page가 꺼져 있는지 확인한다.
- trace id가 응답과 서버 로그에서 일치하는지 확인한다.
- 400, 401, 403, 404, 429, 500의 오류 schema가 일관적인지 확인한다.
위험 신호!
server.error.include-stacktrace=always같은 설정이 운영에 켜져 있다.- exception message를 그대로 API 응답에 넣는다.
- 로그인 실패 메시지가 계정 존재 여부를 알려 준다.
- 오류 응답마다 field 이름과 구조가 다르다.
- trace id 없이 “관리자에게 문의하세요”만 반환한다.
- 서버 로그에는 상세 원인이 없고 클라이언트 응답에만 상세 원인이 있다.
확인 질문
확인 질문
- 오류 응답에서 숨겨야 하는 정보는 무엇인가?
- stack trace, SQL, class name, 내부 path, 서버 버전, 계정 존재 여부, secret 관련 값이다.
- trace id는 왜 필요한가?
- 클라이언트에는 내부 정보를 숨기면서도 운영자가 서버 로그에서 상세 원인을 찾을 수 있게 해 주기 때문이다.
- 좋은 오류 처리는 status를 어떻게 다루는가?
- 의미 있는 HTTP status를 유지하면서 응답 schema와 메시지를 일관되게 만든다.