
주문 상세 API가 권한을 확인하려고 인증 서버를 부릅니다. 배포 중이던 인증 서버가 몇 초 동안 응답하지 않습니다. 권한 검사 코드는 예외를 잡아 경고 로그를 한 줄 남기고, 요청을 다음 단계로 보냅니다. 그 몇 초 동안 주문 상세는 권한 검사 없이 열려 있었습니다. 기능 테스트는 모두 통과했고, 오류 화면도 없었습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 9-2

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| 실패하면 통과(fail-open) | 인증·인가·검증 코드의 catch 블록 | 검사가 실패하면 거부가 기본값 | A10 |
| 규칙에 없는 경로가 열림 | 보안 설정의 마지막 줄 | 열어 둘 경로만 적고 나머지는 거부 | A01·A10 |
| 예외를 삼킴 | 빈 catch, 빈 목록·null 반환 | 좁게 잡고, 못 고치면 다시 던진다 | A10 |
| 끝없이 기다림 | 타임아웃을 정하지 않은 외부 호출 | 연결·응답 타임아웃을 직접 정한다 | A10 |
OWASP Top 10:2025는 예외 상황 처리 실패(Mishandling of Exceptional Conditions)를 A10으로 새로 넣었습니다. 이 글은 그중 보안 검사가 실패할 때의 처리만 다룹니다.
fail-open(검사가 실패하면 통과시키는 처리)은 "인증 서버가 잠깐 멈췄다고 서비스 전체를 멈출 수는 없다"는 판단에서 생기기 쉽습니다. 그 판단이 catch 블록 안의 return true 한 줄이 됩니다. 정상일 때는 아무 차이가 없으니 테스트에서 드러나지 않습니다. 의존 서비스가 실패하는 순간에만 문이 열립니다.
찾을 곳은 정해져 있습니다. 인증·인가, 서명 검증, 속도 제한, 차단 목록 조회처럼 "통과 여부"를 정하는 코드의 catch 블록입니다. 그 안에서 true, 빈 목록, null, 기본 권한을 돌려주는 곳이 후보입니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다. 위험한 모양입니다.
public boolean canRead(String userId, long orderId) {
try {
return authClient.check(userId, orderId);
} catch (AuthServiceException e) {
log.warn("auth check failed, allowing", e);
return true; // 실패가 통과가 된다
}
}
인증·인가 패키지에서 catch 블록을 모아 읽고, 블록 안에서 통과로 이어지는 반환값이 있는지 표로 정리합니다. 다음 절의 실패 주입 테스트가 이 목록을 그대로 확인합니다.
fail-closed(검사가 실패하면 거부하는 처리)는 기본값을 바꾸는 일입니다. 검사를 끝내지 못했으면 허락하지 않은 것으로 봅니다. 대가도 있습니다. 인증 서버가 멈추면 그 서버에 기대는 기능도 같이 멈춥니다. 09편의 Cloudflare 2025-09 장애에서는 Tenant Service가 API 인가 경로에 있어 API 전반이 영향을 받았습니다. 그래서 열어 둘 곳은 코드 곳곳의 catch가 아니라 설정 한곳에 따로 적습니다.
고친 모양입니다.
} catch (AuthServiceException e) {
log.warn("auth check failed: user={} order={}", userId, orderId);
throw new AccessDeniedException("auth check failed"); // Spring Security 예외 → 403
}
경로 규칙도 같은 원칙을 따릅니다. 공개할 경로만 적고, 마지막 줄은 거부로 둡니다.
http.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.GET, "/products/**").permitAll() // 열어 둘 곳은 명시
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()); // 규칙에 없으면 거부
강의에서 만드는 방어 부품 예시(secure-kit)도 같은 규칙을 씁니다. 부품 내부 오류(예: 속도 제한에 쓰는 Redis 연결 실패)가 나면 기본은 막기이고, 풀려면 설정에 명시해야 합니다. 메서드에는 @FailClosed 한 줄로 붙입니다.
인증 클라이언트가 예외를 던지게 만들고 응답이 403인지 봅니다.
@Test
void authServerDown_isDenied() throws Exception {
given(authClient.check(any(), anyLong())).willThrow(new AuthServiceException("FAKE-down"));
mvc.perform(get("/api/orders/1001").with(user("alice")))
.andExpect(status().isForbidden());
}
return true가 없어도 문은 열립니다. 차단 목록을 읽다 실패했을 때 빈 목록을 쓰면 "막을 사람이 없다"는 뜻이 됩니다. 사용자 권한을 못 읽었을 때 null을 돌려주면, 다음 코드가 null을 "제한 없음"으로 읽을 수 있습니다. 예외를 잡아 아무것도 하지 않는 빈 catch는 실패 자체를 지워서, 로그와 경보에도 흔적이 남지 않습니다.
위험한 모양입니다.
Set<String> blocked;
try { blocked = blocklistClient.fetch(); }
catch (Exception e) { blocked = Set.of(); } // 목록을 못 읽으면 아무도 막지 않는다
고친 모양은 처리할 수 있는 예외만 좁게 잡고, 처리하지 못하면 다시 던집니다.
Set<String> blocked;
try {
blocked = blocklistClient.fetch();
} catch (BlocklistUnavailableException e) {
securityEvents.record("blocklist.unavailable", e.getMessage()); // 경보로 이어지는 기록
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "blocklist unavailable");
}
응답 본문에는 스택 추적을 넣지 않습니다. 오류 내용은 서버 로그와 보안 이벤트에만 남깁니다.
정적 분석 규칙이나 코드 검토로 빈 catch와 catch (Exception e) 뒤의 기본값 반환을 찾습니다. 차단 목록 조회를 실패시킨 테스트에서 503과 보안 이벤트 한 건이 나오는지 봅니다.
외부 호출에 타임아웃을 정하지 않으면 실패가 늦게 오거나 오지 않습니다. 인증 서버가 응답 없이 연결만 붙잡고 있으면 요청 스레드가 하나씩 묶이고, 기다리는 요청이 쌓여 서비스가 멈춥니다. 실패를 거부로 바꾸는 코드도 실패가 와야 동작합니다. 클라이언트마다 기본값이 다르니, 확인하지 않고 쓰면 얼마나 기다리는지 모르는 채로 배포됩니다.
@Bean
RestClient authClient(RestClient.Builder builder) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofMillis(300)); // 예시 값
factory.setReadTimeout(Duration.ofSeconds(1)); // 예시 값
return builder.baseUrl("https://auth.example.com")
.requestFactory(factory)
.build();
}
타임아웃이 나면 2절의 catch로 들어가 거부됩니다. 여러 계층이 이어진 호출이라면 09편처럼 바깥 계층의 타임아웃을 더 길게 두고, 재시도는 한 계층에서만 합니다.
테스트용 인증 서버 스텁에 응답 지연을 넣고, 정한 시간 안에 403이 돌아오는지 봅니다.
true, 빈 목록, null, 기본 권한을 돌려주지 않는다fail-open은 의존 서비스가 실패하는 순간에만 드러나서 기능 테스트로는 잡히지 않습니다. 검사를 끝내지 못했으면 거부하고, 열어 둘 곳은 설정 한곳에 적습니다. 예외는 좁게 잡고 처리하지 못하면 다시 던지며, 외부 호출에는 타임아웃을 직접 정합니다. 실패를 주입한 테스트 하나가 이 네 가지를 함께 확인합니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다.
이전 글 7개월을 줄이는 기록: 감사 로그, 대량 조회 탐지, 마스킹, 지울 수 없는 로그
다음 글 운영 권한은 필요할 때만: 공용 계정과 상시 권한 없애기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록