ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 책임 : 유스케이스는 입력 검증이 아니라 도메인 상태 변경
    백엔드 : 서버공부/Spring 2025. 11. 7. 10:49
    728x90

    시작전

     

     

     

    들어가며: 유스케이스와 입력 검증의 역할 혼동

    헥사고날 아키텍처(클린 아키텍처)에서는 유스케이스(use case) 계층이 핵심 비즈니스 로직을 담당한다.

    그런데 개발하다 보면 한 번쯤은 "입력 값 검증은 어디에서 해야 하지?"라는 고민을 해본적이 있을 것이다.

    예를 들어 사용자가 제출한 폼이나 API 요청의 값이 형식에 맞는지 검사하는 작업을 도메인 계층에서 해야 할지, 유스케이스 계층에서 해야 할지 혼란스러울 때가 있다.

    처음에는 "입력 값의 유효성 검사는 도메인 모델이 알아서 해야 하지 않을까?" 하고 생각하기 쉽다.

    그러나 클린 아키텍처 관점에서 보면, 유스케이스 코드는 오로지 도메인 로직(비즈니스 규칙)에 집중해야 하며, 이런 코드가 입력 값 검증 로직으로 오염되어서는 안 된다.

    다시 말해, 유스케이스 계층의 책임은 입력 형식 검증이 아니라 도메인의 상태를 변경하는 것이다.

    하지만 그렇다고 해서 입력 값 검증이 아예 필요 없다는 뜻은 아니다.

    잘못된 입력이 애플리케이션 코어로 들어오면 도메인 상태를 망가뜨릴 수 있기 때문에,

    입력에 대한 검증은 반드시 어디선가 이루어져야 한다.

    관건은 “그것이 어디인가”이다.

    본 글에서는 직접 겪은 혼동을 바탕으로, 입력 검증 책임을 유스케이스가 직접 지지 않으면서도 깨끗하게 처리하는 방법을 정리한다. 

    특히 『만들면서 배우는 클린 아키텍처』 책에서 소개된 Self-Validating Command 을 활용하여, '입력 형식 오류'와 '비즈니스 규칙 오류'를 분리하는 구조를 예시 코드와 함께 보여준다.

    (진짜 목적은 나의 머릿속 정리..)

    입력 검증은 어디에서 수행해야 할까?

    가장 먼저 알아둘 핵심은: “입력 유효성 검증은 유스케이스 클래스의 책임이 아니다!” 이다. 

    유스케이스(애플리케이션 서비스) 메서드 안에서 사용자의 입력값이 null인지, 형식이 맞는지 등을 일일이 검사하기 시작하면,

    유스케이스 코드는 금세 지저분해지고 본연의 역할에서 벗어나게 된다.

    유스케이스는 비즈니스 규칙을 검증하고 도메인 모델의 상태를 변화시키는 데 집중해야 한다.

    그렇다면 입력값 검증은 누가 맡아야 할까?

    한 가지 방법은 프레젠테이션 계층(컨트롤러 등 어댑터)에서 유효성을 검사한 후 유스케이스를 호출하는 것이다.

    예를 들어, Spring MVC의 컨트롤러에서 @Valid 어노테이션을 사용하면 DTO에 대한 Bean Validation 검증이 가능하다. 하지만 이 접근은 문제를 내포하고 있다.

     

    • 동일한 유스케이스를 여러 곳(웹, API, 기타 인터페이스)에서 호출한다면, 각 어댑터마다 똑같은 검증 로직을 구현해야 한다. 이는 중복 코드와 실수의 여지를 낳는다.
    • 어떤 어댑터에서는 검증을 깜빡하고 빠뜨릴 위험도 있다. 그럴 경우 검증되지 않은 잘못된 값이 유스케이스에 전달되어 문제를 일으킬 수 있다.

     

    이러한 이유로, 입력 값 검증은 여전히 애플리케이션 계층의 책임으로 남겨두되, 유스케이스 구현을 어지럽히지 않을 다른 위치에 두어야 한다. 그 해법이 바로 “입력 모델”, 즉 Command 객체를 활용하는 것이다. ( 직전 글인 완전 매핑 전략에서 언급된 바 있다. )

    Command 객체를 통한 유효성 검증 책임 분리

    클린 아키텍처에서는 유스케이스의 입력 데이터 구조를 별도의 DTO/커맨드 객체로 정의한다.

    예를 들어, 송금하기 유스케이스라면 SendMoneyCommand, 닉네임 변경 유스케이스라면 NicknameUpdateCommand와 같은 입력 전용 객체를 만든다. 중요한 것은 이 Command 객체가 자신의 생성 시점에 스스로 유효한지 검증하는 책임을 갖도록 하는 것이다.

    즉, Command의 생성자 내부에서 전달된 인자들의 유효성을 검사하고, 만약 규칙에 어긋난다면 예외를 던져 잘못된 객체가 아예 생성되지 못하도록 막는 것이다.

    이렇게 하면 유스케이스 메서드가 호출될 때에는 이미 올바른 형식의 입력만 들어오도록 보장할 수 있다.

    유효하지 않은 입력은 애플리케이션 코어에 들어오기 전에 차단되므로,

    유스케이스 입장에서는 믿고 사용할 수 있는 보호막이 생기는 셈이다.

    예를 들어, "송금 금액은 음수일 수 없다"는 규칙은 도메인의 현재 상태와 무관한 입력 자체의 규칙이므로,

    송금용 Command 객체의 생성자에서 체크해버릴 수 있다.

    아래 코드는 money 금액이 0보다 작으면 예외를 던져 객체 생성에 실패하도록 하는 예시다.

    public class SendMoneyCommand(
        Long money,
        Long sourceAccountId,
        Long targetAccountId
    ) {
    
            if (money < 0) throw IllegalArgumentException("송금 금액은 0 이상이어야 합니다.");
    }

    위와 같이 Command 객체가 생성 시점에 자체 검증을 수행하면, 유스케이스 메서드를 호출하는 쪽에서 다음과 같이 사용할 수 있습니다:

    // 잘못된 입력: money가 -1이므로 생성자에서 예외 발생
    sendMoney(SendMoneyCommand(-1, 1001L, 1002L))

    SendMoneyCommand(-1, ...)를 생성하는 순간 예외가 발생하여, sendMoney 유스케이스 자체가 호출되지 않는다.

    이렇듯 Command 객체가 입력 값 검증 책임을 맡게 함으로써, 유스케이스를 둘러싼 하나의 오류 방지 계층이 형성된다.

    이제 유스케이스 구현 내부에서는 비즈니스 로직에만 집중할 수 있게 된다.

    또한 각 유스케이스마다 전용 Command 클래스를 사용하는 것이 바람직하다.( 아니 사실 이런 원칙은 유동적으로 적용하는게 맞다.)

    가령 "게시글 등록"과 "게시글 수정" 유스케이스가 있다고 하면,

    겉보기엔 입력 필드가 비슷해 보여도 검증 규칙이나 필요한 값이 다를 수 있다.

    하나의 DTO를 재활용하려고 하면 일부 필드에 null을 허용해야 하는 등 애매한 상황이 생기고, 검증 로직도 조건부로 복잡해진다.

    이는 오히려 실수를 늘리고 코드 가독성을 해친다.

    차라리 유스케이스별로 별도 입력 모델을 만들어 필요한 검증을 각각 넣는 편이 명확하다.

    클래스가 다소 많아지는 비용이 있지만, 응집도 높은 설계와 오류 예방 측면에서 얻는 이익이 더 크다.

    SelfValidating 추상 클래스와 Bean Validation 활용

    위 예시에서는 단순히 if 문을 통해 검증했지만, 실무에서는 Bean Validation(JSR 303)을 활용하면 더욱 선언적으로 검증 규칙을 정의할 수 있다.

    『만들면서 배우는 클린 아키텍처』 책에서는 Bean Validation을 우아하게 적용하기 위해

    SelfValidating<T>라는 추상 클래스를 소개한다.

    이 클래스는 내부에 표준 Validator를 가지고 있으며, 상속받는 DTO가 손쉽게 자기 자신을 검증할 수 있도록 도와준다.

    // Bean Validation 기반 자기 검증 추상 클래스
    public abstract class SelfValidating<T> {
        private final Validator validator;
    
        protected SelfValidating() {
            ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
            this.validator = factory.getValidator();
        }
    
        protected void validateSelf() {
    // 현재 객체(this)를 검증하여 constraint 위반이 있으면 예외 발생
            Set<ConstraintViolation<T>> violations = validator.validate((T) this);
            if (!violations.isEmpty()) {
                throw new ConstraintViolationException(violations);
            }
        }
    }

    javax.validation.Validator를 사용하여 현재 객체를 검증하고, 하나라도 제약 조건을 위반하면 ConstraintViolationException을 던지도록 구현되어 있다.

     

    이제 모든 Command 객체는 이 SelfValidating을 상속받아 생성자에서 validateSelf()만 호출하면 자신의 필드에 대한 검증을 자동으로 수행할 수 있다.

    다음은 닉네임 변경 유스케이스의 입력 모델 NicknameUpdateCommand 예시다.

    이 커맨드 객체는 유저 ID새 닉네임을 인자로 받아들이며, SelfValidating을 상속하여 생성자에서 자신의 유효성을 검사한다.

    public class NicknameUpdateCommand extends SelfValidating<NicknameUpdateCommand> {
    
        private static final String INVALID_NICKNAME_MESSAGE
            = "닉네임은 공백 없이 '.', '-', 영문자와 숫자로 이루어진 2~23자여야 합니다.";
    
        @NotNull(message = "유저 ID는 필수입니다.")
        private final Long userId;
    
        @NotNull(message = INVALID_NICKNAME_MESSAGE)
        @NotBlank(message = INVALID_NICKNAME_MESSAGE)
        @Pattern(regexp = "[a-zA-Z0-9-.]*", message = INVALID_NICKNAME_MESSAGE)
        @Length(min = 2, max = 23, message = INVALID_NICKNAME_MESSAGE)
        private final String nickname;
    
        public NicknameUpdateCommand(Long userId, String nickname) {
            this.userId = userId;
            this.nickname = nickname;
            this.validateSelf();// 생성자에서 입력 값 검증 실행
        }
    
        public Long getUserId() {
            return userId;
        }
        public String getNickname() {
            return nickname;
        }
    }

     

    위 코드에서 @NotNull, @NotBlank, @Pattern, @Length 등의 어노테이션으로 닉네임 필드의 형식 제약을 선언했습니다. SelfValidating을 상속했기 때문에 validateSelf() 호출 한 줄만으로 이 모든 제약을 검사할 수 있다.

    예를 들어 nicknamenull이거나 공백이거나, 정해진 패턴에 어긋나거나 길이가 2자 미만이라면, validateSelf()에서 즉시 예외가 발생한다. 덕분에 유효하지 않은 닉네임을 가진 Command 객체는 아예 생성 자체가 불가능하게 만들었다. 생성이 성공했다는 것은 곧 NicknameUpdateCommandnickname 필드가 형식적으로 올바르다는 뜻이다.

    또한 모든 필드를 final로 선언하여, 한 번 검증을 마치고 나면 그 객체가 불변(immutable) 상태로 유지됨을 보장한다.

     

    참고로 command 객체를 record 로 구현하는 방법도 있다.

     

    참고: 위 코드처럼 Bean Validation을 사용하려면 빌드에 javax.validation 구현체(예: Hibernate Validator)를 추가해야 한다. Spring Boot를 사용한다면 spring-boot-starter-validation 의존성을 추가하면 되고,
    Standalone Java에서는 Hibernate Validator 라이브러리를 수동으로 등록해야 한다.
    SelfValidating 클래스는 생성자에서 ValidatorFactory를 통해 Validator를 구해 사용하므로, 특별한 프레임워크 없이도 동작한다.

     

    유스케이스에서는 도메인 규칙 검증과 상태 변경에 집중

    이제 NicknameUpdateCommand를 이용하는 유스케이스 구현을 살펴보겠다.
    유스케이스 계층에 해당하는 서비스 클래스에서 입력으로 이 Command 객체를 받는다고 가정해 보자.

    유스케이스 메서드는 더 이상 닉네임의 null 체크나 포맷 검사를 할 필요가 없다.

    이미 Command 생성자에서 형식적 오류를 걸러냈기 때문이다.

    그 대신, 유스케이스는 도메인 비즈니스 규칙을 확인하고 실제 도메인 객체의 상태를 변경하는 일에만 집중하면 된다.

    예를 들어 닉네임 변경 시나리오의 비즈니스 규칙으로 "이미 존재하는 닉네임으로는 변경할 수 없다"는 요구사항을 생각해보자.

    이 규칙은 현재 도메인 상태(기존에 해당 닉네임을 가진 사용자가 있는지)를 고려해야 하므로 비즈니스 규칙 검증에 속한다.

    이런 검증은 Command 내부가 아닌, 유스케이스 로직에서 처리해야 한다.

    아래는 유스케이스 서비스 메서드의 예시이다.

    public class UserProfileService {
    // 유스케이스 구현체 (애플리케이션 서비스)
    // ... 생략: 생성자에서 UserRepository 주입 등 ...
    
    public void changeNickname(NicknameUpdateCommand command) {
    // 여기까지 도달했다는 것은 command.nickname 이 형식적으로 유효함을 의미.
    // 1. 비즈니스 규칙 검증: 닉네임 중복 확인
    
    if (userRepository.existsByNickname(command.getNickname())) {
                throw new IllegalArgumentException("이미 사용 중인 닉네임입니다.");
    // 또는 커스텀 예외 DuplicateNicknameException 등을 던질 수 있음
            }
    
    // 2. 도메인 모델 상태 변경: 유저 객체의 닉네임 변경
    User user = userRepository.findById(command.getUserId())
                              .orElseThrow(() -> new NotFoundException("유저가 존재하지 않습니다."));
            user.changeNickname(command.getNickname());
    
    // 3. 변경된 상태 저장 (영속성 어댑터 호출)
            userRepository.save(user);
        }
    }
    

     

    changeNickname 메서드를 보면, 입력 값 형식에 대한 검증 코드가 전혀 없고 곧바로 비즈니스 의미상의 확인 작업부터 진행한다. userRepository.existsByNickname(...) 호출을 통해 닉네임 중복 여부를 체크하고, 위반 시 예외를 던진다.

    이렇게 던져지는 예외는 비즈니스 규칙 오류에 해당한다. 이어서 저장소에서 user 객체를 찾아와 닉네임을 변경하고 저장하는 도메인 상태 변경 작업을 수행한다. 이 흐름이 바로 유스케이스 계층의 본분인 "비즈니스 흐름 제어"이다.

    코드를 정리하면 유스케이스의 일반적인 단계는 다음과 같다.

     

    1. 입력 수신: (이미 Command 객체 생성 시에 형식 검증 완료)
    2. 비즈니스 규칙 검증: (예: 닉네임 중복 검사, 잔액 부족 여부 검사 등 도메인 상태를 고려한 체크)
    3. 모델 상태 조작: 도메인 엔티티의 상태를 변경 (닉네임 변경, 송금 처리 등)
    4. 출력 반환: 필요한 경우 결과를 출력 모델로 변환하여 반환

     

    이 중 1단계의 일부였던 "입력 유효성 검증"이 Command 생성 시점으로 위임되었기 때문에,

    유스케이스 구현체는 2~4단계에 전념할 수 있게 되었다.

    특히 비즈니스 규칙 검증과 입력 값 검증이 확실히 분리되면서, 어느 계층에서 어떤 검증을 담당하는지가 명확해졌다.

    '입력 오류'와 '비즈니스 오류'의 명확한 분리

    이러한 구조를 적용하면서 얻은 큰 이점 중 하나는 오류의 종류를 명확하게 분리할 수 있다는 점이다.

    이제 코드 흐름을 보면:

     

    • 입력 형식 오류: Command 객체 생성 시 validateSelf()에서 발생하는 예외다. 예를 들어 ConstraintViolationException이나 IllegalArgumentException 등으로 나타나며, 주로 클라이언트의 잘못된 입력으로 인해 발생한다. 이 오류는 유스케이스 로직에 진입하기도 전에 발생하므로, 상위 계층(예컨대 컨트롤러나 글로벌 예외 처리기)에서 잡아 적절한 응답을 보낼 수 있다.  보통 HTTP API라면 400 Bad Request로 응답하고 어떤 필드가 잘못됐는지 메시지를 주게 되게된다.
    • 비즈니스 규칙 오류: 유스케이스 내부 또는 도메인 모델에서 발생하는 예외다. 우리 예시에서는 닉네임 중복 시 IllegalArgumentException("이미 사용 중인 닉네임")을 던진 부분이 이에 해당한다. 이런 오류는 비즈니스 조건이 충족되지 않아 작업을 수행할 수 없을 때 발생하며, 주로 도메인 의미에 관련된 예외 (예: DuplicateNicknameException, InsufficientBalanceException 등)로 표현한다. 

     

    이처럼 어떤 단계에서 어떤 예외가 발생했는지에 따라 문제가 입력 문제인지 비즈니스 문제인지 분명하게 구분된다.

    개발자는 예외 처리 로직을 두 유형으로 분리하여 유지보수하기 쉬워지고, 사용자에게도 정확한 피드백을 줄 수 있다.

    예컨대, 닉네임 형식 오류와 닉네임 중복 오류를 구분해주면 사용자 입장에서도 무엇을 수정해야 할지가 명확해진다.

    추가로, 이러한 구조는 테스트 코드 작성에도 도움을 준다. Command 객체 자체를 단위테스트할 때 잘못된 값으로 생성하면 예외가 발생하는지 쉽게 검증할 수 있다. 반대로 올바른 값으로 생성했을 때는 예외가 발생하지 않는지만 확인하면 되니 테스트도 단순해진다. 유스케이스 테스트에서는 비즈니스 로직에만 집중하여, 이미 믿을 수 있는 Command를 가지고 시나리오를 검증하면 된다.

     

    유스케이스는 본분에 충실하게

    정리하면, 유스케이스 계층은 입력 값을 검증하는 세세한 작업까지 직접 떠안을 필요가 없다.

    대신, Command와 같은 입력 모델에게 그 책임을 위임하고, 유스케이스는 비즈니스 규칙을 적용하여 도메인 상태를 변화시키는 본연의 역할에 집중하면 된다. 이러한 책임 분리는 코드 구조를 깔끔하게 만들고, 에러 처리와 확장 측면에서도 이점을 제공한다.

    처음에 입력 검증을 어디서 해야 할지 헷갈렸지만, Self-Validating Command 패턴을 적용한 후 "입력 형식 오류 vs 도메인 비즈니스 오류"를 명확히 구분할 수 있음을 깨달았다. 이제 새로 유스케이스를 작성할 때마다 전용 Command 클래스를 만들고, 거기에 필요한 검증 로직을 담아두는 개발적 관점(?)을 습득하였다.

    참고: 이 글의 내용은 Tom Hombergs의 Get Your Hands Dirty on Clean Architecture (국내 번역서: 만들면서 배우는 클린 아키텍처) 4장의 내용을 기반으로 하였으며, 실제 예제 코드를 통해 재구성했다.
    해당 책에서는 본문에서 설명한 것처럼 유스케이스 구현 시 입력 모델의 생성자에서 검증을 수행하고, 유스케이스는 도메인 로직에 집중하는 패턴을 상세히 다루고 있으니, 더 깊은 이해를 위해 참고하시는 것도 권장한다.

     

    마무리

    사실 느꼈는지 모르겠지만 이런 클린아키텍처는 이렇게 정리해도 어디서 어떤 역할을 수행해야할지 여전히 애매하다.

    이에 대한 세미나를 NHN 에서 김민중님께서 진행하신적이 있는데 아래 링크를 통해 들어보면 도움이 될 것같다. ( 정말 좋은 내용이다! )

     

    https://youtu.be/g6Tg6_qpIVc?si=n7b4uzgDJVZtE4Gb

     

     

     

Designed by Tistory.