4. API와 서비스 계층

hyuckhoon.ko·2021년 10월 31일
1

1️⃣ 아키텍처 리팩토링

지난 시간까지 Domain 계층과 저장소 패턴에 대해 배웠다.

실세계의 웹 애플리케이션에서의 아키텍처는 어떻게 달라야 할까?

1) 우선 API 방식으로 프로덕트가 움직인다. 장고, 플라스크 등의 API 계층이 추가될 것이다.

2) 서비스 계층이라는 추상화 계층이 필요하다.
따라서, API와 테스트 계층은 서비스 계층의 클라이언트가 되는 꼴이다.

3) 실제 환경에서 API는 SQLAlchemy Repository를 사용한다.
테스트는 FakeRepository를 사용한다.


2️⃣ 엔드투엔드 테스트

엔드투엔드란?
end-to-end, E2E 테스트는 영어 표현대로 끝과 끝까지의 테스트다.
API호출부터 DB 저장까지의 테스트다.


3️⃣ 아키텍처 우주인

아키텍처 우주인
"사람들이 워드프로세스나 PDF 파일을 주고받는구나"
통찰: 파일 전송이라는 추상화 계층을 두자!

(시간이 흐르고)

"requests를 주고받는 행위도 결국 같은 맥락아닌가? 메시지를 주고받는거잖아"
통찰: 메시징 추상화 계층을 두면 다 해결할 수 있구나!

하지만 점점 개념의 범위가 넓어지면서 의미전달이 모호해진다.
다른 부서의 실무자들은 파일 전송에 대한 이야기를 할 때, 그건 사실 메시징을 주고받는 행위라고 외쳐대는 단계에 다다른다.

우주비행사가 지구로부터 점점 멀어지면서, 언제 멈춰야할지를 모른다.
계속 하이 레벨 수준에서의 세계만 떠들고 bottom line에 대한 이야기를 하지 않는다.

"high-level pictures of the universe that are all good and fine, but don’t actually mean anything at all."

https://www.joelonsoftware.com/2001/04/21/dont-let-architecture-astronauts-scare-you/

조엘 온 소프트웨어: http://www.yes24.com/Product/Goods/1469763


4️⃣ 복잡도한 유닛 테스트를 리팩토링하기

from django.contrib.auth import get_user_model
from django.urls import reverse
from django.test import TestCase

from rest_framework import status
from rest_framework.test import APIClient

from core import models

# 찜하기URL
LIST_URL = reverse("shopping:main")


def create_sample_product(**param):
    """샘플 제품 생성"""

    return models.Product.objects.create(**param)


class PrivateUserEnrollmentProductApiTest(TestCase):
    """유저 찜 기능 테스트"""

    def setUp(self):
        self.client = APIClient()
        self.user = get_user_model().objects.create(
            email="like-create@user.com",
            password="like-create"
        )
        self.token = create_token(self.user)
        self.client.cookies = SimpleCookie({"access_token": self.token})
        self.brand = models.Brand.objects.create(
            kor_name="샤넬",
            eng_name="Chanel"
        )
        self.product_status_1 = models.ProductStatus.objects.create(
            id=1,
            name="신청 목록"
        )
        self.product_status_5 = models.ProductStatus.objects.create(
            id=5,
            name="판매 대기"
        )
        self.product_status_6 = models.ProductStatus.objects.create(
            id=6,
            name="판매 중"
        )

    def test_empty_user_product_list_post_success(self):
        """찜 리스트 비어있는 상태에서 제품 추가 케이스"""

        # valid 제품 1개
        product_param = {
            "user": self.user,
            "brand": self.brand,
            "product_status": self.product_status_6
        }
        sample_product = create_sample_product(**product_param)
        payload = {"product_id": sample_product.id}
        res = self.client.post(LIKE_CREATE_URL, payload)

        num_of_like = models.UserProduct.objects.filter(user=self.user).count()
        self.assertEqual(res.status_code, 201)
        self.assertEqual(res.data, None)
        self.assertEqual(num_of_like, 1)

    def test_fulled_user_product_list_post_success(self):
        """찜 리스트 채워져 있는 상태에서 제품 추가 케이스"""

        product_param = {
            "user": self.user,
            "brand": self.brand,
            "product_status": self.product_status_6
        }
        # valid 제품 4개 추가
        for i in range(4):
            sample_product = create_sample_product(**product_param)
            payload = {"product_id": sample_product.id}
            self.client.post(LIKE_CREATE_URL, payload)
        # valid 제품 1개 추가
        new_product = create_sample_product(**product_param)
        payload = {"product_id": new_product.id}
        res = self.client.post(LIKE_CREATE_URL, payload)

        num_of_like = models.UserProduct.objects.filter(user=self.user).count()
        self.assertEqual(res.status_code, 201)
        self.assertEqual(res.data, None)
        self.assertEqual(num_of_like, 5)

    def test_empty_user_product_post_fail_invalid_product_status(self):
        """비어있는 찜 리스트에 판매 중(status_id=6)이 아닌 제품 추가 케이스"""

        # invalid 제품 1개 추가
        product_param = {
            "user": self.user,
            "brand": self.brand,
            "product_status": self.product_status_5
        }
        invalid_product = create_sample_product(**product_param)
        payload = {"product_id": invalid_product.id}
        res = self.client.post(LIKE_CREATE_URL, payload)

        num_of_like = models.UserProduct.objects.filter(user=self.user).count()
        self.assertEqual(res.status_code, 400)
        self.assertEqual(res.data, {'MESSAGE': 'invalid product'})
        self.assertEqual(num_of_like, 0)

    def test_fulled_user_product_post_fail_invalid_product_status(self):
        """채워져있는 찜 리스트에 판매 중(status_id=6)이 아닌 제품 추가 케이스"""

        product_param = {
            "user": self.user,
            "brand": self.brand,
            "product_status": self.product_status_6
        }
        # valid 제품 4개 찜
        for i in range(4):
            sample_product = create_sample_product(**product_param)
            payload = {"product_id": sample_product.id}
            self.client.post(LIKE_CREATE_URL, payload)
        # invalid 제품 1개 추가
        product_param = {
            "user": self.user,
            "brand": self.brand,
            "product_status": self.product_status_5
        }
        invalid_product = create_sample_product(**product_param)
        payload = {"product_id": invalid_product.id}
        res = self.client.post(LIKE_CREATE_URL, payload)

        num_of_like = models.UserProduct.objects.filter(user=self.user).count()
        self.assertEqual(res.status_code, 400)
        self.assertEqual(res.data, {'MESSAGE': 'invalid product'})
        self.assertEqual(num_of_like, 4)

    def test_fulled_user_product_post_already_registered_product(self):
        """채워져있는 찜 리스트에 이미 등록된 제품 추가 케이스"""

        product_param = {
            "user": self.user,
            "brand": self.brand,
            "product_status": self.product_status_6
        }
        # valid 제품 4개 찜 추가
        for i in range(4):
            sample_product = create_sample_product(**product_param)
            payload = {"product_id": sample_product.id}
            self.client.post(LIKE_CREATE_URL, payload)
        # 이미 추가된 제품 1개 추가
        already_registered = models.UserProduct.objects.filter(
            user=self.user).first().product
        payload = {"product_id": already_registered.id}
        res = self.client.post(LIKE_CREATE_URL, payload)

        num_of_like = models.UserProduct.objects.filter(user=self.user).count()
        self.assertEqual(res.status_code, 400)
        self.assertEqual(res.data, {'MESSAGE': 'duplicated product'})
        self.assertEqual(num_of_like, 4)

    def tearDown(self):
        models.Brand.objects.all().delete()
        models.ProductStatus.objects.all().delete()
        models.Product.objects.all().delete()
        models.UserProduct.objects.all().delete()

5️⃣ 서비스 계층 소개와 서비스 계층 테스트용 FakeRepository 사용

  • 서비스 계층에서의 오케스트레이션: 저장소에서 여러 가지를 가져오고, 데이터베이스 상태에 따라 입력을 검증하며 오류를 처리하고, 성공적인 경우 커밋.
    (오케스트레이션: 여러 이기종 시스템 전반에서 다양한 단계를 수반하는 프로세스 또는 워크플로우를 자동화하는 방법)

서비스 계층 == 오케스트레이션 == 오케스트레이션 계층 == 유스케이스 계층 != (클라우드)오케스트레이션 (계층)

오케스트레이션은 E2E 테스트에서 실제로 테스트해야 하는 대상이 아니다.

    def create_matches(**param):
    	"""매치 생성"""
        
        # 파라미터 검증 및 생성
    	return Match.objects.create(**param)

장고에서는 유닛테스트 모듈에서 테스트용 데이터베이스가 제공된다.
따라서, 코드 내에 FakeRepository layer가 생성될 필요가 없고 사실 고려할 필요도 없다.
즉, DB의 클라이언트는 API와 테스트로써, 우리는 테스트용 DB와 deploy용 DB를 구분하여 생각할 필요가 없다.


6️⃣ 서비스 종류

  • 저자 의도: 서비스 계층에 대해서 말하고 있는데, 도메인 서비스와 혼동하지 말아라

서비스 계층: 애플리케이션 서비스

  • 데이터베이스에서 데이터를 얻는다.
  • 도메인 모델을 업데이트한다.
  • 변경된 내용을 영속화한다.

도메인 서비스: 엔티티나 값 객체와 무관한 로직 담당

매치 시작 시간 별 참가비가 차등 부여되는 것은 무엇일까?
매치 시작 시간이라는 인스턴스의 엔티티를 알아야 하기 때문이다.

반면, 오늘의 날씨를 파악하여 그 매치가 우천 매치인지, 아닌지를 판단하는 것은 도메인 서비스다. 오늘의 시간별 강수량을 파악후 수치(단위: mm)에 따라 우천 상황인지 아닌지를 판단하기 때문이다.


7️⃣ 장고에서 과연 필요한가

- 서비스 계층 도입에 따른 단점은 없을까?

장고 앱 생성 시, services.py를 생성하여 도메인 로직을 전부 이곳에 작성하면,
빈약한 도메인이라는 부작용이 생길 수도 있다.

웹 프레임워크에서 views.py라는 컨트롤러에 사실상 모든 유스 케이스가 있다.
또한, 장고의 모델 메소드에서 서비스 계층과 도메인 서비스까지 만들어 낼 수 있다.
Fat Model을 지향하는 장고에서 모델과 뷰 사이에 굳이 추상화 계층을 만들 필요는 없다고 생각한다.

0개의 댓글