현대 백엔드 프레임워크에서 클린 아키텍처 구축하기
Emily Parker
Product Engineer · Leapcell

서론
끊임없이 진화하는 백엔드 개발 환경에서 기능적일 뿐만 아니라 확장 가능하고, 테스트 가능하며, 이해하기 쉬운 코드베이스를 유지하는 것은 매우 중요합니다. 프로젝트가 복잡해짐에 따라 초기 빠른 개발은 종종 복잡한 종속성, 취약한 테스트, 높은 변경 비용으로 이어집니다. 이러한 문제는 다양한 기술 스택 전반에 걸쳐 깊이 공감대를 형성하고 있어, 견고한 아키텍처 패턴의 채택이 개발자와 조직 모두에게 중요한 관심사가 되었습니다. 로버트 C. 마틴이 주창한 클린 아키텍처의 원칙은 관심사의 분리 및 프레임워크, 데이터베이스, UI로부터의 독립성을 강조함으로써 강력한 해결책을 제공합니다. 이 글에서는 이러한 강력한 원칙이 세 가지 주요 백엔드 프레임워크인 Django, NestJS, FastAPI에서 어떻게 실질적으로 적용될 수 있는지 살펴보고, 개발자가 더욱 복원력 있고 유지보수 가능한 애플리케이션을 구축할 수 있도록 지원합니다.
핵심 개념 이해
각 프레임워크의 세부 사항에 들어가기 전에 클린 아키텍처의 기본 개념을 명확히 이해하는 것이 필수적입니다.
- 엔티티 (도메인 계층): 기업 전체의 비즈니스 규칙을 캡슐화합니다. 이는 가장 순수하고 가장 높은 수준의 정책이며, 애플리케이션별 관심사로부터 독립적입니다. 데이터베이스, UI 또는 외부 프레임워크의 변경은 엔티티에 영향을 미치지 않아야 합니다.
- 유스 케이스 (애플리케이션 계층): 이 계층은 애플리케이션별 비즈니스 규칙을 포함합니다. 엔티티로의 데이터 흐름을 조율하고 애플리케이션이 엔티티를 어떻게 사용할지 결정합니다. 유스 케이스는 데이터베이스나 웹 프레임워크에 대해 알지 못해야 합니다. 인터페이스 (추상 클래스)는 종종 외부 관심사를 위해 여기에 정의됩니다.
- 인터페이스 어댑터 (인프라 계층): 이 계층은 유스 케이스와 외부 관심사 사이의 게이트웨이 역할을 합니다. 유스 케이스와 엔티티에 편리한 형식의 데이터를 프레임워크, 데이터베이스 또는 외부 서비스에 적합한 형식으로 변환하고, 그 반대로도 수행합니다. 예로는 프레젠터, 게이트웨이, 컨트롤러가 있습니다.
- 프레임워크 및 드라이버 (외부 계층): 이 가장 바깥쪽 계층은 내부 계층에서 정의된 인터페이스의 구체적인 구현으로 구성됩니다. 여기에는 웹 프레임워크(Django, NestJS, FastAPI), 데이터베이스(ORM 구현), 외부 API 통합이 포함됩니다.
여기서 기본 원칙은 종속성 규칙입니다: 종속성은 안쪽으로만 향할 수 있습니다. 안쪽 원은 바깥쪽 원에 대해 아무것도 알지 못해야 합니다.
클린 아키텍처 구현
선택한 프레임워크에 걸쳐 이러한 개념이 실제 코드 예제로 어떻게 번역되는지 살펴보겠습니다.
Django
Django는 "배터리 포함" 철학으로 인해 주의 깊게 접근하지 않으면 때로는 긴밀하게 결합된 애플리케이션으로 이어질 수 있습니다. 그러나 그 모듈성은 클린 아키텍처를 효과적으로 구현할 수 있도록 합니다.
프로젝트 구조 예시:
my_django_project/
├── core/ # 엔티티 및 유스 케이스
│ ├── domain/ # 엔티티 (순수 Python 객체)
│ │ ├── models.py # 예: User, Product 엔티티
│ │ └── __init__.py
│ ├── use_cases/ # 유스 케이스 (비즈니스 로직)
│ │ ├── create_user.py
│ │ ├── get_product.py
│ │ └── __init__.py
│ ├── interfaces/ # 외부 서비스 추상화
│ │ ├── user_repository.py # 예: abstractUserRepository
│ │ └── __init__.py
└── application/ # 인터페이스 어댑터 및 프레임워크
├── users/
│ ├── adapters/ # 데이터베이스/웹용 어댑터
│ │ ├── repositories.py # 구체적인 ORM 구현 (예: UserDjangoRepository)
│ │ └── controllers.py # 유스 케이스를 호출하는 Django 뷰/API 뷰
│ ├── urls.py
│ └── __init__.py
├── products/
│ └── ...
├── config/ # Django 프로젝트 설정
├── manage.py
└── ...
코드 예시 (Django):
-
core/domain/models.py
(엔티티):# 순수 Python 객체, Django ORM 상속 없음 class User: def __init__(self, id: str, username: str, email: str): self.id = id self.username = username self.email = email def update_email(self, new_email: str): # 비즈니스 규칙 if "@" not in new_email: raise ValueError("잘못된 이메일 형식") self.email = new_email
-
core/interfaces/user_repository.py
(추상 리포지토리):from abc import ABC, abstractmethod from core.domain.models import User class UserRepository(ABC): @abstractmethod def get_by_id(self, user_id: str) -> User: pass @abstractmethod def save(self, user: User): pass
-
core/use_cases/create_user.py
(유스 케이스):from core.domain.models import User from core.interfaces.user_repository import UserRepository class CreateUser: def __init__(self, user_repository: UserRepository): self.user_repository = user_repository def execute(self, user_id: str, username: str, email: str) -> User: user = User(id=user_id, username=username, email=email) # 저장 전 잠재적인 추가 비즈니스 로직 self.user_repository.save(user) return user
-
application/users/adapters/repositories.py
(구체적인 Django ORM 리포지토리):from django.db import models from core.domain.models import User as DomainUser from core.interfaces.user_repository import UserRepository # Django ORM 모델 (인프라 세부 사항) class UserORM(models.Model): id = models.CharField(max_length=255, primary_key=True) username = models.CharField(max_length=255) email = models.EmailField() class Meta: db_table = 'users' class DjangoUserRepository(UserRepository): def get_by_id(self, user_id: str) -> DomainUser: orm_user = UserORM.objects.get(id=user_id) return DomainUser(id=orm_user.id, username=orm_user.username, email=orm_user.email) def save(self, user: DomainUser): UserORM.objects.update_or_create( id=user.id, defaults={'username': user.username, 'email': user.email} )
-
application/users/adapters/controllers.py
(Django 뷰/컨트롤러):from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from core.use_cases.create_user import CreateUser from application.users.adapters.repositories import DjangoUserRepository import uuid class CreateUserAPIView(APIView): def post(self, request): user_id = str(uuid.uuid4()) username = request.data.get('username') email = request.data.get('email') if not username or not email: return Response({'error': '사용자 이름과 이메일이 필요합니다'}, status=status.HTTP_400_BAD_REQUEST) repo = DjangoUserRepository() create_user_use_case = CreateUser(user_repository=repo) try: user = create_user_use_case.execute(user_id=user_id, username=username, email=email) return Response({'id': user.id, 'username': user.username, 'email': user.email}, status=status.HTTP_201_CREATED) except ValueError as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
이 설정은 핵심 비즈니스 로직 (User
엔티티 및 CreateUser
유스 케이스)이 Django의 ORM 또는 HTTP 특정 사항을 완전히 알지 못하도록 보장합니다.
NestJS
NestJS는 Angular와 Spring에서 많은 영감을 받아 모듈, 컨트롤러, 서비스, 프로바이더의 사용을 통해 모듈화되고 구조화된 접근 방식을 촉진합니다. 이는 자연스럽게 클린 아키텍처와 잘 맞습니다.
프로젝트 구조 예시:
src/
├── core/ # 엔티티 및 유스 케이스
│ ├── domain/ # 엔티티 (인터페이스/클래스)
│ │ ├── user.ts
│ │ └── product.ts
│ ├── use-cases/ # 애플리케이션별 비즈니스 로직
│ │ ├── create-user.use-case.ts
│ │ ├── get-product.use-case.ts
│ │ └── interfaces/ # 여기에 정의된 추상 리포지토리/게이트웨이
│ │ ├── user-repository.interface.ts
│ │ └── product-repository.interface.ts
└── infrastructure/
├── user/
│ ├── adapters/ # 리포지토리 및 컨트롤러에 대한 구체적인 구현
│ │ ├── user.controller.ts # HTTP 요청 처리, 유스 케이스 주입
│ │ ├── user.entity.ts # TypeORM 엔티티
│ │ └── user.repository.ts # 구체적인 TypeORM 리포지토리 구현
│ ├── user.module.ts
│ └── dtos/
│ └── create-user.dto.ts
├── product/
│ └── ...
├── main.ts
└── app.module.ts
코드 예시 (NestJS):
-
src/core/domain/user.ts
(엔티티):// 프레임워크에 구애받지 않는 순수 TypeScript 클래스/인터페이스 export interface User { id: string; username: string; email: string; updateEmail(newEmail: string): void; } export class UserEntity implements User { constructor(public id: string, public username: string, public email: string) {} updateEmail(newEmail: string): void { if (!newEmail.includes('@')) { // 오류 발생 throw new Error('잘못된 이메일 형식'); } this.email = newEmail; } }
-
src/core/use-cases/interfaces/user-repository.interface.ts
(추상 리포지토리):import { User } from '../domain/user'; export interface IUserRepository { getById(id: string): Promise<User | null>; save(user: User): Promise<void>; } export const USER_REPOSITORY = 'USER_REPOSITORY'; // 종속성 주입을 위한 토큰
-
src/core/use-cases/create-user.use-case.ts
(유스 케이스):import { Inject, Injectable } from '@nestjs/common'; import { User, UserEntity } from '../domain/user'; import { IUserRepository, USER_REPOSITORY } from './interfaces/user-repository.interface'; @Injectable() export class CreateUserUseCase { constructor( @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, ) {} async execute(id: string, username: string, email: string): Promise<User> { const user = new UserEntity(id, username, email); // 추가 비즈니스 로직 await this.userRepository.save(user); return user; } }
-
src/infrastructure/user/adapters/user.entity.ts
(TypeORM 엔티티 - 인프라 세부 정보):import { Entity, PrimaryColumn, Column } from 'typeorm'; @Entity('users') export class UserTypeORMEntity { @PrimaryColumn() id: string; @Column() username: string; @Column() email: string; }
-
src/infrastructure/user/adapters/user.repository.ts
(구체적인 TypeORM 리포지토리):import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { IUserRepository } from '../../../core/use-cases/interfaces/user-repository.interface'; import { User, UserEntity } from '../../../core/domain/user'; import { UserTypeORMEntity } from './user.entity'; import { Injectable } from '@nestjs/common'; @Injectable() export class UserTypeORMRepository implements IUserRepository { constructor( @InjectRepository(UserTypeORMEntity) private readonly ormRepository: Repository<UserTypeORMEntity>, ) {} async getById(id: string): Promise<User | null> { const ormUser = await this.ormRepository.findOne({ where: { id } }); return ormUser ? new UserEntity(ormUser.id, ormUser.username, ormUser.email) : null; } async save(user: User): Promise<void> { const ormUser = this.ormRepository.create(user); // 도메인을 ORM 엔티티에 매핑 await this.ormRepository.save(ormUser); } }
-
src/infrastructure/user/adapters/user.controller.ts
(NestJS 컨트롤러):import { Body, Controller, Post, Res, HttpStatus } from '@nestjs/common'; import { CreateUserUseCase } from '../../../core/use-cases/create-user.use-case'; import { CreateUserDto } from '../dtos/create-user.dto'; import { Response } from 'express'; // Nest의 추상화를 위한 @nestjs/common/response import { v4 as uuidv4 } from 'uuid'; @Controller('users') export class UsersController { constructor(private readonly createUserUseCase: CreateUserUseCase) {} @Post() async createUser(@Body() createUserDto: CreateUserDto, @Res() res: Response) { try { const userId = uuidv4(); const user = await this.createUserUseCase.execute(userId, createUserDto.username, createUserDto.email); return res.status(HttpStatus.CREATED).json({ id: user.id, username: user.username, email: user.email }); } catch (error) { if (error instanceof Error) { return res.status(HttpStatus.BAD_REQUEST).json({ message: error.message }); } return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ message: '예기치 않은 오류가 발생했습니다.' }); } } }
NestJS의 강력한 종속성 주입 시스템은 추상 리포지토리를 유스 케이스에, 그리고 유스 케이스를 컨트롤러에 주입하는 것을 간단하게 만들어 종속성 규칙을 유지합니다.
FastAPI
FastAPI는 성능과 타입 힌트, async/await와 같은 최신 Python 기능을 제공하며, 종속성 주입 시스템을 활용하여 클린 아키텍처로 우아하게 구조화될 수 있습니다.
프로젝트 구조 예시:
my_fastapi_project/
├── core/ # 엔티티 및 유스 케이스
│ ├── domain/ # 엔티티 (Pydantic 모델 또는 일반 Python 클래스)
│ │ ├── user.py
│ │ └── product.py
│ ├── use_cases/ # 애플리케이션별 비즈니스 로직
│ │ ├── create_user.py
│ │ ├── get_product.py
│ │ └── interfaces/ # 추상 리포지토리/게이트웨이
│ │ ├── user_repository.py
│ │ └── product_repository.py
└── infrastructure/
├── api/ # FastAPI 라우트 정의
│ ├── v1/
│ │ ├── endpoints/
│ │ │ ├── user.py # 컨트롤러 (FastAPI 엔드포인트)
│ │ │ └── product.py
│ │ └── routers.py
├── persistence/ # 데이터베이스 구현
│ ├── repositories/
│ │ ├── user_sqlalchemy_repo.py
│ │ └── product_sqlalchemy_repo.py
│ ├── database.py # DB 연결 설정
│ └── models.py # SQLAlchemy ORM 모델
├── main.py # FastAPI 앱 초기화
└── dependencies.py # 종속성 주입 설정
코드 예시 (FastAPI):
-
core/domain/user.py
(엔티티):from pydantic import BaseModel # 검증을 위해 Pydantic 사용 가능, 하지만 핵심 로직은 별도 class User(BaseModel): id: str username: str email: str def update_email(self, new_email: str): if "@" not in new_email: raise ValueError("잘못된 이메일 형식") self.email = new_email
-
core/use_cases/interfaces/user_repository.py
(추상 리포지토리):from abc import ABC, abstractmethod from typing import Optional from core.domain.user import User class UserRepository(ABC): @abstractmethod async def get_by_id(self, user_id: str) -> Optional[User]: pass @abstractmethod async def save(self, user: User) -> None: pass
-
core/use_cases/create_user.py
(유스 케이스):from core.domain.user import User from core.use_cases.interfaces.user_repository import UserRepository class CreateUser: def __init__(self, user_repository: UserRepository): self.user_repository = user_repository async def execute(self, user_id: str, username: str, email: str) -> User: user = User(id=user_id, username=username, email=email) await self.user_repository.save(user) return user
-
infrastructure/persistence/models.py
(SQLAlchemy ORM 모델 - 인프라 세부 정보):from sqlalchemy import Column, String from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() class UserORM(Base): __tablename__ = "users" id = Column(String, primary_key=True, index=True) username = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True)
-
infrastructure/persistence/repositories/user_sqlalchemy_repo.py
(구체적인 SQLAlchemy 리포지토리):from sqlalchemy.orm import Session from core.domain.user import User as DomainUser from core.use_cases.interfaces.user_repository import UserRepository from infrastructure.persistence.models import UserORM from typing import Optional class SQLAlchemyUserRepository(UserRepository): def __init__(self, db: Session): self.db = db async def get_by_id(self, user_id: str) -> Optional[DomainUser]: orm_user = self.db.query(UserORM).filter(UserORM.id == user_id).first() return DomainUser.model_validate(orm_user) if orm_user else None async def save(self, user: DomainUser) -> None: orm_user = self.db.query(UserORM).filter(UserORM.id == user.id).first() if orm_user: for key, value in user.model_dump().items(): setattr(orm_user, key, value) else: orm_user = UserORM(**user.model_dump()) self.db.add(orm_user) self.db.commit() self.db.refresh(orm_user)
-
infrastructure/dependencies.py
(FastAPI 종속성 주입 헬퍼):from sqlalchemy.orm import Session from infrastructure.persistence.database import get_db from core.use_cases.interfaces.user_repository import UserRepository from infrastructure.persistence.repositories.user_sqlalchemy_repo import SQLAlchemyUserRepository from core.use_cases.create_user import CreateUser def get_user_repository(db: Session = Depends(get_db)) -> UserRepository: return SQLAlchemyUserRepository(db) def get_create_user_use_case(user_repo: UserRepository = Depends(get_user_repository)) -> CreateUser: return CreateUser(user_repository=user_repo)
-
infrastructure/api/v1/endpoints/user.py
(FastAPI 엔드포인트/컨트롤러):from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel import uuid from core.domain.user import User as DomainUser from core.use_cases.create_user import CreateUser from infrastructure.dependencies import get_create_user_use_case router = APIRouter() class CreateUserRequest(BaseModel): username: str email: str class CreateUserResponse(BaseModel): id: str username: str email: str @router.post("/users", response_model=CreateUserResponse, status_code=status.HTTP_201_CREATED) async def create_user_endpoint( request: CreateUserRequest, create_user_uc: CreateUser = Depends(get_create_user_use_case) ): try: user_id = str(uuid.uuid4()) user = await create_user_uc.execute(user_id=user_id, username=request.username, email=request.email) return CreateUserResponse.model_validate(user) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
FastAPI의 Depends
기능은 종속성을 주입하는 데 매우 강력하여 HTTP 계층과 애플리케이션의 핵심 로직 간의 깔끔한 분리를 가능하게 합니다.
결론
Django, NestJS 또는 FastAPI에서 클린 아키텍처를 채택하는 것은 유지보수 가능하고, 테스트 가능하며, 확장 가능한 백엔드 애플리케이션을 구축하기 위한 견고한 청사진을 제공합니다. 종속성 규칙을 엄격하게 준수하고 관심사를 별도의 계층으로 분리함으로써 개발자는 외부 프레임워크 및 데이터베이스와 독립적인 시스템을 달성하여 시간이 지남에 따라 더 쉬운 진화와 적응을 가능하게 합니다. 이 접근 방식은 핵심 비즈니스 로직이 기술적 변화에 영향을 받지 않고 그대로 유지되도록 보장하여 애플리케이션이 시대의 시험을 견딜 수 있도록 지원합니다.
궁극적으로 클린 아키텍처는 패턴일 뿐만 아니라 단기적인 편의성보다 장기적인 품질과 아키텍처 무결성을 옹호하는 사고방식입니다.