Web/TDD 2019. 9. 9. 16:41

 

이번 포스팅에서는 목(Mock) 객체를 사용하여 테스트하기 힘든, 혹은 외부환경과 의존성을 끊는 테스트를 하기 위한 방법을 간단하게 다루어 볼 것이다. 여기서 다루는 목(Mock)객체는 정말 단순한 수준의 예제이다. 결과적으로 이번 포스팅의 목적은 목객체는 무엇이고 왜 사용하는 지에 대한 내용이 될 것 같다.

 

지금 진행할 예제는 크게 코드 내용 자체를 알필요?는 없을 것 같다. 이 말은 우리가 개발하며 테스트를 작성할 때, 코드의 내용을 몰라도 된다는 말이 아니다. 테스트 작성은 당연히 코드의 내용을 빠삭히 알고 작성해야 하는 테스크이기 때문이다. 필자가 말하는 "알 필요는 없다"라는 것은 이번 포스팅은 독자들과 같이 애플리케이션을 개발하며 테스트를 작성하는 포스팅이 아니고 어느 순간에 목객체를 사용해야하냐를 다루는 문제이기 때문이다.

 

<위도경도를 이용한 주소정보 받아오기>

아래의 코드는 제목그대로 위도경도를 받아 특정 API를 호출해 해당 위도경도에 위치한 위치정보를 받아오는 코드이다.(효율적인 코드라고는 말할 수 없다. 그냥 흐름만 보자.)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class AddressRetriever {
   public Address retrieve(double latitude, double longitude)
         throws IOException, ParseException {
      String parms = String.format("lat=%.6flon=%.6f", latitude, longitude);
      String response = new HttpImpl().get(
        "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&"
        + parms);
 
      JSONObject obj = (JSONObject)new JSONParser().parse(response);
 
      JSONObject address = (JSONObject)obj.get("address");
      String country = (String)address.get("country_code");
      if (!country.equals("us"))
         throw new UnsupportedOperationException(
            "cannot support non-US addresses at this time");
 
      String houseNumber = (String)address.get("house_number");
      String road = (String)address.get("road");
      String city = (String)address.get("city");
      String state = (String)address.get("state");
      String zip = (String)address.get("postcode");
      return new Address(houseNumber, road, city, state, zip);
   }
}
cs

 

위와 같은 코드가 있다. 간단히 코드내용을 설명하면, retrieve 메소드에 위도와 경도 인자를 받아서 해당 인자를 이용하여 특정 API를 호출하고 위치에 대한 정보를 받아오고 있다. 만약 이 메소드를 단위 테스트하기 위해 걸리는 사항이 있나? 필자는 여기서 하나를 꼽자면 "외부 서버에 위치한 API를 호출하는 부분"를 얘기하고 싶다. 이런 부분은 우리가 통제할 수 있는 환경이 아니다. 즉, 우리의 테스트 코드와는 상관없이 API 트래픽이 높아 결과를 받아오는데 시간이 걸릴 수 있고, 최악으로는 API서버가 다운되고 API호출 결과로 예외가 발생할 수 있는 등의 우리가 통제할 수 없는 문제가 발생할 수 있다. 

 

테스트 관점에서 보면, API에서 적절한 결과값을 받아오는 것을 테스트할 수 있지만 "우리가 개발한 로직이 과연 기대한대로 동작하는 가?"를 테스트 해볼 수 있다. 물론 전자는 직접 API를 호출하여 결과값을 받아오는 테스트 코드를 작성할 수 있지만 후자는 굳이 API호출 동작 자체가 필요하지 않을 수 있다. 

 

그렇다면 직접 API를 호출할 필요가 없는 테스트지만 특정 API를 호출하고 있는 로직이 존재하는 코드를 테스트하기 위해서는 어떻게 할까?

이럴때 이용하는 것이 목(Mock) 객체이다. 일종의 스텁객체라고도 볼 수 있을 것 같다. 이러한 객체를 사용하면서 생기는 이점은 무엇일까?

 

  • 외부환경을 신경쓸 필요가 없이 비지니스로직의 성공유무를 테스트할 수 있다.
  • 외부환경에 따라 테스트가 빨리 끝날 수도 있지만, 외부환경이 갑자기 트래픽이 몰리는 시간이라면 테스트가 느리게 끝날 수도 있다. 하지만 목(Mock)객체를 사용한다면 직접 API를 호출하여 결과를 받아오는 것이 아니기 때문에 빠른 테스트 시간을 유지할 수 있다.
  • 테스트의 복잡도를 낮춰준다.

하지만 목객체를 이용하기 전에 위의 코드는 조금 문제점이 있다. API를 호출하는 객체(HttpImpl클래스)가 메서드 내부에서 초기화되고 있다는 것이다. 이것은 목객체를 이용한 테스트 코드를 작성할 수 없다. 우리는 "의존주입"이라는 것을 이용하여 목객체를 이용할 것이기 때문에 간단하게 프로덕 코드의 설계를 변경할 것이다.

 

"의존주입"을 이용하게 되면 생기는 장점은 아래와 같다.

 

  • 해당 클래스의 메서드는 실제 비지니스로직을 수행하는 HttpClient 객체가 목객체인지 혹은 실제 API를 호출하는 객체인지를 알 필요가 없어진다.

특정 오브젝트와의 결합도를 인터페이스(Http라는 인터페이스)를 이용하여 낮춰버렸기 때문에 이 인터페이스의 구현체로 Mock객체를 주입하던 실제 API를 호출하게 되는 HttpClient(HttpImpl 클래스) 객체를 넣던 해당 클래스는 신경쓸 필요가 없어진다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class AddressRetriever {
   private Http http;
 
   public AddressRetriever(Http http) {
      this.http = http;
   }
 
   public Address retrieve(double latitude, double longitude) throws IOException, ParseException {
      String parms = String.format("lat=%.6f&lon=%.6f", latitude, longitude);
      String response = http.get("http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" + parms);
 
      JSONObject obj = (JSONObject)new JSONParser().parse(response);
 
      JSONObject address = (JSONObject)obj.get("address");
      String country = (String)address.get("country_code");
      if (!country.equals("us"))
         throw new UnsupportedOperationException(
               "cannot support non-US addresses at this time");
 
      String houseNumber = (String)address.get("house_number");
      String road = (String)address.get("road");
      String city = (String)address.get("city");
      String state = (String)address.get("state");
      String zip = (String)address.get("postcode");
      return new Address(houseNumber, road, city, state, zip);
   }
}
cs

 

위 코드와 같이 특정 API 호출을 담당하는 객체를 인스턴스 변수로 선언하고 생성자에서 의존주입을 하고 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class AddressRetrieverTest {
   @Test
   public void answersAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = (String url) -> 
         "{\"address\":{"
         + "\"house_number\":\"324\","
         + "\"road\":\"North Tejon Street\","
         + "\"city\":\"Colorado Springs\","
         + "\"state\":\"Colorado\","
         + "\"postcode\":\"80903\","
         + "\"country_code\":\"us\"}"
         + "}";
      AddressRetriever retriever = new AddressRetriever(http);
 
      Address address = retriever.retrieve(38.0,-104.0);
      
      assertThat(address.houseNumber, equalTo("324"));
      assertThat(address.road, equalTo("North Tejon Street"));
      assertThat(address.city, equalTo("Colorado Springs"));
      assertThat(address.state, equalTo("Colorado"));
      assertThat(address.zip, equalTo("80903"));
   }
 
   @Test
   public void returnsAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = new Http() {
         @Override
         public String get(String url) throws IOException {
            return "{\"address\":{"
               + "\"house_number\":\"324\","
               + "\"road\":\"North Tejon Street\","
               // ...
               + "\"city\":\"Colorado Springs\","
               + "\"state\":\"Colorado\","
               + "\"postcode\":\"80903\","
               + "\"country_code\":\"us\"}"
               + "}";
            }};
      AddressRetriever retriever = new AddressRetriever(http);
 
      Address address = retriever.retrieve(38.0,-104.0);
      
      assertThat(address.houseNumber, equalTo("324"));
      assertThat(address.road, equalTo("North Tejon Street"));
      assertThat(address.city, equalTo("Colorado Springs"));
      assertThat(address.state, equalTo("Colorado"));
      assertThat(address.zip, equalTo("80903"));
   }
}
cs

 

우리는 설계를 변경한 클래스 테스트 작성을 위와 같이 할 수 있다. (포스팅에 올리지는 않았지만 Http 인터페이스는 get라는 메서드 선언을 하고 있는 FunctionalInterface이다.)

 

테스트에서는 스텁 객체(Http)를 이용해 retrieve 메서드를 테스트하고 있다. 또 다른 방법으로 Mokito라이브러리를 이용한 Mock(목) 객체를 이용하여 테스트를 작성할 수도 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class AddressRetrieverTest {
   @Test
   public void answersAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = mock(Http.class);
      when(http.get(contains("lat=38.000000&lon=-104.000000"))).thenReturn(
            "{\"address\":{"
            + "\"house_number\":\"324\","
           // ...
            + "\"road\":\"North Tejon Street\","
            + "\"city\":\"Colorado Springs\","
            + "\"state\":\"Colorado\","
            + "\"postcode\":\"80903\","
            + "\"country_code\":\"us\"}"
            + "}");
      AddressRetriever retriever = new AddressRetriever(http);
 
      Address address = retriever.retrieve(38.0,-104.0);
      
      assertThat(address.houseNumber, equalTo("324"));
      // ...
      assertThat(address.road, equalTo("North Tejon Street"));
      assertThat(address.city, equalTo("Colorado Springs"));
      assertThat(address.state, equalTo("Colorado"));
      assertThat(address.zip, equalTo("80903"));
   }
}
cs

 

목 객체를 이용하면 스텁객체를 사용하는 것보다 더 쉽게 메서드 인자 검사등을 수행할 수 있기 때문에 더 정교하고 똑똑한 일회성 객체를 만들 수 있다.

 

목객체를 이용한 테스트는 비단 외부API를 호출하는 것에만 국한되지 않는다. 기타 비용이 큰 로직에 대해서도 목객체를 이용하여 효율적인 테스트 코드작성, 혹은 비즈니스 로직에 집중된 테스트 코드를 작성할 수 있게된다.

 

하지만 목(Mock) 객체를 이용하기 위해서 중요한 것이 있습니다.

 

  • 목을 사용한 테스트는 진행하길 원하는 내용을 분명하게 기술해야 한다.(메서드 이름등을 명확히 짓는다.)
  • 목이 프로덕 코드의 동작을 올바르게 묘사하고 있는가를 살펴보자.
  • 혹시나 목 객체가 실제 프로덕코드를 실행하고 있지는 않은가?(임시로 프로덕코드에 throw문을 넣어 예외가 발생하는지 테스트 해볼 수 있다.)
  • 프로덕 코드를 직접 적으로 테스트하고 있지 않는 다는 것을 기억하자. 목을 도입하면 테스트 커버리지에서 간극을 형성할 수 있음을 인지하고 실제 클래스의 종단 간 사용성을 보여주는 상위 테스트가 있는지 확인하자.

 

여기까지 간단하게 목객체를 사용하는 이유중 한가지를 다루어봤습니다. 실제 모키토를 이용하여 기술적으로 목객체를 이용하는 방법등을 포스팅 내용에 없기 때문에 별도 자료로 찾아봐야합니다.

posted by 여성게
:
IT이론 2019. 9. 9. 14:06

CQRS는 Command and Query Responsibility Segregation(명령과 조회의 책임 분리)을 나타냅니다. 이름처럼 시스템에서 명령을 처리하는 책임과 조회를 처리하는 책임을 분리하는 것이 CQRS의 핵심입니다. 이제 명령과 조회에 대해 정의할 필요가 있습니다. CQRS에서 명령은 시스템의 상태를 변경하는 작업을 의미하며 조회는 시스템의 상태를 반환하는 작업을 의미합니다. 정리하면, CQRS는 시스템의 상태를 변경하는 작업과 시스템의 상태를 반환하는 작업의 책임을 분리하는 것입니다.

 

모든 연산이 명령과 조회로 쉽게 양분되지 않는다. 개념적으로 어려운 경우도 있고 동시성 등 기술적인 문제도 있다. Martin Fowler는 스택 자료구조의 pop() 연산을 예로 들었다.

 

너무 단순하다고 생각될지 모르겠지만 이것이 전부입니다. 어쩌면 CQRS에 대한 오해는 CQRS가 생각보다 복잡하지 않기 때문일지도 모릅니다. 이 단순한 규칙이 몇 가지 응용기술과 조합되어 시스템에 적용되면 그 모습은 무척이나 다양합니다. 그만큼 CQRS를 설명하는 정보들이 표현하는 구현체의 모습이 제각각이고 여기서 혼란이 시작될 가능성이 있습니다. CQRS를 설명할 때 명령 처리기 패턴(Command Processor Pattern)을 얘기하기도 하고 다른 경우는 다계층 아키텍처(Multitier Architecture)나 이벤트 소싱(Event Sourcing)을 다룹니다. 이것들 모두와 DDD(Domain-Driven Design)를 조합하기도 합니다.

CQRS를 처음으로 소개한 Greg Young은 CQRS는 아주 단순한 패턴(“CQRS is a very simple pattern”)이라고 말했습니다. 물론 Greg Young은 DDD의 고통을 해결하기 위해 CQRS를 사용했다고 하지만 DDD에 국한된 기법은 아닙니다. 이 글에서는 CQRS의 적용 예를 설명하기 위해 다계층 아키텍처를 사용하지만 이것은 단지 하나의 예시일 뿐 CQRS는 아키텍처 독립적입니다. 다시 강조하지만 CQRS 자체는 복잡하거나 거대하지 않습니다. 지금 당장 시스템에 적용해 볼 수 있으며 경우에 따라 이미 실천하고 있을지도 모릅니다.

 

CQRS는 CQS(Command and Query Separation) 원리에 기원한다. 사실 CQRS는 처음에 CQS의 확장으로 얘기되었다. 하지만 CQS는 명령과 조회를 연산 수준에서 분리하는 반면 CQRS는 개체나 시스템 수준에서 분리한다.

 

도메인의 구조(ORM)

상태를 변경할 때와 조회할 때 단일 도메인을 사용하기 때문에 아래와 같은 문제가 발생한다

  • ORM은 도메인의 상태 변경을 구현하는 데 적합하지만, 여러 집계(복잡한 조회)에서 데이터를 가져와 출력하는 기능을 구현하다보면 도메인 복잡도가 높아지는 문제가 발생할 수 있다. 여러 도메인이 조인이 걸려있고 다수의 조인 쿼리가 발생하면 그만큼 DB에 부하는 커지게 되며, 도메인 객체의 디자인 자체도 점점 복잡해진다.

즉, 상태를 변경하는 기능과 상태 정보를 조회하는 기능을 분리하여 도메인을 구성하는 것이다.

 

도메인 모델 관점에서 상태 변경 기능은 주로 한 집계의 상태를 변경한다.

  • 주문 취소 기능과 배송지 정보 변경 기능은 한 개의 Order 집계에서 진행한다.

조회 기능은 하나의 집계로 조회할 수 있지만, 두개 이상의 집계에서 데이터를 조회할 수 있다.

  • 단일 모델로 두 종류의 기능을 구현하면 모델이 불필요하게 복잡해진다.

 

<단일 DB 구조>

 

 

DB를 공유하고 Model을 Command와 Query Model로 분리하여 적용하였다. 쉽고 단순하게 적용할 수 있지만, 같은 Database를 사용하기 때문에 성능에 대한 문제점은 해결하기 힘든 구조이다.(도메인 분리에 따른 복잡도를 낮춰주는 효과는 있다.)

 

<다중 DB 구조>

 

 

Command 도메인 DB와 Query 도메인의 DB를 분리하고 Message broke(Kafka,RabbitMQ)를 이용해 Data 동기화를 처리 하는 방식이다. 각각의 Model에 적합한 구조를 사용할 수 있는 장점이 있다. 하지만 동기화 처리를 위한 Message Broker의 고가용성과 메시지의 신뢰성에 대한 보장을 관리해주어야하는 관리포인트가 생긴다.

 

위의 두개 말고도 Event Sourcing 구조를 이용하여 CQRS를 설계할 수 있다. 그렇다면 CQRS를 적용함으로써 생기는 장단점은 무엇이 있을까?

 

CQRS 장점/단점

 

장점 단점
각각의 도메인 목적에 맞게 집중하여 개발할 수 있다. 구현해야할 코드가 더 많아진다.
명령과 쿼리 파이프라인을 원하는대로 최적화하면서 다른 요소가 깨질 위험은 거의 없다. 더 많은 구현 기술이 필요해진다.
유지 비용이 증가한다.

 

<참고>

 

DDD - CQRS

 

nesoy.github.io

 

 

CQRS란 무엇인가?

CQRS 오해 CQRS와 그 관련 기술들은 .NET 환경을 중심으로 발전해왔고 점차 Java, Ruby 등의 생태계로 확산되고 있습니다. 국내에서는 아직 크게 주목받지는 않지만 최근 CQRS에 대한 관심이 늘어나고 있습니다. CQRS를 처음 접하는 국내 프로그래머들은 혼란스러워하거나 오해를 하곤 합니다. 비단 이런 현상은 CQRS나 국내 환경에 국한되…

justhackem.wordpress.com

 

posted by 여성게
: