진행중인 게임 프로젝트에서 계정 정보 저장에 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가 너무 많은 역할을 담당하면 소프트웨어 디자인의 결합도와 응집도가 높아진다.
[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에 모든 코드가 통합되어 있으니 다수의 사람이 수정을 할 때에도 충돌이 나지 않도록 조심해야 합니다.
불편함과 부족한 부분을 이제 알았으니 실제 제가 적용한 코드를 가져와봤습니다.
[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 데이터를 클라이언트에게 전달합니다.
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에 전달합니다.
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에 데이터를 저장, 영속화 하는 역할을 담당합니다.
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<>()가 의존성 주입을 할 객체를 설정하는 것입니다.
Controller는 HTTP 요청을 받아 해당 요청에 대한 처리를 다른 클래스에 위임해야 합니다.
즉, Controller는 요청을 받고 해당 요청을 처리하기 위한 비즈니스 로직이나 데이터 액세스 코드와 분리되어야 합니다.
Controller가 직접 데이터 액세스나 암호화와 같은 구체적인 구현체에 의존하지 않도록 해야 합니다. 대신, 추상화된 인터페이스나 의존성 주입을 통해 의존성을 주입받아야 합니다.
Controller에서 데이터 전송 객체(DTO)를 받아 비즈니스 로직을 처리하는 서비스 계층을 도입하여 역할을 분리할 수 있습니다.Controller는 요청을 받고 적절한 DTO를 서비스 계층으로 전달하며, 서비스 계층에서는 비즈니스 로직을 처리하고 데이터 액세스를 담당합니다.
소프트웨어 아키텍처 중 하나로 관심사 별로 여러개의 계층으로 분리한 아키텍처입니다.
각 구성 요소는 관심사가 분리(Separation of Concerns)되어 있습니다.
위 사진은 가장 일반적인 레이어드 아키텍처 중 4-tier 아키텍처입니다.