본문 바로가기
개발/개발

[TDD] TDD를 하다 보니 DIP와 VO를 이해해야 했다

by 글쓰는 개발자 2026. 2. 27.

배경

요즘 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 적용

리팩토링의 목표는 두 가지였다.

  1. Value Object로 검증 책임을 분리해서, 엔티티가 항상 유효한 상태를 갖게 만들기
  2. 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 의존성을 도메인 밖으로 밀어냈다.

그래서 구조는 이렇게 바뀌었다.

반응형