프로젝트/쇼핑몰 프로젝트

[쇼핑몰 프로젝트] 서버 다중화 환경에서 세션 기반 인증 문제 해결 – JWT 적용

Joo.v7 2025. 3. 21. 00:44

0. 개요

쇼핑몰 프로젝트는 MSA 방식으로 설계했고, 클라이언트의 모든 요청은 먼저 Nginx로 받아서 이를 프론트 서버로 로드 밸런싱한다.

이 프론트 서버에서 사용자 인증을 진행하는데, Spring Security를 사용했다. 그런데 간헐적으로 인증이 안되는 현상이 문제가 발생했다.

 


1. 문제 원인 분석

문제는 Spring Security의 인증방식에서 비롯됐다. 로그인 시, Spring Security의 동작 과정을 보면

Spring Security 인증 과정

 

 

(1) 사용자의 인증 요청: 로그인 (아이디 - username, 패스워드 - password)

 

(2) UsernamePasswordAuthenticationFilter

  • 요청에 세션ID(JSESSIONID)가 없으면, 사용자는 인증되지 않은 상태로 간주되고, 요청에서 username과 password를 추출해서 UsernamePasswordAuthenticationToken을 생성한다.
  • 요청에 세션ID(JSESSIONID)가 있으면 해당 세션ID에 대응하는 SecurityContext를 찾고, SecurityContextHolder에 설정한다. 그 후 SecurityContext에 Authentication 정보가 있다면, 이미 인증된 사용자로 간주되어서 아래의 추가 인증 과정을 거치지 않는다.

 

(3) AuthenticationManager는 단순 인터페이스고 실제 구현은 ProviderManager다. 여기에는 인증에 필요한 여러 AuthenticationProvider 목록이 있는데 ProviderManager는 전달된 UsernamePasswordAuthenticationToken을 기반으로 사용자 인증을 시도한다. (나는 DaoAuthenticationProvider를 사용했다.)

AuthenticationManager / AuthenticationProvider

 

(4) DatoAuthenticationProvider를 사용하기 위해 UserDetailsService와 PasswordEncoder를 구현해야 한다.

 

(5) UserDetailsService를 직접 구현하는데 loadUserByUsername 메소드를 통해 username으로 DB에 저장된 유저의 정보를 가져와서, 유저 정보를 담은 UserDetails 객체를 반환한다. (이때 DB에서 꺼낸 비밀번호는 암호화 되어 있어야 한다.)

 

(6) UserDetails는 사용자에 대한 정보를 표현하는 인터페이스로 이를 구현하거나 Spring Security에서 제공하는 UserDetails의 구현체인 User 클래스를 사용해도 된다.

 

(7) DatoAuthenticationProvider는 UserDetailsService에서 반환된 UserDetails와 UsernamePasswordAuthenticationToken의 비밀번호를 비교한다. 인증에 실패하면 AuthenticationException이 발생하고, 인증이 성공하면 Authentication 객체가 반환된다.
(Authentication: 사용자의 정보가 담겨 있는 인증된 객체) 

* 주의
아래는 DaoAuthenticationProvider의 코드 중 일부로 additionalAuthenticationCheckes 함수에서 비밀번호를 비교하는데 이때, passwordEncoder.matches()를 사용하기 때문에 PasswordEncoder가 반드시 구현되어 있어야 한다.
또 matches()는 암호화된 비밀번호(UserDetails)와 사용자가 입력한 비밀번호(UsernamePasswordAuthenticationToken)을 비교하기 때문에 UserDetails에 있는 비밀번호는 암호화가 되어 있어야 한다. (원래 비밀번호를 DB에 저장할 때, 반드시 암호화해서 저장해야 한다.)

 

DaoAuthenticationProvider 코드 중 일부

// UserDetails와 UsernamePasswordAuthenticationToken 비밀번호 비교하는 부분
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
        this.logger.debug("Failed to authenticate since no credentials provided");
        throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    } else {
        String presentedPassword = authentication.getCredentials().toString();
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            this.logger.debug("Failed to authenticate since password does not match stored value");
            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
}

// 인증 성공시, Authentication 객체가 생성하여 반환되는 부분
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
    String presentedPassword = authentication.getCredentials().toString();
    boolean isPasswordCompromised = this.compromisedPasswordChecker != null && this.compromisedPasswordChecker.check(presentedPassword).isCompromised();
    if (isPasswordCompromised) {
        throw new CompromisedPasswordException("The provided password is compromised, please change your password");
    } else {
        boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword());
        if (upgradeEncoding) {
            String newPassword = this.passwordEncoder.encode(presentedPassword);
            user = this.userDetailsPasswordService.updatePassword(user, newPassword);
        }

        return super.createSuccessAuthentication(principal, authentication, user);
    }
}

 

(8) (9) (10) 인증이 성공하면 SecurityContext에 Authentication 객체를 저장한다.

SecurityContextHolder

  • SecurityContextHolder: SecurityContext를 담는 컨테이너 역할을 하는 정적 클래스로 ThreadLocal을 사용해 각 스레드마다 SecurityContext의 복사본을 관리한다. (멀티스레드 환경에서도 사용자의 인증 정보 안전하게 유지)
  • SecurityContext: 현재 보안 상태를 나타내는 객체로, Authentication 객체를 포함하고 있다.
  • HttpSession: Spring Security는 이렇게 만들어진 SecurityContext를 HttpSession에 저장된다.
    (Spring Security에서 SecurityContext를 유지하는 방식은 SecurityContextPersistenceFilter에 의해 결정됨)
// SecurityContext 반환
SecurityContextHolder.getContext()

// Authentication 반환
SecurityContextHolder.getContext().getAuthentication()

 

* 문제 발견

Spring Security의 Session 기반 인증 방식이 문제였다. Spring Security가 적용되어 있는 프론트 서버를 이중화해서 운영하는데, 사용자가 서버 1에 로그인을 하면 세션 정보는 서버 1에만 저장되어 있기 때문에, 서버 2에 요청을 보낼때는 인증이 되지 않은 것이다. 따라서 인증이 간헐적으로 성공하거나 실패하는 문제가 발생한 것이다.


2. 여러 해결 방법 검토

  1. Sticky Session: 클라이언트의 요청이 어느 한 서버에 도달해 세션 데이터가 생겼다면, 이 서버는 해당 클라이언트만의 요청/응답만 처리하도록 하는 방법.
    • 문제점
      • 특정 서버에 트래픽이 집중되는 문제. -> 로드 밸런싱을 하는 이유가 없다.
  2. Session Clustering: 서버들을 하나의 클러스터로 묶어서 관리하고, 클러스터 내의 서버들이 세션을 공유할 수 있도록 하는 방식.
    • 문제점
      • 서버마다 동일한 세션 정보를 가지고 있어서, 서버가 확장될수록 추가 메모리 비용 발생.
      • 세션 전파 작업 중 모든 서버에 전파되기까지의 시간차로 세션 불일치 문제가 발생할 가능성 존재.
  3. Session Storage (Redis 활용): 별도의 세션 저장소를 두고, 서버들이 이를 공유하는 방식.
    • 문제점
      • 문제가 발생하면 모든 서버가 장애를 겪을 수 있다. -> 프론트 서버를 이중화한 이유는 단순히 사용자 요청을 분산하기 위한 것뿐만 아니라, 특정 서버가 다운되었을 때 다른 서버가 요청을 처리할 수 있도록 하기 위함이기도 하다.
  4. JWT 기반 인증: JSON 기반의 토큰 형태의 인증 방식으로, 토큰 자체에 정보를 포함하여 인증을 처리하는 방식.
    • 문제점
      • 토큰 탈취 시 보안 문제 -> ex) 공격자가 사용자1의 JWT를 탈취했다. 사용자1이 로그아웃해서 사용자 브라우저에 JWT을 삭제해도, 공격자가 탈취한 JWT은 여전히 유효하다
      • 토큰 강제 만료 문제 -> JWT는 Stateless 방식으로, 서버에서 클라이언트의 세션을 유지하지 않는다. 따라서 한번 발급된 토큰은 유효기간이 끝날 때까지 계속 사용 가능하다.
  5. JWT + Session Storage (Redis): JWT의 단점(토큰 강제 만료 불가능)을 보완한 방식.
    • 특징
      • Access Token (JWT)와 Refresh Token 활용
      • Blacklist (Redis) 활용한 Access Token 무효화

3. 내가 선택한 방법: JWT 기반 인증

프로젝트 초기에 인증을 담당했던 팀원이 JWT 기반으로 하고 있었다. 그러나 당초 계획했던 1주가 아닌 4주차가 되어도 인증 기능을 구현하지 못했고, 팀에서 이탈하게 되어서 시간이 매우 촉박한 상황이었다. 또한 JWT에는 회원PK가 TSID로 들어있고, 이를 다른 기능들(주문, 쿠폰, 포인트 등등)에서 사용해야 하는 상황이어서 빠르게 인증 기능을 구현해야 했다.

MSA 구조로 개발된 서버들을 테스트하면서 세션 문제를 비롯한 여러 이슈가 발생했고, 기존 기능과 코드를 최대한 유지하기 위해 JWT 기반 인증 방법을 채택했다. 

 

(1) 큰 틀의 인증 플로우 (gateway, eureka 서버는 제외함)

큰 틀의 인증 플로우

  1. 인증 요청
  2. JWT Filter가 요청을 받아서
    • JWT가 있으면 인증 서버로 JWT를 보내고, 인증 서버는 secret키로 토큰을 파싱해서 정보들을 준다.
      그 후 Authentication 객체를 만들어서 SecurityContextHolder에 넣는다.
      (Spring Security는 SecurityContextHolder에 Authentication 객체를 저장하면 인증된 것으로 판단한다.)
    • JWT가 없으면 UsernamePasswordAuthenticationFilter에 의해 인증이 진행된다.
      PasswordEncoder와 UserDetailsService를 구현한다. 사용자의 정보는 Task 서버에 요청해서 가져온다.
      • 인증이 성공하면 successHandler로 인해, JWT 토큰을 발급 후 쿠키에 담아서 사용자 브라우저에서 관리한다.
      • 인증이 실패하면 failureHandler로 인해, 로그인 페이지로 리다이렉트된다.

(2) 코드

SecurityConfig

  • Security에 대한 설정.
  • JWT 필터가 UsernamePasswordAuthenticationFilter 보다 먼저 실행됨.
    -> http.addFilterBefore(new JwtAuthFilter(authFeignClient), UsernamePasswordAuthenticationFilter.class);
더보기
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {

    private final AuthFeignClient authFeignClient;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final MemberClient memberClient;
    private final CartService cartService;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
                .requestMatchers(
                        "/css/**", "/js/**", "/images/**", "/public/**", "/fonts/**", "/style.css"
                );
    }

    @Bean
    public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);

//        http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
//                SessionCreationPolicy.STATELESS));


        http.formLogin(formLogin ->
                formLogin
                        .loginPage("/login") // 사용자 정의 로그인 페이지
                        .usernameParameter("id") // 사용자명 파라미터 이름
                        .passwordParameter("password") // 비밀번호 파라미터 이름
                        .loginProcessingUrl("/login/process") // 로그인 처리 URL
                        .successHandler(new AuthSuccessHandler(authFeignClient, memberClient, cartService))// jwt token 추가하기
                        .failureHandler(new AuthFailureHandler())
                        .permitAll() // 로그인 페이지 접근 허용
        );

        http.authorizeHttpRequests(authRequest -> {
            authRequest
                    .requestMatchers(
                            "/", "/login", "/public/**",
                            "/join", "/test/**",
                            "/dormant-account/**", "/dooray-message-authentication",
                            "/cart", "/book/**", "/review/**", "/front/reviews/**", "/like/**",
                            "/book-categories/**").permitAll()
                    .requestMatchers("/admin/**").hasRole("ADMIN") // 로그인할 때 Authentication을 생성하는데 이때 ROLE을 넣어줬음. 그래서 이렇게 사용 가능.
                    .anyRequest().authenticated();
        });

        http.addFilterBefore(new JwtAuthFilter(authFeignClient), UsernamePasswordAuthenticationFilter.class);

        // logout 시 쿠키 삭제하기
        http.logout(logout -> {
            logout.logoutUrl("/logout").
                    deleteCookies("Authorization")
                    .logoutSuccessUrl("/"); // 로그아웃 성공 핸들러 추가
        });

        http.exceptionHandling(exceptionHandling ->
                exceptionHandling
                        .accessDeniedHandler(customAccessDeniedHandler)
//                        .authenticationEntryPoint(customAuthenticationEntryPoint)
        );

        return http.build();
    }

    // Password Encoder, InMemory is Dev
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // BCrypt 암호화 사용
    }

}

 

JWT 필터

  • JWT 토큰이 들어있는 쿠키를 확인한다.
  • JWT 토큰을 인증 서버로 보내서, 파싱 후 리턴받아 Authentication 객체로 만들어서 SecurityContextHolder에 넣는다.
    -> SecurityContextHolder.getContext().setAuthentication(authentication);
더보기
@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private  final AuthFeignClient authFeignClient;

    // JWT 기반 인증: Authorization 쿠키에 있는 jwt 토큰 가져다가 검증함.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("JWT 인증 필터 실행");
        Cookie[] cookies = request.getCookies();

        // 쿠키가 없으면 여기서 걸려서 홈페이지에 쿠키 없이 접근하면 login 페이지로 이동함. 정적 리소스도 다음 필터로 넘김.
        // -> 해결: 쿠키가 없으면 JWT 필터 태우지 않고, 그냥 넘김.
        if(Objects.isNull(cookies) || Arrays.stream(cookies).noneMatch(e -> e.getName().equals("Authorization")) || isStaticResource(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        for(Cookie c : cookies) {
            if(c.getName().equals("Authorization")) {
                log.info("JWT 토큰을 auth로 보내서 토큰 안에 있는 정보 가져옴");
               MemberInfoResponse memberInfoResponse = authFeignClient.getInfoByAuthorization("Bearer " + c.getValue());

               Authentication authentication = new UsernamePasswordAuthenticationToken(
                       memberInfoResponse.loginId(),
                       null,
                       List.of(new SimpleGrantedAuthority("ROLE_" + memberInfoResponse.role()))
               );

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

        filterChain.doFilter(request, response);

    }

    // 정적 리소스 경로인지 확인하는 메서드
    private boolean isStaticResource(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/images/") || path.startsWith("/public/") || path.equals("/style.css");
    }

}

 

UserDetailsService 

  • JWT 토큰이 없으면, 사용자의 정보를 Task 서버에 요청해 DB에서 가져온 후 UserDetails를 리턴한다.
  • Security에서 제공하는 User 클래스 사용함. -> return new User(~);
더보기
@Slf4j
@Service
@RequiredArgsConstructor
public class FeignUserDetailsService implements UserDetailsService {

    private final MemberFeignClient memberFeignClient;
    private final MemberStatusValidator memberStatusValidator;

    // 로그인 때 동작.
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        try {
            ResponseEntity<MemberLoginFeignResponse> memberLoginResponseDtoResponseEntity = memberFeignClient.loadByMemberId(username);

            MemberLoginFeignResponse memberLoginFeignResponse = memberLoginResponseDtoResponseEntity.getBody();

            log.info("loginId: {}, password: {}, role: {}, status: {}", memberLoginFeignResponse.loginId(), memberLoginFeignResponse.password(), memberLoginFeignResponse.role(), memberLoginFeignResponse.status());

            // 멤버 상태 검증 후 그에 따른 적절한 예외처리.
            memberStatusValidator.validateMemberStatus(username, memberLoginFeignResponse.status());

            return new User(memberLoginFeignResponse.loginId(), memberLoginFeignResponse.password(), List.of(new SimpleGrantedAuthority("ROLE_" + memberLoginFeignResponse.role())));

        }catch(FeignException e) {
            throw new IllegalArgumentException("로그인에 실패하였습니다.");
        }
    }
}

 

인증 서버 - Controller와 JWTUtils

더보기

Controller

  • getJwtToken: JWT 토큰을 발급 후 리턴.
  • getInfoByAuthorization: JWT 토큰을 파싱해서, 토큰 안에 든 정보를 리턴.
@Tag(name = "Authentication", description = "JWT 발급 및 파싱")
@RestController
@Slf4j
@RequiredArgsConstructor
public class AuthController {

    private final MemberAdaptor memberAdaptor;

    private final JwtUtils jwtUtils;

@GetMapping("/auth/jwt")
    public ResponseEntity<TokenResponse> getJwtToken(@RequestParam("id") String id){
        ResponseEntity<JwtMemberDto> memberForJWT = memberAdaptor.getMemberForJWT(id);
        TokenResponse tokenResponse = jwtUtils.makeJwt(memberForJWT.getBody());
        return ResponseEntity.of(Optional.of(tokenResponse));
    }

    @GetMapping("/auth/my/info")
    public ResponseEntity<MemberInfoResponse> getInfoByAuthorization(@RequestHeader("Authorization") String authorization) {
        String memberId = jwtUtils.parseAndReturnId(authorization);
        MemberResponseDto memberResponseDto = memberAdaptor.getMemberById(memberId).getBody();
        MemberInfoResponse result = new MemberInfoResponse(
                memberResponseDto.name(),
                memberResponseDto.loginId(),
                memberResponseDto.role());
        return ResponseEntity.ok().body(result);
    }

}

 

 

JWT Utils

  • 토큰 생성/파싱/검증.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtUtils {
    @Value("${jwt.secret}")
    private String secretKey;
    private final JwtProperties jwtProperties;

    public TokenResponse makeJwt(JwtMemberDto jwtMemberDto){

        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.SECOND, jwtProperties.getExpirationTime());

        // TODO - jwt 합쳐지면 여기 수정
        SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes());

        log.info("jwtMemberDto id : {}", jwtMemberDto.getId());

        String jwtToken = Jwts.builder()
                .claim("id", jwtMemberDto.getId())
                .claim("role", jwtMemberDto.getRole())
                .setIssuedAt(new Date())
                .setExpiration(calendar.getTime())
                .signWith(key)
                .compact();

        return new TokenResponse(jwtToken, jwtProperties.getTokenPrefix(), jwtProperties.getExpirationTime());
    }

    /**
     * JWT Token Parse.
     * @param authorization(JWT Token)
     * @return
     */
    public String parseAndReturnId(String authorization) {
        String token = validateAndExtractToken(authorization);
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey.getBytes())
                .build()
                .parseClaimsJws(token)
                .getBody();
        return claims.get("id", String.class);
    }

    public String validateAndExtractToken(String authorization) {
        if (Objects.isNull(authorization) || !authorization.startsWith("Bearer ")) {
            throw new IllegalArgumentException("토큰 헤더가 유효하지않음. Bearer 로 시작해야함.");
        }
        return authorization.substring(7);
    }

}

 

AuthenticationSuccessHandler와 AuthenticationFailureHandler

  • 인증 성공: successHandler에서 인증 서버로 요청을 보내서 JWT 토큰을 발급받아, 쿠키에 담아서 사용자 브라우저에서 관리.
  • 인증 실패: 로그인 페이지로 리다이렉트.
더보기

AuthenticationSuccessHandler

@RequiredArgsConstructor
@Slf4j
public class AuthSuccessHandler implements AuthenticationSuccessHandler {

    private final AuthFeignClient authFeignClient;

    private final MemberClient memberClient;
    private final CartService cartService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        log.info("success handler call ");
        log.info("authentication :{}", authentication);
        User principal = (User)authentication.getPrincipal();

        String username = principal.getUsername();
        log.info("username : {}", username);

        // 여기서 FeignClient로 Auth 서버에 보냄
        JwtLoginIdRequest jwtLoginIdRequest = new JwtLoginIdRequest();
        jwtLoginIdRequest.setId(username);

        ResponseEntity<TokenResponse> jwtToken = authFeignClient.getJwtToken(username);
        log.info("jwt token AccessToken : {}", jwtToken.getBody().getAccessToken());
        log.info("jwt token tokenType : {}", jwtToken.getBody().getTokenType());
        log.info("jwt token ExpiredIn : {}", jwtToken.getBody().getExpiredIn());
//          cookie

        // cookie 이름 바꾸기
        Cookie cookie = new Cookie("Authorization",
                 jwtToken.getBody().getAccessToken()
        );

        cookie.setHttpOnly(true);
        cookie.setPath("/");
        cookie.setMaxAge(jwtToken.getBody().getExpiredIn());

        response.addCookie(cookie);

        /**
         * 멤버 로그인 기록 업데이트 by loginId
         */
        memberClient.updateLoginHistoryRequest(username);

        /**
         * 회원 장바구니 가져와서 redis 에 저장하고 쿠키 유효 기간 업데이트.
         */
        cartService.saveCartFromDbToRedisByLoginId(username, request, response);


//        response.addHeader("Authorization", jwtToken.getBody().getTokenType() + " " + jwtToken.getBody().getAccessToken());
        response.sendRedirect("/");
    }
}

 

AuthenticationFailureHandler

@Slf4j
public class AuthFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 휴면 계정이면 10분짜리 쿠키 생성 후, 휴면 계정 인증 페이지로 이동.
        if(exception.getCause() instanceof AccessNotFoundException) {
            String errorMessage = exception.getMessage();
            String[] errorMessages = errorMessage.split(":");
            String loginId = errorMessages[0];

            HttpSession session = request.getSession(true);
            session.setAttribute("loginId", loginId);

            Cookie cookie = new Cookie(DORMANT_AUTH, session.getId());
            cookie.setHttpOnly(true);
            cookie.setMaxAge(5*60); // 휴면 인증용 쿠키 5분.
            cookie.setPath("/");
            response.addCookie(cookie);

            response.sendRedirect("/dormant-account");
            return;
        }

        // 그 외 나머지
        response.sendRedirect("/login");
    }
}

 


4. 정리 및 배운점

(1) 문제 해결 후 달라진 점

프론트 서버를 여러 개 띄운 후 테스트를 진행해도 정상적으로 인증이 이루어졌다. 기존 세션 기반 인증 방식과 달리, JWT를 적용함으로써 확장성과 안정성이 개선되었다.

(2) JWT 도입이 가져온 장점

  • 서버 확장성 확보: JWT는 세션을 저장할 필요가 없어 로드 밸런싱된 여러 서버에서도 인증이 정상적으로 작동했다.
  • 마이크로서비스 아키텍처(MSA)와의 적합성: 각 서비스가 중앙 인증 서버에 의존하지 않고, JWT만으로 사용자 정보를 검증할 수 있어 MSA 환경에서 유리했다.

(3) 프로젝트에서의 협업 과정에서 얻은 교훈

  • 일정 관리가 무엇보다 중요: 초기 계획보다 인증 기능 구현이 지연되면서 프로젝트 일정이 영향을 받았다. 특히 특정 작업이 완료되어야 다음 일을 수행할 수 있는 경우, 일정 관리가 정말 중요하다고 느꼈다.
  • 과감한 결정의 중요성: 인증 기능 구현이 예상보다 크게 지연되었다. 해당 팀원에게 도움이 필요하냐고 여러 번 물어봤지만, 혼자 해결할 수 있을 것 같다고 해서 믿고 기다렸다. 그러나 결과적으로 3주나 딜레이되었고, 프로젝트 일정에 큰 영향을 미쳤다. 그때 더 빨리 행동하고, 필요하면 과감한 결정을 내렸어야 했다. 때로는 싫은 소리를 하더라도 팀 전체의 흐름을 위해 필요한 조치를 취해야 한다는 점을 배웠다.
  • 주기적인 커밋과 코드 리뷰의 중요성: 인증 기능을 담당하던 팀원이 중간에 프로젝트에서 이탈한 후, 그동안 진행한 코드를 한 번에 합치게 되었다. 그 결과 특히 해당 작업을 넘겨받을 때 많은 어려움을 겪었다. 이를 통해 주기적인 커밋과 코드 리뷰의 중요성을 절실히 느꼈다. 지속적인 코드 공유와 피드백을 통해 이런 문제를 예방하고, 팀원 간의 협업을 원활하게 할 수 있다는 점을 배웠다.
  • 때로는 기존 코드 및 구조를 유지하면서 개선하는 전략 필요: 기존에 JWT를 사용하려 했던 구조를 유지하면서 빠르게 인증을 구현하는 방향을 선택했다. 이는 리팩토링 없이 기능을 안정적으로 도입할 수 있었던 핵심 요인이었다.

5. 향후 개선할 부분

  • Refresh Token 도입: 현재는 Access Token만 사용하고 있으며, 만료된 경우 사용자가 다시 로그인해야 한다. 향후 Refresh Token을 활용한 자동 갱신 기능을 추가하면 사용자 경험(UX)이 더욱 개선될 것이다.
  • Token Blacklist 및 강제 로그아웃 기능: 현재 JWT는 만료될 때까지 유효하기 때문에, 사용자가 로그아웃해도 토큰이 계속 사용할 수 있는 문제가 있다. 이를 해결하기 위해 Blacklist를 도입하여 강제 만료할 수 있도록 개선하면 좋을 것 같다.

 

참고