Today's Codekata
# Investments in 2016
SELECT ROUND(SUM(tiv_2016), 2) AS tiv_2016
FROM Insurance
WHERE tiv_2015 IN (
SELECT tiv_2015
FROM Insurance
GROUP BY tiv_2015
HAVING COUNT(*) > 1
)
AND (lat, lon) IN (
SELECT lat, lon
FROM Insurance
GROUP BY lat, lon
HAVING COUNT(*) = 1
);
# Fix Names in a Table
SELECT user_id,
CONCAT(
UPPER(LEFT(name, 1)),
LOWER(SUBSTRING(name, 2))
) AS name
FROM Users
ORDER BY user_id;
# Department Top Three Salaries
SELECT d.name AS Department,
e.name AS Employee,
e.salary AS Salary
FROM (
SELECT e.*,
DENSE_RANK() OVER (
PARTITION BY e.departmentId
ORDER BY e.salary DESC
) AS ranking
FROM Employee e
) e
JOIN Department d ON e.departmentId = d.id
WHERE e.ranking <= 3;
프로젝트와 강의 수강 밀려 잠시 손을 놓았던 코드카타를 다시 시작해본다.
몇일 쉬었을 뿐인데, 간단한 문법도 기억이 가물가물하다.
아래에는 SQL 순위 함수에 대해 정리해봤다.
| 함수 | 설명 |
| `RANK()` | 동일한 값에 같은 순위를 부여하고, 다음 순위는 건너뜀 (중복 수만큼) |
| `DENSE_RANK()` | 동일한 값에 같은 순위를 부여하지만, 다음 순위는 건너뛰지 않음 |
| `ROW_NUMBER()` | 중복 관계없이 순차적으로 고유한 번호를 부여함 |
| `NTILE(n)` | 전체 데이터를 n개의 그룹으로 나누고, 각 행에 그룹 번호를 부여함 (분위개념) |
예를 들어, 점수가 아래와 같다고 하면
A = 100점, B = 100점, C = 90점, D = 80점
| 함수 | 결과 |
| `RANK()` | A, B 1등 / C 3등 / D 4등 → 2등은 건너뜀 |
| `DENSE_RANK()` | A, B 1등 / C 2등 / D 3등 → 순차적 진행 |
| `ROW_NUMBER()` | A 1등 / B 2등 / C 3등 / D 4등 → 중복 무시하고 번호 부여 |
| `NTILE(n)` | A, B 1등 / C, D 2등 → 상위 2개, 하위 2개로 그룹 나눔 |
`RANK()`와 `DENSE_RANK()`는 순위를 매길 때,
`ROW_NUMBER()`는 고유 식별자처럼 쓰일 때,
`NTILE(n)`은 데이터를 분위별로 나눌 때 유용하다.
Today I Learned
Spring 플러스 주차 개인 과제
#Spring Security 기반 인증/인가 리팩토링하기
이번 과제에서는 기존에 구현되어있던 `Filter`와 `Argument Resolver`를 사용한 인증/인가 로직을 Spring Security 기반으로 리팩토링해보았다. JWT 기반 인증은 그대로 유지하면서, Spring Security의 구조에 맞게 권한 체크와 사용자 정보 주입을 개선했다.
목표
기존 커스텀 필터 방식에서 Spring Security 방식으로 전환하기 (JWT 기반 인증은 유지)
권한 체크를 Spring Security 방식으로 처리하기
구현 방식
1. 인증 필터: JwtAuthenticationFilter
기존의 `JwtFilter`는 `javax.servlet.Filter`를 구현해서 직접 인증 흐름을 제어했지만, Spring Security에서는 `OncePerRequestFilter`를 상속받아 인증 필터를 구현한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(
HttpServletRequest httpRequest,
@NonNull HttpServletResponse httpResponse,
@NonNull FilterChain chain
) throws ServletException, IOException {
String authorizationHeader = httpRequest.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
chain.doFilter(httpRequest, httpResponse);
return;
}
String jwt = jwtUtil.substringToken(authorizationHeader);
if (!processAuthentication(jwt, httpRequest, httpResponse)) {
return;
}
chain.doFilter(httpRequest, httpResponse);
}
// JWT 토큰을 검증하고 SecurityContext에 인증 정보를 설정하는 메서드
private boolean processAuthentication(String jwt, HttpServletRequest request, HttpServletResponse response) throws IOException {
try {
Claims claims = jwtUtil.extractClaims(jwt);
// SecurityContext에 인증 정보가 없으면 설정 (이미 인증된 경우 중복 설정 방지)
if (SecurityContextHolder.getContext().getAuthentication() == null) {
setAuthentication(claims);
}
return true; // 검증 성공
} catch (ExpiredJwtException e) {
log.info("JWT 만료: userId={}, URI={}", e.getClaims().getSubject(), request.getRequestURI());
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException e) {
log.error("JWT 검증 실패 [{}]: URI={}", e.getClass().getSimpleName(), request.getRequestURI(), e);
sendErrorResponse(response, HttpStatus.BAD_REQUEST, "인증이 필요합니다.");
} catch (Exception e) {
log.error("예상치 못한 오류: URI={}", request.getRequestURI(), e);
sendErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, "요청 처리 중 오류가 발생했습니다.");
}
return false; // 검증 실패
}
// JWT Claims에서 사용자 정보를 추출하여 Spring Security의 인증 정보 설정
private void setAuthentication(Claims claims) {
Long userId = Long.valueOf(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class);
UserRole userRole = UserRole.of(claims.get("userRole", String.class));
// 추출한 정보로 인증된 사용자 객체 생성
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
Authentication authenticationToken = new JwtAuthenticationToken(authUser);
// SecurityContext에 인증 정보 저장 - 이후 @AuthenticationPrincipal로 접근 가능
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", status.name());
errorResponse.put("code", status.value());
errorResponse.put("message", message);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
`SecurityContextHolder.getContext().setAuthentication(authenticationToken);`
이 한 줄이 핵심이다.
기존에는 `request.setAttribute()`로 사용자 정보를 주입했지만,
Spring Security에서는 `SecurityContextHolder`를 통해 인증 정보를 전역적으로 관리한다.
이 덕분에 이후 컨트롤러나 서비스에서 `@AuthenticationPrincipal`로 간편하게 사용자 정보를 꺼낼 수 있다.
2. 인증 객체: JwtAuthenticationToken
Spring Security는 인증된 사용자를 `Authentication` 객체로 표현한다.
그래서 JWT에서 추출한 사용자 정보를 담기 위한 커스텀 인증 객체를 만들어줘야했다.
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final AuthUser authUser;
// 인증된 JWT 토큰을 생성하는 생성자.
public JwtAuthenticationToken(AuthUser authUser) {
super(authUser.getAuthorities());
this.authUser = authUser;
setAuthenticated(true); // Spring Security에 사용자가 이미 인증되었음을 알려줌
}
// JWT 인증에서는 토큰 검증 후 자격 증명이 필요하지 않으므로 null을 반환합니다.
@Override
public Object getCredentials() {
return null;
}
// Principal(인증된 사용자)을 반환합니다. (애플리케이션 전체에서 현재 사용자의 정보에 접근하는 데 사용)
@Override
public Object getPrincipal() {
return authUser;
}
}
이 클래스는 `AuthUser`를 principal로 담고, `getAuthorities()`를 통해 권한 정보를 제공한다.
여기서 중요한 건 `authorities` 필드인데, 이걸 통해 Spring Security가 인가(Authorization)를 판단한다.
3. 사용자 정보 객체: AuthUser
기존에는 단순히 사용자 정보를 담는 DTO였지만, Spring Security와 연동하기 위해 `GrantedAuthority`를 포함시켰다.
@Getter
public class AuthUser {
private final Long id;
private final String email;
private final String nickname;
private final UserRole userRole;
private final Collection<? extends GrantedAuthority> authorities;
public AuthUser(Long id, String email, String nickname, UserRole userRole) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.userRole = userRole;
this.authorities = List.of(new SimpleGrantedAuthority(userRole.name()));
}
}
`GrantedAuthority`란 Spring Security에서 권한을 나타내는 인터페이스로, `getAuthority()`라는 단 하나의 메서드만 가지고 있다.
이 메서드는 "ROLE_USER", "ROLE_ADMIN" 같은 문자열을 반환한다.
이 문자열을 기반으로 `@Secured`, `hasAuthority()`, `hasRole()` 같은 인가 어노테이션이 동작하게 된다.
3. @AuthenticationPrincipal 과 @Secured
기존에는 AuthUserArgumentResolver를 만들어서 사용자 정보를 컨트롤러에 주입했지만,
Spring Security에서는 @AuthenticationPrincipal 하나로 해결된다.
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(
@AuthenticationPrincipal AuthUser authUser,
@Valid @RequestBody TodoSaveRequest todoSaveRequest
) {
return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest));
}
@Secured(UserRole.Authority.ADMIN)
@PatchMapping("/admin/users/{userId}")
public void changeUserRole(@PathVariable long userId,
@RequestBody UserRoleChangeRequest userRoleChangeRequest) {
userAdminService.changeUserRole(userId, userRoleChangeRequest);
}
그리고 기존에는 URL 조건 분기나 `if(userRole == ADMIN)`같은 방식으로 권한을 체크했지만,
Spring Security에서는 `@Secured`을 통해 명시적으로 권한을 제한할 수 있다.
코드만 봐도 관리자만 접근 가능하다는 것이 명확하게 드러나서 협업이나 유지보수에도 유리하다.
다만, `@Secured` 안에 enum을 곧바로 넣지 못하기 때문에,
스프링 시큐리티에서 제공하는 권한 기능을 사용하려면, 반드시 prefix로 "ROLE_" 붙여줘야했다.
@Getter
@RequiredArgsConstructor
public enum UserRole {
ROLE_USER(Authority.USER),
ROLE_ADMIN(Authority.ADMIN);
private final String userRole;
public static UserRole of(String role) {
return Arrays.stream(UserRole.values())
.filter(r -> r.name().equalsIgnoreCase(role))
.findFirst()
.orElseThrow(() -> new InvalidRequestException("유효하지 않은 UserRole"));
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
4. Spring Security 설정: SecurityConfig
Spring Security는 기본적으로 세션 기반 인증을 사용하지만, 우리는 JWT 기반 인증을 사용하기 때문에 관련 기능들을 비활성화했다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // Spring Security 활성화
@EnableMethodSecurity(securedEnabled = true) // @Secured 활성화
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class) // JwtAuthenticationFilter를 스프링 시큐리티 인증 프로세스 전에 진행
// JWT 사용 시 불필요한 기능들 비활성화
.formLogin(AbstractHttpConfigurer::disable) // [SSR] 서버가 로그인 HTML 폼 렌더링
.anonymous(AbstractHttpConfigurer::disable) // 미인증 사용자를 익명으로 처리
.httpBasic(AbstractHttpConfigurer::disable) // [SSR] 인증 팝업
.logout(AbstractHttpConfigurer::disable) // [SSR] 서버가 세션 무효화 후 리다이렉트
.rememberMe(AbstractHttpConfigurer::disable) // 서버가 쿠키 발급하여 자동 로그인
.authorizeHttpRequests(auth -> auth
.requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll()
.requestMatchers("/test").hasAuthority(UserRole.Authority.ADMIN) // `/test`는 ADMIN만 허용
.requestMatchers("/open").permitAll() // `/open`은 아무나 접근 가능
.anyRequest().authenticated() // 다른 요청들은 authentication 필요
)
.build();
}
}
그리고 기존에 PasswordEncoder 클래스를 별도로 생성하고, 외부 라이브러리로 비밀번호를 암호화했지만,
Spring Security의 `BcryptPasswordEncoder`를 사용하면서 인증 흐름과 암호화 방식이 통일되었다.
private final PasswordEncoder passwordEncoder;
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
UserRole userRole = UserRole.of(signupRequest.getUserRole());
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
signupRequest.getNickname(),
userRole
);
User savedUser = userRepository.save(newUser);
String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), savedUser.getNickname(), userRole);
return new SignupResponse(bearerToken);
}
6. 인증 흐름
Spring Security를 통해 JWT 기반 인증을 처리하는 전체 흐름은 다음과 같이 구성된다.
① JWT 토큰에서 사용자 정보와 역할을 추출
요청 헤더에서 JWT를 꺼내고, `Claims`를 통해 사용자 ID, 이메일, 닉네임, 역할 등의 정보를 파싱한다.
이 단계는 인증의 시작점이며, 토큰이 유효한지를 검증한다.
② 추출한 정보로 AuthUser 객체 생성
JWT에서 얻은 사용자 정보를 기반으로 `AuthUser` 객체를 생성한다.
이때 `GrantedAuthority`를 포함시켜 사용자의 권한 정보를 함께 담는다.
이 권한 정보는 이후 인가(Authorization) 판단에 사용된다.
`new SimpleGrantedAuthority(userRole.name())`
이 한 줄이 Spring Security가 "ROLE_ADMIN" 또는 "ROLE_USER" 같은 권한을 인식할 수 있게 해준다.
③ JwtAuthenticationToken 생성 후 인증 정보 설정
`AuthUser`를 담은 `JwtAuthenticationToken`을 생성하고,
`SecurityContextHolder.getContext().setAuthentication()`를 통해 인증 정보를 등록한다.
이 작업은 Spring Security에게 “이 사용자는 인증되었고, 이런 권한을 가지고 있어요”라고 알려주는 역할을 한다.
④ 이후 Spring Security가 권한 기반 인가 처리
컨트롤러나 서비스에서 `@Secured("ROLE_ADMIN")` 또는 `@AuthenticationPrincipal`을 사용할 수 있게 된다.
Spring Security는 `SecurityContextHolder`에 저장된 인증 정보를 기반으로 사용자의 권한을 판단하고, 접근을 허용하거나 차단한다.
#마치며
이번 리팩토링은 단순히 코드를 바꾸는 작업이 아니라, 인증/인가 구조를 Spring Security에 맞게 재설계하는 경험이었다.
특히 SecurityContextHolder, Authentication, @AuthenticationPrincipal, @Secured 같은 개념들이 어떻게 연결되는지 직접 구현하면서 이해할 수 있었다.