우테코 - 장바구니 미션 회고

장바구니 미션을 진행하면서 있었던 과정을 회고해보자

그동안 너무 바빠서 미션 회고를 정말 오랜만에 쓴다.. 역시 한번 일을 미루면 계속 미루게 되는 거 같아 될 수 있으면 빨리빨리 끝내는 게 제일 좋은 것 같다. 늦은 만큼 빠르게 본문으로 가보자!


Save 후 어떤 걸 반환값으로 하는게 좋을까?

    public Long save(final ProductEntity productEntity) {
        final SqlParameterSource parameterSource = new BeanPropertySqlParameterSource(productEntity);
        return simpleJdbcInsert.executeAndReturnKey(parameterSource).longValue();
    }

데이터베이스에 단순히 저장만 하고 반환값으로 아무것도 안 줄 수도 있을 것이다. 실제로 처음에 이번 미션에서 딱히 저장하고 나서 반환한 값을 쓸 일이 없을 거 같아 void로 반환하였다. 하지만 테스트하는 과정에서 연관된 객체 간의 관계를 설정해 줄 때 반환값이 없는 경우 직접 찾아 넣어줘야 되거나 각 객체의 id를 1로 초기화시키는 쿼리를 넣어서 id를 예측해서 넣어줘야 되는 번거로움이 생겼다.


  @Test
  void saveCart(){
    //반환값 없는 경우
    productDao.save(new ProductEntity("치킨", "testUrl", 20000));
    memberDao.save(new MemberEntity("testEmail", "testPassword"));

    //dao에서 저장한 객체를 찾거나 예측해서 id를 연관객체에 설정해줘야 됨

    h2CartDao.save(new CartEntity(?, ?));
  }

위처럼 하는 경우 추가로 조회하는 불필요한 일이나 매 테스트마다 id를 초기화(여러 테스트가 있을 경우 rollback 해줘도 데이터는 초기화되지만 id 시작 값은 초기화되지 않음) 해서 넣어줘야 되기 때문에 id에 종속적이라 판단하였고 save 한 후 반환 값을 주는 게 좋을 것이라 생각하였다. 그리고 프로덕션 코드에서도 비즈니스 로직이 더 복잡해지면 이 저장한 값을 바탕으로 후에 객체 간의 관계 설정과 데이터 검색을 간편하게 수행할 수 있을 것 같다!

  @Test
  void saveCart(){
    long productId = productDao.save(new ProductEntity("치킨", "testUrl", 20000));
    long memberId = memberDao.save(new MemberEntity("testEmail", "testPassword"));

    h2CartDao.save(new CartEntity(productId, memberId));
  }

그렇다면 반환 값으로 어떤 걸 주는 게 좋을까? id? 저장한 객체? 일반적으로는 객체의 고유 식별자인 id만 제공해서 관계 설정 및 업데이트를 위해 사용할 수 있을 것 같고 저장한 객체를 이용해서 추가적인 작업을 수행하는 경우에는 객체를 반환할 수 있을 것 같다.

도메인부터 설계? 데이터베이스부터 설계?

예전부터 프로젝트를 할 때 DB부터 설계를 하고 들어가서 당연히 DB부터 설계를 해야 되는 줄 알았다. 하지만, 이전 미션에서 데이터베이스 중심적으로 설계를 함으로 써 단점들을 체감을 했었기 때문에 최근에 이것에 대해 매우 고민이 많았다. DB에 굳이 저장할 필요 없는 필드들이 생성됨으로 써 코드를 짤 때 객체지향적이지 못한 코드들을 생성하게 되었다.

그래서 리뷰어님께 의견을 한번 구해봤다!

어떤 순서로 설계하더라도 결국 구현하다보면 요구사항이 변동/추가되는 일도 생기고 설계할 때는 미처 파악하지 못했던 엣지케이스들도 나오기 때문에 결국 설계를 조금 수정하는 일이 생기기 마련이더라구요 😂

프로젝트 규모에 따라, 요구사항의 복잡도에 따라 그때그때 달라지긴 하지만 보통은 화면을 보며 필요한 API 리스트업을 하고 도메인 설계에 들어가는 편입니다 설계에는 정답이 없기 때문에 무민이 가장 편하고 자신있는 방법으로 진행해도 무방하다고 생각해요 :)

어떻게 설계하든 정답은 없겠지만 나만의 기준이 필요할 것 같다. 우선 DB부터 설계하는 방식은 아무래도 데이터베이스 구조와 관계를 먼저 설계한 다음 거기에 맞춰 애플리케이션을 설계하는 것이므로 좀 더 성능 최적화에 맞춰져있지 않나 생각이 든다. 그래서 대규모 데이터베이스를 사용하는 시스템이나 성능이 중시되는 애플리케이션에 적합한 방식인 것 같다.

아직 현업을 경험해 보지 않아서 대규모니 성능이니 잘 모르겠지만 지금 우선해서 제일 중요하다고 생각하는 건 비즈니스 도메인과 요구사항이라고 생각하기 때문에 여기에 집중할 수 있는 도메인부터 설계하는게 현재로서는 최선이라고 생각한다. 그렇게 설계하고 나서 후에 성능이 개선할 필요가 있을 때 최적화를 하면 좋을 것 같다.

올바른 오류 메시지

오류 메시지는 문제가 발생할 때 개발자가 사용자와 상호 작용하는 주요 방법이라 할 수 있다. 올바르게 작성된 오류 메시지는 사용자에게 어떤 문제점이 있는지, 어떻게 해결해야 하는지 중요한 정보를 제공하지만, 잘못된 오류 메시지는 오히려 사용자에게 혼란을 줄 수 있다. 잘못된 오류 메시지는 다음과 같다.

  • 실행 불가능
  • 애매모호함
  • 어떻게 해결해야 하는지 설명 없음

어떻게 오류 메시지를 작성하면 좋을까?

올바른 오류 메시지 작성을 위한 몇가지 방법들을 알아보자.

조용히 실패하지 마라

실패는 할 수 있다. 하지만 실패를 보고 하지 않는 것은 치명적이다. 왜 실패했는지 알리지 않으면 사용자들에게 올바른 정보를 전달할 수 없다. 사람들이 소프트웨어를 오용하는 걸 완전히 제거할 수는 없지만 최소화하려고 노력해야한다.

오류의 원인 파악

무엇이 잘못되었는지 사용자에게 구체적이고 정확하게 알려줘야 된다. 애매모호한 오류 메시지는 사용자에게 혼란을 줄 수 있다.

❗ Not recommended

잘못된 디렉토리입니다.

👍 Recommended

지정된 디렉토리가 존재하지만 쓸 수 없습니다. 이 디렉토리에 파일을 추가하려면 디렉토리가 쓰기 가능해야 합니다. [이 디렉토리를 쓰기 가능하게 만드는 방법에 대한 설명.]

사용자의 유효하지 않은 입력

사용자가 잘못된 입력을 할 경우 오류메시지는 잘못된 입력을 알려줘야 된다.

❗ Not recommended

우편번호가 잘못되었습니다.

👍 Recommended

미국 우편 번호는 5자리 또는 9자리로 구성되어야 합니다. 지정된 우편 번호(4872953)에는 7자리 숫자가 포함되어 있습니다.

요구 사항 및 제약 조건 지정

사용자가 시스템의 한계를 알고 있지 않기 때문에 요구 사항과 제약 조건을 이해하도록 구체적으로 알려줘야 된다.

❗ Not recommended

첨부파일의 크기가 너무 큽니다.

👍 Recommended

첨부파일의 총 크기(14MB)가 허용 한도(10MB)를 초과합니다. [가능한 솔루션에 대한 세부 정보.]

문제를 해결하는 방법 설명

실행 할 수 있는 오류 메시지를 만들어야 한다. 즉, 문제의 원인을 설명한 후 문제를 해결하는 방법을 설명해야한다.

❗ Not recommended

장치의 클라이언트 앱은 더 이상 지원되지 않습니다.

👍 Recommended

장치의 클라이언트 앱은 더 이상 지원되지 않습니다. 클라이언트 앱을 업데이트하려면 앱 업데이트 버튼을 클릭합니다.

간결하게 작성하라

불필요한 텍스트는 잘라내고 간결하고도 중요한 것을 작성해야한다.

❗ Not recommended

리소스를 찾을 수 없으며 구분할 수 없습니다. 선택한 항목이 클러스터에 없습니다. [클러스터에서 유효한 리소스를 찾는 방법에 대한 설명]

👍 Recommended <이름> 리소스가 <이름> 클러스터에 없습니다. [클러스터에서 유효한 리소스를 찾는 방법에 대한 설명]

이중 부정을 피하라

이중 부정은 말의 뜻을 어렵게 만들기 때문에 피하는게 좋다.

❗ Not recommended

경로 이름 에 대한 범용 읽기 권한은 운영 체제가 액세스를 금지하는 것을 방지합니다.

👍 Recommended

경로 이름 에 대한 범용 읽기 권한이 있으면 누구나 이 파일을 읽을 수 있습니다. 모든 사람에게 액세스 권한을 부여하는 것은 보안 결함입니다. 독자를 제한하는 방법에 대한 자세한 내용은 하이퍼링크를 참조하십시오.

대상자를 고려하라

해당 오류 메시지를 읽는 사람은 개발자가 아닐 확률이 더 높다. 나에게 친숙한 용어가 대상 청중들은 익숙하지 않을 수 있기 때문에 대상자를 고려하자

❗ Not recommended

서버 CPU 용량의 92%에서 실행 중이기 때문에 서버에서 클라이언트의 요청을 삭제했습니다. 5분 후에 다시 시도하십시오.

👍 Recommended

지금 너무 많은 사람들이 쇼핑하고 있어서 Google 시스템에서 구매를 완료할 수 없습니다. 5분 후에 다시 시도하십시오.

일관된 용어를 사용해라

하나의 오류메시지에서 무언가의 이름을 지정했으면 다른 모든 오류 메시지에도 일관되게 용어를 사용하자

❗ Not recommended

127.0.0.1:56에서 클러스터에 연결할 수 없습니다. minikube가 실행 중인지 확인합니다.

👍 Recommended

127.0.0.1:56에서 minikube에 연결할 수 없습니다. minikube가 실행 중인지 확인합니다.

올바른 어조를 사용해라

오류 메시지의 어조는 사용자가 메시지를 해석하는 방식에 상당한 영향을 미치기 때문에 다음과 같은 올바른 어조를 사용하자.

1. 긍정적

사용자가 무엇을 잘못했는지 알려주는 대신, 바로잡는 방법을 알려주자

❗ Not recommended

이름을 입력하지 않았습니다

👍 Recommended

이름을 입력하세요.

2. 오버해서 사과하지말자

“미안”, “제발”이라는 말은 피하고 문제와 솔루션을 명확하게 설명하는데 집중하자.

❗ Not recommended

죄송합니다. 서버 오류가 발생하여 일시적으로 스프레드시트를 로드할 수 없습니다. 불편을 끼쳐드려 죄송합니다. 잠시 기다린 후 다시 시도하십시오.

👍 Recommended

Google 문서도구에서 일시적으로 스프레드시트를 열 수 없습니다. 그동안 문서 목록에서 스프레드시트를 마우스 오른쪽 버튼으로 클릭하여 다운로드해 보세요.

3. 유머를 피하자

오류 메시지에서 유머는 사용자를 화나게하거나 잘못 해석할 수 있도록 하여 원래 오류 메시지의 목적을 손상시킬 수 있다.

❗ Not recommended

서버가 실행 중인가요? 가서 잡아야 겠네요 :D. (Is the server running? Better go catch it :D.)

👍 Recommended

일시적으로 서버를 사용할 수 없습니다. 몇 분 후에 다시 시도하십시오.

4. 사용자를 비난하지 마라

비난보다는 무엇이 잘못되었는지에 대해 초점을 맞추자

❗ Not recommended

오프라인 상태인 프린터를 지정했습니다.

👍 Recommended

지정된 프린터가 오프라인입니다.

usingRecursiveComparison()

테스트코드를 작성할 때 해당 객체들의 동등성(내부 값)을 비교하는 경우가 많다. 그럴 때 마다 equals를 재정의 해줄 수도 있겠지만 usingRecursiveComparsion()을 사용하면 좀 더 편리하게 비교할 수 있다. 그리고 특정 값을 제외하고 비교할 수 있는 기능도 지원해준다.

기본적인 사용법은 아래와 같고 직접 Comparator를 정의하고 싶은 경우 usingComparator()를 사용하면 된다.

    @Test
    void testEqual() {
        assertThat(actualObject)
            .usingRecursiveComparison()
            // .ignoringFields("id")
            // .ignoringActualNullFields()
            .isEqualTo(expectedObject);
    }
  • 특정 필드값 제외 비교 - ignoringFields()
  • null값 제외하고 비교 - ignoringActualNullFields()

Argumentresolver 테스트

이번에 장바구니 미션에서 인증을 하기 위해 사용한 ArgumentResolver를 테스트 해보기위해선 resolveArgument의 파라미터인 MethodParameter, ModelAndViewContainer, NativeWebRequest, WebDataBinderFactory들을 모킹하거나 적절하게 넣어줘야 되는데 어찌 되었든 매우 테스트하기 어려웠다.

그래서 다음과 같이 통합 테스트로 인증을 넣은 경우와 안 넣은 경우를 추가로 테스트했었다.

@Test
  void findCartItems() throws Exception {
    given(productService.findById(any())).willReturn(List.of(
            new CartItemResponse(1, new ProductEntity(1L, "치킨", "chicken", 10000)),
            new CartItemResponse(2, new ProductEntity(2L, "피자", "pizza", 20000))));

    mockMvc.perform(get("/carts")
            .header(AUTHORIZATION, BASIC_TYPE + Base64Coder.encodeString(EMAIL + DELIMITER + PASSWORD)))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.size()").value(2));
  }

  @Test
  void noAuthentication() throws Exception {
    given(memberDao.findByMemberEntity(any())).willReturn(
        Optional.empty());

    mockMvc.perform(get("/carts")
            .header(AUTHORIZATION, BASIC_TYPE + Base64Coder.encodeString(EMAIL + DELIMITER + INVALID_PASSWORD)))
        .andExpect(status().isUnauthorized());
  }

하지만 ArgumentResolver를 구성하는 핵심 로직만 따로 분리해서 테스트했다면 좀 더 쉽고 적절하게 테스트할 수 있었을 거 같다. 다음에는 테스트하기 어려운 경우 좀 더 작게 나눠볼 생각을 해야겠다!

dao 단위 테스트를 순수하게?

dao에서 어느 메서드를 테스트할 때 다른 메서드를 사용하게 되면 순수하게 그 메서드를 테스트하는 게 아니라고 생각했다. 그래서 하나의 메서드만 순수하게 테스트하기 위해 아래와 같이 jdbcTemplate으로 쿼리를 날렸다.

    @Test
    void save() {
        //when
        h2ProductDao.save(productEntity);

        //then
        final List<ProductEntity> productEntities = getProducts();
        final ProductEntity actual = productEntities.get(0);

        //then
        assertThat(productEntities).hasSize(1);
        assertThat(actual)
            .usingRecursiveComparison()
            .ignoringFields("id")
            .isEqualTo(productEntity);
    }

    private List<ProductEntity> getProducts() {
        final String sql = "select * from product";
        final List<ProductEntity> productEntities = namedParameterjdbcTemplate.getJdbcOperations().query(sql, (resultSet, count) ->
                new ProductEntity(
                        resultSet.getLong("id"),
                        resultSet.getString("name"),
                        resultSet.getString("image_url"),
                        resultSet.getInt("price")
                ));
        return productEntities;
    }

그런데 위 경우에는 간단해서 괜찮은데 나중에 저장해야 하는 연관 객체가 많아질 경우 쿼리도 무수하게 많아질 텐데 그 비용을 감수해서라도 순수하게 쿼리를 날리는 게 맞는 걸까라는 의문이 들었다. 그렇게 고민하던 중 문뜩 “다른 메서드를 사용하는 것을 생각하기 전에 우선 지금 @JdbcTest를 이용해 실제 데이터베이스에 접근해 테스트하는 게 단위 테스트가 맞나?”라는 생각이 들었다.

단위 테스트보단 통합 테스트에 가까운 거 같아서 다른 메서드를 통해 테스트해도 충분할 것 같다.

    @Test
    void save() {
        //when
        h2ProductDao.save(productEntity);

        //then
        final List<ProductEntity> productEntities = h2ProductDao.findAll();
        final ProductEntity actual = productEntities.get(0);

        assertThat(productEntities).hasSize(1);
        assertThat(actual)
            .usingRecursiveComparison()
            .ignoringFields("id")
            .isEqualTo(productEntity);
    }

만약에 단위 테스트로 진행하고 싶다면 jdbcTemplate을 mock 해서 안의 로직들이 제대로 실행된 지 verify를 통해 확인할 수 있을 것 같다.


+리뷰어님께 받은 코드 리뷰에 대해 관심이 있으면 다음 PR들을 참고!


참고:

*틀린 부분이 있으면 언제든지 말씀해 주시면 공부해서 수정하겠습니다.


© 2022. All rights reserved.

Powered by 애송이