728x90

 

Cucumber 이란?


Cucumber는 BDD(Behavioral Driven Development) 프레임워크를 지원하고 Gherkin 언어를 사용하여 명세 기반 시나리오를 만들 수 있는 도구이다.  하지만 보통 이러한 테스트 시나리오를 설계할 때 데이터로 작업해야 하는 상황에 자주 직면하게 된다.

 

 

Cucumber에서는 데이터를 제공하는 여러 가지 방법이 있지만 그중에서 Scenario Outline과 Data Tables에 대해서 알아보자. 예제 코드는 next step의 ATDD 강의를 수강하며 진행했던 미션코드로 예제로 들어보았다. 

 

Data Table: Parameterizing Steps with Specific Data


데이터 테이블은 단계를 매개변수화하고 시나리오를 DRY(Don’t Repeat Yourself)로 유지할 수 있게 해주는 Gherkin의 기본 구성 요소이다. 데이터 테이블의 주요 특징은 다음과 같다.

 

Passing a Table of Data

  • 데이터 테이블은 시나리오의 단계에 구조화된 데이터 테이블을 전달하는 데 사용된다.
  • 해당 테이블은 시나리오 단계가 작업할 입력값 세트를 제공한다.
  • 테스트 데이터를 정의하기 위해 별도의 키워드가 필요하지 않는다.
  • 데이터 테이블의 헤더 행은 선택적으로 사용할 수 있다.

 

Tight Coupling

  • 데이터 테이블은 특정 작업과 밀접하게 연관된 데이터 세트를 사용할 때 특히 유용하다.
  • 이는 데이터가 수행하려는 동작과 명확히 관련되어 있다는 것을 의미한다.

 

Directly in Scenario

  • 데이터 테이블은 일반적으로 시나리오 내에서 특정 단계 바로 아래에 작성된다.
  • 이를 통해 시나리오를 깔끔하고 이해하기 쉽게 유지할 수 있다.

 

Multiple Rows of Data

  • 동일한 시나리오 내에서 여러 행의 데이터를 전달할 수 있다.
  • 이를 통해 동일한 단계를 다양한 데이터 입력값으로 테스트할 수 있다.

 

Enclosed Within Triple Pipes

  • 데이터 테이블은 삼중 파이프(|||)로 감싸져 있으며, 여러 열로 구성된다.

 

Data Table 예제

 

데이터 테이블의 사용 예제를 알아보자.  지하철의 경로를 찾는 테스트 시나리오를 생각해 보자.

Feature: 지하철역 경로 관련 기능

#  /**
#  * 교대역   ---  2호선, 10, 1  ----   강남역
#  * |                               |
#  * 3호선, 2, 20                 신분당선, 10, 1
#  * |                               |
#  * 남부터미널역 -- 3호선, 3, 30 ----   양재
#  */
  Background: 이호선, 삼호선, 신분당선 노선의 구간
    Given 지하철역들을 생성 요청하고
      | name   |
      | 교대역    |
      | 강남역    |
      | 양재역    |
      | 남부터미널역 |
    And 지하철 노선들을 생성 요청하고
      | name | color  | upStation | downStation | distance | duration |
      | 2호선  | green  | 교대역       | 강남역         | 10       | 1        |
      | 신분당선 | red    | 강남역       | 양재역         | 10       | 1        |
      | 3호선  | orange | 교대역       | 남부터미널역      | 2        | 20       |
    And 지하철 구간을 등록 요청하고
      | lineName | upStation | downStation | distance | duration |
      | 3호선      | 남부터미널역    | 양재역         | 3        | 30       |

  Scenario: 지하철역 최단 거리 경로를 조회한다.
    When "교대역"에서 "양재역"까지의 "최소 거리" 기준으로 경로 조회를 요청하면
    Then "최소 거리" 기준 "교대역,남부터미널역,양재역" 경로를 응답
    And 총 거리 5와 소요 시간 50을 함께 응답함
    And 이용 요금 1250도 함께 응답함

  Scenario: 지하철역 최단 시간 경로를 조회한다.
    When "양재역"에서 "교대역"까지의 "최소 시간" 기준으로 경로 조회를 요청하면
    Then "최소 시간" 기준 "양재역,강남역,교대역" 경로를 응답
    And 총 거리 20와 소요 시간 2을 함께 응답함
    And 이용 요금 1450도 함께 응답함

public class PathStepDef implements En {

    @Autowired
    private AcceptanceContext context;

    public PathStepDef() {

        Given("지하철 구간을 등록 요청하고", (DataTable table) -> {
            List<Map<String, String>> maps = table.asMaps();
            maps.forEach(param -> {
                String lineName = param.get("lineName");
                Map<String, Object> params = new HashMap<>();
                params.put("upStationId",
                    context.getValueFromStore(param.get("upStation"), StationResponse.class).getId().toString());
                params.put("downStationId",
                    context.getValueFromStore(param.get("downStation"), StationResponse.class).getId().toString());
                params.put("distance", param.get("distance"));
                params.put("duration", param.get("duration"));
                LineResponse line = context.getValueFromStore(lineName, LineResponse.class);
                지하철_구간_등록_요청(line.getId(), params);
            });
        });

        When("{string}에서 {string}까지의 {string} 기준으로 경로 조회를 요청하면",
            (String sourceStationName, String targetStationName, String type) -> {
                Long sourceId = ((StationResponse) context.store.get(sourceStationName)).getId();
                Long targetId = ((StationResponse) context.store.get(targetStationName)).getId();
                PathSearchType searchType = getPathSearchType(type);
                context.response = 지하철_경로_조회_요청(sourceId, targetId, searchType);
            });
        Then("{string} 기준 {string} 경로를 응답", (String type, String path) -> {
            List<String> stationNames = Arrays.asList(path.split(","));
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_역_이름_목록_추출(context.response))
                    .containsExactlyElementsOf(stationNames);
            });
        });

        And("총 거리 {long}와 소요 시간 {long}을 함께 응답함", (Long distance, Long duration) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_거리_추출(context.response))
                    .isEqualTo(distance);
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_시간_추출(context.response))
                    .isEqualTo(duration);
            });
        });
        And("이용 요금 {int}도 함께 응답함", (Integer fare) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_지하철_요금_추출(context.response))
                    .isEqualTo(fare);
            });
        });
    }

		// ...

}

  • 해당 테스트 시나리오에서는 데이터 테이블을 이용하여 테스트 시나리오의 각 단계에 필요한 역, 노선, 구간 정보를 전달하여 경로와 요금을 조회하고 있다.
  • 이처럼 데이터 테이블은 시나리오에서 중복을 피하는 데 도움이 된다.

 

Scenario Outline: Running Scenarios with Multiple Data Sets


Scenario Outline은 여러 데이터 세트로 동일한 시나리오를 실행하려는 경우 강력한 도구다. 특히 데이터 기반 테스트에 특히 유용하며 시나리오 개요의 주요 특징은 다음과 같다.

 

Parameterized Scenarios

  • Scenario Outline은 서로 다른 데이터 집합으로 실행해야 하는 동일한 단계가 있을 때 사용된다.
  • 즉, 다양한 입력으로 동일한 기능을 테스트하려는 상황에서 주로 사용된다.

 

Placeholders

  • Scenario Outline에서는 시나리오에서 꺾쇠괄호(< >)를 사용하여 example table의 헤더 역할을 하는 placeholder들을 정의한다.
  • 이러한 placeholder들은 나중에 삽입될 데이터를 나타낸다.

 

Examples Section

  • Scenario Outline 다음에 example section이 포함된다.
  • 해당 섹션에서는 시나리오의 placeholder들을 대체하는 실제 데이터 세트를 제공한다.
  • 예제 섹션의 각 행은 고유한 테스트 사례를 나타낸다.

 

DRY Feature Files

  • Scenario Outline은 데이터를 단계별로 분리하여 피쳐 파일을 DRY로 유지하여 테스트의 유지 관리성을 높여준다.

 

Scenario Outline 예제

위에서 예를 들었던 지하철 경로 찾기 시나리오에서 만약 경로의 거리뿐만 아니라 노선별, 연령별 요금 정책이 추가되었다고 생각해 보자. 다양한 상황에서의 상태값 검증을 하고 싶다면 여러 개의 시나리오를 작성해야 할까?

Feature: 지하철역 경로 관련 기능

#  /**
#  * 교대역   ---  2호선, 10, 1  ----   강남역
#  * |                               |
#  * 3호선, 2, 20                 신분당선, 10, 1
#  * |                               |
#  * 남부터미널역 -- 3호선, 3, 30 ----   양재역
#  */
  Background: 지하철 역, 노선, 구간을 등록합니다.
    Given 지하철역들을 생성 요청하고
      | name   |
      | 교대역    |
      | 강남역    |
      | 양재역    |
      | 남부터미널역 |
    And 지하철 노선들을 생성 요청하고
      | name | color  | upStation | downStation | distance | duration | additionalFee |
      | 2호선  | green  | 교대역       | 강남역         | 10       | 1        | 0             |
      | 신분당선 | red    | 강남역       | 양재역         | 10       | 1        | 1000          |
      | 3호선  | orange | 교대역       | 남부터미널역      | 2        | 20       | 800           |
    And 지하철 구간을 등록 요청하고
      | lineName | upStation | downStation | distance | duration |
      | 3호선      | 남부터미널역    | 양재역         | 3        | 30       |

  Scenario Outline: 비로그인 사용자가 지하철역 지하철 경로를 조회한다.
    When "<fromStation>" 에서 "<toStation>" 까지의 "<searchType>" 기준으로 경로 조회를 요청하면
    Then "<path>" 경로를 응답
    And 총 거리 <totalDistance>와 소요 시간 <totalDuration>을 함께 응답함
    And 총 이용 요금 <totalFare>도 함께 응답함
    Examples:
      | fromStation | toStation | searchType | path           | totalDistance | totalDuration | totalFare |
      | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 2050      |
      | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 2450      |

  Scenario Outline: 로그인 사용자가 지하철역 지하철 경로를 조회한다.
    When <age> 연령의 로그인된 사용자가 존재하고
    When "<fromStation>" 에서 "<toStation>" 까지의 "<searchType>" 기준으로 경로 조회를 요청하면
    Then "<path>" 경로를 응답
    And 총 거리 <totalDistance>와 소요 시간 <totalDuration>을 함께 응답함
    And 총 이용 요금 <totalFare>도 함께 응답함
    Examples:
      | age | fromStation | toStation | searchType | path           | totalDistance | totalDuration | totalFare |
      | 10  | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 1200      |
      | 15  | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 1710      |
      | 30  | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 2050      |
      | 10  | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 1400      |
      | 15  | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 2030      |
      | 30  | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 2450      |

public class PathStepDef implements En {

    @Autowired
    private AcceptanceContext context;

    public PathStepDef() {
        Given("지하철 구간을 등록 요청하고", (DataTable table) -> {
            List<Map<String, String>> maps = table.asMaps();
            maps.forEach(param -> {
                String lineName = param.get("lineName");
                Map<String, Object> params = new HashMap<>();
                params.put("upStationId",
                    context.getValueFromStore(param.get("upStation"), StationResponse.class).getId().toString());
                params.put("downStationId",
                    context.getValueFromStore(param.get("downStation"), StationResponse.class).getId().toString());
                params.put("distance", param.get("distance"));
                params.put("duration", param.get("duration"));
                LineResponse line = context.getValueFromStore(lineName, LineResponse.class);
                지하철_구간_등록_요청(line.getId(), params);
            });
        });

        When("{string} 에서 {string} 까지의 {string} 기준으로 경로 조회를 요청하면",
            (String sourceStationName, String targetStationName, String type) -> {
                Object accessToken = context.store.get("accessToken");
                Long sourceId = ((StationResponse) context.store.get(sourceStationName)).getId();
                Long targetId = ((StationResponse) context.store.get(targetStationName)).getId();
                PathSearchType searchType = getPathSearchType(type);
                if (accessToken == null) {
                    context.response = 지하철_경로_조회_요청(sourceId, targetId, searchType);
                } else {
                    context.response = 지하철_경로_조회_요청(sourceId, targetId, searchType, accessToken.toString());
                }

            }
        );

        Then("{string} 경로를 응답", (String path) -> {
            List<String> stationNames = Arrays.asList(path.split(","));
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_역_이름_목록_추출(context.response))
                    .containsExactlyElementsOf(stationNames);
            });
        });

        And("총 거리 {long}와 소요 시간 {long}을 함께 응답함", (Long distance, Long duration) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_거리_추출(context.response))
                    .isEqualTo(distance);
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_시간_추출(context.response))
                    .isEqualTo(duration);
            });
        });

        And("총 이용 요금 {int}도 함께 응답함", (Integer totalFare) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_지하철_총_요금_추출(context.response))
                    .isEqualTo(totalFare);
            });
        });

    }

    // ....

}

  • 이처럼 같은 시나리오에 다양하 데이터 세트를 테스트하려는 경우(Date Drivent Testing) Scenario Outline을 사용하면 이상적이다.

 

Data Tables vs Scenario Outlines


위에서 본 것처럼 Data Table과 Scenario Outline 중 어떤 것을 선택할지는 테스트 요구 사항의 성격에 따라 달라진다. 어떤 것을 사용할지 결정하는 데 도움이 되는 몇 가지를 살펴보자.

 

Data Tables

  • 단계와 직접 관련된 특정 데이터를 전달해야 할 때 Data Table을 사용한다.
  • 동일한 시나리오 내에서 테스트하려는 긴밀하게 연결된 여러 데이터 세트가 있을 때 유용하다.
  • Data Table은 시나리오를 간결하고 집중적으로 유지하게 한다.

 

Scenario Outline

  • 서로 다른 데이터 집합으로 동일한 단계를 실행해야 하는 경우 시나리오 개요를 선택하자.
  • 동일한 작업을 다양한 입력으로 반복하는 경우 Scenario Outline을 선택하는 것이 가장 좋다.
  • Scenario Outline은 데이터를 단계별로 분리하여 feature 파일을 더 쉽게 유지 관리할 수 있게 해 준다.

 

효과적인 Scenario Design 하는 법


시나리오에 집중

  • 각 시나리오가 잘 정의된 하나의 동작을 다루도록 하자.
  • 이렇게 하면 문제를 식별하고 시나리오를 유지 관리하기가 더 쉬워진다.

 

의미 있는 이름 사용

  • 시나리오와 단계에 의미 있는 이름을 사용하자.
  • 이렇게 하면 가독성이 향상되고 비기술적인 이해관계자도 테스트를 이해하는 데 도움이 된다.

 

Data Table 및 Scenario Outline 결합

  • 경우에 따라 데이터 테이블과 시나리오 개요를 결합하면 가장 유연하게 사용할 수 있다.
  • 예를 들면, Scenario Outline 내에서 Data Table을 사용하여 특정 데이터 요구 사항과 일반 데이터 요구 사항을 모두 처리할 수 있다.

 

정기적인 검토 및 리팩터링

  • 시간이 지남에 따라 시나리오가 복잡해지거나 중복될 수 있다.
  • 기능 파일을 정기적으로 검토하고 리팩터링 하여 깔끔하고 효율적으로 유지하자.

 

참고 출처

728x90

'test' 카테고리의 다른 글

테스트 더블과 단위테스트  (2) 2024.11.08
728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

서론


지금까지 헥사고날 스타일로 웹 애플리케이션을 만드는 방법을 설명했다. 어떤 부분들은 전통적인 계층형 아키텍처 스타일에도 적용할 수 있다. 또 어떤 부분들은 도메인 중심의 접근법에서만 구현 가능하다.

 

그렇다면 실제로 언제 헥사고날 아키텍처 스타일을 사용해야 할까? 그리고 언제 헥사고날 대신 기존 아키텍처를 고수해야 할까?

 

도메인이 왕이다.


지금까지 살펴본 내용을 통해 영속성 관심사나 외부 시스템에 대한 의존성 등의 변화로부터 자유롭게 도메인 코드를 개발할 수 있는 것이 헥사고날 아키텍처 스타일의 주요 특징이다.

 

 

외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있다는 것은 헥사고날 아키텍처 스타일이 내세우는 가장 중요한 가치다.

 

이것이 헥사고날 아키텍처 스타일이 도메인 주도 설계 방식과 정말 잘 어울리는 이유다. 당연한 말이지만 DDD에서는 도메인이 개발을 주도

한다. 그리고 영속성 문제나 다른 기술적인 측면에 대해서 함께 생각할 필요가 없게 되면 도메인에 대해 가장 잘 고려할 수 있게 된다.

 

헥사고날 스타일과 같은 도메인 중심의 아키텍처 스타일은 DDD의 조력자라고까지 말할 수 있다. 도메인을 중심에 두는 아키텍처 없이는, 또 도메인 코드를 향한 의존성을 역전시키지 않고서는, DDD를 제대로 할 가능성이 없다. 즉, 설계가 항상 다른 요소들에 의해 주도되고 말 것이다.

 

그래서 헥사고날 아키텍처를 사용할지 말지를 결정할 첫 번째 지표로서, 만약 도메인 코드가 애플리케이션에서 가장 중요한 것이 아니라면 이 아키텍처 스타일은 필요하지 않을 것이다.

 

경험이 여왕이다.


인간은 습관의 동물이다. 습관이 저절로 결정을 내리기 때문에 우리는 무언가를 결정할 때 시간을 들일 필요가 없다. 만약 새로운 웹 애플리케이션을 만든다면 계층형 아키텍처 스타일을 이용한다. 과거에 자주 이렇게 해왔고, 습관이 된 것이다.

 

이것이 반드시 나쁜 결정이라는 것은 아니다. 우리가 과거에 했던 일에 편안함을 느끼는데 무언가를 바꿔야 할 이유가 있을까? 따라서 아키텍처 스타일에 대해서 괜찮은 결정을 내리는 유일한 방법은 다른 아키텍처 스타일을 경험해 보는 것이다.

 

헥사고날 아키텍처에 대한 확신이 없다면 지금 만들고 있는 애플리케이션의 작은 모듈에 먼저 시도해 보자. 개념에 익숙해지고 스타일에 익숙해져 보자. 아이디어들은 적용하고, 수정하고, 자신만의 아이디어를 추가해서 편하게 느껴지는 스타일을 개발해 보자

 

그러면 이 경험이 다음 아키텍처 결정을 이끌어 줄 것이다.

 

그때그때 다르다

 


어떤 아키텍처 스타일을 골라야 하는가에 대한 대답은 “그때그때 달라요” 와 같다. 어떤 소프트웨어를 만드느냐에 따라서도 다르고, 도메인 코드의 역할에 따라서도 다르다. 팀의 경험에 따라서도 다르다. 그리고 최종적으로 내린 결정이 마음에 드느냐에 따라서도 다르다.

 

끝으로..


계층형 아키텍처를 구성하며 느껴왔던 데이터베이스 주도 설계에 대한 부분과 경계가 모호한 점 등 여러 가지 느껴왔던 부분들을 해당 책을 읽으면서 명확하게 다시 한번 느낄 수 있었다.

 

특히 굉장히 구체적으로 책이 서술되어 있어 좋았고 도메인 주도 개발을 하기 위해 의존성을 역전시켜 도메인을 외부의 영향을 받지 않도록 하는 핵심적인 가치를 반복적으로 꺼내주어 강력하게 인식할 수 있었다.

 

돌이켜보면 가장 감명 깊게 읽었던 챕터는 ‘8장 경곗값 매핑하기’인 것 같다. 굉장히 현실적인 챕터라고 생각이 들었고 저렇게 다양한 매핑 전략이 존재한다는 것도 알게 되었다. (특히 단방향 전략)

경곗값 매핑하기는 가장 현실적인 문제인 것 같다. 경곗값마다 매핑 모델을 따로 두는 것이 좋은 것임을 알면서도 현실적으로 같은 모델에 매핑만 하는 경우가 많아 의미가 있는 부분인가 하는 생각에 평소에 많이 헤매고 있었는데, 그 이유는 바로 하나의 매핑 전략을 전역적으로 사용해야 한다는 생각이 가장 큰 원인이었던 것 같다.

 

앞으로는 헥사고날 아키텍처를 기반으로 DDD의 대해 더 알아보고자 한다.

728x90
728x90

 

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

서론


지름길을 방지하기 위해서는 먼저 지름길 자체를 파악해야 한다. 이번에는 잠재적인 지름길에 대한 인식을 높이고 그 영향에 대해 이야기해 보자. 이 정보만 있어도 우발적으로 사용되는 지름길을 인식하고 수정할 수 있다. 또는 정당한 지름길이라면 지름길의 효과를 의식적으로 택할 수도 있다. 어떤 때는 지름길을 먼저 취하고 나중에 고치는 것이 실제로 더 경제적일 수도 있다.

 

왜 지름길은 깨진 창문 같을까?


1969년 심리학자 필립 짐바르도는 나중에 ‘깨진 창문 이론’이라고 알려진 실험을 했다. 이 이론은 삶의 많은 부분에 적용할 수 있다. 코드 작업에 적용될 때의 의미는 다음과 같다.

  • 품질이 떨어진 코드에서 작업할 때 더 낮은 품질의 코드를 추가하기가 쉽다.
  • 코딩 규칙을 많이 어긴 코드에서 작업할 때 또 다른 규칙을 어기기도 쉽다.
  • 지름길을 많이 사용한 코드에서 작업할 때 또 다른 지름길을 추가하기도 쉽다.

이 모든 것을 고려하면 ‘레거시’라고 불리는 많은 코드의 품질이 시간이 가면서 심하게 낮아졌다는 게 그리 놀라운 일은 아니다.

 

깨끗한 상태로 시작할 책임


우리는 모두 깨진 창문 시림에 무의식적으로 영향을 받는다. 그래서 가능한 한 지름길을 거의 쓰지 않고 기술 부채를 지지 않은 채로 프로젝트를 깨끗하게 시작하는 것이 중요하다. 지름길에 몰래 스며드는 순간 깨진 창문과 같아져 버려서 더 많은 지름길을 끌어들이기 때문이다.

 

소프트웨어 프로젝트는 대개 큰 비용이 들고 장기적인 노력을 필요로 하기 때문에 깨진 창문을 막는 것이 소프트웨어 개발자들의 아주 막대한 책임이다. 우리가 프로젝트를 마무리하지 못하고 다른 이들이 프로젝트를 인계받아야 할지도 모른다. 프로젝트를 인계받는 입장에서는 이 코드가 연관성이 전혀 없는 레거시이기 때문에 깨진 창문을 만들어 내기가 더 쉽다.

 

그러나 때로는 지름길을 취하는 것이 더 실용적일 때도 있다. 작업 중인 부분이 프로젝트 전체로 봤을 때 그리 중요하지 않은 부분이거나, 프로토타이핑 작업 중이거나, 경제적인 이유가 있을 수도 있다.

 

이러한 의도적인 지름길에 대해서는 세심하게 잘 기록해둬야 한다. 마이클 나이가드가 제안한 아키텍처 결정 기록(ADRs)의 형태도 괜찮다. 우리는 미래의 우리 혹은 프로젝트를 인계받는 이들에게 빚을 지고 있는 것이다. 만약 팀원 모두가 이 문서에 대해 인지하고 있다면 지름길이 합리적인 이유에 의해 의도적으로 추가됐다는 사실을 알기 때문에 깨진 창문 이론의 영향을 더 줄일 수 있을 것이다.

 

이제 헥사고날 아키텍처에서 고려해 볼 수 있는 지름길들을 몇 가지 이야기해 보자.

 

유스케이스 간 모델 공유하기


4장에서 유스케이스마다 다른 입출력 모델을 가져야 한다고 이야기한 적 있다. 즉, 입력 파라미터의 타입과 반환값의 타입이 달라야 한다는 뜻이다. 아래 그림은 두 개의 유스케이스가 같은 입력 모델을 공유하는 예를 보여준다.

  • 유스케이스 간에 입출력 모델을 공유하게 되면 유스케이스들 사이에 결합이 생긴다.

 

공유로 인한 영향은 SendMoneyUseCase와 RevokeActivityService가 결합된다는 것이다. 공유하고 있는 SendMoneyCommand 클래스가 변경되면 두 유스케이스 모두 영향을 받는다. 단일 책임 원칙에서 이야기하는 ‘변경할 이유’를 공유하는 것이다. 출력 모델을 공유하는 경우에도 마찬가지다.

 

유스케이스 간 입출력 모델을 공유하는 것은 유스케이스들이 기능적으로 묶여 있을 때 유효하다. 즉, 특정 요구사항을 공유할 때 괜찮다는 의미다. 이 경우 특정 세부사항을 변경할 경우 실제도 두 유스케이스 모두에 영향을 주고 싶은 것이다.

두 유스케이스가 서로 간에 미치는 영향 없이 독립적으로 진화해야 한다면 입출력 모델을 공유하는 방식은 지름길이 된다. 만약 독립적으로 진화해야 한다면 처음에는 똑같은 입출력 클래스를 복사해야 하더라도 일단 분리해서 시작해야 한다.

 

도메인 엔티티를 입출력 모델로 사용하기


도메인 엔티티인 Account와 인커밍 포트인 SendMoneyUseCase가 있으면 엔티티를 인커밍 포트의 입출력 모델로 사용하고 싶다는 생각이 들지도 모른다.

  • 도메인 엔티티를 유스케이스의 입출력 모델로 사용하면 도메인 엔티티가 유스케이스에 결합된다.

 

인커밍 포트는 도메인 엔티티에 의존성을 가지고 있다. 그 결과, Account 엔티티는 변경할 또 다른 이유가 생겼다.

 

 

Account 엔티티는 인커밍 포트인 SendMoneyUseCase에 의존성이 없으니 인커밍 포트가 어떻게 엔티티를 변경할 이유가 된다는 뜻일까?

현재 Account 엔티티에는 존재하지 않는 정보를 유스케이스가 필요로 한다고 생각해 보자. 이 정보는 최종적으로 Account 엔티티에 저장돼 있어야 하는 것이 아니라 다른 도메인이나 다른 바운디드 컨텍스트에 저장돼야 한다. 그럼에도 불구하고 이미 유스케이스 인터페이스에서 사용할 수 있기 때문에 Account 엔티티에 새로운 필드를 추가하고 싶다는 생각이 든다.

 

간단한 생성이나 업데이트 유스케이스에서는 유스케이스 인터페이스에 도메인 엔티티가 있는 것이 괜찮을지도 모른다. 데이터베이스에 저장해야 하는 바로 그 상태 정보가 엔티티에 있기 때문이다.

 

하지만 유스케이스가 단순히 데이터베이스의 필드 몇 개를 업데이트하는 수준이 아니라 더 복잡한 도메인 로직을 구현해야 한다면, 유스케이스 인터페이스에 대한 전용 입출력 모델을 만들어야 한다. 왜냐하면 유스케이스의 변경이 도메인 엔티티까지 전파되길 바라진 않을 것이기 때문이다.

 

이 지름길이 위험한 이유는 많은 유스케이스가 간단한 생성 또는 업데이트 유스케이스로 시작해서 시간이 지나면서 복잡한 도메인 로직 괴물이 되어간다는 사실 때문이다. 이는 최소 기능 제품으로 시작해서 점점 복잡도를 높여가는 애자일 환경에서 특히 그렇다. 그러므로 처음에는 도메인 엔티티를 입력 모델로 사용했더라도 도메인 모델로부터 독립적인 전용 입력 모델로 교체해야 하는 시점을 잘 파악해야 한다.

 

인커밍 포트 건너뛰기


아웃고잉 포트는 애플리케이션 계층과 아웃고잉 어댑터 사이의 의존성을 역전시키기 위한 필수 요소인 반면, 인커밍 포트는 의존성 역전에 필수적인 요소는 아니다. 인커밍 어댑터가 인커밍 포트 없이 애플리케이션 서비스에 직접 접근하도록 할 수 있다.

  • 인커밍 포트가 없으면 도메인 로직의 진입점이 불분명해진다.

 

인커밍 포트를 제거함으로써 인커밍 어댑터와 애플리케이션 계층 사이의 추상화 계층을 줄였다. 보통 추상화 계층을 줄이는 것은 괜찮게 느껴진다.

 

하지만 인커밍 포트는 애플리케이션 중심에 접근하는 진입점을 정의한다. 이를 제거하면 특정 유스케이스를 구현하기 위해 어떤 서비스 메서드를 호출해야 할지 알아내기 위해 애플리케이션의 내부 동작에 대해 더 잘 알아야 한다. 전용 인커밍 포트를 유지하면 한눈에 진입점을 식별할 수 있다. 이는 새로운 개발자가 코드를 파악할 때 특히 더 도움이 된다.

 

인커밍 포트를 유지해야 하는 또 다른 이유는 아키텍처를 쉽게 강제할 수 있기 때문이다. 10장에서 소개한 아키텍처를 강제하는 옵션들을 이용하면 인커밍 어댑터가 애플리케이션 서비스가 아닌 인커밍 포트만 호출하게 할 수 있다. 그럼 애플리케이션 계층에 대한 모든 진입점을 정의하는 것이 아주 의식적인 결정이 된다. 인커밍 어댑터에서 호출할 의도가 없던 서비스 메서드를 실수로 호출하는 일이 절대 발생할 수 없다.

 

애플리케이션 규모가 작거나 인커밍 어댑터가 하나밖에 없어서 모든 제어 흐름을 인커밍 포트의 도움 없이 단숨에 파악할 수 있다면 인커밍 포트가 없는 것이 편하다. 그러나 애플리케이션의 규모가 이후로도 작게 유지된다고 확신할 수 있을까?

 

애플리케이션 서비스 건너뛰기


어떤 유스케이스에서는 애플리케이션 계층을 통째로 건너뛰고 싶을 수도 있다.

  • 애플리케이션 서비스가 없으면 도메인 로직을 둘 곳이 없다.

그림에서 아웃고잉 어댑터에 있는 AccountPersistenceAdapter 클래스는 직접 인커밍 포트를 구현해서 일반적으로 인커밍 포트를 구현하는 애플리케이션 서비스를 대체한다.

 

간단한 CRUD 유스케이스에서는 보통 애플리케이션 서비스가 도메인 로직 없이 생성, 업데이트, 삭제 요청을 그대로 영속성 어댑터에 전달하기 때문에 정말 구미가 당기는 방법이다. 그대로 전달하는 대신 영속성 어댑터가 직접 유스케이스를 구현하게 할 수 있다.

 

하지만 이 방법은 인커밍 어댑터와 아웃고잉 어댑터 사이에 모델을 공유해야 한다. 이 경우에 공유해야 하는 모델이 Account 도메인 엔티티이므로 앞에서 이야기한 도메인 모델을 입력 모델로 사용하는 케이스가 되는 것이다.

 

나아가 애플리케이션 코어에 유스케이스라고 할 만한 것이 없어진다. 만약 시간이 지남에 따라 CRUD 유스케이스가 점점 복잡해지면 도메인 로직을 그대로 아웃고잉 어댑터에 추가하고 싶은 생각이 들 것이다. 이미 유스케이스가 어댑터에 있으니 말이다. 이렇게 되면 도메인 로직이 흩어져서 도메인 로직을 찾거나 유지보수하기가 어려워진다.

 

결국 단순히 전달만 하는 보일러플레이트 코드가 가득한 서비스가 많아지는 것을 방지하기 위해 간단한 CRUD 케이스에서는 애플리케이션 서비스를 건너뛰기로 결정할 수도 있다. 하지만 유스케이스가 엔티티를 단순히 생성, 업데이트, 삭제하는 것보다 더 많은 일을 하게 되면 애플리케이션 서비스를 만든다는 명확한 가이드라인을 팀에 정해둬야 한다.

 

요약


경제적인 관점에서 지름길이 합리적일 때도 있다. 이번 장에서는 지름길을 사용할지 여부를 결정하는 데 도움이 되도록 지름길을 사용한 결과에 대한 식견을 제공했다.

 

간단한 CRUD 유스케이스에 대해서는 전체 아키텍처를 구현하는 것이 지나치게 느껴지기 때문에 지름길의 유혹을 느낄 수 있다. 하지만 모든 애플리케이션은 처음에는 작게 시작하기 때문에, 유스케이스가 단순한 CRUD 상태에서 벗어나는 시점이 언제인지에 대해 팀이 합의하는 것이 매우 중요하다. 합의를 이루고 난 후에야 팀은 지름길을 장기적으로 더 유지보수하기 좋은 아키텍처로 대체할 수 있다.

 

어떤 경우든 아키텍처에 대해, 그리고 왜 특정 지름길을 선택했는가에 대한 기록을 남겨서 나중에 우리 자신 또는 프로젝트를 인계받는 이들이 이 결정에 대해 다시 평가할 수 있게 하자.

 

 

728x90
728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

서론


지금까지 아키텍처에 대해서 많은 이야기를 나누었다. 하지만 일정 규모 이상의 모든 프로젝트에서는 시간이 지나면서 아키텍처가 서서히 무너지게 된다. 계층 간의 경계가 약화되고, 코드는 점점 더 테스트하기 어려워지고, 새로운 기능을 구현하는 데 점점 더 많은 시간이 든다.

 

이번에는 아키텍처 내의 경계를 강제하는 방법과 함께 아키텍처 붕괴에 맞서 싸우기 위해 취할 수 있는 몇 가지 방법을 살펴보자.

 

경계와 의존성


아키텍처 경계를 강제하는 여러 가지 방법에 대해 이야기하기에 앞서 아키텍처의 어디에 경계가 있고, ‘경계를 강제한다’는 것이 어떤 의미인지 먼저 살펴보자.

  • 아키텍처 경계를 강제한다는 것은 의존성이 올바른 방향을 향하도록 강제하는 것을 의미한다.
  • 아키텍처에서 허용되지 않은 의존성을 점선 화살표로 표시했다.

 

가장 안쪽의 계층에는 도메인 엔티티가 있다. 애플리케이션 계층은 애플리케이션 서비스 안에 유스케이스를 구현하기 위해 도메인 엔티티에 접근한다. 어댑터는 인커밍 포트를 통해 서비스에 접근하고, 반대로 서비스는 아웃고잉 포트를 통해 어댑터에 접근한다. 마지막으로 설정 계층은 어댑터와 서비스 객체를 생성할 팩토리를 포함하고 있고, 의존성 주입 메커니즘을 제공한다.

 

위의 그림을 보면 아키텍처의 경계는 꽤 명확하다. 각 계층 사이, 안쪽 인접 계층과 바깥쪽 인접 계층 사이에 경계가 있다. 의존성 규칙에 따르면 계층 경계를 넘는 의존성은 항상 안쪽 방향으로 향해야 한다. 이제 이러한 의존성 규칙을 강제하는 방법들을 살펴보자.

 

접근 제한자


경계를 강제하기 위해 자바에서 제공하는 가장 기본적인 도구인 접근 제한자부터 시작해 보자. 보통 접근 제한자 중 package-private(default) 제한자에 대해서는 잘 모르는 경우가 있다.

 

package-private 제한자

 

package-private 제한자는 왜 중요할까? 자바 패키지를 통해 클래스들은 응집적인 ‘모듈’로 만들어 주기 때문이다. 이러한 모듈 내에 있는 클래스들은 서로 접근 가능하지만, 패키지 바깥에서는 접근할 수 없다. 그럼 모듈의 진입점으로 활용된 클래스들만 골라서 public으로 만들면 된다. 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.

buckpal
|-- account
    |-- adapter
    |   |-- in
    |   |   |-- web
    |   |       |-- o AccountController 
    |   |-- out
    |   |   |-- persistence
    |   |       |-- o AccountPersistenceAdapter
    |   |       |-- o SpringDataAccountRepository
    |-- domain
    |   |-- + Account
    |   |-- + Activity
    |-- application
        |-- o SendMoneyService
        |-- port
            |-- in
            |   |-- + SendMoneyUseCase
            |-- out
            |   |-- + LoadAccountPort
            |   |-- + UpdateAccountStatePort
  • 접근 제한자를 염두에 두고 3장에서 본 패키지 구조를 다시 한번 살펴보자

 

persistence 패키지에 있는 클래스들은 외부에서 접근할 필요가 없기 때문에 package-private(o 표시)으로 만들 수 있다. 영속성 어댑터는 자신이 구현하는 출력 포트를 통해 접근된다. 같은 이유로 SendMoneyService를 package-private으로 만들 수 있다.

이 방법을 스프링에서 사용하려면 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스들을 우리가 직접 생성해야 하기 때문에 public 제한자를 이용해야 한다.

 

나머지 클래스들은 아키텍처의 정의에 의해 public(+ 표시)이어야 한다. 도메인 패키지는 다른 계층에서 접근할 수 있어야 하고, application 계층은 web 어댑터와 persistence 어댑터에서 접근 가능해야 한다.

 

package-private 제한자는 몇 개 정도의 클래스로만 이뤄진 작은 모듈에서 가장 효과적이다. 그러나 패키지 내의 클래스가 특정 개수를 넘어가기 시작하면 하나의 패키지에 너무 많은 클래스를 포함하는 것이 혼란스러워지게 된다. 이렇게 되면 코드를 쉽게 찾을 수 있도록 하위 패키지를 만드는 방법을 선호한다.

 

하지만 이렇게 하면 자바는 하위 패키지를 다른 패키지로 취급하기 때문에 하위 패키지의 package-private 멤버에 접근할 수 없게 된다. 그래서 하위 패키지의 멤버는 public으로 만들어서 바깥 세계에 노출시켜야 하기 때문에 우리의 아키텍처에서 의존성 규칙이 깨질 수 있는 환경이 만들어진다.

 

컴파일 후 체크


클래스에 public 제한자를 쓰면 아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러는 다른 클래스들이 이 클래스를 사용하도록 허용한다. 이런 경우에는 컴파일러가 전혀 도움이 되지 않기 때문에 의존성 규칙을 위반했는지 확인할 다른 수단을 찾아야 한다.

 

한 가지 방법은 컴파일 후 체크(post-compile check)를 도입하는 것이다. 다시 말해, 코드가 컴파일된 후에 런타임에 체크한다는 뜻이다. 이러한 런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.

 

이러한 체크를 도와주는 자바용 도구로 ArchUnit이 있다. 다른 무엇보다 ArchUnit은 의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공한다. 의존성 규칙 위반을 발견하면 예외를 던지다. 이 도구는 JUnit과 같은 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.

 

이전 절에서 정의한 패키지 구조대로 각 계층이 전용 패키지를 가지고 있다고 가정하면 ArchUnit으로 계층 간의 의존성을 체크할 수 있다. 예를 들어, 도메인 계층에서 바깥쪽의 애플리케이션 계층으로 향하는 의존성이 없다는 것을 체크할 수 있다.

class DependencyRuleTests {
    @Test
    void domainLayerDoesNotDependOnApplicationLayer() {
        noClasses()
        .that()
        .resideInPackage("buckpal.domain..")
        .should()
        .dependOnClassesThat()
        .resideInAnyPackage("buckpal.application..")
        .check(new ClassFileImporter()
              .importPackages("buckpal.."));
    }
}

 

ArchUnit API를 이용하면 적은 작업만으로도 육각형 아키텍처 내에서 관련된 모든 패키지를 명시할 수 있는 일종의 도메인 특화 언어(DSL)를 만들 수 있고, 패키지 사이의 의존성 방향이 올바른지 자동으로 체크할 수 있다.

class DependencyRuleTests {

    @Test
    void validateRegistrationContextArchitecture() {
        HexagonalArchitecture.boundedContext("account")
              .withDomainLayer("domain")
              .withAdaptersLayer("adapter")
                .incoming("web")
                .outgoing("persistence")
                .and()
              .withApplicationLayer("application")
                .services("service")
                .incomingPorts("port.in")
                .outgoingPorts("port.out")
                .and()
              .withConfiguration("configuration")
              .check(new ClassFileImporter()
                 .importPackages("buckpal.."));
    }
}

  • 바운디드 컨텍스트의 부모 패키지를 지정한다.
    • 단일 바운디드 컨텍스트라면 애플리케이션 전체에 해당
  • 도메인, 어댑터, 애플리케이션, 설정 계층에 해당하는 하위 패키지들을 지정한다.
  • 마지막에 호출하는 check()는 몇 가지 체크를 실행하고 패키지 의존성이 의존성 규칙을 따라 유효하게 설정됐는지 검증한다.

 

잘못된 의존성을 바로잡는 데 컴파일 후 체크가 큰 도움이 되긴 하지만, 실패에 안전하지는 않다. 패키지 이름에 오타를 내면 테스트가 어떤 클래스도 찾기 못하기 때문에 의존성 규칙 위반 사례를 발견하지 못할 것이다. 오타가 하나라도 나거나 패키지명을 하나만 리팩터링해도 테스트 전체가 무의미해질 수 있다.

 

이런 상황을 방지하려면 클래스를 하나도 찾지 못했을 때 실패하는 테스트를 추가해야 한다. 그럼에도 불구하고 여전히 리팩터링에 취약한 것은 사실이다. 컴파일 후 체크는 언제나 코드와 함께 유지보수해야 한다.

 

빌드 아티팩트


지금까지 코드 상에서 아키텍처 경계를 구분하는 유일한 도구는 패키지였다. 모든 코드가 같은 모놀리식 빌드 아티팩트의 일부였던 셈이다.

빌드 아티팩트는 빌드 프로세스의 결과물이다. 자바 세계에서 인기 있는 빌드 도구는 메이븐과 그레이들이다. 그러므로 지금까지 단일 메이븐 혹은 그레이들 빌드 스크립트가 있고, 메이븐이나 그레이들을 호출해서 코드를 컴파일하고, 테스트하고, 하나의 JAR 파일로 패키징 할 수 있었다고 상상하자.

 

빌드 도구의 주요한 기능 중 하나는 의존성 해결이다. 어떤 코드베이스를 빌드 아티팩트로 변환하기 위해 빌드 도구가 가장 먼저 할 일은 코드베이스가 의존하고 있는 모든 아티팩트가 사용 가능한지 확인하는 것이다. 만약 사용 불가능한 것이 있다면 아티팩트 리포지토리부터 가져오려고 시도한다. 이마저도 실패한다면 코드를 컴파일하기 전에 에러와 함께 빌드가 실패한다.

 

이를 활용해서 모듈과 아키텍처의 계층 간의 의존성을 강제할 수 있다. 각 모듈 혹은 계층에 대해 전용 코드베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들 수 있다. 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정한다. 클래스들이 클래스패스에 존재하지도 않아 컴파일 에러가 발생하기 때문에 개발자들은 더 이상 실수로 잘못된 의존성을 만들 수 없다.

잘못된 의존성을 막기 위해 아키텍처를 여러 개의 빌드 아티팩트로 만드는 여러 가지 방법

 

 

맨 왼쪽 첫 번째 열의 구조에서는 설정, 어댑터, 애플리케이션 계층의 빌드 아티팩트로 이뤄진 기본적인 3개의 모듈 빌드 방식이 있다. 설정 모듈은 어댑터 모듈에 접근할 수 있고, 어댑터 모듈은 애플리케이션 모듈에 접근할 수 있다. 설정 모듈은 암시적으로 전이적인 의존성 때문에 애플리케이션 모듈에도 접근할 수 있다.

 

어댑터 모듈은 영속성 어댑터뿐만 아니라 웹 어댑터도 포함하고 있다. 즉, 빌드 도구가 두 어댑터 간의 의존성을 막지 않을 것이라는 뜻이다. 두 어댑터 간의 의존성 규칙에서 엄격하게 금지된 것은 아니지만 대부분의 경우 어댑터를 서로 격리시켜 유지하는 것이 좋다.

영속성 계층의 변경이 웹 계층에 영향을 미치거나 웹 계층의 변경이 영속성 계층에 영향을 미치는 것을 바라지 않을 것이다. 단일 책임 원칙을 기억하자. 애플리케이션을 다른 서드파티 API에 연결하는 다른 종류의 어댑터에서도 마찬가지다. 실수로 어댑터 간에 의존성이 추가되는 바람에 API와 관련된 세부사항이 다른 어댑터로 새어나가는 것을 바라지 않을 것이다.

그렇기 때문에 하나의 어댑터 모듈을 여러 개의 빌드 모듈로 쪼개서 어댑터당 하나의 모듈이 되게 할 수도 있다. 두 번째 열의 구조가 여기에 해당한다.

 

다음으로 애플리케이션 모듈도 쪼갤 수 있다. 두 번째 열에서는 애플리케이션 모듈이 애플리케이션에 대한 인커밍/아웃고잉 포트, 그리고 이러한 포트를 구현하거나 사용하는 서비스, 도메인 로직을 담은 도메인 엔티티를 모두 포함하고 있다. 도메인 엔티티가 포트에서 전송 객체(transfer object)로 사용되지 않는 경우라면(’no mapping’ 전략을 허용하지 않는 경우) 의존성 역전 원칙을 적용해서 포트 인터페이스에만 포함하는 API 모듈을 분리해서 빼낼 수 있다. 이는 세 번째 열의 구조가 여기에 해당한다.

 

한걸음 더 나아가 API 모듈을 인커밍 포트와 아웃고잉 포트 각각만 가지고 있는 두 개의 모듈로 쪼갤 수 있다. 이는 네 번째 열의 구조가 여기에 해당한다. 이런 식으로 인커밍 포트나 아웃고잉 포트에 대해서만 의존성을 선언함으로써 특정 어댑터가 인커밍 어댑터인지 아웃고잉 어댑터인지 매우 명확하게 정의할 수 있다 또, 애플리케이션 모듈을 더 쪼갤 수도 있다. 서비스만 가지고 있는 모듈과 도메인 엔티티만 가지고 있는 모듈로 쪼개는 것이다. 이렇게 하면 엔티티가 서비스에 접근할 수 없어지고, 도메인 빌드 아티팩트에 대한 의존성을 간단하게 선언하는 것만으로도 다른 애플리케이션이 같은 도메인 엔티티를 사용할 수 있게 된다.

 

위의 그림에서는 4가지만 표현했지만 실제로는 더 다양한 방법이 있다. 핵심은 모듈을 더 세분화할수록, 모듈 간 의존성을 더 잘 제어할 수 있게 된다는 것이다. 하지만 더 작게 분리할수록 모듈 간에 매핑을 더 많이 수행해야 한다.

 

빌드 모듈로 경계 구분하는 것의 장점

 

이 밖에도 빌드 모듈로 아키텍처 경계를 구분하는 것은 패키지로 구분하는 방식과 비교했을 때 몇 가지 장점이 있다.

  • 빌드 도구는 순환 의존성을 허용하지 않기 때문에 순환 의존성이 없음을 확신할 수 있다.
  • 빌드 모듈 방식에서는 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다.
  • 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로 의존성을 추가하는 일은 우연이 아닌 의식적인 행동이 된다.
순환 의존성은 하나의 모듈에서 일어나는 변경이 잠재적으로 순환 고리에 포함된 다른 모든 모듈을 변경하게 만들며, 단일 책임 원칙을 위배한다.

 

하지만 이런 장점에는 빌드 스크립트를 유지보수하는 비용을 수반하기 때문에 아키텍처를 여러 개의 빌드 모듈로 나누기 전에 아키텍처가 어느 정도는 안정된 상태여야 한다.

 

요약


기본적으로 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는 게 전부다. 만약 의존성이 거대한 진흙 덩어리가 된다면 아키텍처 역시 거대한 진흙 덩어리가 된다. 그렇기 때문에 아키텍처를 잘 유지해나가고 싶다면 의존성이 올바른 방향을 가리키고 있는지 지속적으로 확인해야 한다.

 

새로운 코드를 추가하거나 리팩터링 할 때 패키지 구조를 항상 염두에 둬야 하고, 가능하다면 package-private 가시성을 이용해 패키지 바깥에서 접근하면 안 되는 클래스에 대한 의존성을 피해야 한다.

 

하나의 빌드 모듈 안에서 아키텍처 경계를 강제해야 하고, 패키지 구조가 허용되지 않아 package-private 제한자를 사용할 수 없다면 ArchUnit 같은 컴파일 후 체크 도구를 이용해야 한다.

 

그리고 아키텍처가 충분히 안정적이라고 느껴지면 아키텍처 요소를 독립적인 빌드 모듈로 추출해야 한다. 그래야 의존성을 분명하게 제어할 수 있기 때문이다.

 

아키텍처 경계를 강제하고 시간이 지나도 유지보수하기 좋은 코드를 만들기 위해 세 가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.

이번에는 아키텍처 내의 경계를 강제하는 방법과 함께 아키텍처 붕괴에 맞서 싸우기 위해 취할 수 있는 몇 가지 방법을 살펴보자.

 

728x90
728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

서론


유스케이스, 웹 어댑터, 영속성 어댑터를 구현해봤으니, 이것들을 동작하는 애플리케이션으로 조립해보자. 클래스를 인스턴스화하고 묶기 위해서 의존성 주입 메커니즘을 이용한다. 평범한 자바, 스프링, 스프링 부트 프레임워크에서는 이를 어떻게 하는지 살펴보자

 

왜 조립까지 신경 써야 할까?


왜 유스케이스와 어댑터를 그냥 필요할 떄 인스턴스화 하면 안되는 걸까? 그것은 코드 의존성이 올바른 방향을 가리키게 하기 위해서다. 모든 의존성은 안쪽으로, 애플리케이션의 도메인 코드 방향으로 향해야 도메인 코드가 바깥 계층의 변경으로부터 안전하다는 점을 기억하자.

 

유스케이스가 영속성 어댑터를 호출하고 스스로 인스턴스화한다면 코드 의존성이 잘못된 방향으로 만들어진 것이다. 이것이 바로 아웃고잉 포트 인터페이스를 생성한 이유다. 유스페이스는 인터페이스만 알아야 하고, 런타임에 이 인터페이스의 구현을 제공받아야 한다.

 

이 프로그래밍 스타일의 유익한 부수효과 중 하나는 코드를 훨씬 더 테스트하기 쉽다는 것이다. 한 클래스가 필요로 하는 모든 객체를 생성자로 전달할 수 있다면 실제 객체 대신 목으로 전달할 수 있고, 이렇게 되면 격리된 단위 테스트를 생성하기가 쉬워진다.

 

그럼 우리의 객체 인스턴스를 생성할 책임은 누구에게 있을까? 그리고 어떻게 의존성 규칙을 어기지 않으면서 그렇게 할 수 있을까?

그것은 아키텍처에 대해 중립적이고 인스턴스 생성을 위해 모든 클래스에 대한 의존성을 가지는 설정 컴포넌트가 있어야 한다는 것이다.

 

  • 중립적인 설정 컴포넌트는 인스턴스 생성을 위해 모든 클래스에 접근할 수 있다.
  • 클린 아키텍처에서 이 설정 컴포넌트는 의존성 규칙에 정의한 대로 모든 내부 계층에 접근할 수 있는 원의 가장 바깥쪽에 위치한다.

 

설정 컴포넌트는 우리가 제공한 조각들로 애플리케이션을 조립하는 것을 책임지며 다음과 같은 역할을 수행해야한다.

  • 웹 어댑터 인스턴스 생성
  • HTTP 요청이 실제로 웹 어댑터로 전달되도록 보장
  • 유스케이스 인스턴스 생성
  • 웹 어댑터에 유스케이스 인스턴스 제공
  • 영속성 어댑터 인스턴스 생성
  • 유스케이스에 영속성 어댑터 인스턴스 제공
  • 영속성 어댑터가 실제로 데이터베이스에 접근할 수 있도록 보장

 

더불어 설정 컴포넌트는 설정 파일이나 커맨드라인 파라미터 등과 같은 설정 파라미터의 소스에도 접근할 수 있어야 한다. 애플리케이션이 조립되는 동안 설정 컴포넌트는 이러한 파라미터를 애플리케이션 컴포넌트에 제공해서 어떤 데이터베이스에 접근하고 어떤 서버를 메일 전송에 사용할지 드으이 행동 양식을 제어한다.

 

보다시피 책임이 굉장히 많다. 이것은 단일 책임 원칙을 위반하는게 아닐까? 위반하는게 맞다. 그러나 애플리케이션의 나머지 부분을 깜끌하게 유지하고 싶다면 이처럼 구성요소들을 연결하는 바깥쪽 컴포넌트가 필요하다. 그리고 이 컴포넌트는, 작동하는 애플리케이션으로 조립하기 위해 애플리케이션을 구성하는 모든 구성요소를 알아야 한다.

 

평범한 코드로 조립하기


애플리케이션을 조립할 책임이 있는 설정 컴포넌트를 구현하는 방법은 여러 가지다. 의존성 주입 프레임워크의 도움 없이 애플리케이션을 만들고 있다면 평범한 코드로 이러한 컴포넌트를 만들 수 있다.

package copyeditor.configuration;

public class Application {
    public static void main(String[] args) {
    
        AccountRepository accountRepository = new AccountRepository();
        ActivityRepository activityRepository = new ActivityRepository();

        AccountPersistenceAdapter accountPersistenceAdapter = new AccountPersistenceAdapter(accountRepository, activityRepository);

        SendMoneyUseCase sendMoneyUseCase = new SendMoneyService(
                accountPersistenceAdapter,
                accountPersistenceAdapter
        );

        SendMoneyController sendMoneyController = new SendMoneyController(sendMoneyUseCase);
        
        startProcessingWebRequests(sendMoneyController);
    }
}
  • 설정 컴포넌트의 모습을 관략하게 보여주는 예시
  • main 메서드 안에서 웹 컨트롤러부터 영속성 어댑터까지, 필요한 모든 클래스의 인스턴스로 생성한 후 함께 연결

 

단점

  • 앞의 코드는 웹 컨트롤러, 유스케이스, 영속성 어댑터가 단 하나씩만 있는 애플리케이션 예시
    • 완전한 애플리케이션 실행하기 위해서는 이러한 코드를 엄청나게 많이 만들어야 한다.
  • 각 클래스가 속한 패키지 외부에서 인스턴스를 생성하기 때문에 이 클래스들은 전부 public 이어야 한다.
    • 유스케이스가 영속성 어댑터에 직접 접근하는 것을 막지 못한다.
    • package-private 이용이 가능하다면 좋았을 것이다.

 

이러한 단점을 해결해주고 대신해주는 의존성 주입 프레임워크인 spring framewrok가 있다.

 

스프링의 클래스패스 스캐닝으로 조립


스프링 프레임워크를 이용해서 애플리케이션을 조립한 결과물을 애플리케이션 컨텍스트라고 한다. 애플리케이션 컨텍스트는 애플리케이션을 구성하는 모든 객체(빈)을 포함한다. 스프링은 애플리케이션 컨텍스트를 조립하기 위한 몇 가지 방법을 제공하는데, 각각 장단점이 있다.

 

일단 클래스패스 스캐닝을 살펴보자.

 

클래스패스 스캐닝

  • 클래스패스에서 접근 가능한 모든 클래스를 확인해서 @Component 애너테이션이 붙은 클래스를 찾는다.
  • 해당 애너테이션이 붙은 클래스이 객체를 생성한다.
  • 이 때 클래스는 필요한 모든 필드를 인자로 받는 생성자를 가지고 있어야 한다.
@RequiredArgsConstructor
@PersistenceAdapter
class AccountPersistenceAdapter implements
        LoadAccountPort,
        UpdateAccountStatePort {

    private final AccountRepository accountRepository;
    private final ActivityRepository activityRepository;
    private final AccountMapper accountMapper;

    @Override
    public Account loadAccount(
                    AccountId accountId,
                    LocalDateTime baselineDate) {
        // ...
      }

    @Override
    public void updateActivities(Account account) {
        // ...
    }
}

 

혹은 스프링이 인식할 수 있는 애너테이션을 직접 만들수도 있다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface PersistenceAdapter {

    @AliasFor(annotation = Component.class)
    String value() default "";
}
  • 코드를 읽는 사람들은 아키텍처를 더 쉽게 파악할 수 있다.

 

단점

  • 클래스에 프레임워크에 특화된 애너테이션을 붙여야 한다는 점에서 침투적이다.
    • 강경한 클린 아키텍처파는 이런 방식이 코드를 특정한 프레임워크와 결합시킨다고 할 것이다.
    • 애플리케이션 개발이 아닌 다른 개발자들이 사용할 라이브러리나 프레임워크를 만드는 입장에서는 사용하지 말아야 할 방법이다.
  • 애플리케이션에 존재하는 모든 클래스 중 컨텍스트에 실제로 올라가지 않았으면 하는 클래스가 올라가는 등 실수가 유발될 수 있다.

 

스프링의 자바 컨피그로 조립하기


자바 컨피그를 이용하는 방법은 클래스패스 스캐닝보다 좀 더 세밀하게 설정할 수 있다. 애플리케이션 컨텍스트에 추가할 빈을 생성하는 설정 클래스를 만든다.

@Configuration
@EnableJpaRepositories
class PersistenceAdapterConfiguration {

    @Bean
    AccountPersistenceAdapter accountPersistenceAdapter(
            AccountRepository accountRepository,
            ActivityRepository activityRepository,
            AccountMapper accountMapper
    ) {
        return new AccountPersistenceAdapter(
                accountRepository,
                activityRepository,
                accountMapper
        );
    }

    @Bean
    AccountMapper accountMapper() {
        return new AccountMapper();
    }
}
  • @Configuration을 통해 스캐닝을 가지고 찾아야 하는 설정 클래스임을 표시
    • 클래스패스 스캐닝을 사용하지만 설정 클래스만 선택하기 때문에 발견하기 어려운 에러가 일어날 확률이 줄어든다.
  • 리포지토리 객체들은 @EnableJpaRepositories 애너테이션으로 인해 스프링이 직접 생성해서 제공
  • @EnableJpaRepositories와 같은 기능 애너테이션을 별도의 설정 모듈로 옮기는 편이 애플리케이션을 더 유연하게 만들고, 효율적이다.
    • 다른 모듈은 모킹해서 애플리케이션 컨텍스트를 만들 수 있으므로 테스트에도 큰 유연성이 생긴다.

 

단점

  • 설정 클래스가 생성하는 빈이 설정 클래스와 같은 패키지에 존재하지 않는 다면 이 빈들을 public으로 만들어야 한다.
  • 가시성을 제한하기 위해 패키지를 모듈 경계로 사용하고 각 패키지 안에 전용 클래스를 만들 수 있지만, 하위 패키지를 사용할 순 없다.(10장)

 

요약


클래스패스 스캐닝은 아주 편리한 기능이다. 하지만 코드의 규모가 커지면 금방 투명성이 낮아진다. 어떤 빈이 애플리케이션 컨텍스트에 올라오는지 정확히 알 수 없게 된다. 또, 테스트에서 애플리케이션 컨텍스트의 일부만 독립적으로 띄우가기 어려워진다.

 

반면, 애플리케이션 조립을 책임지는 전용 설정 컴포넌트를 만들면 애플리케이션이 이러한 책임으로부터 자유로워진다. 이 방식을 이용하면 서로 다른 모듈로부터 독립되어 코드 상에서 손쉽게 옮겨 다닐 수 있는 응집도가 매우 높은 모듈을 만들 수 있다.

728x90
728x90

 

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

모델 매핑에 대한 논쟁


이제 각 계층의 어떤 역할을 하는지에 대해 다뤄봤으므로 늘상 겪는 문제인 각 계층의 모델을 매핑하는 것에 대해서 다뤄보자. 매퍼 구현을 피하기 위해 두 계층에서 같은 모델을 사용하는 것에 대해 아래와 같이 논의해본적이 있을 것이다.

 

 

매핑에 찬성하는 개발자

  • 두 계층 간에 매핑을 하지 않으면 양 계층에서 같은 모델을 사용해야 하는데 이렇게 되면 두 계층이 강하게 결합됩니다.

 

매핑에 반대하는 개발자

  • 하지만 두 계층 간에 매핑을 하게 되면 보일러플레이트 코드를 너무 많이 만들게 돼요. 많은 유스케이스들이 오직 CRUD만 수행하고 계층에 걸쳐 같은 모델을 사용하기 때문에 계층 사이의 매핑은 과합니다.

두 개발자 모두 일정 부분 맞기 때문에 몇 가지 매핑 전략을 알아보자.

 

‘매핑하지 않기’ 전략


첫 번째 전략은 ‘No Mapping’ 전략이다.

  • ‘송금하기’ 유스케이스와 관련된 요소들
  • 포트 인터페이스가 도메인 모델을 입출력 모델로 사용하면 두 계층 간의 매핑을 할 필요가 없다.

 

웹 계층에서는 웹 컨트롤러가 SendMoneyUseCase 인터페이스를 호출해서 유스케이스를 실행한다. 이 인터페이스는 Account 객체를 인자로 가진다. 즉, 웹 계층과 애플리케이션 계층 모두 Account 클래스에 접근하여 두 계층이 같은 모델을 사용하는 것을 의미한다.

 

반대쪽의 영속성 계층과 애플리케이션 계층도 같은 관계다. 모든 계층이 같은 모델을 사용하니 계층 간 매핑을 전혀할 필요가 없다.

 

단점

  • 도메인과 애플리케이션 계층은 웹이나 영속성과 관련된 요구사항에 관심이 없음에도 불구하고 모든 요구사항을 다뤄야 한다.
    • 단일 책임 원칙을 위반
  • Account 클래스에 오로지 특정 계층에서만 필요한 필들들을 포함하는 파편화된 도메인 모델로 이어질 수 있음

 

그러나 (조금 지저분하게 느껴질 수는 있지만) 모든 계층이 정확히 같은 구조의, 정확히 같은 정보를 필요로 하는 경우에는 ‘매핑하지 않기’ 전략은 완벽한 선택지다. 하지만 애플리케이션 계층이나 도메인 계층에서 웹과 영속성 문제를 다루게 되면 곧바로 다른 전략을 취해야 한다.

 

이 말은 어떤 매핑 전략을 선택했더라도 나중에 언제든 바꿀 수 있다는 것이다. 경험상 많은 유스케이스들이 간단한 CRUD 유스케이스로 시작했다가 시간이 지남에 따라 값비싼 매핑 전략이 필요한, 풍부한 행동과 유효성 검증을 가진 제대로 된 비즈니스 유스케이스로 변경되었기 때문이다. 영원히 간다한 CRUD 유스케이스로 남는다면 다른 매핑 전략에 시간을 들이지 않았기 떄문에 이것 역시 반가운 일이다.

 

‘양방향’ 매핑 전략


각 계층이 전용 모델을 가진 매핑 전략을 ‘Two-Way’ 매핑 전략이라고 한다.

  • 각 어댑터가 전용 모델을 가지고 있어서 해당 모델을 도메인 모델로, 도메인 모델을 해당 모델로 매핑할 책임을 가지고 있다.

 

각 계층은 도메인 모델과는 완전히 다른 구조의 전용 모델을 가지고 있다. 두 계층 모두 양방향으로 매핑하기 때문에 ‘양방향’ 매핑이라고 부른다.

  • 웹 계층에서는 웹 모델을 인커밍 포트에서 필요한 도메인 모델로 매핑하고, 인커밍 포트에 의해 반환된 도메인 객체를 다시 웹 모델로 매핑한다.
  • 영속성 계층은 아웃고잉 포트가 사용하는 도메인 모델과 영속성 모델 간의 매핑과 유사한 매핑을 담당한다.

 

장점

  • 각 계층이 전용 모델을 가지고 있는 덕분에 각 계층이 전용 모델을 변경하더라도 다른 계층에는 영향이 없다.
    • 웹 모델은 데이터를 최적으로 표현할 수 있는 구조를 가질 수 있다.
    • 도메인 모델은 유스케이스를 제일 잘 구현할 수 있는 구조를 가질 수 있다.
    • 영속성 모델은 데이터베이스에 객체를 저장하기 위해 ORM에서 필요로 하는 구조를 가질 수 있다.
  • 이 매핑 전략은 웹이나 영속성 관심사로 오염되지 않은 깨끗한 도메인 모델로 이어진다. JSON이나 ORM 매핑 애너테이션도 없어도 된다.
    • 단일 책임 원칙을 만족하는 것이다.
  • 개념적으로는 ‘매핑하지 않기’ 전략 다음으로 간단한 전략이라는 것이다.
    • 매핑 책임이 명확하다.
    • 바깥쪽 계층/어댑터는 안쪽 계층의 모델로 매핑하고, 다시 반대 방향으로 매핑한다.
    • 안쪽 계층은 해당 계층의 모델만 알면 되고 매핑 대신 도메인 로직에 집중할 수 있다.

 

단점

  • 너무 많은 보일러플레이트 코드가 생긴다.
    • 매핑 프레임워크를 사용하더라도 매핑 구현에 꽤 시간이 든다.
    • 매핑 프레임워크가 내부 동작 방식을 제네릭 코드와 리플렉션 뒤로 숨길 경우 매핑 로직 디버깅도 고통스럽다.
  • 도메인 모델이 계층 경계를 넘어서 통신하는 데 사용되고 있다.
    • 인커밍 포트와 아웃고잉 포트는 도메인 객체를 입력 파라미터와 반환값으로 사용한다.
    • 바깥쪽 계층의 요구에 따른 변경에 취약해진다.

 

양방향 매핑 전략도 silver bullet이 아니다. 따라서 어떤 매핑 전략도 철칙처럼 여겨져셔는 안 된다. 각 유스케이스마다 적절한 전략을 택할 수 있어야 한다.

 

‘완전’ 매핑 전략


또 다른 매핑 전략은 ‘Full’ 매핑 전략이다.

  • 각 연산이 전용 모델을 필요로 하기 때문에 웹 어댑터와 애플리케이션 계층 각각이 자신의 전용 모을 각 연산을 실행하는 데 필요한 모델로 매핑한다.

이 매핑 전략에서는 각 연산마다 별도의 입출력 모델을 사용한다. 계층 경계를 넘어 통신할 때 도메인 모델을 사용하는 대신 SendMoneyUseCase 포트의 입력 모델로 동작하는 SendMoneyCommand 처럼 각 작업에 특화된 모델을 사용한다. 이런 모델을 가리켜 ‘command’, ‘requset’ 혹은 이와 비슷한 단어로 표현한다.

  • 웹 계층은 입력을 애플리케이션 계층의 커맨드 객체로 매핑할 책임을 가지고 있다.
    • 이러한 커맨드 객체는 애플리케이션 계층의 인터페이스를 명확하게 만들어 준다.
    • 각 유스케이스는 전용 필드와 유효성 검증 로직을 가진 전용 커맨드를 가진다.
  • 애플리케이션 계층은 커맨드 객체를 유스케이스에 따라 도메인 모델을 변경하기 위해 필요한 무엇인가로 매핑할 책임을 가진다.

 

장점

  • 계층간 통신에 도메인 모델을 사용하지 않는다.
  • 여러 유스케이스의 요구사항을 함께 다뤄야 하는 매핑에 비해 구현과 유지보수과 더 쉽다.

 

단점

  • 한 계층을 다른 여러 개의 커맨드로 매핑하는 데는 하나의 웹 모델과 도메인 모델 간의 매핑보다 더 많은 코드가 필요하다.
  • 애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드가 존재한다.

 

이 매핑 전략을 전역 패턴으로는 추천하지 않는다. 이 전략은 웹 계층(혹은 인커밍 어댑터 종류 중 아무거나)과 애플리케이션 계층 사이에서 상태 변경 유스케이스의 경계를 명확하게 할 때 가장 빛을 발한다. 하지만 애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드가 때문에 사용하지 않는 것이 좋다.

 

또한 어떤 경우에는 연산의 입력 모델에 대해서만 이 매핑을 사용하고, 도메인 객체를 그대로 출력 모델로 사용하는 것도 좋다.

  • ex) SendMoneyUseCase가 업데이트된 잔고를 가진 채로 Account 객체를 반환하는 것

이 처럼 매핑 전략은 여러 가지를 섞어쓸 수 있고, 섞어 써야만 한다. 어떤 매핑 전략도 모든 계층에 걸쳐 전역 규칙일 필요가 없다.

 

‘단방향’ 매핑 전략


마지막으로 ‘One-Way’ 전략이다.

  • 동일한 ‘상태’ 인터페이스를 구현하는 도메인 모델과 어댑터 모델을 이용하면 각 계층은 다른 계층으로부터 온 객체를 단방향으로 매핑하기만 하면 된다.

이 전략에서는 모든 계층의 모델들이 같은 인터페이스를 구현한다. 이 인터페이스는 관련 있는 특성(attribute) 대한 getter 메서드를 제공해서 도메인 모델의 상태를 캡슐화 한다.

 

도메인 모델 자체는 풍부한 행동을 구현할 수 있고, 애플리케이션 계층 내의 서비스에서 이러한 행동에 접근할 수 있다. 도메인 객체를 바깥 계층으로 전달하고 싶으면 매핑 없이 할 수 있다. 왜냐하면 도메인 객체가 인커밍/아웃고잉 포트가 기대하는 대로 상태 인터페이스를 구현하고 있기 때문이다.

 

그러고 나면 바깥 계층에서는 상태 인터페이스를 이용할지, 전용 모델로 매핑해야 할지 결정할 수 있다. 행동을 변경하는 것이 상태 인터페이스에 의해 노출돼 있지 않기 때문에 실수로 도메인 객체의 상태를 변경하는 일은 발생하지 않는다.

 

바깥 계층에서 애플리케이션 계층으로 전달하는 객체들도 이 상태 인터페이스를 구현하고 있다. 애플리케이션 계층에서는 이 객체를 실제 도메인 모델로 매핑해서 도메인 모델의 행동에 접근할 수 있게 된다. 이 매핑은 factory 라는 DDD 개념과 잘 어울린다. DDD 용어인 팩터리는 어떤 특정한 상태로부터 도메인 객체를 재구성할 책임을 가지고 있다.

 

이 전략에서 매핑 책임은 명확하다. 만약 한 계층이 다른 계층으로부터 객체를 받으면 해당 계층에서 이용할 수 있는 다른 무언가로 매핑하는 것이다. 그러므로 각 계층은 한 방향으로만 매핑한다. 그래서 단방향 매핑 전략인 것이다.

 

하지만 매핑이 계층을 넘나들며 퍼져 있기 때문에 이 전략은 다른 전략에 비해 개념적으로 어렵다. 이 전략은 계층 간의 모델이 비슷할 때 가장 효과적이다. 예를 들어, 읽기 전용 연산의 경우 상태 인터페이스가 필요한 모든 정보를 제공하기 때문에 웹 계층에서 전용 모델로 매핑할 필요가 전혀 없다.

 

언제 어떤 매핑 전략을 사용할 것인가?


이 질문의 답은 평범하고 슬플게도 ‘그때 그때 다르다’ 이다. 각 매핑 전략이 저마다 장단점을 갖고 있기 때문에 한 전략을 전체 코드에 대한 전역 규칙으로 정의하려는 충동을 이겨내야 한다.

 

언제 어떤 전략을 사용할지 결정하려면 팀 내에서 합의할 수 있는 가이드라인을 정해둬야 한다. 이 가이드라인은 어떤 상황에서 어떤 매핑 전략을 가장 먼저 택해야 하는가와 왜 해당 전략을 우선시하는지도 설명할 수 있어야 한다.

 

예를 들어, 변경 유스케이스와 쿼리 유스케이스에 서로 다른 매핑 가이드라인을 정해뒀다고 해보자. 또 웹 계층과 애플리케이션 계층 사이에서 사용할 매핑 전략과 애플리케이션 계층과 영속성 계층 사이에서 사용할 매핑 전략을 다르게 세웠다고 가정해보자.

 

변경 유스케이스

  • 웹 계층과 애플리케이션 계층 사이에서는 ‘완전 매핑’ 전략을 첫 번째 선택지로 택해야 한다.
    • 유스케이스간의 결합을 제거하여 유스케이스별 유효성 검증 규칙이 명확해지고 특정 유스케이스에서 필요하지 않은 필드를 다루지 않아도 된다.
  • 애플리케이션과 영속성 계층 사이에서는 ‘매핑하지 않기’ 전략을 첫 번째 선택지로 둔다.
    • 매핑 오버헤드를 줄이고 빠르게 코드를 짜기 위해서
    • 만약 애플리케이션 계층에서 영속성 문제를 다뤄야 하게 되면 ‘양방향’ 매핑 전략으로 변경하여 영속성 문제를 영속성 계층에 가둘 수 있게 한다.

 

쿼리 유스케이스

  • 매핑 오버헤드를 줄이고 빠르게 코드를 짜기 위해 ‘매핑하지 않기’ 전략이 웹 계층과 애플리케이션 계층 사이, 애플리케이션 계층과 영속성 계층 사이 첫 번째 선택지가 되어야 한다.
  • 하지만 마찬가지로 만약 애플리케이션 계층에서 영속성 문제나 웹 문제를 다뤄야 하게 되면 웹 계층과 애플리케이션 계층 사이, 애플리케이션 계층과 영속성 계층 사이 각각 ‘양방향’ 매핑 전략으로 변경해야 한다.

 

요약


계층 사이에서 문지기처럼 동작하는 인커밍 포트와 아웃고잉 포트는 서로 다른 계층이 어떻게 통신해야 하는지를 정의한다. 여기서는 계층 사이에 매핑을 수행할지 여부와 어떤 매핑 전략을 선택할지가 포함된다.

 

각 유스케이스에 대해 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략을 사용할 수 있고, 다른 유스케이스에 영향을 미치지 않으면서 코드를 개선할 수 있기 때문에 특정 상황, 특정 시점에 최선의 전략을 선택할 수 있다.

 

상황별로 매핑 전략을 선택하는 것은 전력 매핑 전략을 사용하는 것보다 어렵고 더 많은 커뮤니케이션을 필요로 하겠지만 매핑 가이드라인이 있는 한, 더 유지보수하기 쉬운 코드가 될 것이다.

 

728x90
728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다

 

테스트 피라미드


 

헥사고날 아키텍처에서의 테스트 전략에 대해 알아보자. 아키텍처의 각 요소들을 테스트할 수 있는 유형은 무엇일까? 테스트 피라미드에 따르면 비용이 많이 드는 테스트는 지양하고 비용이 적게 드는 테스트를 많이 만들어야 한다.

테스트 피라미드

 

테스트 피라미드는 몇 개의 테스트와 어떤 종류의 테스트를 목표로 해야 하는지 결정하는데 도움을 준다. 기본 전제는 만드는 비용이 적고, 유지보수하기 쉽고, 빨리 실행되고, 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다는 것이다. 이 테스트는 하나의 단위(일반적으로 하나의 클래스)가 제대로 동작하는지 확인할 수 있는 단위 테스트들이다.

 

여러 개의 단위와 단위를 넘는 경계, 아키텍처 경계, 시스템 경계를 결합하는 테스트는 만드는 비용이 더 비싸지고, 실행이 더 느려지며(설정 에러로 인해) 깨지기 더 쉬워진다. 테스트 피라미드는 테스트가 비싸질수록 테스트의 커버리지 목표는 낮게 잡아야 한다는 것을 보여준다. 그렇지 않으면 새로운 기능을 만드는 것보다 테스트를 만드는 데 시간을 더 쓰게 되기 때문이다.

 

맥락에 따라 테스트 피라미드에 포함되는 계층은 달라질 수 있다. 헥사고날 아키텍처를 테스트하기 위해 내가 선택한 계층들을 한번 살펴보자. ‘단위 테스트’, ‘통합 테스트’, ‘시스템 테스트’의 정의는 맥락에 따라 다르다는 것을 알아두자. 이 말은 프로젝트마다 다른 의미를 가질 수 있다는 뜻이다.

 

단위 테스트는 피라미드의 토대에 해당한다. 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. 만약 테스트 중인 클래스가 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 Mock으로 대체한다.

 

피라미드의 다음 계층은 통합 테스트이다. 이 테스트는 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한 대로 잘 동작하는지 검증한다. 이 책에서 정의한 통합 테스트에서는 두 계층 간의 경계를 걸쳐서 테스트할 수 있기 때문에 객체 네트워크가 완전하지 않거나 어떤 시점에는 mock을 대상으로 수행해야 한다.

 

마지막으로 시스템 테스트는 애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다. 시스템 테스트 위에는 애플리케이션의 UI를 포함하는 end-to-end 테스트 층이 있을 수 있다. 하지만 이 책에서는 백엔드 아키텍처에 대해서 논의하고 있으므로 이 e2e 테스트에 대해서는 고려하지 않기로 한다.

 

이제 헥사고날 아키텍처의 각 계층에 가장 적합한 테스트가 어떤 종류인지 살펴보자

 

단위 테스트로 도메인 엔티티 테스트


먼저 헥사고날 아키텍처의 중심인 도메인 엔티티를 살펴보자. Account 엔티티를 예시로 들어보자

Account의 상태는 과거 특정 시점의 계좌 잔고(baselineBalance)와 그 이후의 입출금 내역(activity)으로 구성되어 있다. withdraw() 메서드가 기대한 대로 동작하는지 검증해 보자.

class AccountTest {

	@Test
	void withdrawalSucceeds() {
		// given
		AccountId accountId = new AccountId(1L);
		Account account = defaultAccount()
			.withAccountId(accountId)
			.withBaselineBalance(Money.of(555L))
			.withActivityWindow(new ActivityWindow(
				defaultActivity()
					.withTargetAccount(accountId)
					.withMoney(Money.of(999L)).build(),
				defaultActivity()
					.withTargetAccount(accountId)
					.withMoney(Money.of(1L)).build()))
			.buildQ;
		
		// when
		boolean success = account.withdraw(Money.of(555L), new AccountId(99L));
		
		// then
		assertThat(success).isTrue();
		assertThat(account.getActivityWindow().getActivities()).hasSize(3);
		assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L));
	}
}
  • 특정 상태의 Account를 인스턴스화하고 withdraw() 메서드를 호출해서 출금을 성공했는지 검증하고, 객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인하는 단위 테스트

이 테스트는 만들고 이해하는 것도 쉬우며, 매우 빠르게 실행된다. 이런 식의 단위 테스트가 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 가장 적절한 방법이다. 도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않기 때문에 다른 종류의 테스트는 필요하지 않다.

 

단위 테스트로 유스케이스 테스트


계층의 바깥쪽으로 나가서, 다음으로 유스케이스를 테스트해 보자. SendMoneyService의 테스트를 살펴보자. SendMoneyUseCase 유스케이스는 출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 lock을 건다. 출금 계좌에서 돈이 출금되고 나면 똑같이 입금 계좌의 락을 걸고 돈을 입금시킨다. 그러고 나서 두 계좌에서 모두 락을 해제한다.

class SendMoneyServiceTest {

	// 필드 선언은 생략
	
	@Test
	void transactionSucceeds() {
	
	    // given
		Account sourceAccount = givenSourceAccount();
		Account targetAccount = givenTargetAccount();
		
		givenWithdrawalWillSucceed(sourceAccount);  
		givenDepositWillSucceed(targetAccount);
	
		Money money = Money.of(500L);
		
		SendMoneyCommand command = new SendMoneyCommand(
			sourceAccount.getId(),
			targetAccount.getId(),
			money);
	
	      // when
		boolean success = SendMoneyService.sendMoney(command);
		
		// then
		assertThat(success).isTrue();
		
		AccountId sourceAccountId = sourceAccount.getId();
		AccountId targetAccountId = targetAccount.getId();
		
		then(accountLock).should().lockAccount(eq(sourceAccountId));
		then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
		then(accountLock).should().releaseAccount(eq(sourceAccountId));
		
		then(accountLock).should().lockAccount(eq(targetAccountId));
		then(targetAccount).shouldO.deposit(eq(money), eq(sourceAccountId));
		then(accountLock).should().releaseAccount(eq(targetAccountId));
		
		
		thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
	}

	// 헬퍼 메서드는 생략
}
  • 테스트의 가독성을 높이기 위해 BDD에서 일반적으로 사용되는 방식대로 given/when/then 섹션으로 구분
  • 코드에는 없지만 테스트는 Mockito 라이브러리를 이용해 given..() 메서드의 목 객체를 생성한다.
  • Mockito는 mock 객체에 대해 특정 메서드가 호출됐는지 검증할 수 있는 then() 메서드도 제공한다.

 

테스트 중인 유스케이스 서비스는 stateless 하기 때문에 ‘then’ 섹션에서 특정 상태를 검증할 수 없다. 대신 테스트는 서비스가 모킹 된 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다. 이는 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약해진다는 의미가 된다. 코드가 리팩터링 되면 테스트도 변경될 확률이 높아진다.

 

그렇기 때문에, 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다. 앞의 예제처럼 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는 것이 좋다. 모든 동작을 검증하려고 하면 클래스가 조금이라도 바뀔 때마다 테스트를 변경해야 한다. 이는 테스트의 가치를 떨어뜨리는 일이다.

 

이 테스트는 단위 테스트이긴 하지만 의존성의 상호작용을 테스트하고 있기 때문에 통합 테스트에 가깝다. 그렇지만 목으로 작업하고 있고 실제 의존성을 관리해야 하는 것은 아니기 때문에 완전한 통합 테스트에 비해 만들고 유지보수하기가 쉽다.

 

통합 테스트로 웹 어댑터 테스트


한 계층 더 바깥으로 나가면 어댑터에 도착한다. 웹 어댑터를 테스트해 보자. 웹 어댑터는 JSON 문자열 등의 형태로 HTTP를 통해 입력을 받고, 입력에 대한 유효성 검증을 하고, 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달한다. 그러고 나서 다시 유스케이스의 결과를 JSON으로 매핑하고 HTTP 응답을 통해 클라이언트에 반환했다.

@WebMvcTest(controllers = SendMoneyController.class)
class SendMoneyControllerTest {
	
	@Autowired
	private MockMvc mockMvc;
	
	@MockBean
	private SendMoneyUseCase SendMoneyUseCase;
	
	@Test
	void testSendMoney() throws Exception {
	
		mockMvc.perform(
			post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
			41L, 42L, 500)
				.header("Content-Type", "application/json"))
				.andExpect(status().isOk());
		
		then(sendMoneyUseCase).should()
			.sendMoney(eq(new SendMoneyCommand(
				new AccountId(41L),
				new AccountId(42L),
				Money.of(500L))));
	}
}
  • 웹 어댑터 테스트에서는 앞의 모든 단계들이 기대한 대로 동작하는지 검증해야 한다.
  • @WemMvcTest 애너테이션은 스프링이 특정 요청 경로, 자바와 json 간의 매핑, HTTP 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 만든다.
  • MockMvc 객체를 이용해 모킹 했기 때문에 실제로 HTTP 프로토콜을 통해 테스트한 것은 아니다.
    • 프레임워크를 테스트할 필요는 없다는 생각
  • 입력을 JSON에서 SendMoneyCommand 객체로 매핑하는 전 과정은 다루고 있다.
    • 자체 검증 커맨드로 만들었다면 이 Json 매핑이 유스케이스에 구문적으로 유효한 입력을 생성했는지도 확인하는 것
  • 또한 유스케이스가 실제로 호출되었는지, HTTP 응답이 기대한 상태를 반환했는지도 검증

 

그럼 왜 이 테스트가 단위 테스트가 아닌 통합 테스트일까? 이 테스트에서는 하나의 웹 컨트롤러 클래스만 테스트한 것처럼 보이지만 프레임워크단에서 특정 경로 요청, java와 json 매핑, http 입력 검증 등에 필요한 객체 네트워크를 인스턴스화하도록 만드는 등 많은 코드들이 동작하고 있다. 그리고 테스트에서는 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증한다.

 

이처럼 웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있기 때문에 격리된 상태로 테스트하기보다는 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다. 웹 컨트롤러를 평범한 단위 테스트로 테스트하면 모든 매핑, 유효성 검증, HTTP 항목에 대한 커버리지가 낮아지고, 프레임워크를 구성하는 이런 요소들이 프로덕션 환경에서 정상적으로 작동할지 확신할 수 없게 된다.

 

통합 테스트로 영속성 어댑터 테스트


비슷한 이유로 영속성 어댑터의 테스트에는 단위 테스트보다는 통합 테스트를 적용하는 것이 합리적이다. 단순히 어댑터의 로직만 검증하고 싶은 게 아니라 데이터베이스 매핑도 검증하고 싶기 때문이다.

 

어댑터에는 Account 엔티티를 데이터베스로부터 가져오는 메서드 하나와 새로운 계좌 활동을 데이터베이스에 저장하는 메서드까지 총 2개의 메서드가 있었다.

@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersistenceAdapterTest {

	@Autowired
	private AccountPersistenceAdapter adapterUnderTest;
	@Autowired
	private ActivityRepository activityRepository;

	@Test
	@Sql("AccountPersistenceAdapterTest.sql")
	void loadsAccount() {
	  	// when
		Account account = adapter.loadAccount(
			new AccountId(1L),
			LocalDateTime.of(2018, 8, 10, 0, 0));
			
		// then
		assertThat(account.getActivityWindow().getActivities()).hasSize(2);
		assertThat(account.calculateBalance()).isEqualTo(Money.of(500))j
	}

	@Test
	void updatesActivities() {
		// given
		Account account = defaultAccount()
			.withBaselineBalance(Money.of(555L))
			.withActivityWindow(new ActivityWindow(
				defaultActivity()
			.withId(null)
			.withMoney(Money.of(1L)).build()))
			.build();
	
	  	// when
		adapter.updateActivities(account);
		
		// then
		assertThat(activityRepository.count()).isEqualTo(1);
		
		ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
		assertThat(savedActivity.getAmount()).isEqualTo(1L);
	}
}
  • @DataJpTest 및 @Import 애너테이션을 추가해서 데이터 접근에 필요한 객체 네트워크와 특정 객체 네트워크를 인스턴스화해야 한다고 스프링에게 알려준다.

이 테스트에서는 데이터베이스를 모킹 하지 않았다는 점이 중요하다. 테스트가 실제로 데이터베이스에 접근한다. 만약 데이터베이스를 모킹 했더라도 테스트는 여전히 같은 코드 라인수만큼 커버해서 똑같이 높은 커버리지를 보여줬을 것이다. 하지만 높은 커버리지라도 불구하고 여전히 실제 데이터베이스와 연동했을 때 SQL 구문의 오류나 데이터베이스 테이블과 자바 객체 간의 매핑 에러 등으로 문제가 생길 확률이 높아진다.

 

또한 프로덕션 환경에는 인메모리 데이터베이스를 사용하지 않는 경우가 많기 때문에 인메모리 데이터베이스에서 테스트가 완벽하게 통과했더라도 실제 데이터베이스에서는 문제가 생길 가능성이 높다. 이러한 이유로 영속성 어댑터 테스트는 실제 데이터베이스를 대상으로 진행해야 한다. Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테이너에 띄울 수 있기 때문에 이런 측면에서 아주 유용하다.

 

실제 데이터베이스를 대상으로 테스트를 실행하면 두 개의 다른 데이터베이스 시스템을 신경 쓸 필요가 없다는 장점도 생긴다. 만약 테스트에서 인메모리 데이터베이스를 사용하면 특정 방식으로 데이터베이스를 설정하거나 데이터베이스별로 두 가지 버전의 데이터베이스 마이그레이션 스크립트를 둬야 할 텐데, 절대 그러고 싶진 않을 것이다.

 

시스템 테스트로 주요 경로 테스트


피라미드 최상단에 있는 시스템 테스트는 전체 애플리케이션을 띄우고 API를 통해 요청을 보내고, 모든 계층이 조화롭게 잘 동작하는지 검증한다. ‘송금하기’ 유스케이스의 시스템 테스트에서는 애플리케이션에 HTTP 요청을 보내고 계좌의 잔고를 확인하는 것을 포함해서 응답을 검증한다.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)  
class SendMoneySystemTest {  
  
    @Autowired  
    private TestRestTemplate restTemplate;  
  
  
    @Test  
    @Sql("SendMoneySystemTest.sql")  
    void sendMoney() {  
		// given
        Money initialSourceBalance = sourceAccount().calculateBalance();  
        Money initialTargetBalance = targetAccount().calculateBalance();  
  
		// when
        ResponseEntity response = whenSendMoney(  
                sourceAccountId(),  
                targetAccountId(),  
                transferredAmount());  
  
		// then
        then(response.getStatusCode())  
                .isEqualTo(HttpStatus.OK);  
  
        then(sourceAccount().calculateBalance())  
                .isEqualTo(initialSourceBalance.minus(transferredAmount()));  
  
        then(targetAccount().calculateBalance())  
                .isEqualTo(initialTargetBalance.plus(transferredAmount()));  
  
    }  
  

 
    private ResponseEntity whenSendMoney(  
            AccountId sourceAccountId,  
            AccountId targetAccountId,  
            Money amount) {  
            
        HttpHeaders headers = new HttpHeaders();  
        headers.add("Content-Type", "application/json");  
        HttpEntity<Void> request = new HttpEntity<>(null, headers);  
  
        return restTemplate.exchange(  
                "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",  
                HttpMethod.POST,  
                request,  
                Object.class,  
                sourceAccountId.getValue(),  
                targetAccountId.getValue(),  
                amount.getAmount());
    }
    
    // 일부 헬퍼 메서드는 생략
}
  • @SpringBootTest 애너테이션은 스프링이 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다.
    • 또한 랜덤 포트로 띄우도록 설정하고 있다.
  • 테스트 메서드에서는 요청을 생성해서 애플리케이션에 보내고 응답 상태와 계자와 새로운 잔고를 검증한다.

 

여기서는 웹 어댑터에서처럼 MockMvc를 이용해 요청을 보내는 것이 아니라 TestRestTemplate을 이용해서 요청을 보낸다. 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 하는 것이다.  실제 HTTP 통신을 하는 것처럼 실제 출력 어댑터도 이용한다. 예제에서는 영속성 어댑터뿐이지만, 다른 시스템과 통신하는 애플리케이션의 경우에는 다른 출력 어댑터들도 존재할 수 있다.

 

시스템 테스트라고 하더라도 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야 할 때도 있다. 헥사고날 아키텍처에서는 이러한 경우 몇 개의 출력 포트 인터페이스만 모킹 하면 되기 때문에 쉽게 해결할 수 있다.

 

테스트 가독성을 높이기 위해 지저분한 로직들을 헬퍼 메서드 안으로 감췄다. 이제 이 헬퍼 메서드들은 여러 가지 상태를 검증할 때 사용할 수 있는 도메인 특화 언어(DSL)를 형성한다.

 

도메인 특화 언어

 

이러한 도메인 특화 언어는 어떤 테스트에서도 유용하지만 시스템 테스트에서는 더욱 의미를 가진다. 시스템 테스트는 단위 테스트나 통합 테스트가 할 수 있는 것보다 훨씬 더 실제 사용자를 잘 흉내 내기 때문에 사용자 관점에서 애플리케이션을 검증할 수 있다.

 

적절한 어휘를 사용하면 훨씬 더 쉬워지고 말이다. 어휘를 사용하면 애플리케이션 사용자를 상징하지만 프로그래머는 아닌 도메인 전문가가 테스트에 대해 생각하고 피드백을 줄 수 있다. JGiven 같은 행동 주도 개발을 위한 라이브러리는 테스트용 어휘를 만드는 데 도움을 준다.

 

시스템 테스트의 장점

 

앞서 본 단위 테스트와 통합 테스트를 만들었다면 시스템 테스트는 앞서 커버한 코드와 겹치는 부분이 많을 것이다. 그럼 추가적인 다른 장점도 있을까? 그것은 바로 단위 테스트와 통합 테스트가 발견하는 버그와는 또 다른 종류의 버그를 발견해서 수정할 수 있게 해 준다.

  • ex) 계층 간 매핑 버그

시스템 테스트는 여러 개의 유스케이스를 결합해서 시나리오를 만들 때 더 빛이 난다. 각 시나리오는 사용자가 애플리케이션을 사용하면서 거쳐갈 특정 경로를 의미한다. 시스템 테스트를 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 애플리케이션을 망가뜨리지 않았음을 가정할 수 있고 배포할 준비가 됐다는 확신을 가질 수 있다.

 

그래서 테스트는 얼마큼 해야 할까?


라인 커버리지는 테스트 성공을 측정하는 데 있어서는 잘못된 지표다. 코드의 중요한 부분이 전혀 커버되지 않을 수 있기 때문에 100%를 제외한 어떤 목표도 무의미하다. 100%라 하더라도 버그가 잘 잡혔는지 확신할 수 없다.

 

저자는 얼마나 마음 편하게 배포할 수 있느냐를 테스트 성공 기준으로 삼으면 된다고 말한다. 프로덕션의 버그를 수정하고 이로부터 배우는 것을 우선순위로 삼으면 제대로 가고 있는 것이다. 프로덕션 버그에 대해서 ‘테스트가 왜 이 버그를 잡지 못했을까?’를 생각하고 이에 대한 답변을 기록하고, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다. 시간이 지날수록 남겨둔 기록은 상황이 개선되고 있음을 증명해 줄 것이다.

 

하지만 우리가 만들어야 할 테스트를 정의하는 전략으로 시작하는 것도 좋다.

 

헥사고날 아키텍처에서 사용하는 전략

  • 도메인 엔티티를 구현할 때는 단위 테스트로 커버
  • 유스케이스를 구현할 때는 단위 테스트로 커버
  • 어댑터를 구현할 때는 통합 테스트로 커버
  • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버

‘구현할 때는’이라는 문구에 주목하자. 만약 테스트가 기능 개발 후가 아닌 개발 중에 이뤄진다면 하기 싫은 귀찮은 작업이 아니라 개발 도구로 느껴질 것이다.(TDD)

 

하지만 코드를 변경할 때마다 테스트를 고치는데 시간이 오래 걸린다면 뭔가 잘못된 것일 수 있다. 테스트가 코드의 구조적 변경에 너무 취약할 것이므로 개선점을 찾아봐야 한다. 리팩터링 할 때마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다.

 

요약


헥사고날 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리한다. 덕분에 핵심 도메인 로직은 단위 테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.

 

입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다. 각 포트에 대해 모킹 할지, 실제 구현을 이용할지 선택할 수 있다. 만약 포트가 작고 핵심만 담고 있다면 모킹 하는 것은 쉬울 것이다. 포트 인터페이스가 더 적은 메서드를 제공할수록 어떤 메서드를 모킹해야 할지 덜 헷갈린다.

 

만약 모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 이는 경고 신호다. 이런 측면에서 테스트는 아키텍처의 문제에 대해 경고하고 유지보수 가능한 코드를 만들기 위한 올바른 길로 인도하는 역할도 한다고 할 수 있다.

728x90
728x90

 

Null Object Pattern이란? 


객체가 존재 하지 않는 경우의 처리를 위해 많은 경우 if (ref == null) 혹은 try catch 구문을 사용해서 예외 처리를 한다.

Null Object Pattern이란 유저가 직접 null check를 하지 않고, 정의된 액션을 수행을 하지만 수행의 내용은 비어있는 빈 객체를 기본으로 제공해 줌으로서 예외 처리를 사전에 피하는 안전한 방식의 프로그래밍 패턴이다.


Null 객체의 의도는 적절한 기본 동작을 제공하는 대체 가능한 대안을 제공하여 객체의 부재를 캡슐화하기 위한 것이다. 간단히 말해, "아무것도 없는 것에서 아무것도 나오지 않는" 디자인이다.

특히 DB에서 데이터를 가지고 오는 경우 id값에 따라 null을 가져올 가능성이 매우 높고, 이를 미리 예측하기도 힘든 경우가 많다. 이럴 경우 예외처리를 하는 것은 가독성을 떨어뜨릴 수 있어 Null Object Pattern을 사용해서 NullPointerException을 방지한다.

 

 

활용 예시


Collections.emptySet, Collections.emptyList, Spring 프레임워크의 Pageable의 unpaged

package org.springframework.data.domain;

import java.util.Optional;

import org.springframework.util.Assert;

/**
 * Abstract interface for pagination information.
 *
 * @author Oliver Gierke
 */
public interface Pageable {

  /**
   * Returns a {@link Pageable} instance representing no pagination setup.
   *
   * @return
   */
  static Pageable unpaged() {
    return Unpaged.INSTANCE;
  }

  // 이하 생략
  • Unpaged.INSTANCE 가 널 객체 패턴이다.
package org.springframework.data.domain;

enum Unpaged implements Pageable {

  INSTANCE;

  @Override
  public boolean isPaged() {
    return false;
  }

  @Override
  public Pageable previousOrFirst() {
    return this;
  }

  @Override
  public Pageable next() {
    return this;
  }

  @Override
  public boolean hasPrevious() {
    return false;
  }

  @Override
  public Sort getSort() {
    return Sort.unsorted();
  }

  @Override
  public int getPageSize() {
    throw new UnsupportedOperationException();
  }

  @Override
  public int getPageNumber() {
    throw new UnsupportedOperationException();
  }

  @Override
  public long getOffset() {
    throw new UnsupportedOperationException();
  }

  @Override
  public Pageable first() {
    return this;
  }
}
  • 실제 코드를 확인해보면  실제 객체와 똑같은 모든 메시지에 응답하게 되어있으며 문제가 되는 메서드에서는  예외를 던지게 되어 있다.

 

널 오브젝트 패턴의 장단점


장점

  • 클라이언트 코드에서 null 값을 비교하는 구문을 최소화 할수 있다.
  • 라이브러리를 제공하는 측면에서 안정성있는 API를 제공할수 있다.
  • 기존에 존재하는 어플리케이션 코드의 변함없이 기능을 제거하고자 할 때 

단점

  • Null 객체를 요구하는 클래스마다 인터페이스를 선언하고 이를 구현하는 Null 객체를 만들어주어야 한다. 
  • 클라이언트에서 Null 객체의 존재를 모르는 경우, 디버깅시에 약간의 혼선이 올 수 있다.
728x90
728x90

spring data mongo를 사용하여 MongoTemplate 를 이용하여 쿼리를 작성하다 보면 아래와 같이 필드값에 대해 string 값을 사용하게 된다.

Criteria.where("_id").`is`(Entity.id)

하지만 이렇게 쿼리를 사용하다 보면 type-safe 하지 않다는 점과 실제로 해당 엔티티에 존재하는 필드인지 오탈자는 없는지 실행되는 순간까지 확인할 수 없으므로 위험도는 증가한다.

private class KPropertyPath<T, U>(
	val parent: KProperty<U?>,
	val child: KProperty1<U, T>
) : KProperty<T> by child

/**
 * Recursively construct field name for a nested property.
 * @author Tjeu Kayim
 */
internal fun asString(property: KProperty<*>): String {
	return when (property) {
		is KPropertyPath<*, *> ->
			"${asString(property.parent)}.${property.child.name}"
		else -> property.name
	}
}

/**
 * Builds [KPropertyPath] from Property References.
 * Refer to a nested property in an embeddable or association.
 *
 * For example, referring to the field "author.name":
 * ```
 * Book::author / Author::name isEqualTo "Herman Melville"
 * ```
 * @author Tjeu Kayim
 * @author Yoann de Martino
 * @since 2.5
 */
operator fun <T, U> KProperty<T?>.div(other: KProperty1<T, U>): KProperty<U> =
	KPropertyPath(this, other)

728x90

'spring' 카테고리의 다른 글

WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02
728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

의존성 역전


 

의존성을 역전시키기 위해 영속성 계층을 애플리케이션 계층의 플러그인으로 만드는 방법을 살펴보자

 

영속성 계층 대신 애플리케이션 서비스에 영속성 기능을 제공하는 영속성 어댑터에 대하 알아보자.

 

코어의 서비스가 영속성 어댑터에 접근하기 위해 포트를 사용한다.

 

애플리케이션 서비스에서는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다. 이 포트는 실제로 영속성 작업을 수행하고 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현된다.

 

헥사고날 아키텍처에서 영속성 어댑터는 ‘주도되는’ 혹은 ‘아웃고잉’ 어댑터다. 애플리케이션에 의해 호출될 뿐, 애플리케이션을 호출하지는 않기 때문이다. 포트는 사실상 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계층이다. 영속성 문제에 신경 쓰지 않고 도메인 코드를 개발하기 위해, 즉 영속성 계층에 대한 코드 의존성을 없애기 위해 이러한 간접 계층을 추가하고 있다는 사실을 잊지 말자. 이렇게 되면 영속성 코드를 리팩토링하더라도 코어 코드를 변경하는 결과로 이어지지 않을 것이다.

 

자연스럽게 런타임에도 의존성은 애플리케이션 코어에서 영속성 어댑터로 향한다. 예를 들어, 영속성 계층의 코드를 변경하는 중에 버그가 생기면 애플리케이션 코어의 기능은 망가질 것이다. 하지만 포트가 계약을 만족하는 한, 코어에 영향을 미치지 않으면서 영속성 코드를 마음껏 수정할 수 있다.

 

영속성 어댑터의 책임


일반적으로 영속성 어댑터가 하는 일들을 살펴보자

  1. 입력을 받는다.
  2. 입력을 데이터베이스 포맷으로 매핑한다.
  3. 입력을 데이터베이스로 보낸다.
  4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다.
  5. 출력을 반환한다.

 

영속성 어댑터는 포트 인터페이스를 통해 입력을 받는다. 입력 모델은 인터페이스가 지정한 도메인 엔티티나 특정 데이터베이스 연산 전용 객체가 될 것이다.

 

그러고 나서 영속성 어댑터는 데이터베이스를 쿼리하거나 변경하는 데 사용할 수 있는 포맷으로 입력 모델을 매핑한다. 자바 프로젝트에서는 데이터베이스와 통신할 때 일반적으로 JPA를 사용하기 때문에 입력 모델을 데이터베이스 테이블 구조를 반영한 JPA 엔티티 객체로 매핑할 것이다. 맥락에 따라 입력 모델을 JPA 엔티티로 매핑하는 것이 들이는 노력에 비해 얻는 것이 많지 않은 일이 될 수도 있다. (8장)

 

JPA나 다른 객체-관계 매핑 프레임워크 대신, 데이터베이스와 통신하기 위해 어떤 기술을 사용해도 상관없다. 입력 모델을 평범한 SQL 구문에 매핑해서 데이터베이스에 보내도 되고, 들어오는 데이터를 파일로 직렬화해서 그것으로부터 데이터를 읽어와도 된다.

 

핵심은 영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있는 것이 아니래 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다는 것이다.

 

다음으로 영속성 어댑터는 데이터베이스에 쿼리를 날리고 쿼리 결과를 받아온다. 마지막으로, 데이터베이스 응답을 포트에 정의된 출력 모델로 매핑해서 반환한다. 가장 중요한 점은 출력 모델이 영속성 어댑터가 아니라 애플리케이션 코어에 위치하는 점이다.

 

입출력 모델이 영속성 어댑터가 아니라 애플리케이션 코어에 있다는 점을 제외하면 책임은 전통적인 영속성 계층의 책임과 크게 다르지 않다. 그러나 전통적인 영속성 계층을 구현할 때에는 없었던 몇 가지 의문들이 생기는데 한번 살펴보자.

 

포트 인터페이스 나누기


서비스를 구현하면서 생기는 의문은 데이터베이스 연산을 정의하고 있는 포트 인터페이스를 어떻게 나눌 것인가다.

 

  • 특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 레포지토리 인터페이스에 넣어 두는 일반적인 방법
  • 하지만 하나의 넓은 아웃고잉 포트 인터페이스에 모든 데이터베이스 연산을 모아두면 모든 서비스가 실제로는 필요하지 않은 메서드에 의존하게 된다.
  • 이렇게 되면 특정 서비스의 단위 테스트를 작성하거나 코드의 가독성이 떨어진다.
필요없는 화물을 운반하는 무언가에 의존하고 있으면 예상하지 못했던 문제가 생길 수 있다. - 로버트 C. 마틴

 

인터페이스 분리 원칙(ISP)은 이 문제의 답을 제시한다. ISP 원칙은 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다. 해당 원칙을 아웃고잉 포트에 적용해보자.

  • 인터페이스 분리 원칙을 적용하면 불필요한 의존성을 제거하고 기존 의존성을 눈에 더 잘 띄게 만들수 있다.
  • 포트의 이름이 포트의 역할을 명확하게 잘 표현하고 있기 때문에 테스트에서는 어떤 메서드를 모킹할지 고민할 필요가 없다.
    • 대부분의 경우 포트당 하나의 메서드
  • 이렇게 매우 좁은 포트를 만드는 것은 코딩을 플러그 앤드 플레이 경험으로 만든다.
    • 서비스 코드를 짤 때는 필요한 포트에 그저 꽂기만 하면 된다.
  • 물론 모든 상황에 ‘포트 하나당 하나의 메서드’를 적용하지는 못할 것이다.
    • 응집성이 높고 함께 사용될 때가 많기 때문에 하나의 인터페이스에 묶고 싶은 데이터베이스 연산들이 있을 수 있다.

 

영속성 어댑터 나누기


이전에는 모든 영속성 포트를 구현한 하나의 영속성 어댑터 클래스가 있었다. 그러나 모든 영속성 포트를 구현하는 한, 하나 이상의 클래스 생성을 금지하는 규칙은 없다.

 

따라서 아래 그림과 같이 영속성 연산이 필요한 도메인 클래스( 혹은 DDD에서의 애그리거트) 하나당 하나의 영속성 어댑터를 구현하는 방식을 선택할 수 있다.

  • 하나의 애그리거트당 하나의 영속성 어댑터를 만들어서 여러 개의 영속성 어댑터를 만들 수도 있다.
  • 영속성 어댑터들은 각 영속성 기능을 이용하는 도메인 경계를 따라 자동으로 나눠진다.
  • 영속성 어댑터를 훨씬 더 많은 클래스로 나눌 수도 있다.
    • JPA 혹은 ORM을 이용한 영속성 포트도 구현하면서 성능을 개선하기 위해 평범한 SQL를 이용하는 다른 종류의 포트도 함께 구현하는 경우
    • 그 후 JPA 어댑터 하나와 평이한 SQL 어댑터 하나를 만들고 각각이 영속성 포트의 일부분을 구현

가장 중요한 점은 도메인 코드는 영속성 포트에 의해 정의된 명세를 어떤 클래스가 충족시키는지에 관심이 없다. 모든 포트가 구현돼 있기만 한다면 영속성 계층에서 하고 싶은 어떤 작업이든 해도 된다.

 

‘애그리거트당 하나의 영속성 어댑터’ 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.  후반부에 청구(billing) 유스케이스를 책임지는 바운디드 컨텍스트를 정의할 것이다.

 

  • 바운디드 컨텍스트 간의 경계를 명확하게 구분하고 싶다면 각 바운디드 컨텍스트가 영속성 어댑터들을 하나씩 가지고 있어야 한다.
  • 각 바운디드 컨텍스트는 영속성 어댑터를 하나 혹은 그 이상 가지고 있다.
  • account 맥락의 서비스가 biling 맥락의 영속성 어댑터에 접근하지 않고, 그 반대도 접근하지 않는다는 의미이다.
  • 다른 맥락에 있는 무엇인가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.

 

spring data jpa 예제


이제 AccountPersistenceAdapter를 구현한 코드를 살펴보자

 

계좌 도메인 클래스

package buckpal.domain;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {

    @Getter 
    private final AccountId id;

    @Getter 
    private final Money baselineBalance;

    @Getter 
    private final ActivityWindow activityWindow;

    public static Account withoutId(
            Money baselineBalance, 
            ActivityWindow activityWindow) {
        return new Account(null, baselineBalance, activityWindow);
    }

    public static Account withId(
            AccountId accountId, 
            Money baselineBalance, 
            ActivityWindow activityWindow) {
        return new Account(accountId, baselineBalance, activityWindow);
    }

    public Optional<AccountId> getId() {
        return Optional.ofNullable(this.id);
    }

    public Money calculateBalance() {
        /** 생략 **/
    }

    public boolean withdraw(Money money, AccountId targetAccountId) {
        /** 생략 **/
    }

    private boolean mayWithdraw(Money money) {
        return Money
  • Account 클래스는 getter, setter만 가진 간단한 데이터 클래스가 아니며 최대한 불변성을 유지하려 한다는 사실을 기억하자
  • 유효한 상태의 Account 엔티티만 생성할 수 있는 팩터리 메서드를 제공하고 출금 전에 계좌의 잔고를 확인하는 일과 같은 유효성 검증을 모든 상태 변경 메서드에서 수행하기 때문에 유효하지 않은 도메인 모델을 생성할 수 없다.

 

엔티티 클래스

 

이제 계좌의 데이터베이스 상태를 표현하는 @Entity 애너테이션이 추가된 클래스를 추가하자.

package buckpal.adapter.persistence;

@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {

	@Id
	@GeneratedValue
	private Long id;

}
  • 계좌의 상태가 ID 하나만으로 구성되어 있다.
  • 나중에 사용자 ID 같은 필드가 추가될 것이다.

 

activity 테이블을 표현하기 위한 클래스도 추가했다.

package buckpal.adapter.persistence;

@Entity
@Table(name = "activity")
@Data
@AllArgsConstructor
@NoArgsConstructor
class ActivityJpaEntity {

	@Id
	@GeneratedValue
	private Long id;

	@Column
	private LocalDateTime timestamp;

	@Column
	private Long ownerAccountId;

	@Column
	private Long sourceAccountId;

	@Column
	private Long targetAccountId;

	@Column
	private Long amount;

}
  • JPA @ManyToOne이나 @OneToMany 애너테이션을 이용해 ActivityJpaEntity 와 AccountJpaEntity 를 연결해서 관계를 표현할 수도 있었지만 데이터베이스 쿼리에 부수효과가 생길 수 있기 때문에 이 부분을 제외하기로 결정

 

다음은 ActivityRepository 코드를 추가해보자

interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {

	@Query("select a from ActivityJpaEntity a " +
			"where a.ownerAccountId = :ownerAccountId " +
			"and a.timestamp >= :since")
	List<ActivityJpaEntity> findByOwnerSince(
			@Param("ownerAccountId") Long ownerAccountId,
			@Param("since") LocalDateTime since);

	@Query("select sum(a.amount) from ActivityJpaEntity a " +
			"where a.targetAccountId = :accountId " +
			"and a.ownerAccountId = :accountId " +
			"and a.timestamp < :until")
	Long getDepositBalanceUntil(
			@Param("accountId") Long accountId,
			@Param("until") LocalDateTime until);

	@Query("select sum(a.amount) from ActivityJpaEntity a " +
			"where a.sourceAccountId = :accountId " +
			"and a.ownerAccountId = :accountId " +
			"and a.timestamp < :until")
	Long getWithdrawalBalanceUntil(
			@Param("accountId") Long accountId,
			@Param("until") LocalDateTime until);

}

 

영속성 어댑터 구현

 

이제 영속성 기능을 제공하는 영속성 어댑터를 구현해보자.

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements 
        LoadAccountPort, 
        UpdateAccountStatePort {

    private final SpringDataAccountRepository accountRepository;
    private final ActivityRepository activityRepository;
    private final AccountMapper accountMapper;

    @Override
    public Account loadAccount(
            AccountId accountId, 
            LocalDateTime baselineDate) {

        AccountJpaEntity account = accountRepository.findById(accountId.getValue())
                .orElseThrow(EntityNotFoundException::new);

        List<ActivityJpaEntity> activities = activityRepository.findByOwnerSince(
                accountId.getValue(), 
                baselineDate);

        Long withdrawalBalance = orZero(
                activityRepository.getWithdrawalBalanceUntil(
                        accountId.getValue(), 
                        baselineDate));

        Long depositBalance = orZero(
                activityRepository.getDepositBalanceUntil(
                        accountId.getValue(), 
                        baselineDate));

        return accountMapper.mapToDomainEntity(
                account, 
                activities, 
                withdrawalBalance, 
                depositBalance);
    }

    private Long orZero(Long value) {
        return value == null ? 0L : value;
    }

    @Override
    public void updateActivities(Account account) {
        for (Activity activity : account.getActivityWindow().getActivities()) {
            if (activity.getId() == null) {
                activityRepository.save(accountMapper.mapToJpaEntity(activity));
            }
        }
    }
}
  • 영속성 어댑터는 애플리케이션에 필요한 LoadAccountPort와 UpdateAccountStatePort 2개의 포트를 구현했다.

 

loadAccount()

  • 유효한 Account 도메인 엔티티를 생성하기 위해서는 활동창 시작 직전의 계좌 잔고가 필요하다.
  • 따라서 데이터베이스에서 조회환 활동 내역을 Account 엔티티에 매핑하고 반환한다.

updateActivities()

  • 계좌의 상태를 업데이트하기 위해서는 Account 엔티티의 모든 활동을 순회하며 ID가 있는지 확인해야 한다.
  • 만약 ID가 없다면 새로운 활동이므로 ActivityRepository를 이용해 저장해야 한다.

 

왜 이런 수고를 해야할까?

  • JPA 애너테이션을 도메인 클래스로 옮기고 이걸 그대로 데이터베이스 엔티티로 저장하면 안 되는 걸까?
  • 이런 ‘매핑하지 않기’ 전략도 유효한 전략일 수 있다.
    • 그러나 이 전략에서는 JPA로 인해 도메인 모델을 타협할 수밖에 없다.
    • ex) 기본 생성자
    • ex) 속성 계층에서는 성능 측면에서 @ManyToOne 관계를 설정하는 것이 적절할 수 있지만 예제에서는 항상 데이터의 일부만 가져오기를 바라기 때문에 도메인 모델에서는 이 관계가 반대가 되기를 원한다.
  • 영속성 측면과 타협없이 풍부한 도메인 모델을 생하고 싶다면 도메인 모델과 영속성 모델을 매핑하는 것이 좋다.

 

데이터베이스 트랜잭션


 

그런데 트랜잭션 경계는 어디에 위치 시켜야 할까?

 

트랜잭션은 하나의 특정한 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐 있어야 한다. 그래야 다 같이 롤백될 수 있기 때문이다.

영속성 어댑터는 어떤 데이터베이스 연산이 같은 유스케이스에 포함되는지 알지 못하기 때문에 언제 트랜잭션을 열고 닫을지 결정할 수 없다. 이 책임은 영속성 어댑터 호출을 관장하는 서비스에 위임해야 한다.

 

자바와 스프링에서 가장 쉬운 방법은 트랜잭션 애너테이션을 애플리케이션 서비스 클래스에 붙여서 스프링이 모든 public 메서드를 트랜잭션으로 감싸게 하는 것이다.

package buckpal.application.service;

@Transactional
public class SendMoneyService implements SendMoneyUseCase {
	...
}
  • 만약 트랜잭션 애너테이션으로 오염되지 않고 깔끔하게 유지되길 원한다면 AsepectJ 같은 도구를 이용해 AOP으로 트랜잭션 경계를 코드에 위빙할 수 있다.

 

요약


도메인 코드에 플러그인처럼 동작하는 영속성 어댑터를 만들면 도메인 코드가 영속성과 관련된 것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다.

 

좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. 심지어 포트 뒤에서 애플리케이션이 모르게 다른 영속성 기술을 사용할 수도 있다. 포트의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.

 

728x90

+ Recent posts