본문 바로가기
카테고리 없음

.NET CORE 백엔드 개발 (1) - 프로젝트 구조 만들기

by 아마도개발자 2025. 9. 29.
반응형

.NET CORE 백엔드 개발하기

신입 시절 .NET CORE 2.1로 만든 백엔드 프로젝트가 있었다. 당시 백엔드 개발을 한번도 해본적이 없는 상태에서 혼자 개발을 해야 했었는데, 슬프게도 도움을 주거나 방향을 제시해 줄 시니어도 없었다.
인터넷과 유튜브를 미친듯이 찾아다니며 프로젝트를 만들어 나갔는데, 그 때는 LLM서비스들이 유행하기 전이라 참고 자료를 찾기가 매우 힘들었다. 특히 .NET은 SPRING에 비해 국내 사용자가 압도적으로 적어 영어 블로그, 영어 유튜브를 열심히 해석해야만 했다.

이번에 여유가 생긴 김에 .NET CORE 2.1이라는 끔찍한 버전과 더 끔찍한 내 실력으로 버무려진 내 1호 프로젝트를 .NET9 으로 재탄생시키려고 한다. 그 김에 나 같은 .NET 초보를 위해 개발 기록을 남긴다.

목표

  • .NET Clean Architecture 'like' 프로젝트 만들기
    • API, Application, Domain, Infrastructure 레이어 구분
    • 로깅 시스템 만들기
    • IExceptionHandler를 사용한 전역 예외 처리 사용
    • Swagger 적용
    • Repostiory 패턴 적용

목표를 위처럼 세운 이유는 예전에 Flutter로 만든 프로젝트를 리팩토링 하면서 느꼈던 것인데, 목표(개발 범위)가 정확하게 정해지지 않으면 점점 욕심이 생겨 프로젝트가 산으로 간다는 것을 느꼈기 때문이다.
사람들이 좋다고 하는 것을 이해도 가지 않으면서 추가하기도 하고, 목표 없이 프로젝트가 확장되기만 해서 결국 리팩토링에 실패하기도 했다.

그래서 이번에는 .NET 백엔드 개발을 처음하는 사람들에게 필수적인 요소들로만 프로젝트를 만들고 싶었다. 기본적인 뼈대만 만들어져 있으면 기능의 추가는 훨씬 쉽게 느껴질 것이다.

프로젝트 아키텍쳐

TodoList
 ├─ src
 │   ├─ TodoList.Domain         # 핵심 비즈니스 규칙
 │   ├─ TodoList.Application    # UseCase / CQRS / Validation
 │   ├─ TodoList.Infrastructure # DB, Repository, Logging
 │   └─ TodoList.API            # ASP.NET Core Web API
 │
 └─ tests
     ├─ TodoList.UnitTests      # Application/Domain 단위 테스트
     └─ TodoList.IntegrationTests # API/Infra 통합 테스트

 

아키텍쳐를 위 처럼 만든 이유는 의존성을 명확하게 하기 위해서이다.

의존성을 명확하게 하면 아키텍처 안정성 유지, 가독성과 이해도 향상, 변경 영향 최소화 (Low Coupling), 테스트 용이성 확보의 장점을 가질 수 있는데, 사실 이 장점들은 Clean Architecture를 공부하면서 적용해볼까? 할 때는 잘 체감이 되지 않았다. 하지만 프로젝트(규칙없이 만들어진)가 어느정도 진행되고 기존 코드를 수정하게 될 경우 역체감이 크게 느껴졌다.

규칙 없이 만들어진 프로젝트는 코드의 가시성이 떨어지고, 무분별한 참조로 수정으로 인해 생길 사이드 이펙트를 예측하기가 굉장히 힘들었다. 테스트 코드를 작성하려고 해도, 코드가 모듈화 되지 않고 의존성이 복잡하기 때문에 정상적인 테스트 코드를 작성하기가 힘들어진다.

출처: https://jasontaylor.dev/

Clean Architecture에서 Domain 레이어와 Application 레이어가 아키텍쳐의 중심(Core) 에 위치한다.
Domain 레이어는 변하지 않는 핵심 규칙과 개념을 포함하고, Application 레이어는 비즈니스 로직과 타입들을 포함한다.

코어(Domain, Application)는 데이터 접근이나 다른 인프라스트럭처 문제들에 의존하지 않아야 하므로, 의존성을 역전(Inversion of Dependencies) 시킨다. 다시 말하면 코어는 위 그림에서 Presentation, Infrastructure가 어떻게 변화하던 전혀 상관이 없어야 한다는 것이다.

  • 의존성 역전: 상위 모듈(추상적인 정책/규칙)은 하위 모듈(구체적인 구현)에 의존하면 안 된다. 즉, 의존의 방향을 바꿔서 핵심 로직(Core)이 외부 구현체에 끌려가지 않도록 하는 원칙.

이를 위해 코어 내부에 인터페이스나 추상화를 정의하고, 실제 구현은 코어 밖의 레이어에서 한다.
예를 들어, 레포지토리 패턴(Repository Pattern)을 구현한다고 하면, 레포지토리 인터페이스는 Core에 두고, 그 인터페이스의 구현체는 Infrastructure 레이어에서 작성한다.

즉, 모든 의존성은 안쪽(Core)으로만 흐르고, Core는 그 어떤 레이어에도 의존하지 않는다.
또한, Infrastructure와 Presentation 레이어는 Core에 의존하지만, 서로 간에는 의존하지 않는다.

도메인(Domain) 레이어

  • 시스템의 핵심 개념(엔티티, 값 객체, 규칙)을 정의하는 레이어
  • "이 소프트웨어가 어떤 문제를 해결해야 하는가?"에 대한 본질을 표현
  • 데이터베이스, UI, 프레임워크 등 기술적인 요소에 의존하지 않는 순수한 코드

도메인 레이어는 크게 엔티티(Entity), 값 객체(Value Object), 도메인 서비스, 도메인 이벤트 로 구성될 수 있다.

Entity는 고유한 식별자를 가진 도메인 객체로 비즈니스에서 중요한 개념을 나타낸다.

 

Entities/TodoItem.cs

using TodoList.Domain.ValueObjects;

namespace TodoList.Domain.Entities;

public class TodoItem
{
    public int Id { get; private set; } // DB에서 자동 증가 (PK)
    public string Title { get; private set; }
    public bool IsCompleted { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? CompletedAt { get; private set; }
    public DueDate DueDate { get; private set; }   // 값 객체(Value Object) 사용 

    private TodoItem() { } // EF Core용 프라이빗 생성자

    public TodoItem(string title, DueDate dueDate)
    {
        Title = title ?? throw new ArgumentNullException(nameof(title));
        DueDate = dueDate ?? throw new ArgumentNullException(nameof(dueDate));
        CreatedAt = DateTime.UtcNow;
        IsCompleted = false;
    }

    public void Complete()
    {
        if (IsCompleted)
            throw new InvalidOperationException("이미 완료된 항목입니다.");

        IsCompleted = true;
        CompletedAt = DateTime.UtcNow;
    }

    public void UpdateTitle(string title) => Title = title;
    public void UpdateDueDate(DueDate dueDate) => DueDate = dueDate;
    public bool IsExpired => DueDate.Value.Date < DateTime.UtcNow.Date;
}

 

여기서 DueDate 속성을 보면 string, int 같은 원시타입이 아니라 내가 정의한 DueDate타입의 속성인 것을 볼 수 있다. 이 DueDate를 Value Object라고 하는데, 도메인에서 중요한 개념을 값 자체로 표현하고 그 값과 관련된 불변성과 규칙을 보장할 때 사용한다.

 

ValueObjects/DueDate.cs

namespace TodoList.Domain.ValueObjects;

public record DueDate
{
    public DateTime Value { get; }

    public DueDate(DateTime value)
    {
        Value = value;
    }
}

 

이 처럼 Datetime를 바로 사용하지 않고, DueDate라는 ValueObject로 처리하면 도메인 규칙의 검증을 통해 항상 올바른 값의 Datetime을 가질 수 있게 된다. 이 Value Object는 immutable(불변)하게 설계하기 때문에 한 번 생성되면 내부 값이 변하지 않으므로, 예측 가능한 동작을 보장할 수 있다.

Value Object가 필요한 경우:

  • 원시 타입으로는 규칙을 표현하기 어려울 때
  • 여러 값이 하나의 개념으로 묶여야 할 때
  • 불변성을 보장해야 할 때
  • 동등성 비교가 값 자체로 이루어져야 할 때

이외에 도메인 서비스와 도메인 이벤트의 경우 DDD(Domian Driven Development)에서 주로 사용되는데, 일반적인 소규모 프로젝트에서는 오버엔지니어링이 될 확률이 높기 때문에 이번 프로젝트에는 구현하지 않았다. 만약 도메인(TodoItem)에 생성, 제거 등의 행위가 일어났을 경우 알람을 보내는 등 추가적인 작업이 필요한 경우가 있는 경우 구현하면 된다.

어플리케이션(Application) 레이어

  • 도메인(비즈니스 규칙)을 실제 “유스케이스(또는 어플리케이션 시나리오)”로 실행하는 레이어
  • 도메인을 직접 구현하지는 않지만, 도메인 객체를 조합하고 외부(인프라, UI)와의 경계를 관리하는 역할
  • Repository, 외부 API, 메시징 등의 추상(인터페이스)을 주입받아 사용하는 역할

Application 레이어 에서는 Interface, service(또는 use case), dto를 구현한다.

 

Models/Dtos/TodoDto.cs

namespace TodoList.Application.Dtos;

public record TodoDto(
    int Id,
    string Title,
    bool IsCompleted,
    DateTime CreatedAt,
    DateTime? CompletedAt,
    DateTime DueDate,
    bool IsExpired
);
  • API와 Application 계층 사이의 데이터 전달 객체. 도메인 엔티티를 그대로 외부에 노출하지 않고 필요한 값만 전달한다.
  • record를 쓰면 불변(immutable) 특성이 있어 안전하게 전달 가능하다.
  • 보통 요청(request) DTO와 응답(response) DTO를 분리한다(생성 시 필요한 필드만 따로, 응답엔 DB 생성된 Id 등 포함). 이 프로젝트에선 TodoDto하나로 통일.

Interfaces/ITodoRepository.cs

using TodoList.Domain.Entities;

namespace TodoList.Application.Interfaces;

public interface ITodoRepository
{
    Task<TodoItem?> GetByIdAsync(int id);
    Task<List<TodoItem>> GetAllAsync();
    Task AddAsync(TodoItem todo);
    Task UpdateAsync(TodoItem todo);
    Task DeleteAsync(int id);
}
  • 도메인 계층이 필요한 저장소 연산을 도메인 타입(엔티티)으로 정의하는 추상 인터페이스.
  • 도메인 엔티티를 반환해야 하고(예: TodoItem), 인프라 세부사항(EF Core의 DbSet 등)은 절대 노출 하지 않아야 함.

인터페이스를 만드는 이유는 다음과 같다.

  • 결합도 낮춤 → 구체 구현이 바뀌어도 인터페이스만 유지하면 다른 레이어/코드 수정 최소화.
  • 유연성 확보 → 여러 구현체를 쉽게 교체하거나 확장 가능 (예: DB → InMemory 변경).
  • 테스트 용이성 → 인터페이스 기반으로 Mock/Fake 구현을 주입해 단위 테스트 가능.

예를들어 인터페이스 없이 직접 참조하는 경우에

// 구체 클래스 직접 의존
public class TodoService
{
    private readonly TodoRepository _repository = new TodoRepository();

    public void AddTodo(string title)
    {
        _repository.Add(title); // TodoRepository에 강하게 묶여 있음
    }
}

이렇게 코드가 작성될 것이다. 이 AddTodo는 TodoRepository가 DB → File → Memory 로 바뀌면 TodoService 코드도 반드시 수정해야 하게된다. 즉, service가 repository에 외존하게 되는데 이는 SOLID 5원칙 중 DIP(역전제어의 원칙)를 위반하는 결과를 초래한다.

인터페이스는 "계약(Contract)" 으로, 서비스는 AddTodo 메서드가 존재한다는 사실만 알고 사용한다. 구현 세부 사항(데이터베이스에 저장하는지, 메모리에 넣는지 등)은 인터페이스 뒤에 숨겨져 있으며, 서비스는 이를 알 필요가 없다.

*DIP = 상위 모듈(서비스)은 하위 모듈(구현)에 의존하면 안 되고, 추상화(인터페이스)에 의존해야 한다.

그렇기 때문에

public class TodoService
{
    private readonly ITodoRepository _repository;

    public TodoService(ITodoRepository repository)
    {
        _repository = repository;
    }
}

위 같이 추상화된 클래스를 참조해야 한다.

 

Interfaces/ITodoService.cs

using TodoList.Application.Dtos;
using TodoList.Application.Requests;

public interface ITodoService
{
    Task<TodoDto> CreateAsync(CreateTodoRequest request);
    Task<IEnumerable<TodoDto>> GetAllAsync();
    Task<TodoDto?> GetByIdAsync(int id);
    Task<TodoDto?> UpdateAsync(UpdateTodoRequest request);
    Task<TodoDto?> CompleteAsync(CompleteTodoRequest request);
    Task DeleteAsync(int id);
}

ITodoRepository와 마찬가지로 상위 모듈이 인터페이스에만 의존하고, 구체 구현(TodoService)을 모르도록 하는 역할을 한다.

 

Services/TodoService.cs

using TodoList.Application.Dtos;
using TodoList.Application.Interfaces;
using TodoList.Application.Requests;
using TodoList.Domain.Entities;
using TodoList.Domain.ValueObjects;

namespace TodoList.Application.Services;

public class TodoService : ITodoService
{
    private readonly ITodoRepository _repository;

    public TodoService(ITodoRepository repository)
    {
        _repository = repository;
    }

    public async Task<TodoDto> CreateAsync(CreateTodoRequest request)
    {
        var todo = new TodoItem(request.Title, new DueDate(request.DueDate));
        await _repository.AddAsync(todo);
        return ToDto(todo);
    }

    public async Task<IEnumerable<TodoDto>> GetAllAsync()
    {
        var todos = await _repository.GetAllAsync();
        return todos.Select(ToDto);
    }

    public async Task<TodoDto?> GetByIdAsync(int id)
    {
        var todo = await _repository.GetByIdAsync(id);
        return todo is null ? null : ToDto(todo);
    }

    public async Task<TodoDto?> UpdateAsync(UpdateTodoRequest request)
    {
        var todo = await _repository.GetByIdAsync(request.Id);
        if (todo == null) return null;

        // EF Core 추적 객체 업데이트
        todo.UpdateTitle(request.Title);
        todo.UpdateDueDate(new DueDate(request.DueDate));

        await _repository.UpdateAsync(todo);
        return ToDto(todo);
    }

    public async Task<TodoDto?> CompleteAsync(CompleteTodoRequest request)
    {
        var todo = await _repository.GetByIdAsync(request.Id);
        if (todo == null) return null;

        todo.Complete();
        await _repository.UpdateAsync(todo);

        return ToDto(todo);
    }

    public async Task DeleteAsync(int id) => await _repository.DeleteAsync(id);

    private static TodoDto ToDto(TodoItem todo) =>
        new(todo.Id, todo.Title, todo.IsCompleted, todo.CreatedAt, todo.CompletedAt, todo.DueDate.Value, todo.IsExpired);
}
  • TodoService는 Application Layer에서 ITodoService를 구현한 클래스
  • 비즈니스 로직(유스케이스)을 실제로 수행하는 구현체이며, Repository를 통해 도메인 엔티티를 저장·조회·수정한다.
  • 외부(API, Controller)는 ITodoService 인터페이스만 알면 되고, 내부 로직(Repository 호출, DTO 변환 등)은 TodoService가 처리
  • 서비스는 "업무 유스케이스 실행"만 책임지고, DB 접근은 Repository, API 응답 포맷은 DTO가 담당 (SRP원칙 준수)
  • ITodoService 계약을 구현한 Application Layer의 핵심 유스케이스 클래스이다.

인프라(Infrastructure) 레이어

Infrastructure 레이어의 역할과 특징

  • 외부 지원: 사용자 인터페이스(UI), 데이터베이스(DB), 웹 API, 프레임워크 등 애플리케이션을 구성하는 외부 기술 및 시스템에 대한 지원을 담당.
  • 영속성 관리: 데이터베이스(또는 파일, api 등)와 상호작용하며 데이터를 저장하고 조회하는 등 데이터의 영속성의 관리를 책임.
  • 기술 구현: 외부 API를 호출하거나 특정 기술 스택(메시징 큐, 파일 시스템 등)과의 연동을 구현.
  • 상위 계층 지원: Core계층의 인터페이스를 받아 실제 구현체를 제공하며, 상위 계층은 외부 기술에 직접 의존하지 않고 인터페이스를 통해 의존성을 분리할 수 있게 한다.

Persistence/AppDbContext.cs

using TodoList.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace TodoList.Infrastructure.Persistence;

public class AppDbContext : DbContext // EF Core에서 ORM(객체-관계 매핑)을 지원
{
    public DbSet<TodoItem> Todos => Set<TodoItem>();
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<TodoItem>(entity =>
        {
            entity.HasKey(t => t.Id);
            entity.OwnsOne(t => t.DueDate, dueDate =>
            {
                dueDate.Property(d => d.Value)
                       .HasColumnName("DueDate")
                       .IsRequired();
            });
        });
    }
}
  • DbContext를 상속하여 EF Core 데이터베이스 컨텍스트를 정의, 애플리케이션과 데이터베이스 간의 다리 역할을 함.
  • 외부에서 DbContextOptions를 주입받아 DI와 EF Core 설정 가능
  • AppDbContext는 Infrastructure Layer에서 DB와 통신하는 핵심 클래스로, Repository가 이를 사용해 실제 데이터 저장/조회를 수행하는 구조

Repositories/TodoRepository.cs


using TodoList.Domain.Entities;
using TodoList.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using TodoList.Application.Interfaces;

namespace TodoList.Infrastructure.Repositories;

public class TodoRepository : ITodoRepository
{
    private readonly AppDbContext _context;

    public TodoRepository(AppDbContext context) => _context = context;

    public async Task AddAsync(TodoItem todo)
    {
        _context.Todos.Add(todo);
        await _context.SaveChangesAsync();
    }

    public async Task<IEnumerable<TodoItem>> GetAllAsync() =>
        await _context.Todos.ToListAsync();

    public async Task<TodoItem?> GetByIdAsync(int id) =>
        await _context.Todos.FindAsync(id);

    public async Task UpdateAsync(TodoItem todo)
    {
        _context.Todos.Update(todo);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var todo = await _context.Todos.FindAsync(id);
        if (todo != null)
        {
            _context.Todos.Remove(todo);
            await _context.SaveChangesAsync();
        }
    }
}
  • TodoRepository는 ITodoRepository 인터페이스를 구현한 클래스
  • Application Layer와 DB(AppDbContext)를 연결하는 역할
  • Repository 패턴을 사용해 DB 접근을 캡슐화하고, 상위 레이어는 DB 구현 세부사항을 몰라도 됨

 

API

 

  • Entry Point (진입점)으로 클라이언트(웹, 앱 등)와 시스템을 연결하는 "외부 진입점"의 역할을 한다. 클라이언트의 요청을 수신하고 적절한 Application Layer의 Use Case(Service) 실행
  • 요청 데이터를 파싱하고, input을 검증하며 어플리케이션 레이어에 전달할 데이터를 준비하는 역할을 한다. 
  • 상태코드(400 Bad Request, 500 Internal Server Error 등)를 포함한 HTTP 응답을 한다.

 

Controllers/TodosController.cs

using Microsoft.AspNetCore.Mvc;
using TodoList.Application.Requests;

namespace TodoList.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class TodoController : ControllerBase
{
    private readonly ITodoService _service;

    public TodoController(ITodoService service) => _service = service;

    [HttpGet]
    public async Task<IActionResult> GetAll() => Ok(await _service.GetAllAsync());

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var todo = await _service.GetByIdAsync(id);
        return todo is null ? NotFound() : Ok(todo);
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateTodoRequest request)
    {
        var todo = await _service.CreateAsync(request);
        return CreatedAtAction(nameof(GetById), new { id = todo.Id }, todo);
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> Update(int id, UpdateTodoRequest request)
    {
        if (id != request.Id) return BadRequest();
        var todo = await _service.UpdateAsync(request);
        return todo is null ? NotFound() : Ok(todo);
    }

    [HttpPost("{id}/complete")]
    public async Task<IActionResult> Complete(int id)
    {
        var todo = await _service.CompleteAsync(new CompleteTodoRequest(id));
        return todo is null ? NotFound() : Ok(todo);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        await _service.DeleteAsync(id);
        return NoContent();
    }
}

 

  • API 엔트리포인트로서 각 API들은 클라이언트의 HTTP 요청을 받아 처리한다.
  • Application Layer(ITodoService)를 호출하여 실제 비즈니스 로직을 실행한다.
  • 요청/응답 변환과 상태 코드 관리를 담당하여 클라이언트와 내부 로직을 연결한다.

 

Repository를 참조할 때, 직접 참조하지 않고 인터페이스를 참조하는 이유는 의존성 방향을 고려해야 하기 때문이다. Clean architecture에서는 API → Application → Domain → Infrastructure 순으로만 의존해야 한다. Repository는 Infrastructure 계층에 속하기 때문에, API 계층에서 직접 참조하면 의존성 역전 원칙(DIP)을 깨뜨린다.

 

 

지금까지 만든 구조를 뼈대로 프로젝트를 채워나가면 깔끔하고 유지보수에 용이한 구조의 프로젝트를 만들 수 있다.

 

소스코드

https://github.com/FreeBono/EasyCleanArchitecture

 

GitHub - FreeBono/EasyCleanArchitecture: This project is made with a clean architecture that is easy to follow.

This project is made with a clean architecture that is easy to follow. - FreeBono/EasyCleanArchitecture

github.com

 

반응형