장고에서 서비스 계층 만들기: 커맨드 패턴

구경회·2022년 4월 6일
9
post-thumbnail

필요성

애플리케이션이 커짐에 따라 여러 도메인에 걸쳐있는 비즈니스 로직이 생겨나게 된다. 또한 DB에 접하다보면 트랜잭션 경계를 지어줘야할 일도 생긴다. 이런 로직을 어디서 관리해야할까? 기존 MVC구조 (장고는 MTV)에서는 이 문제에 대한 아주 명확한 답을 내려주기 힘들다. 뚱뚱한 컨트롤러(장고에서는 뷰)는 좋지 못한 설계이기 때문이다.

이 때 서비스 레이어의 필요성이 생긴다. 스프링이나 혹은 그 아키텍쳐를 차용한 프레임워크라면 다음과 같은 형태의 객체가 익숙할 것이다.

@AllArgsConstructor
@Service
public class PostsService {
    private PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto dto){
        return postsRepository.save(dto.toEntity()).getId();
    }

    @Transactional(readOnly = true)
    public List<PostsMainResponseDto> findAllDesc() {
        return postsRepository.findAllDesc()
                .map(PostsMainResponseDto::new)
                .collect(Collectors.toList());
    }
}

출처: 이동욱(2018), SpringBoot로 웹서비스 개발하기

보통 도메인 로직의 경우 도메인 모델에, 그리고 트랜잭션 관리나 각 도메인의 순서 보장 역할을 한다. 서비스 객체가 뚱뚱해지는 것 역시 바람직하지 않은데, 이유는 대략 두 가지이다.

  1. 객체를 멍청한 데이터 덩어리로 취급하게 된다. 사실상 절차지향적 언어와 다를 것이 없는 것이다.
  2. 컨트롤러가 비대해질 때의 문제(낮은 재사용성 등)들을 한 단계 내린 것에 불과하다.

또 다른 어려움이 있는데, 장고와 같은 프레임워크에서 위와 같은 서비스 레이어를 사용하기는 쉽지 않다. 레포지토리를 이용하지 않다보니 유틸 함수의 집합이 되게 되는데, 이렇게 되면 말 그대로 하나의 거대한 절차지향적 스크립트가 되어버린다. 실제로 다른 언어에선 서비스레이어의 아주 세부적인 구현은 private 함수로 만들어 숨겨놓는데, 하나의 함수 안에 작성하면 그러기 쉽지 않기 때문이다.

장고 서비스 레이어 예시

def create_gradable_solution(
    *,
    task: IncludedTask,
    user: BaseUser,
    code: str=None,
    file: BinaryIO=None
) -> Solution:

    if code is not None and file is not None:
        raise ValidationError("Provide either code or a file, not both!")
    if code is None and file is None:
        raise ValidationError("Provide either code or a file!")
    if code is not None:
        new_solution = Solution.objects.create(
            task=task,
            user=user,
            code=code,
            status=6
        )
    if file is not None:
        new_solution = Solution.objects.create(
            task=task,
            user=user,
            file=file,
            status=6
        )

    return new_solution

출처: HackSoftware/Odin

Rails: ActiveInteraction

레일즈 이후의 프레임워크들, 특히 장고는 레일즈의 영향을 많이 받았다. 동적 언어고 프레임워크의 설계 기조에서 닮아 있는 부분도 많기 때문에 레일즈를 살펴보는게 많은 도움이 된다. 레일즈는 이런 문제 해결을 위해 ActiveInteraction이라는 젬을 많이 이용한다. 하나의 거대한 서비스 객체보다는 여러 개의 커맨드같은 형태로 서비스레이어를 나누게 된다.

class Square < ActiveInteraction::Base
  float :x

  def execute
    x**2
  end
end

위와 같이 선언하고 아래와 같이 사용한다.

outcome = Square.run(x: 'two point one')
outcome.valid?
# => nil
outcome.errors.messages
# => {:x=>["is not a valid float"]}

outcome = Square.run(x: 2.1)
outcome.valid?
# => true
outcome.result
# => 4.41

비슷한 것을 장고에서도 할 수 있지 않을까?

Django: Interaction?

장고에서 비슷한 것을 만들어보자. 우선 실행은 다음과 같은 시그니처를 가졌으면 좋겠다.

Add.run(x=4, y=5)

다음과 같이 훅이 있다면 유용할 거 같다.

class Add:
	def before_run(self):
    	print("before!!")
    
    def after_run(self):
    	print("after!!!")
    
    def run(self, x, y):
    	return x + y

Add.run(x=4, y=5)
# before!!
# 9
# after!!!

대략 다음과 같이 추상클래스를 만들면 될 거 같다.

from abc import ABC, abstractmethod


class Interaction(ABC):
    @abstractmethod
    def execute(self):
        pass

    @classmethod
    def run(cls, *args, **kwargs):
        obj = cls(*args, **kwargs)
        obj.before_run()
        obj.execute()
        obj.after_run()

    def before_run(self):
        pass

    def after_run(self):
        pass

구현하는 쪽은 execute를 반드시, 그리고 runbefore, after 훅을 선택적으로 구현하면 된다.

위 추상 클래스를 상속받아 구현하면 다음과 같다.

class BlockInteraction(Interaction):
    OPERATOR_USERNAME = "admin1024"

    def __init__(self, post, operator=None, notify_to_slack=True):
        self.notify_to_slack = notify_to_slack
        self.post = post
        self.author = post.author

        if not operator:
            operator = User.objects.get(username=self.OPERATOR_USERNAME)
        self.operator = operator

    def execute(self):
        self.post.block(blocked_by=self.operator)
        
        sender = Sender(self.operator)
        message = BlockNotifyChatTemplate(post=self.post, operator=self.operator).content

        sender.chat(receiver=self.author, message=message)

    def after_run(self):
    	self.post.clear_cache()
        
        if self.notify_to_slack:
            self._notify_to_slack()

    # protected

    def _notify_to_jandi(self):
        operator_info = f"{self.operator.nickname}({self.operator.username})"
        
        SlackJob.info("수동 제재", {
			"작업자": operator_info,
            "글": self.post.permalink,
            "글쓴이": self.author.nickname,
		})

장고에서 여러 도메인에 걸쳐있는 비즈니스로직을 처리하느라 고민하는 분들께 조금이나마 도움이 되었으면 좋겠다.

profile
즐기는 거야

4개의 댓글

comment-user-thumbnail
2022년 4월 12일

멋진 접근이네요!

논리 레이어를 나누다보면,
어디까지 도메인인지, 어디서부터 서비스인지 - 그러니까 트랜잭션 스크립트인지 - , 혹은 어디서부터가 컨트롤러인지 명확하지 않은 경우가 많죠.

좋은 접근방법 잘 봤어요 :)

1개의 답글
comment-user-thumbnail
2022년 5월 13일

좋은 글 감사합니다!
저도 매우 동감하는 바입니다.

DRF를 사용하다 보면 예전에는 비즈니스 로직을 view나 serializer에 두는 편이었는데,
최근들어 서비스로직을 따로 분리해서 사용하고 있는데 훨씬 깔끔하고 좋더라고요!

다만 말씀하신 대로 Repository 패턴을 Django에서는 채택하고 있지 않아 제가 고려하지 못했던 점과, 적절한 추상클래스를 설계하지 않았던 문제로 기존 코드들의 서비스 로직들이 복잡해져 유연성이 떨어진 것을 발견했습니다.
(최근에 Architecture Patterns with Python라는 책을 공부중인데, 해당 책에서 Django로 Repository를 사용하는 방법이 나와 있더라고요. 그냥 할 수 있구나~ 정도로만 넘긴 기억이 있습니다.)

그래서 리팩토링 과정중에서 여러 레퍼런스를 찾게 되었고, 이 글이 많이 도움이 되었습니다 :)
(도메인 로직은 최대한 Mixin이나 커스텀 쿼리셋을 통해서 행위의 제한을 해결하려고 합니다)

1개의 답글