INTRO
Spring Framework 4에서 Spring Boot 3으로 마이그레이션하는 고도화 프로젝트 중
OKTA SAML 인증 연동을 붙이고 테스트 서버에 배포했다.
설레는 마음으로 로그인을 시도했는데, 화면이 계속 새로고침되며 멈추지 않았다.
처음에는 "배포를 잘못했나?" 싶어서 서버를 재시작해봤고, 로그를 다시 확인해봤다. 그런데 아무리 봐도 배포 자체는 정상이었다.
Fiddler 네트워크 로그를 열어서 트랜잭션 흐름을 직접 추적해보고 나서야 원인이 보였다.
백엔드에서 터진 HTTP 500 에러와, 그 에러 상황을 전혀 고려하지 않은 프론트엔드 리다이렉트 로직이 서로 맞물려 만들어낸
그야말로 환장의 콜라보였다.
처음에는 백엔드 문제라고 확신했는데, 백엔드를 고쳐도 루프는 계속됐다.
두 레이어를 동시에 수정하고 나서야 루프를 끊을 수 있었다.

문제 상황
처음에 Fiddler 로그를 펼쳐놨을 때는 솔직히 어디서부터 봐야 할지 막막했다.
요청이 수십 개 쌓여 있었고, 다 비슷하게 생긴 리다이렉트가 반복되고 있었다.
하나씩 따라가다 보니 아래처럼 5단계가 계속 순환하는 패턴이 보였다.

무한 루프 흐름도
- 로그인 페이지 진입 → 로그인 페이지 JS가 무조건 OKTA SAML 인증 URL로 리다이렉트
- OKTA 서버에서 아이디/비밀번호 인증 성공 → SAML 콜백 엔드포인트로 POST
- LoginController가 콜백을 수신해 처리하던 중 HTTP 500 에러 발생 (SAML 파싱 오류)
- Tomcat 에러 페이지가 렌더링되고, 해당 에러 페이지에 포함된 공통 스크립트가 로드되면서 인덱스 페이지로 302 리다이렉트
- 인덱스 페이지는 세션이 없으므로 다시 로그인 페이지로 이동 → 1번으로 복귀
흐름을 정리하고 나서 처음 든 생각은 "3번에서 500이 나는 게 문제다. 저걸 잡으면 되겠다"였다.
백엔드 개발자니까 서버 에러부터 고치면 된다는 생각이 자연스럽게 들었다.
그런데 그게 아니었다. 1번 단계의 자바스크립트는 에러 여부를 따지지 않고 무조건 OKTA로 튕겨냈다.
백엔드에서 500이 나든 302가 나든, 결국 다시 로그인 페이지로 돌아오면 JS가 또 OKTA로 보내버리는 구조였다.
백엔드 에러를 아무리 잡아도 프론트엔드가 루프를 계속 만들고 있었던 것이다.
더불어, 기존 코드를 보다가 예외 처리 리다이렉트 경로가 이렇게 되어 있다는 것도 눈에 걸렸다.
// 기존 코드: .jsp 확장자를 직접 지정
return "redirect:/login.jsp?error=true";
Spring MVC를 공부하면서 ViewResolver가 뷰 이름을 실제 경로로 변환해준다는 건 알고 있었는데
이렇게 직접 .jsp로 리다이렉트하면 ViewResolver를 아예 우회한다는 것은 그때 처음 제대로 인식했다.
Model에 세팅해야 할 기본값들이 전부 날아가버려서 빈 화면이나 404가 뜰 수 있는 상태였다.
1번째 시도: 백엔드 try-catch만 적용 (실패)

시도한 이유
"서버에서 500이 안 나면, 에러 페이지가 렌더링되지 않고
에러 페이지 안에 있는 공통 스크립트도 실행되지 않을 것이다.
그러면 루프가 끊기지 않을까?" 라는 논리였다. 콜백 핸들러에 try-catch를 감싸서 예외를 잡고
에러 코드를 파라미터로 담아 명시적으로 로그인 페이지로 리다이렉트했다.
백엔드 개발자로서 할 수 있는 가장 직관적인 대응이었고
이 정도면 해결되지 않을까 생각했다.
적용 방법
@PostMapping("/saml-callback")
public String samlCallback(HttpServletRequest request, Model model) {
try {
// SAML Assertion 파싱 및 사용자 검증 로직
return "redirect:/main";
} catch (Exception e) {
logger.error("[OKTA_ERROR] SAML 인증 콜백 처리 중 오류 발생:", e);
return "redirect:/login?error=SAML_PARSE_FAILED";
}
}
문제점/한계
배포하고 다시 테스트했는데 루프는 그대로였다. 로그를 보니 이번엔 500 대신 302가 찍혔는데
결국 로그인 페이지로 이동하자마자 JS가 다시 OKTA로 튕겨버렸다.
"분명히 에러를 잡았는데 왜 여전히 루프가 도는 거지?"라는 생각이 들었다.
그때 다시 Fiddler 로그를 열어서 흐름을 처음부터 다시 봤다.
서버는 분명 302를 잘 보내고 있었다.
문제는 로그인 페이지 JS가 error 파라미터가 있는지 없는지 확인 자체를 하지 않는다는 것이었다.
에러가 붙어서 돌아오든, 정상으로 돌아오든 무조건 OKTA로 리다이렉트하는 코드가 거기 있었다.
백엔드를 아무리 고쳐봤자 소용없는 구조였다. 이 순간 "아, 이건 내가 담당하는 레이어만의 문제가 아니구나"를 체감했다.
배운 점: 서버에서 에러를 우아하게 처리하는 것과, 사용자 흐름의 루프를 실제로 차단하는 것은 다른 문제다.
백엔드만 고치면 된다는 생각이 얼마나 단편적이었는지 느꼈다.
버그를 분석할 때는 내가 담당하는 레이어가 아니라, 요청이 흘러가는 전체 경로를 먼저 그려봐야 한다.
최종 해결: 백엔드 예외 처리 + 프론트엔드 루프 차단 (2중 방어)

루프를 끊으려면 두 레이어를 반드시 함께 수정해야 한다는 것을 파악했다.
그런데 막상 수정 방향을 잡고 나니 또 다른 걱정이 생겼다.
"내가 고치려는 방식이 혹시 다른 곳을 망가뜨리지 않을까?"
신입으로서 가장 무서운 순간이 이런 때다.
특히 공통 스크립트나 에러 처리 로직은 전체 시스템에 영향을 줄 수 있어서 섣불리 건드리기가 꺼려졌다.
그래서 수정 전에 예상되는 사이드 이펙트를 먼저 정리해보고, 각각에 대한 대응책을 같이 준비했다.
해결책 1: 백엔드 예외 방어 (에러 은폐 방지 포함)
콜백 처리 중 예외가 발생하면 에러 코드를 담아 로그인 페이지로 리다이렉트한다.
그런데 여기서 처음에는 단순하게 catch로 잡고 302로 보내면 된다고 생각했다.
그러다가 "이렇게 하면 운영 중에 에러가 나도 500 알람이 안 뜨는 거 아닌가?" 하는 의문이 들었다.
실제로 그랬다.
try-catch로 모든 예외를 잡아버리면 APM 모니터링 도구 입장에서는 정상 응답처럼 보인다.
운영 중 SAML 파싱이 계속 실패하고 있어도, 담당자가 아무도 인지를 못하게 되는 것이다.
화면을 깔끔하게 처리하려다가 오히려 장애 대응 체계를 무력화할 뻔했다.
그래서 화면 흐름은 302로 처리하되, 서버 로그에는 반드시 전체 스택 트레이스를 남기는 방식으로 보완했다.
// LoginController.java
@PostMapping("/samlCallback")
public String samlCallback(HttpServletRequest request, Model model) {
try {
// SAML Assertion 파싱 및 사용자 검증 로직
return "redirect:/main";
} catch (Exception e) {
// [핵심] 화면 흐름은 302로 제어하되, 서버 로그에는 스택 트레이스를 반드시 남긴다
// → APM 모니터링 체계를 무력화하지 않기 위한 방어 코딩
logger.error("[OKTA_ERROR] SAML 인증 콜백 처리 중 치명적 오류 발생:", e);
return "redirect:/login?error=SAML_PARSE_FAILED";
}
}
기존의 .jsp 직접 리다이렉트도 이 기회에 함께 수정했다.
앞서 문제 상황에서 발견했던 부분인데, 단독으로는 루프와 직접 연결된 버그는 아니었지만
언제든 빈 화면이 뜰 수 있는 잠재적인 결함이었다.
발견한 김에 같이 고치는 게 맞다고 판단했다.
// AS-IS: ViewResolver를 우회, Model 데이터 누락 위험
return "redirect:/login.jsp?error=true";
// TO-BE: ViewResolver를 정상 경유
return "redirect:/login?error=true";
해결책 2: 프론트엔드 루프 차단 (화이트 스크린 방지 포함)
로그인 페이지 JSP에서 OKTA 자동 리다이렉트를 실행하기 전
URL에 error 파라미터가 있는지 먼저 확인하도록 분기를 추가했다.
그런데 여기서도 단순하게 "에러 있으면 리다이렉트 스크립트 멈추면 되겠네" 라고 생각했다가
한 가지를 놓쳤다는 걸 깨달았다.
OKTA 전용 로그인 페이지에는 ID/PW 입력 폼 자체가 없다.
그러니 리다이렉트 스크립트만 멈춰버리면 사용자는 에러 알림을 확인하고 나서
아무것도 없는 빈 화면에 갇혀버리게 된다.

사용자 입장에서는 "내가 뭘 해야 하지?" 가 되는 상황이다.
기능 구현에만 집중하다 보면 이런 UX적인 부분을 놓치기 쉬운데
수정하기 전에 직접 시나리오를 따라가 본 게 도움이 됐다.
에러 발생 시 안내 팝업을 띄운 뒤
지정된 서비스 안내 페이지로 Fallback 처리하는 방식으로 대응했다.
// login.jsp: OKTA 자동 리다이렉트 스크립트 (수정 후)
$(document).ready(function() {
var errorParam = new URLSearchParams(window.location.search).get('error');
if (errorParam) {
// [핵심] 루프를 멈추되, 사용자가 빈 화면에 갇히지 않도록 Fallback 처리
alert("로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
location.href = '/notice';
return; // OKTA 리다이렉트 중단
}
// 정상 흐름: OKTA SAML 인증 URL로 리다이렉트
location.href = "${oktaSamlAuthUrl}";
});
최종 흐름 정리

[정상 흐름]
로그인 페이지 → OKTA 인증 → SAML 콜백 처리 → 메인 페이지
[에러 발생 시 흐름 (수정 후)]
로그인 페이지 → OKTA 인증 → SAML 콜백 처리
→ (Exception 발생) → 서버 로그 기록 → 로그인 페이지?error=SAML_PARSE_FAILED
→ (error 파라미터 감지) → 안내 팝업 → 서비스 안내 페이지
[루프 차단됨]
배운 점

- 복합 버그는 레이어 하나만 고쳐서는 해결되지 않는다.
백엔드 500 에러와 프론트엔드 무조건 리다이렉트 로직이 맞물리면, 각각은 정상처럼 보여도 합쳐지면 무한 루프를 만든다.
버그를 분석할 때 "내 담당 레이어"에만 집중하는 것이 얼마나 좁은 시야인지 이번에 실감했다.
요청 흐름 전체를 먼저 그리는 습관을 들여야 한다. - try-catch로 예외를 잡아 사용자 화면을 깔끔하게 처리하는 것과, 서버 로그에 에러를 남기는 것은 반드시 동시에 해야 한다.
처음에는 "어차피 에러 페이지 보여주면 되지"라고 단순하게 생각했는데, 그 판단이 운영 중 장애를 아무도 인지하지 못하게 만드는 함정이었다. - 기능이 동작하는지 확인하는 것과, 사용자가 에러 상황에서 갇히지 않는지 확인하는 것은 다른 테스트다.
수정 후 반드시 에러 시나리오를 직접 따라가 보는 것이 중요하다.
빈 화면에 방치되는 사용자를 만들지 않으려면, 정상 흐름만큼 에러 흐름도 꼼꼼하게 설계해야 한다.