이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 보안 리뷰 질문은 어떤 순서로 던져야 실질적인 결함을 찾을 수 있는가?
- “취약한가요?”보다 나은 질문은 무엇인가?
- 리뷰 결과를 테스트, 로그, 요구사항 보완으로 어떻게 남기는가?
개요
보안 리뷰 질문지는 체크박스를 채우는 문서가 아니다. 리뷰어가 코드와 API 계약을 따라가며 “어디서 신뢰하는가”, “누가 무엇을 할 수 있는가”, “실패가 증거로 남는가”를 확인하는 작업 도구다. 질문이 추상적이면 답도 추상적이다.
나쁜 질문: 이 API는 안전한가?
좋은 질문: 이 API의 `orderId`는 어느 계층에서 principal의 소유권과 함께 검증되는가?좋은 질문은 파일과 테스트로 이어진다. 답을 찾을 수 없으면 설계나 코드가 부족하다는 신호다.
공격 시나리오
다음 PR이 올라왔다고 가정한다.
feature: 주문 부분 환불 API 추가
endpoint: POST /orders/{orderId}/refunds
actor: customer, support_agent
state change: payment refund requested
asset: order ownership, payment transaction, refund amount, audit log리뷰어는 기능 설명만 읽지 않는다. 요청 값이 어디서 신뢰되는지 코드 경로를 따라간다.
취약한 요청/응답 예시
POST /orders/9182/refunds HTTP/1.1
Host: api.example.com
Cookie: SESSION=customer-session
Content-Type: application/json
{
"userId": 1004,
"amount": 50000,
"reason": "wrong item",
"supportOverride": true
}HTTP/1.1 200 OK
Content-Type: application/json
{
"refundId": 321,
"orderId": 9182,
"status": "APPROVED",
"internalPaymentId": "pay_raw_123"
}이 요청 하나로 여러 질문이 나온다.
userId는 왜 body에 있는가?orderId가 principal의 주문인지 어디서 확인하는가?amount는 서버 계산값인가, 클라이언트 입력값인가?supportOverride는 고객이 보낼 수 있는 필드인가?- 응답에 내부 결제 식별자가 필요한가?
- 승인과 거부가 감사 로그로 남는가?
주제별 핵심 판단
보안 리뷰는 다음 순서로 진행한다.
1. 기능의 상태 변경과 보호 자산을 찾는다.
2. actor와 entry point를 나눈다.
3. 클라이언트가 보낸 값 중 보안 결정에 쓰이는 값을 표시한다.
4. 인증, 인가, 입력 검증, 출력 제한, 로그 위치를 따라간다.
5. 실패 경로와 남는 증거를 확인한다.
6. 테스트가 위 질문을 실제로 검증하는지 확인한다.이 순서가 없으면 리뷰는 “Spring Security 설정이 있어 보인다” 수준에서 멈추기 쉽다.
개선된 요청/응답 예시
[P1] `RefundRequest.userId`를 권한 판단에 사용하지 마세요.
현재 요청 body의 userId와 orderId로 환불 대상을 찾고 있어, 로그인 사용자와 다른 사용자의 주문을 조합할 수 있습니다.
principal.customerId로 소유권 조건을 걸고, userId 필드는 request DTO에서 제거해야 합니다.
거부 케이스는 audit event `refund_decision result=denied reason=ownership_mismatch`로 남겨 주세요.좋은 리뷰 코멘트는 문제, 공격 경로, 수정 방향, 검증 방법을 같이 담는다.
방어 설계
보호 자산 질문:
- 이 기능이 읽거나 바꾸는 보호 자산은 무엇인가?
- 개인정보, 결제 상태, 권한, token, 로그, export 파일이 포함되는가?
- 자산별로 읽기, 생성, 수정, 삭제, export action이 나뉘어 있는가?
actor 질문:
- 이 요청의 actor는 어디서 만들어지는가?
- body, query, header에 있는
userId,role,admin,ownerId를 권한 판단에 쓰지 않는가? - 고객, 관리자, 내부 batch, webhook, 파트너 서버가 같은 경로를 공유하는가?
entry point 질문:
- public API, admin API, webhook, batch, file upload, message consumer가 같은 Service를 호출하는가?
- 각 entry point마다 다른 검증과 로그가 필요한가?
- UI에 버튼이 없다는 이유로 서버 검증을 생략하지 않았는가?
trust boundary 질문:
- 어느 계층에서 외부 입력이 서버가 만든 값으로 바뀌는가?
- Gateway header, forwarded IP, Origin, redirect URL, webhook signature를 어디서 검증하는가?
- 내부 API라는 이유로 인증과 인가를 생략하지 않았는가?
권한 질문:
- 인증과 인가가 분리되어 있는가?
- role 체크 뒤에 resource ownership 체크가 있는가?
- 자기 자신, 같은 조직, 위임 관리자, 만료된 권한 같은 예외를 테스트하는가?
로그 질문:
- 성공뿐 아니라 거부, 반복 실패, 정책 위반도 로그에 남는가?
- actor, asset, action, result, reason, trace id가 구조화되어 있는가?
- token 원문, 비밀번호, 전체 개인정보, 카드번호가 로그에 남지 않는가?
Spring/HTTP 예시
public record RefundRequest(
@NotBlank String reason
) {
}@PostMapping("/orders/{orderId}/refunds")
public ResponseEntity<RefundResponse> refund(
@AuthenticationPrincipal CustomerPrincipal principal,
@PathVariable long orderId,
@Valid @RequestBody RefundRequest request) {
RefundResult result = refundService.requestRefund(
new CustomerRefundCommand(principal.customerId(), orderId, request.reason()));
return ResponseEntity.accepted().body(RefundResponse.safe(result));
}@Transactional
public RefundResult requestRefund(CustomerRefundCommand command) {
Order order = orderRepository.findByIdAndCustomerId(command.orderId(), command.customerId())
.orElseThrow(() -> {
audit.refundDenied(command.customerId(), command.orderId(), "ownership_mismatch");
return new AccessDeniedException("order not found");
});
Money amount = refundPolicy.calculateCustomerRefund(order);
Refund refund = refundRepository.request(order.id(), amount, command.reason());
audit.refundRequested(command.customerId(), order.id(), refund.id(), amount.maskForLog());
return RefundResult.accepted(refund.id());
}리뷰 질문이 DTO 제거, Repository 조건, 감사 로그, 응답 축소로 이어져야 한다.
운영 로그와 감사 지점
level=WARN event=security_review_gap_found pr=184 file=RefundService.java question=resource_ownership result=missing endpoint=/orders/{orderId}/refunds reviewer=kim trace_id=review-184-02level=WARN event=refund_decision result=denied actor_type=customer actor_id=1004 asset_type=order asset_id=9182 action=request_refund reason=ownership_mismatch trace_id=91ac7701리뷰 과정 자체도 기록으로 남길 수 있다. 특히 권한 변경, 결제, 개인정보 export 같은 고위험 기능은 리뷰에서 발견한 gap을 이슈나 체크리스트로 남겨 다음 PR에서 재사용한다.
실전 판단 기준
- 리뷰 질문마다 대응하는 테스트나 로그 확인 방법이 있는가?
- 좋은 리뷰 질문은 endpoint, actor, resource, action, 실패 로그 중 최소 하나를 구체적으로 지목한다.
- 질문지가 체크리스트로만 끝나면 안 되고 수정 PR, 테스트, 운영 로그 항목으로 이어져야 한다.
테스트 포인트
- 다른 사용자의
orderId로 요청했을 때 403 또는 404로 거부되는가? - body에 예상 밖 필드
userId,role,supportOverride를 넣어도 무시되거나 거부되는가? - 고객, 상담원, 관리자 경로가 서로 다른 policy와 audit event를 거치는가?
- 실패 응답이 내부 결제 식별자나 stack trace를 노출하지 않는가?
- 리뷰에서 발견한 gap이 요구사항 ID나 테스트 이름으로 남았는가?
위험 신호!
- 질문지가 “인증 적용 여부”, “SQL Injection 여부” 같은 예/아니오 항목만 있다.
- Controller annotation만 보고 Service와 Repository 조건을 보지 않는다.
- 성공 테스트만 있고 비소유자, 낮은 권한, 만료 token, 중복 요청 테스트가 없다.
- 클라이언트 DTO의 필드가 왜 필요한지 설명하지 못한다.
- 리뷰 코멘트가 “보안상 위험해 보입니다”에서 끝나고 공격 경로와 수정 기준이 없다.
- 로그 검증을 리뷰 범위에서 제외한다.
확인 질문
확인 질문
- 좋은 보안 리뷰 질문의 조건은 무엇인가?
- 특정 값, 계층, actor, asset, 실패 경로를 따라가며 코드와 테스트로 검증할 수 있어야 한다.
- 리뷰에서 DTO 필드를 보는 이유는 무엇인가?
- 클라이언트가 보낸 값이 보안 결정에 섞이는 순간 권한 우회와 값 조작이 생기기 때문이다.
- 리뷰 결과는 어디에 남겨야 하는가?
- PR 코멘트, 요구사항 ID, 테스트 케이스, 감사 로그 필드, 재사용 가능한 체크리스트로 남겨야 한다.