[C#] 도메인 주도 설계 - 데이터의 무결성 유지하기

참조

  • 도메인 주도 설계 철저 입문


무결성이란 무엇인가?

  • 시스템이 수행하는 처리 중에는 데이터의 무결성을 필요로 하는 것이 있습니다.
  • 여기서 말하는 무결성이란 [+'서로 모순이 없고 일관적'+] 이라는 뜻입니다.
  • 상품을 주문할 때 발생하는 주문 내역을 예로 들어 봅니다.
  • 주문 내역은 헤더 부분과 바디 부분으로 구성되는데, 헤더 부분에는 주문자명, 주소 등의 정보가 기재되고 바디 부분에는 주문한 상품의 종류 및 수량이 기재됩니다.

  • 이때 헤더 부분과 바디 부분 중 어느 한쪽이라도 누락된다면 정상적인 주문서가 되지 못합니다.
  • 헤더 부분이 없다면 상품을 주문한 사람이 누구인지 알 수 없고, 바디 부분이 없으면 주문자에게 어떤 상품을 전달해야 하는지 알 수 없기 때문입니다.
  • 주문 내역의 헤더와 바디는 항상 함께 존재해야 한다는 일관성이 필요하며 이것이 무결성 입니다.


치명적인 버그

  • 지금까지 앞서 만든 예제코드는 치명적인 버그가 있었습니다.
  • UserApplicationService는 데이터 무결성을 망가뜨릴 수 있는 심각한 문제를 안고 있습니다.
namespace _01.Application.Users
{
    public class UserApplicationService
    {
        private readonly IUserFactory userFactory; 
        private readonly IUserRepository userRepository;
        private readonly UserService userService;

        (...생략...)
        public void Register(UserRegisterCommand command)
        {
            var name = new UserName(command.Name);
            var user = userFactory.Create(name);

            if (userService.Exists(user))
            {
                throw new CanNotRegisterUserException(user, "이미 등록된 사용자임");
            }

            userRepository.Save(user);
        }
    }
}
  • 사용자 등록 처리에는 '사용자 중복을 허용하지 않음' 이라는 중요한 규칙이 있습니다.
  • 언뜻 보면 지금의 코드도 이 규칙을 준수하는 것처럼 보이지만 해당 코드는 본래 의도대로 동작하지 않는 특정한 조건이 있습니다.
  • 어떤 사람이 새로 사용자 등록을 하려고 합니다.
  • 이 사람이 등록하기를 원하는 사용자 명은 'Kim' 입니다.
  • 첫 단계에서는 'Kim' 이라는 사용자 명을 갖는 사용자가 없기 때문에 코드가 정상적으로 동작합니다.


  • 이때, 동시에 또 다른 사용자가 사용자 등록을 시도합니다.
  • 정말 우연히도 이 사람이 등록하려는 사용자 명도 'Kim' 입니다.
  • 첫 번째 사용자가 사용자 등록 처리를 실행하면 먼저 사용자 명 중복 체크를 한 다음 리포지토토리에 인스턴스 저장을 요청합니다.
  • 리포지토리에 인스턴스를 저장하는 도중 두 번째 사용자가 사용자 명 중복 체크를 시도하면 아직 인스턴스 저장이 끝나지 않았으므로 중복 체크에서 걸리지 않습니다.
  • 결과적으로 Kim 이라는 사용자 명을 갖는 사용자가 중복 저장됩니다.



방법 1 - 유일 키 제약

  • 사용자명이 중복되지 않게 데이터 무결성을 유지할 수 있는 방법으로 유일 키를 이용한 방법을 들 수 있습니다.
  • 유일 키 제약은 데이터베이스의 특정 칼럼값이 각각 유일한 값이 되게 보장하는 기능입니다.
  • 유일 키 제약을 위반하는 레코드를 추가하려고 하면 에러가 발생합니다.
  • 유일 키 제약은 데이터의 무결성을 유지하기 위한 매우 편리한 기능입니다.


유일 키 제약에 중복 확인을 맡겼을 경우의 문제점

  • 유일 키 제약은 소프트웨어가 파국을 맞을 수 있는 위험을 제거해주는 강력한 수단입니다.
  • 그러나 잘못 사용하면 코드의 표현력이 저하됩니다.
  • 유일 키 제약을 적용하면 프로그램이 유효하지 않은 사용자명을 탐지하고 강제 종료되므로 중복 확인이 필요가 없어집니다.
  • 따라서 아래와 같이 간단하게 사용자 등록을 할 수 있다고 생각이 들 것입니다.
public void Register(UserRegisterCommand command)
{
    var userName = new UserName(command.Name);
    var user = userFactory.Create(userName);

    userRepository.Save(user);
}
  • 유일 키 제약을 적용했기 때문에 이 코드에서는 사용자명 중복이 없다는 것이 보장됩니다.
  • 결과만 보면 데이터 무결성을 잘 지키는 코드라고 할 수 있습니다.
  • 하지만, 위의 코드만 보고 사용자명 중복이 허용되지 않는다는 정보를 얻을 수 없습니다.
  • 또한, 유일 키 제약 기능은 특정 테이터베이스의 기술이므로 특정 기술 기반에 의존하는 부분이 생기는 문제도 있습니다.


유일 키 제약의 올바른 활용

  • 도메인 규칙 준수를 위해 유일 키 제약을 이용하는 것은 좋은 방법이 아닙니다.
  • 버그는 대부분 개발자의 착각에서 비롯됩니다.
  • 예를 들어 사용자 중복의 기준을 사용자 명이 아니라 이메일 주소라고 잘못 알고 있었다고 생각해 봅니다.
namespace _01.Domain.Models.Users
{
    public class UserService
    {
        private readonly IUserRepository userRepository;

        public UserService(IUserRepository userRepository)
        {
            this.userRepository = userRepository;
        }

        public bool Exists(User user)
        {
            var duplicatedUser = userRepository.Find(user.Mail);

            return duplicatedUser != null;
        }
    }
}
  • 이때 사용자명 칼럼에 유일 키 제약을 걸었다면 사용자명이 중복된 경우 프로그램이 예외를 발생시키며 종료됩니다.
  • 이는 시스템을 강력하게 보호하는 힘이 될 수 있습니다.
  • 유일 키 제약은 규칙을 준수하는 주 수단이 아니라 안전망 역할로 활용해야 합니다.
  • 유일 키 제약만 믿고 중복 확인을 게을리해서는 안됩니다.


방법2 - 트랜잭션

  • 데이터의 무결성을 유지하기 위한 수단으로는 데이터베이스의 트랜잭션 기능이 더 일반적입니다.
  • 트랜잭션은 서로 의존적인 조작을 한꺼번에 완료하거나 취소하는 방법으로 데이터의 무결성을 지킵니다.

트랜잭션 이란? 데이터베이스의 상태를 변화시키기 위해 수행하는 작업의 단위를 뜻한다.

작업의 단위란? 질의어(SQL) - SELECT, INSERT, DELETE, UPDATE 를 이용하여 데이터베이스를 접근 하는 것을 의미한다.

중요한 것은, 작업의 단위는 질의어 한 문장이 아니라 많은 질의어 명령문들을 사람이 정하는 기준에 따라 정하는 것을 의미한다.



트랜잭션을 사용하는 패턴

  • 트랜잭션을 이용하면 사용자 등록 처리 코드의 문제를 해결할 수 있을 듯 합니다.
  • 트랜잭션은 데이터베이스 커넥션을 통해 사용해야 합니다.
  • 리포지토리가 데이터베이스 커넥션 객체를 전달받고 사용하는 역할을 맡습니다.
namespace _04
{
    public class UserRepository : IUserRepository
    {
        private readonly SqlConnection connection;

        public UserRepository(SqlConnection connection)
        {
            this.connection = connection;
        }

        public void Save(User user, SqlTransaction transaction = null)
        {
            using (var command = connection.CreateCommand())
            {
                if(transaction != null)
                {
                    command.Transaction = transaction;
                }

                command.CommandText = @"
 MERGE INTO users
   USING (
     SELECT @id AS id, @name AS name
   ) AS data
   ON users.id = data.id 
   WHEN MATCHED THEN
     UPDATE SET name = data.name
   WHEN NOT MATCHED THEN
     INSERT (id, name)
     VALUES (data.id, data.name);
";
                command.Parameters.Add(new SqlParameter("@id", user.Id.Value));
                command.Parameters.Add(new SqlParameter("@name", user.Name.Value));
                command.ExecuteNonQuery();
            }
        }
    }
}
  • 트랜잭션 시작 및 커밋을 제어하기 위해 UserApplicationService 역시 SqlConnection을 전달 받게 합니다.
namespace _05.Application.Users
{
    public class UserApplicationService
    {
        // 리포지토리가 가진 것과 같은 커넥션 객체
        private readonly SqlConnection connection;
        private readonly IUserFactory userFactory; 
        private readonly IUserRepository userRepository;
        private readonly UserService userService;

        public UserApplicationService(SqlConnection connection, IUserFactory userFactory, IUserRepository userRepository, UserService userService)
        {
            this.connection = connection;
            this.userFactory = userFactory;
            this.userRepository = userRepository;
            this.userService = userService;
        }
    }
}
  • 이제 준비가 끝났으니, 사용자 등록 처리에 트랜잭션을 적용해 데이터의 무결성을 확보합니다.
namespace _05.Application.Users
{
    public class UserApplicationService
    {
        // 리포지토리가 가진 것과 같은 커넥션 객체
        private readonly SqlConnection connection;
        private readonly IUserFactory userFactory; 
        private readonly IUserRepository userRepository;
        private readonly UserService userService;

        public void Register(UserRegisterCommand command)
        {
            // 커넥션을 통해 트랜잭션을 시작
            using(var transaction = connection.BeginTransaction())
            {
                var userName = new UserName(command.Name);
                var user = userFactory.Create(userName);

                if(userService.Exists(user))
                {
                    throw new CanNotRegisterUserException(user, "이미 등록된 사용자임.");
                }

                userRepository.Save(user, transaction);
                //처리가 완료되면 커밋
                transaction.Commit();
            }
        }
    }
}
  • 위의 코드는 트랜잭션을 통해 데이터의 무결성을 확보합니다.
  • 동시에 같은 사용자명으로 사용자 등록을 시도하는 일이 벌어져도 한쪽만 등록되고 다른 한쪽은 등록에 실패합니다.
  • 그러나, UserApplicationService가 인프라의 객체 SqlConnection에 대해 의존하게 됐습니다.


트랜잭션 범위를 사용하는 패턴

  • 트랜잭션을 통해 데이터 무결성을 확보하는 방법이 데이터베이스에만 한정되는 것은 아닙니다.
  • 데이터 무결성을 확보하는 것 자체는 분명히 추상화 수준이 낮은 특정 기술 기반의 역할입니다.
  • 그러나 비즈니스 로직의 입장에서 생각하면 무결성을 지키는 수단이 무엇이냐는 그리 중요지 않스빈다.
  • 데이터 무결성 자체가 특정 기술에 뿌리를 둘 만큼 추상화 수준이 낮은 개념이 아닙니다.
  • 따라서 비즈니스 로직에는 데이터 무결성을 확보하기 위한 구체적인 구현 코드보다는 '이 부분에서 데이터 무결성을 확보해야 한다' 는 것을 명시적으로 보여주는 코드가 담겨야 합니다.
  • TransactionScope MSDN
namespace _05.Application.Users
{
    public class UserApplicationService
    {
        private readonly IUserFactory userFactory; 
        private readonly IUserRepository userRepository;
        private readonly UserService userService;

        public UserApplicationService(IUserFactory userFactory, IUserRepository userRepository, UserService userService)
        {
            this.userFactory = userFactory;
            this.userRepository = userRepository;
            this.userService = userService;
        }

        public void Register(UserRegisterCommand command)
        {
            // 트랜잭션 범위를 생성함
            // using 문의 범위 안에서 커넥션을 열면 자동으로 트랜잭션이 시작된다.
            using(var transaction = new TransactionScope())
            {
                var userName = new UserName(command.Name);
                var user = userFactory.Create(userName);

                if(userService.Exists(user))
                {
                    throw new CanNotRegisterUserException(user, "이미 등록된 사용자임.");
                }

                userRepostiroy.Save(user);
                // 실제 데이터에 반영하기 위해 커밋
                transaction.Complete();
            }
        }
    }
}
  • 트랜잭션 범위는 트랜잭션의 수행 범위를 정의하기 위한 것입니다.
  • 그러므로 실제 트랜잭션을 시작하지는 않습니다. 다만, 트랜잭션 범위 안에서 데이터베이스 커넥션을 새로 열면 트랜잭션이 함께 시작됩니다.
  • 결과적으로 using문의 범위 안에서는 트랜잭션을 적용한 것과 같은 효과를 얻을 수 있습니다.


트랜잭션으로 인한 로크

  • 데이터베이스가 제공하는 트랜잭션은 일관성 유지를 위해 데이터에 로크(Lock)을 겁니다.
  • 트랜잭션을 사용할 때는 이때 일어나는 로크의 범위를 항상 염두에 둬야 합니다.
  • 트랜잭션으로 인한 로크는 범위를 최소한으로 해야 합니다.
  • 로크의 범위가 너무 넓어지면 그에 비례해 처리의 실패 가능성이 높아집니다.
  • 1개의 트랜잭션으로 저장하는 객체의 수를 1개로 제한하고 객체의 크기를 가능한 한 줄이는 방법으로 로크의 범위를 최소화할 수 있습니다.


정리

  • 데이터 무결성을 유지할 수 있는 방법으로 다양한 선택지가 있지만, 어느 것을 사용하더라도 원하는 목적을 달성할 수 있습니다.
  • 본인 판단하에 더 적합한 방법을 사용하면 됩니다.
728x90

이 글을 공유하기

댓글

Designed by JB FACTORY