순수 Java BoardConfig 객체 조립
main 소스의 구성 루트가 저장소와 PBKDF2 해시 구현을 선택해 회원가입·로그인 서비스를 조립하고, 같은 저장 상태를 보는지 실제 기본 구성으로 검증합니다.
앞 글까지의 MemberRegistrationService와 LoginService는 이미 MemberRepository와 PasswordHasher 역할을 생성자로 받습니다. 이제 구체 구현을 고르고 객체를 연결하는 일을 순수 Java 구성 루트인 BoardConfig 한곳에 모읍니다. 이 클래스는 Spring의 @Configuration이 아니며, 빈 후보를 찾거나 스코프를 관리하지도 않습니다.
앞 글까지의 회원 파일과 넓어진 해시 역할은 그대로 두고, 프로젝트의 src/main/java/board에 아래의 구성 파일과 실행 진입점을 추가합니다. 같은 패키지에 1장의 BoardConfig나 BoardApplication이 있다면 함께 두지 말고 이번 순수 Java 버전으로 바꿉니다. 테스트 쪽에는 BoardConfigTest 파일 하나를 추가합니다.
main 소스에서 완성하는 구성 루트
BoardConfig는 운영 소스(src/main/java)에 있으므로 테스트 소스에 있는 TestPasswordHasher를 참조하지 않습니다. 메모리 저장소 하나와 PBKDF2 해시 어댑터 하나를 필드에 만들어 두고, 두 서비스의 생성자에 넘깁니다.
package board;
import board.member.LoginService;
import board.member.MemberRegistrationService;
import board.member.MemberRepository;
import board.member.MemoryMemberRepository;
import board.member.PasswordHasher;
import board.member.PasswordHashSettings;
import board.member.Pbkdf2PasswordHasher;
public final class BoardConfig {
private final MemberRepository members =
new MemoryMemberRepository();
private final PasswordHasher passwordHasher =
new Pbkdf2PasswordHasher(
PasswordHashSettings.productionDefaults());
public MemberRegistrationService memberRegistrationService() {
return new MemberRegistrationService(
members, passwordHasher);
}
public LoginService loginService() {
return new LoginService(members, passwordHasher);
}
}members 필드가 가리키는 메모리 저장소 하나를 두 서비스에 넘기므로, 한쪽에서 저장한 회원을 다른 쪽이 찾을 수 있습니다. 같은 passwordHasher도 두 생성자에 넘기지만, 이것은 이 예제의 선택일 뿐 모든 PasswordHasher 구현이 같은 객체를 함께 써야 한다는 규칙은 아닙니다. 해시 쪽에서 중요한 것은 같은 형식을 만들고 검증할 수 있는 구현이라는 점입니다. 또 두 메서드는 호출할 때마다 새 서비스 객체를 만듭니다. 이 예제에서 함께 쓰는 핵심 상태는 저장소이며, 서비스가 하나뿐이라는 뜻은 아닙니다.
PURE JAVA · COMPOSITION ROOT
BoardConfig는 main-source 구현을 선택해 두 서비스를 조립한다
구성 객체 하나가 구체 구현을 선택하고 두 사용 사례에 역할 참조를 전달합니다. 서비스는 업무 순서를 알고, 구성 루트는 생성과 연결을 압축해 보여 줍니다.
ONE CONFIG INSTANCE
main 소스의 두 필드를 먼저 만든다
-
members = R#1MemoryMemberRepository한 인스턴스가 순차 학습 실행의 회원 상태를 보관합니다. -
passwordHasher = H#1Pbkdf2PasswordHasher와 운영 기본 설정을 main 소스에서 선택합니다. -
두 생성자에 역할로 전달
구체 구현 생성은 서비스 안으로 돌아가지 않고
BoardConfig에 남습니다.
| 구성 필드 | 활성 main 구현 | 회원가입 서비스 | 로그인 서비스 |
|---|---|---|---|
members · R#1 |
MemoryMemberRepository |
같은 R#1 전달 | 같은 R#1 전달 |
passwordHasher · H#1 |
Pbkdf2PasswordHasher |
같은 활성 참조 전달 | 같은 활성 참조 전달 |
한 BoardConfig 인스턴스가 완성하는 객체 그래프
members · R#1
활성 main 구현: MemoryMemberRepository
회원가입 서비스: 같은 R#1 전달
로그인 서비스: 같은 R#1 전달
passwordHasher · H#1
활성 main 구현: Pbkdf2PasswordHasher
회원가입 서비스: 같은 활성 참조 전달
로그인 서비스: 같은 활성 참조 전달
SOURCE-SET BOUNDARY
test 전용 해시는 main 구성에 들어오지 않는다
TestPasswordHasher는 src/test/java에만 남고, 기본 실행 그래프는 main 소스의 PBKDF2 어댑터를 사용합니다.
SERVICE IDENTITY
공유 필드와 서비스 싱글톤은 다른 주장이다
두 팩터리 메서드는 호출마다 새 서비스 객체를 반환할 수 있습니다. 이 도식의 상태 공유 근거는 두 생성자에 전달되는 R#1입니다.
이 순수 Java 구성은 명시적으로 구현을 선택합니다. Spring 빈 검색·후보 선택·빈 스코프와, 컨테이너가 수명 주기를 관리하는 빈의 종료 콜백은 아직 등장하지 않습니다.
그림은 BoardConfig가 저장소 R#1과 해시 H#1을 한 번씩 만들고, 그 참조를 두 서비스의 생성자에 넘기는 객체 그래프입니다. 화살표는 생성과 생성자 주입을 나타내며 요청의 실행 순서가 아닙니다. 아래쪽 두 상자는 테스트 전용 해시가 운영 구성에 들어오지 않는다는 것과, 저장소를 함께 쓰는 것과 서비스가 하나뿐인 것은 서로 다른 이야기라는 점을 짚습니다.
실행 진입점
실행 코드는 같은 BoardConfig에서 두 서비스를 하나씩 얻고, 회원가입 결과와 로그인 결과를 비교합니다. BoardConfig 객체마다 저장소 필드가 따로 만들어지므로, 두 서비스를 서로 다른 BoardConfig에서 얻으면 로그인이 방금 가입한 회원을 찾지 못합니다. 그래서 아래 코드는 config 하나에서 두 서비스를 모두 얻고, 식별자가 다르면 예외를 던져 구성 오류를 바로 드러냅니다.
package board;
import board.member.Member;
import board.member.SignUpCommand;
public final class BoardApplication {
private BoardApplication() {}
public static void main(String[] args) {
var config = new BoardConfig();
var registration = config.memberRegistrationService();
var login = config.loginService();
Member registered = registration.register(new SignUpCommand(
"[email protected]", "안동민", "secret-1234"));
long loginId = login.authenticate(
"[email protected]", "secret-1234");
if (registered.id() != loginId) {
throw new IllegalStateException(
"composition returned different member ids");
}
System.out.println("memberId=" + loginId);
}
}IDE에서 board.BoardApplication의 main을 실행하면 새 BoardConfig의 저장소는 비어 있으므로, 가입이 식별자 1을 만들고 로그인이 같은 회원을 찾아 다음 한 줄을 출력합니다. 이 실행은 HTTP 서버를 띄우지 않습니다. LoginService를 만들 때 더미 해시를 한 번, 가입할 때 한 번, 로그인할 때 한 번, 모두 세 번 PBKDF2를 계산하므로 출력까지 잠시 걸릴 수 있습니다.
memberId=1BoardApplication은 저장소가 Map을 쓰는지, PBKDF2의 저장 형식이 어떤지, 솔트를 어떻게 만드는지 모릅니다. 구성 루트를 만들고 완성된 사용 사례를 호출할 뿐입니다.
만드는 일과 실행하는 일
이제 각 객체가 맡는 일과 그 객체가 바뀌는 이유를 표로 정리할 수 있습니다.
| 객체 | 이 글에서 맡는 일 | 바뀌는 이유 |
|---|---|---|
MemberRegistrationService | 중복 확인, 해시, 저장의 순서 | 회원가입 규칙이 바뀔 때 |
MemoryMemberRepository | 차례로 실행되는 학습용 메모리 저장 | 저장 기술이 바뀔 때 |
Pbkdf2PasswordHasher | 운영 실행의 해시와 비교 | 해시 정책이 바뀔 때 |
BoardConfig | 구체 구현 선택과 생성자 연결 | 실행 구성이 바뀔 때 |
이렇게 나누면 객체를 만드는 일과 업무를 실행하는 일이 구분됩니다. 다만 구성 루트가 생겼다고 해서 스레드 안전성이나 트랜잭션, 자원 정리가 저절로 생기는 것은 아닙니다.
실제 기본 구성의 행동 검증
구성 테스트는 테스트 전용 대역으로 바꾸지 않고, BoardConfig의 실제 기본 구성을 그대로 한 번 통과시킵니다. 가입 결과의 식별자와 로그인 결과의 식별자가 정확히 같아야 합니다.
package board;
import static org.assertj.core.api.Assertions.assertThat;
import board.member.Member;
import board.member.SignUpCommand;
import org.junit.jupiter.api.Test;
final class BoardConfigTest {
@Test
void 같은_구성에서_가입한_회원으로_로그인한다() {
var config = new BoardConfig();
Member registered = config.memberRegistrationService()
.register(new SignUpCommand(
"[email protected]",
"회원",
"secret-1234"));
long loginId = config.loginService().authenticate(
"[email protected]", "secret-1234");
assertThat(loginId).isEqualTo(registered.id());
}
}./gradlew test --tests board.BoardConfigTest이 테스트의 결과와 BoardConfig의 필드 연결을 함께 보면, 같은 BoardConfig 안에서 회원가입과 로그인이 같은 메모리 상태를 쓴다는 것을 확인할 수 있습니다. 반면 저장소가 같은 객체인지를 공개 API로 드러내 확인하지는 않으며, 운영 환경의 동시성이나 트랜잭션, 자원의 수명까지 확인하는 것도 아닙니다.
구성 테스트는 두 사용 사례가 같은 저장 상태를 보는지 검증한다
가입 저장 뒤 같은 R#1에서 로그인 조회하는 실제 네 역할의 시간 순서와 저장 상태가 있다.
COMPOSITION SMOKE · BEHAVIOR EVIDENCE
구성 테스트는 두 사용 사례가 같은 저장 상태를 보는지 검증한다
참조를 노출하지 않고 가입 결과와 로그인 결과의 동일한 ID를 관찰합니다. 한 구성 객체에서 시작한 두 경로가 현재 메모리 상태를 이어 보는지 행동으로 확인합니다.
REGISTER → AUTHENTICATE → ASSERT
한 구성 객체를 통과한 성공 경로
-
두 사용 사례 조립
같은
BoardConfig에서 회원가입 서비스와 로그인 서비스를 얻습니다. -
회원가입이
R#1에 저장H#1.hash로 만든 해시와 양수 회원 ID가 메모리 저장 상태에 남습니다. -
로그인이 같은 상태를 조회
R#1.findByEmail과 활성 해시의matches가 저장된 회원을 검증합니다. -
loginId == registered.id()로그인 결과가 방금 가입한 바로 그 회원 ID와 정확히 같아야 테스트가 통과합니다.
| 관찰 | 말할 수 있는 것 | 말할 수 없는 것 |
|---|---|---|
| ID 정확히 일치 | 현재 구성의 두 사용 사례가 같은 메모리 회원 상태를 봅니다. | 운영 영속화·분산 상태 공유 |
| 필드 연결 | 코드상 두 생성자에 같은 members 참조를 전달합니다. |
모든 저장소 구현의 수명 정책 |
| 순차 smoke test | 기본 PBKDF2 구성의 가입·로그인 협업이 한 번 완주합니다. | 스레드 안전성·트랜잭션·자원 종료 |
구성 테스트가 고정하는 증거와 남겨 두는 경계
ID 정확히 일치
말할 수 있는 것: 현재 구성의 두 사용 사례가 같은 메모리 회원 상태를 봅니다.
말할 수 없는 것: 운영 영속화·분산 상태 공유
필드 연결
말할 수 있는 것: 코드상 두 생성자에 같은 members 참조를 전달합니다.
말할 수 없는 것: 모든 저장소 구현의 수명 정책
순차 smoke test
말할 수 있는 것: 기본 PBKDF2 구성의 가입·로그인 협업이 한 번 완주합니다.
말할 수 없는 것: 스레드 안전성·트랜잭션·자원 종료
IDENTITY SCOPE
저장소 동일성은 현재 메모리 상태에 필요하다
해시 역할은 호환되는 활성 구성이 핵심이며 모든 구현에서 객체 동일성까지 요구하는 포트 계약은 아닙니다.
이 증거는 같은 구성 객체의 순차 학습 실행에 한정됩니다. Spring 빈 후보 선택과 스코프는 이후 컨테이너 전환 문서의 책임입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 시간은 위에서 아래로 진행한다. 실행점이 가입과 로그인을 순서대로 호출하고 각 서비스가 같은 R#1에 저장·조회한다. 점선 반환 화살표는 호출 결과이며 별도 실행 분기가 아니다. 4역할과 7메시지를 양쪽에서 유지한다. 해시 협력자의 내부 호출은 이 상태 공유 시간선에서 생략하고 본문의 구성 그래프와 테스트에서 확인한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 시간은 위에서 아래로 진행한다. 실행점이 가입과 로그인을 순서대로 호출하고 각 서비스가 같은 R#1에 저장·조회한다. 점선 반환 화살표는 호출 결과이며 별도 실행 분기가 아니다. 4역할과 7메시지를 양쪽에서 유지한다. 해시 협력자의 내부 호출은 이 상태 공유 시간선에서 생략하고 본문의 구성 그래프와 테스트에서 확인한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 같은 저장소 구성 실행점이 가입·로그인에 같은 R#1을 연결합니다. | 저장소: R#1 · 경로: 가입·로그인 | 두 사용 사례가 공유할 상태가 정해집니다. |
| 가입 저장 가입을 호출하여 R#1에 회원을 저장합니다. | 쓰기: R#1 · 산출물: 회원 | 로그인이 볼 저장 상태가 생깁니다. |
| 로그인 조회 이후 로그인도 같은 R#1을 읽습니다. | 읽기: R#1 · 결과: 저장 회원 관찰 | 순서와 공유 참조가 상태 연결의 근거입니다. |
구성 테스트는 두 사용 사례가 같은 저장 상태를 보는지 검증한다
가입 저장 뒤 같은 R#1에서 로그인 조회하는 실제 네 역할의 시간 순서와 저장 상태가 있다.
COMPOSITION SMOKE · BEHAVIOR EVIDENCE
구성 테스트는 두 사용 사례가 같은 저장 상태를 보는지 검증한다
참조를 노출하지 않고 가입 결과와 로그인 결과의 동일한 ID를 관찰합니다. 한 구성 객체에서 시작한 두 경로가 현재 메모리 상태를 이어 보는지 행동으로 확인합니다.
REGISTER → AUTHENTICATE → ASSERT
한 구성 객체를 통과한 성공 경로
-
두 사용 사례 조립
같은
BoardConfig에서 회원가입 서비스와 로그인 서비스를 얻습니다. -
회원가입이
R#1에 저장H#1.hash로 만든 해시와 양수 회원 ID가 메모리 저장 상태에 남습니다. -
로그인이 같은 상태를 조회
R#1.findByEmail과 활성 해시의matches가 저장된 회원을 검증합니다. -
loginId == registered.id()로그인 결과가 방금 가입한 바로 그 회원 ID와 정확히 같아야 테스트가 통과합니다.
| 관찰 | 말할 수 있는 것 | 말할 수 없는 것 |
|---|---|---|
| ID 정확히 일치 | 현재 구성의 두 사용 사례가 같은 메모리 회원 상태를 봅니다. | 운영 영속화·분산 상태 공유 |
| 필드 연결 | 코드상 두 생성자에 같은 members 참조를 전달합니다. |
모든 저장소 구현의 수명 정책 |
| 순차 smoke test | 기본 PBKDF2 구성의 가입·로그인 협업이 한 번 완주합니다. | 스레드 안전성·트랜잭션·자원 종료 |
구성 테스트가 고정하는 증거와 남겨 두는 경계
ID 정확히 일치
말할 수 있는 것: 현재 구성의 두 사용 사례가 같은 메모리 회원 상태를 봅니다.
말할 수 없는 것: 운영 영속화·분산 상태 공유
필드 연결
말할 수 있는 것: 코드상 두 생성자에 같은 members 참조를 전달합니다.
말할 수 없는 것: 모든 저장소 구현의 수명 정책
순차 smoke test
말할 수 있는 것: 기본 PBKDF2 구성의 가입·로그인 협업이 한 번 완주합니다.
말할 수 없는 것: 스레드 안전성·트랜잭션·자원 종료
IDENTITY SCOPE
저장소 동일성은 현재 메모리 상태에 필요하다
해시 역할은 호환되는 활성 구성이 핵심이며 모든 구현에서 객체 동일성까지 요구하는 포트 계약은 아닙니다.
이 증거는 같은 구성 객체의 순차 학습 실행에 한정됩니다. Spring 빈 후보 선택과 스코프는 이후 컨테이너 전환 문서의 책임입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 시간은 위에서 아래로 진행한다. 실행점이 가입과 로그인을 순서대로 호출하고 각 서비스가 같은 R#1에 저장·조회한다. 점선 반환 화살표는 호출 결과이며 별도 실행 분기가 아니다. 4역할과 7메시지를 양쪽에서 유지한다. 해시 협력자의 내부 호출은 이 상태 공유 시간선에서 생략하고 본문의 구성 그래프와 테스트에서 확인한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 시간은 위에서 아래로 진행한다. 실행점이 가입과 로그인을 순서대로 호출하고 각 서비스가 같은 R#1에 저장·조회한다. 점선 반환 화살표는 호출 결과이며 별도 실행 분기가 아니다. 4역할과 7메시지를 양쪽에서 유지한다. 해시 협력자의 내부 호출은 이 상태 공유 시간선에서 생략하고 본문의 구성 그래프와 테스트에서 확인한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 같은 저장소 구성 실행점이 가입·로그인에 같은 R#1을 연결합니다. | 저장소: R#1 · 경로: 가입·로그인 | 두 사용 사례가 공유할 상태가 정해집니다. |
| 가입 저장 가입을 호출하여 R#1에 회원을 저장합니다. | 쓰기: R#1 · 산출물: 회원 | 로그인이 볼 저장 상태가 생깁니다. |
| 로그인 조회 이후 로그인도 같은 R#1을 읽습니다. | 읽기: R#1 · 결과: 저장 회원 관찰 | 순서와 공유 참조가 상태 연결의 근거입니다. |
그림은 위에서 아래로 진행하는 호출 순서입니다. 실행점이 가입을 호출하면 가입 서비스가 같은 저장소 R#1에 회원을 저장하고, 이어서 로그인을 호출하면 로그인 서비스가 같은 R#1에서 그 회원을 찾아 같은 식별자를 돌려줍니다. 점선은 호출의 반환값입니다. 그림 아래의 표처럼 식별자가 정확히 같다는 결과로는 지금 구성의 두 사용 사례가 같은 메모리 상태를 본다고 말할 수 있지만, 운영 환경의 영속화나 여러 서버 사이의 상태 공유까지 말할 수는 없습니다.
정리
- 구성 루트는 구체 구현을 고르고 객체를 연결하는 일을 한곳에 모으며, 순수 Java 클래스로도 충분히 만들 수 있습니다.
- 운영 소스의 구성은 테스트 소스의 대역을 참조하지 않고 운영용 구현을 고릅니다.
- 두 서비스가 같은 저장소 객체를 받아야 한쪽에서 저장한 상태를 다른 쪽이 볼 수 있으며, 이것은 서비스가 하나뿐이라는 것과 다른 이야기입니다.
- 진입점은 구성 루트를 만들고 완성된 사용 사례를 호출할 뿐, 저장 방식이나 해시 형식을 알지 않습니다.
- 실제 기본 구성으로 가입과 로그인을 이어 보는 테스트는 객체가 올바르게 연결되었는지를 행동으로 확인합니다.
연습 문제
1. 서비스가 두 개면 저장소도 두 개일까
하나의 config에서 가입 서비스 A와 B, 로그인 서비스 C를 얻습니다. A가 [email protected]으로 처음 가입한 뒤, B가 같은 이메일로 가입을 시도하고, C가 처음 가입한 회원의 올바른 비밀번호로 로그인합니다. 저장소는 지금의 단일 스레드 메모리 구현이고 세 호출은 차례로 일어납니다. B와 C의 결과를 설명하세요.
해설 보기
memberRegistrationService()를 두 번 불렀으므로 A와 B는 서로 다른 서비스 객체이지만, 두 생성자에는 같은 members 필드가 들어갔습니다. 그래서 B는 A가 저장한 이메일을 같은 저장소에서 찾아 DuplicateEmailException으로 끝납니다. C도 같은 저장소에서 첫 회원을 찾아 식별자 1을 돌려줍니다. 서비스 객체의 개수와 저장소 객체의 개수는 만들어지는 위치가 다르므로 따로 세어야 합니다. 이때 PBKDF2 해시 문자열은 무작위 솔트 때문에 미리 정해 둔 값으로 기대할 수 없으므로, 저장된 해시로 올바른 원문이 검증되는지를 봅니다.
2. 새 config를 만들면
BoardConfigTest에서 가입은 new BoardConfig()로 만든 config 하나로, 로그인은 또 다른 new BoardConfig()로 만든 config로 하도록 바꾸면 테스트는 어떻게 될까요?
해설 보기
두 config는 각자 자신의 MemoryMemberRepository를 만듭니다. 로그인 쪽 config의 저장소는 비어 있으므로 LoginService는 회원을 찾지 못하고 더미 해시로 검증한 뒤 InvalidCredentialsException을 던져 테스트가 실패합니다. 같은 상태를 써야 하는 객체들은 같은 구성 루트에서 만들어져야 한다는 것을 보여 주는 실패이며, 다음 글에서 이런 실패를 자세히 다룹니다.
다음 글 순수 Java 객체 공유와 구현 교체에서는 서비스마다 저장소를 새로 만들 때 상태가 끊기는 실패를 통해, 무엇을 함께 써야 하는지와 구현을 바꾸는 방법을 다룹니다. Spring의 빈 후보 선택과 스코프는 이 장의 마지막 글인 Spring 컨테이너 전환에서 봅니다.