
TL;DR
테스트하기 어려운 코드는 책임이 잘못 놓인 코드다.
회원가입 기능을 만드는 도중 주어진 요구사항에 대하여 필요한 검증은 이랬다.
처음에는 단순하게 생각했다. "검증이니까 요청 들어오는 데서 하면 되지."
문제는, 똑같은 비밀번호 규칙이 회원가입에서도 필요하고 비밀번호 변경에서도 필요하다는 것이었다. 그리고 "비밀번호에 생년월일이 포함되면 안 된다"는 규칙은 비밀번호 필드만 보고는 판단할 수 없었다. 다른 필드를 참조해야 했다.
검증 로직을 어디에 둘지가 생각보다 쉽지 않았고, 결국 4군데를 모두 시도해보게 됐다.


가장 먼저 떠오른 건 Spring의 Bean Validation이었다.
public record SignupRequest(
@NotBlank
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "영문과 숫자만 가능합니다")
String loginId,
@NotBlank
@Size(min = 8, max = 16)
String password,
@Email
String email
) {}
간단한 형식 검증은 문제없었다. 그런데 곧 막혔다.
"비밀번호에 생년월일이 포함되면 안 된다" — 이 규칙은 password와 birthDate, 두 필드를 동시에 봐야 한다. DTO 어노테이션 하나로는 표현할 수 없었다. Spring의 Custom Validator를 만들 수는 있지만, 그러려면 커스텀 어노테이션 정의 클래스와 검증 로직 클래스를 별도로 만들어야 했다. 검증 규칙 하나를 추가하는데 파일이 두 개씩 늘어나는 구조였다.
더 큰 문제는 중복이었다.
SignupRequest → @Pattern + @Size + Custom
ChangePasswordRequest → @Pattern + @Size + Custom ← 똑같은 규칙 반복
비밀번호 변경 DTO에도 같은 어노테이션을 다시 붙여야 했다. 규칙이 바뀌면 두 군데를 수정해야 한다.

DTO에서 안 되니, Service 레이어로 옮겼다.
public MemberModel signup(String loginId, String rawPassword,
String name, LocalDate birthDate, String email) {
if (!loginId.matches("^[a-zA-Z0-9]+$")) {
throw new CoreException(ErrorType.INVALID_LOGIN_ID);
}
if (rawPassword.length() < 8 || rawPassword.length() > 16) {
throw new CoreException(ErrorType.INVALID_PASSWORD);
}
if (rawPassword.contains(birthDate.format(DateTimeFormatter.BASIC_ISO_DATE))) {
throw new CoreException(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE);
}
// ... 이메일, 이름 검증도 여기에
// ... 중복 체크, 암호화, 저장
}
필드 간 교차 검증(비밀번호 ↔ 생년월일)도 자연스럽게 됐다. 한 곳에서 전부 볼 수 있어서 처음엔 괜찮다고 느꼈다.
그런데 테스트를 작성하면서 불편해졌다.
"비밀번호가 8자 미만이면 실패한다"를 테스트하고 싶었을 뿐인데, Service를 테스트하려면 의존성을 전부 준비해야 했다.
@Mock MemberRepository memberRepository;
@Mock PasswordEncoder passwordEncoder;
@Test
void throwsOnShortPassword() {
// 비밀번호 길이와 전혀 무관한 Mock 설정을 먼저 해야 한다
// ...
}
Repository Mock, PasswordEncoder Mock — 비밀번호 길이 검증과는 아무 관계 없는 것들이다. 테스트가 검증 대상보다 Mock 설정에 더 많은 코드를 쓰고 있었다.
그리고 중복 문제도 여전했다. MemberSignupService와 MemberPasswordService 양쪽에 비밀번호 규칙 if문이 복사됐다.

그러면 도메인 엔티티에서 검증하는 건 어떨까. 현재 프로젝트의 BaseEntity에는 guard() 메서드가 있어서, 엔티티 생성/수정 시 자동으로 호출된다.
@MappedSuperclass
@Getter
public abstract class BaseEntity {
/**
* 엔티티의 유효성을 검증한다.
* 이 메소드는 PrePersist 및 PreUpdate 시점에 호출된다.
*/
protected void guard() {}
}
@Entity
public class MemberModel extends BaseEntity {
@Override
protected void guard() {
if (loginId == null || !loginId.matches("^[a-zA-Z0-9]+$")) {
throw new CoreException(ErrorType.INVALID_LOGIN_ID);
}
// ...
}
}
"어떤 경로로 만들어지든 검증을 통과해야 한다"는 점은 매력적이었다. 하지만 두 가지 벽에 부딪혔다.
첫째, 비밀번호는 암호화된 상태로 저장된다.
엔티티가 들고 있는 건 encodedPassword다. 원본 비밀번호의 규칙(8~16자, 생년월일 포함 불가)을 엔티티가 검증할 수 없다. 이미 BCrypt로 변환된 후니까.
둘째, 하나만 테스트하고 싶은데 전부 채워야 한다.
// "이메일 형식"만 테스트하고 싶은데...
new MemberModel(
new LoginId("test"), // 이것도 채워야 하고
"encodedPassword", // 이것도
new MemberName("테스트"), // 이것도
LocalDate.now(), // 이것도
new Email("invalid") // ← 이것만 검증하고 싶다
);

결국 각 값을 별도의 객체로 분리하고, 생성자에서 검증하는 방식으로 갔다.
public class Password {
private final String value;
public Password(String value) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.INVALID_PASSWORD);
}
if (value.length() < 8 || value.length() > 16) {
throw new CoreException(ErrorType.INVALID_PASSWORD);
}
if (!ALLOWED_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.INVALID_PASSWORD);
}
this.value = value;
}
public void validateAgainst(LocalDate birthDate) {
// 생년월일 포함 여부 검증 (다른 값을 참조하는 규칙)
}
}
Service는 검증 없이 흐름만 담당하게 됐다.
public MemberModel signup(String loginId, String rawPassword, ...) {
LoginId loginIdVo = new LoginId(loginId); // 생성 = 검증
Password password = Password.of(rawPassword, birthDate); // 생성 = 검증
MemberName nameVo = new MemberName(name); // 생성 = 검증
memberRepository.findByLoginId(loginId).ifPresent(m -> {
throw new CoreException(ErrorType.DUPLICATE_LOGIN_ID);
});
String encodedPassword = passwordEncoder.encode(rawPassword);
return memberRepository.save(new MemberModel(...));
}
1. 테스트에서 Mock이 사라졌다
// Spring 컨텍스트 없음. Mock 없음. 순수 자바.
@Test
void rejectsPasswordsOutsideLengthRange() {
assertThrows(CoreException.class, () -> new Password("Abc123!"));
}
new Password("short")가 터지면 끝이다. Repository도, PasswordEncoder도 필요 없다. 이전에 Service에서 테스트할 때 느꼈던 "왜 비밀번호 길이를 검증하는데 Mock이 필요하지?" 라는 불편함이 완전히 사라졌다.
실제로 PasswordTest는 Spring 없이 13개의 케이스를 검증한다. 실행 시간도 밀리초 단위다.
2. 검증 중복이 사라졌다
회원가입과 비밀번호 변경, 양쪽에서 동일한 규칙이 필요했다. VO로 분리하니 new Password(rawPassword) 한 줄이면 된다.
// MemberSignupService — 회원가입 시
Password password = Password.of(rawPassword, birthDate);
// MemberPasswordService — 비밀번호 변경 시
Password newPassword = new Password(newRawPassword);
newPassword.validateAgainst(member.birthDate());
규칙이 바뀌면 Password 클래스 하나만 수정하면 된다.
3. "유효하지 않은 값"이 존재할 수 없다
LoginId 객체가 존재한다는 것 자체가 이미 영문+숫자 검증을 통과했다는 의미다. Service나 Controller에서 다시 확인할 필요가 없다. 타입 시스템이 검증을 보장한다.
| 위치 | 테스트 용이성 | 재사용성 | 한계 |
|---|---|---|---|
| Controller (DTO) | O | X (DTO마다 반복) | 필드 간 교차 검증이 어려움 |
| Service | △ (Mock 필요) | X (서비스마다 중복) | 메서드가 비대해짐 |
| Entity | △ (모든 필드 필요) | O | 암호화 전 원본값 검증 불가 |
| Value Object | O (순수 자바) | O (객체 재사용) | 클래스 수가 늘어남 |
VO가 정답이라고 말하고 싶은 건 아니다. 실제로 클래스 수는 늘어났다. LoginId, Password, MemberName, Email — 단순 String이었던 것들이 전부 클래스가 됐기 때문!
그건 아니었다. 실제로 Password는 @Embeddable이 아니다.

DB에 저장되는 건 BCrypt로 암호화된 String이고, Password 객체는 입력값을 검증하는 순간에만 존재하고 사라진다. 저장되는 값과 검증만 하는 값은 역할이 다르다는 걸 이때 알았다.
처음에는 검증이 "어디에 있든 동작만 하면 되지"라고 생각했다. 실제로 Service에 if문을 쭉 나열해도 기능은 돌아간다.
그런데 테스트를 작성하면서 생각이 바뀌었다. 테스트하기 어려운 구조는 대체로 책임이 잘못 배치된 구조였다. "비밀번호 길이 검증을 하는데 왜 DB Mock이 필요하지?" 이 질문에 답하다 보니 자연스럽게 검증이 VO로 옮겨갔다.
TDD가 "테스트를 먼저 작성하는 것"이라는 건 알고 있었다. 그런데 그게 설계를 바꾸는 힘이 있다는 건 이번에 처음 체감했다. 테스트가 쉬워지는 방향으로 코드를 옮기다 보니, 결과적으로 더 나은 구조가 됐다.
다만 이게 항상 맞는 건지는 아직 모르겠다. 규모가 커지면 VO가 너무 많아질 수도 있고, 단순한 필드까지 전부 VO로 만드는 건 과한 것 같기도 하다. 이 부분은 더 경험이 쌓이면 판단이 바뀔 수도 있을 것 같다.
항상 불변인 진리는 “나는 무엇을 테스트할 것인가?”를 놓치지 않는 것인 거 같다. 그러다보면 오버엔지니어링을 하지 않고 트레이드오프에 대하여 판단하는 나만의 기준이 생기는 것 같다.
테스트하기 좋은 구조라는 말은 너무 추상적인 거 같아 "테스트하기 어려운 코드" 에 대하여 집중해 보았다.
요즘은 AI Agent를 통하여 테스트 커버리지 확보에 대한 능률이 크게 증가한 시대라고 생각한다. 진정한 트레이드 오프에 대한 고민이 필요한 시대 아닐까?
(Claude Code만세~)