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

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

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

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

 

오늘은 중간곰과 잠실에서 만나서 지하철 노선도 관리 미션을 이어서 했습니다.

 

 

페어 프로그래밍

어제까지 노선 목록을 조회하는 테스트까지만 만들었고, 오늘은 1단계 및 2단계까지 모두 구현하고 pr을 날렸습니다. 오늘도 마찬가지로 새롭게 알게 된 점을 바탕으로 기록하겠습니다. 참고로, 2단계 요구 사항은 다음과 같습니다.

 

 

 

 

(1) removeIf()의 Exception 동작

removeIf()는 조건에 맞는 요소를 지우는 메소드입니다. 저는 처음에 removeIf()를 통해서 요소를 지울 수 없으면 익셉션을 발생시킬 것이라 예상했습니다. 이에 대해 중간곰은 아마 요소가 없으면 그냥 아무것도 지우지 않고 어떠한 익셉션도 발생시키지 않을 것이라 예상했죠. 그래서 직접 JDK 코드 및 주석을 확인했습니다.

 

 

 

 

removeIf() 내의 익셉션은 NullPointerException과 UnsupportedOperationException이 있습니다. 전자는 filter가 null일 경우이고, 후자는 해당 컬렉션이 지울 수 없는 경우일 때 발생합니다. 후자가 이해가 잘 가지 않을텐데, unmodifiable과 같은 읽기 타입의 컬렉션이나 Arrays.asList()로 만든 리스트 등에 대해 해당 메소드를 실행하면 UnsuppeortedOperationException이 발생합니다.

 

 

(2) Controller, Service, Repository 구현하는 스타일

이번 미션을 통해서 스프링 내의 Controller, Service, Repository를 어떤 식으로 구현해야 하는지 나름의 틀이 생겼습니다. 처음에는 막막하기만 하였지만, 중간곰의 코드를 보면서 저만의 방식을 만들어 나갔습니다. 물론, 이것이 정답은 아니겠지만 그래도 저와 제 페어가 납득했으면 현재로서는 정답이라고 생각합니다.

 

제가 생각하는 Controller(Api만 해당)는 비즈니즈 로직 없이 특정 URI를 만들어 주고, Service Layer를 동작시키고 ResponseEntity를 반환합니다.

 

 

@RestController
public class LineController {

    private final LineService lineService;

    public LineController(final LineService lineService) {
        this.lineService = lineService;
    }

    @PostMapping("/lines")
    public ResponseEntity<LineResponse> createLine(@RequestBody LineRequest lineRequest) {
        LineResponse lineResponse = lineService.createLine(lineRequest);
        return ResponseEntity.created(URI.create("/lines/" + lineResponse.getId())).body(lineResponse);
    }

    // ...
}

 

 

Controller과 Service를 스프링 빈에 등록하여 의존 관계를 맺어주고, createLine() 메소드처럼 인자로 받은 RequestDto를 서비스에 넘겨주고 ResponseDto를 반환받은 후 ResponseEntity의 바디로 설정하는 것이죠. 아직, 전반적인 스프링의 개념이 얕아서 설명 자체는 잘 못하지만 저만의 구조가 생겼습니다.

 

 

Service는 비즈니스 로직을 수행합니다. 다만, 여기서는 Repository와 의존 관계를 맺고 DB에 특정 데이터의 조작이 일어나도록 Dao한테 역할을 위임한다고 생각합니다.

 

 

@Service
public class LineService {

    private final LineDao lineDao;

    public LineService(final LineDao lineDao) {
        this.lineDao = lineDao;
    }

    public LineResponse createLine(final LineRequest lineRequest) {
        final String name = lineRequest.getName();
        final String color = lineRequest.getColor();

        validateDuplicatedLineName(name);
        final Line line = lineDao.save(new Line(name, color));
        return LineResponse.from(line);
    }

   // ...
}

 

 

createLine() 메소드는 인자로 RequestDto를 받고, ResponseDto를 반환합니다. 그리고 그 안에서 비즈니스 로직을 수행하는데, DB에 영향을 주는 코드는 Dao에게 맡깁니다.

 

 

Repository와 Dao는 분명 차이가 있지만, 저는 현재 같다고 생각하고 있습니다. 아직 경험이 매우 적으므로 차차 학습하면서 차이를 익히려고 합니다. 그래서 Respositoy는 Dao처럼 DB에 접근하는 역할을 한다고 생각합니다.

 

 

@Repository
public class LineDao {

    private final JdbcTemplate jdbcTemplate;
    private final RowMapper<Line> lineRowMapper = (resultSet, rowNum) -> new Line(
        resultSet.getLong("id"),
        resultSet.getString("name"),
        resultSet.getString("color")
    );

    public LineDao(final JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Line save(final Line line) {
        final String sql = "INSERT INTO line (name, color) VALUES (?, ?)";
        final GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
        final PreparedStatementCreator preparedStatementCreator = con -> {
            final PreparedStatement preparedStatement = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
            preparedStatement.setString(1, line.getName());
            preparedStatement.setString(2, line.getColor());
            return preparedStatement;
        };
        jdbcTemplate.update(preparedStatementCreator, keyHolder);
        final long id = keyHolder.getKey().longValue();
        return new Line(id, line.getName(), line.getColor());
    }

    public void deleteById(final long id) {
        final String sql = "DELETE FROM line WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }

    // ...
}

 

 

해당 코드는 JdbcTemplate를 사용하였습니다. 현재 Line 테이블의 id는 auto_increment 속성이 들어가 있으므로 save한 후 해당 id 값을 가져오려면 위처럼 KeyHolder 및 PreparedStatementCreator을 사용해야 합니다. 이처럼 Dao는 DB에 직접적으로 접근하여 데이터를 수정하거나 가져옵니다.

 

 

(3) 인자가 많은 ResponseDto 생성

이전 포스팅과 비슷한 이야기인데, 인자가 많은 객체를 생성자를 통해 생성하려면 코드가 필연적으로 길어질 수 밖에 없습니다. 비즈니스 로직 중심의 Service에서 이 ResponseDto를 반환하기 위한 코드는 짧아야 한다고 생각합니다. 이를 위해서는 ResponseDto에 정적 팩토리 메소드를 만들어 주거나 toEntity()와 같은 메소드를 만들면 됩니다. 정적 팩토리 메소드는 이전 포스팅에서 소개했고, toEntity()를 언제 사용하는지 봅시다. (메소드명은 임의의로 사용해도 무방합니다.)

 

 

    public SectionResponse createSection(final long lineId, final SectionRequest sectionRequest) {
        final Long upStationId = sectionRequest.getUpStationId();
        final Long downStationId = sectionRequest.getDownStationId();
        final int distance = sectionRequest.getDistance();

        final Section section = sectionDao.save(new Section(lineId, sectionRequest.getUpStationId(),
            sectionRequest.getDownStationId(), sectionRequest.getDistance()));
        return SectionResponse.from(section);
    }

 

 

sectionDao.save()의 인자로는 Section 객체가 필요합니다. 다만, createSection의 인자로는 Section이 아닌 SectionRequest 객체밖에 없으므로 필연적으로 Section 객체로 바꿔주어야 합니다. 이때, Section이 현재 필요한 인자가 4개이므로 코드가 지저분해 보입니다. 또한, getter도 남발하고 있죠.

 

 

public class SectionRequest {

    private Long upStationId;
    private Long downStationId;
    private int distance;

    public SectionRequest() {
    }

    public SectionRequest(Long upStationId, Long downStationId, int distance) {
        this.upStationId = upStationId;
        this.downStationId = downStationId;
        this.distance = distance;
    }

    public Section toEntity(final Long lineId) {
        return new Section(lineId, upStationId, downStationId, distance);
    }

    public Long getUpStationId() {
        return upStationId;
    }

    public Long getDownStationId() {
        return downStationId;
    }

    public int getDistance() {
        return distance;
    }
}

 

 

그런데, 다음과 같이 SectionRequest 안에서 toEntity() 메소드를 만들게 되면, Service에서 할 일을 SectionRequest한테 위임함으로써 코드가 깔끔해집니다. getter도 쓸 필요가 없게 되죠.

 

다만, 해당 SectonRequest도 아니고 다른 계층의 Section을 반환하는 것이 정말 바람직한 지에 대해서는 좀 더 고민해볼 문제라고 생각합니다.

 

 

(4) Exception에 상태 코드를 담는 행위

이번 미션을 진행하면서 2가지 커스텀 익셉션을 정의하였습니다. 문제는 두 익셉션에 상응하는 상태 코드가 다르다는 것이죠. 이를 해결하려면 ControllerAdvice 단에서 메소드를 2개로 분리하여 404번 에러 코드와 400번 에러 코드를 반환하는 익셉션을 다르게 처리해야 합니다.

 

이에 대해 중간곰은 커스텀 익셉션을 아우르는 상위 클래스를 만들고, 그것의 필드로 상태 코드를 넣자고 제안했습니다. 꽤나 흥미로운 주장이었고, 행동으로 옮겼습니다.

 

 

public class ClientRuntimeException extends RuntimeException {

    private final HttpStatus httpStatus;

    public ClientRuntimeException(final String message, final HttpStatus httpStatus) {
        super(message);
        this.httpStatus = httpStatus;
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }
}

 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ClientRuntimeException.class)
    public ResponseEntity<String> handleClientException(final ClientRuntimeException e) {
        return ResponseEntity.status(e.getHttpStatus()).body(e.getMessage());
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<String> handleServerException(final RuntimeException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
    }
}

 

 

다음과 같이 두 커스텀 익셉션을 상속하는 부모 클래스를 만들고, 그 클래스에 대해 예외처리를 하였습니다. 한결 코드가 깔끔해지는 결과를 얻었지만, 익셉션에 이러한 상태 코드를 넣는 것이 적절한 지는 생각해 볼 필요가 있겠습니다.

 

 

부족한 점

그전까지 스프링으로 테스트를 하지 않아서 스프링 관련 테스트 개념이 가장 부족합니다. 샘플 코드를 보면서 어떻게든 E2E 테스트를 구현해 보았지만, RestAssured이 하는 역할이나 다음 코드가 어떤 동작을 하는지 이해할 수 없었습니다.

 

 

 

 

@DirtiesContext나 @SpringBootTest와 같은 키워드 및 전반적인 테스트 기법의 차이를 익혀야 할 때가 온 것 같습니다. 현재 단위 테스트, 전 구간 테스트(E2E 테스트), 통합 테스트, 인수 테스트의 차이를 모르는 상태입니다.

 

 

그 외에 현재 테스트를 각각 수행하면 통과하지만, 한꺼번에 돌리면 문제가 생깁니다. 이것은 Service 관련 테스트 문제인데, 아마도 Mock을 사용하지 않아서 DB가 꼬인 것으로 추정됩니다. 해당 오류들은 리뷰어님의 피드백을 통해 고쳐나갈 예정입니다.

 

 

정리

지하철 노선도 관리 미션을 테스트를 제외하면 만족스럽게 구현했다고 생각합니다. 다만, 여기서 페어를 끝내지 않고 3단계까지 내일 최대한 진행해보려고 합니다.

댓글

추천 글