원격지원 API에서 CSRF 필터가 정상 요청을 차단하던 문제 수정
목차
CSRF 필터가 정상 요청을 막는 상황은 처음 겪으면 당황스럽다. 로그에는 403이 찍히는데, 클라이언트 쪽에선 뭘 잘못했는지 몰라서 재시도를 반복하고, 그 와중에 운영 채널에 이슈가 올라온다. 이번에 원격지원 API에서 정확히 그런 케이스가 있었다.
원인은 CSRF 인터셉터의 경로 예외 처리 누락이었다. 내부 클래스에서 특정 엔드포인트를 예외 목록에 넣지 않은 채 배포했고, 해당 경로로 들어오는 요청은 CSRF 토큰 검증에 걸려 차단됐다. API 키나 Bearer 토큰 방식으로 인증하는 엔드포인트였기 때문에 CSRF 검증이 애초에 불필요한 경로였는데, 필터 설정이 따라오지 못한 것.
재현해 보면 단순하다. 해당 경로로 POST 요청을 날리면 매번 403이 반환된다. 근데 처음 보고가 들어왔을 때 "간헐적으로 발생한다"는 표현 때문에 처음엔 캐시 문제나 세션 경합을 먼저 의심했다. 로그를 직접 찍어보니 해당 엔드포인트에 요청이 올 때마다 일관성 있게 차단되고 있었다. "간헐적"으로 느낀 건 호출하는 쪽 코드 경로가 여러 갈래라 그 경로를 탈 때만 문제가 터진 것이었다. 로그를 먼저 보는 게 맞다는 걸 다시 확인한 순간이었음.
CSRF 필터와 API 엔드포인트가 충돌하는 구조
CSRF 보호는 기본적으로 브라우저 기반의 세션 인증 흐름을 전제로 설계된다. 사용자가 로그인하고 세션 쿠키가 발급되면, 악의적인 사이트가 그 쿠키를 이용해 위조 요청을 보내는 공격을 막는 게 목적이다. 서버가 요청마다 CSRF 토큰을 검증하는 이유다.
문제는 REST API나 서버 간 통신에선 이 전제 자체가 성립하지 않는다는 점이다. API 키나 JWT Bearer 토큰으로 인증하는 엔드포인트에는 브라우저 세션이 없으니 CSRF 공격 벡터가 존재하지 않는다. 그런데 전역 필터 설정을 "모든 경로에 적용"으로 두면 이런 엔드포인트도 같이 걸린다.
Spring Security 기준으로 예외 처리는 보통 이렇게 쓴다:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers(
"/api/remote-support/**",
"/api/webhook/**"
)
);
return http.build();
}
}
인터셉터를 직접 구현한 경우라면 내부 클래스에서 shouldNotFilter 같은 메서드로 경로를 빼는 방식도 있다. 어느 쪽이든 핵심은 "이 경로는 CSRF 적용 대상이 아님"을 명시적으로 선언하는 것. 묵시적으로 빠져야 한다고 기대하면 언젠가 이번 케이스처럼 터진다.
이번 수정은 내부 클래스에 해당 경로 예외를 추가하는 것으로 마무리했다. 코드 변경 규모는 작았고, 수정 후 재현 테스트에서 정상 응답을 확인하고 배포했다. 재현 → 원인 파악 → 최소 범위 수정 → 배포 흐름. 이 순서를 지키면 의도치 않은 사이드 이펙트가 줄어든다.
자주 나오는 버그 패턴과 대응
비슷한 맥락에서 반복해서 마주치는 버그들이 있다. 증상만 보고 원인 찾는 시간을 줄이려면 패턴으로 정리해두는 게 도움이 된다.
| 패턴 | 증상 | 빠른 확인 포인트 |
|---|---|---|
| null 체크 순서 오류 | NPE (NullPointerException) | 스택 트레이스 최상단 null 참조 지점 |
| 경로 예외 처리 누락 | 필터에서 정상 요청 차단 | 필터 설정 클래스의 excludePath/ignoringRequestMatchers |
| YAML 공백 혼용 | 설정값 파싱 실패 | cat -A 또는 에디터 공백 가시화로 탭/스페이스 혼용 확인 |
| import 누락 | 컴파일 에러 | 빌드 로그 최상단 에러 메시지 |
이번 케이스는 두 번째 줄 - 경로 예외 처리 누락. 로그에 403이 찍히고 필터 관련 메시지가 있으면 이 패턴을 제일 먼저 보는 게 맞다. 이 패턴 테이블이 진짜 유용한 건 증상에서 확인 포인트로 점프할 수 있어서다. 원인 파악에 쓰는 시간이 줄어드는 게 체감된다.
블랙리스트 자동 등록과 Rate Limit 운영
원격지원 같은 API는 외부에 노출되는 만큼 공격성 트래픽 대응도 함께 챙겨야 한다. 수동으로 블랙리스트를 관리하는 건 속도가 느려서 실시간 대응이 안 된다. 이상 패턴을 감지하면 DB에 자동 등록하고 이후 요청은 즉시 차단하는 구조가 현실적으로 유효하다.
자동 등록할 때 실무에서 놓치기 쉬운 것들:
- 오탐 대응: 로드 테스트나 배치 성격의 작업이 짧은 시간에 요청을 몰아 날리면 공격으로 오해받을 수 있다. 내부 IP 대역이나 특정 User-Agent는 예외 처리를 해두는 게 안전하다.
- TTL 설정: 영구 차단은 오탐 복구가 어렵다. 일정 시간 후 자동 만료되게 하고, 필요할 때 수동으로 연장하는 방식이 운영하기 편하다.
- 알림 연동: 자동 차단이 발생했을 때 슬랙이든 디스코드든 알림이 와야 모니터링이 된다. 자동화가 아무리 잘 돼도 담당자가 상황을 모르면 오탐 발생 시 늦게 대응하게 된다.
Rate Limit 설정도 비슷한 고민이 있다. "얼마나 강하게 막을 것인가"보다 "정상 사용자는 안 막으면서 공격은 차단하는가"가 실제로 중요한 질문이다. 너무 타이트하게 잡으면 정상 사용자가 429를 받고, 너무 느슨하면 의미가 없다.
Redis 슬라이딩 윈도우 방식은 고정 윈도우보다 경계 구간 버스팅 공격에 강하다. 고정 윈도우는 창 경계 직전/직후에 요청을 몰아서 2배 트래픽을 흘리는 공격이 가능한데, 슬라이딩 윈도우는 그게 안 된다. 다만 Redis가 단일 장애점이 되면 Rate Limit 자체가 날아가거나 전체 API에 영향이 생기니, 연결 풀 크기와 타임아웃 설정은 반드시 함께 챙겨야 한다. Redis 연결이 막혔을 때 실패 개방(fail open)으로 갈지 실패 차단(fail closed)으로 갈지 정책도 미리 정해두는 게 낫다. 장애 상황에서 그 결정을 즉흥으로 내리기엔 시간이 없다.
이번 CSRF 수정 자체는 코드 한 줄에 가까운 변경이었지만, 이런 류의 설정 누락이 운영 중에 터지면 규모와 무관하게 대응 비용이 생긴다. 작은 수정이라도 흐름을 지키고 배포하는 게 장기적으로 시스템 신뢰도에 쌓이는 방식이라 생각하고 있다.
댓글 0
첫 댓글 달아줘.