[LG CNS 6기] 본 과정 32일차 TIL / [Spring Boot] - Redis, Spring Security, 비밀번호 해싱

김승진·2026년 9월 11일

LG CNS AM 6기 TIL

목록 보기
41/46

1. 오늘의 한 줄 요약

Redis, Spring Security!

2. 오늘 배운 것

2.1 왜 Redis인가 — 가상화와 인메모리 DB

Refresh Token처럼 잠깐 쓰였다가 유효기간이 끝나면 사라지는 데이터는 User 테이블에 컬럼을 만들어 관리하기엔 성격이 안 맞는다. 이런 값은 인메모리 DB(Redis, H2, SQLite)에 담는데, 현업에서는 대부분 Redis를 선호한다고 한다.

문제는 Redis에 윈도우용 프로그램이 없다는 점이다. 리눅스 기반이라, 윈도우에서 쓰려면 가상화 환경이 필요하다. 오늘 배운 가상화 구분은 이렇다.

  • 베어메탈: 서버 한 대에 필요한 걸 전부 직접 올려서 쓰는 방식
  • 하이퍼바이저 기반: 서버 위에 가상머신(VM)을 올리는 방식. 설정이 복잡해서 실무에서는 잘 안 쓴다
  • 컨테이너 기반: Docker처럼 VM보다 가벼운 컨테이너를 올리는 방식. 우리가 쓰는 게 이거다

이미지는 특정 시점의 파일시스템을 그대로 저장해둔 압축 파일이고, 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

2.2 RedisTemplate — 스프링과 Redis 연결하기

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은 초 단위로 계산하는데, 이 부분은 앞으로 초 단위인지 밀리초 단위인지 코드 볼 때마다 한 번씩 확인하는 습관이 필요할 것 같다.

2.3 웹 필터에서 프레임워크 필터로 — Spring Security

어제 만든 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만 주석 처리해서 꺼뒀다.

2.4 SecurityConfig — 필터 체인에 끼워 넣기

@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();
}
  • CSRF는 이름은 CORS랑 비슷해 보이지만 다른 개념이다. 사용자가 시키지 않은 요청을 다른 사람이 그 사용자인 것처럼 위장해서 보내는 공격이고, 우리는 세션이 아니라 토큰으로 인증하기 때문에 꺼둬도 된다.
  • OPTIONS 요청을 permitAll로 열어두니 preflight가 그냥 통과돼서, 어제 필터 안에 있던 preflight 대응 코드가 더 이상 필요 없어졌다.
  • sessionCreationPolicy(STATELESS)는 로그인 상태를 서버가 기억해두지 않겠다는 설정이다. 요청마다 토큰만 보고 판단하니 서버 쪽에 저장해둘 상태 자체가 없어진다.
  • addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)로 내가 만든 필터가 Security의 원래 필터들 사이 어디에 들어갈지 순서를 지정한다.

2.5 비밀번호 해싱 — 해싱과 암호화는 다르다

둘 다 "암호화"라고 뭉뚱그려 부르기 쉬운데, 핵심은 키의 존재 여부다.
암호화는 키가 있어서 원래 값으로 되돌릴 수 있고, 해싱은 그 키 자체가 없어서 되돌릴 방법이 없다. BCryptPasswordEncoder가 하는 일이 이 해싱이고, DB가 유출되더라도 저장된 값만으로는 원래 비밀번호를 알아낼 수 없다는 게 이 방식을 쓰는 이유다.

UserRequestDTO에 @Builder(toBuilder = true)를 추가하고, 회원가입 시점에 비밀번호만 해싱한 새 DTO를 하나 더 만든다.

UserRequestDTO hashingDTO = request.toBuilder()
    .password(passwordEncoder.encode(request.getPassword()))
    .build();

로그인 로직도 바뀌었다. 예전에는 이메일+비밀번호로 바로 조회했는데, 이제는 이메일로만 사용자를 찾은 다음 passwordEncoder.matches(입력한 비밀번호, DB에 저장된 해시값)로 맞는지 확인한다. 저장된 해시를 다시 원래 비밀번호로 풀어보는 게 아니라, 입력값 쪽을 같은 규칙으로 해싱해서 두 해시값이 같은지만 비교하는 방식이다.

2.6 SecurityContextHolder — 필터가 심어둔 값을 꺼내 쓰기

지금까지는 블로그를 작성할 때 글쓴이 이메일을 요청 본문에 실려온 값 그대로 썼다. 근데 그 값은 클라이언트가 아무렇게나 채워서 보낼 수 있는 값이라, 로그인한 사람과 실제로 다른 이메일이 들어올 수도 있는 구조였다.

그래서 오늘부터는 요청 본문 대신 필터가 토큰을 검증하면서 SecurityContextHolder에 넣어둔 이메일을 가져다 쓴다.

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();

로그아웃(/users/signOut)도 같은 방식으로 동작한다. 프론트에서 이메일을 따로 보낼 필요 없이, 서버가 컨텍스트에서 이메일을 꺼내 그 계정의 Redis Refresh Token만 지운다. Access Token 쪽은 Redis에 따로 저장해두지 않았는데, 30분이면 만료돼서 굳이 관리할 이유가 없다.

2.7 그 외 — 댓글 컨트롤러 완성, 필드명 정리

  • Comment 쪽은 지금까지 Service 레이어까지만 JPA로 옮겨져 있었는데, 오늘 예전 MyBatis 프로젝트에 있던 Controller 코드를 가져와서 JPA 구조에 맞게 고쳐 넣었다.
  • 블로그 상세조회 응답에서 .comments() 자리가 비어 있던 부분(BlogResponseDTO.fromEntityWithComments())도 오늘 실제로 댓글 목록을 채우도록 완성했다.
  • 프론트에서 그동안 blog.id, comment.id로 쓰던 부분을 JPA 엔티티의 실제 필드명인 blog.blogId, comment.commentId에 맞게 바꿨다.

3. 실습 / 적용

직접 해본 것:

  • Redis 컨테이너를 띄우고 RedisInsight로 접속해서, 로그인할 때마다 RT:이메일 키에 토큰과 TTL이 들어가는 걸 확인했다.
  • 토큰 없이 API를 호출하면 막히고, 로그인해서 받은 토큰을 헤더에 넣으면 정상적으로 통과하는지 테스트했다.
  • 회원가입 후 DB에 비밀번호가 평문이 아니라 해시값으로 들어가는 걸 확인하고, 그 계정으로 로그인이 정상적으로 되는지 테스트했다.
  • 로그아웃 요청을 보낸 뒤 다시 RedisInsight를 열어보니 그 계정의 RT 키 자체가 안 보였다.

4. 오늘의 회고

  • 느낀 점: 슬럼프를 겪는 시기인 것 같다. 조금만 더 힘내보자!
  • 다음에 할 것: 한 주 배운 것들 돌아보기

#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP

profile
이것저것

0개의 댓글