이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 구현 중에는 Controller, Service, Repository에서 각각 어떤 보안 경계를 확인해야 하는가?
- DTO allowlist, Bean Validation, Method Security, Repository 조건은 어떻게 연결되는가?
- 구현 체크리스트의 증거를 코드와 테스트에 어떻게 남겨야 하는가?
개요
구현 중 보안 체크리스트는 “나중에 테스트하자”가 아니라 코드를 작성하는 순간 보안 경계가 사라지지 않게 잡는 도구다. API 설계에서 정한 actor, ownerCheck, auditEvent가 실제 Controller, Service, Repository에 반영되어야 한다.
Spring 애플리케이션에서는 Controller가 요청 형식을 받고, Service가 업무 권한과 상태 전이를 판단하며, Repository가 데이터 범위를 좁힌다. 어느 한 계층만 믿으면 우회 경로가 생긴다.
구현 중 가장 흔한 실수는 entity 직접 바인딩, 클라이언트 ownerId 신뢰, annotation만 붙인 권한 검사, 문자열 query 결합, 예외 메시지 노출, 로그 민감값 누락이다.
이 문서의 핵심은 체크리스트를 코드 diff에 붙이는 것이다. 항목마다 “어느 파일의 어느 코드가 이 경계를 지키는가”가 보여야 한다.
공격 시나리오
- 공격자는 request body에 화면에는 없는
role,ownerId,approved,price필드를 추가한다. - Controller validation은 통과하지만 Service에서 소유권 검사를 하지 않는 endpoint를 찾는다.
- Repository가
findById(id)만 사용하면 다른 사용자의 resource id를 넣어 조회한다. @PreAuthorize가 public method가 아닌 내부 self-invocation에 붙어 우회된다.- 동적 검색 구현에서
sort와keyword를 문자열로 query에 붙인다. - 예외 handler가 stack trace와 SQL 오류를 그대로 응답한다.
취약한 요청/응답 예시
PATCH /api/orders/9001 HTTP/1.1
Host: api.example.com
Authorization: Bearer member-1004
Content-Type: application/json
{"ownerId":1004,"status":"APPROVED","price":0}@PatchMapping("/api/orders/{orderId}")
public OrderResponse update(@PathVariable long orderId, @RequestBody Order order) {
order.setId(orderId);
return OrderResponse.from(orderRepository.save(order));
}entity 직접 바인딩과 repository save가 합쳐지면 Mass Assignment와 권한 우회가 동시에 생긴다.
주제별 핵심 판단
- Request DTO는 허용 필드만 가진 allowlist다.
- Bean Validation은 형식 검증이고 업무 의미 검증은 Service에서 한다.
- 객체 소유권은 서버 데이터로 확인한다.
- 기능 권한은 method security나 policy service로 명시한다.
- Repository는
findByIdAndOwnerId처럼 데이터 범위를 줄인다. - 예외 handler는 내부 구현을 숨기고 traceId를 제공한다.
- 로그는 event name과 reason을 남기되 민감값을 제거한다.
개선된 요청/응답 예시
public record OrderUpdateRequest(
@NotNull OrderStatus status,
@Size(max = 200) String memo
) {}@PatchMapping("/api/orders/{orderId}")
public OrderResponse update(@PathVariable long orderId,
@Valid @RequestBody OrderUpdateRequest request,
Authentication authentication) {
MemberId memberId = principalResolver.memberId(authentication);
Order order = orderService.updateOwnedOrder(memberId, orderId, request);
return OrderResponse.from(order);
}public Order updateOwnedOrder(MemberId memberId, long orderId, OrderUpdateRequest request) {
Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
.orElseThrow(OrderNotFoundException::new);
order.changeStatus(request.status());
audit.record("order_updated", memberId, orderId);
return order;
}클라이언트가 보낸 ownerId는 사용하지 않는다. principal과 서버 DB 조건으로 소유권을 확인한다.
방어 설계
- Controller에는
@Validrequest DTO와 인증 주체 추출만 둔다. - Service에는 업무 권한, 상태 전이, 재인증 필요 여부를 둔다.
- Repository에는 tenant/member/resource 범위 조건을 둔다.
- Entity setter를 줄이고 domain method로 의미 있는 변경만 허용한다.
- 동적 query의 값은 parameter binding, 구조는 allowlist로 처리한다.
- 오류 응답은 공통
@RestControllerAdvice에서 처리한다. - 보안 이벤트 발행은 업무 성공/실패 지점에 둔다.
- 민감 action은 테스트가 없으면 PR을 통과시키지 않는다.
Spring/HTTP 예시
@PreAuthorize("@orderPolicy.canUpdate(authentication, #orderId)")
@PatchMapping("/api/orders/{orderId}")
public OrderResponse update(@PathVariable long orderId,
@Valid @RequestBody OrderUpdateRequest request) {
return OrderResponse.from(orderService.update(orderId, request));
}public boolean canUpdate(Authentication authentication, long orderId) {
MemberId memberId = principalResolver.memberId(authentication);
return orderRepository.existsByIdAndMemberId(orderId, memberId);
}Method Security를 쓰더라도 Service 내부에서 예외 경로나 batch 경로가 같은 policy를 쓰는지 확인해야 한다.
운영 로그와 감사 지점
level=WARN event=implementation_security_gap result=blocked api=PATCH /api/orders/{id} reason=entity_request_body_detected file=OrderController.java traceId=e10a1192level=INFO event=security_check_evidence_recorded result=allowed api=PATCH /api/orders/{id} evidence=OrderSecurityTest.nonOwnerCannotUpdate owner=team-order traceId=e10a1192- 보안 체크리스트 실패는 PR comment나 build annotation으로 남긴다.
- 구현 증거는 테스트 이름과 코드 위치로 남긴다.
- 허용한 예외는 risk acceptance로 관리한다.
- 코드 생성이나 scaffold가 만든 기본 endpoint도 inventory에 등록한다.
- 로그 sanitizer 누락은 구현 결함으로 다룬다.
실전 판단 기준
@RequestBody Entity는 대부분 위험 신호다.findById뒤에 소유권 검사 없이 반환하면 IDOR 위험이 크다.- 클라이언트가 보낸
userId,ownerId,role,price는 보안 결정 근거가 아니다. - annotation 권한 검사는 self-invocation, 비동기 작업, batch에서 빠질 수 있다.
Map<String, Object>요청은 허용 필드가 코드에 드러나지 않는다.- 예외 로그에 request body 전체가 남으면 민감값 유출로 이어진다.
테스트 포인트
- DTO에 허용되지 않은 필드를 넣었을 때 거부되거나 무시 정책이 명확한지 확인한다.
- 비소유자 resource id로 조회/수정/삭제가 실패하는지 확인한다.
- 권한 없는 role이 action을 수행할 수 없는지 확인한다.
- 문자열 query 결합이 없는지 정적 검색과 테스트로 확인한다.
- 예외 응답에 stack trace, SQL, class name이 없는지 확인한다.
- 실패 이벤트가 설계된 event name과 reason으로 남는지 확인한다.
위험 신호!
- Entity를 request body로 직접 받는다.
- DTO에 민감 필드가 들어 있으나 endpoint 권한이 낮다.
- Repository가 tenant/member 조건 없이 resource id만 조회한다.
- 테스트 없이
@PreAuthorize만 추가하고 끝낸다. - exception message를 그대로 응답한다.
- 로그에 request body 전체를 출력한다.
확인 질문
확인 질문
- 구현 중 가장 먼저 확인할 경계는 무엇인가?
- 클라이언트 입력이 DTO allowlist로 제한되고 소유권이 서버 데이터로 확인되는지다.
- Controller, Service, Repository는 각각 무엇을 맡는가?
- Controller는 형식, Service는 업무 권한과 상태 전이, Repository는 데이터 범위 제한을 맡는다.
- 구현 체크리스트의 증거는 무엇인가?
- 코드 위치, 테스트 이름, 로그 event, 설정 diff, 리뷰 기록이다.