비밀번호 하나 검증하려다 VO를 만들고, 레이어 책임을 다시 생각하게 된 이야기
왜 이 글을 쓰게 됐나
루퍼스 백엔드 부트캠프 1주차 과제는 TDD로 Member 도메인 구현하기였다.
솔직히 처음엔 테스트 코드를 먼저 짜야 하는 이유를 잘 몰랐다. 그냥 "먼저 짜는 거구나" 하고 시작했는데, 막상 해보니 테스트 코드가 내 설계의 문제를 나보다 먼저 알아채고 있었다.
이 글은 그 과정에서 겪은 고민들을 정리한 글이다.
TDD가 뭔데?
TDD는 Test-Driven Development, 테스트 주도 개발이다. 코드를 먼저 짜는 게 아니라, 테스트를 먼저 짜고 그 테스트를 통과시키는 방식으로 개발한다.
흐름은 이렇다.
🔴 Red -> 실패하는 테스트를 먼저 작성한다
🟢 Green -> 테스트를 통과시키는 최소한의 코드를 작성한다
🔵 Refactor -> 동작은 유지하면서 코드를 정리한다
이 사이클을 반복한다.
처음엔 "테스트가 먼저 실패하는 게 뭐가 의미 있지?" 싶었는데, 직접 해보니까 테스트를 먼저 쓰는 행위 자체가 "이 코드가 뭘 해야 하는지"를 먼저 정의하는 과정이라는 걸 느꼈다. 구현하기 전에 인터페이스와 책임을 먼저 고민하게 되는 것이다.
테스트 작성은 3A 패턴을 따른다.
@Test
void returns201_whenAllFieldsAreValid() {
// Arrange - 준비: 테스트에 필요한 데이터와 환경 설정
MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest(
"testUser1", "Password1!", "홍길동", "1990-01-01", "test@example.com"
);
// Act - 실행: 테스트 대상 기능 호출
ResponseEntity<ApiResponse<Void>> response = testRestTemplate.exchange(
ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType
);
// Assert - 검증: 결과 확인
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
테스트 더블이란?
테스트를 짜다 보면 외부 의존성(DB, 다른 서비스 등)이 문제가 된다. 실제 DB를 매번 띄우면 느리고, 특정 상황을 재현하기도 어렵다.
이때 쓰는 게 테스트 더블(Test Double) 이다. 영화 촬영에서 위험한 장면을 대신 찍는 스턴트 더블처럼, 테스트에서 실제 객체 대신 가짜 객체를 사용하는 것이다.
대표적인 종류 세 가지만 정리하면,
| Stub |
미리 정해진 값을 반환하도록 설정한 가짜 객체 |
특정 상황을 재현하고 싶을 때 |
| Mock |
메서드가 호출됐는지, 몇 번 호출됐는지 검증하는 가짜 객체 |
상호작용(호출 여부)을 검증하고 싶을 때 |
| Spy |
실제 객체처럼 동작하면서 호출 기록도 남기는 객체 |
실제 로직은 유지하되 호출 여부도 확인하고 싶을 때 |
코드로 보면 이렇다.
// Mock - 가짜 객체. 실제로 아무것도 하지 않음
MemberRepository memberRepository = mock(MemberRepository.class);
// when().thenReturn() 으로 반환값 설정 (Stubbing)
when(memberRepository.findByLoginId("testUser1")).thenReturn(Optional.of(member));
// verify로 호출 검증
verify(memberRepository, times(1)).save(any(Member.class));
// Spy - 실제 객체를 감싸서 호출 기록도 남김
BCryptPasswordEncoder passwordEncoder = spy(new BCryptPasswordEncoder());
// 실제 encode()가 실행되면서 호출됐는지도 검증 가능
verify(passwordEncoder, times(1)).encode("NewPassword2@");
실제 스프링 빈을 쓰려면 스프링 컨텍스트 전체를 띄워야 한다. mock/spy를 쓰면 순수 자바 객체로 만들 수 있어서 훨씬 빠르고 가볍다. 그게 단위 테스트에서 테스트 더블을 쓰는 핵심 이유다.
처음엔 Member 안에 다 때려넣었다
개념은 알았는데, 실제로 구현할 때는 별 고민 없이 Member 생성자 안에 모든 검증 로직을 넣었다.
public Member(String loginId, String password, String name, String birthDate, String email) {
validatePassword(password, birthDate);
validateEmail(email);
validateBirthDate(birthDate);
this.loginId = loginId;
this.password = password;
...
}
처음엔 "뭐 돌아가니까 됐다"고 생각했다. 근데 단위 테스트를 작성하기 시작하면서 이상한 느낌이 들었다.
테스트를 짜다가 멈췄다
MemberTest를 작성하는데, 비밀번호 검증 실패 케이스를 쓰다 보니 이런 생각이 들었다.
"지금 내가 뭘 테스트하는 거지? Member를 테스트하는 건가, 비밀번호 규칙을 테스트하는 건가?"
Member 단위 테스트인데 비밀번호 길이 검증, 특수문자 규칙, 생년월일 포함 여부까지 죄다 MemberTest에 들어가고 있었다.
게다가 비밀번호 암호화를 나중에 추가해야 한다는 걸 뒤늦게 깨달았는데, 암호화된 비밀번호가 생성자에 들어오게 되면 검증 자체가 이상해진다.
// 이미 암호화된 "$2a$10$..." 같은 값이 들어오면
// 길이도 16자 초과, 특수문자 규칙도 다 깨진다
테스트를 짜다가 "이거 뭔가 잘못됐다"는 생각이 들었다..
Value Object란?
설계를 고민하다가 Value Object(VO) 개념을 찾아보게 됐다.
VO는 값 자체로 동등성을 판단하는 객체다. 일반 엔티티가 ID로 식별되는 것과 달리, VO는 가진 값이 같으면 같은 객체로 본다.
VO의 특징은 이렇다.
| 불변성 |
생성 후 상태가 바뀌지 않는다 |
| 값 동등성 |
ID가 아니라 값이 같으면 동일하다 |
| 자기 검증 |
생성 시점에 유효성을 스스로 보장한다 |
| 행위 포함 |
값과 관련된 행위를 직접 가진다 |
비밀번호는 자체적인 규칙(길이, 형식, 생년월일 포함 여부)과 행위(검증, 변경)가 있으니 VO로 분리하기 딱 좋은 후보였다.
반면 이메일이나 생년월일은 포맷 검증만 있고 별도 행위가 없어서 굳이 VO로 만들지 않았다. 이게 맞는 판단인지는 멘토링에서 여쭤볼 예정이다.
@Embedded / @Embeddable이란?
VO를 JPA 엔티티에서 활용할 수 있게 해주는 게 @Embedded와 @Embeddable이다.
- @Embeddable : 이 클래스는 다른 엔티티에 포함될 수 있는 값 타입이다
- @Embedded : 이 필드는 @Embeddable 클래스다
핵심은 자바 객체는 분리되어 있지만, DB 테이블은 하나라는 것이다.
// 자바 코드에서는 Password 객체가 분리되어 있고
@Embeddable
public class Password {
@Column(name = "password")
private String encodedValue;
}
// Member 엔티티에서 @Embedded로 포함시키면
@Entity
public class Member {
@Embedded
private Password password;
}
-- DB 테이블은 members 하나에 password 컬럼이 그냥 들어간다
CREATE TABLE members (
id BIGINT PRIMARY KEY,
login_id VARCHAR(255),
password VARCHAR(255), -- Password 객체의 encodedValue
name VARCHAR(255),
...
);
객체지향적으로 설계하면서도 DB 스키마는 단순하게 유지할 수 있는 게 장점이다.
Password를 분리하기로 했다
이 개념들을 적용해서 Password를 @Embeddable VO로 분리했다.
@Embeddable
public class Password {
@Column(name = "password", nullable = false)
private String encodedValue;
protected Password() {} // JPA 기본 생성자
private Password(String encodedValue) {
this.encodedValue = encodedValue;
}
// 생성 시 검증 책임
public static Password of(String rawPassword, String birthDate, String encodedValue) {
validate(rawPassword, birthDate);
return new Password(encodedValue);
}
// 비밀번호 일치 검증 책임
public boolean matches(String rawPassword, PasswordEncoder encoder) {
return encoder.matches(rawPassword, this.encodedValue);
}
// 비밀번호 변경 책임
public Password change(String oldRaw, String newRaw, String birthDate, String newEncoded, PasswordEncoder encoder) {
if (!matches(oldRaw, encoder)) throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 올바르지 않습니다.");
if (matches(newRaw, encoder)) throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.");
return Password.of(newRaw, birthDate, newEncoded);
}
}
Member는 이제 비밀번호에 직접 관여하지 않고 위임만 한다.
@Entity
public class Member {
@Embedded
private Password password;
// 단순 위임
public void changePassword(String oldRaw, String newRaw, String newEncoded, PasswordEncoder encoder) {
this.password = password.change(oldRaw, newRaw, this.birthDate, newEncoded, encoder);
}
}
분리하고 나니 Member는 멤버 객체 생성에, Password는 비밀번호 규칙에 집중하게 됐다.
또 다른 고민: encode()는 어디서 해야 할까
분리는 했는데 또 막히는 지점이 생겼다.
처음엔 Password.of() 내부에서 암호화까지 같이 처리했다.
// 초기 버전 - 도메인 안에서 encode() 실행
public static Password of(String rawPassword, String birthDate, PasswordEncoder encoder) {
validate(rawPassword, birthDate);
return new Password(encoder.encode(rawPassword)); // 암호화가 도메인에서
}
PasswordEncoder를 외부에서 주입받는 방식이라 직접 빈을 참조하진 않지만, encode() 실행 자체가 도메인 안에서 일어나고 있다.
멘토링에서 들었던 말이 생각났다.
"비즈니스 규칙(검증)은 도메인, 인프라 관심사(암호화)는 서비스 계층"
그래서 encode()를 서비스 계층으로 올렸다.
// 변경 후 - MemberService에서 encode()
@Transactional
public Member register(String loginId, String password, String name, String birthDate, String email) {
String encoded = passwordEncoder.encode(password); // 암호화는 서비스에서
Password encodedPassword = Password.of(password, birthDate, encoded);
return memberRepository.save(new Member(loginId, encodedPassword, name, birthDate, email));
}
// Password.of() - 검증만 남음
public static Password of(String rawPassword, String birthDate, String encodedValue) {
validate(rawPassword, birthDate);
return new Password(encodedValue);
}
원칙대로 분리했는데, 테스트를 짜면서 또 어색한 부분이 생겼다.
// 검증 실패 케이스인데 어차피 예외 나서 쓰이지도 않는 값을 encode()로 만들어야 함
Password.of(shortPassword, "1990-01-01", encoder.encode(shortPassword))
// ↑ 이게 의미가 있나?
원칙을 따르면 테스트 가독성이 떨어지고, 테스트 가독성을 살리면 원칙에서 벗어나는 트레이드오프가 생겼다. 아직 이 부분은 답을 못 찾았고, 멘토링에서 여쭤볼 예정이다.
테스트 책임도 나눴다
Password VO를 분리하고 나서 각 테스트 레이어의 책임도 자연스럽게 정리됐다.
PasswordTest -> 비밀번호 규칙 검증 (길이, 형식, 생년월일 포함 여부)
-> 비밀번호 변경 규칙 (기존 비번 틀림, 동일 비번 불가)
-> 새 비밀번호가 올바르게 암호화됐는지
MemberTest -> changePassword() 호출 후 password 필드가 교체됐는가
MemberServiceTest -> encode()가 올바른 시점에 호출됐는가 (spy 검증)
-> save(), findByLoginId() 등 메서드 호출 검증 (mock 검증)
MemberServiceIntegrationTest -> 레이어가 실제로 연결됐을 때 DB 저장/조회가 되는가
MemberTest의 비밀번호 변경 테스트가 이렇게 바뀌었다.
// before - 암호화 검증까지 같이 하고 있었음
void changesPassword_whenCredentialsAreValid() {
member.changePassword("Password1!", "NewPassword2@", encoder);
assertThat(member.matchesPassword("NewPassword2@", encoder)).isTrue(); // ← Password 책임
}
// after - 필드 교체 여부만 본다
void changesPassword_whenCredentialsAreValid() {
Password oldPassword = member.getPassword();
member.changePassword("Password1!", "NewPassword2@", encoder.encode("NewPassword2@"), encoder);
assertThat(member.getPassword()).isNotSameAs(oldPassword); // ← Member의 관심사만
}
MemberServiceTest도 "encode가 호출됐는가"만 보도록 바꿨다.
// before
assertThat(member.matchesPassword("NewPassword2@", passwordEncoder)).isTrue();
// after - passwordEncoder를 spy()로 선언해서 호출 검증
verify(passwordEncoder, times(1)).encode("NewPassword2@");
각 테스트가 자기 레이어의 관심사에만 집중하게 됐다.
결과와 배운 것
솔직히 처음엔 테스트 코드를 먼저 짜야 하는 이유를 체감하지 못했다.
근데 직접 해보면서 느낀 건, 테스트를 짜다가 "이거 왜 이렇게 짜기 불편하지?" 라는 감각이 오면 그게 설계가 뭔가 잘못되었고, 다시 살펴 볼 수 있다는 것이다.
MemberTest에서 비밀번호 규칙 케이스를 쓰는 게 불편했던 이유는 Member에 책임이 너무 많이 몰려 있었기 때문이었다. 테스트가 먼저 그걸 알아챘다.
각 계층의 검증 책임도 정리됐다.
- 컨트롤러 : HTTP 요청 형식, API 스펙 검증
- 도메인 : 비즈니스 규칙 검증 (비밀번호 형식, 생년월일 포함 여부 등)
- 서비스 : 인프라 관심사 처리 (암호화 등)
아직 트레이드오프를 어떻게 판단해야 하는지, VO 분리 기준이 어디에 있는지는 명확한 답을 얻지는 못했다. 앞으로의 멘토링과 라이브세션 등을 통해 최대한 여쭤보고 답을 얻을려고 한다.