클라이언트 요청 처리

이제 인증이 성공한 클라이언트로부터 인증 코드를 받아서 사용자 정보를 백엔드(클라이언트 입장에서는)에 요청하도록 컨트롤러, 서비스, 리포지토리를 만들겠다.

컨트롤러

@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class LoginController {
    private final LoginService loginService;

    @PostMapping("/auth/{provider}")
    public ResponseEntity<JSONResponse<UserResponse>> login(
            @PathVariable("provider") String providerName,
            @RequestBody @Valid LoginRequest loginRequest
    ) {

        return ResponseEntity.ok()
                .body(JSONResponse.onSuccess(loginService.login(loginRequest.code(), providerName)));
    }
}

// loginRequest.java
public record LoginRequest(
        @NotBlank String code
) {
}

서비스

OAuth 제공자를 클라이언트(서비스 클래스)에서 신경 쓸 필요 없게 하기 위해서 ProviderFactory를 설계하였다.

@Component
@RequiredArgsConstructor
public class ProviderFactory {
    private final List<OAuthProvider> providers;

    public OAuthProvider mapping(String providerName) {
        return providers
                .stream()
                .filter(provider -> provider.is(providerName))
                .findFirst()
                .orElseThrow(() -> new CommonException(ErrorCode.INVALID_REQUEST));
    }
}

is 메소드는 Stream 객체의 filter() 에서 제공자 이름과 주어진 경로 변수 값을 비교하여 이름이 같은 경우 true가 되어 해당 OAuthProvider 구현체가 반환된다. 그리고 이 코드에서 가장 중요한 것은 같은 상위 클래스 또는 인터페이스를 상속받은 빈은 다형성에 의해서 List로 상속받은 모든 빈 객체의 의존성 주입을 받을 수 있다는 것이다! 나는 몰랐다…

@Service
@RequiredArgsConstructor
public class LoginService {
    private final LoginRepository loginRepository;
    private final ProviderFactory providerFactory;

    public UserResponse login(String code, String providerName) {
        OAuthProvider provider = providerFactory.mapping(providerName);
        UserInfoDto userInfo = provider.authenticate(code);
        // 사용자가 데이터베이스에 존재하지 않으면 데이터베이스에 삽입 후 사용자 엔티티를 반환
        User user = getOrCreateUser(userInfo);
        // 엔티티로부터 응답값을 변환 후 반환
        return UserResponse.convertFrom(user);
    }

    private User getOrCreateUser(UserInfoDto userInfoDto) {
        User authUser = null;
        if (userInfoDto.getAuthType() == AuthType.KAKAO) {
            authUser = loginRepository.findByNickname(userInfoDto.getName());
        } else {
            authUser = loginRepository.findByEmail(userInfoDto.getEmail());
        }

        return authUser == null ? createUser(userInfoDto) : authUser;
    }

    private User createUser(UserInfoDto userInfoDto) {
        User persistResult = null;
        if (userInfoDto.getAuthType() == AuthType.KAKAO) {
            persistResult = User.builder()
                    .nickname(userInfoDto.getName())
                    .authType(userInfoDto.getAuthType())
                    .build();


        } else {
            persistResult = User.builder()
                    .email(userInfoDto.getEmail())
                    .nickname(userInfoDto.getName())
                    .authType(userInfoDto.getAuthType())
                    .build();
        }
        loginRepository.save(persistResult);
        return persistResult;
    }
}

한 가지 아쉬운 점은 카카오 OAuth의 경우 비즈니스 어플리케이션이 아니면 사용자 이름과 프로필 사진만 요청할 수 있기 때문에 쓸데없는 조건문을 추가해야 했다.

레포지토리

@Repository
public interface LoginRepository extends JpaRepository<User, Long> {
    // 사용자 이메일 기준으로 엔티티 조회(구글)
    User findByEmail(String email);
    // 사용자 이름 기준으로 엔티티 조회(카카오)
    User findByNickname(String nickname);
}

테스트

테스트는 만들어 두었던 클라이언트가 잘 작동됐다는 가정 하에 응답만 보도록 하겠다.

구글

구글 OAuth 정보

카카오

카카오 OAuth 정보

데이터베이스

데이터베이스 사용자 값 확인

번외

서비스에서 ProviderFactory를 이용하여 OAuthProvider 구현체를 가져왔는데 이 방법은 어떨까 싶어서 기록해둔다. 스프링에 ArgumentResolver를 추가로 등록하여 서비스에서 ProviderFactory에 의존하지 않도록 만들 것이다.

ArgumentResolver 등록

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthProviderPicker {
}
@Component
@RequiredArgsConstructor
public class ProviderArgumentResolver implements HandlerMethodArgumentResolver {
    private final ProviderFactory providerFactory;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter
                .withContainingClass(OAuthProvider.class)
                .hasParameterAnnotation(AuthProviderPicker.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        // path variable이라 / 기준으로 나누고 마지막 문자열을 가져오도록 함
        // 이 방식은 쿼리 파라미터가 더 나을 것 같다.
        // String[] pathVariableFromRequest = request.getRequestURI().split("/");
        // String providerName = pathVariableFromRequest[pathVariableFromRequest.length - 1];

        // 키 값으로 접근이 가능하기 때문이다.
        String providerName = request.getParameter("provider");

        log.info(providerName);
        return providerFactory.mapping(providerName);
    }
}
@Configuration
@RequiredArgsConstructor
public class AuthResolverConfig implements WebMvcConfigurer {
    private final ProviderArgumentResolver providerArgumentResolver;

    @Override
    public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(providerArgumentResolver);
    }
}

요청 메소드 및 서비스 수정

public class LoginController {
    private final LoginService loginService;

    @PostMapping("/auth")
    public ResponseEntity<JSONResponse<UserResponse>> login(
            @AuthProviderPicker OAuthProvider provider,
            @RequestBody @Valid LoginRequest loginRequest
    ) {

        return ResponseEntity.ok()
                .body(JSONResponse.onSuccess(loginService.login(loginRequest.code(), provider)));
    }
}
@Service
@RequiredArgsConstructor
public class LoginService {
    private final LoginRepository loginRepository;

    public UserResponse login(String code, OAuthProvider provider) {
        UserInfoDto userInfo = provider.authenticate(code);
        User user = getOrCreateUser(userInfo);
        return UserResponse.convertFrom(user);
    }
}

참고로 쿼리 파라미터를 사용해야 코드가 더 깔끔하기 때문에 클라이언트에서 요청할 때 제공자의 이름을 쿼리 파라미터로 전달해줘야 한다.

// 기존 API 사용 시
fetch('http://localhost:8080/api/user/auth/google')
// Argument Resolver + 쿼리 파라미터 사용 시
fetch('http://localhost:8080/api/user/auth?provider=google')

아무튼 이런식으로 만들면 서비스에서 ProviderFactory를 의존할 필요가 없어진다. 또한 (아마도) 메시지 처리 때 다뤘던 수정자 주입을 사용하면 ProviderFactory를 정적 메소드로 활용하여 ArgumentResolver에서도 의존성 주입을 할 필요가 없을 것 같다. 뭐가 더 나은지는 모르겠다.


스프링 시큐리티 없이 OAuth 기능 구현을 완료하였다. 이제 사용자 정보를 응답하는 것이 아닌 JWT를 응답하도록 구현할 차례다.

여담으로 3일 정도 걸린 것 같은데 알고보니 2일차에 완성해놓고 로직에 테스트 코드를 하나 안지워서 계속 오류가 발생해서 하루를 날렸다. 디버깅할 때 파일 하나에 꽂히지 말고 전체적으로 보도록 해야겠다고 생각하였다.

전체 코드 참고

참고차료

우테코 깃 허브 - 행록

카테고리:

업데이트:

댓글남기기