Service가 왜 없어요!? - Service Layer 도입기

쩡뉴·2024년 10월 9일
2

백엔드 개발

목록 보기
6/8
post-thumbnail

들어가는 글 🙋‍♀️

Flask는 매우 유연한 웹 프레임워크다. Flask로 백엔드 개발을 하면서 미처 고려하지 않았었지만, 도입해보니 좋았던 'Service Layer'에 대한 정리를 해보고자 한다.

그동안 내가 겪은 백엔드 아키텍처 🫢

회사에 입사한 후부터는 줄곧 Flask로만 개발을 해왔다. 진행되고 있던 프로젝트에 투입되어 개발하였고, 회사에서 지정한 나름의 개발 아키텍처를 따라 가려고 했다. 기본적으로는 MVC 패턴을 따르고 있었으나 완전한 MVC 패턴이라고 할 수도 없었다. 폴더 구조로 나뉜 바를 구체적으로 설명하자면, 크게는 controllers, models, 그리고 백그라운드 태스크에서 수행할 로직이 정리된 각 도메인의 폴더로 나뉘어져 이 구조로 개발을 했다. 폴더 구조의 예시는 다음과 같다. (주제와 벗어난 다른 폴더들은 설명 하지 않는다!)

main
ㄴ controllers
   ㄴ domain_a
   		ㄴ list.py
        ㄴ info.py
   ㄴ domain_b
        ㄴ list.py
ㄴ domain_a
	ㄴ extractors.py
ㄴ models
	ㄴ domain_a.py
    ㄴ domain_b.py
  • models
    • 데이터 모델에 대한 설계를 하고, 데이터 접근 및 CRUD에 대한 로직을 구현했다.
      # 예시라서 자잘한 import와 코드는 생략...
      class DomainA(db.model):
      	id = db.Column(db.Integer, primary=True, autoincrement=True)
          name = db.Column(db.String)
          
          @classmethod
          def get(cls, id_):
            return cls.query.filter(cls.id == id_).one_or_none()
          
          @classmethod
          def insert(cls, name):
            domain_a_inst = cls(name)
            db.session.insert(domain_a_inst)
            db.session.commit()
            return domain_a_inst.id
          
          def update(self, name):
            self.name = name
            db.session.commit()
  • controllers
    • API 컨트롤러를 모아두었다. endpoint와 request/response 스키마를 적용하는 동시에 대부분의 비즈니스 로직이 함께 작성되었다. 또한 model에서 정의한 데이터 접근 메소드를 여기서 직접 사용했다.

      # 예시라서 자잘한 import와 코드는 생략...
      from main.models.domain_a import DomainA
      domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a")
      
      @domain_a_bp.patch("")
      def get_domain_a(id, name):
      	"""업데이트 API"""
      	domain_a = DomainA.get(id)
          if domain_a is None:
      		return "Not Found", 404
          domain_a.update(name)
      	return domain_a._asdict(), 200
  • 백그라운드 태스크에서 수행할 로직이 정리된 각 도메인의 폴더(?)
    • 위 예시에서는 main/domain_1에 해당하는 폴더이다.
    • 주로 데이터 ETL 파이프라인을 관리하기 위해 만든 폴더이며, 도메인에 따라 ETL이 필요한 경우에 따로 폴더 관리를 했다.

'Service Layer'가 왜 없어요? 🤔

그러다가 새로운 시니어 개발자들이 입사를 하면서 현 상황의 문제점을 제기했다. 백엔드 회의에서 'Service Layer가 없는데 그 이유가 뭔가요?'라는 말과 함께 레이어 도입에 대한 설득을 했다. 듣고 보니 그렇다. '왜 없었지?'

처음에는 크게 고안되지 않았을 수도 있다. Flask는 원체 유연하니까, 서비스가 그다지 크지 않았으니까, 일단 어플리케이션이 돌아가면 되지! 등등.. 하지만 현재는 서비스가 많이 커졌고, 실제로 프로젝트에 반영되어야 할 도메인도 엄청 많아졌다. (초기에는 2~3개였다면 지금은 10개 가까이 되고 있음) 관리가 어려워지던 차에, 비즈니스 로직이라도 따로 떼내면 좀 더 관리 편리성이 오를까 싶은 생각이 들어 service layer 도입에 적극 찬성했다.

[그동안 내가 알던 레이어는 포토샵의 레이어였을 뿐...]

Service Layer란?

Service Layer는 'Business Layer'라고도 하며, 기본적인 비즈니스 로직이 구현되는 레이어다. 프론트로부터 요청 받은 데이터를 가지고 기능적 정의를 통해 결과를 만들어내는 과정을 말한다.

좀 더 쉽게 말해, 레스토랑 주방에서 주문 받은 요리를 절차와 순서에 맞춰 요리하는 프로세스를 코드로 구현한다는 것..이다.

[미안합니다... 죄송합니다...]

(일단... 넘어갑니다~)

어떻게 적용했을까?

위의 컨트롤러 예시를 다시 끌어왔다.

from main.models.domain_a import DomainA
domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a")
    
@domain_a_bp.patch("")
def update_domain_a(id, name):
  	domain_a = DomainA.get(id)
    if domain_a is None:
   		return "Not Found", 404
    domain_a.update(id, name=name)
   	return domain_a._asdict(), 200

여기에서의 비즈니스 로직은 "DomainA 테이블에서 받은 id로 데이터를 가져온다. 만약 그 데이터가 없을 땐 'Not Found'를 표출한다."가 될 것이다. 이를 service layer로 잘 쪼개보자!

서비스 레이어 만들기

main/services/domain_a.py라는 스크립트에 다음과 같이 정의한다.

from main.models.domain_a import DomainA

class DomainASerivce:
	@staticmethod
	def update(id_, name):
    	# 트랜잭션 관리하는 건 생략, 트랜잭션에 대한 내용은 다른 포스팅에서 다루겠다.
	  	domain_a = DomainA.get(id_)
        domain_a.update(name)
        return domain_a

그리고 컨트롤러에서는 model을 가져다가 쓰는 것이 아닌 service를 가져다가 쓴다.

from main.service.domain_a import DomainAService
domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a")
    
@domain_a_bp.patch("")
def update_domain_a(id, name):
	try:
  		domain_a = DomainAService.update(id, name)
	   	return domain_a._asdict(), 200
    except NotFoundError:
    	return "Not Found". 404

확실하게 service와 controller가 분리되었다고 할 수 있다. (간단한 구현이라 차이점이 좀 안 느껴지는 거 같기도...)

[오빠 나 뭐 달라진 거 없어!? (나도 잘 모르겠다는 게 함정)]

도입해보니 어떤가? 💡

서비스가 분리가 되면서 해소 되었던 것은 다음과 같다.

컨트롤러로부터 비즈니스 로직이 분리가 되었다

그동안에는 컨트롤러 내부에 비즈니스 로직을 구현했던 터라 동일한 로직을 다른 컨트롤러나 백그라운드 태스크 핸들러 내부에서 사용하게 될 때를 고려한 함수를 따로 만들기도 했지만, 컨트롤러 스크립트를 참조하는 경우가 생겼다. 예시는 다음과 같다.
만약 위의 업데이트 로직을 다음과 같이 함수화 해놓는다 해도,

from main.models.domain_a import DomainA
domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a")

def update_domain_a(id, name):
	# 컨트롤러 내부에 함수를 만들어 쓰곤 했었다.
  	domain_a = DomainA.get(id)
    domain_a.update(id, name=name)
    return domain_a

@domain_a_bp.patch("")
def update_domain_a(id, name):
	try:
		domain_a = update_domain_a(id, name)
   		return domain_a._asdict(), 200
    except NotFoundError:
    	return "Not Found", 404

다른 스크립트에서 update_domain_a 함수를 갖다 쓰고 싶으면 위 controller 스크립트를 import 해야 한다. 그리고 각 controller에서 위와 같이 함수를 갖다 쓰다보면 circular import(서로를 참조)하는 경우도 빈번했다.

service layer로 비즈니스 로직을 분리하면, 위와 같은 경우처럼 controller 스크립트를 참조하거나 circular import가 일어나지 않는다.

재사용성에 용이하다

위의 문제로 인해 코드를 중복해서 작성하는 경우도 있었다. 그리고 내가 작업하지 않은 파일이라면 해당 파일 내에 저런 update_domain_a 같은 함수가 존재하는지를 모르기도 한다. 이럴 땐 service layer라는 명확한 컨센서스를 가지고 가면 service layer에 해당 로직이 구현되었는지 확인하면 된다.

그리고 domain_b에서 domain_a의 비즈니스 로직을 가져다 써야하는 경우도 생긴다. 백그라운드 태스크 등의 비동기 로직에서 비즈니스 로직을 쓰기도 한다. 그러다보니 이 모든 것을 service layer로 분리했을 때 그 재사용성이 좋아지는 경험을 했다.

앞으로는 뭘 더 해야 할까? 🛳️

서비스 레이어 도입을 통해서 이미 널리 쓰이고 있는 레이어드 패턴을 잘 적용하면 좋겠다는 생각이 들었다. 가령, 현재는 모델 클래스에 직접 메소드를 정의한 것이 곧 repository 레벨일 수 있는데, 이를 잘 분리해볼 필요가 있단 생각이 들었다. 이 또한 모델 레벨에서 관리하다 보면 참조 테이블 간의 로직을 사용해야 할 때 디펜던시가 걸리기 때문이다.

class DomainA(db.model):
  	id = db.Column(db.Integer, primary=True, autoincrement=True)
    name = db.Column(db.String)

    @classmethod
    def get(cls, id_):  # repositoy로 분리 가능할 것 같다!
    	return cls.query.filter(cls.id == id_).one_or_none()

    @classmethod
    def insert(cls, name):  # 이것도 마찬가지!
        domain_a_inst = cls(name)
        db.session.insert(domain_a_inst)
        db.session.commit()
        return domain_a_inst.id

그리고 위에서 언급한 데이터 ETL를 처리하기 위한 백그라운드 태스크에서 수행할 로직이 정리된 각 도메인의 폴더(?)도 service layer에 편입 시킬 수 있을 거 같단 생각이 들었다.

(본격 레거시 타파하기..)

마치며 🤗

입사 전 교육을 받았던 당시에 썼던 Django로 백엔드를 잠깐이라도 해본 경험에 의하면, Django에서도 MTV라는 아키텍처 패턴이 존재하고 이에 strict하게 구현을 해야 했다.
이에 비하면 Flask는 매우 유연한 프레임워크다. 정해진 구조화 패턴이 없기 때문에 때론 매우 편하다. 서비스가 작았을 때만 해도 그 문제를 크게 못 느꼈지만, 점점 규모가 커지면서 더더욱이 체계적인 구조가 필요했고, DDD(Domain Driven Development)가 표준이 된 만큼 최대한 따라가도록 해야겠다 싶다.

오랫동안 Flask 프레임워크를 쓰면서 flexible하게 살아왔고, 관리 포인트도 많지 않아 크게 문제가 되지 않았지만. 같이 개발하는 동료들과 컨센서스를 맞추려면 '표준'을 따르는 게 가장 쉽고 명확한데 이를 오래 간과했던 것 같단 생각이 들었다. 각 레이어에 맞게 개발해서 역할 분리가 잘 된다면 커뮤니케이션 비용도 많이 줄어들겠다 싶다. (실제로도 체감 중...)

삶의 지도 말미에 적었던 것과 같은 결로, 내가 몰랐던 것을 알아갈 때 나는 발전한다고 믿는다. 그래서 결국엔 또 '공부를 끊임 없이 해야 한다'로 귀결된다. 타인이 말하는 말에도 그냥 지나치지 말아야 하며, (좀 더 능동적으로 행동하자는 의미에서) 스스로도 찾아 나서는 힘을 계속 길러야겠단 생각이 새삼 들었던 경험이었다.

앞으로도 레거시 타파를 위한 글을 좀 더 작성해보고자 한다. To be continued...!

[뭐 어쨌거나 아무튼 돌아가고 있어서 일단 다행인 걸로... 하지만 하나씩 고쳐보자.]
profile
파이썬으로 백엔드를 하고 있습니다. Keep debugging life! 📌 archived: https://blog.naver.com/lizziechung

0개의 댓글