본문으로 건너뛰기

안동민 개발노트

본문 시작

OCP·DIP와 생성자 주입의 경계

이미 생성자 주입된 회원가입 서비스에서 구체 구현 선택을 분리해야 하는 이유와 필수 역할의 null 방어, 중복 실패 뒤 호출 횟수를 실행 가능한 코드로 검증합니다.

앞 문서까지의 MemberRegistrationService는 이미 MemberRepositoryPasswordHasher를 생성자로 받습니다.

이 문서에서 생성자 주입을 새로 도입하지 않습니다.

대신 현재 구조가 구체 구현 교체를 어디에서 흡수하고, 생성·호출 경계에서 무엇을 검증할 수 있게 하는지 확인합니다.

앞 문서에서 확장한 PasswordHasherhash + matches 계약도 그대로 이어집니다.


구체 구현 선택을 서비스로 되돌린 반례

아래 파일은 의존 방향을 비교하기 위한 완전한 반례이며 운영 소스에 추가하지 않습니다.

회원가입 순서는 같지만 서비스가 저장소와 PBKDF2 설정을 직접 선택합니다.

board-core/src/example/java/board/member/CoupledMemberRegistrationService.java - 비교용 반례
package board.member;

import java.util.Objects;

public final class CoupledMemberRegistrationService {
    private final MemberRepository members =
            new MemoryMemberRepository();
    private final PasswordHasher passwordHasher =
            new Pbkdf2PasswordHasher(
                    PasswordHashSettings.productionDefaults());

    public Member register(SignUpCommand command) {
        Objects.requireNonNull(command, "command must not be null");
        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));
    }
}

같은 PasswordHasher 역할을 쓰더라도 new Pbkdf2PasswordHasher(...)가 서비스 소스에 있으면 구현 선택과 정책 설정이 고수준 사용 사례에 다시 결합됩니다.

회원가입 서비스 안에서 PBKDF2 구현과 설정을 직접 선택할 때 생기는 소스 의존과 변경 파급을, 구성 루트가 역할 구현을 선택하는 구조와 비교합니다.

SOURCE DEPENDENCY · CHANGE RIPPLE

구현 선택이 서비스에 남으면 교체가 업무 소스로 번진다

필드 타입이 역할이어도 구체 구현을 만드는 소스는 그 구현에 의존합니다. 선택 위치를 구성 영역으로 옮겨야 안정된 회원가입 순서가 구현 교체에서 분리됩니다.

SERVICE-OWNED SELECTION

구체 선택 한 줄이 변경 파급의 출발점이 된다

  1. 선택 소스

    new Pbkdf2PasswordHasher(...)가 회원가입 서비스 안에 있습니다.

  2. 구체 의존 추가

    서비스가 Pbkdf2PasswordHasherPasswordHashSettings의 생성 방법까지 알아야 합니다.

  3. 교체 때 업무 소스 수정

    역할 계약은 같아도 새 어댑터의 생성자와 설정에 맞춰 서비스 파일을 바꿉니다.

  4. 검증 범위 확산

    회원가입 순서가 바뀌지 않았는데 서비스 컴파일과 회귀 검증이 교체 작업에 묶입니다.

구체 구현 선택 위치에 따른 소스 영향
선택 위치 서비스가 아는 것 같은 계약의 구현 교체
서비스 내부 역할 + 구체 클래스 + 생성 설정 MemberRegistrationService 소스 수정
구성 영역 MemberRepositoryPasswordHasher 역할 선택 소스만 수정하고 서비스 순서는 유지

SCOPED OCP

안정된 계약 아래의 구현 교체를 분리한다

OCP 주장은 역할 계약이 유지되는 교체 범위입니다. 모든 파일을 영원히 수정하지 않는다는 뜻이 아닙니다.

SCOPED DIP

고수준 소스는 역할을, 구성 영역은 선택을 안다

생성자 문법만이 아니라 소스 의존과 구현 선택의 소유권을 함께 분리해야 합니다.

구성 루트는 다음 문서의 책임입니다. 이 문서는 구현 교체의 소스 경계만 설명하며 배포 무중단이나 트랜잭션 성질을 주장하지 않습니다.


OCP와 DIP의 주장 범위

여기서 OCP는 PasswordHasher 계약이 안정된 동안 구현만 교체할 때 회원가입 순서의 소스를 수정하지 않는다는 범위로 읽습니다.

앞 문서처럼 hash 전용 계약에 matches를 추가하면 구현 클래스가 함께 수정되는 것은 의도적인 계약 확장입니다. 모든 변경을 막는다는 주장이 아닙니다.

DIP도 생성자 문법만으로 성립하지 않습니다.

고수준 서비스의 소스가 MemberRepositoryPasswordHasher 역할만 알고, 구체 어댑터 선택을 서비스 밖의 구성 영역이 소유할 때 이 예제의 의존 방향이 분리됩니다.


이미 주입된 서비스를 완전한 파일로 확인

다음 파일은 앞 문서의 생성자 주입을 유지하면서 두 필수 역할과 요청 명령을 null에서 방어합니다.

board-core/src/main/java/board/member/MemberRegistrationService.java
package board.member;

import java.util.Objects;

public final class MemberRegistrationService {
    private final MemberRepository members;
    private final PasswordHasher passwordHasher;

    public MemberRegistrationService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        this.members = Objects.requireNonNull(
                members, "members must not be null");
        this.passwordHasher = Objects.requireNonNull(
                passwordHasher, "passwordHasher must not be null");
    }

    public Member register(SignUpCommand command) {
        Objects.requireNonNull(command, "command must not be null");
        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));
    }
}

생성 직후부터 두 역할이 존재하므로 setter 호출 순서나 부분 초기화 상태를 따로 관리하지 않습니다.

구체 클래스가 아닌 역할 타입을 받는다는 점까지 함께 봐야 합니다.


생성 실패와 중복 빠른 실패를 호출 횟수로 검증

테스트 대역은 확장된 PasswordHasher의 두 메서드를 모두 구현합니다.

아래 테스트는 생성자 null 방어와 중복 경로의 existsCalls == 1, hashCalls == 0, saveCalls == 0을 한 파일에서 검증합니다.

board-core/src/test/java/board/member/MemberRegistrationServiceInteractionTest.java
package board.member;

import java.util.Optional;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

final class MemberRegistrationServiceInteractionTest {
    @Test
    void 필수_역할이_null이면_생성_즉시_거절한다() {
        var members = new RecordingMemberRepository(false);
        var hasher = new RecordingPasswordHasher();

        assertThatNullPointerException()
                .isThrownBy(() -> new MemberRegistrationService(
                        null, hasher))
                .withMessage("members must not be null");
        assertThatNullPointerException()
                .isThrownBy(() -> new MemberRegistrationService(
                        members, null))
                .withMessage("passwordHasher must not be null");
    }

    @Test
    void 중복_이메일이면_해시와_저장을_호출하지_않는다() {
        var members = new RecordingMemberRepository(true);
        var hasher = new RecordingPasswordHasher();
        var service = new MemberRegistrationService(members, hasher);

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

        assertThat(members.existsCalls).isEqualTo(1);
        assertThat(hasher.hashCalls).isZero();
        assertThat(members.saveCalls).isZero();
    }

    private static final class RecordingMemberRepository
            implements MemberRepository {
        private final boolean duplicate;
        private int existsCalls;
        private int saveCalls;

        private RecordingMemberRepository(boolean duplicate) {
            this.duplicate = duplicate;
        }

        @Override
        public boolean existsByEmail(String email) {
            existsCalls++;
            return duplicate;
        }

        @Override
        public Member save(Member member) {
            saveCalls++;
            return member.identifiedBy(saveCalls);
        }

        @Override
        public Optional<Member> findByEmail(String email) {
            return Optional.empty();
        }
    }

    private static final class RecordingPasswordHasher
            implements PasswordHasher {
        private int hashCalls;

        @Override
        public String hash(String rawPassword) {
            hashCalls++;
            return "{recording}";
        }

        @Override
        public boolean matches(
                String rawPassword,
                String encodedPasswordHash
        ) {
            return false;
        }
    }
}

이 증거는 중복을 발견한 단일 호출 경로에서 뒤 협력자를 실행하지 않는다는 뜻입니다.

existsByEmail → save를 원자적으로 만들거나 동시 가입과 외부 시스템을 함께 롤백한다는 뜻은 아닙니다.

회원가입 서비스가 생성자에서 두 필수 역할의 null을 거절하고, 중복 이메일 경로에서 해시와 저장을 한 번도 호출하지 않는 증거를 구성 루트 인계와 함께 설명합니다.

CONSTRUCTION · FAST-FAIL EVIDENCE

필수 역할과 중복 실패 순서는 생성과 호출 경계에서 잠근다

완성되지 않은 객체는 생성 시 거절하고 중복 뒤 작업은 호출 횟수로 막습니다. 생성자 주입은 구현 선택을 밖으로 넘기는 연결점이자 필수 협력자를 고정하는 경계입니다.

CONSTRUCTOR BOUNDARY

두 역할은 생성 직후부터 non-null이다

  1. members 검사

    Objects.requireNonNull이 저장 역할 누락을 즉시 거절합니다.

  2. passwordHasher 검사

    확장된 PasswordHasher 역할 누락도 같은 생성 경계에서 거절합니다.

  3. 완성된 서비스 전달

    부분 초기화나 setter 호출 순서 없이 두 역할 참조가 고정됩니다.

DUPLICATE PATH

중복 판정은 뒤 협력자 호출을 차단한다

  1. 명령 null 검사

    요청 경계가 잘못된 명령을 먼저 거절합니다.

  2. existsByEmail == true

    저장소가 중복을 보고하면 서비스가 즉시 예외를 선택합니다.

  3. DuplicateEmailException

    해시와 회원 생성·저장 이전에 호출이 끝납니다.

실행 가능한 테스트가 고정하는 경계 증거
관찰 경로 기대 결과 증명하지 않는 것
members = null 서비스 생성 실패 · 메시지 고정 구성 루트의 구현 선택 정확성
hasher = null 서비스 생성 실패 · 메시지 고정 해시 구현의 보안 강도
중복 이메일 existsCalls == 1 · hashCalls == 0 · saveCalls == 0 동시 가입 원자성 · 외부 시스템 롤백

PORT CONTINUITY

기록 대역도 hash + matches를 구현한다

회원가입은 성공 경로에서 hash만 호출하지만 테스트 대역은 앞 문서에서 확장된 포트 전체를 구현합니다.

COMPOSITION HANDOFF

구성 영역이 공유 저장소와 활성 해시 구현을 고른다

다음 문서가 구현을 선택하고 완성된 MemberRegistrationService를 호출자에게 넘깁니다.

existsCalls == 1, hashCalls == 0, saveCalls == 0은 관찰한 중복 단일 호출의 빠른 실패 증거입니다. existsByEmail → save를 트랜잭션이나 동시성 경계로 만들지는 않습니다.


구현 선택은 구성 루트로 넘긴다

서비스에서 구체 구현 생성을 제거하면 구성 영역이 공유할 MemberRepository와 활성 PasswordHasher를 선택해 완성된 서비스를 전달해야 합니다.

이 문서에서는 구성 코드를 미리 구현하지 않습니다.

다음 문서의 기존 예제에 남은 과거 서비스 이름과 테스트 전용 해시의 실행 기본값은 그 문서의 재설계 범위에서 현재 MemberRegistrationService 및 확장된 해시 계약에 맞춰 정리합니다.


연습 문제

회원가입 뒤 환영 알림을 보내되 저장 호출이 실패하면 알림 호출 횟수가 0인지 기록 대역으로 검증하세요.

WelcomeNotifier를 생성자로 받고 구현 선택을 구성 영역으로 넘기되, 이 호출 순서만으로 저장과 알림 사이의 트랜잭션 원자성이 생긴다고 주장하지 마세요.