각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 3 회고 (157일차)

제이온 (Jayon) 2021. 7. 9.

안녕하세요? 제이온입니다.

 

어제는 팀원들과 회식을 하느라 부득이하게 포스팅을 하지 못했습니다. 그래서 어제 배웠던 내용을 오늘 오전에 적어보려고 합니다.

 

 

Oauth 코드 리팩토링

가장 먼저 했던 작업은 기존의 Oauth 코드를 리팩토링하는 일이었습니다. 어제 작성했던 코드를 다시 살펴보겠습니다.

 

 

     public String login(String accessToken) {
        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders apiRequestHeader = new HttpHeaders();
        apiRequestHeader.add("Authorization", "Bearer " + accessToken);
        apiRequestHeader.add("Content-type", "application/x-www-form-urlencoded;charset=utf8");
        HttpEntity<HttpHeaders> apiRequest = new HttpEntity<>(apiRequestHeader);

        HttpEntity<String> apiResponse = restTemplate.exchange(
            "https://kapi.kakao.com/v2/user/me",
            HttpMethod.POST,
            apiRequest,
            String.class
        );

        JSONObject jsonObject = new JSONObject(apiResponse.getBody());
        Long oauth_id = jsonObject.getLong("id");
        JSONObject kakao_account = (JSONObject) jsonObject.get("kakao_account");
        String email = kakao_account.getString("email");
        JSONObject profile = (JSONObject) kakao_account.get("profile");
        String nickname = profile.getString("nickname");

        User socialLoginUser = new SocialLoginUser(nickname, oauth_id.toString(),
            OAuthPlatform.KAKAO, email);

        userRepository.save(socialLoginUser);

        // 토큰 발급
        return tokenProvider.createToken(socialLoginUser.getId().toString());
    }

 

 

프론트엔드 측에서 받아온 액세스 토큰을 이용하여 카카오 api 서버에 요청을 보내고, JSONObject를 통해 json 응답을 Long이나 String 값으로 매핑하고 있습니다. 그리고 얻어온 사용자 정보를 이용하여 User 객체를 만들고 DB에 넣은 후 토큰까지 발급하였습니다.

 

여기서 카카오 api 서버에 요청을 보내는 과정은 다른 도메인에서 수행하고, 서비스 레이어는 얻어온 사용자 정보를 통해 User 객체를 만들어서 DB에 넣고 토큰을 만들도록 리팩토링하였습니다.

 

 

@Component
public class UserInfoProvider {

    public static final String KAKAO_API_SERVER_URI = "https://kapi.kakao.com/v2/user/me";

    private final RestTemplate restTemplate;

    public UserInfoProvider(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    public SocialLoginResponse findSocialLoginResponse(String accessToken) {
        HttpEntity<HttpHeaders> apiRequest = prepareRequest(accessToken);
        try {
            return restTemplate.postForObject(KAKAO_API_SERVER_URI, apiRequest, SocialLoginResponse.class);
        } catch (HttpClientErrorException e) {
            throw new AuthenticationException("토큰 인증에 실패하였습니다.");
        }
    }

    private HttpEntity<HttpHeaders> prepareRequest(String accessToken) {
        HttpHeaders apiRequestHeader = new HttpHeaders();
        apiRequestHeader.setBearerAuth(accessToken);
        apiRequestHeader.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        apiRequestHeader.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
        return new HttpEntity<>(apiRequestHeader);
    }
}

 

 

UserInfoProvider라는 객체를 정의하였고, 그 안에서 postForObject를 통해 응답 결과를 받아왔습니다. 여기서 postForObject 메소드는 요청보낼 때 헤더를 함께 넣어줄 수 있고, 특정한 타입을 지정하여 응답 결과를 바로 그 타입으로 매핑이 가능합니다. 해당 매핑은 Jackson 라이브러리를 사용하며, 메시지 컨버터를 거치는 것으로 알고 있습니다.

 

그리고 기존에는 헤더를 하드 코딩으로 설정하였는데, setXXX 계열의 메소드를 사용하여 하드 코딩대신 주어진 값을 이용하여 설정하였습니다.

 

 

@Service
@RequiredArgsConstructor
@Transactional
public class OAuthService {

    private final SocialLoginUserRepository socialLoginUserRepository;
    private final JwtTokenProvider tokenProvider;
    private final UserInfoProvider userInfoProvider;

    public String login(String accessToken) {
        SocialLoginUser socialLoginUser = parseUser(userInfoProvider.findSocialLoginResponse(accessToken));
        Optional<SocialLoginUser> foundSocialLoginUser = socialLoginUserRepository.findByOauthId(socialLoginUser.getOauthId());

        if (foundSocialLoginUser.isEmpty()) {
            socialLoginUserRepository.save(socialLoginUser);
            return tokenProvider.createToken(socialLoginUser.getId().toString());
        }
        return tokenProvider.createToken(foundSocialLoginUser.get().getId().toString());
    }

    private SocialLoginUser parseUser(SocialLoginResponse socialLoginResponse) {
        String oauthId = socialLoginResponse.getId();
        KaKaoAccount kaKaoAccount = socialLoginResponse.getKaKaoAccount();
        String email = kaKaoAccount.getEmail();
        Profile profile = socialLoginResponse.getKaKaoAccount().getProfile();
        String nickname = profile.getNickname();
        return new SocialLoginUser(nickname, oauthId, OAuthPlatform.KAKAO, email);
    }

}

 

 

다음으로, 서비스 레이어입니다. 여기에는 UserInfoProvider 객체로부터 받아온 응답값을 통해 SocialLoginUser 객체를 생성하고, 새로 등록한 유저인지 기존의 등록된 유저인지 판단하여 토큰을 생성하도록 만들었습니다. 그리고 지금 포스팅하면서 느끼는건데 parseUser 메소드는 UserInfoProvider 객체에 있어도 되지 않을까 생각이 듭니다.

 

 

스프링 인터셉터와 스프링 리졸버 도입

지금까지 받아온 액세스 토큰을 통해 카카오 api 서버에 토큰을 보내고, 응답으로 온 사용자 정보를 이용하여 JWT를 생성하였습니다. 이제는 우리가 만든 JWT가 들어간 api 요청이 있을 때, JWT가 유효한지 확인하는 작업을 거쳐야 합니다. 이것은 스프링 인터셉터를 통해 구현하였습니다.

 

 

public class LoginInterceptor implements HandlerInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    public LoginInterceptor(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if ("OPTIONS".equals(request.getMethod())) {
            return true;
        }
        final String accessToken = AuthorizationExtractor.extract(request);
        if(accessToken == null){
            return true;
        }
        jwtTokenProvider.validateToken(accessToken);
        return true;
    }
}

 

 

preflight 과정을 위항 OPTIONS 메소드는 바로 통과하게 해 주었고, 비로그인자가 댓글을 달 수 있으므로 토큰이 아예 비어있을 경우도 통과하게 해 주었습니다. 그 외에는 토큰 자체가 만료되거나 완전 이상한 토큰인지 검증합니다.

 

이후에는 댓글 기능은 구현한 팀원 측에서 JWT 토큰을 읽고 소셜 로그인 유저인지 비로그인 유저인지 판단하는 기능으 만들어 주면 좋겠다는 요청을 받았습니다. 그래서 스프링 리졸버를 통해 적절한 유저 객체를 반환해 주도록 만들었습니다. 해당 부분은 우선 돌아가게는 만들었으나 리팩토링할 필요가 있어 보입니다.

 

 

@RequiredArgsConstructor
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthService authService;

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

    @Override
    public User resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        String accessToken = AuthorizationExtractor.extract(Objects.requireNonNull(webRequest.getNativeRequest(HttpServletRequest.class)));
        return authService.findUserByToken(accessToken);
    }
}

 

 

먼저, Authorization 헤더에 있는 토큰만 쏙 빼오고, 그 토큰을 authService에 보냅니다.

 

 

@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {

    private final SocialLoginUserRepository socialLoginUserRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public User findUserByToken(String accessToken) {
        if (accessToken == null) {
            return new GuestUser();
        }

        jwtTokenProvider.validateToken(accessToken);

        String userId = jwtTokenProvider.getPayload(accessToken);
        return socialLoginUserRepository.findById(Long.parseLong(userId)).orElseThrow(IllegalAccessError::new);
    }
}

 

 

그리고 토큰이 null일 경우 비로그인 유저를 반환하고, 그렇지 않을 경우 토큰 검증을 거친뒤 정상적인 토큰이라면 페이로드에 있는 User_id를 추출합니다. 그리고 이 User_id를 이용하여 소셜 로그인 유저 객체를 반환합니다.

 

 

    @PostMapping
    public ResponseEntity<CommentResponse> save(@AuthenticationPrincipal User user, @RequestBody CommentCreateRequest commentRequest) {
        CommentResponse commentResponse = commentService.save(user, commentRequest);
        return ResponseEntity.status(HttpStatus.CREATED).body(commentResponse);
    }

 

 

그러면 댓글 컨트롤러에서는 @AuthenticationPrincipal (우리가 정의한 어노테이션)이 붙은 User에 대해서는 위에서 작성한 스프링 리졸버가 작동하여 비로그인 유저 또는 소셜 로그인 유저 객체를 반환하게 됩니다.

 

 

정리

Oauth 경험이 있는 우기와 외부 api와 매핑 경험이 있던 제가 페어로 진행하였고, 아론과 제리는 댓글과 프로젝트 기능 구현을 페어로 진행하였습니다. 철저한 분업이 아주 성공적으로 이뤄졌고 우리가 정한 핵심 기능도 70% 정도는 완료되었다고 생각합니다.

 

다만, 최근 폭발적인 코로나 확진세로 인하여 다음 주부터는 우테코를 전면 온라인으로 진행한다고 하니 이 부분은 참 고민이 많습니다. 더 이상 고시원에 있을 이유는 없지만 팀플은 해야 하고, 그렇다고 고시원 빼버렸다가 코로나 상황이 한 달 이내에 괜찮아 진다면 다시 방을 구하기도 어렵습니다. 계속 생각을 해 봐야겠습니다.

댓글

추천 글