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

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

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

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

 

오늘은 오전에 브라운의 스프링 테스트 수업을 듣고, 오후에는 중간곰과 페어 프로그래밍을 이어서 했습니다.

 

 

브라운의 스프링 테스트 수업

수업은 단위 테스트, 통합 테스트, E2E 테스트 등에 대한 소개와 다양한 테스트 관련 어노테이션을 알려주는 수업이었습니다. 그 외에는 크루들의 Q&A가 대부분이었는데, 몇 가지 새롭게 알게 된 점이 있었습니다. 그리고 그것은 안그래도 어제부터 고민해왔던 주제였습니다. 바로, '중복된 이름에 관한 예외 처리를 어디서 할 것인가?'입니다.

 

저는 이것을 커스텀 익셉션을 만든 다음 서비스에서 검증을 하였습니다. 물론, DB 테이블 속성으로 UNIQUE를 주면 되는데 왜 굳이 커스텀 익셉션을 만들고 그것을 서비스에서 검증하냐고 반론을 제기할 수 있습니다. 하지만, UNIQUE 속성으로 인해 발생하는 DuplicatedKeyException을 ControllerAdvice에 등록하여 400번대 에러로 반환한다면 치명적인 문제가 생길 수 있습니다.

 

좀 더 이해하기 쉬운 예제로 보겠습니다. 체스 게임에서 빈 공간은 움직이면 안 되므로 해당 익셉션을 UnsupportedOperationException로 정의했다고 가정해 봅시다. 정상적인 경우에는 당연히 프론트 쪽에서 빈 공간을 움직이면 적절한 에러 메시지가 띄워질 것입니다. 하지만, 개발자가 실수로 unmodifiable 타입의 컬렉션에 add나 remove를 하면 무슨 일이 생길까요? 맞습니다. 동일한 UnsupportedOperationException이 발생하므로 이것도 빈 공간을 움직였다는 에러로 잡히게 됩니다. 따라서, 비즈니스 에러와 서버 에러는 명확히 구분지어야하고 비즈니스 에러는 커스텀 익셉션으로 관리하는 것이 바람직합니다.

 

그래서 저는 아래와 같이 지하철 정보를 저장할 때, 서비스 단에서 검증을 해 주었습니다.

 

 

    public StationResponse createStation(final StationRequest stationRequest) {
        final String name = stationRequest.getName();
        validateDuplicatedStationName(name);
        final Station station = stationDao.save(new Station(name));
        return new StationResponse(station.getId(), station.getName());
    }

    private void validateDuplicatedStationName(final String name) {
        stationDao.findByName(name)
            .ifPresent(station -> {
                throw new DuplicatedNameException("중복된 이름의 지하철역입니다.");
            });
    }

 

 

저는 어제까지만 해도 해당 방식이 옳다고 생각하고 있었습니다. 하지만, 여러 크루의 의견을 들어보니 동시성 이슈가 생길 수 있음을 알게 되었습니다. 수많은 사용자가 동시에 지하철 정보를 저장할 때, 제대로 중복 검증이 이루어지지않고 데이터가 저장될 수 있다는 것이죠. 그렇다면, 결국 DB에 UNIQUE 속성을 걸어야 하므로 문제가 원점으로 돌아가게 됩니다.

 

이 문제에 대해 제 대학 동기에게 물어보니, DuplicatedKeyException을 핸들링하되, 제가 만든 커스텀 익셉션으로 throw를 하여 Controller단에서 DuplicatedKeyException이 아닌 커스텀 익셉션으로 처리하라고 조언해 주었습니다. 쉽게 이야기하면, DAO에서 try ~ catch를 통해 중복 이름 검증을 하라는 것이죠.

 

 

    public Station save(final Station station) {
        try {
            final String sql = "INSERT INTO station (name) VALUES (?)";
            final GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
            final PreparedStatementCreator preparedStatementCreator = con -> {
                final PreparedStatement preparedStatement = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
                preparedStatement.setString(1, station.getName());
                return preparedStatement;
            };
            jdbcTemplate.update(preparedStatementCreator, keyHolder);
            final long id = keyHolder.getKey().longValue();
            return findById(id).get();
        } catch (DuplicateKeyException e) {
            throw new DuplicatedNameException("중복된 이름의 지하철역입니다.");
        }
    }

 

 

위 방식은 동시성 이슈를 해결하면서도 제가 만든 커스텀 익셉션으로 비즈니스 에러를 해결할 수 있다는 장점이 있습니다. 물론, 단점도 존재합니다. 서비스에서 검증하는 것과는 달리, DB에서 쿼리를 날리고 업데이트하다가 문제가 생기는 그 시점에서 에러를 뱉으므로 성능이 좀 더 떨어진다는 것이죠. 이 경우가 마음에 들지 않는다면, 서비스와 DAO에 대해 모두 검증 로직을 추가하면 될 것 같습니다. 저는 우선 DAO에만 검증하도록 두었습니다.

 

이렇게 update와 delete 쿼리도 해당하는 ID가 없을 때 try ~ catch로 에러 핸들링을 하려고 하였으나 문제가 생겼습니다. 바로, 두 가지 쿼리는 해당하는 ID가 없으면 그냥 동작을 안할뿐, 특정한 에러를 반환하지는 않는다는 것이죠. 대신, 아래 코드처럼 update()의 반환값이 업데이트한 데이터의 개수라는 것을 이용하여 에러 핸들링을 할 수 있습니다.

 

 

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

        if (deletedCnt < 1) {
            throw new DataNotFoundException("해당 Id의 지하철역이 없습니다.");
        }
    }

 

 

이러한 방식으로 updateById()와 deleteById()에 검증 로직을 주었습니다.

 

 

마지막으로, 익셉션의 필드로 상태 코드를 주는 행위입니다. 이것은 과거 중간곰이 제안한 방식인데, 처음에는 단순히 익셉션이 상태 코드를 갖는다는 것이 낯설고 단순히 뭔가 이상하다는 이유로 거부감이 느껴졌습니다. 하지만, 이를 제리에게 말해보니, '너가 생각하기에 명확한 단점이 없으면, 한 번 그 방식을 적용해 봐라'라고 답변해 주었습니다. 저는 이성적이지 않은 지극히 감성적인 이유만으로 훌륭한 방식을 지나쳐 버릴 뻔했던 것이죠.

 

현업자에게 익셉션의 필드로 상태 코드를 주는 방식이 올바른지 질문하니까 실제로도 자주 쓰이며, 비즈니스 에러는 아래와 같은 계층 구조를 통해 관리한다고 알려주었습니다.

 

 

 

 

또한 BusinessException은 필드로 상태 코드를 갖고 있기에, ControllerAdvice에서 효과적으로 코드를 작성할 수 있습니다.

 

 

    @ExceptionHandler(BusinessException.class)
    protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
        log.error("handleEntityNotFoundException", e);
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse response = ErrorResponse.of(errorCode);
        return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
    }

 

 

비즈니스 에러마다 400, 401, 404 등등 상태 코드가 다를 수 있는데, 이를 추상화함으로써 편리하게 이용할 수 있는 것이죠.

 

앞으로는 남을 설득할 만한 충분한 근거가 없다면, 그것이 익숙해 보이지 않더라도 받아들이는 자세를 길러야겠습니다.

 

 

페어 프로그래밍

오늘은 중간곰과 테스트 코드를 리팩토링해 보았습니다. 처음에는 위에서 말한 에러 핸들링에 대해서 길게 이야기한 후, DAO에서 검증하기로 합의하였습니다. 그리고 TDD가 아닌 것이 아쉽지만, 뒤늦게라도 DAO, Service에 관한 Test를 만들기로 하였습니다.

 

가장 먼저, DaoTest를 만들었습니다.

 

 

@JdbcTest
class StationDaoTest {

    @Autowired
    private JdbcTemplate jdbcTemplate;
    private StationDao stationDao;

    @BeforeEach
    void setUp() {
        stationDao = new StationDao(jdbcTemplate);
    }

    @DisplayName("지하철역을 생성한다.")
    @Test
    void save() {
        final Station station = new Station("잠실역");

        final Station createdStation = stationDao.save(station);

        assertThat(createdStation.getId()).isEqualTo(createdStation.getId());
        assertThat(createdStation.getName()).isEqualTo(createdStation.getName());
    }

    @DisplayName("기존에 존재하는 지하철역 이름으로 지하철역을 생성한다.")
    @Test
    void saveStationWithDuplicateName() {
        final Station station = new Station("잠실역");
        stationDao.save(station);

        assertThatThrownBy(() -> stationDao.save(station))
            .hasMessage("중복된 이름의 지하철역입니다.")
            .isInstanceOf(DuplicatedNameException.class);
    }

    @DisplayName("지하철역을 제거한다.")
    @Test
    void delete() {
        final Station station = new Station("잠실역");
        final Station createdStation = stationDao.save(station);

        assertThatCode(() -> stationDao.deleteById(createdStation.getId()))
            .doesNotThrowAnyException();
    }

    @DisplayName("존재하지 않는 지하철역 이름으로 지하철역을 제거한다.")
    @Test
    void deleteWithAbsentName() {
        assertThatThrownBy(() -> stationDao.deleteById(1L))
            .hasMessage("해당 Id의 지하철역이 없습니다.")
            .isInstanceOf(DataNotFoundException.class);
    }

    @DisplayName("전체 지하철역을 조회한다.")
    @Test
    void findAll() {
        final List<String> stationNames = Arrays.asList("잠실역", "강남역", "건대입구역");
        stationNames.stream()
            .map(Station::new)
            .forEach(stationDao::save);

        assertThat(stationDao.findAll()).extracting("name").isEqualTo(stationNames);
    }

    @DisplayName("특정 이름의 지하철역을 조회한다.")
    @Test
    void findByName() {
        final String name = "잠실역";
        final Station createdStation = stationDao.save(new Station(name));

        final Station station = stationDao.findByName(name).get();

        assertThat(station.getId()).isEqualTo(createdStation.getId());
        assertThat(station.getName()).isEqualTo(createdStation.getName());
    }

    @DisplayName("특정 id의 지하철역을 조회한다.")
    @Test
    void findById() {
        final String name = "잠실역";
        final Station createdStation = stationDao.save(new Station(name));

        final Station station = stationDao.findById(createdStation.getId()).get();

        assertThat(station.getId()).isEqualTo(createdStation.getId());
        assertThat(station.getName()).isEqualTo(createdStation.getName());
    }
}

 

 

사실, 테스트 관련 어노테이션의 개념이 부족해서 해당 코드가 나오기까지 꽤 오랜 시간이 걸렸습니다. 그 고민을 적으면서 어떻게 해결하였는지 작성해 보겠습니다.

 

 

(1) DB를 어떻게 초기화할 것인가?

가장 처음 떠올릴 수 있는 방법은 테스트 실행 전마다 매번 테이블을 날리고 다시 생성하는 것입니다. 이것은 직접 @BeforeEach에서 쿼리문을 작성하여 테이블을 초기화하거나, @Sql 어노테이션을 통해서 테이블을 초기화할 수 있습니다. 전자는 너무 단순하니 생략하고, 후자인 @sql 어노테이션 사용법을 보겠습니다.

 

먼저, test 디렉토리 resources를 만들고 스키마를 정의하는 sql 파일을 만듭니다. 그리고 테스트 클래스 또는 테스크 메소드 위에 "@Sql("classpath:tableInit.sql")"와 같은 어노테이션을 작성하면 됩니다. 테스트 클래스 위에 해당 어노테이션을 작성하면, 모든 테스트 메소드가 실행되기 전에 해당 쿼리문을 실행합니다. 테스트 메소드는 위에 해당 어노 테이션을 작성하면, 해당 테스트가 실행되기 전에 해당 쿼리문을 실행합니다.

 

 

테이블을 날리지 않고도 DB를 초기화할 수 있습니다. 바로, @Transactional 어노테이션을 사용하는 것이죠. "테스트" 클래스 위에 이 어노테이션을 지정하면, 모든 테스트 메소드가 실행된 이후마다 롤백을 하여 DB를 초기화합니다. 다만, 이렇게 DB를 초기화하면 auto_increment가 계속해서 올라간다는 문제가 있습니다. 저는 auto_increment를 사용하는 ID가 그렇게 중요하지는 않아서 @Transactional 어노테이션을 통해 DB를 초기화하였습니다.

 

그런데, 잘 보면 저는 테스트 클래스 위에 @JdbcTest만 붙였지, @Transactional은 붙이지 않았습니다. 이것이 가능한 이유는 @JdbcTest을 들어가 보면 알 수 있습니다.

 

 

 

 

바로, JdbcTest 안에 @Transactional이 있기 때문이죠.

 

 

(2) JdbcTemplate만 왜 @Autowired가 될까?

저는 그 외에 @JdbcTest를 붙인 클래스 내에서 JdbcTemplate만 @Autowired가 가능한 이유가 궁금했는데, 이것은 @JdbcTest 안에 @AutoConfigureJdbc가 있기 때문이었습니다. 이 @AutoConfigure는 쉽게 말해서 개발자가 @Autowired를 사용하도록 허용하는 역할을 합니다. 물론, 알아보니까 Jdbc 외에 Cache와 TestDatabase도 @Autowired가 가능한 것을 알 수 있습니다.

 

 

(3) 테스트를 위한 H2 DB를 만들어야 할까?

현재 저는 테스트에서 StationDao를 사용하고 있습니다. 그리고 이 Dao는 프로덕션에서 정의되어 있는 H2 DB를 사용한다고 생각할 수 있습니다. 그래서 이 H2 DB를 이용하여 배포한다면, 테스트 코드 실행 이후 데이터가 날아가지 않을까라는 의문을 가질 수 있습니다. 하지만, 적어도 @JdbcTest를 사용하는 테스트라면 그런 걱정을 하지 않아도 됩니다. 왜냐하면 @JdbcTest는 인메모리 DB를 제공하기 때문이죠.

 

 

 

 

위 사진은 StationDao의 생성자 부분에서 디버그를 찍은 모습입니다. dataSource를 자세히 보시면, url이 'mem:' 뒤에 뭔지 모를 값들이 쭉 나와있는 것을 알 수 있습니다. 하지만, 실제 제 프로덕션의 application.yml을 보면, H2 DB의 url은 'jdbc:h2:mem:testdb'인 것을 확인할 수 있습니다.

 

 

 

 

(4) Service Layer는 어떻게 테스트할까?

처음에는 ServiceTest의 필드로 StationDao를 갖고, Service가 Dao에 의존하는 모습을 보였습니다. 하지만, 중간곰과 저는 Service의 기능 자체에 대해 단위 테스트를 하고 싶었습니다. 사실, 저는 단위 테스트와 통합 테스트를 아직 구분을 짓지는 못하겠어서 단위 테스트가 맞는지도 의문이 들지만, 구글링을 해 보니 Mock을 통해 서비스를 테스트할 수 있다는 정보를 얻었습니다.

 

다만, 뭔가 제가 원하는 버전에 맞는 Mock을 찾기란 쉽지 않았고, 케빈의 코드를 참고하여 테스트 코드를 작성해 보았습니다.

 

 

    @DisplayName("역 추가 기능")
    @Test
    void createStation() {
        final String name = "잠실역";
        final Station station = new Station(name);
        given(stationDao.save(station)).willReturn(new Station(1L, name));

        final StationResponse createdResponse = stationService.createStation(new StationRequest(name));

        assertThat(createdResponse.getId()).isEqualTo(1L);
        assertThat(createdResponse.getName()).isEqualTo(name);
        verify(stationDao, times(1)).save(station);
    }

 

 

자세한 원리는 모르겠으나, given(A).willReturn(B) 구조는 A의 기능으로 인한 반환값은 B라는 것으로 추측하고 있습니다.

 

 

    public StationResponse createStation(final StationRequest stationRequest) {
        final String name = stationRequest.getName();
        final Station station = stationDao.save(new Station(name));
        return new StationResponse(station.getId(), station.getName());
    }

 

 

그래서 위 메소드에서 StationDao.save() 메소드의 반환 결과는 ID가 1L이고 이름이 잠실역인 Station 객체가 됩니다. 그리고 마지막으로 verify() 코드는 해당 save() 메소드가 몇 번 실행되었는지 확인하는 것 같습니다.

 

이렇게 단순히 지하철역을 저장하는 Mock 코드는 어느 정도 이해가 되고 납득도 갔습니다.

 

 

하지만, 중복된 이름의 역 등록 관련 테스트를 할 때 문제가 생겼습니다. 왜냐하면, Mock은 빈 껍데기에 불과하므로 어떠한 데이터를 저장할 수가 없기 때문이죠. 그래서 아래와 같은 코드가 우리에게는 최선이었습니다.

 

 

    @DisplayName("중복된 이름의 지하철역 추가할 때 실패하는지 확인")
    @Test
    void checkDuplicatedStationName() {
        final String name = "잠실역";
        final Station station = new Station(name);
        given(stationDao.save(station)).willThrow(new DuplicatedNameException("중복된 이름의 지하철역입니다."));

        assertThatThrownBy(() -> stationService.createStation(new StationRequest(name)))
            .isInstanceOf(DuplicatedNameException.class)
            .hasMessage("중복된 이름의 지하철역입니다.");

        verify(stationDao, times(1)).save(station);
    }

 

 

익셉션 발생 자체에 초점을 맞춰서 어떻게 보면 의미 없어 보이는 테스트가 된 것이죠. 그냥 단순히 답정너같은 느낌의 테스트고, 이렇게 하는 것이 Mocking인지도 의문이 들었습니다. 그래서 이 부분은 함께 공부해서 좀 더 파악해 보고 내일 다시 작성해 보기로 결정하였습니다.

 

 

정리

에러를 핸들링하는 방법과 DAO 테스트 하는 방법을 익혀서 나름 의미있는 하루라고 생각합니다. 다만, Mock에 많은 시간을 들였음에도 어떻게 해야하는 지에 대한 가이드라인이 없어서 조금 아쉽긴 합니다. 내일 주말이지만 중간곰과 함께 온라인 페어 프로그래밍을 하면서 오늘의 어려웠던 문제를 해결하면 좋겠습니다.

댓글

추천 글