인증 서비스 로그를 감사해보니 — 민감정보가 새는 세 가지 경로

seonwoo_jung·3일 전

로그는 가장 조용한 유출 경로다

DB 암호화, TLS, 시크릿 관리까지 챙겨놓고도 의외로 많은 서비스가 로그 파일에 민감정보를 평문으로 쌓는다. 로그는 접근 권한이 느슨한 경우가 많고(개발자 대부분이 본다), 수집 파이프라인을 타고 외부 SaaS로 흘러가기도 하며, 보존 기간도 길다. 유출돼도 한참 동안 모른다.

인증 서비스의 로그 체계를 정비하면서 발견한 민감정보가 새는 세 가지 경로와, "그럼 뭘 남겨야 하는가"에 대한 원칙을 정리한다.


경로 1: p6spy — SQL 디버깅 도구는 바인딩 파라미터를 평문으로 남긴다

p6spy는 JPA가 날리는 SQL을 바인딩 값까지 채워서 보여주는 고마운 도구다. 그런데 그 말은 곧, 운영에서 켜두면 이런 로그가 쌓인다는 뜻이다.

-- p6spy가 남기는 로그 (바인딩 값이 채워진 완성형 SQL)
select * from member where phone_number = '01012345678'
update member_auth set password = '$2a$10$N9qo8uLOickgx2ZMRZoMye...' where id = 42

전화번호, 비밀번호 해시가 로그 파일에 평문으로 들어간다. 해시라서 괜찮다고 생각할 수 있지만, 해시는 오프라인 크래킹의 대상이고, 전화번호는 그 자체로 개인정보다.

처음엔 "스테이징에서는 SQL 디버깅이 필요하니 유지"였는데, 스테이징도 실데이터에 준하는 데이터가 흐르는 환경이라 결국 dev에서만 켜는 것으로 정리했다. 환경별 프로필 분리가 그 수단이다:

# application-dev.yml — 디버깅 편의를 위해 켠다
decorator:
  datasource:
    enabled: true

# application-stg.yml — 바인딩 파라미터 평문 노출 때문에 끈다
decorator:
  datasource:
    enabled: false

운영은 한 단계 더 갔다. 로깅만 끄는 게 아니라 p6spy의 DataSource 데코레이션 자체를 비활성화해서, 프록시를 거치는 미세한 오버헤드까지 제거했다. "끈 줄 알았는데 래핑은 되어 있는" 상태를 남기지 않는다.


경로 2: 디버깅하다 남긴 payload 로그 — 토큰이 통째로 적힌다

소셜 로그인 콜백을 디버깅하면서 이런 로그를 남긴 적이 있다 (있었다, 고백한다).

log.warn("apple callback payload => {}", request.payload());

문제: 이 payload 안에는 identity token이 들어 있다. 토큰이 로그에 적히면, 로그를 볼 수 있는 모든 사람·시스템이 그 토큰의 유효기간 동안 해당 사용자를 가장할 수 있는 재료를 갖게 된다.

"디버깅 로그는 머지 전에 지운다"는 다짐으로는 부족하다. 이 로그는 리뷰를 통과해 운영까지 갔다. 그래서 규칙을 바꿨다:

외부에서 들어온 원문(payload, 헤더, 토큰)은 로그에 남기지 않는다. 예외 없음.

디버깅에 필요한 건 "어느 provider에서 무엇이 실패했는가"이지 원문이 아니다:

try {
    callbackInfo = client.callback(request.payload());
} catch (Exception e) {
    // payload에는 identity token이 포함될 수 있으므로 원문을 로그에 남기지 않는다
    log.error("OAuth 콜백 처리 실패. provider={}", request.provider(), e);
    throw e;
}

원문이 정말 필요한 상황(파싱 실패의 재현 등)이라면, 로그가 아니라 에러 트래커의 접근 통제된 컨텍스트로 보내는 게 맞다.


경로 3: Swagger — 민감정보는 아니지만 공격 지도를 준다

운영에 Swagger UI가 열려 있으면 공격자에게 전체 API 명세, 파라미터 구조, 인증 방식을 지도로 제공하는 셈이다. 직접적인 정보 유출은 아니지만, 다른 취약점의 탐색 비용을 극적으로 낮춰준다.

이것도 프로필로 해결 — dev에서만 활성:

# application-stg.yml / application-prod.yml
springdoc:
  api-docs:
    enabled: false

보너스: 로그 레벨 위생 — 4xx는 사고가 아니다

민감정보는 아니지만 같이 정리한 것. 기존 코드는 모든 예외를 error로 찍고 있었다.

// before: 비밀번호 틀려도 error, DB 죽어도 error
log.error("CustomException => {}", exception.getMessage(), exception);

비밀번호 불일치, 중복 가입 시도 같은 4xx는 정상적인 비즈니스 거절이다. 이걸 error로 찍으면 error 채널이 노이즈로 가득 차고, 진짜 사고(5xx)가 그 속에 묻힌다. 경보의 신뢰도가 떨어지면 사람들이 경보를 무시하기 시작하고, 그게 진짜 장애를 늦게 발견하는 원인이 된다.

// after: 4xx는 warn, 5xx만 error
if (exception.getStatus().is5xxServerError()) {
    log.error("CustomException => {}", exception.getMessage(), exception);
} else {
    log.warn("CustomException => {} ({})", exception.getMessage(), exception.getStatus());
}

이제 "error 로그 발생 = 조사 대상"이라는 등식이 성립한다.


그럼 뭘 남겨야 하는가 — 식별자 기반 감사 로그

빼는 것만 정리하면 로그가 빈약해진다. 인증 서비스라면 보안 감사를 위해 남겨야 하는 이벤트가 분명히 있다. 원칙은 하나다:

값(value)이 아니라 식별자(ID)를 남긴다.

이벤트남기는 것남기지 않는 것
가입/탈퇴/로그인 성공memberId, provider, ip전화번호, 이메일
로그인 실패ip, 실패 사유 분류입력된 비밀번호(당연), 전화번호
refresh token 오용 시도시도 사실 자체 (warn)토큰 값
계정 연결/해제memberId, authTypeOAuth 원문
member.updateLastLogin();
...
log.info("일반 로그인 성공. memberId={}, ip={}", member.getId(), clientInfo.ip());
// 만료/탈취된 토큰 사용 시도의 신호일 수 있어 기록한다 (토큰 값 자체는 남기지 않는다)
.orElseThrow(() -> {
    log.warn("유효하지 않은 refresh token으로 갱신 시도");
    return new CustomException(TOKEN_INVALID);
});

식별자만 있어도 "누가, 언제, 어디서(ip), 무엇을"의 감사 추적은 완성된다. 값이 필요한 조사라면 식별자로 DB를 보면 되고, 그 접근은 DB 권한 체계가 통제한다. 로그에 값을 넣는 순간 그 통제가 사라진다.


정리 — 점검 체크리스트

내 서비스에서 5분 안에 확인해볼 수 있는 것들:

  • SQL 로깅 도구(p6spy, hibernate show_sql + 바인딩 로거)가 운영/스테이징에서 꺼져 있는가?
  • grep -rn "payload\|token\|password" --include="*.java" | grep "log\." — 원문을 찍는 로그가 있는가?
  • Swagger/GraphQL playground가 운영에 열려 있는가?
  • 4xx와 5xx가 같은 레벨로 찍히고 있는가? (error 경보의 신뢰도)
  • 감사가 필요한 이벤트(인증 성공/실패, 권한 변경)가 식별자 기반으로 남고 있는가?

로그는 기능이 아니라서 리뷰에서 관대해지기 쉽다. 하지만 유출 사고의 단골 경로이고, 동시에 사고 조사의 유일한 증거이기도 하다. 무엇을 지우고 무엇을 남길지는 설계의 영역이다.

0개의 댓글