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

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

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

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

 

오늘도 온라인으로 우테코를 진행하였고, 코니의 피드백을 중점적으로 반영해 보았습니다.

 

 

지하철 경로 조회 3단계 미션 1차 피드백

 

 

첫 번째 수정 사항은 메소드, 파라미터명 그리고 반환 형태를 수정하라는 것입니다. 우선, Member끼리 비교하는 것인데 파라미터명이 'request'가 들어간 것이 어색하고, 메소드명이 valid라는 것도 어색합니다. 그래서 메소드명을 hasSameMemberInfo()정도로 수정하고, 파라미터명도 그냥 member로 고쳤습니다. 그리고 해당 파라미터는 findByEmail()을 통해 얻어온 Member이므로 이메일은 당연히 같을 것입니다. 따라서 비밀번호만 비교하도록 변경하였습니다.

 

 

 

 

두 번째 수정 사항은 파라미터명을 수정하라는 것입니다. 먼저, 해당 코드의 전문을 보겠습니다.

 

 

    public List<Long> findLinesContainStationById(Long id) {
        String sql = "select L.id as line_id, L.name as line_name, L.color as line_color, " +
            "S.id as section_id, S.distance as section_distance, " +
            "UST.id as up_station_id, UST.name as up_station_name, " +
            "DST.id as down_station_id, DST.name as down_station_name " +
            "from LINE L \n" +
            "left outer join SECTION S on L.id = S.line_id " +
            "left outer join STATION UST on S.up_station_id = UST.id " +
            "left outer join STATION DST on S.down_station_id = DST.id " +
            "WHERE UST.id = ? or DST.id = ?";

        List<Map<String, Object>> result = jdbcTemplate.queryForList(sql, id, id);
        return result.stream()
            .map(info -> info.get("LINE_ID"))
            .map(obj -> Long.valueOf(String.valueOf(obj)))
            .collect(Collectors.toList());
    }

 

 

해당 메소드는 특정 지하철역을 갖고 있는 노선을 찾는 역할을 합니다. 그런데, 메소드 명에 Line과 Station도 들어가고 거기다가 ById라고 하니까 파라미터가 lineId인지 stationId인지 헷갈립니다. 따라서 명시적으로 stationId를 붙이도록 수정하였습니다.

 

 

 

 

세 번째 수정 사항은 예외 처리를 잘 하라는 것입니다. 제가 경로 조회할 때 급하게 한다고 예외를 대충 처리했는데 딱 걸려버렸습니다 ㅎㅎ.. 그래서 커스텀 예외도 만들면서 ControllerAdvice를 생성하여 에러핸들링을 하였습니다.

 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    private final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Void> handleBusinessException(BusinessException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(AuthorizationException.class)
    public ResponseEntity<Void> handleAuthorizationException(AuthorizationException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    @ExceptionHandler({SQLException.class, DuplicateKeyException.class})
    public ResponseEntity<Void> handleSQLException(SQLException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Void> handleSeverException(RuntimeException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

 

 

저는 계층 구조를 좋아해서 적절히 나눠보긴 하였습니다. 그런데, 여기서 한 가지 문제점은 DuplicatedKeyException이 SQLException의 상속을 받지 않아서 해당 익셉션이 발생하면 RuntimeException으로 간주되어 500번 에러가 발생합니다. 이때, handleServerException()을 주석 처리하고 해당 익셉션이 발생하도록 유도하면 또 정상적으로 400번 에러가 발생합니다. 자세한 원리는 모르겠으나 실제로 DB에 값을 넣으면서 SQLIntegrityConstraintViolationException 익셉션이 발생하는데, 이것이 SQLException의 상속을 받으므로 400번 에러가 정상적으로 발생하는 듯합니다. 그래서 일단은 DuplicatedException을 handleSQLException()에 같이 올려두고 코니에게 질문하기로 하였습니다.

 

 

 

 

네 번째 수정 사항은 특정 로직을 리팩토링하라는 것입니다. 먼저, 코드 전문을 보겠습니다.

 

 

    private static RowMapper<Section> rowMapper(List<Station> stations) {
        return (rs, rowNum) -> {
            final long id = rs.getLong("id");
            final long upStationId = rs.getLong("up_station_id");
            final long downStationId = rs.getLong("down_station_id");
            final int distance = rs.getInt("distance");
            final Station upStation =
                stations.stream().filter(station -> station.isSameId(upStationId))
                    .findAny().orElseThrow(() -> new NotFoundStationException("해당하는 Id의 지하철역이 없습니다."));
            final Station downStation =
                stations.stream().filter(station -> station.isSameId(downStationId))
                    .findAny().orElseThrow(() -> new NotFoundStationException("해당하는 Id의 지하철역이 없습니다."));
            return new Section(id, upStation, downStation, distance);
        };
    }

 

 

해당 메소드는 mapper 역할을 합니다. 이때, 파라미터인 지하철역 리스트를 가져와서 upStationId와 동일한 지하철역, downStationId와 동일한 지하철역을 가져오는 로직이 효율적이지 않습니다. 따라서, 아래와 같이 StationDao를 활용하도록 수정하였습니다.

 

 

    private RowMapper<Section> rowMapper(List<Long> stationIds) {
        return (rs, rowNum) -> {
            final long id = rs.getLong("id");
            final long upStationId = rs.getLong("up_station_id");
            final long downStationId = rs.getLong("down_station_id");
            final int distance = rs.getInt("distance");

            if (!stationIds.contains(upStationId) || !stationIds.contains(downStationId)) {
                throw new NotFoundStationException("해당하는 Id의 지하철역이 없습니다.");
            }
            final Station upStation = stationDao.findById(upStationId);
            final Station downStation = stationDao.findById(downStationId);
            return new Section(id, upStation, downStation, distance);
        };
    }

 

 

파라미터를 Station의 ID 리스트로 설정하고, stationDao의 findById()를 통해 바로 Station을 가져오도록 수정하였습니다.

 

 

 

 

다섯 번째 수정 사항은 테스트가 성공하도록 바꾸라는 것입니다. 저는 인터셉터를 추가하면서 역, 노선, 관리 부분은 로그인해야 기능을 이용할 수 있도록 고쳤습니다. 그래서 당연히 지금 테스트는 돌아가지 않는 것이죠. 따라서, 인증 처리를 넣음으로써 이를 해결했습니다.

 

 

    public static ExtractableResponse<Response> 지하철역_생성_요청(String name) {
        회원_등록되어_있음(EMAIL, PASSWORD, AGE);
        TokenResponse tokenResponse = 로그인되어_있음(EMAIL, PASSWORD);
        StationRequest stationRequest = new StationRequest(name);

        return RestAssured
            .given().log().all()
            .auth().oauth2(tokenResponse.getAccessToken())
            .body(stationRequest)
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .when().post("/api/stations")
            .then().log().all()
            .extract();
    }

 

 

위와 같이 매번 회원을 등록하고, 로그인을 하여 토큰을 가져옵니다. 그리고 RestAssured의 auth().oauth2({토큰}) 메소드를 통해 인증을 합니다. 자세한 원리는 모르겠으나 해당 메소드를 넣어 주어야 인터셉터에서 정상적으로 토큰이 유효한지 검증할 수 있습니다.

 

 

 

 

마지막 수정 사항은 외래키 참조 무결성을 지키라는 것입니다. 저는 Station-Section에 대해서는 이를 잘 지켰지만, Line-Section은 빼먹었습니다. 이에 대해 Line을 지우기 전에 해당 line_id에 속하는 Section을 지우는 메소드를 만들려고 하였으나, 바다가 cascade 명령어를 알려주었습니다.

 

 

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),
    foreign key (line_id) references line(id) on delete cascade
);

 

 

맨 마지막 줄을 보면 "on delete cascade"라는 명령어가 있습니다. 이 속성을 붙이면, 해당 line_id가 지워질 때 참조되는 Section이 같이 지워진다는 것을 의미합니다. 이를 통해 손쉽게 외래키 참조 무결성을 지킬 수 있습니다.

 

 

431번 에러 코드 해결

피드백을 반영하던 중, 샐리라는 크루에게 431번 에러가 발생한 것을 보았습니다. 이것은 전체 헤더의 길이가 너무 길거나, 특정 헤더의 길이가 너무 길 경우 발생하는 에러입니다. 제 나름대로 도와주었지만, 결국 해결은 못 해 주어서 찝찝한 마음이 있었습니다.

 

추후에 샐리가 node.js 버전이 높아서 그랬다고 알려주었고, 저는 이유가 궁금했습니다. 10.24 버전과 14.0.0 버전에는 어떤 차이가 있길래 14.0.0 버전에 대해 431번 에러가 발생하는지 말이죠. 구글링을 해 보니 해답을 찾을 수 있었습니다. 이 포스팅에 따르면, 최근 노드 버전에 대해서는 보안 상의 이유로 헤더의 크기를 8KB로 낮췄다고 합니다. 이전 노드 버전의 헤더 크기는 16KB인 것에 비해 반토막이나 났죠. 그런데 샐리의 어떤 코드가 이렇게 431번 에러를 야기했는지까지는 모르겠습니다.

 

 

인프런 김영한님 스프링 입문편 강의

H2부터 안들었었는데, 오늘 나머지 부분을 다 들으면서 완강했습니다~~.

 

우테코 과정에서 많이 연습했던 순수 JDBC, JDBC 템플릿 외에 JPA라는 신문물을 접해서 신기했습니다. 쿼리문을 최소화한 것도 편리했는데, JPA에 더 나아가서 스프링 데이터 JPA는 인터페이스만으로도 CRUD를 모두 지원해주니까 신세계였습니다.

 

이번 스프링 입문편 강의는 전반적인 스프링의 맛을 보는데 확실히 도움이 되었고, 아마 이 다음 기본편부터 쭉 커리큘럼을 따라갈 계획입니다.

 

 

정리

3일간 잠도 별로 못 자고 빡세게 미션을 해서 그런지 오늘은 좀 늘어졌습니다. 늘어진 김에 오늘 하루는 휴식을 잘 취해서 내일부터 다시 열심히 하려고 합니다.

댓글

추천 글