Django와 Layered Architecture 사이에서 타협점 찾기

아테나스랩
아테나스랩 팀블로그
19 min readApr 14, 2023

안녕하세요. 저는 아테나스랩에서 서버 챕터 리드를 맡고 있는 Leo입니다. 저희 서버챕터는 Python Django 프레임워크를 사용하여 140만 명이 사용하는 학교생활 필수 앱 오늘학교와 학원정보를 제공하는 오늘학교 아카데미 웹 서비스에 대한 백엔드 개발과 유지보수를 하고 있습니다.

시장에 빠르게 제품을 선보이고 검증해야 하는 여러 스타트업의 상황이 동일하듯이 개발을 하다 보면 생산성과 안정성 사이 Trade-Off가 발생하는 상황을 자주 마주치게 됩니다.

이 글을 통해 그러한 상황에서 저희들이 어떤 결정을 내렸는지, 그 과정에서 어떤 시행착오들이 있었고 어떻게 해결했는지, 또 앞으로의 방향성은 무엇인지에 대해 이야기 해보려 합니다.

초기 제품 런칭 이후 서비스가 성장하면서 팀원들이 늘어나고 아키텍처 구조 변경에 대한 고민을 시작하는 분들께 이 글이 도움이 되었으면 좋겠습니다.

어떤 문제가 있었나요?

Django에서 제공하는 어드민, 보안, 템플릿 등의 빌트인 기능은 개발자가 신경 써야 하는 것들을 줄여 높은 생산성을 가질 수 있게 해주는 장점이 있습니다. 저희 팀은 그런 Django의 장점을 적극 사용하며 MTV(Model-Template-View) 패턴을 바탕으로 초기 애플리케이션 구조를 만들어 사용하고 있었습니다. 하지만 점점 팀의 규모가 커지고 서버 챕터의 개발자가 충원되는 과정에서 저희 서버 챕터는 아래의 문제에 직면하게 되었고, 높은 생산성이 장점이었던 Django는 오히려 개발 속도를 지연시키고 성능의 문제를 발생시키게 됩니다.

[문제점]

  1. 기능 개선 및 추가가 진행됨에 따라 Controller 역할을 하는 View단의 로직들이 절차지향적으로 늘어남으로써 View 클래스 하나가 스크롤을 해야 확인될 정도로 코드가 길어짐
  2. 모듈별 역할이 정립되어 있지 않아 View, Model, ModelSerializer, ModelForm 등 서로 다른 위치에 비즈니스 로직을 구현함으로써 코드가 중복되고, 이에 대한 유지 보수가 어려워짐
  3. 코드 컨벤션의 부재, 개발자마다 다른 코딩 스타일 차이로 인해 코드를 작성한 개발자만이 유지 보수가 가능해짐

아테나스랩 서버챕터는 모두 주니어 개발자로 구성되어 있었고, 여전히 빠르게 제품 개발에 몰두하여 결과물들을 만들어내야만 하는 상황이었습니다. 이런 상황에서 저희에게 필요한 아키텍처는 확장성이 높고, 유지보수가 편리하며, 러닝커브가 높지 않은, 그리고 무엇보다도 생산성이 뛰어나야 했습니다. 이를 바탕으로 저희는 아키텍처 개선의 방향성을 아래와 같이 설정했습니다.

[개선 방향성]

  1. 적절한 아키텍처와 디자인 패턴을 도입하고 모듈별 역할을 정의하여 코드 일관성을 유지한다.
  2. 혼재되어 있는 비즈니스 로직들을 통합하여 코드가 어떤 역할을 하는지 쉽게 파악하도록 한다.
  3. Django가 제공하는 생산성을 해치지 않는 측면에서 빌트인 기능 사용을 줄여 추후 높은 성능을 가진 경량화된 프레임 워크로의 전환을 용이하게 한다.

여러가지 검증된 아키텍처와 설계방식이 존재하였지만 개발자가 적은 팀의 상황에서 만약 MSA(Micro Service Architectrue)를 선택하면 독립적으로 나누어진 서비스들로 인해 관리 포인트가 늘어날 것 같았습니다. 그리고 도메인 전문가의 부재 상황에서 DDD(Domain Driven Design) 설계방식을 선택한다면 개발자들 간 도메인 이해도 차이로 인해 많은 설계 이슈와 커뮤니케이션 비용이 발생할 것 같았습니다.

그래서 저희는 당장의 문제해결에 초점을 맞추어 모듈별 책임을 분명하게 할 수 있고 러닝커브가 크게 높지 않은 Layered 아키텍처를 적용해보기로 했습니다.

Django와 Layered Architecture 사이에서 타협점 찾기

레이어드 아키텍처는 관심사 분리에 따라 각각의 레이어가 하위 레이어에만 의존하도록 구성하는 아키텍처입니다. 일반적으로 Presentation Layer, Business Layer, Persistence Layer, Database Layer의 4계층 레이어로 구성하여 애플리케이션의 결합도를 낮추고 유지보수성을 향상시키는데에 목적이 있습니다.

Presentation Layer 사용자 인터페이스와 상호 작용하는 계층. 사용자의 요청을 받아 처리하고 결과를 반환

Business Layer 비즈니스 로직을 구현하는 계층. 데이터 검증, 프로세스 제어 등의 작업을 수행

Persistence Layer 데이터베이스와의 상호작용이 이루어지며 유효성 검사, 오류 처리, 데이터의 CRUD 작업 등을 수행

Database Layer 데이터베이스를 정의.

저희는 해당 아키텍처에 대한 적합성을 검증하지 못한 상황이었고, 시행착오를 겪어보지 않았기에 레이어를 엄격하게 구현하고 싶지 않았습니다. 그래서 저희는 Django가 가진 기본적인 구조를 깨지 않는 선에서 Presentation Layer의 역할을 수행하는 View만을 유지한 채 나머지 Business Layer와 Persistence Layer는 통합하여 구현하기로 결정했습니다.

먼저, Model과 View의 역할을 정의하고 컨벤션을 정립하였습니다. 초기에는 빠른 개발 생산성을 위해 모든 비즈니스 로직을 Model에 구현했었는데, 해당 비즈니스 로직들을 제거하고 데이터 모델 정의로서의 역할로만 구현하였습니다. View는 클라이언트의 HTTP 요청을 처리하고 응답만 반환하는 사용자 인터페이스와 관련된 로직만을 처리하게 하였습니다. 해당 View는 Presentation Layer에 속하며, Http Request, Authentication, Json Response의 역할을 담당하게 됩니다.

[Model 변경 전후 예시]

<기존의 Fat Model 패턴 예시>

class Post(models.Model):
user = models.ForeignKey("User", on_delete=models.PROTECT)
title = models.CharField(max_length=100)
text = models.CharField(max_length=1000)
view_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
deleted_at = models.DateTimeField(null=True)

class Meta:
ordering = ("-id",)
verbose_name = "Post"
verbose_name_plural = "Posts"

def save(self, *args, **kwargs):
...

def update_title(self, *args, **kwargs):
...

def update_text(self, *args, **kwargs):
...
<변경된 데이터 정의 Model 예시>

class Post(models.Model):
user = models.ForeignKey("User", on_delete=models.PROTECT)
title = models.CharField(max_length=100)
text = models.CharField(max_length=1000)
view_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
deleted_at = models.DateTimeField(null=True)

class Meta:
db_tabl = "post"

[View 변경 전후 예시]

<기존의 복잡한 모든 로직이 혼재되어 있는 View 예시>

class PostCreateView(APIView):
authentication_classes = (...)

def post(self, request, *args, **kwargs):
user = self.request.user
title = self.request.data.get("title")
text = self.request.data.get("text")
try:
post = Post.objects.create(user, title, text)
response_data = ...
...
# 이후 각종 비즈니스 로직을 포함한 코드들
except Exception as e:
raise ...
return JsonResponse(status=201, data=response_data)
<변경된 Presentation의 역할만을 수행하는 View 예시>

class PostCreateView(APIView):
authentication_classes = (...)

def post(self, request, *args, **kwargs):
try:
post = create_post(**kwargs)
# Business Layer인 서비스 로직만을 호출
except Exception as e:
raise ...
return JsonResponse(status=201, data=post_serializer(post=post))

Model과 View에서 분리된 비즈니스 로직들은 Service Layer에서 처리할 수 있도록 구현하였는데 하나의 Service 함수 내에서 비즈니스 플로우에 따른 처리를 모두 수행할 수 있도록 트랜잭션 스크립트 패턴(Transaction Script Pattern)을 적용하였습니다.

트랜잭션 스크립트 패턴은 일련의 비즈니스 로직들을 하나의 트랜잭션으로 묶어 단일 함수 또는 단일 스크립트에서 처리하는 구조를 가진 패턴입니다. 해당 패턴은 로직의 응집도가 높아져 재사용성, 확장성이 떨어진다는 단점이 존재하지만 로직의 흐름이 단반향이라 이해하기 쉽고 구현이 간단하다는 장점이 있습니다. 이런 이유로 여러 곳에 구현되어 있던 기존의 비즈니스 로직들을 제거하고 Service Layer로 재구현해야 했던 저희 상황에는 적합한 패턴이라 판단하여 적용하게 되었습니다.

또한, 트랜잭션 스크립트 내에서 사용자 액션과 무관한 비즈니스 로직들은 Python에서 동시성 프로그래밍이 가능하도록 해주는 Celery 라이브러리를 사용하여 트랜잭션의 수행에 영향이 없도록 하였습니다.

위와 같이 구현된 해당 Service Layer는 Business Layer에 속하며 Create, Update, Delete Query, Business Logic, Validation의 역할을 담당하게 됩니다.

<INSERT, UPDATE, DELETE 쿼리와 여러 비즈니스 로직들이 수행되는 Service 예시>

@transaction.atomic
def create_post(user_id: id, title: str, text: str) -> Post:
"""
1. title과 text 데이터 정합성을 검증합니다.
2. Post 객체를 생성합니다.
3. Post 생성 슬랙알림을 발송합니다.
"""
if not title or not text:
raise ValueError("No required fields.")

if len(title) > 100:
raise ValueError("Post title must be 100 characters or less.")

if len(text) > 1000:
raise ValueError("Post text must be 1000 characters or less.")

post = Post.objects.create(user=user, title=title, text=text)

transaction.on_commit(
lambda: send_slack_for_create_post.apply_async((post.id,))
)
return post

그리고 ORM(Object Relational Mapping) 쿼리를 쉽게 최적화하기 위해 조회(Select)의 역할만을 수행하는 ORM들을 Selector Layer로 분리하였고, 동적일 필요가 없는 데이터를 제공해야 하는 상황이라면 Selector 함수에 캐싱을 적용하여 DB 부하를 최소화하였습니다. 해당 Selector Layer 또한 Business Layer에 속하며 Select, Exists Query의 역할을 담당하게 됩니다.

<SELECT, EXISTS 쿼리만을 수행하는 Selector 예시>

def get_post_by_id(post_id: int) -> Optional[Post]:
try:
return Post.objects.filter(id=post_id, deleted_at__isnull=True).get()
except Post.DoesNotExist:
return None

def get_post_queryset_by_user_id(user_id: int) -> "QuerySet[Post]":
return Post.objects.filter(user_id=user_id, deleted_at__isnull=True)

def check_is_exists_post_by_user_id(user_id: int) -> bool:
return Post.objects.filter(user_id=user_id, deleted_at__isnull=True).exists()

개선 후 아키텍처 구조가 직관적이고 단순해져서 개발이 빠르게 진행될 수 있었으며 레이어 간의 역할이 명확해졌기 때문에 쿼리 최적화가 필요하다면 Selector, 비즈니스 로직 파악이 필요하다면 Service, 응답 형태를 확인하고 싶다면 View를 확인하기만 하면 되는 구조가 되었습니다.

또한, 비즈니스 로직에 대한 코드가 하나의 트랜잭션으로 구성되어 있기 때문에 개발자 누구나 각각의 트랜잭션을 명확하게 파악하고 이해할 수 있게 되었고, 개별 트랜잭션의 단위 테스트에 적합했습니다. 트랜잭션 단위 테스트로 해당 트랜잭션에서 기대되는 결과를 검증하는 것이 용이해진 것입니다.

시행착오는 없었나요?

1. 강한 결합으로 인한 재사용의 어려움

앞서 드린 설명처럼 저희가 적용한 Business Layer는 Service, Selector 두 가지의 Layer가 포함되어 있으며 특정 Service 함수 실행에 필요한 Selector 함수들이 존재하였습니다.

Selector는 다른 레이어의 함수들을 호출하지 못하고, 대부분 Service 함수에서 Selector 함수들을 호출하도록 되어 있었는데 레이어 간의 호출이 일관적으로 이루어져 구현이 간단하지만, 강한 결합으로 인해 Selector 함수의 재사용이 어려워 코드가 중복되는 문제점이 생겼습니다.

<Selector 레이어 분리 후 거대해진 selectors 파일이 포함된 디렉토리 구조 예시>

app_name
├── api
├── ...
├── selectors.py # 하나의 앱 단에 존재하는 selectors 파일 내에 selector 함수들을 지속해서 확장
└── services.py
<Selector 레이어를 모듈화하고 논리적인 역할에 맞도록 함수 네이밍을 세부적으로 구분한 예시>

app_name
├── api
├── ...
├── selectors
│ ├── all_posts.py
│ ├── user_posts.py
│ ├── search_posts.py
│ └── ...
└── services

def get_post_by_id_for_staff_user(id: int) -> Optional[Post]:
# 스태프 유저를 위한 selector, 삭제된 Post를 포함하여 조회합니다.
...

def get_post_with_comment_queryset_by_post_id(post_id: int) -> "QuerySet[Post]":
# post id를 통해 Comment모델 prefetch를 수행한 post 쿼리셋을 조회합니다.
...

이를 해결하기 위해 Selector와 Service Layer에 속해 있는 함수들을 더 논리적인 단위로 분리하고 디렉토리와 함수명에 역할에 맞는 접두사와 접미사를 지정하는 네이밍 컨벤션을 추가, 분리하여 당장의 문제를 해결하였습니다.

2. 단일 역할 책임 위배로 인한 문제

기존 Service Layer에는 비즈니스 로직의 수행 및 데이터 정합성 검증의 두 가지 역할이 있었습니다. Service Layer의 비즈니스 로직이 복잡해지면서 가독성을 떨어뜨리고, 개발자가 집중해야 할 비즈니스 로직을 파악하는 데 어려움이 있었습니다. 이를 해결하기 위해 데이터를 검증하고 설정들을 관리하는 Python 라이브러리인 Pydantic을 사용한 Validator Layer를 추가하여 데이터 정합성 검증의 역할을 분리하였습니다. Validator를 통해 데이터 검증을 완료한 후 Service 레이어를 호출하게 됨으로써 Service Layer는 비즈니스 로직의 수행에만 집중할 수 있게 되었습니다.

<데이터 검증의 역할이 분리된 Validator 레이어 예시>

class PostCreateValidator(pydantic.Basemodel):
title: str
text: str

@validator("title")
def title_length(cls, v):
if len(title) > 100:
raise ValueError("Post title must be 100 characters or less.")

@validator("text")
def text_length(cls, v):
if len(text) > 1000:
raise ValueError("Post text must be 1000 characters or less.")

3. Django Active Record 패턴의 문제

Django에서는 Model과 DB가 직접 연결되어 있는 Active Record 패턴을 차용하고 있습니다. Service 및 Selector Layer에서 Model과 직접적으로 상호 작용하기 때문에, DB 테이블의 변경에 따라 모든 코드를 수정해야 하는 문제점이 있었습니다. 이를 해결하기 위해 컬럼 수정을 최소로 하는 등 DB 테이블 마이그레이션 규칙을 정립하여 문제를 최소화하였습니다.

앞으로의 방향성

애플리케이션의 규모가 점점 커지고 복잡해짐에 따라 더 세분화된 계층 구조가 필요할 수도 있습니다. 모듈 세분화, 네이밍 컨벤션을 정립하여 더 나은 구조로 개선했지만, Selector와 Service Layer간의 강한 결합을 해결하기 위해 추상화 계층을 추가하여 인터페이스를 최소화하려고 합니다.

<추상화 계층 추가 예시>

# selectors/abstracts.py
class AbstractPostSelector(abc.ABC):
@abstractmethod
def get_post_by_id(self, post_id: int) -> Post:
pass

@abstractmethod
def get_post_queryset_by_user_id(self, user_id: int) -> "QuerySet[Post]":
pass

# selectors/posts.py
class PostSelector(AbstractPostSelector):
def get_post_by_id(self, post_id: int) -> Post:
return Post.objects.filter(id=post_id, deleted_at__isnull=True).get()

def get_post_queryset_by_user_id(self, user_id: int) -> "QuerySet[Post]":
return Post.objects.filter(user_id=user_id, deleted_at__isnull=True)

또한, Service Layer에서 의존성 주입 패턴을 사용하여, 데이터 액세스 로직과 비즈니스 로직을 각각의 클래스로 분리하고 유연한 구조를 유지할 수 있게 하려고 합니다.

<selector 의존성 주입 예시>

class PostService:
def __init__(self, selector: PostSelector):
self.selector = selector

def update_post(self, post_id: int, title: Optional[str] = None, text: Optional[str] = None) -> Post:
post = self.selector.get_post_by_id(post_id=post_id)
post.title = title
post.text = text
post.save()
return post

이처럼 위에 소개한 두 가지 방향성 외에도 저희에게 맞는 아키텍처와 패턴을 선택하여 지속적으로 구조를 개선해나가고 있습니다.

다만, 기능 추가 및 유지보수 과정에서 해당 시점에 맞는 아키텍처는 계속 변하기 때문에 아키텍처 변경에 드는 비용보다 아키텍처 변경으로 인한 효용성이 커지는 시점에 도달할 때마다 거대한 모놀리식 형태에서 적절한 규모로 서버를 분리해나가고 있습니다.

최근에는 모든 애플리케이션 로직들이 하나의 모놀리식 서버에 구현되어 있는 상태에서 데이터 처리와 수집, 통계 등의 역할을 별도로 수행할 수 있도록 성능이 검증된 Python FastAPI 프레임워크를 채택해 서버를 분리하였습니다.

글을 마치며

이 글에서 소개해드린 Django와 Layered Architecture 사이에서 찾은 타협점들 덕분에 초기에 정의한 문제점들을 어느정도 해소할 수 있게 되었습니다. 산재되어 있던 비즈니스 로직으로 인해 유지보수를 하기 위해 모든 코드를 뒤져야 하는 상황이 줄어들었고, 새로운 개발자가 합류하더라도 코드 일관성을 계속 유지할 수 있게 되었죠.

무엇보다도 팀원들과 함께 문제점을 정의하고, 개선 방향성을 세워 해결해나가는 과정, 저희에게 어울리는 아키텍처를 찾으며 마주한 의사결정의 순간들과 시행착오들이 제일 갚진 경험이었습니다.

이처럼 아테나스랩 서버챕터는 팀의 상황과 규모, 프로젝트의 크기와 복잡성, 개발자의 역량에 따라 새로운 시도를 하며 개발해오고 있습니다. 개발에 있어서 명확한 정답은 없고 정답에 가까운 방향만이 있다고 생각하는 서버 챕터의 문화를 바탕으로 저희가 겪은 여러가지 시도와 문제해결 과정, 방향성을 앞으로도 공유하도록 하겠습니다. 감사합니다.

저희 아테나스랩과 함께 성장하실 분을 찾고 있습니다! 🕶

좋은 서비스를 만드는 것에 더불어, 저희 아테나스랩과 더 나은 기술을 고민하고 서로 자극이 될 수 있는 조직을 만들며 성장하고 싶은 분들은 아래 채용 중 포지션 페이지를 참고해 주세요 🎉

채용 중 포지션 : https://prompie.com/s/alq3upis/

--

--