OCP·DIP와 생성자 주입의 경계
이미 생성자 주입된 회원가입 서비스에서 구체 구현 선택을 분리해야 하는 이유와 필수 역할의 null 방어, 중복 실패 뒤 호출 횟수를 실행 가능한 코드로 검증합니다.
앞 글까지의 MemberRegistrationService는 이미 MemberRepository와 PasswordHasher를 생성자로 받습니다. 그래서 이 글에서 생성자 주입을 새로 도입하지는 않습니다. 대신 지금의 구조가 구현 교체의 영향을 어디서 받아 내는지, 그리고 객체를 만들 때와 호출할 때 무엇을 확인할 수 있게 해 주는지 살펴봅니다. 앞 글에서 hash와 matches로 넓힌 PasswordHasher도 그대로 씁니다.
구체 구현 선택을 서비스로 되돌린 반례
다음 파일은 의존 방향을 비교하려고 만든 반례이며, 운영 소스에 추가하지 않습니다. 회원가입 순서는 같지만, 서비스가 저장소와 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(...)가 있으면 서비스는 그 구현 클래스와 PasswordHashSettings를 만드는 방법까지 알아야 합니다. 구현을 고르는 일과 정책 값을 정하는 일이 회원가입이라는 사용 사례에 다시 묶이는 것입니다.
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이 들어오면 거부하는 서비스의 전체 파일입니다. 읽을 때는 서비스 안에 협력 객체를 만드는 new가 없고, import도 java.util.Objects 하나뿐이라는 점을 봅니다. 저장 기술이나 해시 구현의 클래스를 가져오지 않으므로, 이 파일은 어떤 어댑터가 쓰일지 모르는 채로 컴파일됩니다.
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));
}
}객체가 만들어지는 순간부터 두 역할이 모두 있으므로, 세터를 어떤 순서로 호출해야 하는지나 일부만 초기화된 상태를 따로 신경 쓸 필요가 없습니다. 이때 생성자가 구체 클래스가 아니라 역할 타입을 받는다는 점도 함께 봐야 합니다.
생성 실패와 중복 빠른 실패를 호출 횟수로 검증
다음 테스트는 생성자의 null 거부와, 중복 이메일일 때 existsCalls == 1, hashCalls == 0, saveCalls == 0이 되는지를 한 파일에서 확인합니다. 테스트 대역은 넓어진 PasswordHasher의 두 메서드를 모두 구현합니다.
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;
}
}
}./gradlew test --tests board.member.MemberRegistrationServiceInteractionTestMockito 같은 라이브러리 대신 호출 횟수만 세는 작은 클래스를 직접 만들었습니다. 이 장은 순수 Java로 진행하므로, 테스트 대역도 평범한 Java 클래스로 충분하다는 것을 보여 주려는 것입니다. 두 테스트가 통과하면, 중복을 발견한 한 번의 호출에서 뒤의 협력 객체를 실행하지 않는다는 것이 확인됩니다. 그렇다고 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 | 실패 뒤 저장 상태를 바꾸지 않습니다. |
그림은 생성자에서 두 필수 역할을 이미 받은 뒤의 가입 호출을 보여 줍니다. 중복 판정이 true이면 예외로 바로 끝나 hashCalls와 saveCalls가 0이고, 중복이 아닐 때만 해시와 저장으로 진행합니다. 아래쪽 표는 각 테스트가 확인하는 것과 확인하지 않는 것을 나눕니다. null 거부 테스트는 구성 영역이 올바른 구현을 골랐는지나 해시 구현이 얼마나 안전한지를 확인하지 않고, 중복 테스트는 동시 가입의 원자성을 확인하지 않습니다.
구현 선택은 구성 루트로 넘긴다
서비스에서 구체 구현을 만드는 코드를 없애면, 어딘가에서는 함께 쓸 MemberRepository와 지금 쓸 PasswordHasher를 골라 완성된 서비스를 만들어 넘겨야 합니다. 이렇게 애플리케이션의 객체들을 한곳에서 만들고 연결하는 곳을 구성 루트(composition root)라고 합니다. 이 글에서는 구성 코드를 미리 만들지 않고, 다음 글 순수 Java BoardConfig 객체 조립에서 지금의 MemberRegistrationService와 넓어진 해시 역할을 연결하며 운영 소스의 PBKDF2 구현을 고릅니다.
정리
- 필드 타입이 역할이어도 서비스 안에서 구체 구현을
new로 만들면, 서비스는 그 구현과 설정에 의존하게 됩니다. - OCP는 역할이 그대로인 동안 구현만 바꿀 때 사용 사례의 소스를 고치지 않는다는 범위의 주장이며, 역할 자체를 넓히는 변경과 구분합니다.
- DIP는 고수준 소스가 역할만 알고 구체 구현의 선택을 구성 영역이 맡을 때 성립하며, 생성자 문법만으로 지켜지지 않습니다.
- 생성자에서 필수 역할의
null을 거부하면 일부만 초기화된 객체가 만들어지지 않습니다. - 호출 횟수를 세는 테스트 대역으로, 중복을 발견한 뒤 해시와 저장을 호출하지 않는다는 것을 확인할 수 있습니다.
연습 문제
1. 저장이 실패하면 알림도 없다
WelcomeNotifier를 생성자로 받아 회원가입 뒤에 환영 알림을 보내도록 서비스를 고친 다음, 저장이 실패하면 알림이 한 번도 호출되지 않는지 기록용 대역으로 확인하세요. 중복 검사 대역은 false를, 해시 대역은 유효한 고정 문자열을 돌려줍니다. 저장 대역은 saveCalls를 하나 올린 뒤 상태를 바꾸거나 회원을 돌려주기 전에 예외를 던지고, 알림 대역은 호출 횟수만 기록합니다. 알림은 저장이 성공한 결과를 받은 뒤에만 호출합니다. 이 호출 순서만으로 저장과 알림 사이에 트랜잭션의 원자성이 생긴다고 주장해서는 안 됩니다.
해설 보기
이 조건에서 저장이 실패하면 중복 검사 1회, 해시 1회, 저장 1회, 알림 0회가 기록됩니다. 저장이 정상적으로 값을 돌려주지 않았으므로 뒤의 알림 호출에 도달하지 않습니다. 다만 이 기록은 우리가 정한 호출 경로를 확인할 뿐, 모든 저장소가 예외를 던지기 전에 상태를 바꾸지 않는다는 일반적인 보장은 아닙니다.
반대로 저장은 성공하고 알림 대역이 예외를 던지는 경우는 저장 1회, 알림 1회이며, 이미 저장된 회원은 자동으로 되돌려지지 않습니다. 두 경우를 서로 다른 테스트로 두면, 호출 순서를 확인하는 것과 저장과 외부 발송을 하나로 묶는 것이 다른 문제라는 점이 분명해집니다.
다음 글 순수 Java BoardConfig 객체 조립에서는 구성 루트를 만들어 구현을 고르고, 완성된 서비스를 호출하는 쪽에 넘깁니다.