이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 하나의 웹 요청은 백엔드 내부에서 어떤 보안 경계를 통과하는가?
  • 각 경계에서 신뢰할 수 있는 값과 다시 검증해야 하는 값은 무엇인가?
  • Java/Spring 백엔드에서는 요청 흐름을 어떤 코드, 설정, 로그로 추적해야 하는가?

개요

웹 보안의 출발점은 “어떤 설정을 켤 것인가”가 아니라 “요청이 어느 경계를 지나며 어떤 값이 신뢰되는가”다. 같은 HTTP 요청이라도 브라우저, CDN, API Gateway, Servlet Filter, Spring Security Filter, Controller, Service, Repository, DB를 지나며 신뢰 수준이 계속 바뀐다.

백엔드 개발자가 이 흐름을 보지 못하면 보안 판단이 흩어진다. Controller는 userId를 받았고, Service는 로그인 여부만 확인했고, Repository는 소유자 조건 없이 조회했으며, 로그는 200 OK만 남기는 식이다. 기능은 동작하지만 공격자가 값 하나를 바꿨을 때 어느 경계에서 막혀야 하는지 설명할 수 없다.

전체 지도 문서는 각 세부 주제의 입구다. 인증, 인가, 브라우저 보안, token, 입력 검증, 감사 로그를 따로 외우기 전에 요청이 지나가는 길을 먼저 그려야 한다.

요청 흐름

일반적인 Spring 백엔드 요청은 다음 경계를 지난다.

Browser or API Client
  -> DNS / CDN / Load Balancer
  -> API Gateway or Reverse Proxy
  -> Servlet Container
  -> Spring Security FilterChain
  -> Controller
  -> Service / Domain Policy
  -> Repository / External Client
  -> Database / Message Broker
  -> Audit Log / Metric / Trace

각 단계의 책임은 다르다. Gateway는 TLS 종료, rate limit, 공통 header 정리를 맡을 수 있다. Spring Security FilterChain은 인증과 일부 요청 수준 인가를 맡는다. Controller는 입력 형식과 요청 DTO를 다룬다. Service는 업무 규칙과 리소스 소유권을 판단한다. Repository는 DB 조회 조건으로 권한 누락을 마지막으로 줄인다. 감사 로그는 나중에 이 판단을 복원한다.

공격 시나리오

공격자는 요청 흐름을 거꾸로 본다. 어떤 값이 어디서 검증되는지 찾고, 검증되지 않는 값을 정상 요청처럼 보이게 바꾼다.

  • 로그인 후 자신의 주문 상세 API를 호출해 URL 구조와 응답 필드를 확인한다.
  • /api/orders/1004/api/orders/2002로 바꾸어 다른 사용자의 주문을 조회한다.
  • role, ownerId, price, redirect_uri, Origin처럼 서버가 과신할 수 있는 값을 조작한다.
  • 실패 응답이 403인지 404인지, 오류 메시지에 내부 클래스나 SQL이 보이는지 확인한다.
  • 차단되면 느린 빈도, 여러 IP, 오래된 token, 다른 endpoint 순서로 우회 가능성을 본다.

이 과정은 특별한 공격 도구 없이도 브라우저 개발자 도구와 HTTP client만으로 가능하다. 그래서 전체 흐름 문서에는 “어디서 막아야 하는가”가 들어가야 한다.

취약한 요청/응답 예시

GET /api/orders/2002 HTTP/1.1
Host: api.example.com
Cookie: SESSION=member-1004
X-Request-Id: 7f9c2b0e
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "orderId": 2002,
  "memberId": 2002,
  "items": ["notebook"],
  "totalPrice": 1300000
}

이 응답은 사용자가 로그인했다는 사실만 확인하고 주문 소유권을 확인하지 않은 상태다. URL의 orderId는 클라이언트가 바꿀 수 있으므로 권한 결정의 근거가 될 수 없다.

주제별 핵심 판단

  • 요청 경계는 코드 위치가 아니라 값의 출처와 검증 책임으로 나눈다.
  • 인증 전 요청, 인증 후 요청, 관리자 요청, 내부 시스템 요청은 같은 로그 정책으로 보면 안 된다.
  • Filter에서 인증이 끝났더라도 Service에서 resource ownership을 다시 확인해야 한다.
  • DB 조회 조건에 member_id = principal.memberId가 들어가면 권한 판단이 데이터 접근과 연결된다.
  • 감사 로그는 요청의 마지막 부가 기능이 아니라 보안 결정의 증거다.

개선된 요청/응답 예시

GET /api/orders/2002 HTTP/1.1
Host: api.example.com
Authorization: Bearer access-token-for-member-1004
X-Request-Id: 7f9c2b0e
HTTP/1.1 403 Forbidden
Content-Type: application/json
 
{
  "code": "ORDER_ACCESS_DENIED",
  "traceId": "7f9c2b0e"
}
level=WARN event=authorization_denied actor=member:1004 resource=order:2002 action=read result=blocked reason=owner_mismatch traceId=7f9c2b0e

개선된 흐름은 응답과 로그를 분리한다. 클라이언트에는 내부 구조를 드러내지 않고, 서버 로그에는 누가 어떤 리소스에 어떤 action을 시도했고 왜 거부되었는지 남긴다.

방어 설계

  • Gateway에서 공통 rate limit, request id, TLS, 허용 host를 정리한다.
  • Spring Security FilterChain에서 인증 방식, 세션 정책, CSRF, CORS의 공통 경계를 설정한다.
  • Controller에서 요청 DTO를 allowlist로 받고 Bean Validation으로 형식과 크기를 제한한다.
  • Service에서 principal과 resource의 관계를 판단한다.
  • Repository에서 소유자 조건을 포함해 조회한다.
  • Audit log에 actor, resource, action, result, reason, traceId를 남긴다.
  • 실패 응답은 내부 원인을 숨기되 운영자가 traceId로 추적할 수 있게 만든다.
  • 테스트는 요청 경계별로 나눈다. 인증 없음, 만료 token, 다른 사용자 리소스, 잘못된 Origin, 예상 밖 JSON 필드를 따로 검증한다.

Spring/HTTP 예시

@GetMapping("/api/orders/{orderId}")
public OrderResponse getOrder(@PathVariable Long orderId, Authentication authentication) {
    Long memberId = ((CustomPrincipal) authentication.getPrincipal()).memberId();
    return orderQueryService.getOrder(orderId, memberId);
}
 
@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId, Long memberId) {
    Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
        .orElseThrow(() -> new AccessDeniedException("order access denied"));
    return OrderResponse.from(order);
}

이 예시는 권한 결정을 Controller의 URL 값에 맡기지 않는다. 인증된 principal에서 memberId를 얻고, Repository 조회 조건에 orderIdmemberId를 함께 넣어 resource ownership을 데이터 접근 단계까지 끌고 간다.

운영 로그와 감사 지점

level=INFO event=request_received method=GET path=/api/orders/{orderId} principal=member:1004 traceId=7f9c2b0e
level=WARN event=authorization_denied actor=member:1004 resource=order:2002 action=read result=blocked reason=owner_mismatch traceId=7f9c2b0e

요청 수신 로그와 권한 거부 로그를 분리하면 운영자가 요청 흐름과 보안 결정을 함께 추적할 수 있다.

  • token 원문, 비밀번호, 주민번호, 전체 카드번호는 남기지 않는다.
  • path variable의 실제 값은 resource id로 정규화해 남긴다.
  • 401, 403, 400, 429, 500은 서로 다른 운영 의미를 가진다.
  • traceId가 Controller, Service, DB slow query, audit log에 이어져야 한다.
  • 관리자 대리 조회와 고객센터 조회는 일반 사용자 조회와 별도 이벤트로 남긴다.

실전 판단 기준

  • 기본 구현은 “로그인했는가”와 “이 리소스에 접근 가능한가”를 분리해야 한다.
  • 요청 본문에 들어온 userId, role, ownerId는 권한 근거가 아니라 검증 대상이다.
  • 리뷰에서는 보안 경계가 Filter에만 몰려 있지 않은지, Service와 Repository까지 이어지는지 본다.
  • batch, internal API, admin API, webhook 같은 예외 경로는 일반 사용자 API와 다른 경계를 가진다.
  • 좋은 판단은 정상 흐름도 옆에 공격 흐름도를 같이 그릴 때 나온다.

테스트 포인트

  • 다른 사용자의 resource id로 조회했을 때 403 또는 정책상 404가 나오는지 확인한다.
  • 인증 없는 요청, 만료 token, 잘못된 issuer token, 권한 없는 role을 분리해 테스트한다.
  • Controller test뿐 아니라 실제 Spring Security FilterChain이 적용되는 통합 테스트를 둔다.
  • Repository 조회에 principal 기반 조건이 빠지지 않았는지 테스트 데이터로 확인한다.
  • 거부 이벤트가 audit log에 남고 traceId로 요청 로그와 연결되는지 확인한다.

위험 신호!

  • Controller에서 받은 userId를 Service 권한 판단에 그대로 사용한다.
  • 모든 권한 실패가 500으로 떨어진다.
  • 운영 로그에는 200/500만 있고 actor, resource, reason이 없다.
  • 내부 API라는 이유로 인증과 감사 로그를 생략한다.
  • Gateway나 WAF가 막아 줄 것이라고 가정하고 애플리케이션 권한 검사를 제거한다.

확인 질문

확인 질문

  • 웹 요청 흐름에서 가장 중요한 보안 경계는 어디인가?
    • 하나가 아니라 값의 신뢰 수준이 바뀌는 모든 지점이다. Filter, Controller, Service, Repository, Log가 각각 다른 책임을 가진다.
  • URL의 resource id를 왜 바로 신뢰하면 안 되는가?
    • 클라이언트가 바꿀 수 있는 값이므로 principal과 resource ownership을 서버에서 다시 확인해야 한다.
  • 감사 로그는 왜 방어 설계에 포함되는가?
    • 공격을 막는 순간뿐 아니라 사고 이후 영향 범위와 원인을 복원하는 증거가 되기 때문이다.

참고 문서