[Kakao Cloud School] 24번째 회고록

lango·2023년 5월 10일
0
post-thumbnail

Intro


뚜렷한 이유와 목적을 우선하자.

본 프로젝트를 개발해오면서 사용했던 다양한 기술과 도구들에 대해서 그저 되는대로 반영하다보니 왜? 라는 질문에 대해서 답변을 하지 못했던 순간들이 많았다.

팀원들과 소통하면서 어떤 기술을 도입하거나 고려할 때 이 기술이 우리에게 필요한지를 살펴보기로 하였지만, 이런저런 핑계로 정리를 하지 못했다.

단순히 프로젝트를 개발하는 것도 중요하지만, 이 프로젝트를 구성하기 위해 무엇을 사용했는지, 어떻게 사용했는지, 왜 사용했는지를 설명하지 못하면 프로젝트를 진행한 의미가 없을 것이라고 생각했다.

비단 프로젝트 뿐만이 아니라 공부를 할 때도 이유와 목적을 분명하게 정해야 함을 느낄 수 있었고 앞으로는 이 기준을 우선하여 개발에 임하려고 한다.




Week 24

카카오 클라우드 스쿨 24주차 111~115일까지의 공부하고 고민했던 흔적들을 기록하였습니다.

MSA 구조에서 사용자 인증을 위한 API Gateway의 필요성

최종 프로젝트에서 필자가 담당한 역할 중 하나는 사용자 서비스의 개발이다. 이 범위에는 사용자 관련 인증도 포함되어 있다.

이 프로젝트는 AWS의 Managed Service 중 하나인 EKS Cluster를 운영환경 위에서 동작하도록 구축했다. 그리고 EKS 위에서 사용자 서비스를 포함한 4개의 서비스가 독립적으로, 즉 MSA 형태로 실행되고 있다.

그런데 대책없이 서비스를 쪼개어 개발하다보니 인증과 관련된 문제를 간과하고 있었다.

문제 인지

사실 모든 서비스를 인증된 사용자만 이용할 수 있도록 제한할 수 있었지만 프로젝트 목적을 따라 문제풀이, 멘토링 서비스의 일부 조회 기능은 비로그인시에도 이용할 수 있어야 했고, 등록/수정/삭제(CUD)와 같은 기능은 로그인한 사용자만 이용해야 했다.

이를 위해 회원가입 기능을 통해 사용자 테이블에 데이터를 저장하고, 로그인시 사용자 테이블 데이터 존재유무에 따라 인증된 사용자라는 것을 알려주어야 한다.

그래서 로그인을 하면 사용자 서비스와 통신하게 되고 사용자 서비스에서 인증 여부를 검증하여 클라이언트로 응답을 내려주도록 설계했다.

이 때, 사용자 서비스가 아닌 문제풀이, 멘토링 서비스에서는 사용자 정보를 알 수 없는데 사용자 정보가 포함된 API 요청을 받은 문제풀이, 멘토링 서비스가 사용자 인증을 처리할 수 있을까? 라는 문제를 알게 되었다.

문제 해결을 위한 방안 도색

본 문제를 해결하기 위한 솔루션으로 2가지를 도출해낼 수 있었다.

  • API Gateway 서비스 개발
  • 모든 서비스에 인증 시스템 개발

사실 API Gateway 서비스를 확장하여 클라이언트에서 독립된 서버 애플리케이션으로 사용자 정보가 포함된 API 요청을 하면 API Gateway 서비스에서 사용자 인증을 처리하고 요청을 처리할 수 있는 서비스와 통신하는 구조로 개발하는 것이 정답에 가깝다고 생각했다.

그런데 프로젝트 개발 일정이 많이 지연되어 일주일 안에 API Gateway 개발과 테스트를 모두 끝내야했다. 마음만은 API Gateway를 개발하고 싶었지만, 팀원들과 소통 후 MVP 구현에 집중하기 위해 인증 시스템을 모든 서비스에 개발하기로 결정하였다.

사용자 정보를 알 수 없는 다른 서비스에서의 사용자 인증하는 과정은?!

독립된 Spring Boot 서버 애플리케이션에서 서비스를 제공하다보니 세션을 사용하기보단 JWT를 활용하여 발급한 토큰을 통해 인증을 하기로 하였다.

토큰을 이용해 인증을 하는 과정은 다음과 같다.

  1. 사용자 서비스에서 인증에 필요한 토큰(JWT)을 발급한 후, 클라이언트에서는 받은 토큰을 모든 API 요청 헤더에 담아서 서버로 전달한다.
  2. 각 서비스들은 API 요청을 받으면 요청 헤더에 담긴 토큰의 유효성을 검사하여 API 요청을 승인하거나 거절한다.

이 때, 어쩔 수 없이 모든 서비스에서 토큰의 유효성을 검증할 필터를 공통으로 작성하기 때문에 중복 코드가 발생할 수 밖에 없었다.

인증 로직 중복을 줄이기 위한 발버둥

사용자, 문제풀이, 멘토링 서비스에서 엑세스 토큰을 발급할 일이 있을까?

엑세스 토큰은 로그인한 사용자에게만 발급해주면 되기 때문에 사용자 서비스에서만 토큰을 발급하고, 문제풀이, 멘토링 서비스에서는 토큰의 유효성을 검사하기만 하면 된다.

요청에 대한 응답 절차를 간단하게 그림으로 보면 다음과 같다.

클라이언트에서의 모든 요청을 처리할 서버에서는 요청 헤더에 토큰이 담겨있는지 확인하고 토큰이 없다면 거절 응답을 보낸다.

만약 토큰이 담겨있다면 토큰의 유효성을 검사하고, 유효하다면 요청을 처리할 비즈니스 로직을 수행한 결과를 응답으로 보낸다.

이에 따라, 모든 서비스에 요청에 대한 인증 시스템을 모두 개발하는 수고는 덜 수 있었지만 토큰 유효성 검사와 관련된 로직은 중복으로 개발할 수 밖에 없었다.

인증 시스템 살펴보기

토큰 발급

본 프로젝트에서 서비스되는 서버 애플리케이션은 모두 Spring Boot를 사용하였기에 인증과 관련된 처리를 위해서 Spring Security를 활용해 토큰의 발급과 검증, 재발급과 관련된 로직을 개발하였다. 이 때, 토큰을 발급하기 위해서 JWT를 활용하였다.

먼저 Spring Security를 사용하기 위한 설정 클래스의 일부를 살펴보자.

SecurityConfig.java

@Log4j2
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    
    // ... 이하 생략
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("[SecurityConfig] Configure --------------------------------");
        AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder.userDetailsService(memberDetailService).passwordEncoder(passwordEncoder());

        // Get AuthenticationManager
        AuthenticationManager authenticationManager = authenticationManagerBuilder.build();

        // 반드시 필요
        http.authenticationManager(authenticationManager);

        // APILoginFilter
        // 스프링 Security에서 username 과 password를 처리하는 UsernamePasswordAuthenticationFilter 의 앞쪽에서 동작하도록 설정
        LoginFilter apiLoginFilter = new LoginFilter("/api/auth/login");
        apiLoginFilter.setAuthenticationManager(authenticationManager);
        
        // APILoginFilter 다음에 동작할 핸들러 생성하기
        // 로그인 성공과 실패에 따른 핸들러를 설정할 수 있다.
        LoginSuccessHandler successHandler = new LoginSuccessHandler(jwtUtil, memberRepository);
        apiLoginFilter.setAuthenticationSuccessHandler(successHandler);

        // APILoginFilter의 위치 조정, 로그인 필터 적용
        http.addFilterBefore(apiLoginFilter, UsernamePasswordAuthenticationFilter.class);
        
        // Acess 토큰 검증 필터 적용하기
        http.addFilterBefore(tokenCheckFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
        
        // Refresh 토큰 컴증 필터를 적용하기
        http.addFilterBefore(new RefreshTokenFilter("/api/auth/refresh", jwtUtil), TokenCheckFilter.class);

        // ... 이하 생략
        
        return http.build();
    }
}

SecurityConfig를 WebSecurityConfigurerAdapter 상속받지 않고 SecurityFilterChain을 빈으로 등록하여 사용했다.

이전에는 WebSecurityConfigurerAdapter를 상속받은 구성 클래스를 사용하여 필터 체인을 구성했지만, 이 방법은 이제는 더 이상 권장되지 않으며, Spring Security 5부터는 SecurityFilterChain을 빈으로 등록하는 방법을 권장한다.

모든 로직을 하나하나 까보기보단 유효한 로직 위주로 살펴보자.

/api/auth/login이라는 로그인 요청에 대해서는 LoginFilter가 적용된다. LoginFilter는 GET Method가 아닌 모든 Method 요청에 대해서 MemberDetailService에서 처리하도록 아이디와 비밀번호를 Map으로 만드는 역할을 수행한다.

여기서 로그인 정보가 잘못되었다면 스프링 시큐리티에서 401 예외를 응답하게 되고, 로그인이 성공했다면 LoginSuccessHandler가 적용된다.

LoginSuccessHandler는 엑세스 토큰과 리프레쉬 토큰을 발급하고 HTTP ONLY 쿠키를 담아 보내준다.

이렇게 사용자 서비스에서 토큰 발급 과정이 이루어진다.

토큰 검증

모든 서비스에서 요청에 대한 토큰의 유효성을 검사하는 과정도 간단하게 살펴보자.

위에서 살펴본 SecurityConfig에서는 엑세스 토큰을 검증하기 위해 tokenCheckFilter를 적용하였다.

여기서 중요한 점은 비인증 요청과 인증 요청을 구분하여 토큰 검증을 수행해야 한다는 것이다.

예를 들면 회원가입 요청은 로그인하지 않은 상태로 서버에서 전달받기 때문에 토큰 검증 없이도 수행되어야 한다.

if (path.startsWith("/api/auth/register") {
    filterChain.doFilter(request, response);
    return;
}

이를 위해 위와 같이 TokenCheckFilter에서 토큰 검증을 생략할 수 있도록 조건문을 작성했다.

위 조건에 해당하지 않으면 토큰 검증, 즉 인증이 필요한 요청으로 판단하고 토큰 검증을 수행하게 된다.

추가로 토큰의 유효성 검사을 수행할 때, 잘못된 토큰에 대해서는 MalformedJwtException, SignatureException, ExpiredJwtException을 발생하도록 했다.

  • MalformedJwtException: JWT의 형식이 잘못되어 파싱할 수 없는 경우 발생한다. 올바른 형식의 JWT가 아닌 일반 문자열을 파싱하려고 할 때 발생할 수 있다.
  • SignatureException: JWT의 서명 검증이 실패한 경우 발생한다. JWT의 서명은 발행한 서버가 유효한 것으로 증명하기 위해 사용되며, 이 예외는 서명 검증에 실패한 경우 발생한다.
  • ExpiredJwtException: JWT가 만료된 경우 발생한다. JWT에는 발행 시간과 만료 시간이 포함되며, 이 예외는 JWT의 만료 시간이 현재 시간보다 이전인 경우 발생한다.

위와 같이 토큰 발급은 로그인 요청을 받는 사용자 서비스에서 수행하고, 로그인 요청이 아닌 요청에 대해서는 토큰을 검증하도록 하여 사용자, 문제풀이, 멘토링 3개의 서버에서 비인증 사용자와 인증 사용자의 요청을 처리할 수 있었다.

물론 이 과정 속에서 예외처리에 대한 부분도 많이 미흡하고, 클린 코드로서 부족한 부분도 많았지만, 의도했던 바를 이룰 수 있었다.




Final..

이번 주는 내가 맡은 부분에서 치명적인 문제가 발견되어 많이 힘들었다.
호기롭게 사용자 도메인을 맡았지만, 높은 퀄리티는 커녕 동작조차 위태로운 구조로 코드를 작성하다보니 팀원들에게 미안했고 많이 부족하다는 것을 깨닫게 되었다.

이 때, 사용자 서비스와 인증과 관련된 시스템을 개발하면서 아무 생각 없이 개발하면 앞으로 개발자로 성장하는 데 한계가 있음을 알게 되었다.

그저 되는대로 개발하는 태도를 고치기로 마음먹었고 이를 위해서는 내가 작성하여 개발한 코드의 의도가 남에게 타당한 이유와 목적으로 드러나는지 고민하면서 개발해야 함을 팀원들에게 공유하고 앞으로 남은 기간동안이라도 이러한 팀 문화 속에서 개발하기로 하였다.


이번 글에서 살펴본 코드에 대해서는 Developers 프로젝트의 Github에서 확인하실 수 있습니다.

혹여 잘못된 내용이 있다면 지적해주시면 정정하도록 하겠습니다.

참고자료 출처

profile
찍어 먹기보단 부어 먹기를 좋아하는 개발자

0개의 댓글