본문으로 건너뛰기

안동민 개발노트

본문 시작

순수 Java 객체 공유와 구현 교체

저장소를 호출마다 다시 만들 때 가입과 로그인 상태가 갈라지는 실패를 재현하고, 한 BoardConfig 인스턴스의 공유 경계와 구현 교체가 실제로 증명하는 범위를 구분합니다.

앞 문서의 BoardConfig는 저장소와 해시 어댑터를 필드에서 한 번 만들고 두 서비스에 전달했습니다.

같은 구성 객체를 사용한다는 사실만으로 메서드 반환값이 자동으로 공유되지는 않습니다.

공유 여부는 객체를 어디서 몇 번 만들고 어떤 참조를 전달했는지로 결정됩니다.


호출마다 새 저장소를 만드는 실패

다음 테스트 전용 구성은 저장소 역할을 반환할 때마다 new MemoryMemberRepository()를 실행합니다.

회원가입 서비스는 R#1에 저장하지만 로그인 서비스는 새 R#2를 조회합니다.

board-core/src/test/java/board/PerCallRepositoryConfigTest.java
package board;

import board.member.InvalidCredentialsException;
import board.member.LoginService;
import board.member.MemberRegistrationService;
import board.member.MemberRepository;
import board.member.MemoryMemberRepository;
import board.member.PasswordHasher;
import board.member.SignUpCommand;
import board.member.TestPasswordHasher;
import org.junit.jupiter.api.Test;

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

final class PerCallRepositoryConfigTest {
    @Test
    void 저장소를_호출마다_만들면_가입한_회원을_로그인에서_찾지_못한다() {
        var config = new PerCallRepositoryConfig();

        var registered = config.memberRegistrationService()
                .register(new SignUpCommand(
                        "member@example.com",
                        "회원",
                        "secret-1234"));

        assertThat(registered.id()).isPositive();
        assertThatThrownBy(() -> config.loginService().authenticate(
                "member@example.com", "secret-1234"))
                .isInstanceOf(InvalidCredentialsException.class)
                .hasMessage("invalid credentials");
    }

    private static final class PerCallRepositoryConfig {
        private final PasswordHasher passwordHasher =
                new TestPasswordHasher();

        MemberRegistrationService memberRegistrationService() {
            return new MemberRegistrationService(
                    memberRepository(), passwordHasher);
        }

        LoginService loginService() {
            return new LoginService(
                    memberRepository(), passwordHasher);
        }

        private MemberRepository memberRepository() {
            return new MemoryMemberRepository();
        }
    }
}

TestPasswordHasher는 두 경로의 해시 동작을 같게 고정하는 test-source 대역입니다.

이 실패의 원인은 해시 객체가 아니라 저장 상태가 R#1과 R#2로 분리된 데 있습니다.

테스트 전용 구성 객체의 두 서비스 조립 메서드가 MemoryMemberRepository를 각각 새로 만들어 회원가입은 R#1에 저장하고 로그인은 빈 R#2를 조회하므로, 같은 저장소 타입과 같은 config만으로는 상태 공유가 성립하지 않는 실패를 설명합니다.

PER-CALL CREATION · SPLIT STATE

저장소를 호출마다 만들면 가입과 로그인이 다른 상태를 본다

같은 클래스와 같은 구성 객체는 같은 인스턴스를 뜻하지 않습니다. 두 서비스 조립 호출이 각각 new MemoryMemberRepository()를 실행하면 저장과 조회가 서로 다른 Map에 도착합니다.

REGISTER ON R#1 → AUTHENTICATE ON R#2

호출 횟수가 저장소 identity를 둘로 나눈다

  1. 테스트 config G#1 생성

    해시 대역 H#1은 필드에서 한 번 만들지만 저장소는 아직 만들지 않습니다.

  2. 회원가입 서비스 조립

    memberRepository() 첫 호출이 R#1을 만들고 회원을 그 Map에 저장합니다.

  3. 로그인 서비스 조립

    memberRepository() 두 번째 호출이 비어 있는 R#2를 만듭니다.

  4. 동일한 이메일 조회 실패

    로그인은 R#2에서 회원을 찾지 못해 고정된 invalid credentials로 끝납니다.

REPOSITORY R#1

가입 경로

회원가입 서비스가 저장한 양수 ID 회원은 R#1의 Map에만 존재합니다.

REPOSITORY R#2

로그인 경로

로그인 서비스가 받은 R#2는 같은 클래스의 새 객체이며 저장 상태가 비어 있습니다.

두 서비스 조립 호출이 받은 협력자와 관찰 결과
조립 호출 저장소 참조 해시 참조 관찰
memberRegistrationService() R#1 · 새 Map H#1 · test 대역 가입 성공 · R#1에 저장
loginService() R#2 · 새 Map H#1 · 같은 제어 변수 회원 없음 · 동일 자격 증명 실패

CAUSE, NOT COINCIDENCE

같은 해시 대역을 써도 분리된 상태는 이어지지 않는다

H#1 공유는 실패 원인을 저장소 생성 횟수로 고정하기 위한 테스트 조건입니다. 모든 PasswordHasher가 같은 객체 identity를 공유해야 한다는 계약은 아닙니다.

순수 Java 메서드는 반환 객체를 자동 캐시하지 않습니다. 공유가 필요하면 한 번 만든 참조를 필드나 명시적인 상위 조립 경계에서 두 서비스 생성자에 전달해야 합니다.


한 구성 객체 안의 공유 경계

앞 문서의 실제 BoardConfigmemberspasswordHasher를 인스턴스 필드에서 초기화합니다.

같은 BoardConfig에서 구성 메서드를 여러 번 호출하면 서비스 객체는 새로 생길 수 있지만 각 생성자에는 같은 필드 참조가 전달됩니다.

대상생성 경계이 예제에서 확인하는 범위
BoardConfignew BoardConfig()마다 새 그래프서로 다른 config의 메모리 상태는 분리
MemoryMemberRepositoryconfig 인스턴스의 필드 초기화 때 한 번같은 config가 만든 서비스들이 같은 상태를 조회
Pbkdf2PasswordHasherconfig 인스턴스의 필드 초기화 때 한 번현재 main 그래프가 선택한 해시 계약 구현
회원가입·로그인 서비스구성 메서드를 호출할 때마다 생성 가능새 서비스도 같은 협력자 참조를 받을 수 있음
SignUpCommand와 메서드 입력사용 사례 호출마다 생성호출별 값이며 공유 상태가 아님

구성 객체가 더 이상 직접 참조되지 않아도 서비스가 저장소와 해시 어댑터를 참조하면 그 객체들은 계속 도달 가능합니다.

따라서 순수 Java 구성은 생성 횟수와 참조 공유 경계를 드러내지만 가비지 컬렉션 시점이나 자원 종료를 자동으로 관리하지 않습니다.


구현 교체가 증명하는 것

main 그래프는 Pbkdf2PasswordHasher를 선택하고 단위 테스트는 필요한 서비스에 TestPasswordHasher를 직접 전달할 수 있습니다.

두 서비스가 PasswordHasher 역할에만 의존하므로 계약이 안정된 동안 구현 선택은 구성 경계에 남습니다.

확인 대상근거여기서 증명하지 않는 것
같은 config의 상태 공유앞 문서의 가입 후 로그인 양성 테스트JVM 전역 singleton
호출별 저장소 재생성 실패이 문서의 R#1/R#2 음성 테스트공유 저장소의 thread safety
해시 구현의 호출 계약ch2-3의 두 구현 공통 계약 테스트같은 해시 객체의 참조 identity 필요성
구성의 구현 선택서비스 소스는 역할만 참조기존 저장 해시의 자동 호환·migration
메모리 그래프의 도달 가능성config와 서비스가 협력자 참조 보유transaction·persistence·resource shutdown

같은 PasswordHasher 객체를 전달하는 것은 현재 그래프의 선택이지 모든 구현이 따라야 하는 일반 계약이 아닙니다.

반대로 서로 다른 구현 객체가 같은 포트를 구현해도 저장 인코딩을 서로 해석할 수 있다는 보장은 없습니다.

{test} 형식과 $pbkdf2-sha256$v1$ 형식 사이의 교체는 서비스 소스 변경 범위와 기존 데이터 호환성을 분리해서 판단해야 합니다.

이 메모리 예제는 새 BoardConfig를 만들면 저장 상태도 새로 시작하므로 운영 중 hot swap이나 기존 해시 migration을 증명하지 않으며 호환 fallback도 추가하지 않습니다.

한 BoardConfig 인스턴스가 R#1과 H#1을 필드에서 한 번 만들고 새 회원가입·로그인 서비스에 같은 참조를 전달하는 그래프 경계와, PasswordHasher 구현 선택을 바꿀 때 서비스 소스 변경 범위·저장 데이터 호환성·운영 수명 보장을 별도로 검증해야 함을 설명합니다.

GRAPH BOUNDARY · REPLACEMENT EVIDENCE

공유 범위와 구현 교체는 서로 다른 증거를 요구한다

한 config의 필드 공유는 상태 연결을, 역할 의존은 소스 변경 범위를 설명합니다. 둘 다 JVM 전역 수명이나 기존 저장 형식의 자동 호환을 뜻하지 않습니다.

BOARD CONFIG G#1

필드는 한 번 만들고 서비스 생성자는 같은 참조를 받는다

  1. new BoardConfig()

    config 인스턴스 G#1의 필드 초기화가 저장소 R#1과 PBKDF2 어댑터 H#1을 각각 한 번 실행합니다.

  2. 회원가입 서비스 생성

    새 서비스 S#1이 R#1과 H#1 참조를 받아 중복 확인·해시·저장을 수행합니다.

  3. 로그인 서비스 생성

    새 서비스 S#2가 같은 R#1과 H#1 참조를 받아 회원 조회와 해시 검증을 수행합니다.

  4. 추가 구성 메서드 호출

    S#3처럼 서비스는 더 생길 수 있지만 G#1의 필드 참조는 그대로 전달됩니다.

SAME CONFIG G#1

같은 상태 경계

G#1이 만든 서비스들은 R#1을 통해 가입 뒤 로그인의 같은 회원 상태를 관찰합니다.

NEW CONFIG G#2

별도 객체 그래프

new BoardConfig()를 다시 실행하면 R#2와 H#2를 가진 독립 그래프가 시작됩니다.

공유와 구현 교체를 판단하는 서로 다른 증거
질문 근거 확인되는 범위 확인되지 않는 범위
상태가 이어지는가 같은 G#1에서 가입 후 로그인 두 서비스가 같은 R#1 참조 사용 JVM 전역 singleton
재생성이 끊는가 R#1 저장 후 R#2 조회 실패 메서드별 new가 상태 분리 공유 저장소의 thread safety
구현을 바꿀 수 있는가 서비스는 PasswordHasher만 참조 안정된 계약에서 서비스 소스 유지 기존 해시 형식의 자동 호환
수명이 관리되는가 config와 서비스가 협력자 참조 보유 참조가 있는 동안 객체 도달 가능 GC 시점·transaction·resource shutdown

SOURCE CHANGE ≠ STORED-DATA COMPATIBILITY

객체 identity보다 포트와 저장 형식의 호환을 검증한다

main의 PBKDF2 어댑터와 test-source 대역은 같은 호출 포트를 구현하지만 서로 다른 인코딩을 만듭니다. 구성 선택을 바꿔도 서비스 소스가 유지된다는 사실은 기존 {test} 값이 $pbkdf2-sha256$v1$ 어댑터에서 검증되거나 migration 없이 hot swap된다는 보장이 아닙니다.

순수 Java 구성의 보장과 명시적 한계
구분 이 예제의 결론 별도 경계
구성 G#1 안의 R#1·H#1 선택과 생성자 연결을 읽을 수 있음 다른 진입점이 별도 config를 만들지 검증
원칙 안정된 역할에서 구현 교체라는 특정 변경 축의 OCP·DIP 증거 생성자 주입만으로 모든 원칙을 증명하지 않음
운영 순차 메모리 예제의 상태 연속성 동시성·원자성·영속성·종료·migration

이 장의 공유 범위는 config 인스턴스별 객체 그래프입니다. 컨테이너가 관리하는 빈 후보와 스코프는 다음 문서 이후의 별도 규칙입니다.


순수 Java 구성의 한계

하나의 객체 그래프를 올바르게 조립했다는 사실만으로 동시 접근, 원자적 중복 방지, transaction, 영속성, 외부 자원 종료가 해결되지는 않습니다.

다른 코드가 별도의 저장소나 구성 객체를 만들면 다시 다른 그래프가 생길 수 있으므로 실제 진입점이 어떤 config를 사용하는지도 함께 검증해야 합니다.

다음 문서에서 같은 역할과 구현을 Spring 컨테이너로 옮기지만, 빈 후보 선택과 스코프의 의미는 이 순수 Java 구성에 미리 적용하지 않습니다.


연습 문제

같은 config에서 회원가입 뒤 로그인이 성공하고, 서로 다른 두 config 사이에서는 회원 상태가 보이지 않는 테스트를 작성하세요.

결과를 “전역 singleton”이 아니라 “config 인스턴스별 객체 그래프”라는 말로 설명합니다.