본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
2장 : 객체지향 설계와 Spring

OCP·DIP 위반과 생성자 주입

회원가입 서비스가 구체 저장소와 해시 구현을 직접 생성할 때 생기는 변경 파급을 확인하고 생성자 주입으로 의존 방향을 바로잡습니다.

테스트용 TestPasswordHasher를 운영용 Pbkdf2PasswordHasher로 바꾸려면 현재 서비스의 new 코드를 수정해야 합니다.

회원가입 순서는 변하지 않았는데 해시 구현 변경이 업무 서비스까지 번진 것입니다.

DefaultMemberRegistrationService.java - 문제
 private final PasswordHasher passwordHasher =
-        new TestPasswordHasher();
+        new Pbkdf2PasswordHasher(settings);

OCP를 변경 범위로 읽기

OCP는 새 구현을 추가할 때 안정된 업무 코드의 수정을 줄이라는 원칙입니다.

모든 파일을 영원히 닫아 두라는 뜻이 아닙니다.

비밀번호 해시 정책 자체가 바뀌면 PasswordHasher 계약도 바뀔 수 있지만, 구현만 교체할 때 회원가입 순서까지 수정할 이유는 없습니다.


DIP를 의존 방향으로 읽기

DIP는 고수준 정책이 저수준 구현 클래스보다 역할 계약에 의존하게 합니다.

나쁜 방향: MemberRegistrationService → Pbkdf2PasswordHasher
좋은 방향: MemberRegistrationService → PasswordHasher ← Pbkdf2PasswordHasher

서비스와 구현이 모두 PasswordHasher를 향합니다.

서비스는 해시 방법을 모르고 구현은 계약을 지킵니다.


생성자 주입으로 수정

DefaultMemberRegistrationService.java
public final class DefaultMemberRegistrationService {
    private final MemberRepository members;
    private final PasswordHasher passwordHasher;

    public DefaultMemberRegistrationService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        this.members = members;
        this.passwordHasher = passwordHasher;
    }

    public Member register(SignUpCommand command) {
        if (members.existsByEmail(command.email())) {
            throw new DuplicateEmailException(command.email());
        }
        String passwordHash = passwordHasher.hash(command.rawPassword());
        return members.save(Member.signUp(
                command.email(), command.name(), passwordHash));
    }
}

필수 협력자가 생성자에 모두 보이므로 불완전한 서비스를 만들기 어렵습니다.

테스트는 빠른 가짜 구현을, 운영 구성은 실제 해시 구현을 전달할 수 있습니다.


실패 순서 테스트

MemberRegistrationServiceFailureTest.java
@Test
void 중복_이메일이면_해시와_저장을_실행하지_않는다() {
    var members = new RecordingMemberRepository(true);
    var hasher = new RecordingPasswordHasher();
    var service = new DefaultMemberRegistrationService(members, hasher);

    assertThatThrownBy(() -> service.register(new SignUpCommand(
            "member@example.com", "회원", "secret-1234")))
            .isInstanceOf(DuplicateEmailException.class);

    assertThat(hasher.called()).isFalse();
    assertThat(members.saved()).isFalse();
}

구현 선택은 어디에서 하는가

서비스에서 new를 제거했으므로 누군가는 구현을 골라야 합니다.

프로그램 시작 지점에서 저장소, 해시, 서비스를 한 번 조립하는 객체를 구성 루트라고 합니다.

다음 문서에서 BoardConfig로 구현합니다.


연습 문제

회원가입 뒤 환영 알림을 보내되 저장 실패 때는 보내지 않아야 합니다.

WelcomeNotifier를 생성자로 받고 호출 순서를 테스트하세요.

알림 구현을 서비스 안에서 직접 만들지 않아야 하는 이유도 OCP와 DIP 관점에서 설명합니다.