728x90

개요

레디스 사용을 안해봤기 때문에 어려울 것 같았는데.. 막상하니 너무나도 쉬웠다. 

레디스 설정은 아래 블로그에서 설정하자.
[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정 :: 미정 (tistory.com)

 

[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정

* Docker-Desktop을 이용해 MySQL DBMS를 실행프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를

eesko.tistory.com

 

기존 MySQL을 사용해서 리프레시 토큰을 저장했는데 유효기간이 끝나면 삭제가 되어야 하는데 사진처럼 계속 디비에 남아있는 것을 볼 수 있다. 스케쥴링을 사용해서 매번 지워주는 번거로운 일을 해야 하기 때문에 만약 배포를 하게 되면 너무 번거로울 것 같아서 레디스를 도입하게 되었다. 

레디스의 TTL이라는 기술을 통해 만료시간을 정해주고 만료시간이 지나면 알아서 삭제되도록 구현하도록 하겠다.

 

 

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

 

 

의존성 설치

//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis' 

 

 

 

필요한 환경변수 세팅

spring:
 data:
  redis:
    host: localhost
    port: 6379
    password: 1234

 

 

 

 

RedisConfig 

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        return redisTemplate;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration
                = new RedisStandaloneConfiguration("localhost", 6379);
        redisStandaloneConfiguration.setPassword("1234");
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }
}
  • 여기서 host와 post 번호, password는 환경변수 (yml 파일 등)을 통해 안보이게 설정하는 것이 좋다.
  • 나는 개인 프로젝트로 연습용이라 그냥 서버에서 사용했지만 실제로 배포를 하게 되면 환경변수로 설정해서 보안을 위해 안보이게 하는 것이 좋다.

 

 

TokenService.java

@Service
public class RedisTokenService {

    private final RedisTemplate<String, String> redisTemplate;
    private final String REDIS_REFRESH_TOKEN_KEY_PREFIX = "refreshToken:";

    public RedisTokenService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 리프레시 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 사용자 ID를 값으로 저장합니다.
     * @param refreshToken 리프레시 토큰
     * @param expiration 토큰 만료 시간 (밀리초 단위)
     */
    public void addRefreshToken(String refreshToken, long expiration) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        redisTemplate.opsForValue().set(key, refreshToken, expiration, TimeUnit.MILLISECONDS);
    }

    /**
     * 주어진 리프레시 토큰이 유효한지 확인합니다.
     * @param refreshToken 리프레시 토큰
     * @return 유효성 여부
     */
    public boolean isRefreshTokenValid(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        String storedUserId = redisTemplate.opsForValue().get(key);
        return storedUserId != null;
    }

    /**
     * 주어진 리프레시 토큰을 삭제합니다.
     * @param refreshToken 리프레시 토큰
     */
    public void deleteRefreshToken(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        redisTemplate.delete(key);
    }



    /**
     * 주어진 리프레시 토큰에 대해 저장된 사용자 ID를 조회합니다.
     * @param refreshToken 리프레시 토큰
     * @return 저장된 사용자 ID
     */
    public String getRefreshToken(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        return redisTemplate.opsForValue().get(key);
    }
}
  • 리프레시 토큰을 레디스에 저장하는 로직이다. RedisTemplate를 사용해서 레디스에 저장하는 코드이다.
  • 레디스는 기본적으로 키:값 형태로 저장이 되는데,
  • 키 값으로 REDIS_REFRESH_TOKEN_KEY_PREFIX: {리프레시 토큰 값} 으로 저장을 했고
  • 값으로는 리프레시 토큰 값이 저장되도록 구현했다.

 

@Service
@Slf4j
public class BlackTokenRedisService {
    private final RedisTemplate<String, String> redisTemplate;
    private final String REDIS_BLACKLIST_KEY_PREFIX = "blacklist:";

    public BlackTokenRedisService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 블랙리스트 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 만료 시간을 값으로 저장합니다.
     * @param token 블랙리스트 토큰
     * @param expiration 만료 시간 (밀리초 단위)
     */
    public void addBlacklistedToken(String token, long expiration) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        redisTemplate.opsForValue().set(key, token, expiration, TimeUnit.MILLISECONDS);
    }

    /**
     * 주어진 블랙리스트 토큰이 유효한지 확인합니다.
     * @param token 블랙리스트 토큰
     * @return 유효성 여부
     */
    public boolean isTokenBlacklisted(String token) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        String storedToken = redisTemplate.opsForValue().get(key);
        return storedToken != null;
    }

    /**
     * 주어진 블랙리스트 토큰을 삭제합니다.
     * @param token 블랙리스트 토큰
     */
    public void deleteBlacklistedToken(String token) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        redisTemplate.delete(key);
    }
}
  • 로그아웃시 AccessToken을 Redis의 블랙리스트에 저장한다.
  • 왜냐면 다시 요청이 들어왔을때 더 이상 못쓰는 AccessToken라는 것을 알려줘야하기 때문에..

<진행 로직>

1. 로그인시 Redis에 토큰을 저장함.
2. Redis AccessToken이 저장되어있다는 것은 블랙리스트에 올려져 있다는 것이고 , 사용하면 안됨.
3. 해당 access token redis에 있다면 만료된 토큰인것.
4. 로그아웃 시 Access Token 을 blacklist에 등록하여 만료시키기
5. 로그아웃 시 Redis에 저장된 RefreshToken을 삭제하고 Blacklist Access Token을 등록.
6. 다시 로그인을 할 때 Blacklist 존재하는지 확인 (로그아웃 된 토큰인지)
7. 만약 사용자가 JWt를 통해 인가를 요청했을 시 블랙리스트의 조회를 통해 로그아웃한 JWT 토큰인지 아닌지 판별
 
  • 위와 같은 형태로 진행된다.
  • 즉 로그아웃한 엑세스 토큰은 더이상 사용해서는 안되는 토큰이기 때문에 해킹이 되었을 때 보안에 신경을 쓸 수 있게 된다.
  • 참고로 엑세스 토큰은 인증/인가 전용, 리프레시 토큰은 단순 엑세스 토큰을 재발급 해주는 용도라고 보면 된다.

 

 

Filter

@Slf4j
@RequiredArgsConstructor
public class RedisJwtFilter2 extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;
    private final BlackTokenRedisService blackTokenRedisService;
    private final RedisTokenService redisTokenService;

    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/login", "/api/login", "/de","/api/users/signup","/api/mail/.*","/api/verify-code",
            "/api/check-email","/css/.*","/js/.*", "/api/check-usernick", "/api/refreshToken", "/signup",
            "/oauth2/.*","/login/oauth2/.*", "/api/users/randomNickname", "/files/.*",
            "/api/reset-password", "/api/verify-temporary-password", "/my/change-password",
            "/v3/.*", "/swagger-ui/.*"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String requestPath = request.getRequestURI();
        if (isPermitAllPath(requestPath)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = getToken(request);

        if (!StringUtils.hasText(token)) {
            handleMissingToken(request, response);
        } else {
            handleTokenValidation(request, response, token);
        }
        filterChain.doFilter(request, response);
        log.info("토큰이 있는지 확인 : {}", token);
    }

    private boolean isPermitAllPath(String requestPath) {
        return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
    }

    private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken)) {
            try {
               String storedToken = redisTokenService.getRefreshToken(refreshToken);

                System.out.println(storedToken);

                if (refreshToken.equals(storedToken)) {
                    if (!jwtUtil.isRefreshTokenExpired(refreshToken)) {
                        String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
                        setAccessTokenCookie(response, newAccessToken);
                        getAuthentication(newAccessToken);
                    } else {
                        handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
                    }
                } else {
                    handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
                }
            } catch (ExpiredJwtException e) {
                handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
            }
        } else {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
        }
    }



    private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
        try {
            if (blackTokenRedisService.isTokenBlacklisted(token)) {
                handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
            } else {
                getAuthentication(token);
            }
        } catch (ExpiredJwtException e) {
            handleExpiredAccessToken(request, response, token, e);
        } catch (UnsupportedJwtException e) {
            handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
        } catch (MalformedJwtException e) {
            handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
        } catch (IllegalArgumentException e) {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
        } catch (Exception e) {
            handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
        }
    }

    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
        log.warn("Access token expired: {}", token);
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken) && !jwtUtil.isRefreshTokenExpired(refreshToken)) {
            String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
            setAccessTokenCookie(response, newAccessToken);
            getAuthentication(newAccessToken);
        } else {
            handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
        }
    }

    private String getToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private String getRefreshToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        response.addCookie(accessTokenCookie);
    }

    private void getAuthentication(String token) {
        Claims claims = jwtUtil.parseAccessToken(token);
        String username = claims.getSubject();
        Long userId = claims.get("userId", Long.class);
        String name = claims.get("name", String.class);
        List<GrantedAuthority> authorities = getGrantedAuthorities(claims);

        CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities);
        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
        List<String> roles = claims.get("roles", List.class);
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
        handleException(request, exceptionCode, logMessage, null);
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
        request.setAttribute("exception", exceptionCode.getCode());
        log.error(logMessage, e);
        log.info("로그인을 부탁드립니다. --> 토큰이 없어요.");
    }
}
  • 위 코드에서는 달라진 점은 크게 없다. 레디스 블랙리스트에 있는지 보는 부분 말고...

 

 

 

controller.java

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api")
public class RedisController2 {

    private final JWTUtil jwtUtil;
    private final UserServiceImpl userServiceimpl;
    private final PasswordEncoder passwordEncoder;
    private final RedisTokenService refreshTokenService;
    private final BlackTokenRedisService blackTokenRedisService;

    /**
     * 로그인 요청 시 JWT 토큰 발급
     */
    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> login(@RequestBody @Valid UserLoginDto userLoginDto,
                                                      BindingResult bindingResult,
                                                      HttpServletResponse response) {
        log.info("로그인 요청이 들어왔습니다.");
        log.info("아이디 :: {}", userLoginDto.getUsername());
        log.info("비밀번호 :: {}", userLoginDto.getPassword());

        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }

        Optional<User> user = userServiceimpl.findByUsername(userLoginDto.getUsername());

        if (!passwordEncoder.matches(userLoginDto.getPassword(), user .get().getPassword())) {
            return new ResponseEntity("비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED);
        }

        List<String> roles = user.get().getRoles().stream().map(Role::getName).collect(Collectors.toList());

        // 기존 리프레시 토큰 삭제
        refreshTokenService.deleteRefreshToken(String.valueOf(user.get().getId()));

        // 토큰 발급
        String accessToken = jwtUtil.createAccessToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);
        String refreshToken = jwtUtil.createRefreshToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);

        // 리프레시 토큰을 레디스에 저장
        refreshTokenService.addRefreshToken(refreshToken, jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT);

        // 토큰 쿠키 저장
        Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT / 1000));

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);

        // 응답 값
        UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.get().getId())
                .username(user.get().getUsername())
                .build();

        return new ResponseEntity<>(loginResponseDto, HttpStatus.OK);
    }

    /**
     * 로그아웃 요청
     */
    @GetMapping("/logout")
    public void logout(@CookieValue(name = "accessToken", required = false) String accessToken,
                       @CookieValue(name = "refreshToken", required = false) String refreshToken,
                       HttpServletResponse response) {
        log.info("로그아웃 요청이 들어왔습니다.");

        if (accessToken == null) {
            try {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Access token not found in cookies.");
            } catch (IOException e) {
                log.error("로그아웃 중 오류 발생", e);
            }
            return;
        }

        try {
            // JWT 블랙리스트에 추가
            Date expirationTime = Jwts.parser()
                    .setSigningKey(jwtUtil.getAccessSecret())
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getExpiration();

            log.info("만료시간: {}", expirationTime);

            // 블랙리스트에 추가
            blackTokenRedisService.addBlacklistedToken(accessToken, expirationTime.getTime() - System.currentTimeMillis());

            SecurityContextHolder.clearContext();

            // 쿠키 삭제
            Cookie accessCookie = new Cookie("accessToken", null);
            accessCookie.setPath("/");
            accessCookie.setMaxAge(0);
            response.addCookie(accessCookie);

            Cookie refreshCookie = new Cookie("refreshToken", null);
            refreshCookie.setPath("/");
            refreshCookie.setMaxAge(0);
            response.addCookie(refreshCookie);

            // 레디스에서 리프레시 토큰 삭제
            if (refreshToken != null) {
                refreshTokenService.deleteRefreshToken(refreshToken);
            }

            response.sendRedirect("/");  // 리디렉션 처리
        } catch (IOException e) {
            log.error("로그아웃 후 리디렉션 중 오류 발생", e);
        }
    }
}
  • 여기서 로그인에 성공하면 리프레시 토큰을 레디스에 담아준다.
  • 로그아웃 시 엑세스 토큰은 블랙리스트에 담아준다.

 

 

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOauth2UserService customOAuth2UserService;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final BlackTokenRedisService jwtBlacklistService;
    private final RedisTokenService refreshTokenService;
    private final CustomSuccessHandler customSuccessHandler;
    private final JWTUtil jwtUtil; //JWTUtil 주입

    public SecurityConfig(CustomUserDetailsService customUserDetailsService, CustomOauth2UserService customOAuth2UserService,
                          AuthenticationConfiguration authenticationConfiguration, CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
                          BlackTokenRedisService jwtBlacklistService, RedisTokenService refreshTokenService,
                          CustomSuccessHandler customSuccessHandler, JWTUtil jwtUtil) {

        this.customUserDetailsService = customUserDetailsService;
        this.customOAuth2UserService = customOAuth2UserService;
        this.authenticationConfiguration = authenticationConfiguration;
        this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
        this.jwtBlacklistService = jwtBlacklistService;
        this.refreshTokenService = refreshTokenService;
        this.customSuccessHandler = customSuccessHandler;
        this.jwtUtil = jwtUtil;
    }

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",      // 메인페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/login", // 로그인 페이지
            "/api/users/signup" // 회원가입 페이지
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable())
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -> csrf.disable())
                .httpBasic(httpBasic -> httpBasic.disable())
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource configurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("http://localhost:3000"); 
        config.addAllowedOrigin("*"); 
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE")); 
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
  • 여기서도 크게 달라진건 없고 레디스에 저장하기 위해 리프레시토큰, 블랙리스트 서비스로 변경해주면 된다.

 

 

이렇게 기존 RDB를 사용해서 토큰을 저장했는데 레디스를 사용함으로써 만료시간 지정해주고 더욱 서버의 부담을 줄여줄 수 있도록 구현했다.

 

 

 

728x90
728x90

* Docker-Desktop을 이용해 MySQL DBMS를 실행

프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.

일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를 사용하냐고 물어보면 

도커(Docker)를 사용하는 이유는 무엇일까? (velog.io)

 

도커(Docker)를 사용하는 이유는 무엇일까?

도커를 사용하면 개발 환경을 이미지화 시키고 해당 이미지를 통해 개발 및 배포 환경을 쉽게 컨테이너화 시켜 구축할 수 있다. 이런 도커를 사용하는 이유에 대해 간단히 알아보자.개발을 하다

velog.io

이 분의 글을 읽으면 좋을 것 같다. 협업 과정에서 고충을 겪을 환경설정의 문제를 해결해준다는 점! 

그럼 첫 번째는 MySQL 설치부터 할 것이다.

아래에 있는 각 단계를 따라오면 된다.

 

도커에서 mysql을 사용하기 위해서 할 일은 다음 4가지이다.

  • Windows를 모두 업데이트 한다.
  • Hyper-V(가상화) 또는 WSL2를 이용해 설치를 한다.
  • Windows 버전에 따라서 설치가 어려울 수도 있다. (Home버전?)
  • Docker-Desktop을 설치한다. → 설치만 ! 실행은 나중에

 

1. 도커 설치

  • Windows용 Docker-Desktop 다운로드

Docker Desktop: The #1 Containerization Tool for Developers | Docker

 

Docker Desktop: The #1 Containerization Tool for Developers | Docker

Docker Desktop is collaborative containerization software for developers. Get started and download Docker Desktop today on Mac, Windows, or Linux.

www.docker.com

도커에서 제공해주는 메뉴얼도 있다.

Install Docker Desktop on Windows | Docker Docs

 

Install Docker Desktop on Windows

Get started with Docker for Windows. This guide covers system requirements, where to download, and instructions on how to install and update.

docs.docker.com

 

 

2. Hyper-V(가상화) 또는 WSL2를 이용해 설치

  • 나는 윈도우에서 사용중이라 이 과정을 걸쳤다.
  • 윈도우 검색에서 “기능”이라고 입력하면 윈도우 기능 켜기/끄기가 보여진다.
  • 여기에서 ‘Hyper-V’ 또는 ‘Windows 하이퍼바이저 플랫폼’을 선택한다.
  • Hyper-V는 하이퍼바이저(Hypervisor)라고 한다. 하이퍼바이저는 단일 물리적 머신에서 여러 가상 머신을 실행하는 데 사용할 수 있는 소프트웨어이다.
  • 선택 → 확인 → 윈도우를 재시작

  • 하이퍼바이저가 실행되려면 BIOS에서 가상화 관련된 옵션이 사용가능해야한다.
  • PC마다 BIOS메뉴 구성은 다를 수 있다.
  • 자신의 메인보드 이름으로 구글에서 검색한다.
  • 보통 BIOS는 컴퓨터가 켜지기 전 DEL , F2 키를 빠르게 입력하면 진입 가능하다. 컴퓨터마다 진입 버튼은 다를 수 있으니 찾아봐야 함

 

3. WSL2 설치

  • 관련메뉴얼이다

WSL 설치 | 마이크로소프트 런 (microsoft.com)

  • Windows의 PowerShell을 관리자 권한으로 실행 후 아래 명령어 2줄을 입력해서 WSL을 활성화 한다.
# WSL2 설치
> dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
> dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
  • 이후 윈도우를 재시작한다.

 

3.1. WSL2 Linux 커널 업데이트

  • 본인 PC의 CPU 아키텍처에 맞춰 설치 파일을 다운로드 한다. 보통 x64용

x64 : https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi

ARM64 : https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_arm64.msi

  • Windows의 PowerShell을 관리자 권한으로 실행 후 다음 명령어 실행
# WSL2 Linux 커널 업데이트
> wsl --set-default-version 2

 

 

WSL2 명령 모음

# 사용가능한 Linux 배포판 리스트 보기
> wsl --list --online

# 특정 리눅스 배포판 설치하기 (Linux를 사용해보고 싶으면 나중에 설치해보기)
> wsl --install --distribution <Distribution Name>
# ex
> wsl --install --distribution Ubuntu

# 설치된 Linux 배포판 리스트
> wsl --list --verbose

# 특정 리눅스 배포판 삭제하기
> wsl --unregister <Distribution Name>
# ex
> wsl --unregister ubuntu

 

3.2 도커 실행

  • 이전에 다운로드한 Docker Desktop Installer.exe 파일을 실행하면 WSL2 환겨에서 실행할 것인지 묻는 checkbox가 보일 수 있다. (권장)
  • WSL은 Hyper-V 기반이 아닌데, WSL2는 Hyper-V 기반 !
  • 설치 완료 후, PC를 재시작한다.
  • 이후, docker-desktop을 실행하면 처음 보여지는 화면 동의(Accept)한다.
  • 첫 번쨰 항목을 선택하고 Finish 버튼을 클릭한다.
  • 윈도우가 재시작할 때 docker desktop이 자동으로 재시작되길 원한다면 첫 번째 체크박스를 설정한다. (Start Docker Desktop when you sign in to your computer)

 

3.3 WSL에 설치된 배포본을 확인

  • wsl에 docker-desktop이 설치된 것을 확인할 수 있다.
  • docker-desktop을 설치하고 실행하면 WSL2에 docker가 제공하는 리눅스가 설치된다.
  • 이 리눅스가 docker-desktop이며, docker로 실행되는 이미지들은 해당 리눅스 커널 위에서 동작한다고 보면 된다.
# WSL에 설치된 배포본 확인
> wsl --list --verbose

ex

 

 

 

3.4  MySQL DBMS를 실행하기 위한 docker-compose.yml 파일을 준비

  • Windows 10
    • c:/docker/mysql 폴더에 docker-compose.yml 파일을 작성
    • docker-compose.yml
version: "2"

services:
  vacation-db:
    image: mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "루트계정암호" # "root1234"
      MYSQL_DATABASE: "database이름"     # "examplesdb"
      MYSQL_USER: "아이디"               # "dms2756"
      MYSQL_PASSWORD: "암호"             # "123456789"
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./database/init/:/docker-entrypoint-initdb.d/
      - ./database/datadir/:/var/lib/mysql
    platform: linux/x86_64
    ports:
      - 3306:3306

 

3.5 docker-compose up -d

  • yml 파일이 있는 곳에서 터미널을 열고 MySQL 서버를 실행하는 명령어를 입력한다.
# MySQL 서버 실행
> docker-compose up -d
  • -d 옵션은 백그라운드로 실행하는 옵션이다.
  • 파일 내 volumes 설정은 Docker 안에서 실행되는 리눅스의 /docker-entrypoint-initdb.d/ , var/lib/mysql 폴더가 docker-compose.yml 파일이 원래 있는 경로에 database 폴더를 생성하고, 그 아래의 init, datadir 에 생성되도록 한다.
  • 원래 docker는 종료되면 docker에서 실행된 내용들은 모두 사라진다.
  • 이렇게 docker를 실행하는 컴퓨터 쪽의 디스크와 마운트 시킴으로써 사라지지 않고 계속 사용할 수 있게 할 수 있다.
  • # docker-compose.yml의 일부 volumes: - ./database/init/:/docker-entrypoint-initdb.d/ - ./database/datadir/:/var/lib/mysql

 

docker-compose.yml 파일이 있는 경로

  • docker-compose.yml 파일이 있는 곳에 database 폴더가 있는 것을 볼 수 있다.
  • 해당 폴더 안에 mysql의 data가 저장된다.
  • docker-compose down 명령을 실행하면 mysql 서버가 종료된다. database 폴더를 삭제하고 다시 실행하면 모든 데이터가 삭제된다. (주의)
  • docker ps 명령으로 실행되는 이미지에서 container id를 복사 후 mySQL 이미지에 접속한다.
> docker ps
# mysql 접속
> docker exec -it {복사한conainterID} /bin/bash

 

 

그럼 끝났다.

 

 


* Docker-Desktop을 이용해 Redis를 실행 

위의 과정을 통해 도커가 설치되어 있다는 가정 하에 아래를 진행한다.

 

 

1. 도커 폴더 아래 redis 폴더를 생성한다.

2. redis 폴더 아래 docker-compose.yml 작성한다.

services:
  redis:
    image: redis:latest # 최신 Redis 이미지를 사용
    container_name: redis
    command: ["redis-server", "--requirepass", "{password}", "--appendonly", "yes"]
    ports:
      - "6379:6379" # 로컬의 6379 포트를 Redis 컨테이너의 6379 포트에 매핑
    volumes:
      - ./redis-data:/data # 로컬의 'redis_data' 볼륨을 컨테이너의 /data 디렉토리에 매핑하여 데이터 영속성 유지

volumes:
  redis-data:

3. 터미널을 열어서 아래의 명령어를 입력한다.

# 레디스 이미지 가져오기
> docker pull redis

# 이미지 가져왔는지 확인
> cker images

# 레디스 실행
> docker compose up -d

> docker ps 
# 레디스 컨테이너 접속
> docker exec -it {레디스 컨테이너 아이디} redis-cli

# 레디스 암호
> AUTH {password} 

# 모든 키 검색
> KEYS * 

# 키 값 출력
> KEYS refreshToken:* 

# 해당 키에 대한 값 출력
> GET {키 값} 

# 해당키의 유효 시간 출력
> TTL {키 값} 

# 해당키 삭제
> DEL {키 값}

 

 

 

그럼 레디스도 잘 설치될 것이다.

728x90
728x90

멋사 파이널 프로젝트에서 인증/인가를 담당했다.

이때 로그인시 인증에 대해서 JWT 로그인을 구현하였다.

 

JWT 토큰을 이용한 로그인에는 Access-Token과 Refresh-Token이 쓰이는데,

Access-Token은 인증을 위한 토큰으로 사용되고, Refresh-Token은 액세스 토큰을 재발급 하는데 사용된다.

 

[기존의 Refresh Token 사용 방식]

입력한 회원정보와 가입한 회원정보가 일치할 시, Login을 할때, Refresh-Token을 서버에서 만들어 쿠키에 저장하고 MySQL database에 저장후 액세스 토큰의 유효기간 만료로 인한 토큰 재발급 시, 리프레시 토큰값을 비교하여 일치 할 경우, 새로운 액세스 토큰과 리프레시 토큰을 발급하도록 구현했다.

 

하지만 이 방식의 아쉬운 점은, Refresh-Token을 발급한 이후, 별도의 로그아웃 API 호출이 없는 경우 깔끔하게 토큰이 DB에서 삭제되지 못한다는 점이다.

 

그래서 프로젝트 리팩토링을 진행하면서, MySQL에 저장했던 Refresh-Token을 Redis로 바꾸었다.!!

 


왜 Redis 인가? Redis vs RDB

Redis 는 리스트, 배열 형식의 데이터 처리에 특화되어 있다. 리스트 형 데이터의 입력과 삭제가 MySQL보다 10배 정도 빠르다. 이런 Redis 를 RefreshToken 의 저장소로 사용할 경우, 빠른 접근 속도로 사용자가 로그인시(리프레시 토큰 발급시) 병목이 발생할 가능성을 낮출 수 있다.

 

또 Refresh Token 은 발급된 이후 일정시간 이후 만료가 되어야한다. 리프레시 토큰을 RDB 등에 저장하면, 스케줄러등을 사용해서 만료된 토큰을 주기적으로 DB 에서 제거해 줘야 한다.

그러나 Redis 는 기본적으로 데이터의 유효기간(time to live) 을 지정할 수 있다. 이런 특징들이 바로 Refresh Token 을 저장하기에 적합하다고 생각했다.

 

또한 Redis 는 휘발성이라는 특징으로 인해 데이터가 손실될수도 있는 위험이 있으나, Redis 에 저장될 리프레시 토큰은 손실되더라도 그리 손해가 큰 편은 아니다. 기껏해봤자 RefreshToken 이 없어져서 다시 로그인을 시도해야 하는 정도이다.

Trand-Off를 고려했을 때, 이는 큰 문제가 아니라 생각해서 Redis에 Refresh-Token을 저장하려는 결정을 내렸다.

 


Redis 사용 시 이점

1. 빠른 액세스와 만료 관리

  • Redis는 메모리 기반 데이터 저장소이므로 매우 빠른 읽기/쓰기를 제공
  • 이를 통해 refreshToken의 유효성을 빠르게 확인하고 필요 시 만료시킬 수 있다.
  • 짧은 응답 시간은 보안 이벤트(예: 토큰 탈취 시도)에 빠르게 대응할 수 있게 해준다.

 

2. 간편한 만료 및 삭제

  • Redis는 TTL(Time to Live) 기능을 제공하여 키에 유효기간을 설정할 수 있다.
  • 이를 통해 refreshToken의 자동 만료가 가능하며, 특정 조건에서 토큰을 신속히 무효화할 수 있다.
  • 이는 RDBMS보다 효율적이며 보안 사고 시 피해를 줄일 수 있다

 

3. 세션 무효화 용이성

  • 유저가 로그아웃하거나 비정상적인 활동이 감지될 경우 Redis에서 특정 refreshToken을 쉽게 삭제할 수 있다.
  • RDBMS에서는 복잡한 쿼리와 트랜잭션 처리가 필요할 수 있지만, Redis에서는 단일 명령으로 처리할 수 있어 더 빠르고 효과적

 

4. 스케일링의 용이성

  • Redis는 분산 캐시 시스템을 지원하며, 이를 통해 큰 규모의 사용자를 처리할 때에도 성능을 유지할 수 있다.
  • 이는 특히 대규모 시스템에서 보안 모니터링과 대응 속도를 유지하는 데 중요

 

5. 분리된 저장소

  • refreshToken을 RDBMS가 아닌 Redis에 저장함으로써 데이터베이스와 캐시 간에 책임을 분리할 수 있다.
  • 이는 데이터베이스에 대한 접근을 최소화하여 공격 벡터를 줄일 수 있다.
  • 예를 들어, 데이터베이스가 공격을 받더라도 캐시에 저장된 토큰 정보는 노출되지 않을 수 있다.
728x90
728x90

개요

JWT 공부를 시작했을 때 JWT를 왜 사용하는지를 한동안 고민에 잠겼고 결론에 대해서 정리하고 싶어서 작성하게 되었다.

 

JWT의 STATELESS 상태에 대한 집착

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
    private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록

    // AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
  
    private final JWTUtil jwtUtil; //JWTUtil 주입

    // AuthenticationManager Bean 등록
    // AuthenticationConfiguration 인자를 또 받기 때문에 주입
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/login", "/", "/join").permitAll()
                        .requestMatchers("/admin").hasAuthority("ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form.disable())
                .httpBasic(auth -> auth.disable())
                .sessionManagement(session -> session // jwt 방식에서는 session --> STATELESS
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable())
                // --> 우리는 formLogin 을 disable 해놨기 때문에 UsernamePasswordAuthenticationFilter 을 직접 커스텀 해야한다.
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class)
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class)
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource())
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
	@Bean
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET","POST","DELETE"));
        source.registerCorsConfiguration("/**",config);
        return source;
    }
}
  • 위의 시큐리티 JWT config를 구성한 코드를 보면
  • SessionCreationPolicy.STATELESS 상태가 등장하는 것을 볼 수 있다.
  • 그래서 나는 이 STATELESS 상태에 초점이 맞추어 생각을 했다.
    --> JWT 구현을 위해 STATELESS 상태가 필요하지만 STATELESS에 초점을 잡고 생각을 하니 문제가 많아졌다.
    --> 아래를 읽다보면 왜 문제인지 알 수 잇을 것이다.

 

문제 상황

  • 일단 기본적으로 JWT를 사용하면 토큰 탈취의 문제로 기존 Access 토큰과 함께 Refresh 토큰의 도입을 이야기한다.
  • 따라서 Refresh, Access라는 2가지의 토큰을 발급해주는데, Refresh 토큰 요청는  Access 토큰 만료보다 주기 자체가 길기 때문에 탈취 당할 확률은 낮다고는 하지만 어찌되었든 탈취 당할 수 있다.
  • 그래서 이 탈취 당할 확률을 방지하기 위해 서버측에서 어떠한 방법을 구현해야 하는데 이 부분에 대해서 많은 고민을 했다. 




레디스, 데이터베이스의 도입

  • 만약, 토큰이 탈취 되었을때 서버의 제어권과 로그아웃 문제 등 을 생각해보자.
  • 일단 토큰이 탈취되면 토큰의 만료 기간 까지 서버측은 고통을 받을 것이다.
  • 따라서 아예 서버를 꺼버리거나 서버 비밀키를 변경하는 상황까지 가는 불상사가 발생할 것이다.
  • 혹은 프론트 서버측 로그아웃을 구현할 수도 있는데 이때 이미 해커가 토큰을 복제 했다면 해커는 그 토큰을 가지고 계속 서버에 접속할 수 있기 때문에 여전히 문제가 있다.
  • 그래서 서버측에서 이러한 문제에 대해서 구현해줘야 하는데 이를 위해 서버측 Redis와 같은 저장소에 발급한 Refresh 토큰을 저장해서 로그아웃을 하거나 토큰이 탈취 당했을 때 블랙리스트를 도입하여 블랙리스트에 저장한다는 구현들이 많았다.
  • 그래서 로그아웃 상태거나 탈취된 토큰은 Redis 서버에서 제거하여 앞으로 Access 토큰 재발급이 불가능하도록 설정하는 것이었다.



하지만 모순...

  • 위처럼 서버에서 레디스같은 저장소를 도입하는 것을 이야기 했지만 나는 그것은 모순이라고 생각했다.
  • Refresh들을 저장하기 위한 Redis를 도입해버리면 사실상 세션 클러스터링을 작업하고 세션 방식을 사용하는 것이 좋지 않을까? 라는 생각을 하게 된 것이다.
  • 왜냐면 나는 jwt config 를 작성하면서 앞 단에서 세션 STATELESS 작업을 했는데, 뒷단인 다른 곳에서 상태 저장이 생겨버리는 것이 아닌가 라는 생각이 든 것이다.
  • 그래서 탈취를 막으면서도 Redis를 도입하지 않을 방법에 대해서 고민을 했지만.....
  • 시원한 해답은 얻지를 못했다. 아래처럼 계속 둘레에 빠져 한 곳만 바로보고 있던 것이다.
    STATELESS → 그런데 Redis → 그럼 차라리 세션 → 왜 JWT를 사용했지? 

 

결론 : JWT를 왜 사용하는가?

우리는 우리가 할려는 일에 대해서 목표가 무엇인지 판단해야 한다.
우리가 JWT의 목적을 확인하지 않고 구현에만 열중한다면 무엇을 하는지도 모르는 것에 불과하다. 따라서 JWT의 STATELESS한 상태에만 목적을 두는 것이 아닌 JWT가 왜 필요한지를 생각했고 해답을 찾았다.


JWT를 사용한 이유

  • 모바일 앱
    • JWT가 사용된 주 이유는 결국 모바일 앱의 등장이다.
    • 모바일 앱의 특성상 주로 JWT 방식으로 인증/인가를 진행한다.
    • 그렇기 때문에 결국 세션 STATLESS는 앱의 등장으로 인해 부수적인 효과인 것이다.
  • 장시간 로그인과 세션
    • 장기간 동안 로그인 상태를 유지하려고 세션 설정을 하면 서버 측 부하가 많이 가기 때문에 JWT 방식을 이용하는 것도 한 방법이다.

위의 이런 이유때문에 jwt가 만들어졌고 JWT의 목적이 STATELESS가 아니기 때문에 나중에 로그아웃에 대해서 레디스나 디비에 리프레시 토큰을 저장하는 로직을 추가하는 것이 올바르다고 판단을 내렸다. 

728x90

+ Recent posts