Spring 계층별 검증 책임 / Mockito(Mock/Stub/Spy)

chaewon·2025년 6월 22일

입력값 검증 책임 분리와 중복 피하는 법, 트레이드오프

1. 계층별 검증 책임 나누기

계층역할 / 책임
Controller기본적인 형식/구조 검증
@Valid, @NotNull, @Pattern
→ 빠르게 잘못된 요청 차단
Service비즈니스 규칙 검증
→ 예: 유저가 이미 탈퇴했는지, 채널 수정 권한 있는지 등
Domain도메인 규칙 검증 및 상태 변경 책임
→ 도메인 객체 스스로 불변성 유지
→ 예: channel.rename(name) 시 유효성 내부 체크
Repository (DB)DB 무결성 보장
→ 유니크 제약조건, FK 등
→ 모든 검증을 통과해도 최후 보루로 작동

2. 중복 검증 피하면서 안정성 확보하는 법

  • 계층에 따른 검증 책임 철저히 분리
    ex) @Email은 Controller, "이미 존재하는 이메일" 여부는 Service.

    • controller 에서는 '형식'을 본다. 이메일 형식, 숫자 범위, 문자열 null여부 등으로 판단할 수 있다.
    • service 는 '로직'을 따질 수 있다. 탈퇴한 유저인지, 권한이 있는지, 이미 존재한 유저인지 등이 그 예시이다.
    • repository에서는 'db 예외'를 본다. 코드 상 유니크 제약 조건 , 동시에 두명이 같은 정보로 회원가입을 하는 경우 등을 처리할 수 있다.
    • Domain은 객체 자체의 내부 상태와 불변성을 검증한다.
      예를 들어, 주문이 이미 취소된 상태인지, 배송이 시작되었는지 등 객체 스스로의 유효성 판단 책임을 갖는다.
    • DB는 트랜잭션, 동시성 제어, 제약 조건 등을 통해 멀티스레드 환경에서도 항상 데이터 무결성을 보장해주는 최후의 보루이다.
  • 비즈니스 검증은 반드시 Service 계층에서만

    • 검증 로직을 Service에 두면 유지보수가 쉽다.
    • 여러 기능의 controller 혹은 서비스 내부 메서드에서 재사용 가능 → 중앙 집중화
  • 계층에 따른 예외 처리
    → Controller: MethodArgumentNotValidException
    → Service: UserAlreadyExistsException, ChannelPermissionException

    • controller는 주로 요청 자체가 잘못된 경우를 처리한다.
      형식적인 오류 (@Valid, @NotBlank, @Email 등) 에 대해서는 스프링 기본 예외가 발생하지만,
      사용자에게 더 명확한 메시지를 주기 위해 커스텀 예외로 변환하거나 직접 던지는 방식도 활용된다.

    • service는 도메인 상의 로직을 위반한 경우를 처리한다.
      해당 도메인에 맞는 의미 있는 커스텀 예외를 직접 던진다.


3. 트레이드오프

장점

  1. 역할이 명확해져서 유지보수가 쉬움

    • 오류 위치 찾기 용이
    • 수정할 때 해당 계층만 보면 되니까 안정적
  2. 검증 로직 중복 없이 재사용 가능

    • 예: 유저가 탈퇴했는지 확인하는 로직 → Service 한 곳에 두면 여러 기능에서 재사용 가능
  3. 단위 테스트 작성이 쉬워짐

    • 각 계층을 독립적으로 테스트 가능

단점

  1. 신중한 구조 파악 필요

    • 어디서 어떤 검증을 하는지 익숙하지 않으면 헷갈림
    • 협업 시 혼동을 줄이려면 검증 위치를 명확히 정한 팀 규칙이나 validateXxx처럼 통일된 메서드 네이밍이 필요함
  2. 검증이 누락되거나 중복될 수 있음

    • Controller와 Service 모두에서 중복 검증하지 않도록 유의
  3. 간단한 기능엔 오버엔지니어링처럼 느껴질 수 있음

    • CRUD 같은 단순 API에서는 계층 분리가 과한 구조처럼 보일 수 있음


Mockito - Mock / Stub / Spy

1. Mock

  • 가짜 객체, 아무 동작도 안 함 (행동 정의해야 작동)
  • 메서드 호출 여부, 횟수 등 행위 검증 중심
UserRepository userRepository = mock(UserRepository.class);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));

사용하는 경우
→ 의존성만 필요하고 내부 동작은 중요하지 않을 때
verify()로 호출 확인하고 싶을 때


2. Stub

  • 정해진 입력에 대해 정해진 출력만 반환하는 단순 객체
  • 테스트 데이터 기반 결과 검증 중심
when(calculator.add(2, 3)).thenReturn(5);

사용하는 경우
→ 특정 입력값에 대한 결과만 중요할 때 - 지금 테스트하는 대상 외에 다른 의존 기능은
그냥 원하는 결과만 나오게 하고 넘어가고 싶을 때
→ 복잡한 검증 없이 결과만 받고 싶을 때


3. Spy

  • 실제 객체를 감싸면서 특정 메서드만 mocking 가능
  • 진짜 동작 + 필요한 부분만 mocking
List<String> list = new ArrayList<>();
List<String> spyList = spy(list);

// 진짜 동작
spyList.add("hello");
System.out.println(spyList.get(0)); // "hello"

// 일부만 가짜로 바꾸기
doReturn(100).when(spyList).size();

System.out.println(spyList.size()); // 100 (원래 size는 1이어야 함)

사용하는 경우
일부 메서드만 가짜로 바꾸고, 나머지는 실제 동작해야 할 때
→ 기존 동작 보존하면서 추적하고 싶을 때

상황설명
1. 호출 여부는 추적하고, 실제 동작은 막고 싶을 때예: 이메일 전송 메서드 호출은 확인하지만 실제로 보내지 않음
2. 일부 메서드만 가짜로, 나머지는 진짜처럼 동작하게 하고 싶을 때예: get()은 가짜 값 반환, put()은 진짜 저장
3. 특정 동작만 실패하도록 시뮬레이션하고 싶을 때예: 외부 API만 실패 처리하고 전체 흐름 테스트
4. 상태 변화나 부작용이 많은 객체의 일부 동작만 통제하고 싶을 때예: 캐시, 파일 저장, 알림 전송 등

상황별 선택 정리

상황선택
전체 기능 대체Mock
반환값만 지정해서 빠르게 테스트Stub
진짜 객체 쓰고 일부만 조작Spy

2개의 댓글

comment-user-thumbnail
2025년 6월 23일

정리가 깔끔하네요! 잘 읽고 갑니다!!👍

1개의 답글