UI에 의존적인 개발을 하지 말자

UI에 의존적인 개발을 하지 말자

프로젝트의 유연성과 지속적으로 키워나가기 위해서는 UI에 의존하면 안된다.

도메인 관점

현재 요구사항

  • 상품 목록을 출력한다
  • JSON
[
  {
    "name": "사과",
    "category": "과일",
    "price": 1000
  },
  {
    "name": "독도로션",
    "category": "화장품",
    "price": 1000
  }
]

코드

public class Product {
    private Long id;
    private String name;
    private String category;
    private Integer price;

    public Long id() {
        return id;
    }

    public String name() {
        return name;
    }

    public String category() {
        return category;
    }

    public Integer price() {
        return price;
    }
}

 

@RestController
@RequiredArgsConstructor
public class ProductController {
    private final ProductService productService;

    @GetMapping
    public ProductListResponse findAll() {
        List<Product> products = productService.findAll();
        return new ProductListResponse(products);
    }
}
public record ProductListResponse(Set<ProductResponse> products) {
    public ProductListResponse(List<Product> products) {
        this(products.stream()
                .map(ProductResponse::new)
                .collect(Collectors.toSet()));
    }
}

 

@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductReader productReader;

    public ProductListResponse findAll() {
        List<Product> products = productReader.findAll();
        return new ProductListResponse(products);
    }
}

 

@Component
@RequiredArgsConstructor
public class ProductReader {
    private final ProductRepository productRepository;

    public List<Product> findAll() {
        return productRepository.findAll();
    }
}

 

변경 요구사항

  • 상품 목록을 카테고리 별로 분류하여 출력한다
  • JSON
{
  "과일": [
    {
      "name": "사과",
      "category": "과일",
      "price": 1000
    }
  ],
  "화장품": [
    {
      "name": "독도로션",
      "category": "화장품",
      "price": 1000
    }
  ]
}

 

요구사항 분석

  • 변경된 요구 사항은 프론트에서도 기존 JSON을 가지고 그룹핑을 할 수 있다.
  • 서버에서 처리해야 한다면 어떻게 코드를 작성 해야 할까?

방안1

  • Service에서 스트림 사용하여 카테고리로 그룹핑
@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductReader productReader;

    public Map<String, List<Product>> findAll() {
        List<Product> products = productReader.findAll();
        return products.stream()
                .collect(groupingBy(Product::category));
    }
}

 

public record ProductListResponse(Map<String, List<Product>> products) {
    public ProductListResponse(Map<String, List<Product>> products) {
        this.products = products;
    }
}

 

방안2

  • Reader에서 스트림 사용하여 카테고리로 그룹핑
@Component
@RequiredArgsConstructor
public class ProductReader {
    private final ProductRepository productRepository;

    public Map<String, List<Product>> findAll() {
        List<Product> products = productRepository.findAll();
        return products.stream()
                .collect(groupingBy(Product::category));
    }
}

 

public record ProductListResponse(Map<String, List<Product>> products) {
    public ProductListResponse(Map<String, List<Product>> products) {
        this.products = products;
    }
}

 

 

방안3

  • 컨트롤러 혹은 Response에서 스트림 사용하여 카테고리로 그룹핑
@RestController
@RequiredArgsConstructor
public class ProductController {
    private final ProductService productService;

    @GetMapping
    public ProductListResponse findAll() {
        List<Product> products = productService.findAll();
        return new ProductListResponse(products);
    }
}

 

public record ProductListResponse(Map<String, List<Product>> products) {
    public ProductListResponse(List<Product> products) {
        this(products.stream()
                .collect(groupingBy(Product::category))
        );
    }
}
  • 3가지 방안 중에서 어느 방안이 좋을까?
  • 우선, 요구사항의 변경이 도메인 속에서 변경이 일어나는 것인지, 외부에 의한 변경 사항으로 수정을 해야 하는지 고민 해야 한다
  • 고민 포인트
    • 변경된 요구 사항은 프론트에서도 처리 가능
    • 일부 페이지에서만 단순 노출(가정)
  • 비즈니스와와 관련된 변경이 아닌 외부에 의한 변경 사항으로 판단할 수 있다.
  • 방안1과 방안2는 비즈니스와 관련된 곳에서 변경이 일어나는 곳이다.
  • 그럼 프레젠테이션 영역에서 변경하는 방안3가 적절한것일까?
  • 대부분 도메인과 엔티티를 동일시해서 사용하는 상황일텐데 트랜잭션 범위까지 신경을 써야 하고 도메인이 Controller 까지 침범하는게 좋은 방법이라곤 생각하지 않는다.
  • 지금 상황에서 내린 결론은 Service에서 ResponseDto를 생성해서 반환하는게 코드 관리적인 측면에서 편할 것 같다는 생각이 든다.

느낀점

  • Service는 Facade 역할로 볼 수 있다.
    • 도메인과 컴포넌트들을 조합하여 결과물을 도출한다.
  • 비즈니스를 구현하는 로직은 하위 계층인 도메인 계층 혹은 컴포넌트들이 될 수 있다.
    • 컴포넌트는 DB 접근 뿐만 아니라 메시지 큐 혹은 외부 API 호출 등을 컴포넌트라 볼 수 있다.

질문

  • 하나의 API가 아닌 여러 API 에서 동일하게 상품을 카테고리 별로 그룹핑 해서 내려줘야 한다면?
  • 도메인과 상관없는 단순 노출이라면
    • 그룹핑 된 상품 Response를 나타내는 별도의 Response 객체를 한개 만들어서 필요한 API에서 공통으로 사용한다.
    • Service에서는 Product를 반환한다.
  • 도메인과 관련된 변경이라면
    • 상품과 카테고리가 도메인 관점에서 병합되었을때 유의미한 역할과 책임을 맡아야 한다면 GroupedProductReader를 별도로 생성한다.
    • GroupedProductReader 객체에서 카테고리로 그룹화된 상품을 프레젠테이션 영역까지 반환 한다.

DB 관점

변경되는 데이터를 UI에 노출할 때 DB에 저장해서 보여줄지 아니면 계산해서 보여줄 수 있는지 고민해야 한다

예시1

할인가

  • 할인가를 UI에 보여줘야 한다.
  • 할인 정책에 따라 할인가가 달라진다.
  • 할인가를 DB로 관리하면 관리 포인트가 생긴다. 할인 정책이 변경될 때 마다 업데이트 쿼리를 날려 줘야 하기 때문이다.
  • 할인가를 UI에 보여줄때는 원가와 할인 정책을 계산해서 보여줄 수 있다.

예시2

랭킹

  • 상품 주문수량 TOP 10을 UI에 보여줘야 한다.
  • 주문 수량 집계를 위해 스케쥴러가 10분마다 TOP 10을 업데이트 한다.
    • 주문 수량 집계를 위해 주문 수량을 DB에 저장한다.
  • 하지만, 레디스의 SortedSet을 사용하면 주문 수량을 저장할 필요도 없고 스케쥴러도 필요 없다.
    • 주문이 발생할때 마다 이벤트를 발생 시켜 레디스의 SortedSet의 score를 올려준다.
    • 주문수량 TOP 10을 실시간으로 보여줄 수 있다.
  • 스케쥴러가 필요한 기능은 이벤트 스트림이 가능한지 고민해야 한다.

참고