
1차 프로젝트를 마치고 얼마되지 않아 바로 2차 프로젝트를 시작하게 되었다. 1차완 다르게 2차부터는 직접 주제를 정하고 기획을 했어야 했다. 그렇기에 팀장의 자리는 너무나 부담이 되어 피하고 싶었지만..이런 당첨되고 말았다. 그래도 어찌하리 막중한 책임감을 갖고 열심히 프로젝트를 이끌려고 했다. 그렇게 우리는 주제선정을 위해 시간을 가졌고 내가 구상한 주제와 팀원의 주제의 치열한 경쟁 끝에 데브코스 2차프로젝트로 개발자를 위한 채팅 서비스을 하게 되었다. 솔직히 처음엔 마음에 들지 않았다. 그때 나에겐 채팅이란 다 비슷비슷한 서비스라는 인식이 있었던 것 같다. 나중에 프로젝트 회고에서 다시 한 번 말할 것 같지만, 지금은 너무나 애정이 있는 프로젝트이다.
프로젝트의 목표는 협업중 깃허브에서의 코드리뷰보다 가볍고 쉬운 소통을 도와주는 채팅프로그램이었다.
그렇게 주제선정을 하고 각자 역할분담을 했다. 나의 첫 역할은 회원가입/로그인/마이페이지였다. 즉 유저와 관련한 기능을 맡게되었다. 난 이게 금방 끝날 줄 알았다..인증에 늪에 빠질줄은..
그러던 어느날 우리 팀에겐 큰 시련이 닥쳐왔다. 선정된 주제를 제안하신 팀원의 데브코스 하차 소식.. 개인사정이라고 하셔서 자세히 여쭤보진 않았다. 그건 그렇고 그분이 담당하신 부분은 깃허브 이벤트 알림 기능인 WebHook부분이었다. 우리팀은 일단 WebHook부분은 제쳐두고 채팅방기능을 완성한 뒤에 구현하는 것으로 합의했다. 이것이 나의 첫 팀장으로서의 시련이 아니었나싶다. 지금 생각해보니 이때부터 작업량에 대한 집착이 생긴 것 같기도 하다.
그렇게 우리는 작업을 시작하게 되었고, 나로썬 첫 주도적인 프로젝트의 시작이었다. 아래는 초반 개발을 하면서 마주했던 문제들의 극복과정의 일부 정리했다. 트러블 슈팅은 앞으로 몇개 더 업로드 해볼 생각이다.
참고로 이 프로젝트의 이름은 DevChat이다. 로그인 화면은 디자인하는 과정에서 임시로 작명한거였는데, 의외로 팀원들의 반응이 좋아서 그대로 채택되었다..너무 흔한것 같긴 하다만..
로그인 시 존재하지 않는 이메일로 접근하면, MemberService에서 커스텀 예외를 던지도록 구현했는데, Spring Security가 이를 인증 실패로 간주하지 않고 전역 예외로 처리했다.
결과적으로 AuthenticationFailureHandler가 호출되지 않고 예외가 앱 전체로 퍼지는 문제가 발생했다.
AuthenticationManager는 기본적으로 AuthenticationException을 상속받는 UsernameNotFoundException 같은 인증 관련 예외만 “정상적인 인증 실패”로 처리한다. 그러나 내가 정의한 커스텀 예외 AuthenticationException을 상속받이 않았기에 인증 실패가 아닌 시스템 예외로 간주되어 필터 체인을 벗어났다.
MemberService에서 유저를 찾을 수 없는 경우 기존 커스텀 예외가 아닌 UsernameNotFoundException을 던지도록 수정:
.orElseThrow(() -> new UsernameNotFoundException("이메일을 찾을 수 없습니다"));
이로써 Spring Security가 정상적으로 로그인 실패로 인식하고 AuthenticationFailureHandler를 타게 됨.
Spring Security의 인증 흐름을 더 깊이 들여다보면, ExceptionTranslationFilter가 AuthenticationException 및 AccessDeniedException만 처리하고, 나머지 예외는 그대로 전파된다는 구조를 가진다. 따라서 커스텀 예외를 만들더라도, 그것이 AuthenticationException을 상속받지 않는다면 해당 필터에서 잡히지 않아 인증 실패 흐름이 아닌 전역 예외 처리 흐름으로 넘어간다는 점을 알게 되었다.
이 전까진 예외의 의도 정도만 생각하고 개발을 해왔다. 그러나 이번 경험을 통해서 예외의 의도뿐 아니라 상속 구조, 그리고 프레임워크 내부의 처리 흐름에 대한 이해의 중요성을 체감하게 되었다.
프론트엔드(React)에서 백엔드(Spring Boot)로 API 요청을 보낼 때, 브라우저 콘솔에 CORS 관련 에러가 발생하며 요청이 차단되었다.
분명 요청 URL과 포트도 맞췄고, 서버도 정상적으로 동작 중이었는데, 클라이언트 쪽에서 아예 응답 자체를 받지 못하는 상황이었다.
Spring에서는 CORS 문제가 일어날 수 있는 곳은MVC단과 Security단이다.
DispatcherServlet을 통해 MVC에의해 처리되고,문제는 브라우저의 Preflight 요청(OPTIONS 메서드) 때문이다. 이 요청은 실제 요청 전에 브라우저가 서버의 CORS 정책을 확인하기 위해 보내는 것으로, Security를 거져야하는 요청이면 Security가 응답을 한다. 이때 WebMvcConfigurer에만 CORS 설정을 해둔 상태였기 때문에, 브라우저가 CORS 에러를 발생시킨 것이다.
즉, CORS 설정을 WebMvc 단에만 한 것은 MVC에서만 유효하고, Security를 통과하는 요청에는 전혀 영향을 주지 못한다는 것을 놓쳤던 것이 문제의 본질이었다.
SecurityFilterChain 내부에 다음과 같이 cors() 설정을 추가해 Spring Security가 CORS 요청을 허용하도록 수정했다:
http.cors(Customizer.withDefaults());
그리고 별도로 작성한 WebConfig 클래스에서 다음과 같이 CORS 정책을 명시적으로 설정하여 클라이언트에서의 요청을 허용했다:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}
이 두 설정이 함께 작동하면서 정상적으로 요청이 허용되었고, 클라이언트도 응답을 받을 수 있게 되었다.
이 문제를 통해 웹의 요청/응답 흐름이 생각보다 더 복잡한 구조와 메커니즘을 가지고 있다는 것을 알게 되었다. 특히 Spring에서는 요청이 Security와 MVC 두 레이어로 나뉘어 처리되고, 같은 설정(CORS)이라도 어느 레이어에서 적용되는지가 중요하다는 점을 명확히 느낄 수 있었다. 이번 경험을 계기로 웹 통신 구조나 브라우저의 동작 방식(Preflight 요청 등)에 대한 이해를 더 깊게 할 필요성을 느꼈다. 단순히 문제가 발생했을 때 설정만 고치는 것이 아니라, 그 이유와 내부 흐름까지 이해하는 자세가 중요하다는 것을 다시 한번 깨닫게 되었다.
사용자 정보(예: 닉네임, 프로필 이미지 등)를 수정한 뒤에도, 프론트엔드에서 호출하는 "내 정보 조회 API" 응답 값이 여전히 수정 전 데이터를 반환했다.
회원정보를 조회하는 API의 메소드는 아래와 같았는데
public MemberResponse getMemberDetails(Authentication auth) {
MemberDetails member = (MemberDetails) auth.getPrincipal();
return MemberDetails.toResponse(member);
}
Spring Security는 로그인 시 UserDetails 객체를 기반으로 Authentication을 생성하고, 이 객체를 SecurityContext에 저장하여 세션 단위로 인증 상태를 유지한다.
이 구조에서는 로그인 당시 생성된 MemberDetails 인스턴스가 Authentication.getPrincipal()에 그대로 담겨 있으며, 이후 사용자 정보가 DB에서 변경되더라도 기존 세션의 인증 객체는 자동으로 갱신되지 않는다. 그러므로 DM의 데이터를 수정했어도 인증객체는 로그인시의 유저정보를 여전히 담고 있어, 수정된 유저정보를 조회할 수 없었던 것 이다.
이 문제를 해결하기 위해 생각한 방법은 크게 두 가지다:
사용자 정보 수정 이후, 새로 생성한 MemberDetails로 인증 객체를 재구성하여 SecurityContext에 다시 설정하는 방식:
SecurityContextHolder.getContext().setAuthentication(newAuth);
이 방법이 세션 내 인증 정보와 사용자 정보가 항상 일치시키기에 깔끔하고, 성능에도 조금은 유리할 것 같았지만, 인증객체를 생성하는 로직을 따로 구성해야 하고 재인증 처리도 번거로울 수 있어서 이 방법은 선택하지 않았다.
Authentication.getPrincial()에서 사용자 ID만 추출한 뒤, DB에서 최신 사용자 정보를 조회해서 응답에 반영
public MemberResponse getMemberDetails(Authentication auth) {
MemberDetails loginMember = (MemberDetails) auth.getPrincipal();
Long memberId = loginMember.getId();
Member member = getMemberById(memberId);
return MemberMapper.toResponse(member);
}
나는 이 방식으로 구현했다. 인증 객체는 인증 상태 유지를 위해 그대로 두고, 실제 응답에 필요한 사용자 정보는 항상 최신 상태 기준으로 처리할 수 있도록 했다.
인증 객체를 직접 갱신하는 방법도 고려했지만, 구조가 복잡해지고 로직이 불필요하게 늘어나는 점이 부담스러웠다. 반면, DB에서 사용자 정보를 다시 조회하는 방식은 단순하고 변경된 내용을 바로 반영할 수 있어 더 명확하게 느껴졌다. 특히 닉네임이나 프로필처럼 보안과 직접 관련 없는 정보라면 굳이 인증 객체까지 건드릴 필요는 없다고 판단했다.
물론, 인가 정보가 DB에 존재하고 그 정보가 변경될 수 있었다면, 인증 객체와 DB 상태 간의 불일치로 인해 인가 로직에 문제가 생겼을 수도 있다. 하지만 당시에는 별도의 인가 시스템이 없는 상태였기 때문에, 이렇게 간단하게 해결하는 방식이 더 적절하다고 판단했다.
추가적인 성능 이슈에 대해서는, 향후 캐싱 등을 통해 충분히 개선할 수 있다고 보았고, 현재 서비스 구조와 요구 수준에서는 이 방식이 가장 깔끔하고 안전한 선택이라고 생각했다.
이 문제를 마주하기 전까지 나는 Authentication 객체를 단순히 Spring Security가 사용하는 유저 정보 정도로만 생각하고 있었다. 하지만 실제로는 이 객체가 로그인 시점의 사용자 상태를 유지하는 일종의 스냅샷이라는 사실을 알게 되었다. 이러한 구조를 제대로 이해하지 못한 채, 사용자 정보를 수정하면 자동으로 Authentication 객체에도 반영될 것이라고 단순하게 생각했고, 변경 사항이 반영되지 않아 한참을 헤맸다.결국 내가 생각 없이 사용해오던 Spring Security의 구조, 특히 Authentication 객체의 역할과 동작 방식에 대해 처음부터 다시 이해하게 되었고, 그동안 기능 위주로만 개발해왔던 내 태도에 대해서도 반성하게 되었다. 이 경험을 통해 단순히 "작동한다"는 수준을 넘어서, Spring Security가 내부적으로 어떻게 인증 상태를 유지하고 처리하는지, 그 원리를 정확히 이해하는 것이 얼마나 중요한지 깊이 느낄 수 있었다.