아래는 “To-Do 관리 시스템” 예시를 바탕으로 작성한 기능정의서(Feature Specification) 예시입니다.
본 문서는 프로젝트 개요 → 기능 상세 → 시스템 설계/구조 → 기술 스택 순으로 정리했습니다. 필요에 따라 추가·수정·삭제하여 실제 현업 환경에 맞게 활용하시면 되겠습니다.
Id
를 반환하거나, Index 페이지 등으로 리다이렉트UpdateDetails()
메서드 통해 유효성 검증 후 DB UpdateMarkComplete()
메서드 호출IsCompleted = true
업데이트MyApp
├── Core/ // 도메인(핵심 비즈니스 로직) 계층
│ ├── Models/ // 도메인 엔터티, Value Objects
│ ├── Aggregates/ // (필요시) 애그리게이트 루트
│ ├── Contracts/ // 도메인 인터페이스(Repository 등)
│ └── Events/ // 도메인 이벤트
│
├── Infrastructure/ // 기술 구현 계층
│ ├── Persistence/ // DapperContext 등 DB 설정
│ ├── Repositories/ // Repository 구현체
│ └── ExternalServices/ // 외부(Email, 3rd-party) 서비스 구현
│
├── UseCases/ // CQRS, Application 로직 계층
│ ├── Commands/ // Command + CommandHandler
│ ├── Queries/ // Query + QueryHandler
│ ├── Dtos/ // DTO, Request/Response 객체
│ ├── Contracts/ // (필요시) Application(Service) 인터페이스
│ └── Mapping/ // AutoMapper 등 매핑 설정
│
├── WebUI/ // 프레젠테이션 계층 (ASP.NET Core MVC)
│ ├── Controllers/ // MVC 컨트롤러
│ ├── Views/ // Razor Views
│ ├── wwwroot/ // 정적 파일
│ └── Shared/ // Layout, Partial View 등
│
├── Tests/ // 테스트 프로젝트들
│ ├── CoreTests/ // 도메인 로직 테스트
│ ├── UseCasesTests/ // Application(CQRS) 테스트
│ └── IntegrationTests/ // DB 연동 등 통합 테스트
│
└── MyApp.sln
Id
(PK, int)Title
(nvarchar)Description
(nvarchar)DueDate
(datetime)IsCompleted
(bit)[사용자]
↓ (Create 페이지 이동)
[WebUI: Controller] --CreateToDoDto-->
↓
[UseCases: CreateToDoCommandHandler]
↓ (AutoMapper를 통해 CreateToDoCommand -> ToDoItem 변환)
[Core: ToDoItem] (도메인 엔티티 생성/검증)
↓
[Infrastructure: ToDoRepository] (DapperContext -> DB Insert)
↓
[DB] (ToDoItems 테이블)
DefaultConnection
을 통해 연결
MyApp
├── Core/ // (과거 Domain)
│ ├── Models/ // 도메인 엔터티, Value Object 등
│ │ └── ToDoItem.cs
│ ├── Aggregates/ // (필요시) 애그리게이트 루트
│ ├── Contracts/ // 도메인 계층에서 필요한 인터페이스 (ex. Repository)
│ │ └── IToDoRepository.cs
│ └── Events/ // 도메인 이벤트
│
├── Infrastructure/ // (과거 Infrastructure)
│ ├── Persistence/ // DapperContext 등 DB 연결/세팅
│ │ └── DapperContext.cs
│ ├── Repositories/ // 구체적인 리포지토리 구현체
│ │ └── ToDoRepository.cs
│ └── ExternalServices/ // 외부 서비스(Email, 3rd-party API 연동) 모음
│ └── EmailService.cs // 예시
│
├── UseCases/ // (과거 Application)
│ ├── Commands/ // CQRS - Command 및 Handler
│ │ ├── CreateToDoCommand.cs
│ │ └── CreateToDoCommandHandler.cs
│ ├── Queries/ // CQRS - Query 및 Handler
│ │ ├── GetAllToDosQuery.cs
│ │ └── GetAllToDosQueryHandler.cs
│ ├── Dtos/ // DTO(입출력 데이터 전달 용도)
│ │ ├── ToDoItemDto.cs
│ │ └── CreateToDoDto.cs
│ ├── Contracts/ // (필요시) UseCase 계층 내 인터페이스
│ └── Mapping/ // AutoMapper 등 매핑 설정
│ └── MappingProfile.cs
│
├── WebUI/ // (과거 Presentation)
│ ├── Controllers/ // ASP.NET MVC 컨트롤러
│ │ └── ToDosController.cs
│ ├── Views/ // Razor Views
│ │ └── ToDos/
│ │ ├── Index.cshtml
│ │ └── Create.cshtml
│ ├── wwwroot/ // 정적 파일(CSS, JS 등)
│ │ ├── css/
│ │ └── js/
│ │ └── validation.js
│ └── Shared/ // Layout, Partial View 등
│ └── _Layout.cshtml
│
├── Tests/ // 테스트 프로젝트들
│ ├── CoreTests/ // (과거 DomainTests)
│ ├── UseCasesTests/ // (과거 ApplicationTests)
│ └── IntegrationTests/ // 통합 테스트
│
└── MyApp.sln // 솔루션 파일
ToDoItem.cs
(Models)using System;
namespace MyApp.Core.Models
{
/// <summary>
/// 실제 비즈니스 규칙 및 유효성 검증을 포함하는 ToDo 엔터티.
/// </summary>
public class ToDoItem
{
public int Id { get; private set; }
public string Title { get; private set; }
public string Description { get; private set; } // 50개 이상의 필드를 포함할 수 있다고 가정
public DateTime? DueDate { get; private set; }
public bool IsCompleted { get; private set; }
/// <summary>
/// 신규 ToDoItem 생성 시 필요한 최소 정보(Title, Description, DueDate).
/// </summary>
public ToDoItem(string title, string description, DateTime? dueDate)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentNullException(nameof(title));
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentNullException(nameof(description));
Title = title;
Description = description;
DueDate = dueDate;
IsCompleted = false;
}
/// <summary>
/// 할 일을 완료 상태로 변경한다.
/// </summary>
public void MarkComplete()
{
IsCompleted = true;
}
/// <summary>
/// 할 일 정보를 업데이트한다.
/// </summary>
public void UpdateDetails(string title, string description, DateTime? dueDate)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentNullException(nameof(title));
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentNullException(nameof(description));
Title = title;
Description = description;
DueDate = dueDate;
}
}
}
IToDoRepository.cs
(Contracts)using System.Collections.Generic;
using System.Threading.Tasks;
using MyApp.Core.Models;
namespace MyApp.Core.Contracts
{
/// <summary>
/// 도메인(핵심 비즈니스) 계층에서 필요한 저장소(Repository) 인터페이스.
/// ToDo 데이터를 어떻게 저장/조회/삭제하는지는 구현체(Infrastructure)가 담당.
/// </summary>
public interface IToDoRepository
{
Task<List<ToDoItem>> GetAllAsync();
Task<ToDoItem?> GetByIdAsync(int id);
Task<int> AddAsync(ToDoItem toDoItem);
Task UpdateAsync(ToDoItem toDoItem);
Task DeleteAsync(int id);
}
}
DapperContext.cs
(Persistence)using System.Data;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace MyApp.Infrastructure.Persistence
{
/// <summary>
/// Dapper를 이용하여 DB Connection을 생성하는 역할.
/// IConfiguration을 주입받아 ConnectionString 등을 관리.
/// </summary>
public class DapperContext
{
private readonly IConfiguration _configuration;
public DapperContext(IConfiguration configuration)
{
_configuration = configuration;
}
/// <summary>
/// DB Connection 객체를 생성해서 반환.
/// </summary>
public IDbConnection CreateConnection()
{
return new SqlConnection(_configuration.GetConnectionString("DefaultConnection"));
}
}
}
ToDoRepository.cs
(Repositories)using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using MyApp.Core.Contracts;
using MyApp.Core.Models;
using MyApp.Infrastructure.Persistence;
namespace MyApp.Infrastructure.Repositories
{
/// <summary>
/// IToDoRepository 인터페이스의 구현체.
/// DapperContext를 통해 DB와 상호작용하며, ToDoItem 엔터티를 저장/조회/삭제한다.
/// </summary>
public class ToDoRepository : IToDoRepository
{
private readonly DapperContext _context;
public ToDoRepository(DapperContext context)
{
_context = context;
}
public async Task<List<ToDoItem>> GetAllAsync()
{
const string query = "SELECT * FROM ToDoItems";
using var connection = _context.CreateConnection();
var result = await connection.QueryAsync<ToDoItem>(query);
return result.ToList();
}
public async Task<ToDoItem?> GetByIdAsync(int id)
{
const string query = "SELECT * FROM ToDoItems WHERE Id = @Id";
using var connection = _context.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ToDoItem>(query, new { Id = id });
}
public async Task<int> AddAsync(ToDoItem toDoItem)
{
const string query = @"
INSERT INTO ToDoItems (Title, Description, DueDate, IsCompleted)
VALUES (@Title, @Description, @DueDate, @IsCompleted);
SELECT CAST(SCOPE_IDENTITY() as int);
";
using var connection = _context.CreateConnection();
var newId = await connection.ExecuteScalarAsync<int>(query, new
{
toDoItem.Title,
toDoItem.Description,
toDoItem.DueDate,
toDoItem.IsCompleted
});
return newId;
}
public async Task UpdateAsync(ToDoItem toDoItem)
{
const string query = @"
UPDATE ToDoItems
SET Title = @Title,
Description = @Description,
DueDate = @DueDate,
IsCompleted = @IsCompleted
WHERE Id = @Id;
";
using var connection = _context.CreateConnection();
await connection.ExecuteAsync(query, toDoItem);
}
public async Task DeleteAsync(int id)
{
const string query = "DELETE FROM ToDoItems WHERE Id = @Id";
using var connection = _context.CreateConnection();
await connection.ExecuteAsync(query, new { Id = id });
}
}
}
ToDoItemDto.cs
using System;
namespace MyApp.UseCases.Dtos
{
/// <summary>
/// ToDo 데이터를 전달받거나 반환할 때 사용하는 전송 객체(예: 조회 시).
/// </summary>
public class ToDoItemDto
{
public string Title { get; set; }
public string Description { get; set; }
public DateTime? DueDate { get; set; }
}
}
CreateToDoDto.cs
using System;
namespace MyApp.UseCases.Dtos
{
/// <summary>
/// ToDo 생성 시, 사용자로부터 입력받을 데이터 전송 객체.
/// (Controller에서 [HttpPost]로 받는 폼 데이터 등에 대응)
/// </summary>
public class CreateToDoDto
{
public string Title { get; set; }
public string Description { get; set; }
public DateTime? DueDate { get; set; }
}
}
CreateToDoCommand.cs
using System;
using MediatR;
namespace MyApp.UseCases.Commands
{
/// <summary>
/// ToDo를 새로 생성하기 위한 Command.
/// MediatR의 IRequest<T>를 구현하여 Handler에게 전달됨.
/// </summary>
public class CreateToDoCommand : IRequest<int>
{
public string Title { get; set; }
public string Description { get; set; }
public DateTime? DueDate { get; set; }
}
}
CreateToDoCommandHandler.cs
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using MediatR;
using MyApp.Core.Contracts;
using MyApp.Core.Models;
namespace MyApp.UseCases.Commands
{
/// <summary>
/// CreateToDoCommand를 처리하는 Handler.
/// ToDoItem 엔터티로 매핑 후, 리포지토리에 저장 로직을 호출.
/// </summary>
public class CreateToDoCommandHandler : IRequestHandler<CreateToDoCommand, int>
{
private readonly IToDoRepository _repository;
private readonly IMapper _mapper;
public CreateToDoCommandHandler(IToDoRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<int> Handle(CreateToDoCommand request, CancellationToken cancellationToken)
{
// Command -> Entity 변환(AutoMapper)
var toDoItem = _mapper.Map<ToDoItem>(request);
// DB에 저장
var newId = await _repository.AddAsync(toDoItem);
return newId;
}
}
}
GetAllToDosQuery.cs
using System.Collections.Generic;
using MediatR;
using MyApp.UseCases.Dtos;
namespace MyApp.UseCases.Queries
{
/// <summary>
/// 모든 ToDo 목록을 가져오기 위한 Query.
/// MediatR를 통해 Handler에 전달.
/// </summary>
public class GetAllToDosQuery : IRequest<List<ToDoItemDto>>
{
}
/// <summary>
/// 특정 ID를 가진 ToDo를 조회하기 위한 Query.
/// </summary>
public class GetToDoByIdQuery : IRequest<ToDoItemDto>
{
public int Id { get; }
public GetToDoByIdQuery(int id)
{
Id = id;
}
}
}
GetAllToDosQueryHandler.cs
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using MediatR;
using MyApp.Core.Contracts;
using MyApp.UseCases.Dtos;
namespace MyApp.UseCases.Queries
{
/// <summary>
/// GetAllToDosQuery를 처리하는 Handler.
/// 모든 ToDoItem을 조회 후, ToDoItemDto 리스트로 매핑하여 반환.
/// </summary>
public class GetAllToDosQueryHandler : IRequestHandler<GetAllToDosQuery, List<ToDoItemDto>>
{
private readonly IToDoRepository _repository;
private readonly IMapper _mapper;
public GetAllToDosQueryHandler(IToDoRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<ToDoItemDto>> Handle(GetAllToDosQuery request, CancellationToken cancellationToken)
{
var toDoItems = await _repository.GetAllAsync();
return toDoItems.Select(item => _mapper.Map<ToDoItemDto>(item)).ToList();
}
}
}
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using MediatR;
using MyApp.Core.Contracts;
using MyApp.UseCases.Dtos;
namespace MyApp.UseCases.Queries
{
/// <summary>
/// GetToDoByIdQuery를 처리하는 Handler.
/// Repository를 통해 해당 ID의 ToDoItem을 조회하고, DTO로 매핑 후 반환한다.
/// </summary>
public class GetToDoByIdQueryHandler : IRequestHandler<GetToDoByIdQuery, ToDoItemDto>
{
private readonly IToDoRepository _repository;
private readonly IMapper _mapper;
public GetToDoByIdQueryHandler(IToDoRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<ToDoItemDto> Handle(GetToDoByIdQuery request, CancellationToken cancellationToken)
{
// Repository 이용해 DB에서 아이템 조회
var toDoItem = await _repository.GetByIdAsync(request.Id);
// 조회 결과가 없으면 null 반환(또는 예외 처리)
if (toDoItem == null)
return null;
// Entity -> DTO 매핑
return _mapper.Map<ToDoItemDto>(toDoItem);
}
}
}
MappingProfile.cs
using AutoMapper;
using MyApp.Core.Models;
using MyApp.UseCases.Commands;
using MyApp.UseCases.Dtos;
namespace MyApp.UseCases.Mapping
{
/// <summary>
/// 객체 간 매핑 규칙 설정.
/// Command -> Entity, DTO -> Entity 등.
/// </summary>
public class MappingProfile : Profile
{
public MappingProfile()
{
// CreateToDoCommand -> ToDoItem
CreateMap<CreateToDoCommand, ToDoItem>();
// ToDoItemDto <-> ToDoItem (양방향)
CreateMap<ToDoItemDto, ToDoItem>().ReverseMap();
// 필요 시, CreateToDoDto -> ToDoItem 매핑 등 추가 가능
CreateMap<CreateToDoDto, ToDoItem>();
}
}
}
ToDosController.cs
using System.Threading.Tasks;
using AutoMapper;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MyApp.UseCases.Commands;
using MyApp.UseCases.Dtos;
namespace MyApp.WebUI.Controllers
{
/// <summary>
/// MVC Controller: ToDo 항목에 대한 Create/Read/Update/Delete 등 요청을 처리.
/// </summary>
public class ToDosController : Controller
{
private readonly IMediator _mediator;
private readonly IMapper _mapper;
public ToDosController(IMediator mediator, IMapper mapper)
{
_mediator = mediator;
_mapper = mapper;
}
[HttpGet]
public IActionResult Create()
{
return View();
}
[HttpPost]
public async Task<IActionResult> Create(CreateToDoDto dto)
{
if (!ModelState.IsValid)
return View(dto);
// DTO -> Command 변환
var command = new CreateToDoCommand
{
Title = dto.Title,
Description = dto.Description,
DueDate = dto.DueDate
};
// Handler를 통해 새 ToDo 생성
await _mediator.Send(command);
// 생성 후 목록 페이지로 리다이렉트
return RedirectToAction("Index");
}
/// <summary>
/// 예시: 목록 화면(Index) - Query를 통해 모든 ToDo 가져오기
/// </summary>
[HttpGet]
public IActionResult Index()
{
// 단순 페이지 이동(실제 데이터 호출은 서버단에서 별도 처리 or Razor에서 직접 호출 가능)
// 혹은 Mediator Query 호출 후 View에 모델을 넘길 수도 있음
return View();
}
/// <summary>
/// 특정 ID의 ToDo 상세 페이지를 보여주는 메서드 (예시).
/// </summary>
[HttpGet]
public async Task<IActionResult> Detail(int id)
{
// GetToDoByIdQuery를 발행하여 Handler 호출
var toDoItem = await _mediator.Send(new GetToDoByIdQuery(id));
// 결과가 없으면 404 처리(또는 다른 로직)
if (toDoItem == null)
return NotFound();
// Razor View에 모델 전달
return View(toDoItem);
}
}
}
참고:
여기서는 Index 액션 메서드 내에서 실제 GetAllToDosQuery 호출 로직을 생략했습니다.
실제로는 다음과 같이 할 수도 있습니다.[HttpGet] public async Task<IActionResult> Index() { var query = new GetAllToDosQuery(); var toDoList = await _mediator.Send(query); return View(toDoList); }
그리고Index.cshtml
에서 모델을 받아서 렌더링할 수 있습니다.
Tests
├── CoreTests/
│ └── ToDoItemTests.cs // 예: 도메인 모델(Unit Test)
├── UseCasesTests/
│ ├── CreateToDoCommandHandlerTests.cs
│ └── GetAllToDosQueryHandlerTests.cs
└── IntegrationTests/
└── ToDoIntegrationTests.cs // 실제 DB 연동 등 통합 테스트
CoreTests
에서는 ToDoItem
같은 도메인 모델의 순수 비즈니스 로직 유효성 검증을 테스트합니다.UseCasesTests
에서는 Command/Query Handler를 테스트하면서, Mock Repository 등을 활용해 시나리오 검증을 합니다.IntegrationTests
에서는 실제 DB(혹은 테스트 DB)에 붙여 보거나, 여러 계층을 한 번에 테스트합니다.가장 중요한 점은, “비즈니스 로직(Core)을 잘 보호하고, 다른 계층(UseCases, Infrastructure, WebUI)과 의존성 흐름이 명확히 구분되는지”를 항상 염두에 두는 것입니다.
이렇게 폴더 구조와 네임스페이스를 재구성하면, 유지보수와 확장성 면에서 큰 이점을 누릴 수 있습니다.