Redis, Spring Security!
Refresh Token처럼 잠깐 쓰였다가 유효기간이 끝나면 사라지는 데이터는 User 테이블에 컬럼을 만들어 관리하기엔 성격이 안 맞는다. 이런 값은 인메모리 DB(Redis, H2, SQLite)에 담는데, 현업에서는 대부분 Redis를 선호한다고 한다.
문제는 Redis에 윈도우용 프로그램이 없다는 점이다. 리눅스 기반이라, 윈도우에서 쓰려면 가상화 환경이 필요하다. 오늘 배운 가상화 구분은 이렇다.
이미지는 특정 시점의 파일시스템을 그대로 저장해둔 압축 파일이고, Redis도 이미 Docker Hub에 이미지가 올라와 있어서 직접 만들 필요 없이 받아서 컨테이너로 띄우면 된다.
docker run -d --name redis-container -p 6379:6379 redis:7.2
docker exec -it redis-container redis-cli
# UI 툴 (HeidiSQL 같은 역할)
docker run -d --name redis-insight -p 8001:5540 redis/redisinsight:latest
application-dev.yml에 spring.data.redis(host/port/timeout)를 적어두면 Redis와의 연결 자체는 Spring Boot가 알아서 맺어준다. 다만 그 상태로 쓰면 키·값이 기본 직렬화 방식으로 저장돼서 RedisInsight에서 봤을 때 읽을 수 없는 값이 된다. 그래서 키·값을 문자열로 다루도록 직접 지정해주는 RedisTemplate 빈이 필요했는데, 그게 RedisConfig다.
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory rcf) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(rcf);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
실제 저장/삭제는 RedisService에서 한다.
private static final long RT_TTL = 60 * 60 * 24 * 7; // 7일, 초 단위
public void saveToken(String email, String rt){
redisTemplate.opsForValue().set("RT:"+email, rt, RT_TTL, TimeUnit.SECONDS);
}
public void deleteToken(String email) {
redisTemplate.delete("RT:"+email);
}
키를 그냥 이메일로만 쓰지 않고 앞에 "RT:"를 붙인 건, 나중에 Redis에 다른 종류의 값이 더 들어올 걸 대비해서 이게 리프레시 토큰이라는 걸 구분해두기 위해서이다. TTL은 초 단위로 계산하는데, 이 부분은 앞으로 초 단위인지 밀리초 단위인지 코드 볼 때마다 한 번씩 확인하는 습관이 필요할 것 같다.
어제 만든 JwtFilter는 jakarta.servlet.Filter를 구현한, 스프링과 무관한 순수 서블릿 필터였다. 오늘은 이걸 Spring Security 쪽 필터로 바꿨다.
JwtAuthenticationFilter는 OncePerRequestFilter를 상속받는다. 이름에서 짐작할 수 있듯, 요청 한 번당 이 필터가 여러 번 겹쳐 실행되지 않도록 스프링이 관리해준다.AntPathMatcher로 필터 안에서 직접 걸러냈는데, 오늘은 그 역할이 SecurityConfig의 authorizeHttpRequests 설정 쪽으로 넘어갔다. 그래서 필터 자체는 토큰이 없어도 그냥 다음 단계로 넘겨버리고, 실제로 막을지 말지는 SecurityConfig가 결정한다.Claims(JWT 데이터: header, payload, sign)에서 getSubject()로 이메일을, get("role", String.class)로 권한을 꺼내서 UsernamePasswordAuthenticationToken을 만들고 SecurityContextHolder에 담아둔다.JwtFilter와 CorsConfig는 지운 게 아니라 @Component/@Configuration만 주석 처리해서 꺼뒀다.@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/users/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
OPTIONS 요청을 permitAll로 열어두니 preflight가 그냥 통과돼서, 어제 필터 안에 있던 preflight 대응 코드가 더 이상 필요 없어졌다.sessionCreationPolicy(STATELESS)는 로그인 상태를 서버가 기억해두지 않겠다는 설정이다. 요청마다 토큰만 보고 판단하니 서버 쪽에 저장해둘 상태 자체가 없어진다.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)로 내가 만든 필터가 Security의 원래 필터들 사이 어디에 들어갈지 순서를 지정한다.둘 다 "암호화"라고 뭉뚱그려 부르기 쉬운데, 핵심은 키의 존재 여부다.
암호화는 키가 있어서 원래 값으로 되돌릴 수 있고, 해싱은 그 키 자체가 없어서 되돌릴 방법이 없다. BCryptPasswordEncoder가 하는 일이 이 해싱이고, DB가 유출되더라도 저장된 값만으로는 원래 비밀번호를 알아낼 수 없다는 게 이 방식을 쓰는 이유다.
UserRequestDTO에 @Builder(toBuilder = true)를 추가하고, 회원가입 시점에 비밀번호만 해싱한 새 DTO를 하나 더 만든다.
UserRequestDTO hashingDTO = request.toBuilder()
.password(passwordEncoder.encode(request.getPassword()))
.build();
로그인 로직도 바뀌었다. 예전에는 이메일+비밀번호로 바로 조회했는데, 이제는 이메일로만 사용자를 찾은 다음 passwordEncoder.matches(입력한 비밀번호, DB에 저장된 해시값)로 맞는지 확인한다. 저장된 해시를 다시 원래 비밀번호로 풀어보는 게 아니라, 입력값 쪽을 같은 규칙으로 해싱해서 두 해시값이 같은지만 비교하는 방식이다.
지금까지는 블로그를 작성할 때 글쓴이 이메일을 요청 본문에 실려온 값 그대로 썼다. 근데 그 값은 클라이언트가 아무렇게나 채워서 보낼 수 있는 값이라, 로그인한 사람과 실제로 다른 이메일이 들어올 수도 있는 구조였다.
그래서 오늘부터는 요청 본문 대신 필터가 토큰을 검증하면서 SecurityContextHolder에 넣어둔 이메일을 가져다 쓴다.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();
로그아웃(/users/signOut)도 같은 방식으로 동작한다. 프론트에서 이메일을 따로 보낼 필요 없이, 서버가 컨텍스트에서 이메일을 꺼내 그 계정의 Redis Refresh Token만 지운다. Access Token 쪽은 Redis에 따로 저장해두지 않았는데, 30분이면 만료돼서 굳이 관리할 이유가 없다.
.comments() 자리가 비어 있던 부분(BlogResponseDTO.fromEntityWithComments())도 오늘 실제로 댓글 목록을 채우도록 완성했다.blog.id, comment.id로 쓰던 부분을 JPA 엔티티의 실제 필드명인 blog.blogId, comment.commentId에 맞게 바꿨다.직접 해본 것:
RT:이메일 키에 토큰과 TTL이 들어가는 걸 확인했다.#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP