들어가며

  • 이번 글에서는 벡엔드 파트에서 일어났었던 문제점들을 중점적으로 적고 개인적인 생각을 붙이면서 이어나가려고 한다.
  • 특별히 언급할 것은 Spring Boot를 사용한 프로젝트 API 서버 전체를 필자가 담당했다는 것 정도이다.

역할 분담

  • 개발을 들어가기 전에 역할 분담을 다시 해야할 필요가 있었다.
  • 프로젝트가 이전에 해본 적이 거의 없었던 모바일 앱과 관련된 주제이다 보니 Flutter를 FE 2명이 담당하기에는 시간이 부족할 수 있다는 것이었다.
  • 그래서 역할을 재분류를 한 결과 다음과 같은 분담이 이루어졌다.
    • 모바일 앱 프론트엔드 파트 : 3명(FE 파트 2명 + BE 파트 1명)
    • Open Vidu 파트 : 2명(BE 파트 2명)
    • API 서버 개발 : 1명(필자)
  • 어쩌다 보니 API 서버 전체를 내가 담당하게 되었다. 다른 사람들이 적은 코드들을 보고 서로 리뷰하며 의견을 공유하고 싶었던 나에게 있어서는 조금 아쉬웠지만 역할을 분담해야 하는 이유가 명확했고, 그렇게 하지 않으면 시간 내에 프로젝트 마무리를 할 수 없었다고 생각했기 때문에 받아들이게 되었다.

Spring Security

  • 아... 시큐리티는 초반부터 후반까지 정말 프로젝트 전체 과정에 걸쳐 꾸준히 팀을(특히 필자를) 고통스럽게 하는 주요 요인 중 하나였다.
  • 이번 프로젝트에서 나는 OAuth 2.0을 사용하여 구글 로그인을 구현하는 것에 도전하게 되었다. 기본적인 E-mail, Password를 사용해 인증을 수행하고 JWT를 사용한 엑세스 토큰을 반환하는 로직은 이전에도 했던 것이었지만 이번에는 사용자 인증을 OAuth 2.0을 사용해서만 하기로 결정했기 때문에 OAuth 2.0을 사용할 필요가 있었다.
  • 기능 구현 자체는 꽤나 빠르게 진행이 되었다. Spring Security에서 OAuth 2.0을 사용하여 로그인하는 자료가 공식 문서 뿐만 아니라 꽤 많은 곳에 있었고, 특히나 구글의 경우에는 기본적으로 Spring Security가 어느 정도의 설정을 미리 해놓았기 때문이다.
  • 문제는 Flutter와 같이 모바일의 경우에 웹과는 조금 다르게 구현해야 한다는 점이었다.

차이점

  • 기본적으로 웹 상에서 OAuth 2.0을 구현하는 경우에는 Spring Security쪽에서 거의 모든 처리를 다 수행해준다.
    • 맨 처음 사용자가 구글로 로그인하기 버튼을 누르면 구글 로그인 페이지로 이동되게 되는데 그곳에서 로그인을 수행하게 되면 구글에서 리소스 서버로의 엑세스 토큰을 발급받아 서버로 반환하게 된다.
    • 웹에서는 시큐리티가 해당 엑세스 토큰을 기반으로 하여 구글 Resource 서버에서 사용자 정보까지 모두 자동으로 가져오게 되며 우리는 구글이 보내준 데이터를 적절하게 가공하여 회원가입/로그인을 수행하고, 우리 서버의 JWT 토큰을 발급하면 끝난다.
  • 문제는 모바일의 경우에 클라이언트가 앱이다 보니 구글로 로그인하기 버튼을 눌러 로그인을 진행하게 되면 구글 Resource 서버로 접근하는데 사용하는 엑세스 토큰을 앱으로 전달해야 할 필요가 있었다.
    • 즉 모바일 앱으로 시큐리티가 받은 엑세스 토큰이 전송되어야 하는 단계가 새롭게 추가되어야 한다.
  • 팀원이 해당 문제와 관련해서 알아보다가 OIDC(OpenID Connect)을 사용해보는게 어떨까 하는 이야기도 나왔지만 해당 기술과 관련된 자료가 많지 않았고, 차후 소셜 로그인을 네이버, 카카오로도 확장하려고 하는 부분에 있어 현재는 카카오만 OIDC를 지원하고 있어 이 부분은 기각하게 되었다.
    • 해당 문제는 꽤 간단하게 해결이 가능했는데 우리 앱으로 리다이렉션을 수행하고 그때 구글 resouce 서버로 접근 가능한 엑세스 토큰을 query string으로 보내는 것이었다.
  • 이후 해당 엑세스 토큰을 사용하여 OAuth 2.0 로그인이 완료되면 우리 서버에서 회원가입/로그인을 수행한 후 우리 API 서버 엑세스 토큰을 다시 모바일 앱쪽으로 query string을 이용해 보내는 것으로 해결하였다.

대략적인 플로우 차트

@RequiredArgsConstructor
@Component
@Slf4j
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private final TokenProvider tokenProvider;

    private static final String URI = "https://domain/api/v1/auth/success";

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

        log.debug("success to oauth2 authorization");
        String accessToken = tokenProvider.generateAccessToken(authentication);
        log.debug("accessToken : {}", accessToken);
        tokenProvider.generateRefreshToken(authentication, accessToken);

        String redirectUrl = UriComponentsBuilder.fromHttpUrl(URI)
            .queryParam("accessToken", accessToken)
            .build()
            .toUriString();

        log.debug("redirectUrl : {}", redirectUrl);
        response.sendRedirect(redirectUrl);
    }
}
  • 해당 코드는 OAuth 2.0으로 로그인이 성공한 경우에 실행되는 핸들러 코드이다. 여기기에서 서버 컨트롤러 주소로 이동되게 된다.
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "인증 api", description = "로그인, 회원탈퇴, 로그아웃 api를 다룹니다.")
public class AuthController {

    private final AuthService authService;

    @GetMapping("/success")
    public void success(@RequestParam(value = "accessToken") String accessToken, HttpServletResponse response) throws
        IOException {

        response.sendRedirect("deeplink?access_token="+accessToken);
    }
    ...

}
  • 핸들러에서 리다이렉트한 요청은 해당 컨트롤러에서 받아 앱으로 다시 엑세스 토큰을 보내게 된다.
  • 이후 Flutter에서 구글 Resource 서버로 요청을 보낸 뒤 사용자 정보를 API 서버로 보내고 해당 정보를 DB에서 조회하여 없는 경우 회원가입, 존재하는 경우 로그인을 진행해 엑세스 토큰을 발급하게 된다.

토큰 문제

  • 사실 위의 문제는 크게 어려움이 없었다. 원래 스프링 시큐리티가 하던 부분을 내가 구현을 수행했으면 끝나는 문제였고, 어떻게 해결하는지도 명확했었다.
  • 지속적으로 우리를 괴롭혔던 것은 토큰과 관련된 문제였다.
  • 우리는 JWT Authentication Filter 라는 커스텀 필터를 하나 만들어 해당 필터가 사용자 요청에서 header를 읽어 JWT 토큰 데이터가 존재하는지를 확인하도록 하였다.
  • 문제는 어떤 이유인지는 모르겠지만 잘못된 JWT 양식이 들어온다는 로그가 계속해서 찍히고 있었다. 정말로 JWT에서 문제가 발생했다면 모르겠지만 Flutter에서 Spring 서버까지 JWT 양식이 잘못된 토큰이 들어온 기록이 없었던 것이다. 여기에서 나는 토큰 값이 null로 보내지는 경우가 존재하는게 아닌가 라는 생각을 했었다.

토큰 값이 null인 경우

  • 토큰 값이 null로 들어오는 경우를 생각해보니 몇가지가 있긴 했었다.

    • 처음 앱으로 진입하는 경우 사용자의 알람들을 서버에서 불러오는 요청을 보내게 되어 있는데 이 경우에 사용자 액세스 토큰값은 null이다. 따라서 요청을 보낼 때 request header에서 Authorization 값이 null이 들어가게 되고 여기에서 문제가 발생할 것이다.
  • Spring Boot Actuator로 접근이 들어가는 경우

    • 필자가 모니터링을 통해 Spring Boot Actuator를 사용해서 /actuator로의 접근 엔드포인트를 열었는데 이 부분에 접근할 경우 request header에 Authorization 값이 들어가지 않으므로 null이 들어가는 경우
  • 두 가지 모두 충분히 가능성이 있는 것이었다.

    • 특히나 엑츄에이터의 경우 Prometheus가 메트릭 수집을 위해 지속적으로 접근해야 하므로 null이 들어오는 상황이 지속적으로 발생한다고 보았다.
  • 그래서 로그를 통해 상황을 관찰한 결과 실제로 저 두가지의 상황에서 null이 들어오고 있었다. 그 이외에도 토큰 문제가 발생했긴 했지만 대부분 토큰이 만료된 상황에서 재발급이 되는 과정 중 로그가 발생한 것이었기 때문에 충분히 무시할 수 있는 것이었다.

  • 이후 null값이 들어오게 되면 액세스 토큰을 파싱하지 않고 바로 이후의 필터로 들어오도록 설정하면서 후반부가 되어서야 해결할 수 있던 문제였다. 그나마 다행이었던 점은 나머지 경우에 대해서 토큰이 null로 꽂혀 들어오면서 문제가 발생하는 일은 없었다는 것이었다.

@RequiredArgsConstructor
@Component
@Slf4j
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private static final String TOKEN_PREFIX = "Bearer ";

    /**
     * 요청에 대한 JWT 토큰 필터에서 수행되는 메서드
     *
     * @param request 사용자 요청
     * @param response 서버 응답
     * @param filterChain 다음 필터로 이동할 {@code FilterChain} 객체
     * @throws ServletException 서블릿 관련 예외 발생시 던저짐
     * @throws IOException 요청 또는 응답에 대해 읽을 수 없을 경우 던저짐
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        String accessToken = resolveToken(request);

        if(ObjectUtils.isEmpty(accessToken)) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            if(tokenProvider.validateToken(accessToken)) {
                setAuthentication(accessToken);
            }
        } catch (MalformedJwtException | IllegalArgumentException e) {
            log.info("유효하지 않은 구성의 JWT 토큰입니다.");
            request.setAttribute("exception", ErrorCode.INVALID_TOKEN.toString());
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
            request.setAttribute("exception", ErrorCode.TOKEN_EXPIRED.toString());
            request.setAttribute("accessToken", accessToken);
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 엑세스 토큰에서 인증 정보를 설정하는 메서드
     *
     * @param accessToken 사용자에게 받은 엑세스 토큰
     */
    private void setAuthentication(String accessToken) {
        Authentication authentication = tokenProvider.getAuthentication(accessToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    /**
     * 헤더에 있는 엑세스 토큰 값을 가져오는 메서드
     *
     * @param request 사용자 요청
     * @return 파싱한 사용자 요청 헤더 내부의 엑세스 토큰 값
     */
    private String resolveToken(HttpServletRequest request) {
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);

        if(!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) {
            return token.substring(TOKEN_PREFIX.length());
        }

        return null;
    }

}

느꼈던 점

  • 시큐리티에서 하도 두들겨 맞으면서 디버깅하다 보니 자연스럽게 http 요청/응답에 대한 로그를 만들어야 겠다는 생각이 들게 되었다. 아쉽게도 이번 공통 프로젝트에서는 로깅하기에는 시간이 모자라 할 수 없었지만 이후에는 http 요청/응답을 로깅하는 필터를 하나 만들어 추가해야 겠다는 생각을 하게 되었다.
    • 이후에 언급하겠지만 로그를 수집해서 모니터링하는 시스템을 구축했는데 여기에 추가하게 되면 아마 문제 대응에 큰 도움이 되지 않을까 라는 생각을 한다.
  • 그리고 OAuth 2.0을 이번에 처음 사용하게 되었는데 시큐리티에서 친절하게도 대부분의 처리를 모두 해 준 덕분에 수고를 크게 덜 수 있었다고 생각한다. 물론 Flutter라는 특수한 클라이언트 때문에 수정을 해야하긴 했지만 웹 환경에서 구현한다면 꽤나 쉽고 빠르게 진행할 수 있지 않을까라는 생각을 하게 되었다.

'프로젝트 > JaNyang' 카테고리의 다른 글

공통 프로젝트 후기 정리  (0) 2024.08.15
복사했습니다!