우아한테크코스 레벨 3 팀 프로젝트 festabook에서 학습한 내용을 정리한 글입니다.
💭 들어가며
학생회 페이지는 자원 생성, 수정, 삭제 기능을 포함하고 있으므로 해당 리소스에 대한 인증과 인가가 필요하다. 이를 위해 학생회 API에 로그인 기능을 도입하였다. 또한 단순 JWT 사용 대신 Spring Security를 적용한 이유는 본문에 작성했다.
✅ Spring Security 구조

▶ JWT 로그인 과정
- 클라이언트가 JWT를 포함하여 서버에 요청을 보낸다.
- AuthenticationFilter가 요청을 가로채서 헤더에서 토큰을 추출하고, 토큰의 서명, 만료 시간 등을 검증한다.
- 토큰이 유효하다면, 토큰의 subject(일반적으로 ID 또는 username)를 꺼내 UserDetailsService에 전달한다.
- 서비스는 DB에서 사용자를 조회하여 Spring Security가 이해할 수 있는 형태의 UserDetails 객체를 반환한다.
- 이후 이 Authentication 객체를 SecurityContext에 저장한다.
→ 매 요청마다 이러한 방식으로 SecurityContext에 Authentication 주입한다.
▶ 의문점
1️⃣ SecurityContextHolder에 Authentication을 저장하는 이유가 무엇인가요?
SecurityContextHolder → Authentication → Principal에 객체(PrincipalDetails, Council 등)를 담는 이유는 결국 스프링 시큐리티 전역 컨텍스트에서 인증 주체(로그인한 사용자)를 꺼내 쓰기 위함이다.
- 컨트롤러나 서비스에서 @AuthenticationPrincipal로 편하게 주입받을 수 있도록 하기 위함
- 인증된 사용자 정보(아이디, 권한 등)를 매번 다시 DB 조회하지 않고 재사용하기 위함
@PostMapping
public AnnouncementResponse createAnnouncement(
@AuthenticationPrincipal CouncilDetails councilDetails,
@RequestBody AnnouncementRequest request
) {
return announcementService.createAnnouncement(councilDetails.getFestivalId(), request);
}
2️⃣ Spring Security의 구조 사진에서 특정 단계가 생략되는 이유는 무엇인가요?
현재 프로젝트는 전통적인 ID/PW 로그인 방식 대신 JWT 기반 인증 방식을 사용하기 때문이다. 해당 사진은 ID/PW 인증을 전제로 한 Spring Security 구조이다.
- ID/PW 로그인 (전통적인 방식): 사용자가 입력한 비밀번호를 검증해야 한다. 이 과정을 담당하는 요소가 AuthenticationManager와 AuthenticationProvider이며, 이들은 UserDetailsService를 통해 DB에 저장된 암호화된 비밀번호를 조회하고 사용자가 제출한 비밀번호와 비교하여 인증 성공 여부를 결정한다. 기존에는 ID/PW 기반의 전통적인 방식으로 로그인을 처리했다.
- JWT 방식: JWT는 서버가 이미 사용자의 신원을 성공적으로 인증한 후에 발급해 준 토큰이다. 따라서 요청에 포함된 JWT가 위조되지 않았고 유효기간이 지나지 않았다는 토큰 자체의 유효성만 검증하면 충분하다. 비밀번호를 다시 비교할 필요가 없으므로 AuthenticationManager의 역할이 필요 없어진다.
3️⃣ JWT만으로 충분해 보이는데, 왜 Spring Security를 함께 사용하나요?
- SecurityConfig에서 확인할 수 있듯이, 특정 URL은 누구나 접근 가능(permitAll), 특정 URL은 권한이 있는 사용자만 접근 가능(hasAuthority) 등 접근 제어 규칙을 쉽고 명확하게 설정할 수 있다.
- Spring 프레임워크의 다양한 기능(@AuthenticationPrincipal, 메서드 레벨 보안 @PreAuthorize 등)과 자연스럽게 연동된다. JWT만 단독으로 사용한다면 이러한 편의 기능을 모두 직접 구현해야 한다.
- 추후 OAuth2 소셜 로그인 같은 다른 인증 방식을 도입할 때도 Spring Security를 사용하면 최소한의 코드 변경으로 손쉽게 확장할 수 있다.
✅ 코드
▶ SecurityConfig (핵심)
SecurityConfig는 모든 Security 설정을 구성하는 가장 핵심 클래스이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final String[] SWAGGER_WHITELIST = {
"/api-docs/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-resources/**",
};
private static final String[] GET_WHITELIST = {
"/event-dates",
"/event-dates/*/events",
"/announcements",
"/places",
"/places/*",
"/places/previews",
"/places/geographies",
"/festivals",
"/festivals/universities",
"/festivals/geography",
"/lost-items",
"/questions"
};
private static final String[] POST_WHITELIST = {
"/devices",
"/festivals/*/notifications",
"/places/*/favorites",
"/councils/login",
"/councils" // TODO: ADMIN 생성 시 삭제
};
private static final String[] DELETE_WHITELIST = {
"/festivals/notifications/*",
"/places/favorites/*"
};
private final CorsFilter corsFilter;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
defaultSecuritySetting(http);
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(SWAGGER_WHITELIST).permitAll()
.requestMatchers(HttpMethod.GET, GET_WHITELIST).permitAll()
.requestMatchers(HttpMethod.POST, POST_WHITELIST).permitAll()
.requestMatchers(HttpMethod.DELETE, DELETE_WHITELIST).permitAll()
.anyRequest().hasAnyAuthority(RoleType.ROLE_COUNCIL.name())
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(corsFilter, JwtAuthenticationFilter.class);
return http.build();
}
private void defaultSecuritySetting(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable));
}
}
- authorizeHttpRequests: 경로별 인가 규칙을 정의한다. 예를 들어 permitAll(모두 허용), hasAuthority(특정 권한 필요) 등을 설정할 수 있다.
- exceptionHandling:
- AuthenticationEntryPoint: 인증되지 않은 사용자가 보호된 리소스에 접근 시 401 Unauthorized 응답을 반환하도록 커스텀 핸들러를 지정한다.
- AccessDeniedHandler: 인증은 되었으나 해당 리소스에 접근할 권한이 없는 경우 403 Forbidden 응답을 반환하도록 커스텀 핸들러를 지정한다.
- addFilterBefore: 필터 체인의 실행 순서를 명시적으로 정의한다. 먼저 CorsFilter가 요청의 출처를 확인하고, 이후 JwtAuthenticationFilter가 인증을 처리한다.
▶ CorsConfig
CORS 설정을 별도의 @Configuration 클래스로 분리하여 CorsFilter Bean을 등록한 뒤, 이를 SecurityConfig에 주입해 필터 체인에 추가한다. (http.cors()로 SecurityConfig 내에서 직접 설정하는 방식도 가능하다.)
@Configuration
public class CorsConfig {
private static final List<String> ALLOWED_ORIGINS = List.of(
"http://localhost:5173",
"http://127.0.0.1:5173",
"https://festabook.app",
"https://dev.festabook.app"
);
private static final List<String> ALLOWED_METHODS = List.of(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
);
private static final List<String> ALLOWED_HEADERS = List.of("*");
private static final long MAX_AGE = 3600L;
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(ALLOWED_ORIGINS);
config.setAllowedMethods(ALLOWED_METHODS);
config.setAllowedHeaders(ALLOWED_HEADERS);
config.setAllowCredentials(true);
config.setMaxAge(MAX_AGE);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
▶ JwtAuthenticationFilter
OncePerRequestFilter를 상속하여 요청마다 한 번만 실행된다.
- HTTP 요청 헤더에서 JWT를 추출하고 유효성을 검증한다.
- UserDetailsService를 통해 UserDetails를 로드한 뒤 Authentication 객체를 생성하고 SecurityContextHolder에 저장한다.
- 인증 실패 시 AuthenticationException 또는 BusinessException을 발생시킨다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHENTICATION_HEADER = "Authorization";
private static final String AUTHENTICATION_SCHEME = "Bearer ";
private final JwtProvider jwtProvider;
private final CouncilDetailsService councilDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String accessToken = extractTokenFromHeader(request);
if (accessToken != null && jwtProvider.isValidToken(accessToken)) {
String username = jwtProvider.extractBody(accessToken).getSubject();
UserDetails userDetails = councilDetailsService.loadUserByUsername(username);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (AuthenticationException | BusinessException e) {
SecurityContextHolder.clearContext();
throw e;
}
filterChain.doFilter(request, response);
}
private String extractTokenFromHeader(HttpServletRequest request) {
String token = request.getHeader(AUTHENTICATION_HEADER);
if (StringUtils.hasText(token) && token.startsWith(AUTHENTICATION_SCHEME)) {
return token.substring(AUTHENTICATION_SCHEME.length());
}
return null;
}
}
▶ CustomAuthenticationEntryPoint
인증되지 않은 사용자가 보호된 리소스에 접근하여 AuthenticationException이 발생했을 때 호출된다. 주로 401 Unauthorized 응답을 반환한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
log.info("Security 인증 실패: 요청 경로={}, 메서드={}, 에러 메시지={}",
request.getRequestURI(),
request.getMethod(),
exception.getMessage(), exception);
AuthenticationErrorResponse errorResponse = new AuthenticationErrorResponse(
String.valueOf(HttpStatus.UNAUTHORIZED.value()),
"인증이 필요합니다."
);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
response.getWriter().flush();
}
private record AuthenticationErrorResponse(
String errorCode,
String errorMessage
) {
}
}
Spring Security Filter Chain에서 발생한 예외는 전역 ControllerAdvice에서 처리할 수 없다. 따라서 별도의 예외 처리 로직과 예외 관련 로그를 작성해야 한다.
▶ CustomAccessDeniedHandler
인증은 되었으나 필요한 권한이 없어 AccessDeniedException이 발생했을 때 호출된다. 주로 403 Forbidden 응답을 반환한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception)
throws IOException, ServletException {
log.info("Security 인가 실패: 요청 경로={}, 메서드={}, 에러 메시지={}",
request.getRequestURI(),
request.getMethod(),
exception.getMessage(), exception);
AccessDeniedErrorResponse errorResponse = new AccessDeniedErrorResponse(
String.valueOf(HttpStatus.FORBIDDEN.value()),
"접근 권한이 없습니다."
);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
response.getWriter().flush();
}
private record AccessDeniedErrorResponse(
String errorCode,
String errorMessage
) {
}
}
Spring Security Filter Chain에서 발생한 예외는 전역 ControllerAdvice에서 처리할 수 없다. 따라서 별도의 예외 처리 로직과 예외 관련 로그를 작성해야 한다.
▶ CouncilDetailsService
UserDetailsService의 구현체로, 사용자 식별자(username)를 기준으로 DB에서 사용자 정보를 조회해 UserDetails 타입으로 반환한다.
@Service
@RequiredArgsConstructor
public class CouncilDetailsService implements UserDetailsService {
private final CouncilJpaRepository councilJpaRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Council council = councilJpaRepository.findByUsername(username)
.orElseThrow(() -> new BusinessException("존재하지 않는 학생회입니다.", HttpStatus.NOT_FOUND));
return new CouncilDetails(council);
}
}
▶ CouncilDetails
UserDetails 인터페이스의 구현체로, Council 엔티티를 래핑하여 Spring Security가 필요로 하는 정보(username, password, authorities, 계정 상태 등)를 제공한다.
@Getter
@RequiredArgsConstructor
public class CouncilDetails implements UserDetails {
private final Council council;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return council.getRoles().stream()
.filter(Objects::nonNull)
.map(RoleType::name)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
}
@Override
public String getPassword() {
return council.getPassword();
}
@Override
public String getUsername() {
return council.getUsername();
}
public Long getFestivalId() {
return council.getFestival().getId();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
✅ 이해하기 쉬운 예시
▶ Spring Security와 JWT
🔽 Spring Security: 우리 앱의 든든한 경비원
Spring Security는 우리 애플리케이션을 지켜주는 전문 경비 시스템이라고 생각하면 쉽다.
- 인증(Authentication): "누구신가요?" → 앱에 들어오려는 사람이 정말 등록된 회원인지 신원을 확인하는 과정이다. (예: 로그인)
- 인가(Authorization): "여기에 들어갈 수 있나요?" → 신원이 확인된 회원이 특정 페이지나 기능에 접근할 권한이 있는지 확인하는 과정이다. (예: 관리자만 접속 가능한 페이지)
🔽 JWT: 우리의 신원
과거에는 경비원이 방문자 명단(Session)을 일일이 들고 다니며 확인했지만, 사람이 많아지면 비효율적이다. 그래서 우리는 더 똑똑한 방법을 쓴다. 바로 JWT(JSON Web Token)라는 ‘출입증'을 사용하는 것이다.
- 로그인 성공: 회원이 ID와 비밀번호로 로그인에 성공하면, 서버는 그 회원만을 위한 암호화된 출입증(JWT)을 발급해 준다.
- 앱 사용: 회원은 앱의 다른 기능을 사용할 때마다 이 출입증을 경비원에게 보여주기만 하면 된다.
▶ 로그인 과정: 출입증 검사 과정
🔽 1단계: 모든 요청은 보안 게이트로 (SecurityConfig)
SecurityConfig 파일은 우리 앱의 보안 설계도이다. 이 설계도에는 어떤 규칙으로, 어떤 순서로 방문객을 확인할지 모두 적혀 있다. 아래 코드는 "모든 방문객은 정문(기본 필터)을 통과하기 전에 우리가 고용한 특별 경비원(jwtAuthenticationFilter)에게 먼저 검사를 받아라"라고 지시하는 것과 같다.
// SecurityConfig.java
// 커스텀 필터 등록
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
...
또한, 설계도에는 누구나 출입증 없이 들어올 수 있는 공개 구역(Whitelist)도 지정되어 있다.
// SecurityConfig.java
.requestMatchers(HttpMethod.GET, GET_WHITELIST).permitAll() // GET_WHITELIST 목록은 출입증 검사 안함
...
.anyRequest().hasAnyAuthority(RoleType.ROLE_COUNCIL.name()) // 그 외 모든 곳은 '학생회' 출입증 필요
...
🔽 2단계: 특별 경비원의 출입증 검사 (JwtAuthenticationFilter)
JwtAuthenticationFilter는 우리가 고용한 출입증 전문 검사원이다.
// JwtAuthenticationFilter.java
// 1. "출입증 좀 보여주시겠어요?" (헤더에서 토큰 추출)
String accessToken = extractTokenFromHeader(request);
// 2. "이 출입증이 진짜인지, 유효기간은 남았는지 확인해볼게요." (토큰 유효성 검사)
if (accessToken != null && jwtProvider.isValidToken(accessToken)) {
// 3. "출입증에 적힌 이름이 '미소'님이 맞군요." (토큰에서 사용자 정보 추출)
String username = jwtProvider.extractBody(accessToken).getSubject();
// 4. "등록된 회원 명단에 이름이 있는지 확인하겠습니다." (DB에서 사용자 정보 조회)
CouncilDetails councilDetails = (CouncilDetails) councilDetailsService.loadUserByUsername(username);
// 5. "확인되셨습니다. 임시 출입증을 발급해 드릴게요." (Authentication 객체 생성)
Authentication authentication = new UsernamePasswordAuthenticationToken(
councilDetails, null, councilDetails.getAuthorities());
// 6. "이 임시 출입증을 잘 보이는 곳에 부착해주세요." (SecurityContextHolder에 저장)
SecurityContextHolder.getContext().setAuthentication(authentication);
}
🔽 3단계: 임시 출입증은 왜 필요한가? (@AuthenticationPrincipal)
검사원은 왜 굳이 확인이 끝난 회원에게 임시 출입증(Authentication 객체)을 만들어서 보이는 곳(SecurityContextHolder)에 붙여줄까? 이유는 아주 간단하다. 효율성 때문이다.
앱 안에는 여러 부서(기능, 페이지)가 있다. 만약 임시 출입증이 없다면, 사용자가 다른 부서로 이동할 때마다 경비원이 또다시 다가와서 디지털 출입증을 검사하고 회원 명단을 뒤져봐야 한다. (DB 추가 조회 필요)
하지만 임시 출입증을 발급해서 보이는 곳에 붙여두면, 다른 부서의 직원들은 그 임시 출입증만 보고도 "아, 이 사람은 이미 정문에서 신원 확인이 끝난 사람이구나!"라고 즉시 인지할 수 있다. 더 이상 반복해서 신원을 확인할 필요가 없는 것이다.
이 덕분에 우리는 컨트롤러 같은 곳에서 @AuthenticationPrincipal이라는 편리한 기능을 사용할 수 있다.
// Controller
public AnnouncementResponse createAnnouncement(
// "저기요, 보이는 곳에 붙어있는 임시 출입증 정보 좀 주세요!"
@AuthenticationPrincipal CouncilDetails councilDetails,
...
) { ... }
🔽 예외 처리: 만약 문제가 생긴다면? (CustomAuthenticationEntryPoint, CustomAccessDeniedHandler)
우리 경비 시스템은 문제가 생겼을 때 대처하는 방법도 정해져 있다.
- 출입증이 없는 경우 (CustomAuthenticationEntryPoint): 출입증 없이 들어오려고 하면, 경비원은 "신원 확인이 필요합니다. 로비로 가세요."라고 말하며 401 에러를 보낸다.
- 권한이 없는 경우 (CustomAccessDeniedHandler): 일반 직원의 출입증으로 '사장실'에 들어가려고 하면, 경비원은 "여긴 들어갈 권한이 없습니다."라고 말하며 403 에러를 보낸다.
🎬 마무리하며
Spring Security를 이미 여러 차례 사용해본 경험이 있어 세팅 자체는 오래 걸리지 않았다. 다만 곱씹어보면 JWT만으로도 충분했을 것 같다는 생각이 들기도 했다. 그럼에도 불구하고 추후 편리한 로그인을 위해 OAuth 도입 가능성이 크다고 판단했고, 미리 Spring Security를 적용하면 구조 변경이나 커스텀 어노테이션 작성이 필요 없다는 장점이 있었다. 또한 나는 현재 Spring Security에 대한 러닝커브가 크지 않아 도입을 결정했다.
비록 익숙한 기술이지만, 우아한테크코스를 통해 다시 접한 Spring Security는 새롭게 느껴지는 부분도 있었고, 복습 차원에서 의미 있는 학습이 되었다고 생각한다!
📍 참고 자료
'Backend > Spring' 카테고리의 다른 글
| [Spring] 커넥션(Connection), 커넥션 풀(Connection Pool), HikariCP 설정 (0) | 2025.10.27 |
|---|---|
| [Spring] Spring, Spring Boot (0) | 2025.06.27 |
| [Spring] Spring MVC Config (0) | 2025.05.12 |
| [Spring] 스프링 빈(Spring Bean), Spring Core 어노테이션 (1) | 2025.04.23 |
| [Spring] IoC 컨테이너, 의존성 주입(DI) (2) | 2025.04.23 |