배경
요즘 TDD를 주제로 스프링 프로젝트를 진행하고 있다.
처음에는 테스트 코드만 잘 짜면 되는 줄 알았다.
그런데 점점 이런 단어들이 들려오기 시작했다.
- 책임
- 역할
- DIP
- VO
그리고 코드 리뷰에서 이런 피드백을 받았다.
“VO를 써보는 건 어떠세요?”
그 순간의 내 생각은 이랬다.
- Controller도 나눴고
- Repository도 만들었고
- RepositoryImpl도 분리했는데
- 심지어 model도 있어!
도대체 뭐가 더 필요하다는 거지?
나는 이미 레이어를 나눴다고 생각했다
내 프로젝트 구조는 전형적인 레이어드 아키텍처였다.

각 레이어의 역할도 나름 정리했다.
- Interfaces : HTTP 경계
- Application : 유스케이스 흐름
- Domain : 비즈니스 로직
- Infrastructure : DB 구현
구조는 맞아 보였다.
그런데도 DIP를 신경 써야 한다는 말을 들었다.
Repository를 썼는데도 DIP가 아니라고?
Repository도 인터페이스로 받았으니 DIP가 적용된 거 아닌가?
그런데 코드 리뷰에서 이런 피드백을 받았다.
“지금 구조는 아직 DIP가 완전히 적용된 건 아니에요.”
그 순간부터 헷갈리기 시작했다.
@RequiredArgsConstructor
@Component
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public MemberModel saveMember(MemberModel memberModel) {
Optional<MemberModel> existing =
memberRepository.findByLoginId(memberModel.getLoginId());
if (existing.isPresent()) {
throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다.");
}
String encrypted = passwordEncoder.encode(memberModel.getPassword());
memberModel.encryptPassword(encrypted);
try {
return memberRepository.save(memberModel);
} catch (DataIntegrityViolationException e) {
throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다.");
}
}
}
나는 이렇게 생각했다.
“JpaMemberRepository를 직접 쓰는 게 아니라
MemberRepository 인터페이스를 쓰고 있는데?”
그런데 왜 DIP가 아니라고 할까?
DIP의 핵심은 이것이다.
고수준 모듈은 저수준 모듈에 의존하면 안 된다.
둘 다 추상화에 의존해야 한다.
나는 “인터페이스를 쓰고 있으니 추상화에 의존하고 있다”라고 생각했다.
하지만 실제 의존성을 보면 달랐다.
현재 Service가 알고 있는 것들
- PasswordEncoder (Spring Security 기술 컴포넌트)
- DataIntegrityViolationException (DB 예외)
- @Transactional (Spring 인프라 어노테이션)
즉, Service는 여전히 Spring과 DB 기술을 알고 있다.
Repository 타입만 인터페이스였을 뿐,
실제로는 인프라에 강하게 묶여 있었다.
DIP의 핵심 한 줄 정리
"나는 햄버거가 어떻게 만들어지는지(DB, 라이브러리) 알 필요 없다. 나는 그저 배고플 때 햄버거(인터페이스)를 받기만 하면 된다!"
DIP는 “타입”이 아니라 “의존성 방향”의 문제였다
내가 착각했던 부분은 이것이다.
서비스에서 MemberRepository 인터페이스로 받았으니 DIP 아닌가?
하지만 더 중요한 질문은 이것이었다.
- 그 인터페이스는 어디에 정의되어 있는가?
- Service는 어떤 패키지를 import하고 있는가?
- 비즈니스 로직이 기술에 의존하고 있지 않은가?
그래서 구조를 다시 보았다
Before 구조는 이런 느낌이었다.
Service
↓
MemberRepository (interface)
↓
Jpa 구현체
겉보기엔 괜찮아 보인다.
하지만 실제 의존성은 이랬다.
Service
↘ PasswordEncoder
↘ DataIntegrityViolationException
↘ Spring Transaction
Service가 인프라 기술을 너무 많이 알고 있었다.
getMember에서 드러난 문제
기존 코드는 이렇게 생겼다.
public MemberInfo getMember(String loginId) {
LoginId validLoginId = new LoginId(loginId); <---- 이부분에 대한 해결책 중 하나가 VO 도입!
MemberModel member = memberRepository.findByLoginId(validLoginId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND));
return MemberInfo.from(member);
}
“아이디 형식 검증”을 넣으려다 보니 new LoginId(id)로 항상 객체를 생성해서 검증을 해야했다.
- 조회 메서드마다 new LoginId(...)가 들어간다
- 검증/변환 책임이 서비스로 내려온다
- 코드가 “비즈니스”가 아니라 “입력 정리”로 보이기 시작한다
그래서 “이 반복을 없애고 싶다”라고 물어봤고, 그때 받은 해결책 중 하나가 “VO를 써보라”였다.
VO는 ‘new를 없애는 기술’이 아니라 ‘유효한 값만 내부로 들어오게 만드는 설계’다.
해결은 new를 마술처럼 없애는 게 아니었다.
VO 생성 위치를 서비스가 아니라 ‘경계’로 올리는 것이었다.
그럼 “VO를 어디서 만들 것인가?”가 자연스럽게 다음 고민이 되었다.
DIP는 그다음 문제였다
VO는 값의 규칙/의미를 고정하는 문제였고,
DIP는 의존성 방향(도메인이 인프라를 모르게) 문제였다.
리팩터링을 하다 보니 둘이 같이 등장해서 헷갈렸지만, 출발점은 분명했다.
서비스에서 반복되는 new LoginId(...)를 없애고 싶었다.
그 해결이 “VO 생성 위치를 경계로 옮기는 것”이었다.
Member → User 리팩토링: VO 도입과 DIP 적용
리팩토링의 목표는 두 가지였다.
- Value Object로 검증 책임을 분리해서, 엔티티가 항상 유효한 상태를 갖게 만들기
- Repository 계약을 도메인 중심으로 정리해서 DIP를 명확히 만들기
프로젝트 구조는 흔히 말하는 4-layer를 따르긴 하지만, 내 구현에서는
Domain 레이어에
- VO
- Domain Service
- Repository Interface
를 함께 두었다.
즉, “도메인 규칙”과 “도메인이 요구하는 저장 계약(Port)”을 한 곳에 모으는 방식이다.
기존 구조의 문제
1) 검증 규칙이 엔티티에 종속됨
public MemberModel(String loginId, String password, ...) {
validateLoginId(loginId);
validatePassword(password, birthDate);
this.loginId = loginId;
this.password = password;
}
- 규칙이 엔티티 private 메서드에 붙어 있어 재사용/변경에 불리
- 결국 엔티티가 “검증+상태”를 다 떠안게 됨
2) 원시 비밀번호와 암호화 비밀번호가 혼재됨
MemberModel member = new MemberModel(loginId, rawPassword, ...);
member.encryptPassword(passwordEncoder.encode(member.getPassword()));
- 엔티티가 원시 비밀번호를 잠깐이라도 갖는 구간이 생김
- 엔티티가 일시적으로 유효하지 않은 상태를 가질 수 있음
3) Repository 계약이 도메인 요구를 반영하지 못함
- update()처럼 실제로 필요 없는 메서드가 계약에 남아 있었고,
- 구현체는 Optional.empty() 같은 의미 없는 동작을 하게 됨
변경 내용
(1) Domain Layer에 VO를 두고 “유효성”을 타입으로 고정
VO의 핵심은 단순히 클래스를 추가하는 게 아니라:
유효하지 않은 값이 시스템 내부에 존재할 수 없게 만드는 것
예: LoginId VO
@Embeddable
public class LoginId {
private String value;
public LoginId(String value) {
if (value == null || value.isBlank()) { throw ... }
if (!value.matches("^[a-zA-Z0-9]+$")) { throw ... }
this.value = value;
}
}
이제 loginId 규칙은 엔티티가 아니라 LoginId가 소유한다.
(2) 비밀번호는 생명주기에 따라 Raw / Encrypted로 분리
- RawPassword: 검증 전용(비영속)
- EncryptedPassword: 암호화 결과만 보관(영속)
RawPassword rp = RawPassword.of(rawPassword, birthDate);
EncryptedPassword ep = EncryptedPassword.of(encoder.encode(rp.value()));
Users users = Users.create(loginId, ep, name, birthDate, email);
이렇게 바뀌면서 엔티티가 원시 비밀번호를 갖는 시점 자체가 사라졌다.
(3) 엔티티는 “항상 유효한 상태로만” 생성되도록 변경
- public 생성자 제거
- Users.create(...) 팩토리 메서드로만 생성
비밀번호 변경도 한 단계로 원자적으로 처리
public void changePassword(String newRawPassword, Function<String, String> encoder) {
RawPassword rp = RawPassword.of(newRawPassword, this.birthDate);
this.password = EncryptedPassword.of(encoder.apply(rp.value()));
}
(4) DIP 적용: Domain 레이어가 Repository 계약을 소유
Before
- MemberRepository.update() 존재
- 구현체에서 의미 없는 반환
After
// Domain Layer
public interface UserRepository {
Users save(Users users);
Optional<Users> findByLoginId(LoginId loginId);
boolean existsByLoginId(LoginId loginId);
}
// Infrastructure Layer
@Repository
public class UserRepositoryImpl implements UserRepository {
}
여기서 중요한 점은:
- Domain이 인터페이스를 소유한다.
- Infrastructure가 구현한다.
- Application은 인터페이스에만 의존한다.
이게 DIP가 되는 이유는 “주입 타입이 인터페이스라서”가 아니라,
즉, 의존성 방향이
Infrastructure → Domain
으로 뒤집혔다.
그래서 의존성은 이렇게 된다:
- Domain은 Infrastructure를 모른다
- Infrastructure는 Domain을 안다(implements 하니까)
“그럼 Service를 Domain에 둔 건 괜찮나?”
내 케이스에서는 Service를 Domain에 두었다.
이때 기준은 딱 하나다.
★ Domain Service로 두기 좋은 경우
- 유즈케이스 흐름 조립이 아니라
- “도메인 규칙/상태 변경”이 중심일 때
(예: 비밀번호 변경 규칙, 사용자 생성 규칙, 상태 전이)
※ Application Service로 분리하고 싶은 경우
- 트랜잭션 경계/외부 API 호출/여러 Aggregate 조합이 많아질 때
- 도메인 규칙보다 “흐름 오케스트레이션”이 커질 때
나는 현재 단계에서는 도메인 규칙 정리가 우선이라 Domain에 두는 선택을 했다.
DIP 적용: Repository 인터페이스 정리
리팩토링의 의미
이번 변경은 단순한 “VO 도입”이 아니었다.
- 도메인 규칙을 타입으로 고정했다.
- 엔티티의 유효 상태를 보장했다.
- Repository 계약을 도메인 중심으로 재정의했다.
- Infrastructure 의존성을 도메인 밖으로 밀어냈다.
그래서 구조는 이렇게 바뀌었다.


'개발 > 개발' 카테고리의 다른 글
| [Java / Spring Boot] 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일-2편 (1) | 2026.03.06 |
|---|---|
| [Java / Spring Boot] @Transactional 붙이면 동시성이 다 해결될 줄 알았다 -1편 (0) | 2026.03.05 |
| [스크랩 기능] 스크랩은 HardDelete vs SoftDelete? (0) | 2026.02.26 |
| [스크랩 기능] 스크랩 삭제 API 설계 – scrapId만으로 충분할까? (0) | 2026.02.26 |
| [스크랩 기능]폴더 하나 만들면서 UX와 설계를 같이 고민해본 이야기_1 (1) | 2026.02.11 |