OCP·DIP와 생성자 주입의 경계
이미 생성자 주입된 회원가입 서비스에서 구체 구현 선택을 분리해야 하는 이유와 필수 역할의 null 방어, 중복 실패 뒤 호출 횟수를 실행 가능한 코드로 검증합니다.
앞 문서까지의 MemberRegistrationService는 이미 MemberRepository와 PasswordHasher를 생성자로 받습니다.
이 문서에서 생성자 주입을 새로 도입하지 않습니다.
대신 현재 구조가 구체 구현 교체를 어디에서 흡수하고, 생성·호출 경계에서 무엇을 검증할 수 있게 하는지 확인합니다.
앞 문서에서 확장한 PasswordHasher의 hash + matches 계약도 그대로 이어집니다.
구체 구현 선택을 서비스로 되돌린 반례
아래 파일은 의존 방향을 비교하기 위한 완전한 반례이며 운영 소스에 추가하지 않습니다.
회원가입 순서는 같지만 서비스가 저장소와 PBKDF2 설정을 직접 선택합니다.
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(...)가 서비스 소스에 있으면 구현 선택과 정책 설정이 고수준 사용 사례에 다시 결합됩니다.
SOURCE DEPENDENCY · CHANGE RIPPLE
구현 선택이 서비스에 남으면 교체가 업무 소스로 번진다
필드 타입이 역할이어도 구체 구현을 만드는 소스는 그 구현에 의존합니다. 선택 위치를 구성 영역으로 옮겨야 안정된 회원가입 순서가 구현 교체에서 분리됩니다.
SERVICE-OWNED SELECTION
구체 선택 한 줄이 변경 파급의 출발점이 된다
-
선택 소스
new Pbkdf2PasswordHasher(...)가 회원가입 서비스 안에 있습니다. -
구체 의존 추가
서비스가
Pbkdf2PasswordHasher와PasswordHashSettings의 생성 방법까지 알아야 합니다. -
교체 때 업무 소스 수정
역할 계약은 같아도 새 어댑터의 생성자와 설정에 맞춰 서비스 파일을 바꿉니다.
-
검증 범위 확산
회원가입 순서가 바뀌지 않았는데 서비스 컴파일과 회귀 검증이 교체 작업에 묶입니다.
| 선택 위치 | 서비스가 아는 것 | 같은 계약의 구현 교체 |
|---|---|---|
| 서비스 내부 | 역할 + 구체 클래스 + 생성 설정 | MemberRegistrationService 소스 수정 |
| 구성 영역 | MemberRepository와 PasswordHasher 역할 |
선택 소스만 수정하고 서비스 순서는 유지 |
구체 구현 선택 위치에 따른 소스 영향
서비스 내부
서비스가 아는 것: 역할 + 구체 클래스 + 생성 설정
같은 계약의 구현 교체: MemberRegistrationService 소스 수정
구성 영역
서비스가 아는 것: MemberRepository와 PasswordHasher 역할
같은 계약의 구현 교체: 선택 소스만 수정하고 서비스 순서는 유지
SCOPED OCP
안정된 계약 아래의 구현 교체를 분리한다
OCP 주장은 역할 계약이 유지되는 교체 범위입니다. 모든 파일을 영원히 수정하지 않는다는 뜻이 아닙니다.
SCOPED DIP
고수준 소스는 역할을, 구성 영역은 선택을 안다
생성자 문법만이 아니라 소스 의존과 구현 선택의 소유권을 함께 분리해야 합니다.
구성 루트는 다음 문서의 책임입니다. 이 문서는 구현 교체의 소스 경계만 설명하며 배포 무중단이나 트랜잭션 성질을 주장하지 않습니다.
OCP와 DIP의 주장 범위
여기서 OCP는 PasswordHasher 계약이 안정된 동안 구현만 교체할 때 회원가입 순서의 소스를 수정하지 않는다는 범위로 읽습니다.
앞 문서처럼 hash 전용 계약에 matches를 추가하면 구현 클래스가 함께 수정되는 것은 의도적인 계약 확장입니다. 모든 변경을 막는다는 주장이 아닙니다.
DIP도 생성자 문법만으로 성립하지 않습니다.
고수준 서비스의 소스가 MemberRepository와 PasswordHasher 역할만 알고, 구체 어댑터 선택을 서비스 밖의 구성 영역이 소유할 때 이 예제의 의존 방향이 분리됩니다.
이미 주입된 서비스를 완전한 파일로 확인
다음 파일은 앞 문서의 생성자 주입을 유지하면서 두 필수 역할과 요청 명령에 null이 전달되면 거부합니다.
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을 한 파일에서 검증합니다.
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(
"[email protected]", "회원", "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를 원자적으로 만들거나 동시 가입과 외부 시스템을 함께 롤백한다는 뜻은 아닙니다.
필수 역할과 중복 실패 순서는 생성과 호출 경계에서 고정한다
중복 판정 true에서 즉시 끝나 hash/save 0, 정상에서 해시·저장으로 가는 경계가 관찰된다.
CONSTRUCTION · FAST-FAIL EVIDENCE
필수 역할과 중복 실패 순서는 생성과 호출 경계에서 잠근다
완성되지 않은 객체는 생성 시 거절하고 중복 뒤 작업은 호출 횟수로 막습니다. 생성자 주입은 구현 선택을 밖으로 넘기는 연결점이자 필수 협력자를 고정하는 경계입니다.
CONSTRUCTOR BOUNDARY
두 역할은 생성 직후부터 non-null이다
-
members검사Objects.requireNonNull이 저장 역할 누락을 즉시 거절합니다. -
passwordHasher검사확장된
PasswordHasher역할 누락도 같은 생성 경계에서 거절합니다. -
완성된 서비스 전달
부분 초기화나 setter 호출 순서 없이 두 역할 참조가 고정됩니다.
DUPLICATE PATH
중복 판정은 뒤 협력자 호출을 차단한다
-
명령 null 검사
요청 경계가 잘못된 명령을 먼저 거절합니다.
-
existsByEmail == true저장소가 중복을 보고하면 서비스가 즉시 예외를 선택합니다.
-
DuplicateEmailException해시와 회원 생성·저장 이전에 호출이 끝납니다.
| 관찰 경로 | 기대 결과 | 증명하지 않는 것 |
|---|---|---|
| members = null | 서비스 생성 실패 · 메시지 고정 | 구성 루트의 구현 선택 정확성 |
| hasher = null | 서비스 생성 실패 · 메시지 고정 | 해시 구현의 보안 강도 |
| 중복 이메일 | existsCalls == 1 · hashCalls == 0 · saveCalls == 0 |
동시 가입 원자성 · 외부 시스템 롤백 |
실행 가능한 테스트가 고정하는 경계 증거
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를 트랜잭션이나 동시성 경계로 만들지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 이 그림은 생성자에서 필수 역할 두 개를 이미 받은 뒤의 가입 호출 범위다. 중복 판정 true는 예외로 직접 끝나며 hashCalls와 saveCalls가 0이다. 정상 분기는 해시와 저장으로 진행한다. 앞의 생성자 null 실패와 호출 경로의 실패는 아래 증거에서 별도로 구분한다. 6노드와 5간선이다. 알림 연습의 외부 호출이나 트랜잭션 원자성을 이 코드에 추가한 그림이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 필수 역할과 중복 실패 순서는 생성과 호출 경계에서 고정한다 |
| 6 | 중복 판정에서 후속 협력자 호출을 차단하기 |
| 7 | 이 그림은 생성자에서 필수 역할 두 개를 이미 받은 뒤의 가입 호출 범위다. 중복 판정 true는 예외로 직접 끝나며 hashCalls와 saveCalls가 0이다. 정상 분기는 해시와 저장으로 진행한다. 앞의 생성자 null 실패와 호출 경로의 실패는 아래 증거에서 별도로 구분한다. 6노드와 5간선이다. 알림 연습의 외부 호출이나 트랜잭션 원자성을 이 코드에 추가한 그림이 아니다. |
| 41 | 중복 판정은 후속 협력자 호출을 차단한다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 필수 역할 뒤 중복 검사 생성자로 두 역할을 받은 서비스가 가입 중복을 확인합니다. | 전제: 필수 두 역할 · 중복: 직접 예외 | 호출 중복 실패를 생성 실패와 구분합니다. |
| 정상 해시 실행 중복이 아닌 입력만 hash를 호출합니다. | 중복 시 hashCalls: 0 · 정상: 해시 | 검사를 통과한 값만 해시 경계로 갑니다. |
| 정상 저장 해시를 저장에 전달합니다. | 중복 시 saveCalls: 0 · 정상: save | 실패 뒤 저장 상태를 바꾸지 않습니다. |
필수 역할과 중복 실패 순서는 생성과 호출 경계에서 고정한다
중복 판정 true에서 즉시 끝나 hash/save 0, 정상에서 해시·저장으로 가는 경계가 관찰된다.
CONSTRUCTION · FAST-FAIL EVIDENCE
필수 역할과 중복 실패 순서는 생성과 호출 경계에서 잠근다
완성되지 않은 객체는 생성 시 거절하고 중복 뒤 작업은 호출 횟수로 막습니다. 생성자 주입은 구현 선택을 밖으로 넘기는 연결점이자 필수 협력자를 고정하는 경계입니다.
CONSTRUCTOR BOUNDARY
두 역할은 생성 직후부터 non-null이다
-
members검사Objects.requireNonNull이 저장 역할 누락을 즉시 거절합니다. -
passwordHasher검사확장된
PasswordHasher역할 누락도 같은 생성 경계에서 거절합니다. -
완성된 서비스 전달
부분 초기화나 setter 호출 순서 없이 두 역할 참조가 고정됩니다.
DUPLICATE PATH
중복 판정은 뒤 협력자 호출을 차단한다
-
명령 null 검사
요청 경계가 잘못된 명령을 먼저 거절합니다.
-
existsByEmail == true저장소가 중복을 보고하면 서비스가 즉시 예외를 선택합니다.
-
DuplicateEmailException해시와 회원 생성·저장 이전에 호출이 끝납니다.
| 관찰 경로 | 기대 결과 | 증명하지 않는 것 |
|---|---|---|
| members = null | 서비스 생성 실패 · 메시지 고정 | 구성 루트의 구현 선택 정확성 |
| hasher = null | 서비스 생성 실패 · 메시지 고정 | 해시 구현의 보안 강도 |
| 중복 이메일 | existsCalls == 1 · hashCalls == 0 · saveCalls == 0 |
동시 가입 원자성 · 외부 시스템 롤백 |
실행 가능한 테스트가 고정하는 경계 증거
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를 트랜잭션이나 동시성 경계로 만들지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 이 그림은 생성자에서 필수 역할 두 개를 이미 받은 뒤의 가입 호출 범위다. 중복 판정 true는 예외로 직접 끝나며 hashCalls와 saveCalls가 0이다. 정상 분기는 해시와 저장으로 진행한다. 앞의 생성자 null 실패와 호출 경로의 실패는 아래 증거에서 별도로 구분한다. 6노드와 5간선이다. 알림 연습의 외부 호출이나 트랜잭션 원자성을 이 코드에 추가한 그림이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 필수 역할과 중복 실패 순서는 생성과 호출 경계에서 고정한다 |
| 6 | 중복 판정에서 후속 협력자 호출을 차단하기 |
| 7 | 이 그림은 생성자에서 필수 역할 두 개를 이미 받은 뒤의 가입 호출 범위다. 중복 판정 true는 예외로 직접 끝나며 hashCalls와 saveCalls가 0이다. 정상 분기는 해시와 저장으로 진행한다. 앞의 생성자 null 실패와 호출 경로의 실패는 아래 증거에서 별도로 구분한다. 6노드와 5간선이다. 알림 연습의 외부 호출이나 트랜잭션 원자성을 이 코드에 추가한 그림이 아니다. |
| 41 | 중복 판정은 후속 협력자 호출을 차단한다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 필수 역할 뒤 중복 검사 생성자로 두 역할을 받은 서비스가 가입 중복을 확인합니다. | 전제: 필수 두 역할 · 중복: 직접 예외 | 호출 중복 실패를 생성 실패와 구분합니다. |
| 정상 해시 실행 중복이 아닌 입력만 hash를 호출합니다. | 중복 시 hashCalls: 0 · 정상: 해시 | 검사를 통과한 값만 해시 경계로 갑니다. |
| 정상 저장 해시를 저장에 전달합니다. | 중복 시 saveCalls: 0 · 정상: save | 실패 뒤 저장 상태를 바꾸지 않습니다. |
구현 선택은 구성 루트로 넘긴다
서비스에서 구체 구현 생성을 제거하면 구성 영역이 공유할 MemberRepository와 활성 PasswordHasher를 선택해 완성된 서비스를 전달해야 합니다.
이 문서에서는 구성 코드를 미리 구현하지 않습니다.
다음 문서의 순수 Java BoardConfig 객체 조립에서 현재 MemberRegistrationService와 확장된 해시 계약을 연결하고 main 소스의 PBKDF2 구현을 선택합니다.
연습 문제
회원가입 뒤 환영 알림을 보내되 저장 호출이 실패하면 알림 호출 횟수가 0인지 기록 대역으로 검증하세요.
WelcomeNotifier를 생성자로 받고 구현 선택을 구성 영역으로 넘기되, 이 호출 순서만으로 저장과 알림 사이의 트랜잭션 원자성이 생긴다고 주장하지 마세요.
기록 대역의 실패 조건을 고정하기
앞 연습에서는 중복 검사 결과를 false로, 해시 결과를 유효한 고정 문자열로 둡니다. 저장 대역은 saveCalls를 한 번 올린 뒤 실제 상태를 변경하거나 회원을 반환하기 전에 예외를 던지게 하세요. 알림 대역은 호출 횟수만 기록하며, 저장 성공 결과를 받은 뒤에만 호출하는 순서를 사용합니다.
저장 실패와 알림 실패를 구분한 풀이
이 조건의 저장 실패에서는 중복 검사 1회·해시 1회·저장 1회·알림 0회입니다. 저장이 정상 반환하지 않아 뒤 알림으로 진행하지 않습니다. 이 기록은 정한 호출 경로를 검증합니다. 모든 저장소가 예외를 던지기 전 상태를 바꾸지 않는다는 일반 보장은 아닙니다.
반대로 저장이 성공한 뒤 알림 대역이 예외를 던지는 별도 경우에는 저장 1회·알림 1회이고 이미 저장된 회원은 자동으로 롤백되지 않습니다. 두 경우를 다른 테스트로 두어, 호출 순서와 저장·외부 발송의 원자성을 구분합니다.