1. 계층 구조 아키텍처

네 영역을 구성할 때 많이 사용하는 아키텍처는 아래와 같다.

계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 한다. 하지만 구현의 편리함을 위해 계층 구조를 유연하게 가져갈 수 있다.

 

구조가 엄격하든 유연하든 하위 계층은 상위 계층을 의존하지 않는다. 그 말은 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라 계층을 의존한다는 것이다. 그로 인해 테스트의 어려움과 기능 확장의 어려움을 야기한다. 아래에서 자세하게 알아보자.

 

public class DroolsRuleEngine {
  private KieContainer kContainer;
    
  public DroolsRuleEngine() {
    KieServices ks = KieServices.Factory.get();
    kContainer = ks.getKieClasspathContainer();
  }

  public void evalute(String sessionName, List<?> facts) {
    KieSession ksession = kContainer.newKieSession(sessionName);
        
    try {
        facts.forEach(x -> ksession.insert(x));
        ksession.fireAllRules();
    } finally {
        kSession.dispose();
    }
  }
}
public class CalculateDiscountService {
  private DroolsRuleEngine ruleEngine;

  public CalculateDiscountService() {
    ruleEngine = new DroolsRuleEngine();
  }

  public Money calculateDiscount(OrderLine orderLines, String customerId) {
    Customer customer = findCusotmer(customerId);
    
    MutableMoney money = new MutableMoney(0);
    List<?> facts = Arrays.asList(custome, money);
    facts.addAll(orderLines);
    ruleEngine.evalute("discountCalculation", facts);
    return money.toImmutableMoney();
  }
  ...
}

위의 코드를 보면 CalculateDiscountService가 DroolsRuleEngine을 의존하고 있다. Service 클래스를 테스트하려면 RulesEngine이 완벽하게 동작해야만 테스트가 가능하다. 또한, Drools에 특화된 코드가 Service 내부에 존재하기 때문에 변경에 유연하지 못하다는 문제를 인지할 수 있다. 이러한 문제를 어떻게 해결할 수 있을까?

 

2. DIP

위의 계층 구조는 테스트의 어려움과 변경에 유연하지 못하다는 단점이 존재한다. 이를 DIP로 해결할 수 있다.

저수준 모듈이 고수준 모듈을 의존하도록 DIP를 적용한 코드는 다음과 같다.

public interface RuleDiscounter {
  public Money applyRules(Customer customer, List<OrderLine> orderLines);
}

public class DroolsRuleDiscounter implements RuleDiscounter {
  private KieContainer kContainer;
    
  public DroolsRuleEngine() {
    KieServices ks = KieServices.Factory.get();
    kContainer = ks.getKieClasspathContainer();
  }

  @Override
  public void applyRules(String sessionName, List<?> facts) {
    KieSession ksession = kContainer.newKieSession(sessionName);
        
    try {
        facts.forEach(x -> ksession.insert(x));
        ksession.fireAllRules();
    } finally {
        kSession.dispose();
    }
  }
}
public class CalculateDiscountService {
  private RuleDiscounter ruleDiscounter;

  public Money calculateDiscount(OrderLine orderLines, String customerId) {
    Customer customer = findCusotmer(customerId);
    return ruleDiscounter.applyRules(customer, orderLines);
  }
}

Service는 더 이상 Drools에 의존하지 않는다. 대신 RuleCounter를 통해 룰을 적용한다는 사실만 알 뿐이다. 이를 도식화하면 아래와 같다.

저수준 모듈이 고수준 모듈을 의존하도록 구조를 변경함으로써 단점을 극복하였다. 구현 객체의 변경이 필요하면 설정 파일에서 변경하기만 하면 된다. 테스트는 RuleDiscounter가 인터페이스이므로 대역 객체를 사용해서 진행하면 된다.

 

인터페이스 분리 시 주의사항

DIP는 단순히 인터페이스와 구현 클래스를 분리하는 것이 아니다. 고수준 모듈이 저수준 모듈에 의존하지 않도록 분리하는 것이 중요하다.

왼쪽이 잘못된 예이고, 오른쪽이 올바른 예이다.

DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다. CalculateDiscountService는 이름을 보면 알 수 있듯이 가격 할인 계산이라는 기능을 구현한다. 그에 따라 RuleDiscount 인터페이스는 가격 할인을 위한 룰을 적용한다라는 하위 기능을 메서드로 가지고 있다. 별 의도 없이 DroolsRuleEngine가 그저 인터페이스를 구현하도록 한 것이 아니다.

 

이런 장점을 가지고 있는 DIP이지만, 모든 상황에서 인터페이스로 분리하는 것은 번거롭다. 이점이 있다고 판단되면 적용하자.

 

인프라스트럭처 주의사항

무조건 인프라스트럭처에 대한 의존을 없앨 필요는 없다.

 

@Entity, @Transactional과 같은 애노테이션은 인프라스트럭처에 해당된다. 이를 사용하면 의존성을 가지긴 하지만, 구현의 편리함이라는 장점이 훨씬 크기 때문이다.

 

3. 도메인 영역의 주요 구성 요소

요소 설명
애그리거트 애그리거트는 연관된 엔티티와 밸류를 개념적으로 하나로 묶은 것이다. 애그리거트는 구성 객체들을 관리하기 위해 루트 엔티티를 갖는다.
리포지터리 도메인 모델의 영속성을 처리한다. 애그리거트 단위로 저장하고 조회하는 기능을 정의한다.
도메인 서비스 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. 가령 할인 금액 계산을 구현하기 위해서는 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현해야 한다. 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다.

 

4. 모듈 구성

패키지를 어떻게 세분화해야 하는지 정해진 규칙은 없다. 단순히 4개의 영역으로 구분하거나 하위 도메인 별로 나누어도 된다. 도메인이 복잡하면 애그리거트(도메인 모델)와 도메인 서비스를 별도의 패키지에 위치시켜도 된다.

 

한 패키지에 너무 많은 타입이 몰려서 불편하지 않으면 된다. 필자는 가급적 한 패키지에 10 ~ 15개 미만으로 타입 개수를 유지하려 한다.

1. 도메인이란?

도메인이란 소프트웨어로 해결하고자 하는 문제 영역을 의미한다. 만약 우리가 온라인 서점을 구현하고 싶다고 해보자. 그러면 이 온라인 서점이 구현해야 할 소프트웨어의 대상, 즉 도메인이 되는 것이다. 

 

한 도메인은 다시 하위 도메인으로 나눌 수 있다. 하위 도메인들이 서로 연동하면서 완전한 기능을 제공하는 것이다.

 

도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다. 예를 들어 결제나 배송과 같은 하위 도메인은 외부 업체의 시스템을 사용하는 방식으로 도메인을 구성할 수 있다.

 

2. 도메인 전문가와 개발자 간 지식 공유

개발자는 요구 사항을 바탕으로 구조를 설계하고 개발을 진행한다. 첫 단추인 요구사항 분석의 중요성은 두말할 것도 없다. 

 

정산과 배송과 같이 각 도메인에는 전문가가 있다. 개발자는 이러한 전문가들과 직접 대화하면서 요구사항을 올바르게 이해하는 것이 중요하다. 물론 사전에 도메인 지식을 어느 정도 갖춰야 한다.

번거롭다고 느낄 수 있지만, 전문가와 관계자와 지식을 공유해야 원하는 제품을 만들 가능성이 높아진다.

 

3. 도메인 모델

도메인 모델 설명에 앞서, 먼저 애플리케이션의 아키텍처 구성은 다음과 같다.

영역 설명
표현 클라이언트의 요청을 처리하거나 정보를 보여준다. 여기서 클라이언트는 사용자 또는 외부 시스템이다.
응용 클라이언트가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
도메인 시스템이 제공할 도메인 규칙을 제공한다.
인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다. 도메인, 응용, 표현 영역은 구현 기술을 사용한 코드를 직접 만들지 않는다. 인프라 영역에서 제공하는 기능을 사용해서 필요한 기능을 개발한다.

 

도메인 계층은 도메인의 핵심 규칙을 구현한다. 주문 취소는 배송 전에만 할 수 있다.출고 전에 배송지를 변경할 수 있다. 와 같은 요구 사항이 도메인 규칙에 해당된다. 그리고 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.

 

4. 엔티티와 밸류

도메인 영역에는 엔티티와 밸류가 존재한다. 

엔티티

엔티티는 테이블가 매핑되어 있는 객체를 말한다. 엔티티의 가장 큰 특징은 식별자를 가진다는 것이다. 이 식별자는 엔티티 객체마다 서로 다른 값을 가진다. 위의 그림에서는 Order가 엔티티이고, 식별자는 주문번호가 되겠다.

 

식별자는 변하지 않고 고유하기 때문에, 두 엔티티 객체의 식별자가 서로 같으면 두 엔티티는 같다고 판단할 수 있다. 그에 맞춰 equals()와 hashCode() 메서드를 적절하게 구현하자.

 

밸류

응집도 높은 필드들을 모아 새로운 타입으로 정의한 것을 밸류 타입이라고 부른다. 가령 도시, 번지, 우편 번호 세 개의 필드를 Address로 묶어서 사용할 수 있다. 해당 타입을 사용하더라도 테이블에 영향을 주지 않는다. (아무리 밸류 타입이 많아져도 하나의 Order 테이블만 존재한다는 의미이다.)

 

밸류 타입을 선언할 때 불변으로 설계하는 것이 좋다. 불필요하게 setter를 열어놓으면 어디서든 접근할 수 있기 때문에 잘못 사용될 가능성이 존재한다. 따라서 생성자를 통해서만 값을 설정할 수 있도록 하고, 변경이 필요하면 새로운 객체를 생성하는 방법을 고려하자.

추가로 setter라는 이름도 애매하다. 그 대신 changePassword와 같이 직관적인 메서드 이름을 사용하자.

 

5. 도메인 용어와 유비쿼터스 언어

코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 
public OrderState {
	STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
public OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELEVERING, DELIVERY_COMPLETED
}

 

 

이처럼 명확한 이름은 코드의 가독성이 높아지고 버그도 줄어든다는 장점을 지닌다. 개발자뿐만 아니라 전문가, 관계자들 간에 공통의 언어를 규정하고 사용하자. 시간이 흘러 도메인 이해가 높아지면 그에 걸맞게 이름을 재정의하면 더더욱 좋다.

1. 기술 소개

GraphQL이란 클라이언트와 서버 간의 api를 위한 쿼리 언어이다. 사전에 리소스와 관리 방식 정의하면, Rest 방식과 달리 클라이언트 측에서 원하는 데이터만 조회할 수 있다!

  - 관리 방식은 read 역할을 하는 query와 cud 역할을 하는 mutation, 구독 개념의 subscription으로 나누어진다.

 

예를 들어 아래와 같이 리소스와 query를 정의해 보자.

type Book {
  id: ID
  title: String
  author: Author
}
type Author {
  id: ID
  firstName: String
  lastName: String
  books: [Book]
}
type Query {
  book(id: ID!): Book
  author(id: ID!): Author
}

 

그러면 아래처럼 Query의 모든 파라미터를 채우지 않고, 조회하고 싶은 데이터만 넘겨서 받을 수 있다!

// 쿼리
query {
  book(id: "1") {
    author {
	  firstName
    }
  }
}

// 결과
{
  "title": "Black Hole Blues",
  "author": {
    "firstName": "Janna"
  }
}

 

2. REST 방식과의 차이점

1) 데이터 조회 방식의 차이

REST API에서는 한 페이지에서 여러 리소스를 조회하기 위해, 아래와 같이 여러 api를 호출해야 한다.

 

반면에 GraphQL에서는 아래와 같이 단일 쿼리로 조회할 수 있다. 번거롭게 api를 여러 번 호출하고 데이터를 가공하는 단계를 클라이언트가 부담하지 않아도 된다.

 

2) 오버 페칭과 언더 페칭

오버 페칭이란 불필요한 데이터까지 조회하는 것을 말하고, 언더 페칭이란 특정 api가 필요한 정보가 누락되어 있는 것을 의미한다. 언더 페칭의 경우 추가 요청을 필요하다는 비효율성이 존재한다.

 

REST 방식에서는 오버 페칭과 언더 페칭이 일반적인 문제이다. GraphQL은 원하는 데이터만 가져올 수 있기 때문에 그러한 문제를 고민하지 않아도 된다.

 

3. 마치면서

이러한 장점들이 존재하지만, 캐싱의 복잡함과 같은 단점들도 존재한다. 또한, GraphQL 서버 뒤에서 REST API를 추상화해서 사용할 수도 있기 때문에 이러한 장단점들들 잘 고려해서 도입해 보자.

  - 캐싱에 복잡함에 대해서, REST API의 경우에는 URL을 식별자로 캐싱하면 된다. 하지만 GraphQL의 경우에는 동일한 리소스에 대해 각 쿼리가 다를 수 있으므로 캐싱이 복합하다. 다만, GraphQL 위에 구축된 대부분의 라이브러리는 효율적인 캐싱 메커니즘을 제공한다.

 

참고 링크

- https://graphql.org/

- https://www.prisma.io/blog/top-5-reasons-to-use-graphql-b60cfa683511

- https://hwasurr.io/api/rest-graphql-differences/

- https://www.javatpoint.com/graphql-advantages-and-disadvantages

+ Recent posts