레거시 인증/인가 시스템 Spring Security 4.2.5로 마이그레이션 하기
오랜만에 세션의 서늘한 감각 느끼기
여는 글
최근 워낙 많은 기업들이 해킹 이슈에 시달리고 있다. 그러다 보니 이중망이 구성된 우리 회사도 보안 취약점 점검을 실시했고, 가맹점 사이트에 대해 여러 취약점이 발견되었다. 사실, 알고 있던 사실들이었다. 우선순위 보완점이 아니었을 뿐. 하지만, 가맹점 사이트의 인증/인가는 언젠가 꼭 내 손으로 뜯어고치리라 다짐했었다.
기존 우리 가맹점 사이트의 인증/인가 방식을 들으면 놀랄 것이다. Spring Security, JWT는 일절 없다. Session 방식으로 검증하는 것은 맞으나, Session 관리 DB 테이블도 없었고 SessionInterceptor 코드 하나에 의존하고 있었다. WebMVC 설정도 부실했고, 심지어 세션 타임아웃 시간을 클라이언트(브라우저)에서 핸들링하고 있었다.
물론 기술적으로 Interceptor를 무시하는 건 아니다. 그동안 방충망과 같은 역할은 너무 잘해주고 있었기 때문이다. 하지만 구멍 난 방충망의 구멍을 메우는 것도 중요하지만, 좀 더 내구도 좋은 방충망으로 교체할 시기가 되었다. 정적 리소스 접근 문제, 파라미터 변조 가능성, 사용자 경험을 해치는 세션 만료 처리 등 바로잡을 필요가 있었다.
모든 걸 갈아엎을 계획은 아니었다. 우리 시스템은 별도의 미들웨어를 사용하지도 않고, 사용할 계획도 없고(사용할 수도 없고), 레거시 시스템에서는 항상 주어진 것에 감사하며 최선을 다해야 한다ㅜ.
내 계획은 이렇다. Spring Security를 통한 세션 인증/인가 관리. DB 테이블 추가 없이, 애플리케이션 계층에서만 쇼부(?)를 보기로 했다.
마이그레이션 전: 레거시의 민낯
기존 아키텍처 분석
마이그레이션 전 시스템은 web.xml과 인터셉터에 의존하는 전형적인 레거시 구조였다.
graph TD
A[Request] --> B[DispatcherServlet]
B --> C{SessionInterceptor}
C -- 인증 실패 --> D[로그인 페이지]
C -- 인증 성공 --> E[AuthService]
E --> F[비밀번호 확인]
E --> G[Controller]
가장 큰 문제는 인증 로직이 비즈니스 로직과 섞여 있다는 점이었다. AuthService 안에서 IP 검사, 비밀번호 검사, 세션 생성을 모두 처리하고 있었고, 사용자가 정상적인 플로우를 거치지 않고 URL 직접 입력을 통해 접근하는 것(Forced Browsing)을 체계적으로 막기 어려웠다.
정적 리소스 정리
본격적인 Security 적용 전, 의외의 복병은 정적 리소스였다. 레거시 시스템의 단점은 어디서 사왔는지 모를 정적 페이지들이다. 안 쓰는 플러그인, 페이지, 에셋 등 용량만 차지하는 데드 리소스(Dead Resource)가 너무 많았다.
내가 입사하고 나서 스타일시트과 UI/UX 라이브러리들은 내가 다 다시만들었기 때문에, 지워가는 것에 두려움은 없었다. 틈이 날때마다 교체해두었으니까… 그래도, 한 땀 한 땀 지워가며 동작을 점검했고, 거의 400개의 폴더를 삭제했다. 데드 리소스가 400개 폴더라니.
파일 다운로드 보안 강화
Spring Security를 적용하면 정적 리소스 경로를 막게 되므로, 기존의 파일 다운로드 방식도 수정해야 했다. 기존에는 클라이언트에게 파일의 실제 경로를 그대로 노출하고 location.href로 다운로드를 제공했다.
이를 백엔드로 옮기는 작업을 진행했다.
- 레거시 라이브러리를 뒤져 파일 경로를 반환하는 코드를 찾아냄.
- 실제 파일 경로 대신 임시 토큰을 생성하고,
(토큰, 파일경로)를 해시맵에 저장. - 클라이언트는 토큰만 가지고 다운로드 API를 호출.
- 서버가 토큰을 해석해 파일을 스트리밍.
이제 사용자는 실제 파일의 경로를 알 수 없게 되었다.
Spring Security 4.2.5 도입
기존 Spring 버전과의 호환성을 위해 기설치 되어있던 Spring Security 4.2.5를 선택했다.
마이그레이션 목표
| 항목 | 기존 (Legacy) | 목표 (Spring Security) |
|---|---|---|
| 인증 처리 | SessionInterceptor | Spring Security Filter Chain |
| 세션 관리 | 클라이언트 주도 / 자체 Session 객체 | SecurityContext로 대체 |
| 접근 제어 | 커스텀 어노테이션으로 Interceptor 계층에서 처리 | Spring Security 설정 |
Security Config 설정
web.xml에 DelegatingFilterProxy를 등록하고, Spring Security Config로 보안 설정을 잡았다. 기존에 커스텀 어노테이션으로 관리된던 세션 미검증 API Endpoint들을 antMatchers로 한곳에서 관리하게 되니 속이 다 시원했다.
SessionFilter: 경로 직접 입력 차단
이번 마이그레이션의 핵심 중 하나는 ‘정상적인 경로를 통하지 않은 접근 차단’이었다. Spring Security의 isAuthenticated()만으로는 “로그인은 했지만, 이 페이지에 정상적인 흐름으로 진입하지 않은” 사용자를 걸러내기 어려웠다.
여러 API가 @PathVariable이나 @RequestParam을 많이 사용하고 있었는데, URL 직접 입력 또는 프록시 툴을 통한 파라미터 변조 접근이 가능했는데,
이런 직접 입력 방지를 위해 커스텀 SessionFilter를 구현하여 Security Filter Chain 뒷단(FilterSecurityInterceptor 뒤)에 배치했다. 이 필터는 Referer 헤더를 체크하여 이전 경로가 없거나 외부에서 직접 접근(URL을 직접 입력 등)한 경우를 차단한다. 덕분에 비즈니스 로직에 붙어있던 검증 코드를 필터 계층으로 깔끔하게 격리할 수 있었다.
세션 객체 마이그레이션: 레거시 JSP 호환
또 다른 고민은 “기존 JSP 페이지들의 세션 의존성을 어떻게 처리할 것인가”였다. 페이지 곳곳에서 ${SESSION.userId}, ${SESSION.grade} 같은 방식으로 세션 객체를 참조하고 있었기에, 이를 모두 SecurityContextHolder로 바꾸려면 프론트엔드 대공사가 필요했다.
해결책은 기존 세션 객체를 Spring Security의 인증 객체로 승격시키는 것이었다. 커스텀 세션 객체가 UserDetails를 상속받도록 마이그레이션하여, Authentication.getPrincipal()로 사용할 수 있게 만들었다.
그리고 앞서 구현한 SessionFilter에 레거시 호환 로직을 추가했다.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof CustomUser) {
CustomUser user = (CustomUser) auth.getPrincipal();
httpRequest.setAttribute("SESSION", user);
}
이렇게 하면 하나의 객체가 Spring Security 인증 객체이면서 동시에 기존 JSP가 참조하는 SESSION 역할을 겸하게 된다. 기존 JSP를 수정하지 않고, 보안 계층만 Spring Security로 교체하는 데 성공했다.
배포 전략: 마이크로 리팩토링과 그랜드 리팩토링의 공존
이번 마이그레이션은 흥미로운 점이 있었다. 개발 환경과 운영 환경에서 서로 다른 리팩토링 전략이 적용되었다는 것이다.
개발 서버: 마이크로 리팩토링
개발 서버에서는 전형적인 마이크로 리팩토링 방식을 따랐다.
SecurityConfig 수정 → 테스트 → SessionFilter 추가 → 테스트 → JSP 호환 로직 → 테스트 → ...
작은 단위로 수정하고, 바로 검증하고, 문제가 있으면 수정하고 재검증하는 사이클을 반복했다. Filter Chain 순서를 바꿔보기도 하고, Spring Security Config에서 할 수 있는 설정들을 테스트 해보기도 하고, antMatchers 패턴을 조정해보기도 하면서 점진적으로 안정화해 나갔다.
운영 서버: 그랜드 리팩토링
하지만 운영 서버는 상황이 달랐다. 무중단 배포가 구성되어 있지 않았기 때문이다.
운영 서버에 마이크로하게 조금씩 반영하려면 매번 서비스를 중단해야 했다. “SecurityConfig만 먼저 올리고, 다음에 Filter 올리고, 그 다음에 JSP 호환 로직 올리고…” 이런 식으로 진행하면 가맹점 서비스가 하루에도 몇 번씩 중단되는 상황이 발생한다. 현실적으로 불가능했다.
결국 운영 서버 입장에서는 개발 서버에서 충분히 검증된 전체 변경사항을 한 번에 반영하는 그랜드 리팩토링 방식을 택할 수밖에 없었다.
두 전략의 공존
| 환경 | 전략 | 이유 |
|---|---|---|
| 개발 서버 | 마이크로 리팩토링 | 즉시 재시작 가능, 빠른 피드백 루프 |
| 운영 서버 | 그랜드 리팩토링 | 무중단 배포 미지원, 서비스 중단 최소화 필요 |
이 경험을 통해 느낀 점은, 리팩토링 전략은 코드의 복잡도만으로 결정되는 게 아니라 인프라 환경에도 크게 좌우된다는 것이다. 무중단 배포가 갖춰져 있었다면 운영 서버에서도 마이크로하게 반영하며 리스크를 분산시킬 수 있었을 것이다.
다행히 개발 서버에서 충분한 검증을 거쳤기에 운영 반영은 무사히 마쳤지만, 이후 무중단 배포 환경 구축의 필요성을 다시 한번 체감하게 된 계기가 되었다.
트러블슈팅 : Mixed Content 에러
하지만, Spring Security로 마이그레이션 후 모든게 완벽했던 것은 아니다. 마이그레이션 후 발생한 트러블 슈팅에 대해 궁금하다면 요링크 클릭!
마이그레이션 결과
Before & After
변경 전:
- 클라이언트가 세션 시간을 체크하다 보니 조작이 쉬움.
- 정적 리소스 폴더 경로만 알면 모든 파일에 아무나 접근 가능.
- 사용자가 URL을 직접 입력해 비정상적인 경로로 진입 가능.
- 세션 만료 시 안내 메세지 없이 기능 먹통.
변경 후:
- 서버 주도 세션 관리: 타임아웃, 중복 로그인 방지가 서버에서 확실하게 제어됨.
- 표준화된 보안: 인증/인가가
SecurityConfig한 곳에서 관리됨. - 안전한 파일 다운로드: 실제 경로 은닉.
- UX를 고려한 에러 페이지: 403(권한 없음), 404(찾을 수 없음), 500(서버 에러) 상황별로 친절한 안내 문구가 담긴 커스텀 에러 페이지를 적용하여 사용자 경험을 개선함.
- 클린 아키텍처: 400개에 달하는 불필요한 폴더 삭제 및 인증 로직 분리.
맺는 글
Interceptor 하나에 의존하던 시스템을 Spring Security마이그레이션 하고 나니, 너무 개운하다.
특히 이번 작업이 즐거웠던 건, 오랜만에 Spring Security의 설정들을 깊이 들여다볼 수 있는 기회였기 때문이다. 인증/인가는 모든 서비스의 기반이 되는 로직이고 기초 단계라 이미 잘 돌아가고 있다면 건들지 않는 것이 정설인데, 이렇게 다시금 바닥부터 하나하나 쌓아 올리며 Filter 동작 원리나 Provider 구조를 다시 떠올리며 마이그레이션 하는 과정 자체가 꽤 재밌었다.
문득 취준생 시절이 떠오르기도 했다. 그때는 설정도 잘 모르면서, 왜 필요한지도 모른 채 무작정 따라 치며 공부했었는데, 이제는 우리 회사 레거시 시스템에 어떤 설정이 왜 필요한지를 이해하며 적용하고 있는 나를 보며 묘한 격세지감을 느꼈다.
비록 DB 테이블 없이 어플리케이션 메모리/세션 기반으로 처리했기에 완벽한 Stateless는 아니지만, 주어진 환경(No Middleware, No DB Schema Change) 안에서는 최선의 방어책을 구축했다고 생각한다. 이제 다음 보안점검 때는 조금 더 당당하게 화면을 보여줄 수 있을 것 같다.