MarbleUs Project #2 Spring Security/JWT Token /Redis 인증/인가

John Jun·2023년 10월 3일
0

table of contents


1. 인증 시나리오
2. Spring Security Filter
3. JwtAuthenticationFilter & OAuth2LoginAuthenticationFilter
4. AuthenticationFilter
5. trouble Shooting #1 문제 파악
6. trouble Shooting #2 적용 및 결과
7. 회고

1. 인증 시나리오

내가 맡은 기능 역할 중 가장 중요하고 선행이 되어 했던 부분은 역시 보안 인증에 관한 부분이였고 이를 달성하기 위해 우선 사용자가 어떻게 인증을 진행할지 그 시나리오를 먼저 생각해보기로 하였다. 기본적으로 우리 서비스는 장바구니등의 유저의 상태를 유지시킬 필요가 크게 없는 서비스라 생각이 들었고 무엇보다 AWS에서 제공하는 프리티어로 진행하는 프로젝트였기 때문에 서버의 메모리 사용량을 최소화 할 필요가 있었고 이에 나는 세션을 사용한 로그인 인증 방식 보다는 JWT 토큰을 이용한 인증 방식이 더 효율적이라 생각하고 이를 채택하였다. 내가 처음 생각한 시나리오는 이렇게 진행되었다.

  1. 사용자(유저)는 기본 회원가입 혹은 구글의 OAuth2.0서비스를 이용해 본인의 정보를 등록하게 된다.
  2. 서버에서는 회원이 기입한 비밀번호를 Base64인코딩을 통해서 암호화 한 후 DB의 Member 테이블에 저장한다.
  3. 유저는 회원가입을 통해 저장된 이메일과 비밀번호를 이용하여 로그인을 시도하고 Sping Security의 필터에서 credential를 검증하게 된다.
  4. 유저의 credential이 검증이 되었다면 서버에서는 30분의 만료시간을 가진 유저의 이메일만을 저장한 Access Token과 짧은 만료시간을 가진 액세스 토큰의 자동 재발급을 위한 Refresh Token을 secretkey를 이용하여 발급하고 이를 헤더 혹은 Redirect되는 URL에 담아 전송한다.
  5. 클라이언트는 발급받은 두 토큰을 localStorage에 저장한다.
  6. 클라이언트 단에서 해당 유저의 모든 요청에 이 두가지 토큰을 헤더에 담아 보내고 서버의 시큐리티 필터에서 이를 잡아 Access Token의 유효성을 검증하고 만약 만료된 토큰이라면 Refresh 토큰을 검증하여 Access Token을 갱신한 후 이를 Response헤더에 담아 보낸다.
  7. 클라이언트는 갱신된 Access Token으로 요청을 재게한다.

모든 요청에 Access와 Refresh 토큰을 항상 모두 담아 보냄으로 새로운 토큰을 발급하는 과정을 간략화 하였다.

해당 시나리오를 현실화하기 위해 먼저 보안의 기본 시나리오를 짜기위해 우선 Spring Security Filter를 사용하기위한 Configuration 클래스를 만들어야하였다.
우선 기본적인 http요청에 대한 시큐리티 필터의 흐름고 설정들을 위한 filterChain메서드를 만들어 @Beane등록을 해 주었다.

2. Spring Security Filter

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .headers().frameOptions().sameOrigin() //h2 이용하기위한 설정
                .and()
                .csrf().disable()
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .exceptionHandling()
                .authenticationEntryPoint(new MemberAuthenticationEntryPoint(jwtTokenizer,authorityUtils,extractor))
                .and()
                .apply(new CustomFilterConfigurer())
                .and()
                .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()
                )
                .oauth2Login(oauth2 -> oauth2.successHandler(new OAuth2memberSuccessHandler(jwtTokenizer,authorityUtils,memberService,extractor,memberVerifier,nickNameGenerator,passwordEncoder)))
                .logout()
                .logoutUrl("/logout")
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "POST"))
                .addLogoutHandler(new CustomLogoutHandler(redisServiceUtil,extractor))
                .logoutSuccessUrl("http://marbleus-s3.s3-website.ap-northeast-2.amazonaws.com");
        return http.build();
    }

시큐리티 필터에서 커스펌 필터를 사용하기 위한 AbstractHttpConfigurer를 상속하는 CustomFilterConfigurer 또한 이너클래스로 정의해 주었다.

public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer,HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer,authorityUtils);

            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer, memberService,redisServiceUtil,extractor);
            jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");  //기본 로그인 시도 주소 프론트에서 이 URL로 로그인을 시도한다.       //

           	builder.addFilter(jwtAuthenticationFilter);
            builder.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
            builder.addFilterAfter(jwtVerificationFilter, OAuth2LoginAuthenticationFilter.class);
        }
    }

커스텀해 사용할 필터들을 DI받고 이를 builder를 사용하여 순서를 정의하여 주었다.

3. JwtAuthenticationFilter & OAuth2LoginAuthenticationFilter

JwtVerificationFilter에서는 모든 요청의 헤더에서 Access Token을 검증하고 인증를 하는 역할을 한다.
그리고 JwtAuthenticationFilter 와 OAuth2LoginAuthenticationFilter는 최초의 인증(로그인)을 담당한다. 따라서 이 두 필터를 통해 기본 로그인을 시도하고 다음 모든 요청에 JwtVerificationFilter에서 요청헤더의 토큰을 검증한다.

이렇게 기본 설정을 위한 클래스들을 만들고 다음으로 정의한 커스텀 필터들 또한 만들어 주었다.

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenizer jwtTokenizer;
    private final MemberService memberService;
 

//1. 로그인 요청이 들어올때 사용자가 기입한 크레덴셜을 검증한다.

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
		// loginDto를 통해 유저가 credential의 검증을 요청할때 ObjectMapper를 통해 그 값을 읽어온다. 
        
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken); //실제적인 인증은 현재 필터가 아닌 AuthenticationManager가 대신한다. 이때, 우리 서비스는 email을 유저네임으로 사용할 것이기 때문에 데이터베이스에서 사용자의 크리덴셜을 조회한 후, 조회한 크리덴셜을 AuthenticationManager에게 전달하는 UserDetailsService를 커스텀하여야 한다. 이후 이어서 설명하곘다.
    }

//2. 크레덴셜의 검증이 성공했을때 액세스 토큰과 리프레쉬 토큰을 JwtTokenizer클래스를 통해 generate하여 response의 헤더에 담아 클라이언트에 전송한다.

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Member member = (Member) authResult.getPrincipal();  

        String accessToken = delegateAccessToken(member);   
        delegateRefreshToken(member); 
        log.info("accessToken is generated");


        response.setHeader("Authorization", accessToken);  
        response.setHeader("Refresh", refreshToken);
    }

// * Jwt 토큰을 생성하는 메소드들 *

    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", member.getEmail());
        claims.put("roles", member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

   
    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}

OAuth2.0인증을 위한 핸들러 또한 크게 다르지 않다. 다만, 오어스 로그인은 구글의 로그인 서비스 로직을 따라야 하므로 응답 헤더에 토큰을 담기보다는 url에 두가지 토큰을 담아 클라이언트로 넘겨주고 이를 프론트단에서 노출되지 않고 localStorage에 저장만 하는 역할을 가진 페이지를 만들어 최초 로그인 과정을 구현하였다. 또한 이때 사용자의 최소한의 정보를 DB에 저장하였다.

다음으로 최초의 로그인이 성공한 후의 모든 요청에 대해 토큰을 검증하기 위한 OncePerRequestFilter를 상속한 JwtVerificationFilter을 만들어 주었다.

@Slf4j
public class JwtVerificationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;

    public JwtVerificationFilter(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

	//1. 일차적으로 토큰이 우리 서버에서 발행한 형식에 맞는 토큰인지(Bearer)를 검증하고 맞지 않거나 토큰이 없다면 필터를 실행시키지 않고 인증을 실패시킨다.
    
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization");

        return authorization == null || !authorization.startsWith("Bearer");
    }

//2. 서버에서 발행한 토큰이 존재한다면 다음으로 JwtTokenizer를 이용하여 서버의 Secret 키를 이용해 디코딩하고 디코딩에 성공했다면 그 유효시간을 다음으로 확인하여 유효시간이 지나지 않은 토큰이라면 인증을 성공시킨다.
    

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            Map<String, Object> claims = verifyJws(request); // 1. 토큰을 검증
            setAuthenticationToContext(claims);           // 2. 검증이 성공하면 ContextHolder에 유저 정보를 담은 UsernamePasswordAuthenticationToken을 만들어 올린다.

        } catch (SignatureException se) {  //* 서버에서 만든 시크릿 키로 디코딩을 할 수 없을때 유효하지 않은 토큰임을 인지하고 인증을 실패시킨다.(실제적인 실패처리는 AuthenticationEntryPoint에서 진행된다.)
            request.setAttribute("exception", se);
        } catch (ExpiredJwtException ee) { //* 토큰의 만료시간이 지났을 경우 AuthenticationEntryPoint애서 이를 캐치하여 Refresh 토큰을 확인하고 Access 토큰을 재발급한다. 만약, 리프레쉬 토큰 또한 만료되었다면 인증을 실패 시킨다.
            request.setAttribute("exception", ee);
        } catch (Exception e) { //* 그외의 기타 예외들을 처리한다.
            request.setAttribute("exception", e);
        }



        filterChain.doFilter(request,response); // 필터체인에서 현재 필터를 실행 시킨다.
       
    }

    private void setAuthenticationToContext(Map<String, Object> claims) {
        String username = (String) claims.get("username");
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));

        Authentication authentication = new UsernamePasswordAuthenticationToken(username,null,authorities);

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

    private Map<String, Object> verifyJws(HttpServletRequest request) {
        String jws = request.getHeader("Authorization").replace("Bearer ", "");
        String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());
        Map<String,Object> claims = jwtTokenizer.getClaims(jws,base64EncodedSecretKey).getBody();
        return claims;
    }
}

doFilterInternal에서 발생한 예외들은 현재 필터에서 처리하지 않고 Attribute만 발생한 exception으로 바꾼뒤 AuthenticationEntryPoint에 그 처리를 위임한다. 나는 Refresh 토큰을 검증하여 새로운 Access 토큰을 발급해 줄 위치를 고민하며 그 흐름을 파악하였고 AuthenticationEntryPoint에서 만료된 토큰을 캐치하는 부분에서 이 일을 수행하면 될 것 이라 생각하였고 이를 적용하기 위해 커스텀 AuthenticationEntryPoint를 만들어 주었다.

4. AuthenticationEntryPoint

@Slf4j
@Component
@RequiredArgsConstructor
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;


    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        Exception exception = (Exception) request.getAttribute("exception"); // Exception을 담을 요청에서 해당 exception을 읽어 온다.

        if (exception instanceof ExpiredJwtException) { // 해당 Exception이 ExpiredJwtException에 해당한다면 아래의 코드들을 실행한다.

            Map<String, Object> claims = verifyJws(request);//리퀘스트 헤더에서 Refresh토큰을 읽어 유효성을 검증하고 유저 정보를 담은 claims를 읽어온다.
            
            // Refresh 토큰의 검증이 성공했다면 다음에서 새로운 Access 토큰을 발급한다.
            
            Map<String, Object>  newClaims = new HashMap<>();
            String username = (String) claims.get("sub");
            List<String> roles = authorityUtils.createRoles(username);
            newClaims.put("username",username);
            newClaims.put("roles", roles);

            Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
            String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());
            
            String accessToken = jwtTokenizer.generateAccessToken(newClaims,username,expiration, base64EncodedSecretKey);


            setAuthenticationToContext(newClaims); // ContextHolder 유저 정보를 저장해 로그인 상태를 등록한다. 

           
            //새로운 액세스 토큰 응답 헤더에 담아 전송

            response.setHeader("Authorization",accessToken);


        } else {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // Set the unauthorized status code
            response.getWriter().write("Token expired or invalid"); // Set the error message
            logExceptionMessage(authException, exception);
        }

    }

    private void setAuthenticationToContext(Map<String, Object> claims) {
        String username = (String) claims.get("username");
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));

        Authentication authentication = new UsernamePasswordAuthenticationToken(username,null,authorities)
        
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }



    private Map<String, Object> verifyJws(HttpServletRequest request) {
        String jws = request.getHeader("Refresh").replace("Bearer ", "");
        String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());
        Map<String,Object> claims = jwtTokenizer.getClaims(jws,base64EncodedSecretKey).getBody();
        return claims;
    }

    private void logExceptionMessage(AuthenticationException authException, Exception exception) {
        String message = exception != null ? exception.getMessage() : authException.getMessage();
        log.warn("Unauthorized error happened: {}", message);
    }

}

여기까지 인증을 담당하는 필터들을 그 흐름에 맞게 설명하였다. 다음은 필터들 내부에서 공통적으로 사용되고 있는 토큰을 만들고 유효성을 검증하는 JwtTokenizer, AuthenicationManager가 DB에 저장된 사용자를 로드하여 로그인 정보와 비교하여 실제적인 인증을 실행하기 위해 커스텀해 주었던 커스텀 UserDetailsService 클래스를 설명하겠다.

먼저 JwtTokenizer의 모습이다.

package com.marbleUs.marbleUs.common.auth.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;

@Component
public class JwtTokenizer {

    @Getter
    @Value("${jwt.key.secret}")
    private String secretKey;

    @Getter
    @Value("${jwt.access-token-expiration-minutes}")
    private int accessTokenExpirationMinutes;

    @Getter
    @Value("${jwt.refresh-token-expiration-minutes}")
    private int refreshTokenExpirationMinutes;

    public String encodedBasedSecretKey(String secretKey){
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String generateAccessToken(Map<String,Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey){
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

    public String generateRefreshToken(
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey){
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
        return Jwts.builder()
                .setSubject(subject)
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

    public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey){
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);

        return claims;
    }

    public void verifySignature(String jws, String base64EncodedSecretKey){

        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);
    }

    public Date getTokenExpiration(int expirationMinutes){
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, expirationMinutes);
        Date expiration = calendar.getTime();
        return expiration;
    }

    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
    byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
    Key key = Keys.hmacShaKeyFor( keyBytes );
    return key;
    }
}

다음은 UserDetailsService 인터페이스를 구현한 MemberDetailService의 모습이다. 유저네임이 이메일과 같음을 정의해 주어야 로그인시 받은 이메일 정보로 인증이 가능하다.

@Component
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final CustomAuthorityUtils authorityUtils;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        return new MemberDetails(findMember);
    }

    private class MemberDetails extends Member implements UserDetails {
        public MemberDetails(Member findMember) {
                setId(findMember.getId());
                setEmail(findMember.getEmail());
                setPassword(findMember.getPassword());
                setRoles(findMember.getRoles());
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorityUtils.createAuthorities(this.getRoles());
        }


        @Override
        public String getUsername() {
            return getEmail(); // 해당 서비스에서는 유저네임이 이메일임을 정의한다.
        }

        @Override
        public boolean isAccountNonExpired() {
            return true;
        }

        @Override
        public boolean isAccountNonLocked() {
            return true;
        }

        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }

        @Override
        public boolean isEnabled() {
            return true;
        }
    }
}

여기까지 일차적으로 JWT토큰인증 방식으로 로그인 기능을 구현하였다. 기능을 완성하였고 테스트도 성공을 하였지만 시나리오를 검증하고 프론트와 합을 맞추면서 몇가지 문제점들과 개선되어야 할 점들이 발견이 되었다. 다음에서 어떠한 문제점들이 발견하였는지와 그 해결책을 논의해 보겠다.

5. trouble Shooting #1 문제 파악

  1. 가장 큰 문제점은 무엇보다도 보안이였다. 해당 로그인 인증파트를 맡은 프론트 협업자분이 토큰을 핸들링하는 부분을 굉장히 어려워 하셨다. 원래의 계획한 시나리오는 Access Token이 만료되었을 시 서버는 이를 캐치하여 Refresh 토큰을 요청하게 되고 프론트는 따로 다른 위치에 저장해 놓은 Refresh토큰을 서버로 보내주는 보안을 위한 추가적인 과정을 계획하였으나 이를 핸들링하는 부분에서 굉장히 어려워 하셨고 나는 이를 해결하기 위해 두가지 토큰을 모든 요청에 같이 보내고 이를 Access토큰 만료시에 Refresh토큰을 바로 검증하고 새로운 토큰을 발급해 주는 식으로 간단화 하였다. 하지만 Refresh토큰이 탈취될 수 있다는 굉장히 큰 리스크라 판단되어지는 문제가 대두되었다.

  2. 또한 네트워크의 비용에 대한 문제였다. 사실 큰 문제는 아니였지만 쿼리의 수와 그 양을 최대한 줄여 성능을 향상 시키는것은 언제나 서버 개발자의 숙명과도 같은 의무라 생각하는 나에게는 큰 고심거리였다. 모든 요청에 헤더 하나가 추가된다는 것은 나에게 결코 유쾌하지 않은 부분이였다.

해결책

근본적인 해결책은 Refresh토큰의 핸들링을 서버에서 오롯이 전담하자는 것이였다. 제한된 시간과 리소스를 생각해 봤을때 프론트분이 이 문제를 다루는 부분에 익숙해 지기를 기다리는 것 보다는 서버에서 이를 핸들링하여 프론트의 수고를 덜어드리는게 나은 선택이라 판단되었다. 프론트의 작업량이 상당하다는것을 충분히 경험을 통해 인지하고 있었기에 내린 결정이였다. 또한 보안적인 측면에서 생각해 보았을때도 서버에서 Refresh 토큰을 보관하고 로딩하는 것이 훨씬 안전하다고 판단이 되어 더욱 확신을 가지고 진행 할 수 있었다.

첫번째 시도: 처음에는 Refresh 토큰을 DB에 저장하는 쪽으로 시나리오를 수정하였다. DB에 Refresh 토큰을 저장하고 Access 토큰의 만료시 해당 Refresh토큰을 로드하여 검증/토큰 재발급하면 되므로 프론트는 딱히 이를 핸들링하는 부분에 신경을 쓰지 않아도 되었고 새롭게 헤더에 담겨오는 Access 토큰을 업데이트만 해주면 되는 부분이였고 보안적으로도 훨씬 안전해졌기에 충분히 만족할만한 부분이였다. 하지만 나는 여기서 새로운 문제, 혹은 개선사항들을 발견할 수 있었다. 그것들은 아래와 같다.

1. 관계형 DB를 사용하고 있었기에 간단한 토큰하나를 읽어오는대도 맴버(유저)와 매핑된 토큰을 찾기위해 쓸데없이 추가적인 쿼리들이 발생한다는것

2. 450분이라는 짧은 만료시간을 가진 웹 애플리케이션임에 리프레쉬 토큰이 만료시에 필요한 토큰 삭제가 빈번하게 발생해야 했고 이를 위해 추가적인 쿼리들을 자주 발생시켜야 했기에 비효율적이라 느껴졌다.

위의 두 문제들은 사소해 보였지만 향후 서비스가 커지고 사용자가 많아짐에 따라 서비스의 속도와 성능이 저하 될 수 있다고 생각이 들었다. 특히나 사용자가 인식하지 못하고 진행되어야 할 토큰 갱신부분 때문에 하나의 트랜잭션으로 묶인 비지니스 로직들의 속도가 저하될 수 있다는것은 바람직하지 않다고 생각되었다. 그래서 생각하였다. 인메모리를 사용하자!

두번째 시도: 그래서 나는 빠르게 그 값을 저장하고 읽어올 수 있는 자바의 인메모리, HashMap를 사용하여 이 문제를 해결하기로 했다. HashMap를 사용한다면 굉장히 향상된 속도로 토큰을 다른 테이블과 매핑할 필요없이 저장 및 삭제를 할 수 있었고 굉장히 효과적인 대안이 될 수 있을것으로 보였다. 그래서 키값으로 유저의 아이디를 두고 value값으로 토큰을 저장하는 형식으로 코드를 업데이트 하였고 이는 굉장히 효과적이였다. 하.지.만.. 상상도 못했던 아주 커다란 문제를 직면하고 말았다. 그것은 바로 데이터의 동시성 문제였다. 내가 이번 프로젝트 동안 가장 중점시 하였던것을 확장에 유연하게 대처할 수 있는 서버와 테이블을 만드는것이였는데 이 부분에서 아주 크게 이 해결책이 문제가 되었다. 서비스가 스케일 아웃될 경우 자바의 인메모리를 사용한다면 모든 인스턴스가 같은 값을 공유하지 못하고 Refresh 토큰을 저장한 인스턴스와 사용자가 보낸 요청을 처리하는 인스턴스가 다르다면 Refresh 토큰을 읽어올 수 없다는 점! 이였다. 내가 원한 것은 인메모리 처럼 빠른 저장 삭제가 가능함과 동시에 그 데이터의 동시성을 보장해야 하는 저장소였다.

기존 처음 변경한 방식대로 관계형DB인 메인 RDS 데이터베이스에 저장한다면 속도는 조금 느려도 스케일 아웃시 문제가 없을 것이고 병목현상이 생긴다면 DB를 Sharding 함으로 해결 할 수 있으니 다시 이 방식으로 돌아가야 하는가 심각하게 고민을 하였다..

마지막 시도: 언제나 그렇듯 답을 존재하였다. 그것을 바로 NoSQL을 사용하는것! 그중에서도 cache를 사용하는 Redis의 존재를 알게 되었고 이는 나의 서비스를 신세계로 인도하였다. 내가 원했듯이 저장 삭제가 간편하고 빠르고, 언제든 Remote 서버로 따로 띄워서 동시성문제도 해결할 수 있는 Redis의 존재가 바로 내가 찾은 최적의 답이었다. 그래서 나는 유저가 로그인을 시도하고 성공한 시점에 Redis에 유저의 Ip + Refresh를 키값으로 리프레쉬토큰을 저장하였고 이때 만료시간을 설정하여 만료시간이 다하면 자동으로 삭제가 되도록 구현하였고 로그아웃을 할때도 이가 삭제되도록 구현을 하였다. 이는 굉장히 효과적으로 내가 원했던 점들을 모두 충족시켰고 지금까지는 최고의 대안이라 여겨진다.

6. trouble Shooting #2 적용 및 결과

아래는 Redis를 이용해 만료시간을 두고 값을 저장하는 서비스 Layer를 구현한 코드이다.

@Service
@RequiredArgsConstructor
public class RedisServiceUtil {

    private final StringRedisTemplate stringRedisTemplate;

    public String getData(String key) {
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        return valueOperations.get(key);
    }

    public void setDateExpire(String key, String value, long duration) {
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        Duration expireDuration = Duration.ofSeconds(duration);
        valueOperations.set(key, value, expireDuration);
    }

    public long expirationSecondGenerator(Instant now, Instant dueDate){
        long secondsBetween = ChronoUnit.SECONDS.between(now,dueDate);
        return secondsBetween;
    }

    public void deleteData(String key){
        if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(key))) stringRedisTemplate.delete(key);
    }
}

해당 서비스 클래스는 StringRedisTemplate을 통해 값을 읽어오고 만료시간을 설정하여 값을 저장하고 마지막으로 삭제하는 역할을 한다. 해당 서비스 클래스를 사용하기 위해 인증처리 과정에서 AuthenticationFilter의 내용을 다음과 같이 수정해 주었다.

	@Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Member member = (Member) authResult.getPrincipal();  

        String accessToken = delegateAccessToken(member);   
        String ip = extractor.getClientIP(request);
        delegateRefreshToken(member,ip); //
        log.info("accessToken is generated");


        response.setHeader("Authorization", accessToken);  
//        response.setHeader("Refresh", refreshToken); 삭제
    }
    
    private void delegateRefreshToken(Member member, String ip) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        Instant now = Instant.now();
        Instant expirationDate = expiration.toInstant();
        long secondsBetween = redisServiceUtil.expirationSecondGenerator(now,expirationDate);
        redisServiceUtil.setDateExpire(ip+"_Refresh",refreshToken,secondsBetween);

//        return refreshToken; 삭제
    }

다음은 Access Token 만료시 Refresh 토큰을 읽어 새로운 토큰을 발급해 주는 AuthenticationEntryPoint의 수정 내용이다.

	@Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        Exception exception = (Exception) request.getAttribute("exception");

        if (exception instanceof ExpiredJwtException) {
//            String refreshToken = request.getHeader("Refresh"); 삭제



            Map<String, Object> claims = verifyJws(request); <1>
            Map<String, Object>  newClaims = new HashMap<>();
            String username = (String) claims.get("sub");
            List<String> roles = authorityUtils.createRoles(username);
            newClaims.put("username",username);
            newClaims.put("roles", roles);

            Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
            String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());
            String accessToken = jwtTokenizer.generateAccessToken(newClaims,username,expiration, base64EncodedSecretKey);


            setAuthenticationToContext(newClaims);

        
            //새로운 액세스 토큰 응답 헤더에 담아 전송

            response.setHeader("NewAccessToken",accessToken);


        } else {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // Set the unauthorized status code
            response.getWriter().write("Token expired or invalid"); // Set the error message
            logExceptionMessage(authException, exception);
        }
        
 	private Map<String, Object> verifyJws(HttpServletRequest request) {
    		
        //<1> 레디스에 저장되어 있는 리프레쉬 토큰을 읽어와 검증하는 부분
        
        String ip = interceptor.getClientIP(request);
        String jws = redisServiceUtil.getData(ip+"_Refresh").replace("Bearer ", "");
        String base64EncodedSecretKey = jwtTokenizer.encodedBasedSecretKey(jwtTokenizer.getSecretKey());
        Map<String,Object> claims = jwtTokenizer.getClaims(jws,base64EncodedSecretKey).getBody();
        return claims;
    }

    }

인메모리를 사용하는 cache기반의 NoSQL, Redis를 사용하여 Refresh토큰을 저장하고 관리함으로 다음과 같은 장점을 가지게 되었다. 1.클라이언트에 Refresh토큰을 보관하지 않아 보다 보안성을 높이고 2. 인메모리 NoSQL DB의 이점인 빠른 속도로 저장 조회 할 수 있고 3. 만료 시간을 설정하여 자동으로 삭제를 가능하게하고 4. 언제든지 Remote서버로 분리시켜 데이터의 동시성을 지켜서 Scale Out에 유리한 구조를 가져갈 수 있다.

다만, Redis 서버가 정상적으로 동작하지 않을 경우 모든 로그인/인증 서비스가 진행되지 않는다는 점과 인메모리를 지속적으로 사용하기에 사용자의 메모리 사용량을 예측/분석하여 메모리를 관리하여야 한다는 고민점들이 여전히 남아있고 개선/최적화해 나갈 부분이라 생각한다.

7. 회고

사용자 인증에 대한 기능은 어떠한 서비스에서도 기반이 되는 핵심 기능중 하나이다. 따라서 그 속도와 안정성, 그리고 보안 부분에서 그 기능을 항상 최적화 시키고 안정화 시켜야 하는 기능이다. 특히나 보안은 서비스를 운영하는데 있어서 근반이 되는 가장 중요한 부분이기에 기능을 구현하는데 지속적으로 개선점과 문제점을 찾고 고민해왔고 지금도 개선점들을 찾고 있다.

그 과정중에 대표적인 cache기반 데이터 저장소인 Redis를 공부하게 되었고 Redis와 cache에 대해 더욱 깊이 공부해보려한다.

이번 프로젝트에서 인증 부분에 Redis를 사용하게 되었던 이유들에 따라 개선점을 찾고 서비스의 질을 높일 수 있었던거 같아서 어느정도 만족하고 있다. 캐시는 굉장히 매력적인 기술이고 서비스의 속도를 개선하는데 있어서 최고의 기술임에는 분명하다. 사실, 로그인 부분보다는 사실 반복적으로 동일한 결과를 돌려주는 이미지나 썸네일등을 리스폰스해야 하는 경우에 더욱 효과적으로 장점을 활용할 수 있을것이다.

그 방법은 중간에 Redis를 두어 한번 조회될때 이를 메인 DB에서 읽어옴과 동시에 Redis에 저장하고 Redis를 중간에 조회하여 기록이 있으면 빠르게 같은 응답값을 리턴하는 등의 방법으로 사용하여 서비스의 로딩 속도를 확실히 개선 시킬 수 있을 것이다.

다만, 어떠한 기술이든 장점만 있지 않았다. 캐시는 읽고 쓰는데 있어 빠른 성능을 제공하지만 저장 공간이 작고 비용이 비싸다는 단점이 있고 이를 고려하지 않고 캐시만을 고집한다면 분명 서버는 높은 메모리 사용량을 감당하지 못하고 다운되어 버리고 말 것이다.

따라서 서비스의 환경, 혹은 고려해야 할 여러가지 측면들에 따라 기존의 방식 혹은 다른 기술들이 더 나을 경우가 분명 있을것이고 알고있는 기술만을 사용하는 것이 아니라 그 니즈에 따라 적절한 기술을 잘 취사선택 하는것이 서버 개발자의 가장 중요한 덕목 중 하나라고 생각한다.

profile
I'm a musician who wants to be a developer.

0개의 댓글