BackEnd/쿠링

[쿠링] 사용자 인증의 시작부터 끝까지의 여정

샤아이인 2023. 10. 7.

 

항상 프로젝트를 진행할 때마다 한 번씩은 고민해 왔던 문제가 있다.

 

바로 "인증, 인가 가 과연 해당 서비스의 핵심 비즈니스 로직인가?"에 대한 고민이다.

 

경험이 부족했던 시점에야 당연히 이러한 생각조차 하지 못하여 논의할 내용조차 없었다

경험이 조금 있는 시점에서는 일단은 API수준에서 구현은 가능하지만 이를 어떤 방식으로 분리해야 할지 몰랐다.

 

이번 글에서는 이러한 나의 고민을 스스로 적어보고, 해결해나가는 과정을 기술할 것이다! (즉, 지극히 개인적인 내용.... 태클 환영)

 

고민 1 : Core Business란 무엇인가? 경계가 있는가?

가장 우선적으로 고민했던 부분은 Core Business라는 것 이 뚜렷한 경계가 있는 것 인가였다? 이에 대한 확인을 위해 위대하신 GPT에게 질문을 던져보았다.

GPT : 인증과 권한 부여는 많은 서비스에서 중요한 구성 요소이지만, 이것이 '핵심 비즈니스 로직'을 구성하는지 여부는 서비스의 성격에 따라 달라집니다.

 

: 그럼 서비스의 성격이란 뭔데??

 

GPT : 어떤 경우에는 서비스가 주로 사용자 상호작용을 위한 안전한 플랫폼을 제공하는 데 중점을 둘 수 있으며, 이러한 경우 인증 및 권한 부여는 서비스의 핵심 기능에 필수적인 요소가 됩니다.

 

GPT : 반면에 일부 서비스, 특히 콘텐츠 중심이거나 트랜잭션이 많은 서비스의 경우 핵심 비즈니스 로직이 콘텐츠 전송, 데이터 처리, 금융 거래 등과 같은 경우 인증 및 권한 부여는 여전히 필수적이지만 서비스를 구동하는 주요 비즈니스 로직이 아닐 수도 있습니다.

 

즉, 서비스의 목적과 목표하는 바에 따라서 핵심이 될 수도, 아닐수도 있다는 것인데...

대부분의 사용자 특이적으로 동작하는 서비스라면 일단 있는 것이 합리적이다는 판단인 것 같다.

그럼 이건 어느 계층에서 구현해야 할까? 코어랑 같은 모듈일까?

 

고민 2 : Is authentication, authorization really the core business logic of the service?

 

확실한 판단보다는, 나에게 판단할 수 있는 몇 가지 기준을 GPT가 제공해 주었다. 어쩌면 당연한 결과일지도... 이 문제는 Deterministic 하지 못한 문제이기 때문에... Non-Deterministic에 가깝다 할 수 있으니 말이다.

 

그중 가장 마음에 들며, 선택하기로 결정한 기준점은 다음과 같다.

관심사를 분리하는 것이 핵심이며, 인증 및 권한 부여는 보안 및 액세스 제어와 관련된 문제인 반면, 핵심 비즈니스 로직은 애플리케이션의 주요 기능에 관한 것이라는 의미에 100% 동의한다.

 

결론 : 관심사 분리를 목표로 구현해 나가자!

그렇다 해당 부분이 핵심일지 말지는 나 스스로 결정하는 것도 아니고, 또한 항상 똑같이 구별되는 것도 아니다.

어떠한 상황에서는 핵심이 될 수도, 아닐 수도 있는 것이다. 이러한 상황, 즉 비즈니스 모델이라는 context자체가 이러한 기준을 제공해줄 것 이다. 어쩌면 이 또한 객체지향의 확장 아닐까? 단일 객체 안에 함몰되지 말고, 객체 간의 상호작용과 문맥 전체를 살펴봐야 하듯,

해당 로직이 핵심일지 아닐지는 전체 문맥을 봐야 한다!

 

이러한 고민은 바탕으로, 쿠링의 인증로직을 핵심 로직으로부터 분리해 보면서 경험한 사실을 다음과 같이 정리하게 되었다.

 


1. 문제가 되는 상황

팀원들이 Admin 기능으로 다음과 같은 기능을 요구해 주셨다!

1) 모든 사용자에게 커스텀 알림을 전송하는 기능

2) 서버의 상태를 확인하는 기능 (이미 서버 모니터링 도구가 있지만.... 서버가 아닌 직군에서는 이를 활용하기 어려웠던 것 같다)

3) 최근 알림 기록

4) 추가로 테스트 공지 알림 전송

 

과 같은 기능이 필요하다 들었고, 우선 이를 처리할 Admin이라는 도메인 자체가 필요하다 생각되었다.

기존의 User와는 너무다 결이 다르다 직관적으로 생각했기 때문이다.

 

나는 인증 시스템을 평소처럼 Controller를 통해 빠르게 구현하려 하였다.....

하지만 위에서 언급했던 원초적인 질문인 "인증, 인가 가 과연 해당 서비스의 핵심 비즈니스 로직인가?"가 떠올랐고, 이에 대한 해결을 해볼 시점이 되었다 생각했다.

 

이전에 Spring Security를 통해 인증, 인가를 3번 정도 구현해 봤고, 그 과정에서 Security의 전체적인 흐름에 대하여 매우 잘 공부해 두었기 때문에 "내가 한번 직접 구현해 볼 수 있지 않을까?" 란 생각을 하게 되었다.

 

(다음과 글은 Spring Security를 디버깅 + Docs를 보면서 Flow를 정리했던 글이다)

https://blogshine.tistory.com/535

 

[Spring Security] Authentication Flow

본 글은 Spring Security docs 와 여러 블로그 들을 참고하고, 공부하면서 요약하였습니다. https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-abstractprocessingfilter Servl

blogshine.tistory.com

따라서 직접 인증 로직 전체를 구현해 보게 되었다!! 많은 Spring의 로직을 차용하여 적용하였다!

 

2. Authentication Flow 직접 구현해 보기

2-1) 인증 Flow

우선 최종적으로 구현된 나만의 인증로직 흐름은 다음과 같다.

직접 그리느라.... 힘들었다... 무단복사 절대금지...

1. 사용자의 요청이 들어온다

2. 일단 SecurityContextHolder를 Thread Local을 통해 생성해 준다. (추후 Authentication 보관 용도)

 

3. AuthenticationNonChainginFilter는 Spring에서 제공하는 interceptor의 preHandle을 통해 무조건 False를 반환시키도록 만든 abstract class이다. 이에 대한 세부 구현은 이를 상속한 class인 AdminTokenAuthenticaionFilter를 통해 수행한다.

 

따라서 비즈니스 로직의 컨트롤러를 절대로 호출하지 않게 된다!

 

4. AdminTokenAuthenticaionFilter에서 UserDetailsService를 호출하고

5. UserDetails를 상속한

6. Admin을 반환한다.

7. 이를 받은 UserDetailsService가 Admin을 다시 AdminTokenAuthenticaionFilter에 반환하고 Authentication(인증)을 수행한다.

8. 인증에 성공한다면 SuccessHandler를 통해 JWT 토큰으로 변환하게 된다.

9. 이후 SecurityContex를 clear 한 후

10. JWT 토큰을 성공적으로 반환시킨다.

 

그림의 숫자와 설명을 맞추느라 약간 문맥이 끊어지는 듯 한 설명이긴 한데, 위와 같은 큰 흐름을 통해 실행되게 된다.

 

2-2) 코드 살펴보기

우선 전체 패키지 구조는 다음과 같다.

AuthConfig에 최대한 선언적으로 @Bean을 통해 빈을 만들려고 하였다.

 

각 Class위에 @Component를 통해 생성해도 되지만, 관리적 + 전체 흐름을 보기에는 수동으로 빈으로 설정해 주고 Config에서 살피는 것이 더 도움 된다 생각했다.

@Configuration
@RequiredArgsConstructor
public class AuthConfig implements WebMvcConfigurer {

    // 일부 생략...

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SecurityContextPersistenceFilter());

        registry.addInterceptor(new AdminTokenAuthenticationFilter(
                adminDetailsService, passwordEncoder(), objectMapper,
                adminLoginSuccessHandler(), adminLoginFailureHandler()))
                .addPathPatterns("/api/v2/admin/login");

        registry.addInterceptor(new BearerTokenAuthenticationFilter(jwtTokenProvider)).addPathPatterns("/api/v2/admin/**");
    }

    @Override
    public void addArgumentResolvers(List argumentResolvers) {
        argumentResolvers.add(new AuthenticationPrincipalArgumentResolver());
    }
}

Interceptor인 AdminTokenAuthenticationFilter를 통해 "/api/v2/admin/login"의 경로로 Admin의 로그인 요청이 올 경우 해당 인터셉터가 처리하게 된다.

 

(모든 코드를 올리지는 않겠다. 흐름을 잡는 선에서만 코드를 첨부해 두겠다. 개인적으로 글에 코드가 너무 많으면 잘 눈에 들어오지 않는다 생각하기 때문이다)

 

2-3) AuthenticationNonChainingFilter

NonChainingFilter는 위에서 언급하였듯, preHandle에서 무조건 false를 반환하는 abstract class이다.

@RequiredArgsConstructor
public abstract class AuthenticationNonChainingFilter implements HandlerInterceptor {

    protected final UserDetailsService userDetailsService;
    protected final PasswordEncoder passwordEncoder;
    protected final ObjectMapper objectMapper;
    protected final AuthenticationSuccessHandler successHandler;
    protected final AuthenticationFailureHandler failureHandler;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        try {
            AuthenticationToken tokenRequest = convert(request);
            Authentication authentication = authenticate(tokenRequest);
            afterAuthentication(request, response, authentication);
            return false;
        } catch (Exception e) {
            unsuccessfulAuthentication(request, response, e);
            return false;
        }
    }

    public Authentication authenticate(AuthenticationToken tokenRequest) {
        String principal = tokenRequest.getPrincipal();
        String credentials = tokenRequest.getCredentials();

        UserDetails userDetails = userDetailsService.loadUserByUsername(principal);
        if (userDetails == null) {
            throw new AuthenticationException();
        }

        if (isNotMatchPassword(credentials, userDetails)) {
            throw new AuthenticationException();
        }

        return new Authentication(userDetails.getPrincipal(), userDetails.getAuthorities());
    }
    
    private boolean isNotMatchPassword(String password, UserDetails userDetails) {
        return !passwordEncoder.matches(password, userDetails.getPassword());
    }

    protected void afterAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        successHandler.onAuthenticationSuccess(request, response, authentication);
    }

    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Exception failed) throws IOException {
        failureHandler.onAuthenticationFailure(request, response, failed);
    }

    protected abstract AuthenticationToken convert(HttpServletRequest request) throws IOException;
}

convert 메서드의 경우 이를 상속한 자식 class에서 구현해주어야 한다. 자식 클래스마다 AuthenticaionToken을 추출하는 방식이 다를 것 이기 때문이다. 나 같은 경우 AdminTokenAuthenticationFilter 가 이를 상속한 자식이 된다.

 

로그인의 처리 방식으로 JWT의 Bearer방식, Basic방식, Password방식 등 모두 재각각 일 텐데, 이를 추출하는 방식을 모두 하위 클래스에서 알맞게 구현해 주면 된다. 나머지는 상위 클래스인 AuthenticaionNonChainingFilter에서 책임진다.

 

즉, 상위 클래스의 메서드에서 전체 흐름을 총괄하고, 자식이 해당 메서드의 일부 기능을 자신이 custom 하는 것이다.

 

그렇다!! Factory Method 패턴인 것이다!!

 

이렇게 자식 클래스로부터 인증을 진행할 정보들을 AuthenticationToken으로 만든 후 반환해 주면,

이후에 authenticate 메서드에서 해당 유저의 id를 통해 정보를 불러와 password가 일치하는지 판단하게 된다.

만약 authentication에 성공한다면 SuccessHandler를 호출하여 토큰을 반환하게 된다!!


추가로 여기에는 적혀있지 않지만, AOP를 통해 메서드의 annotation을 로딩하여 권한을 판단하는(인가) 로직 또한 구현되어 있다!

이에 대한 내용은 생략하겠다.

 

3. OSIV는??

흔히 말하는 Open Session In View에 대하여 반박이 들어올 수 있다.

 

혹시 OSIV를 모른다면 다음 글부터 읽어보길 권장한다!

https://blogshine.tistory.com/550

 

[JPA] Open Session In View 더 깊게

사실 예전에 이미 OSIV에 대한 글을 작성한 적이 있다. https://blogshine.tistory.com/379 HTML 삽입 미리보기할 수 없" data-og-host="blogshine.tistory.com" data-og-source-url="https://blogshine.tistory.com/379" data-og-url="https://b

blogshine.tistory.com

우선 크게 2가지로 나누게 되는데

1) 사용자의 첫 로그인 후 토큰을 발급받음 (상대적으로 적은 호출)

2) 발급받은 토큰을 통해 지속적으로 API 사용 (더 많은 호출)

 

결론부터 말하면 OSIV는 거의 false와 유사하게 작동할 것이다.

1번째 요청의 사용자 로그인의 경우에만 User를 인터셉터(presentation)까지 불러오기 때문에 OSIV true인 것 같지만,

 

이후 2번째 요청부터 token을 사용하기 때문에 OSIV=false와 같아진다.

토큰 안에 User에 대한 Role 정보가 있기 때문에 해당 User의 권한을 바로 판단할 수 있다. Repository로부터 user를 호출할 일이 적어도 presentation 계층에서는 없어진다는 의미이다. 

 

따라서 비즈니스 로직 API에 대한 권한이 있는지 인가를 바로 진행할 수 있다.

 

4. 느낀 점

이번 대규모 리팩토링을 통하여 확실하게 인증, 인가는 핵심로직이 아님을 확신하게 되었다.

어느 대기업이나, 스타트업이든 인증팀을 따로 분리할 수 있는 근본적인 이유는 이로부터 오는 것 아닐까?

 

해당 회사가 제공하는 가치(핵심 비즈니스 로직)로부터 인증(부가로직)은 분리가 가능해야 한다.

이로 인해 비즈니스 로직팀은 해당 기능 구현에만 집중할 수 있고, 보안팀 또한 보안사항에만 집중할 수 있는 구조가 된 것이다.

 

점진적으로 리팩토링 하면서, Security와 유사한 구조를 만들고, 테스트까지 작성하면서 최종 작동하는 코드를 보니

정말로 뿌듯하고, 최근 구현한 기능 중 가장 마음에 드는 구조가 나온 것 같아 기쁘다!

댓글