NestJS 및 ASP.NET Core에서 Hexagonal Architecture를 사용하여 견고한 애플리케이션 구축
Lukas Schneider
DevOps Engineer · Leapcell

소개
끊임없이 진화하는 백엔드 개발 환경에서 견고하고 유지 관리 가능하며 변화에 적응 가능한 애플리케이션을 구축하는 것이 가장 중요합니다. 시스템이 복잡해짐에 따라 긴밀하게 결합된 아키텍처는 테스트를 어렵게 만들고, 리팩토링을 위험하게 만들며, 확장을 악몽으로 만들어 상당한 어려움을 초래할 수 있습니다. 바로 여기서 Ports and Adapters라고도 알려진 Hexagonal Architecture와 같은 아키텍처 패턴이 강력한 솔루션을 제공합니다. 이 패턴은 핵심 비즈니스 로직을 외부 종속성 및 프레임워크에서 분리하여 기술 변화와 인프라 변경에 탄력적인 시스템을 구축할 수 있도록 지원합니다. 이 글에서는 Node.js 생태계를 위한 NestJS와 .NET 세계를 위한 ASP.NET Core라는 두 가지 인기 있는 백엔드 프레임워크 내에서 Hexagonal Architecture의 실제 구현을 탐구하고, 진정으로 유연하고 테스트 가능한 애플리케이션을 만들기 위한 원칙을 활용하는 방법을 보여줍니다.
Hexagonal Architecture의 핵심 개념
코드를 살펴보기 전에 Hexagonal Architecture의 기초를 이루는 기본 개념을 명확하게 이해해 봅시다.
- Hexagonal Architecture (Ports and Adapters): 이 아키텍처 패턴은 핵심 비즈니스 로직( "내부")을 외부 관심사("외부")에서 분리하여 느슨하게 결합된 애플리케이션 구성 요소를 만드는 것을 목표로 합니다. "육각형"은 애플리케이션 코어를 나타내며, 그 측면은 외부 시스템과의 상호 작용을 허용하는 "포트"입니다.
- Ports: 상호 작용을 위한 계약을 정의하는 애플리케이션 코어가 소유한 인터페이스입니다. 애플리케이션의 "의도" 또는 "기능"을 나타냅니다. 두 가지 주요 포트 유형이 있습니다.
- Driving Ports (Primary Ports): UI, API 클라이언트 등 외부 액터가 애플리케이션의 동작을 구동하기 위해 호출합니다. 애플리케이션의 API를 나타냅니다.
- Driven Ports (Secondary Ports): 데이터베이스, 메시지 큐 등 외부 서비스에서 구현하며 애플리케이션 코어가 작업을 수행하기 위해 호출합니다. 외부 인프라에 대한 애플리케이션의 종속성을 나타냅니다.
- Adapters: 포트를 통해 "외부" 세계를 애플리케이션의 "내부"에 연결하는 구체적인 구현입니다.
- Driving Adapters (Primary Adapters): 외부 요청을 애플리케이션의 Driving Port에 대한 호출로 변환합니다 (예: REST 컨트롤러, GraphQL 리졸버).
- Driven Adapters (Secondary Adapters): Driven Port를 구현하여 애플리케이션 코어 요청을 특정 기술 호출로 변환합니다 (예: 데이터베이스 리포지토리, HTTP 클라이언트).
- Application Core (Domain): 비즈니스 로직, 엔티티 및 사용 사례를 포함하는 애플리케이션의 핵심입니다. 특정 기술이나 프레임워크와 독립적이어야 합니다.
이러한 분리의 주요 이점은 애플리케이션 코어가 어댑터에서 사용하는 특정 기술을 인식하지 못한다는 것입니다. 관계형 데이터베이스를 NoSQL 데이터베이스로 바꾸거나 메시징 큐를 변경하더라도 핵심 비즈니스 로직을 변경하지 않고 수행할 수 있습니다.
NestJS에서 Hexagonal Architecture 구현
모듈 기반 구조와 강력한 의존성 주입을 활용하는 NestJS는 Hexagonal Architecture를 구현하는 데 탁월한 선택입니다. 간단한 예로 Product
관리 기능을 살펴보겠습니다.
1. Application Core (Domain Layer)
먼저 핵심 Product
엔티티와 이를 기반으로 작동하는 사용 사례(서비스)를 정의합니다.
// src/product/domain/entities/product.entity.ts export class Product { constructor( public id: string, public name: string, public description: string, public price: number, ) {} // Product 관련 비즈니스 로직 updatePrice(newPrice: number): void { if (newPrice <= 0) { throw new Error('Price must be positive'); } this.price = newPrice; } } // src/product/domain/ports/product.repository.port.ts (Driven Port) export interface ProductRepositoryPort { findById(id: string): Promise<Product | null>; save(product: Product): Promise<Product>; findAll(): Promise<Product[]>; delete(id: string): Promise<void>; } // src/product/domain/ports/product.service.port.ts (Driving Port) - 애플리케이션 서비스에 대한 개념적 포트입니다. // NestJS에서는 종종 컨트롤러에서 사용하는 주입 가능한 서비스로 매핑됩니다. // 실제 서비스는 애플리케이션 계층의 일부로 정의합니다. // src/product/application/dtos/create-product.dto.ts export class CreateProductDto { name: string; description: string; price: number; } // src/product/application/dtos/update-product.dto.ts export class UpdateProductDto { name?: string; description?: string; price?: number; } // src/product/application/services/product.service.ts (Application Service - 개념적 Driving Port를 구현) import { Injectable, Inject } from '@nestjs/common'; import { ProductRepositoryPort } from '../../domain/ports/product.repository.port'; import { Product } from '../../domain/entities/product.entity'; import { CreateProductDto } from '../dtos/create-product.dto'; import { UpdateProductDto } from '../dtos/update-product.dto'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class ProductService { constructor( @Inject('ProductRepositoryPort') private readonly productRepository: ProductRepositoryPort, ) {} async createProduct(dto: CreateProductDto): Promise<Product> { const newProduct = new Product(uuidv4(), dto.name, dto.description, dto.price); return this.productRepository.save(newProduct); } async getProductById(id: string): Promise<Product | null> { return this.productRepository.findById(id); } async getAllProducts(): Promise<Product[]> { return this.productRepository.findAll(); } async updateProduct(id: string, dto: UpdateProductDto): Promise<Product> { let product = await this.productRepository.findById(id); if (!product) { throw new Error(`Product with ID ${id} not found.`); } if (dto.name) product.name = dto.name; if (dto.description) product.description = dto.description; if (dto.price) product.updatePrice(dto.price); // 도메인 로직 사용 return this.productRepository.save(product); } async deleteProduct(id: string): Promise<void> { await this.productRepository.delete(id); } }
2. Infrastructure Adapters
이제 ProductRepositoryPort
에 대한 구체적인 어댑터를 구현합니다.
// src/product/infrastructure/adapters/in-memory-product.repository.ts (Driven Adapter) import { Injectable } from '@nestjs/common'; import { ProductRepositoryPort } from '../../domain/ports/product.repository.port'; import { Product } from '../../domain/entities/product.entity'; @Injectable() export class InMemoryProductRepository implements ProductRepositoryPort { private products: Product[] = []; constructor() { // 데모를 위해 초기 데이터로 시드 this.products.push(new Product('1', 'Laptop', 'Powerful laptop', 1200)); this.products.push(new Product('2', 'Mouse', 'Ergonomic mouse', 25)); } async findById(id: string): Promise<Product | null> { return this.products.find(p => p.id === id) || null; } async save(product: Product): Promise<Product> { const index = this.products.findIndex(p => p.id === product.id); if (index > -1) { this.products[index] = product; } else { this.products.push(product); } return product; } async findAll(): Promise<Product[]> { return [...this.products]; } async delete(id: string): Promise<void> { this.products = this.products.filter(p => p.id !== id); } } // TypeORMProductRepository로 쉽게 변경할 수 있습니다: // src/product/infrastructure/adapters/typeorm-product.repository.ts // import { Injectable } from '@nestjs/common'; // import { InjectRepository } from '@nestjs/typeorm'; // import { Repository } from 'typeorm'; // import { ProductRepositoryPort } from '../../domain/ports/product.repository.port'; // import { Product } from '../../domain/entities/product.entity'; // import { ProductORMEntity } from '../entities/product.orm-entity'; // TypeORM 엔티티 정의 // // @Injectable() // export class TypeORMProductRepository implements ProductRepositoryPort { // constructor( // @InjectRepository(ProductORMEntity) // private readonly typeormRepo: Repository<ProductORMEntity>, // ) {} // // async findById(id: string): Promise<Product | null> { // const ormEntity = await this.typeormRepo.findOneBy({ id }); // return ormEntity ? ProductMapper.toDomain(ormEntity) : null; // } // // ... save, findAll, delete에 대한 유사한 구현 // }
3. Driving Adapters (Presentation Layer)
REST API 컨트롤러는 Driving Adapter 역할을 하여 HTTP 요청을 ProductService
에 대한 호출로 변환합니다.
// src/product/presentation/product.controller.ts (Driving Adapter) import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; import { ProductService } from '../application/services/product.service'; import { CreateProductDto } from '../application/dtos/create-product.dto'; import { UpdateProductDto } from '../application/dtos/update-product.dto'; import { Product } from '../domain/entities/product.entity'; @Controller('products') export class ProductController { constructor(private readonly productService: ProductService) {} @Post() async create(@Body() createProductDto: CreateProductDto): Promise<Product> { return this.productService.createProduct(createProductDto); } @Get(':id') async findOne(@Param('id') id: string): Promise<Product | null> { return this.productService.getProductById(id); } @Get() async findAll(): Promise<Product[]> { return this.productService.getAllProducts(); } @Put(':id') async update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto): Promise<Product> { return this.productService.updateProduct(id, updateProductDto); } @Delete(':id') async remove(@Param('id') id: string): Promise<void> { await this.productService.deleteProduct(id); } }
4. Module Configuration
NestJS 모듈은 종속성을 오케스트레이션하는 데 중요합니다. 여기서는 ProductService
를 ProductController
에 바인딩하고 InMemoryProductRepository
를 ProductRepositoryPort
의 구현으로 제공합니다.
// src/product/product.module.ts import { Module } from '@nestjs/common'; import { ProductService } from './application/services/product.service'; import { ProductController } from './presentation/product.controller'; import { InMemoryProductRepository } from './infrastructure/adapters/in-memory-product.repository'; @Module({ imports: [], controllers: [ProductController], providers: [ ProductService, { provide: 'ProductRepositoryPort', // 인터페이스 토큰 제공 useClass: InMemoryProductRepository, // 구체적인 구현 사용 }, ], exports: [ProductService], // 다른 모듈에서 ProductService를 소비해야 하는 경우 }) export class ProductModule {} // app.module.ts에서 ProductModule을 가져옵니다. // import { ProductModule } from './product/product.module'; // @Module({ // imports: [ProductModule], // controllers: [], // providers: [], // }) // export class AppModule {}
이 설정은 도메인 로직(Product
, ProductRepositoryPort
)을 데이터베이스 구현(InMemoryProductRepository
)과 API 계층(ProductController
) 모두에서 명확하게 분리합니다. TypeORM으로 전환하려면 ProductModule
의 useClass
제공자에서 TypeORMProductRepository
를 생성하고 변경하기만 하면 됩니다. ProductService
와 ProductController
는 변경되지 않습니다.
ASP.NET Core에서 Hexagonal Architecture 구현
ASP.NET Core의 내장 의존성 주입 및 계층형 아키텍처는 Hexagonal Architecture에 자연스럽게 적합합니다. Product
예제를 다시 만들어 보겠습니다.
1. Application Core (Domain Layer)
Product
엔티티와 제품 저장을 위한 핵심 계약을 정의합니다.
// Products/Domain/Entities/Product.cs namespace HexagonalNetCore.Products.Domain.Entities { public class Product { public Guid Id { get; private set; } public string Name { get; private set; } public string Description { get; private set; } public decimal Price { get; private set; } public Product(Guid id, string name, string description, decimal price) { if (id == Guid.Empty) throw new ArgumentException("Id cannot be empty.", nameof(id)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name cannot be empty.", nameof(name)); if (price <= 0) throw new ArgumentException("Price must be positive.", nameof(price)); Id = id; Name = name; Description = description; Price = price; } // 비즈니스 로직을 위한 메소드 public void UpdatePrice(decimal newPrice) { if (newPrice <= 0) { throw new ArgumentException("Price must be positive.", nameof(newPrice)); } Price = newPrice; } public void UpdateDetails(string? name, string? description) { if (!string.IsNullOrWhiteSpace(name)) Name = name; if (!string.IsNullOrWhiteSpace(description)) Description = description; } } } // Products/Domain/Ports/IProductRepository.cs (Driven Port) using HexagonalNetCore.Products.Domain.Entities; using System.Collections.Generic; using System.Threading.Tasks; namespace HexagonalNetCore.Products.Domain.Ports { public interface IProductRepository { Task<Product?> GetByIdAsync(Guid id); Task<IEnumerable<Product>> GetAllAsync(); Task AddAsync(Product product); Task UpdateAsync(Product product); Task DeleteAsync(Product product); } } // Products/Application/DTOs/CreateProductDto.cs namespace HexagonalNetCore.Products.Application.DTOs { public class CreateProductDto { public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public decimal Price { get; set; } } } // Products/Application/DTOs/UpdateProductDto.cs namespace HexagonalNetCore.Products.Application.DTOs { public class UpdateProductDto { public string? Name { get; set; } public string? Description { get; set; } public decimal? Price { get; set; } } } // Products/Application/Services/ProductService.cs (Application Service - 개념적 Driving Port를 구현) using HexagonalNetCore.Products.Application.DTOs; using HexagonalNetCore.Products.Domain.Entities; using HexagonalNetCore.Products.Domain.Ports; using System; using System.Collections.Generic; using System.Threading.Tasks; namespace HexagonalNetCore.Products.Application.Services { public class ProductService { private readonly IProductRepository _productRepository; public ProductService(IProductRepository productRepository) { _productRepository = productRepository; } public async Task<Product> CreateProductAsync(CreateProductDto dto) { var product = new Product(Guid.NewGuid(), dto.Name, dto.Description, dto.Price); await _productRepository.AddAsync(product); return product; } public async Task<Product?> GetProductByIdAsync(Guid id) { return await _productRepository.GetByIdAsync(id); } public async Task<IEnumerable<Product>> GetAllProductsAsync() { return await _productRepository.GetAllAsync(); } public async Task<Product> UpdateProductAsync(Guid id, UpdateProductDto dto) { var product = await _productRepository.GetByIdAsync(id); if (product == null) { throw new ArgumentException($