본문 바로가기

Project/Trouble Shooting

[Independe] 소셜 로그인 도입 시 동일 호스트 문제 해결

해당 글은 프로젝트에 소셜 로그인을 도입하며 발생한 문제를 해결했던 방법에 대해 적은 글이다. 하지만 블로그를 운영하기 한참전에 해결했던 문제라 기억에 의존하며 쓴 글이며 자세한 디버깅 과정을 담아내지 못 해서 아쉽다.

 

자취생 커뮤니티 프로젝트를 진행하며 사용자가 편하게 로그인할 수 있도록 소셜 로그인을 도입했다. 도입 과정에서 많은 인가 서버를 모두 도입할 필요는 없다고 생각하여 설문을 이용해 가장 많이 사용하는 인가 서버만 도입하도록 했다.

해당 사진은 실제 설문의 결과로 카카오와 네이버 로그인 사용량이 제일 많아 카카오, 네이버 로그인을 도입하기로 했다. 하지만.. 도입과정에서 수 많은 오류가 있었다.

 

소셜 로그인 도입 당시 개인적으로 테스트해보기 위해 Thymeleaf를 이용해 SSR으로 소셜 로그인 기능을 테스트했다. Authorization Code Grant 방식의 소셜 로그인을 도입했으며 Thymeleaf를 이용할 땐 문제없이 로그인이 완료되었다. 이때까진 손쉽게 소셜로그인을 도입할 수 있을거 같았다. 그러나 우리 프로젝트의 실제 프론트엔드인 Vue.js와 통신을 할 때 문제가 발생했다. 소셜 로그인을 통해 로그인을 하면 Spring에서 내가 정의하지 않은 특이한 오류가 화면에 나타났다.

 

디버깅을 통해 소셜 로그인 진행 시 인가서버와 Code 를 가져오는 과정까진 성공했지만 해당 Code를 통해 실제 사용자 정보를 가져오는 과정에서 오류가 발생했다. 이 후 구글링을 통해 수 많은 

 

첫 번째 시도로 기존엔 vue.js 에서 spring 의 oauth2 엔드포인트를 ajax를 사용해 요청했다. 구글링 결과 ajax가 아닌 a 태그를 이용해야 한다는 글이 많아 ajax가 아닌 a 태그로 요청을 변경했지만 오류를 해결하는건 실패했다. 하지만 ajax 가 아닌 a 태그를 사용해야 하는건 맞았다.

 

두 번째 시도로 디버깅 시 인가서버에서 사용자의 정보를 가져오는 과정을 진행할 시 HttpSessionOAuth2AuthorizationRequestRepository 에서 사용자의 기존 요청 정보를 저장하는 OAuth2AuthorizationRequest의 값이 null로 변경되어 오류가 나타났음을 식별했다. 내 프로젝트는 JWT를 이용해 로그인을 처리하는데 Spring OAuth2 는 세션을 이용해 사용자의 요청 정보를 저장하기 때문에 오류가 발생한다는 글을 많이 찾을 수 있었다. 이에 대한 해결책으로 사용자의 요청을 세션이 아닌 쿠키로 저장하는 HttpCookieOAuth2AuthorizationRequestRepository를 개발하였다.

@Component
@Slf4j
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int cookieExpireSeconds = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {

        if (authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);

        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {

        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}

public class CookieUtils {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }

        return Optional.empty();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie: cookies) {
                if (cookie.getName().equals(name)) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Object object) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(object));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(SerializationUtils.deserialize(
                Base64.getUrlDecoder().decode(cookie.getValue())));
    }

}

위 코드는 실제 프로젝트에 도입한 코드이며 load, save, remove 메서드 등을 이용해 사용자의 요청 정보를 쿠키에 저장하고 불러오는 코드이다. 내 생각에 SecurityConfig 에서 세션에 저장하지 못 하도록 STATELESS 설정을 했으니 기존 사용자의 요청 정보가 없어졌을 것이며 이제 쿠키를 이용해 저장하니 드디어 이 오류가 해결될 것 같았다.

 

그러나... 내 바람과 달리 서버는 변함없이 내가 정의하지 않은 오류를 내뱉었다.. 이미 약 2주동안 구글링을 통해 삽질을 했기에 포기하고 싶은 마음이 커졌다. 꼭 소셜 로그인이 필요할까.. 그냥 로그인만 하면 충분하지 않을까 라는 생각이 떠나지 않았다. 하지만 사용자 입장에서 사이트를 사용할 때 기존의 로그인보다 소셜 로그인이 훨씬 편하다는걸 알고 있었으며 고작 이정도로 포기한다면 난 개발자를 할 수 없을 것 같았다. 난 다시 코드를 보며 여러 자료를 찾아나갔다.

 

그러나 이미 보았던 내용들이 전부였고 오류는 쉽게 해결되지 않았다. 지푸라기라도 잡는 심정으로 친구의 지인이 백엔드 개발자를 준비하고 있다하여 양해를 구하고 자문을 구했다. 같이 코드를 보며 내가 디버깅 했던 과정을 그대로 보여주며 오류에 대해 설명했다. 그분 역시 이런 오류는 처음 보았으며 오류의 원인을 정확히 파악하지 못 했다.

 

우리는 같이 디버깅을 해가며 오류를 식별하기 시작했다. 디버깅 중 갑자기 그분께서 '어..? 혹시 CORS 뚫으셨어요?' 라는 질문을 나에게 했다. 'CORS 설정은 이미 해놓았는데' 라고 생각하자마자 인가서버에 대한 CORS는 설정하지 않았다는게 떠올랐다. 난 '혹시 인가 서버도 CORS 설정을 해야할까요?' 라고 물어보았다. 브라우저에서 '현재 CORS 오류를 뱉고 있으니 설정해보는게 좋겠다' 라고 하셨다. 내가 식별하지 못 한 문제를 식별해 이 오류를 드디어 해결할 수 있겠다 라는 기쁨과 동시에 이 간단한 설정을 하지 않아 약 2주간 고생을 했다는 사실이 슬프고 부끄럽기도 했다. 하지만.. CORS  설정을 완료해도 같은 오류를 뱉어냈다.. 좌절과 함께 시간이 너무 늦어 더 이상 자문을 구할 수 없었다. 아쉬움을 뒤로하고 그 날은 오류 해결을 그만하기로 했다.

 

며칠 뒤 난 하나의 StackOverflow 글을 발견했다. 예전에도 비슷한 글을 본 것 같았는데 hosts 라는 글이 반복되며 나와는 크게 관련없는 글 같아 무시했던 기억이 났다. 난 무언가에 꼽힌듯 해당 글을 해석해가기 시작했다. 오래전에 본 글이라 자세히 기억나진 않지만 호스트가 동일하여 저장된 값을 덮어쓰는 것이다 라는 내용이었다. 뭔가 내 프로젝트에 발생한 오류와 연관이 있을 것 같았고 호스트라는 키워드로 자료를 찾아나갔다. 그 결과 인가서버와 통신하는 과정에서 Vue.js와 Spring을 내 PC 즉 Localhost로 기동하니 포트번호는 8080, 3000, 8081 등으로 다르지만 호스트 즉 Localhost는 동일하기 때문에 저장했던 사용자의 요청 정보를 덮어쓴다는 것이다. 난 곧바로 해결방법을 찾아보았다. (해당 StackOverflow의 글을 다시 찾아보려 했지만 오래전에 본 글이라 다시 찾을 수 없었다)

 

해결 방법은 PC 내부의 host 파일을 변경하는 것이다. 난 Mac을 사용하므로 터미널에서 아래의 명령을 이용해 호스트 파일에 접근한다.

sudo vim /private/etc/hosts

 

이 후 호스트 파일에 아래와 같이 추가하여 spring과 vue의 도메인을 나누었다.

127.0.0.1 spring
127.0.0.1 vue

http://spring:8080 또는 http://vue:8081 등으로 접속하면 기존의 localhost로 접속하는 것 처럼 동작하였다. 네이버 개발자 서버에 서비스 url 과 CallbackURL을 localhost가 아닌 변경한 호스트 이름으로 기입하고 실제 요청도 변경된 호스트 이름으로 호출하였다.

 

그 결과 3주간 날 힘들게 했던 오류는 더 이상 보이지 않고 정상 소셜 로그인이 드디어 정상적으로 동작했다. 약 3주간 날 괴롭히던 오류를 해결하니 그 희열은 엄청났다. 매우 힘든 과정이었지만 이 과정을 통해 디버깅을 하는 방법을 터득한 것 같았다. 이전엔 디버깅은 거의 사용하지 않거나 로그를 이용해 문제를 식별했는데 그러면 프레임워크 내부에서 발생하는 오류를 해결하기 어려웠다. 하지만 이젠 프레임워크 내부의 오류도 두렵지 않게 되었다. 개발자는 오류를 통해 성장하는것..!