NestJS 및 ASP.NET Core에서 리포지토리 패턴을 사용하여 데이터 액세스 분리
Min-jun Kim
Dev Intern · Leapcell

소개
백엔드 개발의 복잡한 세계에서 데이터 상호 작용을 관리하는 것은 종종 중요한 과제가 됩니다. 애플리케이션이 복잡해짐에 따라 비즈니스 로직과 특정 데이터 액세스 메커니즘을 직접적으로 얽히게 하는 것은 취약하고 유지 관리하기 어렵고 테스트하기 어려운 코드 기반으로 이어질 수 있습니다. 데이터베이스 기술이나 ORM을 변경할 때 애플리케이션의 많은 부분을 재구성해야 하는 시나리오를 상상해 보세요. 이는 정말로 어려운 일입니다. 이처럼 관심사 분리를 위해 설계된 패턴이 매우 유용합니다. 이 중에서 리포지토리 패턴은 모듈성과 테스트 용이성을 강력하게 지원하는 패턴으로, 도메인 로직과 기본 데이터 저장소 간의 깨끗한 인터페이스 역할을 합니다. 이 글에서는 NestJS 및 ASP.NET Core라는 두 가지 유명한 백엔드 프레임워크 내에서 리포지토리 패턴의 실제 구현을 살펴보고 애플리케이션 아키텍처를 어떻게 크게 개선할 수 있는지 보여줄 것입니다.
리포지토리 패턴의 핵심 개념
구현에 앞서 관련 핵심 개념에 대한 명확한 이해를 갖추도록 하겠습니다.
도메인 모델: 데이터 지속성 문제와 독립적으로 애플리케이션의 핵심 엔터티 및 비즈니스 로직을 나타냅니다. 예를 들어 전자상거래 애플리케이션에서 Product, Order, Customer는 도메인 모델의 일부가 될 것입니다.
데이터 액세스 계층(DAL): 이 계층은 데이터베이스(SQL, NoSQL), 외부 API 또는 파일 시스템과 같은 데이터 지속성 메커니즘과 상호 작용하는 책임이 있습니다. 데이터 쿼리, 삽입, 업데이트 및 삭제와 같은 작업이 여기에 속합니다.
리포지토리 패턴: 데이터 검색 및 저장 방식을 추상화하는 디자인 패턴으로, 애플리케이션의 도메인 개체가 기본 데이터 액세스 기술을 인지하지 않도록 합니다. 이는 도메인 개체의 인메모리 컬렉션 역할을 하며, 이러한 개체를 추가, 제거, 찾기 및 업데이트하는 메서드를 제공합니다.
작업 단위(선택 사항이지만 권장됨): 종종 리포지토리 패턴과 함께 사용되는 작업 단위 패턴은 단일 트랜잭션으로 작업 그룹을 관리합니다. 비즈니스 트랜잭션 내의 모든 변경 사항이 함께 커밋되거나 함께 롤백되도록 하여 데이터 일관성을 유지합니다.
리포지토리 패턴의 주요 목표는 데이터 액세스 기술의 변경으로부터 비즈니스 로직을 보호하는 것입니다. 추상화 계층을 도입함으로써 서비스 또는 컨트롤러는 ORM이나 원시 데이터베이스 쿼리가 아닌 리포지토리를 사용하여 상호 작용하게 됩니다.
리포지토리 패턴 구현
NestJS 및 ASP.NET Core 모두에서 리포지토리 패턴의 실제 구현을 살펴보고 핵심 원칙을 강조하며 예제 코드 스니펫을 공유하겠습니다.
리포지토리가 없을 때의 문제점
ORM과 직접 상호 작용하는 일반적인 서비스를 고려해 봅시다.
// NestJS, 리포지토리 없음 // product.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Product } from './product.entity'; @Injectable() export class ProductService { constructor( @InjectRepository(Product) private productRepository: Repository<Product>, ) {} async createProduct(name: string, price: number): Promise<Product> { const product = this.productRepository.create({ name, price }); return this.productRepository.save(product); } async findAllProducts(): Promise<Product[]> { return this.productRepository.find(); } }
이 코드는 TypeORM의 Repository 메서드를 직접 노출합니다. TypeORM에서 Mongoose 또는 사용자 지정 데이터베이스 클라이언트로 전환하기로 결정하면 ProductService를 직접 수정해야 합니다. 이 긴밀한 결합은 실제 데이터베이스 설정을 요구하는 간단한 단위 테스트조차도 더 어렵게 만듭니다.
NestJS에서 리포지토리 패턴 구현
NestJS에서는 TypeScript 인터페이스와 종속성 주입을 활용하여 리포지토리 패턴을 효과적으로 구현할 수 있습니다.
먼저 리포지토리에 대한 계약을 정의합니다.
// src/product/interfaces/product-repository.interface.ts import { Product } from '../product.entity'; export interface IProductRepository { create(product: Partial<Product>): Promise<Product>; findAll(): Promise<Product[]>; findById(id: number): Promise<Product | undefined>; update(id: number, product: Partial<Product>): Promise<Product | undefined>; delete(id: number): Promise<void>; } // 종속성 주입을 위한 토큰을 정의합니다 export const PRODUCT_REPOSITORY = 'PRODUCT_REPOSITORY';
다음으로 TypeORM을 사용하여 이 인터페이스를 구현합니다.
// src/product/infrastructure/typeorm-product.repository.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Product } from '../product.entity'; import { IProductRepository } from '../interfaces/product-repository.interface'; @Injectable() export class TypeOrmProductRepository implements IProductRepository { constructor( @InjectRepository(Product) private readonly ormRepository: Repository<Product>, ) {} async create(productData: Partial<Product>): Promise<Product> { const product = this.ormRepository.create(productData); return await this.ormRepository.save(product); } async findAll(): Promise<Product[]> { return await this.ormRepository.find(); } async findById(id: number): Promise<Product | undefined> { return await this.ormRepository.findOne({ where: { id } }); } async update(id: number, productData: Partial<Product>): Promise<Product | undefined> { await this.ormRepository.update(id, productData); return this.findById(id); // 업데이트된 엔티티를 다시 가져옵니다 } async delete(id: number): Promise<void> { await this.ormRepository.delete(id); } }
이제 ProductService는 IProductRepository 인터페이스에만 의존합니다.
// src/product/product.service.ts import { Injectable, Inject } from '@nestjs/common'; import { Product } from './product.entity'; import { IProductRepository, PRODUCT_REPOSITORY } from './interfaces/product-repository.interface'; @Injectable() export class ProductService { constructor( @Inject(PRODUCT_REPOSITORY) private readonly productRepository: IProductRepository, ) {} async createProduct(name: string, price: number): Promise<Product> { return this.productRepository.create({ name, price }); } async getAllProducts(): Promise<Product[]> { return this.productRepository.findAll(); } async getProductById(id: number): Promise<Product | undefined> { return await this.productRepository.findById(id); } }
마지막으로 모듈에서 종속성 주입을 구성합니다.
// src/product/product.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Product } from './product.entity'; import { ProductService } from './product.service'; import { ProductController } from './product.controller'; import { IProductRepository, PRODUCT_REPOSITORY } from './interfaces/product-repository.interface'; import { TypeOrmProductRepository } from './infrastructure/typeorm-product.repository'; @Module({ imports: [TypeOrmModule.forFeature([Product])], controllers: [ProductController], providers: [ ProductService, { provide: PRODUCT_REPOSITORY, useClass: TypeOrmProductRepository, }, ], exports: [ProductService], }) export class ProductModule {}
이 설정은 ProductService를 수정하지 않고 TypeOrmProductRepository를 다른 구현(예: MongooseProductRepository 또는 테스트를 위한 MockProductRepository)으로 쉽게 교체할 수 있습니다.
ASP.NET Core에서 리포지토리 패턴 구현
ASP.NET Core의 접근 방식은 C# 인터페이스와 내장 종속성 주입을 활용하여 매우 유사합니다.
인터페이스를 정의합니다.
// Interfaces/IProductRepository.cs using System.Collections.Generic; using System.Threading.Tasks; using YourApp.Domain.Models; // Product가 도메인 모델이라고 가정 namespace YourApp.Application.Interfaces { public interface IProductRepository { Task<Product> AddAsync(Product product); Task<IEnumerable<Product>> GetAllAsync(); Task<Product> GetByIdAsync(int id); Task UpdateAsync(Product product); Task DeleteAsync(int id); } }
Entity Framework Core를 사용하여 인터페이스를 구현합니다.
// Infrastructure/Data/Repositories/ProductRepository.cs using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System.Threading.Tasks; using YourApp.Application.Interfaces; using YourApp.Domain.Models; using YourApp.Infrastructure.Data; // Your DbContext namespace YourApp.Infrastructure.Data.Repositories { public class ProductRepository : IProductRepository { private readonly ApplicationDbContext _dbContext; public ProductRepository(ApplicationDbContext dbContext) { _dbContext = dbContext; } public async Task<Product> AddAsync(Product product) { await _dbContext.Products.AddAsync(product); await _dbContext.SaveChangesAsync(); return product; } public async Task<IEnumerable<Product>> GetAllAsync() { return await _dbContext.Products.ToListAsync(); } public async Task<Product> GetByIdAsync(int id) { return await _dbContext.Products.FindAsync(id); } public async Task UpdateAsync(Product product) { _dbContext.Entry(product).State = EntityState.Modified; await _dbContext.SaveChangesAsync(); } public async Task DeleteAsync(int id) { var product = await _dbContext.Products.FindAsync(id); if (product != null) { _dbContext.Products.Remove(product); await _dbContext.SaveChangesAsync(); } } } }
서비스 계층은 인터페이스에만 의존합니다.
// Application/Services/ProductService.cs using System.Collections.Generic; using System.Threading.Tasks; using YourApp.Application.Interfaces; using YourApp.Domain.Models; namespace YourApp.Application.Services { public class ProductService { private readonly IProductRepository _productRepository; public ProductService(IProductRepository productRepository) { _productRepository = productRepository; } public async Task<Product> CreateProduct(string name, decimal price) { var product = new Product { Name = name, Price = price }; return await _productRepository.AddAsync(product); } public async Task<IEnumerable<Product>> GetAllProducts() { return await _productRepository.GetAllAsync(); } public async Task<Product> GetProductById(int id) { return await _productRepository.GetByIdAsync(id); } // ... 기타 비즈니스 로직 메서드 } }
Startup.cs(또는 .NET 6+ 최소 API의 Program.cs)에서 종속성 주입을 구성합니다.
// Startup.cs (ConfigureServices 메서드) public void ConfigureServices(IServiceCollection services) { // ... 기타 서비스 services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));s services.AddScoped<IProductRepository, ProductRepository>(); services.AddScoped<ProductService>(); // 서비스도 등록합니다 }
애플리케이션 시나리오 및 이점
리포지토리 패턴은 여러 시나리오에서 빛을 발합니다.
- 데이터베이스 마이그레이션: 데이터베이스 시스템(예: SQL Server에서 PostgreSQL로) 또는 ORM(예: 데이터베이스를 변경하지 않고 Entity Framework Core에서 Dapper로)을 변경해야 할 때 기존 인터페이스를 준수하는 새 리포지토리 구현만 만들면 됩니다.
- 테스트: 실제 데이터베이스 연결 없이
ProductService에 대한IProductRepository인터페이스를 쉽게 모의(mock)하거나 스터브(stub)할 수 있으므로 단위 테스트가 매우 간편해집니다. 이렇게 하면 더 빠르고 안정적이며 격리된 테스트를 수행할 수 있습니다. - 도메인 주도 설계(DDD): 리포지토리 패턴은 집계 루트에 대한 컬렉션 유사 인터페이스를 제공하는 DDD의 초석입니다.
- 일관성: 데이터 액세스 로직을 중앙 집중화하여 애플리케이션 전체에서 데이터와 상호 작용하는 일관된 방식을 보장합니다.
- 성능 최적화: 특정 작업(예: 복잡한 조인, 저장 프로시저)을 기본 구현 세부 정보를 서비스 계층에 노출하지 않고 리포지토리 내에 캡슐화할 수 있습니다.
이점 요약:
- 분리: 비즈니스 로직은 데이터 액세스 문제와 분리됩니다.
- 테스트 용이성: 단위 테스트를 위해 모의 및 스터브가 가능합니다.
- 유지 관리 용이성: 데이터 액세스 기술의 변경이 제한적인 영향을 미칩니다.
- 모듈성: 관심사의 명확한 분리를 촉진합니다.
- 유연성: 여러 데이터 소스 구현을 허용합니다.
결론
리포지토리 패턴은 데이터 액세스 로직을 애플리케이션의 도메인 및 비즈니스 서비스와 분리하기 위한 강력하고 효과적인 전략을 제공합니다. 추상화 계층을 도입함으로써 모듈성을 촉진하고 테스트 용이성을 크게 향상시키며 NestJS 및 ASP.NET Core 애플리케이션 모두에서 유지 관리성을 향상시킵니다. 이 패턴을 구현하면 데이터 상호 작용이 깔끔하고 일관되며 발전하는 기술 요구 사항에 대한 복원력을 보장하여 백엔드 시스템을 장기적으로 더욱 적응 가능하고 관리하기 쉽게 만듭니다. 보다 유연하고 지속 가능한 백엔드 아키텍처를 구축하기 위해 리포지토리 패턴을 채택하십시오.

