[ASP.NET Core] Service, Repository 패턴 적용해서 Controller 코드 분리하기

Arthur·2024년 2월 12일
0
post-thumbnail

도입하게 된 계기


진행중인 게임 프로젝트에서 계정 정보 저장에 ASP.NET Core을 Rest API 서버로 사용하고 있습니다.

그런데 코드를 작성하면 할수록 Controller에 코드가 집중되어 작성되고 있었습니다.

[Route("api/[controller]")]
[ApiController]
public class AccountController : Controller
{
	private readonly AppDbContext _context;
	private readonly PasswordEncryptor _passwordEncryptor;

	public AccountController(AppDbContext context, PasswordEncryptor passwordEncryptor)
	{
		_context = context;
		_passwordEncryptor = passwordEncryptor;
	}

	[HttpPost]
	[Route("signup")]
	public AccountSignupResDto Signup([FromBody] AccountSignupReqDto req)
	{
		AccountSignupResDto res = new AccountSignupResDto();
		if (string.IsNullOrEmpty(req.AccountName) || string.IsNullOrEmpty(req.Password) || string.IsNullOrEmpty(req.ConfirmPassword) || string.IsNullOrEmpty(req.Nickname) ||
			req.Password.Equals(req.ConfirmPassword) == false)
		{
			res.IsSignupSucceed = false;
			return res;
		}

		AccountDb account = _context.Accounts
							.AsNoTracking()
							.Where(a => a.AccountName == req.AccountName)
							.FirstOrDefault();

		if (account == null)
		{
			string encryptPassword = _passwordEncryptor.Encrypt(req.Password);

			AccountDb newAccount = new AccountDb()
			{
				AccountName = req.AccountName,
				Password = encryptPassword,
				Nickname = req.Nickname,
				CreatedAt = DateTime.Now,
			};
			EntityEntry<AccountDb> addedAccount = _context.Accounts.Add(newAccount);

			_context.SaveChanges();

			res.AccountId = addedAccount.Entity.AccountId;
			res.IsSignupSucceed = true;
		}
		else
		{
			res.IsSignupSucceed = false;
		}

		return res;
	}

	[HttpPost]
	[Route("login")]
	public AccountLoginResDto Login([FromBody] AccountLoginReqDto req)
	{
		AccountLoginResDto res = new AccountLoginResDto();
		if (string.IsNullOrEmpty(req.AccountName) || string.IsNullOrEmpty(req.Password))
		{
			res.IsLoginSucceed = false;
			return res;
		}

		AccountDb account = _context.Accounts
				.AsNoTracking()
				.Where(a => a.AccountName == req.AccountName)
				.FirstOrDefault();

		if (account != null || _passwordEncryptor.IsmatchPassword(req.Password, account.Password))
		{
			res.AccountId = account.AccountId;
			res.Nickname = account.Nickname;
			res.IsLoginSucceed = true;
		}
		else
		{ 
			res.IsLoginSucceed = false;
		}

		return res;
	}
}

자바와 스프링을 사용해 웹 개발을 할 때는 Service와 Repository를 사용해 레이어를 분리하는 것은 거의 기본적으로 해야하는 것처럼 진행 하고 있었습니다.
그래서 Controller에 코드가 계속 늘어나는 불편함을 제대로 알지 못했었습니다.

ASP.NET Core로 개발하면서 느꼈던 불편함과 코드를 분리하기 위해 시도한 것들을 공유하기 위해 이렇게 글로 작성해봤습니다.



Controller에 응집된 코드와 역할의 문제점


Controller가 너무 많은 역할을 담당하면 소프트웨어 디자인의 결합도와 응집도가 높아진다.


[Route("api/[controller]")]
[ApiController]
public class AccountController : Controller
{
	private readonly AppDbContext _context;
	private readonly PasswordEncryptor _passwordEncryptor;

	public AccountController(AppDbContext context, PasswordEncryptor passwordEncryptor)
	{
		_context = context;
		_passwordEncryptor = passwordEncryptor;
	}

	[HttpPost]
	[Route("signup")]
	public AccountSignupResDto Signup([FromBody] AccountSignupReqDto req)
	{
		AccountSignupResDto res = new AccountSignupResDto();
		if (string.IsNullOrEmpty(req.AccountName) || string.IsNullOrEmpty(req.Password) || string.IsNullOrEmpty(req.ConfirmPassword) || string.IsNullOrEmpty(req.Nickname) ||
			req.Password.Equals(req.ConfirmPassword) == false)
		{
			res.IsSignupSucceed = false;
			return res;
		}

		AccountDb account = _context.Accounts
							.AsNoTracking()
							.Where(a => a.AccountName == req.AccountName)
							.FirstOrDefault();

		if (account == null)
		{
			string encryptPassword = _passwordEncryptor.Encrypt(req.Password);

			AccountDb newAccount = new AccountDb()
			{
				AccountName = req.AccountName,
				Password = encryptPassword,
				Nickname = req.Nickname,
				CreatedAt = DateTime.Now,
			};
			EntityEntry<AccountDb> addedAccount = _context.Accounts.Add(newAccount);

			_context.SaveChanges();

			res.AccountId = addedAccount.Entity.AccountId;
			res.IsSignupSucceed = true;
		}
		else
		{
			res.IsSignupSucceed = false;
		}

		return res;
	}
}

위 코드의 단순하기 때문에 큰 문제가 되지는 않을 수 있습니다.

하지만 하나씩 뜯어보면 개선해야 할 부분들이 보입니다.

  • Controller가 너무 많은 역할을 담당한다.
    • 너무 많은 것을 담당하기 때문에 결합도(Coupling)이 높아집니다.
  • 버그가 발생 시 어떤 역할을 담당하는 부분에서 발생한 것인지 알기 어렵다.
    • 결국에는 디버깅이 어려워집니다.
    • 코드양이 늘어나면 그만큼 스크롤도 늘어나고 로직의 플로우를 이해하는데도 어려워집니다.
  • 테스트하기 어렵다.
    • 결합도가 높은 코드는 테스트를 하기 어렵습니다. 테스트 코드를 작성할 때도 테스트를 하기 위해 필요한 인스턴스가 많아집니다.

이 외에도 협업을 하면서도 발생하는 불편함이 있습니다.
Controller에 모든 코드가 통합되어 있으니 다수의 사람이 수정을 할 때에도 충돌이 나지 않도록 조심해야 합니다.

불편함과 부족한 부분을 이제 알았으니 실제 제가 적용한 코드를 가져와봤습니다.



불편함을 개선한 코드


1. Controller

[Route("api/[controller]")]
[ApiController]
public class AccountController : Controller
{
	// 의존성 주입(Dependency Injection)을 사용해 AccountService를 가져옵니다.
	private readonly IAccountService _accountService;

	// 생성자를 통해서 의존성을 주입합니다.
	public AccountController(IAccountService accountService)
	{
		_accountService = accountService;
	}

	[HttpPost]
	[Route("signup")]
	public async Task<ActionResult<AccountSignupResDto>> Signup([FromBody] AccountSignupReqDto req)
	{
		var res = await _accountService.AddAccount(req);
		return Ok(res);
	}
}

C# 코드를 모른다고 해도 딱 보면 코드의 양이 훨씬 줄어들어다는 것을 알 수 있습니다.

Controller는 클라이언트로부터 Request데이터를 받고 해당 데이터를 DTO로 서비스 레이어에 전달합니다.

서비스 로직이 처리되고 반환되면 Response 데이터를 클라이언트에게 전달합니다.


2. Service

IService

public interface IAccountService
{
	Task<ServiceResponse<AccountSignupResDto>> AddAccount(AccountSignupReqDto req);
}

Controller와 결합도를 낮추기위해 정의된 인터페이스입니다.
Controller는 추상화 되어 있는 서비스 인터페이스를 통해 소통을 합니다.


Service

public class AccountService : IAccountService
{
	private readonly IAccountRepository _accountRepository;
	private readonly PasswordEncryptor _passwordEncryptor;

	public AccountService(PasswordEncryptor passwordEncryptor, IAccountRepository accountRepository, IMapper mapper)
	{
		_passwordEncryptor = passwordEncryptor;
		_accountRepository = accountRepository;
	}
	public async Task<ServiceResponse<AccountSignupResDto>> AddAccount(AccountSignupReqDto req)
	{
		ServiceResponse<AccountSignupResDto> res = new();

		Account account = _accountRepository.GetAccountByAccountname(req.AccountName);

		if (account == null)
		{
			string encryptPassword = _passwordEncryptor.Encrypt(req.Password);

			Account newAccount = new Account()
			{
				AccountName = req.AccountName,
				Password = encryptPassword,
				Nickname = req.Nickname,
				CreatedAt = DateTime.Now,
			};

			bool isAddAccountSucced = _accountRepository.AddAccount(newAccount);
			if (isAddAccountSucced == false)
			{
				res.Error = "RepoError";
				res.Success = false;
				res.Data = null;
				return res;
			}

			res.Success = true;
			res.Data = _mapper.Map<AccountSignupResDto>(newAccount);
			res.Message = "Created";
		}
		else
		{
			res.Message = "Duplicated Account";
			res.Success = false;
			res.Data = null;
		}

		return res;
	}
}

서비스 로직이 작성되어 있는 클래스이고, 서비스 인터페이스의 구현체입니다.
여기서 DB에 데이터를 저장하고 조회 및 수정하는 역할을 하는 로직은 Repository에서 담당합니다.

서비스 로직은 실질적으로 DB에 데이터를 저장하지는 않고 저장하거나 조회해야 할 객체와 데이터를 Repository에 전달합니다.


3. Repository

IRepository

public interface IAccountRepository
{
	Account GetAccountByAccountname(string accountname);

	bool AddAccount(Account account);
}

서비스 로직과 결합도를 낮추기 위해 정의되어 있는 Repository 인터페이스입니다.


Repository

public class AccountRepository : IAccountRepository
{
	private readonly DataContext _dataContext;

	public AccountRepository(DataContext dataContext)
	{
		_dataContext = dataContext;
	}

	public Account GetAccountByAccountname(string accountname)
	{
		return _dataContext.Accounts.FirstOrDefault(a => a.AccountName == accountname);
	}

	public bool AddAccount(Account account)
	{
		_dataContext.Accounts.Add(account);
		return Save();
	}
	public void UpdateAccountLastLogin(Account account)
	{
		account.LastLoginAt = DateTime.Now;

		Save();
	}

	public bool Save()
	{
		return _dataContext.SaveChanges() >= 0 ? true : false;
	}
}

제가 진행한 프로젝트에는 ORM 기술인 Entity Framework를 사용했습니다.
(자바와 스프링에서 비슷한 기술로는 JPA가 있습니다.)

실질적으로 DB에 데이터를 저장, 영속화 하는 역할을 담당합니다.


4. 의존성 주입(Dependency Injection) 처리

public class Program
{
	public static void Main(string[] args)
	{
		var builder = WebApplication.CreateBuilder(args);

		builder.services.AddScoped<IAccountService, AccountService>();
		builder.services.AddScoped<IAccountRepository, AccountRepository>();
		builder.services.AddSingleton<PasswordEncryptor>();
		
		// ...
	}
}

의존성 주입을 사용해 객체의 생성과 사용의 관심을 분리하도록 처리합니다.

여기서 AccountService 객체의 생명 주기는 클라이언트 요청(Request)당 한 번 생성됩니다.
위 코드에서 AddScoped<>()가 의존성 주입을 할 객체를 설정하는 것입니다.



핵심 키워드


단일 책임 원칙(SRP, Single Responsibility Principle)

Controller는 HTTP 요청을 받아 해당 요청에 대한 처리를 다른 클래스에 위임해야 합니다.
즉, Controller는 요청을 받고 해당 요청을 처리하기 위한 비즈니스 로직이나 데이터 액세스 코드와 분리되어야 합니다.


의존성 역전 원칙(DIP, Dependency Inversion Principle)

Controller가 직접 데이터 액세스나 암호화와 같은 구체적인 구현체에 의존하지 않도록 해야 합니다. 대신, 추상화된 인터페이스나 의존성 주입을 통해 의존성을 주입받아야 합니다.


DTO 및 서비스 계층 도입

Controller에서 데이터 전송 객체(DTO)를 받아 비즈니스 로직을 처리하는 서비스 계층을 도입하여 역할을 분리할 수 있습니다.Controller는 요청을 받고 적절한 DTO를 서비스 계층으로 전달하며, 서비스 계층에서는 비즈니스 로직을 처리하고 데이터 액세스를 담당합니다.



레이어드 아키텍처(Layered architecture)


소프트웨어 아키텍처 중 하나로 관심사 별로 여러개의 계층으로 분리한 아키텍처입니다.
각 구성 요소는 관심사가 분리(Separation of Concerns)되어 있습니다.

위 사진은 가장 일반적인 레이어드 아키텍처 중 4-tier 아키텍처입니다.

레이어드 아키텍처의 특징

  • 각 계층은 추상화된 인터페이스로만 소통을 합니다.
  • 단방향 의존성을 가지며, 하위 계층은 상위 계층을 몰라야 합니다.
    • 하위 계층은 인터페이스만 제공하고 요청만 받을 뿐입니다.
  • ASP.NET Core와 스프링에서는 데이터 전송 객체(DTO)를 사용해서 데이터를 전송합니다.

각 계층의 특징

  • Presentation Layer : 사용자 혹은 클라이언트 시스템과 직접적으로 연결되는 부분입니다.
  • Business Layer : 실제로 시스템이 구현해야 하는 핵심 로직을 담당합니다.
  • Persistence Layer : 데이터의 영구 저장과 관리를 담당하는 부분입니다.
    • 데이터베이스와의 상호 작용을 추상화합니다.
  • Database Layer : 데이터베이스를 뜻합니다.


참고 자료


  • How to Web API .Net Core Basics to Advanced Part 4 Service Layer => 링크
  • 유튜브 영상 - Service Layer Pattern Tutorial => 링크
  • [아키텍쳐] Layered Architecture란? (feat. 자동차 경주 미션 예시) => 링크
  • [소프트웨어 아키텍처] 레이어드 아키텍처(Layered Architecture)란? => 링크
  • [.NET Core] Service 생명주기 - AddScoped, AddTransient, AddSingleton => 링크
  • 위키백과 - 위존성 주입 => 링크
profile
기술에 대한 고민과 배운 것을 회고하는 게임 서버 개발자의 블로그입니다.

0개의 댓글