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

[우아한 테크코스 3기] LEVEL 2 회고 - 지하철 경로 조회 1, 2단계 미션 1차 피드백을 받아 보다 (104일차)

제이온 (Jayon) 2021. 5. 16.

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

 

지하철 경로 조회 미션 1, 2단계 1차 피드백을 받아서, 이에 대한 내용을 공유하려고 합니다.

 

 

지하철 경로 조회 미션 1, 2단계 피드백

 

 

첫 번째 수정 사항은 ok() 안에 body 값을 넣으라는 것입니다. 저는 ok() 메소드의 인자로 body을 바로 넣을 수 있을지 몰랐는데 가능했습니다. 

 

 

 

 

두 번째 수정 사항은 코니에게 DM을 보내 보니까 적절한 곳에서 예외를 처리하라고 답변받았습니다. 현재 해당 메소드는 MemberService에서 쓰이고 있는데, 인증 관련 에러가 발생하는 것이 어색하기 때문이죠. 따라서 해당 메소드의 반환 타입을 Member가 아니라 Optional<Member>로 바꾸고 이 메소드를 사용하는 외부(Auth)에서 orElseThrow() 사용하도록 수정하였습니다.

 

 

 

 

세 번째는 수정 사항은 아니고 저의 질문에 대한 답변입니다. 이번 미션을 수행하면서 액세스 토큰을 쿠키를 통해 전송하는 방법과 js단에서 localStorage를 사용하는 방법을 모두 사용해 보았습니다. 그런데, 둘 중에 어떤 것을 사용하는 것이 적합한지 질문하였고, 새로고침할 때마다 로그아웃이 되는 현상을 질문하였습니다.

 

다만, 코니도 Vue나 현업에서 로그인을 다루지는 않아서 직접적인 답변보다는 관련 링크를 달아주셨습니다. JWT 저장 관련 포스팅은 이곳에서 참조하실 수 있습니다. 해당 포스팅에 따르면 JWT 토큰 저장을 쿠키에 하든 localStorage에 하든 단점이 존재하는데, 가장 좋은 방법은 둘 중 어느 것도 아닌 refresh token이라고 합니다. 그런데, refresh token은 뭔지도 잘 모르겠고 아직은 적용하고 싶지 않아서(?) 좀 더 구현하기 쉬운 localStorage를 사용하기로 했습니다. 왜냐하면, 쿠키는 스프링에서 쿠키를 만들어 header에 담아야하고, js 단에서 쿠키를 가져오는 코드가 조금 길었기 때문입니다. 반면, localStorage는 setItem()으로 토큰을 저장하고 getItem()으로 토큰을 반환하면 돼서 꽤나 심플합니다.

 

localStroage 사용은 워낙 간단하니까 쿠키를 사용한 코드를 보겠습니다.

 

 

    @PostMapping("/login/token")
    public ResponseEntity<Void> tokenLogin(@RequestBody TokenRequest tokenRequest) {
        final TokenResponse tokenResponse = authService.createToken(tokenRequest);
        final ResponseCookie responseCookie =
            ResponseCookie.from("accessToken", tokenResponse.getAccessToken()).path("/").maxAge(1800L).build();
        final HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.SET_COOKIE, responseCookie.toString());
        return ResponseEntity.ok().headers(headers).build();
    }

 

 

해당 메소드는 로그인할 때 토큰을 발급해 주는 메소드입니다. TokenRequest는 email과 password를 갖고 있는 객체이며, TokenResponse는 String 타입의 jwt 토큰 정보를 가지고 있습니다. 하단에 ResponseCookie를 통해 쿠키를 생성할 수 있는데, path("/")는 모든 경로에서 쿠키에 접근할 수 있다는 의미이고 maxAge()는 토큰의 수명 시간을 정해줍니다. 그 아래에는 HttpHeaders가 있는데, 말그대로 헤더에 쿠키를 담는 코드입니다.

 

이제, js단에서 쿠키를 받아서 토큰을 만들어 봅시다.

 

 

  let option = {
    method: 'POST',
    headers: {
    'Content-Type': 'application/json'
    },
    'credentials': 'include',
    body: JSON.stringify(loginRequest)
  };

  let response = await fetch("http://localhost:8080/login/token", option);
  if (!response.ok) {
    this.showSnackbar(SNACKBAR_MESSAGES.LOGIN.FAIL);
    return;
  }

  const COOKIE = name => document.cookie
  .split("; ")
  .find(row => row.startsWith(name))
  .split("=")[1];
  const accessToken = COOKIE("accessToken");

 

 

아까 서버에서 작성한 "/login/token"에 접근하기 위하여 위와 같이 비동기 통신을 합니다. 그리고 쿠키는 위와 같은 코드로 가져올 수 있으며, 이를 이용하여 결과적으로 액세스 토큰을 얻을 수 있습니다.

 

 

그 외에 새로고침 이슈나 사용자 정보 업데이트 후 바로 수정이 안 되는 이슈는 글 하단부에 설명하겠습니다.

 

 

 

 

네 번째 수정 사항은 외래키 참조 무결성을 지키라는 것입니다. 현재, 테이블을 보면 SECTION 테이블이 STATION 테이블을 참조하고 있으므로 역을 다이렉트로 삭제하면 외래키 참조 무결성이 발생합니다. 따라서, 해당 STATION을 지우기 전에 STATION이 있는 구간을 지워 주어야 합니다. 이 부분은 원래 우테코에서 주어진 코드가 다소 복잡해서 구현하기는 힘들었습니다.

 

 

    public void deleteStationInEveryLine(Long id) {
        final List<Long> lineIds = lineDao.findLinesContainStationById(id);

        for (final Long lineId : lineIds) {
            removeLineStation(lineId, id);
        }
    }

 

 

이런 식으로 지우려는 Station이 있는 Line을 먼저 찾고, 그 Line를 돌면서 지우려는 Station이 있는 구간을 싹 지워줍니다. 이것보다 더 좋은 방법이 있는지는 모르겠습니다. 아무래도 해당 테이블의 구조가 아래와 같아서 일단 최선의 방법을 사용해 보았습니다.

 

 

create table if not exists STATION
(
    id bigint auto_increment not null,
    name varchar(255) not null unique,
    primary key(id)
);

create table if not exists LINE
(
    id bigint auto_increment not null,
    name varchar(255) not null unique,
    color varchar(20) not null,
    primary key(id)
);

create table if not exists SECTION
(
    id bigint auto_increment not null,
    line_id bigint not null,
    up_station_id bigint not null,
    down_station_id bigint not null,
    distance int not null,
    primary key(id),
    foreign key (up_station_id) references station(id),
    foreign key (down_station_id) references station(id)
);

 

 

 

 

 

마지막 수정 사항은 토큰 유효성을 체크를 다른 곳에서 하라는 것입니다. 이 피드백 및 새로 고침시 로그아웃 이슈를 고치기 위하여 정말 힘든 시간을 보냈습니다. 이 부분은 따로 문단을 나눠서 설명하겠습니다.

 

 

스프링 인터셉터의 필요성

위 피드백을 반영하기 전에 제가 짰던 코드의 문제점을 먼저 말씀드리겠습니다. 현재는 인터셉터라는 것 없이 리졸브 단에서만 토큰 유효성을 검증하고 LoginMember를 반환합니다. 또한, 이 리졸브를 통해 토큰의 유효성을 검증하는 것은 오로지 컨트롤에 @AuthenticationPrincipal이 붙은 파라미터가 있어야만 가능합니다. 즉, 로그인 이후 사용할 수 있는 컨텐츠(역 관리, 노선 관리, 구간 관리 등)에 대해서 비로그인 유저도 사용할 수 있게 됩니다. 왜냐면 역, 노선, 구간 관련 컨트롤러에는 @AuthenticationPrincipal이 붙은 파라미터가 없기 때문이죠. 그렇다고 해당 컨트롤러들에 이 어노테이션을 달아서 리졸브에게 LoginMember를 반환해 달라고 요청하는 것도 부적절합니다.

 

이때 필요한 것이 바로 스프링 인터셉터입니다.

 

 

 

 

스프링 인터셉터는 DispatcherServlet가 실행된 후 Controller로 가기 전에 끼어 들어서 HttpRequest나 HttpResponse를 가로챕니다. 위 사진이 얼핏 보면 복잡해보이는데, 아래와 같이 이해하시면 좋습니다.

 

 

https://goodgid.github.io/Spring-HandlerInterceptor/

 

 

우리는 컨트롤러에 가기 전에 토큰 검증을 해야 하므로 인터셉터의 preHandler() 메소드를 사용해야 합니다. HandlerInterceptor을 implements 받아서 구현해 봅시다.

 

 

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) throws Exception {
        final String accessToken = AuthorizationExtractor.extract(request);
        if (!jwtTokenProvider.validateToken(accessToken)) {
            throw new AuthorizationException("잘못된 토큰입니다.");
        }
        return true;
    }
}

 

 

위와 같이 request에서 Authorization 헤더 부분의 jwt 토큰을 가져옵니다. 그리고 JwtTokenProvider의 validateToken() 메소드를 사용하여 토큰을 검증합니다. 참고로 해당 preHandle() 코드에서 반환값이 false가 나온다면, controller로 요청을 하지 않습니다. 다만, 저는 바로 에러로 날려서 401 에러 코드가 반환되도록 하였습니다. 그리고 인터셉터를 Bean에 등록하기 위하여 아래와 같은 코드를 작성합니다.

 

 

@Configuration
public class AuthenticationPrincipalConfig implements WebMvcConfigurer {

    private final JwtTokenProvider jwtTokenProvider;

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

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor())
            .addPathPatterns("/**")
            .excludePathPatterns("/members")
            .excludePathPatterns("/login/token");
    }

    @Bean
    public LoginInterceptor loginInterceptor() {
        return new LoginInterceptor(jwtTokenProvider);
    }
}

 

 

여기서 addInterceptors() 메소드는 인터셉터로 검증할 path를 입력합니다. 저는 일단 모든 urI은 전부 등록하고, 검증이 필요없는 부분만 제외하는 방식으로 코드를 작성했습니다.

 

이렇게 인터셉터를 구현하면, 리졸브의 토큰을 검증하는 로직을 인터셉터로 옮김으로써 검증 책임을 리졸브에게 의존하지 않게 됩니다.

 

 

public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

    private final JwtTokenProvider jwtTokenProvider;

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

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

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

        return getLoginMember(accessToken);
    }

    public LoginMember getLoginMember(String token) {
        final String subject = jwtTokenProvider.getPayload(token).getSubject();

        final Long id = Long.valueOf(subject);
        return new LoginMember(id);
    }
}

 

 

위와 같이 리졸브는 검증 없이 단순히 특정 객체를 임의의 객체로 변환해 주기만 합니다.

 

 

CORS 이슈

위와 같이 인터셉터를 만들어 주고 통신을 하려니까 CORS 문제가 발생했습니다. 

 

 

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedMethods("*")
                .allowedOriginPatterns("http://localhost:8081")
                .allowCredentials(true)
                .exposedHeaders(HttpHeaders.LOCATION); // <<< 여기
    }

 

 

웨지가 말해준 대로, 이렇게 WebConfig 설정을 하고 js 단에서 fetch 통신을 할 때 credentials을 true로 설정해도 CORS 이슈가 발생했습니다. 어떻게 해야 하나 골머리를 앓고 있던 와중에 샐리가 Vue 단에서 프록시 설정하는 방법을 알려주었습니다. vue.config.js에 들어가서 아래와 같이 코드를 작성합니다.

 

 

const path = require('path');

module.exports = {
  outputDir: path.resolve(__dirname, '../src/main/resources/static/'),
  devServer: {
    proxy: {
      '/': {
        target: 'http://localhost:8080/',
        ws: true,
        changeOrigin: true,
      },
    },
  },
  transpileDependencies: [
    'vuetify'
  ]
};

 

 

그리고 저는 그전까지 fetch 통신할 때 urI를 "http://localhost:8080/stations/와 같이 포트 번호를 구체적으로 명시해 주었습니다. 프록시 설정을 하게 되면 "http://localhost:8081/"로 시작하는 모든 url에 대해서 명시적으로 "http:/localhost:8080/"으로 바꾸어 주므로 fetch 통신할 때의 url의 포트 번호는 전부 떼 줍니다. 이렇게 하면 동일 포트와의 통신이 되어서 CORS 문제가 발생하지 않습니다. 케빈은 인터셉터 단계에서 메소드가 OPTION인지 확인해 보라고 조언해주었지만... 잘 모르겠어서 일단은 이 방법을 사용하기로 했습니다 ㅋㅋㅋㅋ

 

 

새로고침할 때 로그아웃이 되는 이슈

사실, 이 이슈는 인터셉터를 만들기 전에도 존재했습니다. 그때는 그냥 "유저가 알아서 로그인 다시 하겠지~"라는 안일한 생각으로 넘어갔습니다. 그런데, 이제는 로그아웃을 넘어서서 새로 고침할 때마다 401 에러가 발생하는 큰 문제가 생겼습니다. 이유는 간단합니다.

 

예를 들어, 우리가 메인 화면에서 역 관리 버튼을 눌러서 "/stations"에 접속했다고 가정해 봅시다. 그렇다면, Vue 단에서 아래와 같은 코드가 동작합니다.

 

async created() {
    const accessToken = localStorage.getItem("token");
    const response = await fetch("/api/stations", {
      method: 'GET',
      headers: {
        "Authorization": "Bearer " + accessToken
      }
    });
    if (!response.ok) {
      throw new Error(`${response.status}`);
    }
    const stations = await response.json();
    this.setStations([...stations]); // stations 데이터를 단 한개 존재하는 저장소에 등록
  }

 

 

먼저, 사용자가 정상적으로 로그인된 유저인지 확인하는 것이죠. 그래서 헤더에 토큰을 담아서 서버로 넘기고, 인터셉터에서 유효한 토큰인지 검증합니다.

 

 

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) throws Exception {
        final String accessToken = AuthorizationExtractor.extract(request);
        if (!jwtTokenProvider.validateToken(accessToken)) {
            throw new AuthorizationException("잘못된 토큰입니다.");
        }
        return true;
    }
}

 

 

앞서 언급했던 위 인터셉터의 preHandle()를 통해 검증하는 것이죠.

 

그런데, 우리가 바로 해당 페이지에서 새로고침을 때려 버린다면 어떻게 될까요? 맞습니다. 헤더에 토큰이 없으므로 인터셉터에서 에러를 잡아서 401번 에러 코드를 반환할 것입니다. 분명, 사용자는 로그인 상태지만 401번 에러 코드가 발생하는 아이러니한 상황이 연출되는 것이죠.

 

또, 재미있는 점은 없는 url을 입력하면 404번 에러가 떠야 하는데 위와 마찬가지로 401번 에러가 발생합니다. 왜냐하면, 이 url도 토큰이 없는데 에러에 잡혀서 401번 에러가 뜨는 것입니다. 즉, 우리는 특정 url을 입력하면 모두 서버에 통신된다는 사실을 알 수 있습니다. 왜 그럴까요?

 

 

const path = require('path');

module.exports = {
  outputDir: path.resolve(__dirname, '../src/main/resources/static/'),
  devServer: {
    proxy: {
      '/': {
        target: 'http://localhost:8080/',
        ws: true,
        changeOrigin: true,
      },
    },
  },
  transpileDependencies: [
    'vuetify'
  ]
};

 

 

이유는 간단합니다. 우리가 프록시 url을 '/'로 설정해 버렸기 때문입니다. 즉, 프론트에서의 모든 경로는 8080의 스프링 서버로 통신된다는 뜻입니다. 하지만, api 통신을 위해서가 아닌 url까지는 서버랑 통신할 필요가 없습니다. 따라서, 해당 프록시 url을 '/api'정도로 바꾸고 fetch 통신에서도 '/stations'이 아닌 '/api/stations'으로 바꿔 주어야 합니다. 이렇게 하면, 401번 에러는 발생하지 않습니다.

 

 

길고 길었네요. 이제, 새로고침해도 로그인이 유지되게 만들어 봅시다. 일단, main.js에 들어갑니다.

 

 

new Vue({
  vuetify,
  router,
  store,
  render: h => h(App)
}).$mount("#app");

 

 

들어가면 뭔가 보일 텐데, 이 안에 해당 유저가 로그인 상태이고, 유효한 토큰인지 확인하는 메소드를 만들면 됩니다.

 

 

new Vue({
  vuetify,
  router,
  store,
  async created() {
      const accessToken = localStorage.getItem("token");
      if (accessToken) {
        const member = await fetch("/api/members/me", {
          method: 'GET',
          headers: {
            'Authorization': 'Bearer ' + accessToken
          }
        });
        if (member.ok) {
          const memberInfo = await member.json();
          this.setMember(memberInfo);
        }
      }
    },
    methods: {
    ...mapMutations([SET_MEMBER])
    },
  render: h => h(App)
}).$mount("#app");

 

 

저는 위와 같이 작성했습니다. 정확한 작동 원리는 모르겠으나, 매 페이지마다 해당 created() 메소드가 작동하여 인증 과정을 거칩니다.

 

 

정리

인터셉터, 리졸버, CORS, 새로고침 이슈까지.. 뭘 하면 할수록 에러가 늘어나서 정말 고생했습니다. 그래도 삽질을 많이 하면서 인터셉터와 리졸버의 감을 잡을 수 있었고, CORS는 절 화나게 했으며, vue 작동 방식인 SPA를 익힐 수 있었습니다.

댓글

추천 글