본문 바로가기

우아한테크코스/미션 정리

Level2. atdd-subway-path 정리

2021-06-11글

STEP1, 2

JwtTokenProvider

  • jwt 토큰 생성, 토큰 검증, 토큰에서 인증 정보 추출하는 유틸 클래스

AuthorizationExtractor

  • HTTP의 Authorization Header에서 Bearer 타입인 경우 Access Token을 추출하는 유틸 클래스

AuthenticationPrincipalConfig

@Configuration
public class AuthenticationPrincipalConfig implements WebMvcConfigurer {
    private final AuthService authService;

    public AuthenticationPrincipalConfig(AuthService authService) {
        this.authService = authService;
    }

    @Override
    public void addArgumentResolvers(List argumentResolvers) {
        argumentResolvers.add(createAuthenticationPrincipalArgumentResolver());
    }

    @Bean
    public AuthenticationPrincipalArgumentResolver createAuthenticationPrincipalArgumentResolver() {
        return new AuthenticationPrincipalArgumentResolver(authService);
    }
}
  • AuthenticationPrincipalArgumentResolver는 빈 등록이 되어있지 않음
  • 해당 리졸버를 활용할 수 있도록 등록해주는 java config
  • AuthenticationPrincipalArgumentResolver를 만들고 등록

AuthenticationPrincipalArgumentResolver

public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
    private final AuthService authService;

    public AuthenticationPrincipalArgumentResolver(AuthService authService) {
        this.authService = authService;
    }

    // resolveArgument 메서드가 동작하는 조건을 정의하는 메서드
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // 파라미터 중 @AuthenticationPrincipal이 붙은 경우 동작하게 설정
        return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
    }

    // supportsParameter가 true인 경우 동작
    @Override
    public Member resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        String token = AuthorizationExtractor.extract((HttpServletRequest) webRequest.getNativeRequest());
        return authService.findMemberByToken(token);
    }
}
  • 컨트롤러 메서드에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩해주는 인터페이스
  • resolveArgument 에서 Member 도메인 자체를 반환했었음

관련 정리

AuthInterceptor

@Component
public class AuthInterceptor implements HandlerInterceptor {
    private final AuthService authService;

    public AuthInterceptor(AuthService authService) {
        this.authService = authService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = AuthorizationExtractor.extract(request);
        authService.validateToken(token);
        return true;
    }
}
  • 인증 / 인가에 대한 토큰 검증을 담당
  • 토큰이 유효하면 true를 반환하도록
  • AuthorizationExtractor.extract() 가 리졸버랑 중복이 되는데?
    • 어쩔 수 없음. 이 둘은 서로 모르는 관계로 독립적이니
    • 변경의 영향도 최소화를 위해 JwtProvider가 아닌 JwtProvider를 포함한 AuthService를 가지도록

STEP3

Graph - 그래프 역할을 하는 도메인

public class Graph {
    private final WeightedMultigraph<Station, DefaultWeightedEdge> graph = new WeightedMultigraph(DefaultWeightedEdge.class);

    public Graph(List<Section> sections) {
        initPath(sections);
    }

    private void initPath(List<Section> sections) {
        for (Section section : sections) {
            initEdge(section);
        }
    }

    private void initEdge(Section section) {
        addVertex(section.getUpStation());
        addVertex(section.getDownStation());
        graph.setEdgeWeight(graph.addEdge(section.getUpStation(), section.getDownStation()), section.getDistance());
    }

    private void addVertex(Station station) {
        if (!graph.containsVertex(station)) {
            graph.addVertex(station);
        }
    }

    public Path shortestPath(Station source, Station target) {
        DijkstraShortestPath<Station, DefaultWeightedEdge> dijkstraShortestPath = new DijkstraShortestPath<>(graph);
        return new Path(dijkstraShortestPath.getPath(source, target).getVertexList(), dijkstraShortestPath.getPathWeight(source, target));
    }
}

Path - 사실상 DTO 느낌...

public class Path {
    private final List<Station> stations;
    private final double distance;

    public Path(List<Station> stations, double distance) {
        this.stations = stations;
        this.distance = distance;
    }

    public List<Station> stations() {
        return stations;
    }

    public double distance() {
        return distance;
    }
}

처음 구현한 로직 - 모든 Line을 불러와 최단 거리 조회

public PathResponse findPath(Long sourceId, Long targetId) {
        List<Line> lines = lineDao.findAll();
        Graph graph = new Graph(lines);
        Path path = graph.shortestPath(stationDao.findById(sourceId), stationDao.findById(targetId));
        return new PathResponse(StationResponse.listOf(path.stations()), (int) path.distance());
    }

  • 사실 처음에는 sections만 가져와서 그래프를 그렸으나, 이렇게 되면 해당 역의 이름들을 가져올 수 없었음
  • 기존 코드를 사용하려고 일단 LineDao를 이용했었는데, 재연링의 리뷰대로 모든 Line을 조회하기 보다 필요한 Sections만 조회하도록 리팩토링

  • 이 부분은 DM으로 이야기를 나눴는데,
@Service
public class PathService {
    private final SectionDao sectionDao;
    private final StationDao stationDao;

    public PathService(SectionDao sectionDao, StationDao stationDao) {
        this.sectionDao = sectionDao;
        this.stationDao = stationDao;
    }

    public PathResponse findPath(Long sourceId, Long targetId) {
        List<Section> sections = sectionDao.findByStationIds(Arrays.asList(sourceId, targetId));
        Graph graph = new Graph(sections);
        Path path = graph.shortestPath(new Station(sourceId), new Station(targetId));
        return new PathResponse(StationResponse.listOf(combineStationById(path)), (int) path.distance());
    }

    private List<Station> combineStationById(Path path) {
        List<Long> stationIds = path.stations()
                .stream()
                .map(Station::getId)
                .collect(Collectors.toList());
        return stationDao.findByIds(stationIds).sortedStation(stationIds);
    }
}

CORS 이슈 해결 법

  • 나같은 경우는 front쪽에 프록시 서버를 두어 해당 이슈가 발생하지 않도록 하였음

정리

Cookie vs LocalStorage

  • 토큰을 어디에 저장할까? 글을 보고 토큰을 쿠키에 저장
  • 재연링의 리뷰로 쿠키를 사용했을 때의 문제점을 알아봄
  • XSS 공격을 막을 수 있는 HTTP-Only 쿠키를 고려
  • 하지만 이는 JS에서 꺼내 쓸 수 없어 헤더에 토큰을 실어 보낼 수 없는 이슈
  • (미션 요구사항과 OAuth 표준 등을 고려해) 재연링과 나눈 DM 내용과 같이 다음과 같은 문제들로 쿠키 대신 LocalStorage에 토큰을 저장하도록 변경
* 웹 이외의 클라이언트까지 고려하는 것이 좋다고 생각
* OAuth 표준을 지키기 위함
* 사실 HttpOnly를 통해 보호하여도, 하이재킹 등 많은 보안적 위험이 존재하기 때문에 악의적인 의도를 가진 사람이 불편해질 뿐 보안적으로 완벽하게 처리되지 않음
* MDN에서도 HTML5 이후부터 Cookie를 저장소와 같은 용도로 사용하지 않을 것을 권장

정리

Front

  • vue.config를 통해 중복되는 URL에 대한 설정
  • 중복되는 fetch는 모듈로 따로 만들어서(fetch.js) 사용하도록 변경

ArgumentResolver와 AuthInterceptor

ArgumentResolver

Strategy interface for resolving method parameters into argument values in the context of a given request.

  • 매개변수의 리졸빙
  • 컨트롤러에서 파라미터를 바인딩 해주는 역할

AuthInterceptor

Workflow interface that allows for customized handler execution chains. Applications can register any number of existing or custom interceptors for certain groups of handlers, to add common preprocessing behavior without needing to modify each handler implementation.
A HandlerInterceptor gets called before the appropriate HandlerAdapter triggers the execution of the handler itself. This mechanism can be used for a large field of preprocessing aspects, e.g. for authorization checks, or common handler behavior like locale or theme changes. Its main purpose is to allow for factoring out repetitive handler code.

  • 권한 확인 또는 로케일 또는 테마 변경과 같은 일반적인 핸들러 동작과 같은 전처리 측면의 넓은 분야에 사용
  • 인증 / 인가에 대한 검사
  • 주요 목적은 반복적 인 핸들러 코드를 제거하는 것
  • Spring doc에서 공식적으로 authorization checks를 하는 애라고 지정해준 존재
  • 공식문서

인터셉터와 리졸버는 서로를 모르는 관계이고 독립적으로 가야함

참고할 글

정리

다시 구현한다면?

ArgumentResolver 에서 DTO인 LoginMember를 반환하게 할걸!

  • 도메인을 반환하면 뷰와 컨트롤러에 노출시키고 도메인을 조작할 가능성이 있음
  • 또 불필요한 pw 필드도 가지게 됨

비번 암호화와 member 객체에서 비번을 확인하게 할걸!

public TokenResponse createToken(TokenRequest tokenRequest) {
    Member member = memberDao.findByEmailAndPassword(tokenRequest.getEmail(), tokenRequest.getPassword())
            .orElseThrow(() -> new AuthorizationException("로그인 실패입니다."));
    String accessToken = jwtTokenProvider.createToken(String.valueOf(member.getId()));
    return new TokenResponse(accessToken);
}
  • 비번을 암호화 할 때는 Spring Security의 BCryptPasswordEncoder() 를 사용
  • 회원가입할 때 입력한 비번을 인코딩하여 저장
  • 그런데 만약 여기서 로그인할 때 입력한 비번을 encoder.encode() 하여 조회하면 실패
  • 왜냐면 동일한 원문이어도 각기 다른 인코딩된 값을 내뱉기 때문
PasswordEncoder encoder = new BCryptPasswordEncoder();String 원문 = "qwe123";String 암호화된_원문 = encoder.encode(원문);String 암호화된_원문_2 = encoder.encode(원문);encorder.matches(원문, 암호화된 원문); // true암호화된_원문.equals(암호화된_원문2); //false
  • 때문에 쿼리로 Member를 받아오고 이 객체에서 encorder.matches()를 이용해 비번을 확인하자

최단거리 찾는 로직을 전략 패턴으로 구현할걸!

  • 도메인에 외부 라이브러리를 가지고 있는 것이 옳은가?
  • 만약 알고리즘이 변경되면 도메인에도 영향이 감
  • 전략패턴으로 Dijkstra 알고리즘을 주입하자

2021-06-16 위 사항들을 반영하여 다시 구현

ArgumentResolver에서 DTO인 LoginMember 반환하도록 리팩토링

public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {    private final AuthService authService;    public AuthenticationPrincipalArgumentResolver(AuthService authService) {        this.authService = authService;    }    @Override    public boolean supportsParameter(MethodParameter parameter) {        return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);    }    @Override    public LoginMember resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {        String token = AuthorizationExtractor.extract((HttpServletRequest) webRequest.getNativeRequest());        return authService.findLoginMemberByToken(token);    }}

LoginMember

public class LoginMember {    private Long id;    private String email;    private int age;    public LoginMember(Member member) {        this(member.getId(), member.getEmail(), member.getAge());    }
  • 로직에 필요한 정보만 담고 있는 DTO 생성
  • 아 참고로 Interceptor에서는 토큰 검증만 하도록 함
    • 차피 이미 로그인으로 아이디 비번을 검증했잖아?
  • 리졸버에서는 토큰을 통해 진짜 매개변수 리졸빙만 담당

member 객체에서 비번을 확인하도록

AuthService

public TokenResponse createToken(TokenRequest tokenRequest) {    Member member = findMember(tokenRequest);    if (member.isInvalidPassword(tokenRequest.getPassword())) {        throw new AuthorizationException("로그인 실패입니다.");    }    String accessToken = jwtTokenProvider.createToken(String.valueOf(member.getId()));    return new TokenResponse(accessToken);}private Member findMember(TokenRequest tokenRequest) {    return memberDao.findByEmail(tokenRequest.getEmail())            .orElseThrow(() -> new AuthorizationException("로그인 실패입니다."));}
  • 추후 단방향 비번 암호화를 위해 객체에서 비번을 비교하도록 변경

최단거리 찾는 로직을 전략 패턴으로

  • 최단 경로 찾기 전략을 인터페이스로, 이를 다익스트라로 구현한 클래스를 만듦
  • 알고리즘을 코드의 영향없이 변경할 수 있도록

SubwayGraph

public class SubwayGraph {
    private final WeightedMultigraph<Station, DefaultWeightedEdge> graph;

    public SubwayGraph(WeightedMultigraph<Station, DefaultWeightedEdge> graph, List<Section> sections) {
        this.graph = graph;
        initPath(sections);
    }
   // ...
  • 그래프 도메인

ShortestPathStrategy 인터페이스

public interface ShortestPathStrategy {
    List<Station> getVertexList(Station source, Station target);

    double getPathWeight(Station source, Station target);
}
  • 전략마다 getVertexList()getPathWeight() 를 계산하도록 명세

DijkstraShortestPathStrategy

public class DijkstraShortestPathStrategy implements ShortestPathStrategy {
    private final ShortestPathAlgorithm<Station, DefaultWeightedEdge> shortestPathAlgorithm;

    public DijkstraShortestPathStrategy(SubwayGraph subwayGraph) {
        this.shortestPathAlgorithm = new DijkstraShortestPath<>(subwayGraph.getGraph());
    }

    @Override
    public List<Station> getVertexList(Station source, Station target) {
        return shortestPathAlgorithm.getPath(source, target).getVertexList();
    }

    @Override
    public double getPathWeight(Station source, Station target) {
        return shortestPathAlgorithm.getPathWeight(source, target);
    }
}
  • SubwayGraph를 받아서 최단 경로를 구할 수 있음

ShortestPathFinder

public class ShortestPathFinder {
    private final ShortestPathStrategy shortestPathStrategy;

    public ShortestPathFinder(ShortestPathStrategy shortestPathStrategy) {
        this.shortestPathStrategy = shortestPathStrategy;
    }

    public Path findShortestPath(Station source, Station target) {
        return new Path(shortestPathStrategy.getVertexList(source, target), shortestPathStrategy.getPathWeight(source, target));
    }
}
  • 최단 경로를 찾는 역할
  • 전략을 주입받아 최단 경로를 구하고 싶어 도메인을 분리하였음...

변경된 PathService

public PathResponse findPath(Long sourceId, Long targetId) {
    List<Section> sections = sectionDao.findByStationIds(Arrays.asList(sourceId, targetId));
    SubwayGraph subwayGraph = new SubwayGraph(new WeightedMultigraph<>(DefaultWeightedEdge.class), sections);
    ShortestPathFinder shortestPathFinder = new ShortestPathFinder(new DijkstraShortestPathStrategy(subwayGraph));
    Path shortestPath = shortestPathFinder.findShortestPath(new Station(sourceId), new Station(targetId));
    return new PathResponse(StationResponse.listOf(combineStationById(shortestPath)), (int) shortestPath.distance());
}

private List<Station> combineStationById(Path path) {
    List<Long> stationIds = path.stations()
            .stream()
            .map(Station::getId)
            .collect(Collectors.toList());
    return stationDao.findByIds(stationIds).sortedStation(stationIds);
}

반영된 코드는 여기에 👻