계층 별 검증은 어떻게 하는가?

이프·2025년 4월 24일
0

tech-blog

목록 보기
3/4

들어가며

프로젝트를 진행하면서 데이터 검증에 대한 고민은 항상 존재합니다. 특히 레이어드 아키텍처를 사용할 때, 각 계층마다 데이터 검증이 필요한지, 어떤 종류의 검증을 해야 하는지 결정하는 것은 쉽지 않습니다.

코코무라는 프로젝트에서 Study를 생성하는 다음과 같은 코드를 작성했습니다:

    public Long createStudy(final Long userId, final CreatePublicStudyDto dto) {
        final User user = userService.getUserWithThrow(userId);
        final Study study = Study.createStudy(dto);

        studyRepository.save(study);
        membershipService.joinLeader(study, user);
        workbookRelationService.addWorkbooksToStudy(study, dto.workbooks());
        languageRelationService.addRelationToStudy(study, dto.languages());

        return study.getId();
    }

Study 생성 DTO는 다음과 같습니다:

@Schema(description = "공개 스터디 생성 요청")
public record CreateStudyDto(
    @NotBlank @Size(max = 20)
    @Schema(description = "스터디명", example = "매일 열심히하는 스터디")
    String name,
    @NotEmpty
    @Schema(description = "스터디에서 추구하는 언어 식별자", example = "[1, 2]")
    List<Long> languages,
    @NotEmpty
    @Schema(description = "스터디에서 추구하는 문제집 식별자", example = "[1, 2]")
    List<Long> workbooks,
    @NotNull
    @Schema(description = "스터디 설명", example = "우리 스터디는 ~ ")
    String description,
    @NotNull @Min(value = 2) @Max(value = 10)
    @Schema(description = "스터디 최대 인원", example = "80")
    int totalUserCount
) {
}

문득, "스터디 생성에 대한 제약은 비즈니스 규칙인데?" 라는 생각이 들었습니다. 그리고 이 규칙은 도대체 어느 곳에서 지켜져야 하는 것인가? 라는 고민을 바탕으로 포스트를 작성하게 되었습니다.

MVC 구조에서 presentation layer vs business layer vs persistence layer의 validation은 언제 해야 되고, 차이가 무엇일까요?

public record Dto(@NotNull String name) {}

예를 들어 dto에서 jakarta validation으로 controller에서 검증하는 것이 있죠?

public class Domain {
  String name;
  public Domain(String name) {
    Assert.NotNull(name, "이름 필수");
  }
}

그리고, Domain에서 validation 을 활용해서 검증하는 방법도 있습니다.

@Validated
public class Service {
  public void service(@Valid Dto dto)
}

그런데 이 외에도 Service에서 @Validated 어노테이션으로 검증하는 방법이 있습니다. 여기서 부터 꼬리 물듯이 계속해서 고려할 부분이 발생합니다.
1. Persist Layer인 Repository도 검증해야 될까요?
2. Controller - Service - Domain - Repository 모두 검증해야 될까요?
3. InfraStructure(ex. Messaging)를 의존한다면 여기서도 검증해야 될까요?

이 글에서는 여러 레퍼런스를 통해 찾아본 정보를 바탕으로, 개인적인 의견을 담아 지금까지의 고민과 각 계층별 검증 전략, 그 이유에 대해 정리해 보려고 합니다.


본론

긴 고민을 마친 결과, 각 계층의 고유한 책임을 고려해야한다 라는 한 문장으로 정리되었습니다. 각 계층이 다음 계층으로 데이터를 전달할 때, 그 데이터가 유효하고 안전한지 확인하는 방어적인 접근이 필요합니다.


Presentation(Controller)

표현 계층은 외부 세계와 애플리케이션을 연결하는 관문입니다. 이 계층의 핵심 검증 책임은 "애플리케이션 내부로 들어오는 데이터가 서비스 계층에서 사용하기에 유효 형식인가?"입니다.

presentation은 business로 데이터 정합성을 생각해 데이터를 전달해야 합니다. 안전한 데이터를 전달하기 위해 Presentation 계층으로 진입할 때, 미리 방어 로직으로 유효성 검증이 필요합니다.

그런데 방어의 기준을 잡기가 참 어렵습니다. 저는 아래와 같은 기준을 정했습니다.

Presentation Layer는 데이터를 전달하기 위해 표현하는 계층입니다.
presentation layer -> business layer로 데이터를 전달 할 때, presentation 은 business rules를 전혀 모르는 입장입니다. 하지만, business Layer에서 요구하는 arguement를 보고 어떤 형식의 데이터를 원하는지?를 알 수 있습니다.

결론은 데이터를 표현할 수 있는 type 형식만 검증하면 되는 것 입니다. 그럼 Controller에서 validation은 왜 사용하는지 고민이 발생합니다.

Controller as GateKeeper

Spring Web MVC에서 지원하는 Controller 의 검증은 애플리케이션의 첫 방어선으로, 잘못된 데이터가 내부 계층으로 전파되는 것을 방지하는 GateKeeper 역할을 합니다.

Business Layer에 잘못된 데이터가 전달되면 다음과 같은 리소스 낭비가 발생할 수 있습니다.

  • Controller에서: Stack, Heap Memory 낭비, Thread Pool 낭비
  • Service에서: DB Connection Pool 낭비, Transaction Overhead 발생
  • Domain 생성: Heap Memory 낭비
//example
@Transactional
public void testCode(Dto dto) {
    testRepository.findAll();
    Test test = new Test(dto.testInfo());
    testRepository.save(test);
	secondTestService.process(dto.secondId());
}

// SeconTestService.java
@Transactional
public void process(Long secondId) {
	seconTestRepository.findById(secondId); 
    
}

위 과정을 모두 거치고 추가적인 로직에서 잘못된 데이터로 인해 예외가 발생한다면, 앞서 작성한 리소스 낭비가 모두 발생하고 예외 응답이 발생하게 될 것 입니다.

이 과정에서 모든 DB Connection pool과 Thread pool이 사용되었다면, 다른 사용자들은 API 요청에서 커넥션과 스레드를 사용하기 위해 대기 할 것이고, 결국 서버 반응이 느리다는 경험을 하게 됩니다. 또, 직접 API를 요청한 Client도 DB Latency Time 등을 생각하면 HTTP가 Connection 된 동안 예외 응답을 받기 까지 응답 지연을 경험하게 됩니다.

이 문제는 Controller를 Gate Keepper 역할로 사용하면, Resource를 아끼고 DB Latency Time 없이 UX 측면에서 개선된 사용자 경험을 제공할 수 있습니다.

HTTP Validation

Controller를 Gate Keeper로 사용하기 위해서는 Spring Web MVC의 기술로Method 레벨에 @Valid 어노테이션 할당과 Jakarta Bean Validation 라이브러리를 활용하여 쉽게 검증을 구현할 수 있습니다:

@PostMapping("/studies")
public ResponseEntity<Long> createStudy(
    @RequestBody @Valid CreateStudyDto dto,
    @AuthenticationPrincipal UserPrincipal principal
) {
    Long studyId = studyService.createStudy(principal.getId(), dto);
    return ResponseEntity.ok(studyId);
}

External Libraries에서 jakarta.validation.constraints 패키지로 들어가보면, 관련 검증 기술이 제공되고 있고 예시 코드 중 DTO 코드처럼 어노테이션을 할당해 손쉽게 검증 할 수 있습니다. (@NotNull, @NotEmpty , ... , ETC)

스터디 제목에 비속어를 금지하는 시나리오를 가정
다만, @Pattern과 같은 검증은 조금 고려해봐야 할 것 같습니다. Study name에 비속어를 포함하지 않는다는 비즈니스 로직이 있다면, Controller에서 GateKeeper로 검증하기에는 너무 Heavy하지 않을까? 라는 고민이 발생합니다.

이 시나리오에서 아래와 같은 TradeOff가 발생합니다:
효율성 낭비 + UX 상승 + Resource 낭비 + UX 저하

단순히, 'TradeOff만 본다면 하나라도 이득인 전자가 낫지 않을까?' 라는 생각을 할 수도 있습니다. 코드 관점에서 본다면 비속어는 정규식으로 표현하기에 한계가 명확합니다. 전자를 선택했을 때, Study Name을 필터링하기 위한 Business Class가 하나 필요할텐데 이것을 Presentation 에서 사용한다면, Layer의 경계가 무너집니다.

그래서 저는 아래와 같이 정의했습니다:
@Pattern에서 사용하는 정규식으로 포함할 수 없으면 복잡한 검증이다.

이런 복잡한 검증은 비즈니스에서만 진행하는게 좋다고 생각합니다. 하지만 전화번호나 이메일과 같이 Pattern으로 검증할 수 있다면, GateKeeper로 사용하기에도 좋다고 생각합니다.

다른 Presentation Layer 기술에서의 검증은?

REST API 외에도 WebSocket, Messaging Queue 등 다양한 기술을 사용할 수 있습니다. 이런 경우에도 방어적 차원에서 검증은 필수적입니다.

Web Socket
Spring 환경에서 WebSocket은 STOMP 프로토콜을 사용할 경우 REST API와 유사하게 @Valid 어노테이션을 적용할 수 있습니다:

@Controller
public class StudyWebSocketController {
    
    @MessageMapping("/studies/create")
    public void createStudy(@Valid CreateStudyDto dto, Principal principal) {
        // 비즈니스 로직
    }
}

예외 처리도 @ControllerAdvice와 @MessageExceptionHandler를 통해 처리할 수 있습니다:

@ControllerAdvice
public class WebSocketExceptionHandler {

    private final SimpMessagingTemplate messagingTemplate;
    
    @Autowired
    public WebSocketExceptionHandler(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    @MessageExceptionHandler(MethodArgumentNotValidException.class)
    public void handleValidationException(MethodArgumentNotValidException e, 
                                         SimpMessageHeaderAccessor headerAccessor) {
        String sessionId = headerAccessor.getSessionId();
        // 해당 세션에 에러 메시지 전송
        messagingTemplate.convertAndSendToUser(
            sessionId, 
            "/queue/errors", 
            new ErrorResponse("검증 오류가 발생했습니다.")
        );
    }
}

Spring에서 @Controller 개념을 확장해서 예시 코드와 같이 GateKeeper 역할을 할 수 있습니다.

Messaging
반면 MQ와 같은 다른 Presentation Layer 기술은 새로운 고민 포인트를 가져다 줍니다. 특히 메세징은 대부분 Worker 처리와 같은 배치성 작업 및 상태 알림을 위한 이벤트성 기술입니다. 이 때, 검증이 필요할까요?

결론은 Yes입니다. 하지만, Contoller의 GateKeeper 역할의 검증이 아닌 Object Mapper에서 type 일치성을 의미하는 Validtion이 필요하다는 것입니다.

그럼, 왜 @Valid와 같은 어노테이션으로 Validation을 할 필요가 없을까요? GateKeeper가 필요한 이유는 Connect 된 Http 통신에서 빠르게 응답을 주기 위함입니다. 이벤트 발생은 server to server의 비동기식입니다. 이 경우, UX와 연관된 부분이 있을까요?

저는 개인적으로 없다고 생각합니다. 또, 예외가 발생하더라도 응답해 줄 곳도 없습니다 ... WebMVC는 @ControllerAdviceSource로 Validation에 대한 응답을 줄 수 있지만, 메시징 방법은 아무래도 힘듭니다. 대표적으로 DLQ나 그냥 무시하는 방법이 있는데, 여기서 다루지는 않겠습니다.

정리: Presentation Layer 검증

핵심 책임: Service Layer로 안전한 데이터 전달
검증 범위: 기본적인 데이터 타입
하지만, GateKeeper 용도로 Validation 추천

기술Type 검증@Valid예외 처리
REST APIObject Mapperyes@ControllerAdvice
WebSocketObject Mapperyes@ControllerAdvice + @MessageExceptionHandler
Message QueueObject MappernoDLQ(Dead-Letter-Queue)로 처리

Business Layer (Service, Domain)

Business Lyaer는 핵심 검증은 비즈니스 규칙을 수행하는 것입니다.

스터디 참여 시나리오

클라이언트가 스터디를 생성하기 위해, EndPoint post /api/studies/1로 API 요청을 한 상황입니다.

비즈니스 규칙

  1. 스터디의 이름은 2글자 이상이고 20글자 미만이다.
  2. 스터디의 처음 인원수는 0명이다.
  3. 스터디의 전체 인원수는 양수이다.
  4. 중복된 스터디명이 존재하면 안된다.

Service에서의 검증

Service Layer는 도메인 간 상호작용을 조율하고, 트랜잭션을 관리합니다. 이 계층의 핵심 검증 책임은 "도메인 간 상호작용을 검증하는 것" 입니다.

@Service
@RequiredArgument
public class StudyService {

    private final StudyRepository studyRepository;

    // Study Aggregate
	public Study create(Long userId, Dto dto) {
    	// Cross Domain interaction
        if (studyRepository.existByStudyName(dto.name)) {
        	throw new IllegalArgumentException();
        }
        
    	Study study = new Study(dto);
        return studyRepository.save(study);
    }

}

현재 코드에서 Service는 동일한 이름의 Study가 있는지, 도메인 간 상호작용을 검증하는 역할을 합니다. 이어서 스터디 생성을 위한 트랜잭션이 시작됩니다.

만약, 사용자 도메인에서 참여한 스터디 정보가 모두 필요하다면 서로 다른 경계에 있는 여러 도메인 간 상호작용이 발생합니다.

Domain에서의 검증
도메인은 독립적으로 관리를 합니다. 이 때, 핵심 검증 책임은 "내부 상태는 객체가 검증한다." 입니다. 도메인은 하나의 객체로 본인의 상태에 대해 완전 보장이 되어 있어야 합니다.

@Entity
public class Study {
	@Id private Long id;
    private String name;
    private int currentUserCount;
    private int totalUserCount;
    
    public Study(String name, int totalUserCount) {
    	Assert.NotEmpty(name, "name is not Empty");
        Assert.gt(totalUserCount, 0, "userCount must be Positive number");
        this.name = name;
        this.currentUserCount = 0;
        this.totalUserCount = totalUserCount;
    }
    
}

코드와 같이 Domain은 생성될 때, 캡슐화를 위한 내부 상태를 명확히 검증해야합니다.

정리

같은 Business Layer에서 Service의 검증과 Domain 검증의 경계가 명확히 있다.

  • Service: 도메인 간 상호작용을 검증
  • Domain: 독립적으로 도메인의 내부 상태를 검증

Service Validation은 상호작용에 따라 있을 수도 있고 없을 수도 있습니다.
Domain Validation은 방어 로직 차원에서 같은 Layer인 Service에서 들어오는 데이터여도 검증이 꼭 필요합니다. ("Trust but verify")


Persistence Layer (Repository)

Persistence Layer는 데이터베이스와의 상호작용을 담당합니다. 이 계층의 주요 질문은 "높은 수준의 컴포넌트에서 전달된 데이터를 신뢰해도 되는가?"입니다.

Persistence 계층의 검증 필요성

영속 계층은 데이터를 영구적으로 관리하는 계층입니다. 즉, 검증의 목적보다는 데이터를 저장하고 조회하는게 주 목적입니다. 그래서 Spring Boot에서는 관련 Interface 명칭도 Repository입니다. 그리고 보통 Service(고수준)에서 호출되므로, 일반적으로 추가 검증이 필요하지 않습니다.

하지만, Persistence Layer도 검증이 필요하긴 합니다. DB 에서 제약 조건을 통해 데이터 무결성을 보장해야합니다. Unique 제약이나 NotNull 제약 같은 다양한 Constraint로 관리하기 때문에, 코드 레벨에서의 검증은 중복이 될 수 있습니다.

이 경우는 Validate를 고려해보자.
DB에서 마이그레이션이나 SQL Injecsion 혹은 DBA에 의해 값이 수정될 수 있습니다. 저장소에서 데이터를 읽어오는데 무결하지 않다고 보장 할 수 없습니다. 그래서 최근에는 Entity to Domain Model을 사용해 추가적인 validation을 진행합니다.

@Entity
public class StudyEntity {
    @Id
    private Long id;
    private String name;
    private String description;
    
    // Entity → Domain 변환 시 검증
    public Study toDomain() {
        validateEntityState();
        return new Study(this.id, this.name, this.description);
    }
    
    private state void validateEntityState() {
        // DB에서 로드된 엔티티 상태 검증
        if (this.name == null || this.name.isBlank()) {
            throw new IllegalStateException();
        }
    }
}

정리

  • 검증 필요성: 일반적으로 낮음 (Service에서 검증 완료)
  • 예외 상황: Entity-Domain 분리 시 변환 과정에서 검증 필요
  • DB 제약 조건: 최종 방어선으로 활용

마치며..

계층검증 책임
Presentation외부 입력 데이터의 기본 타입
Service도메인 간 비즈니스 규칙
Domain자신의 상태 유효성, 도메인 규칙
Repository특별한 경우(Entity-Domain 변환 등), 보통 DB 제약

이러한 계층별 검증 전략을 통해 애플리케이션의 안정성과 데이터 무결성을 확보할 수 있습니다. 검증은 단순히 에러를 방지하는 것을 넘어, 코드의 품질과 유지보수성을 높이는 중요한 부분이라고 생각됩니다.

지금까지 계층별 검증 전략에 대해 알아보았습니다. 여러분의 프로젝트에서는 어떤 검증 전략을 사용하고 계신가요? 댓글로 의견을 나눠주세요!

Reference

profile
if (이런 시나리오는 어떨까?) then(테스트로 검증하고 해결) else(다음 시나리오 고민)

0개의 댓글