소셜 로그인 (Feat. DDD, 예외 처리)

김민우·2024년 8월 21일
0

잡동사니

목록 보기
24/28

이번 프로젝트에서도 소셜 로그인을 담당하게 되었다. 약 2번 정도 소셜 로그인을 구현한 경험이 있으나, Spring Security의 OAuth2 라이브러리를 사용하였기에 이번에는 직접 OAuth2 모듈을 만들어보자 라는 생각으로 임하게 되었다.

Spring Security를 사용하지 않은 이유


1. 예외 처리

Spring Security를 활용하면 인증/인가를 Filter를 통해 쉽게 제공 가능하다는 장점이 있다. 하지만, Filter의 경우 서블릿 영역 밖이라 세세한 예외 처리가 힘들었다. 인증/인가 실패 또한 AOP를 통해 전역적으로 예외 처리를 하고 싶었기에 Spring Security를 쓰지 않고 인터셉터를 통해 인증/인가를 구현하기로 마음먹었다.

2. 프로젝트 요구 사항

이번 프로젝트 기능은 크게 정기 회원/1회용 회원 2가지로 나뉜다. 각 회원끼리는 필드 구성도 다를뿐더러 요구사항 또한 매우 다르다.

  • 정기 회원
    • 소셜 로그인
    • 정기 회원끼리 일정, 방 기능 제공
    • 추후 Todo와 같은 캘린더 기능 제공 예정
  • 1회용 회원
    • 아이디/비밀번호 로그인
    • When2Meet과 같은 일정 기능 제공
    • 삽입/삭제 빈번

따라서, 설계 초반부터 두 도메인을 나눠 독립적인 개발을 지향했다. 만약, Spring Security를 사용했다면 이로 인해 두 도메인간 결합도가 높아졌을 것으로 예상되었다.

3. OAuth2 확장성

Spring Security에서 제공하는 OAuth2는 토큰 발급, 프로필 조회 등 기본적인 기능에 대한 Provider를 제공한다. 하지만, 로그아웃과 같은 다른 기능에 대한 Provider를 제공하지 않아 아쉬웠다.

이전에 진행한 카카오톡 선물하기 + 펀딩 프로젝트에서 이에 대해 고민하다 결국 커스텀 Provider를 만들어 로그아웃 기능을 제공했다.

결론적으로, 요구사항 변경으로 소셜 회원 로직이 확장되는 경우 대처하기 어렵다고 판단했다.

OAuth2 모듈


@ConfigurationProperties 을 활용해 yaml 파일로 작성된 프로퍼티를 객체화하여 필요한 Provider를 컨테이너에 등록했다.

기존 Spring Security OAuth2 와 비슷하게 Adaptor 패턴을 사용하여 메모리에 소셜별 Provider를 캐싱하고 필요시 가져와 쓰는 로직으로 구현했다.

자세한 소스코드는 아래 gitHub Repository에서 확인할 수 있다.

소셜 로그인 흐름


소셜 로그인의 흐름은 아래와 같다.

  1. 소셜 엑세스 토큰 발급
  2. (1)에서 발급받은 엑세스 토큰으로 사용자 프로필 조회
  3. 프로필 정보를 통해 회원 Repository를 조회하여 없다면 회원 엔티티를 생성한다.
  4. 회원 엔티티를 통해 JWT를 발급한다.

외부 API를 트랜잭션에서 제외

외부 API 요청/응답 흐름이 트랜잭션에 포함된다면 트랜잭션이 길어질 수 있다. 이는 DB 자원을 낭비하게 되고 최악의 경우 외부 서버에 장애가 발생한 경우 내부 서버 또한 장애로 이어질 수 있다.

RegularAuthService.java

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class RegularAuthService {
    private final InMemoryOAuthProviderRepository inMemoryOAuthProviderRepository;
    private final OAuthClient oAuthClient;
    private final OAuthResultHandler oauthResultHandler;
    private final TokenProvider tokenProvider;

    public MemberLoginResponse login(final MemberLoginRequest memberLoginRequest) {
        final OAuthProvider oAuthProvider = inMemoryOAuthProviderRepository.findByProviderName(memberLoginRequest.providerName());
        final Member member = oAuthClient.getProfile(oAuthProvider, memberLoginRequest.code())
                .publishOn(Schedulers.boundedElastic())
                .map(attributes -> oauthResultHandler.saveOrGet(attributes, memberLoginRequest.providerName()))
                .block();

        final AuthPrincipal authPrincipal = AuthPrincipal.from(member);
        final String accessToken = tokenProvider.createToken(authPrincipal);
        return MemberLoginResponse.of(accessToken, member);
    }
}

외부 API 요청이 끝난 후 map() 을 통해 영속성 계층에 접근한다. block()을 사용한 이유는 위 소셜 로그인 흐름이 비동기로 진행되면 안되기 때문이다.

OAuthResultHandler.java

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class OAuthResultHandler {
    private final MemberRepository memberRepository;

    @Transactional
    public Member saveOrGet(final Map<String, Object> attributes, final String providerName) {
        final OAuthProfile oAuthProfile = OAuthProfileFactory.of(attributes, providerName);
        return memberRepository.findBySocialId(oAuthProfile.getSocialId())
                        .orElseGet(() -> memberRepository.save(oAuthProfile.toEntity()));
    }
}

여기서 부터 트랜잭션이 시작된다. 외부 API 응답값(프로필)을 파싱하여 존재여부에 따라 save() 후 엔티티를 가져온다.

참고
아쉬운 점은 쿼리-명령-분리 원칙을 지키지 않는다는 것이다. 이 메서드는 팀원과 상의 후 예외 케이스로 두기로 결정했다.

예외 처리


이전에도 WebClient를 사용하여 소셜 로그인을 구현했지만, 예외 발생 시 응답 Body를 활용하지 않고 일괄적으로 처리했다.

이번 기회에 onStatus()와 같은 메서드를 활용해서 예외 처리를 하기로 했다. 응답값의 상태 코드에 따라 적절한 예외를 발생시켰다.

예외 처리 전 OAuthClient.java

@RequiredArgsConstructor
@Service
public class OAuthClient {
    private final MultiValueMapConverter multiValueMapConverter;
    private final WebClient webClient;

    public Mono<Map<String, Object>> getProfile(final OAuthProvider provider, final String code) {
        return issueToken(provider, code)
                .flatMap(response -> getProfileFromToken(provider, response.access_token()));
    }

    private Mono<Map<String, Object>> getProfileFromToken(final OAuthProvider provider, final String socialAccessToken) {
        return webClient.method(provider.profileMethod())
                .uri(provider.profileUrl())
                .headers(header -> header.setBearerAuth(socialAccessToken))
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<>() {
                });
    }

    private Mono<OAuthTokenResponse> issueToken(final OAuthProvider provider, final String code) {
        return webClient.method(provider.tokenMethod())
                .uri(provider.tokenUrl())
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(getIssueTokenParams(provider, code)))
                .retrieve()
                .bodyToMono(OAuthTokenResponse.class);
    }

    private MultiValueMap<String, String> getIssueTokenParams(final OAuthProvider provider, final String code) {
        return multiValueMapConverter.convertFrom(OAuthTokenRequest.of(provider, code));
    }
}

WebClient 에서 발생하는 예외가 AOP에 의해 처리되도록 설계되었다. 에러 발생 시 소셜마다 적절한 응답을 반환하는데 이를 로깅조차 하지않아 원인을 파악하기 힘들다.

외부 API 요청은 총 2개(토큰 발급, 프로필 조회)인데, 이를 구분하지 않고 일괄로 처리하므로 클라이언트 입장에서 인가 코드가 잘못되었는지, 서버 내부에서 문제가 발생했는지 알 수 있는 방법이 없다.

또한, 외부 API 요청이 너무 오래동안 지속되는 경우를 막을 수 없다. 요청이 성공했다 한들 1분이 걸렸다면... 물론, RestTemplate은 동기적으로 수행되므로 그동안 스레드가 블로킹되지만, WebClient는 비동기적으로 작동하므로 요청동안 스레드는 다른 일을 할 수 있다.

그러나, 해당 컨텍스트 유지 비용, 네트워크 리소스가 낭비된다는 등 다양한 문제가 여전히 존재한다. 임계치 이상으로 요청 시간이 길어진다면 적정선에서 다시 시도를 하거나 예외를 발생시켜 요청을 종료시켜야 한다.

OnStatus()를 활용한 예외 처리

예외 처리된 OAuthClient.java

@RequiredArgsConstructor
@Service
public class OAuthClient {
    private final MultiValueMapConverter multiValueMapConverter;
    private final OAuthTimeoutDecorator oAuthTimeoutDecorator;
    private final ProfileFailHandler profileFailHandler;
    private final TokenIssueFailHandler tokenIssueFailHandler;
    private final WebClient webClient;

    public Mono<Map<String, Object>> getProfile(final OAuthProvider provider, final String code) {
        return oAuthTimeoutDecorator.decorate(issueToken(provider, code))
                .flatMap(response -> {
                    if (failedTokenIssue(response)) {
                        return Mono.error(OAuthTokenIssueException.createWhenResponseIsNullOrEmpty());
                    }

                    return oAuthTimeoutDecorator.decorate(getProfileFromToken(provider, response.access_token()));
                });
    }

    private Mono<Map<String, Object>> getProfileFromToken(final OAuthProvider provider, final String socialAccessToken) {
        return webClient.method(provider.profileMethod())
                .uri(provider.profileUrl())
                .headers(header -> header.setBearerAuth(socialAccessToken))
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> profileFailHandler.handle4xxError(clientResponse, provider))
                .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> profileFailHandler.handle5xxError(clientResponse, provider))
                .bodyToMono(new ParameterizedTypeReference<>() {
                });
    }

    private Mono<OAuthTokenResponse> issueToken(final OAuthProvider provider, final String code) {
        return webClient.method(provider.tokenMethod())
                .uri(provider.tokenUrl())
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(getIssueTokenParams(provider, code)))
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> tokenIssueFailHandler.handle4xxError(clientResponse, provider))
                .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> tokenIssueFailHandler.handle5xxError(clientResponse, provider))
                .bodyToMono(OAuthTokenResponse.class);
    }

    private boolean failedTokenIssue(final OAuthTokenResponse oAuthTokenResponse) {
        return Objects.isNull(oAuthTokenResponse) || StringUtils.isNullOrEmpty(oAuthTokenResponse.access_token());
    }

    private MultiValueMap<String, String> getIssueTokenParams(final OAuthProvider provider, final String code) {
        return multiValueMapConverter.convertFrom(OAuthTokenRequest.of(provider, code));
    }
}

토큰 발급, 프로필 조회 API 모두 onStatus() 를 적용했다. 응답 객체를 매핑하기 전 상태 코드를 통해 에러 확인 후 소셜에서 제공하는 에러 응답으로 매핑 후 예외를 발생시킨다.

TokenIssueFailHandler.java

@Component
public class TokenIssueFailHandler {
    public Mono<OAuthTokenIssueException> handle4xxError(final ClientResponse clientResponse,
                                                         final OAuthProvider oAuthProvider) {
        return clientResponse.bodyToMono(OAuthErrorResponseFactory.getTokenIssueResponseClassFrom(oAuthProvider.name()))
                .map(OAuthTokenIssueException::new);
    }

    public Mono<IllegalStateException> handle5xxError(final ClientResponse clientResponse,
                                                      final OAuthProvider oAuthProvider) {
        return clientResponse.bodyToMono(OAuthErrorResponseFactory.getTokenIssueResponseClassFrom(oAuthProvider.name()))
                .map(OAuthTokenIssueErrorResponse::getMessage)
                .map(IllegalStateException::new);
    }
}

에러 발생시 ClientResponse을 소셜에서 제공하는 에러 응답으로 바인딩 후 에러 코드, 메시지를 로깅하거나 클라이언트에게 제공함으로써 구체적인 원인을 알 수 있게 되었다.

물론, 소셜마다 에러 시 응답 형태가 다르다. 이를 제공하주는 팩토리 클래스를 살펴보자.

OAuthErrorResponseFactory.java

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class OAuthErrorResponseFactory {
    private static final Map<String, ProviderErrorResponseConfig> errorResponseConfigMap = new HashMap<>();

    static {
        errorResponseConfigMap.put("kakao", new ProviderErrorResponseConfig(
                KakaoProfileErrorResponse.class, KakaoTokenIssueErrorResponse.class)
        );
        errorResponseConfigMap.put("naver", new ProviderErrorResponseConfig(
                NaverProfileErrorResponse.class, NaverTokenIssueErrorResponse.class)
        );
        errorResponseConfigMap.put("google", new ProviderErrorResponseConfig(
                GoogleProfileErrorResponse.class, GoogleTokenIssueErrorResponse.class)
        );
    }

    public static Class<? extends OAuthProfileErrorResponse> getProfileResponseClassFrom(final String providerName) {
        return getProviderErrorResponseConfig(providerName).profileErrorResponseClass;
    }

    public static Class<? extends OAuthTokenIssueErrorResponse> getTokenIssueResponseClassFrom(final String providerName) {
        return getProviderErrorResponseConfig(providerName).tokenIssueErrorResponseClass;
    }

    private static ProviderErrorResponseConfig getProviderErrorResponseConfig(final String providerName) {
        final ProviderErrorResponseConfig config = errorResponseConfigMap.get(providerName);
        if (config == null) {
            throw new UnsupportedProviderException(providerName);
        }
        return config;
    }

    private record ProviderErrorResponseConfig(
            Class<? extends OAuthProfileErrorResponse> profileErrorResponseClass,
            Class<? extends OAuthTokenIssueErrorResponse> tokenIssueErrorResponseClass) {
    }
}

미리 메모리에 소셜마다 응답 클래스를 올려놓고 필요마다 이를 가져와 알맞는 객체로 매핑한다.

데코레이터 패턴

앞서 API 요청/응답 시간이 임계치 이상으로 오래 걸린다면 재시도, 예외 발생 조치를 해야한다고 했다. 이는 Mono.timeout(), retryWhen() 등으로 구현할 수 있다.

그러나, 요청 API 마다 이를 붙이면 중복 코드가 발생하므로 데코레이터 패턴을 활용해 중복 코드를 최소화하며 책임을 분산시키기로 했다.

OAuthTimeoutDecorator.java

@Component
public class OAuthTimeoutDecorator {
    private final int timeout;
    private final int maxRetry;

    public OAuthTimeoutDecorator(@Value("${oauth.timeout}") final int timeout,
                                 @Value("${oauth.max-retry}") final int maxRetry) {
        this.timeout = timeout;
        this.maxRetry = maxRetry;
    }

    public <T> Mono<T> decorate(final Mono<T> mono) {
        return mono.timeout(Duration.ofMillis(timeout))
                .retryWhen(Retry.max(maxRetry).filter(this::isRetryable))
                .onErrorMap(TimeoutException.class, e -> new IllegalStateException("요청 시간이 초과되었습니다.", e));
    }

    private boolean isRetryable(Throwable ex) {
        return (ex instanceof IllegalStateException) || (ex instanceof TimeoutException);
    }
}

예외 클래스에도 책임을 부여하자 (Feat. DDD)

앞서 외부 API 에러 발생 시 로깅을 한다 했는데, 위 코드에는 로깅이 하나도 없다. 그렇다면 로깅은 어디서 할까? 비밀은 커스텀 예외 클래스에 있다.

OAuthTokenIssueException.java

@Slf4j
public class OAuthTokenIssueException extends RuntimeException {
    public OAuthTokenIssueException(final OAuthTokenIssueErrorResponse errorResponse) {
        super(errorResponse.getMessage());
        log.error("소셜 토큰 발급 실패. 에러 코드 : {}, 에러 메시지 : {}", errorResponse.getErrorCode(), errorResponse.getMessage());
    }

    private OAuthTokenIssueException(final String message) {
        super(message);
    }

    public static OAuthTokenIssueException createWhenResponseIsNullOrEmpty() {
        return new OAuthTokenIssueException("소셜 토큰 발급 API 호출은 성공했으나 응답값에 엑세스 토큰이 존재하지 않습니다. 네이버 소셜 로그인의 경우 인가 코드를 확인하세요.");
    }
}

예외 객체를 만드는 경우 전달받은 에러 파라미터 담긴 메시지나 에러 코드를 로깅한다. 또한, 정적 팩토리 메서드를 제공하여 특정 상황에서 예외 발생을 시키도록 했다.

참고 정적 팩토리 메서드(createWhenResponseIsNullOrEmpty())가 있는 이유
네이버 소셜 토큰 발급은 인가 코드가 잘못된 경우 상태 코드 4xx이 아닌 2xx를 반환한다. 이는 onStatus()를 통해 처리하기 힘들다.

OAuthClient 에서 엑세스 토큰 발급 후 프로필을 조회하는 과정에 if문을 통해 토큰이 제대로 발급됬는지 검증하는데 검증 실패 시 마찬가지로 OAuthTokenIssueException 을 발생시킨다.

이 때, 단순히 new OAuthTokenIssueException() 라 명시했다면 가독성이 떨어졌을 것이다. 따라서, 정적 팩토리 메서드를 통해 메서드명에 의미를 부여했다.

DDD를 목표로 개발을 하고 있어 엔티티 설계시 @Convert 를 사용하여 VO를 적극적으로 활용했다. 반면, 예외 클래스의 경우는 크게 신경쓰지 않았으나 이러한 예외 클래스까지 책임을 부여하여 더욱이 결합도가 낮아졌다는 느낌이 들었다.

0개의 댓글