티스토리 뷰
루프팩 첫 주, 회원 도메인 세 가지를 TDD로 구현했다.
잘한 것보다 어색했던 게 더 많아서, 일단 기록부터 남긴다. TDD를 막 시작한 사람, 도메인 검증을 어디 둘지 고민하는 사람에게 참고가 될지도.
(Red 실패하는 테스트 Green 최소 구현 Refactor 코드 정리 다음 사이클)
이번 주에 한 일
3개의 유스케이스를 TDD 사이클 한 바퀴씩 돌렸다.
- 회원가입
POST /api/v1/users - 내 정보 조회
GET /api/v1/users/me(이름 마지막 글자*마스킹) - 비밀번호 수정
PATCH /api/v1/users/me/password
각 기능은 test → feat → refactor 세 커밋으로 끊어서, 한 사이클이 어디서 끝나는지 git 그래프만 봐도 보이도록 했다.

가장 곱씹었던 결정 — 검증 책임의 위치
회원가입 한 줄 짜는 데 검증이 다섯 종류가 나왔다.
- loginId가 영문/숫자만인가?
- 이름이 비어있지 않은가?
- 이메일 형식이 맞는가?
- 비밀번호가 정책(8~16자, 허용 문자, 생년월일 미포함)을 만족하는가?
- 이미 가입된 loginId인가?
처음엔 UserService 한 곳에 다 넣었다가, 이 다섯 개는 같은 곳에 있으면 안 될 것 같다는 느낌이 들었다. 그래서 다음과 같이 나눴다.
UserModel.init 형식 검증 loginId · 이름 · 이메일 "도메인 불변식" 이게 깨진 객체는 존재해선 안 됨
UserService 정책 + 컨텍스트 검증 비밀번호 규칙 · 중복 · 인증 "정책은 변한다"
DB/외부 의존이 필요한 검증도 여기에 Controller 입출력 매핑만 검증 책임 X "얇게 유지" DTO ↔ 도메인 변환만 담당

도메인 모델 — 형식 검증
init {
if (!loginId.matches(LOGIN_ID_REGEX)) throw CoreException(BAD_REQUEST, "...")
if (name.isBlank()) throw CoreException(BAD_REQUEST, "...")
if (!email.matches(EMAIL_REGEX)) throw CoreException(BAD_REQUEST, "...")
}
UserModel의 init 블록에 둔 이유는 단순하다. 이 규칙이 깨진 UserModel 인스턴스는 시스템 어디에도 존재해선 안 되기 때문이다. 도메인 객체의 불변식이라면, 그 객체의 생성 시점에 강제하는 게 가장 안전하다.
도메인 서비스 — 정책/컨텍스트 검증
@Transactional
fun signUp(loginId: String, rawPassword: String, ...): UserModel {
if (userRepository.findByLoginId(loginId) != null) {
throw CoreException(CONFLICT, "이미 사용 중인 로그인 ID입니다.")
}
validatePassword(rawPassword, birthDate)
val user = UserModel(...)
return userRepository.save(user)
}
비밀번호 규칙은 언제든 바뀔 수 있는 정책이고, 중복 체크는 Repository가 필요한 컨텍스트 검증이다. 둘 다 도메인 모델 안에 넣을 수 없는 이유가 분명하다.
TYPE 1 형식 검증 위치 · Model.init 깨지면 객체 자체가 성립하지 않음
TYPE 2 정책 검증 위치 · Service 정책은 변하지만 모델은 변하지 않음
TYPE 3 컨텍스트 검증 위치 · Service DB/외부 의존이 필요한 검증

한 번 막혔던 곳 — 검증 순서
처음 작성한 signUp 흐름은 이랬다.
// before
validatePassword(rawPassword, birthDate) // 1. 비밀번호 정책 검증
if (userRepository.findByLoginId(loginId) != null) { ... } // 2. 중복 체크
val user = UserModel(...) // 3. 객체 생성
테스트를 돌리다 보니, 이미 가입된 사용자가 잘못된 비밀번호로 다시 시도할 때 응답 메시지가 "비밀번호 형식 오류" 로 뜨는 게 어색했다. 사용자 입장에서 진짜 원인은 "이미 가입된 ID" 인데, 그게 가려졌다.
순서를 뒤집고 나니 두 가지가 좋아졌다.
// after — Refactor 커밋 0dd5742
if (userRepository.findByLoginId(loginId) != null) { ... } // 1. 중복 먼저
validatePassword(rawPassword, birthDate) // 2. 정책 검증
val user = UserModel(...) // 3. 객체 생성
- 응답이 정직해졌다 — 진짜 원인이 먼저 보임.
- 불필요한 정규식·BCrypt 비용이 줄었다 — 어차피 실패할 요청이면 가장 싼 검증부터.
fail-fast 라는 단어를 책에서만 봤는데, 직접 순서 바꾸고 테스트 돌렸을 때 "아 이게 그거구나" 싶었다. 5줄 순서가 전부였다.
순서 하나 바꿨을 뿐인데 응답이 정직해지고, 불필요한 BCrypt 비용도 같이 사라졌다.
작은 추상화의 큰 효과 — PasswordEncoder
처음엔 이렇게 썼다.
class UserService(
private val passwordEncoder: BCryptPasswordEncoder, // 구현체에 직접 의존
)
Refactor 단계에서 인터페이스로 바꿨다.
class UserService(
private val passwordEncoder: PasswordEncoder, // 인터페이스에 의존
)
@Configuration
class PasswordConfig {
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
한 줄 변화처럼 보이지만 테스트 코드가 달라진다. mockk<PasswordEncoder>() 로 인코딩 결과를 통제할 수 있게 되어, 단위 테스트에서 BCrypt 연산 비용도 사라지고 "이 입력에 이 결과를 반환한다"고 명시적으로 지정할 수 있게 됐다. Refactor 커밋 짜면서 이 변화를 넣을 때 솔직히 처음엔 그냥 인터페이스로 바꾸는 게 뭐가 다른가 싶었는데, 테스트가 훨씬 가벼워지는 걸 보고서야 이해가 됐다.
"추상화는 다형성을 위해서가 아니라, 통제 가능성을 위해서 한다"는 말이 이번에 와닿았다.

KPT
Keep — 페이즈 단위 커밋 분리
PR 리뷰 단위가 명확해지고, "왜 이 코드가 추가되었는지"가 커밋 그래프로 그대로 설명됐다. Refactor 커밋만 따로 보면 "이 주에 뭘 정리했는가"가 한눈에 보여서, 이건 앞으로도 계속 유지하고 싶다.
Problem — Red 단계 한 커밋이 너무 컸다
회원가입 Red 커밋이 +563줄. stub 프로덕션 코드를 한 번에 다 넣다 보니 "테스트 1개 추가"의 단위가 무너졌다. 진짜 TDD스럽게 한다면 더 잘게 쪼개야 했다.
Try — 다음 사이클부턴 "검증 케이스 1~2개 = Red 1커밋"
엔드포인트 단위가 아니라 검증 케이스 단위로 끊어보고 싶다. 그리고 Refactor 단계에서 발견한 인터페이스 추상화는 별도 PR로 분리해도 될지 시도해볼 예정.

마무리
1주차 끝났는데 아직 모르겠는 게 더 많다. 그래도 이번 주 테스트 하나하나 돌리면서 건진 게 있다.
검증 코드 한 줄을 어디 둘지 정하는 건, 결국 "이 테스트가 무엇을 검증해야 하는가"를 먼저 정하는 일이었다. 테스트가 흔들리면 설계가 흔들리고, 설계가 잡히면 테스트도 같이 잡혔다.
'Coding > Dev Study' 카테고리의 다른 글
| [LOOp:pak vol.4] 스투시 반팔티를 10명이 동시에 주문하면 생기는 일 (0) | 2026.06.07 |
|---|---|
| [LOOp:pak vol.4] 이 규칙은 도메인일까, 유스케이스일까 — Service / Facade 의 진짜 분기 기준 (0) | 2026.05.29 |
| [LOOp:pak vol.4] 좋아요, 그 단순해 보이는 기능에서 결정해야 했던 것들 (0) | 2026.05.22 |
| [항해 복귀 스터디] 2주차 설계 + 아키텍처 패턴 (0) | 2026.02.02 |
| [항해 복귀 스터디] 1주차 TDD (0) | 2026.02.02 |
- Total
- Today
- Yesterday
- 코딩테스트공부
- 취준
- 코딩테스트 공부
- 코딩테스트
- 취업준비
- 백엔드 개발자 취업 준비
- 알고리즘공부
- 프로그래머스 자바
- 제로베이스 백준 장학금
- 백엔드 개발자 기술 면접 준비
- 백준
- java
- 코테공부
- 알고리즘
- 개발자 취준
- 코딩테스트 준비
- 백엔드 개발자
- 개발자 취업 준비
- 개발자 면접 준비
- 프로그래머스 카카오
- 코테준비
- 코테 준비
- 알고리즘 공부
- 자바공부
- 취업 준비
- 기술 면접 준비
- 제로베이스 백엔드 스쿨
- 프로그래머스
- 주니어 개발자 취업 준비
- 자바
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
