본문으로 건너뛰기

안동민 개발노트

본문 시작

역할과 구현 분리

회원가입을 순수 Java 객체로 나누며 역할·구현·의존성의 뜻과 Spring이 객체 연결을 돕는 이유를 처음부터 익힙니다.

객체는 데이터와 동작을 함께 가진 Java 인스턴스입니다.

한 객체가 일을 끝내기 위해 다른 객체를 사용하면 두 객체 사이에 의존성이 있다고 말합니다.

예를 들어 회원가입 서비스는 회원을 저장해야 하므로 회원 저장소에 의존합니다.

역할은 “무엇을 할 수 있는가”를 나타내고 구현은 “그 일을 어떻게 하는가”를 나타냅니다.

Java에서는 보통 인터페이스로 역할을, 클래스로 구현을 표현합니다.

이 장은 역할과 객체 연결을 설명하기 위한 순수 Java 학습 트랙입니다.

앞 장 애플리케이션의 모든 파일이 이 예제에 이미 존재하거나 그대로 함께 빌드된다고 주장하지 않습니다.


회원가입 요구사항

게시판의 회원가입은 다음 순서로 진행됩니다.

  1. 이메일이 이미 사용 중인지 확인한다.
  2. 원문 비밀번호를 해시한다.
  3. 이메일·이름·비밀번호 해시를 가진 회원을 저장한다.

이 순서에서 바뀌는 이유는 서로 다릅니다.

저장 방식은 메모리에서 데이터베이스로 바뀔 수 있고, 해시 알고리즘은 보안 정책에 따라 바뀔 수 있습니다.

서비스 안에서 두 구현을 직접 만들면 구현을 바꿀 때마다 서비스도 수정해야 합니다.

MemberRepository.java
public interface MemberRepository {
    boolean existsByEmail(String email);
    Member save(Member member);
}
PasswordHasher.java
public interface PasswordHasher {
    String hash(String rawPassword);
}

MemberRepository는 중복 이메일 확인과 저장 계약만 말하고 Map이나 SQL을 드러내지 않습니다.

PasswordHasher도 해시 역할만 말하고 구체 알고리즘을 드러내지 않습니다.

호출하는 쪽은 이 계약만 알면 됩니다.

이 장의 학습 그래프에서는 메모리 저장소를 현재 선택 예시로 사용합니다.

JDBC 저장소와 PBKDF2 해시는 나중에 구성에서 고를 수 있는 운영 구현 후보입니다.

TestPasswordHasher는 다음 문서에서 다룰 테스트 전용 개념 후보일 뿐이며, 이 문서의 hash-only 포트에 맞춘 구현은 여기서 보여 주지 않습니다. 결정적이고 의도적으로 안전하지 않으므로 실제 비밀번호에는 절대 사용하지 않습니다.


구현을 직접 고르는 문제

다음 코드는 복사해 실행할 완성 예제가 아니라 직접 생성과 주입을 비교하기 위한 축약 스케치입니다. 앞 장의 command·validation 경계를 의도적으로 생략하고, 다음 문서에서 다룰 TestPasswordHasher 개념 후보의 이름만 미리 사용합니다.

코드의 new 자체가 문제는 아닙니다. 수명이 짧고 선택이 고정된 값 객체나 내부 도우미를 지역에서 만드는 일은 자연스럽습니다.

여기서는 저장 기술과 해시 정책처럼 바뀔 가능성이 큰 선택과 협력자 수명까지 서비스 안으로 새어 들어오는 점이 문제입니다.

MemberRegistrationService.java - 개선 전
public final class MemberRegistrationService {
    private final MemberRepository members =
            new MemoryMemberRepository();
    private final PasswordHasher passwordHasher =
            new TestPasswordHasher();

    public Member register(
            String email,
            String name,
            String rawPassword
    ) {
        if (members.existsByEmail(email)) {
            throw new DuplicateEmailException(email);
        }
        String passwordHash = passwordHasher.hash(rawPassword);
        return members.save(Member.signUp(email, name, passwordHash));
    }
}

register의 업무 순서는 저장 구현이나 해시 알고리즘과 무관합니다.

그런데 new MemoryMemberRepository()new TestPasswordHasher() 때문에 변동 구현 선택과 수명 관리가 서비스 수정으로 번집니다.


생성자로 협력자 받기

서비스가 구현을 만들지 않고 생성자로 받으면 역할과 구현 선택을 분리할 수 있습니다.

MemberRegistrationService.java - 생성자 주입
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");
    }

    // register의 업무 코드는 앞과 같다.
}

필요한 객체를 생성자 밖에서 전달하는 방식을 생성자 주입이라고 합니다.

이는 Spring 전용 문법이 아니라 평범한 Java 언어 사용법입니다.

main 메서드나 테스트 준비 코드에서 구현을 직접 만들고 생성자에 전달하는 수동 구성도 완전히 유효합니다.

Spring은 애플리케이션이 커졌을 때 bean 정의와 선택한 scope에 따라 등록된 bean을 생성·설정·연결합니다. 생명주기 callback의 범위도 scope를 따르며, prototype bean은 초기화 뒤 넘겨진 다음 파괴 callback이 자동 호출되지 않습니다.

연결이 끝난 뒤 요청이 실행될 때는 서비스가 저장소와 해시 객체를 호출합니다. 일반적인 의존성 주입 자체가 두 객체 사이의 모든 호출을 매번 중개하지는 않지만, Spring AOP·트랜잭션·보안 proxy를 별도로 구성하면 proxy를 통해 들어온 호출에 advice가 적용될 수 있습니다.

또한 서비스·저장소처럼 Spring이 관리하도록 등록한 객체의 생명주기를 관리하는 것이지, register 도중 만들어지는 Member 같은 모든 Java 객체를 자동으로 bean으로 관리하는 것은 아닙니다.

MemberRegistrationService가 MemberRepository와 PasswordHasher 역할에 소스 의존하고, 외부 구성이 현재 학습용 MemoryMemberRepository, 미래 JDBC·PBKDF2, 테스트 전용 개념 후보를 구분해 선택하는 구조와 생성자 주입만으로 DIP·OCP·SRP가 증명되지는 않는 판단 기준을 설명합니다.

DEPENDENCY DIRECTION · IMPLEMENTATION CHOICE

역할에 의존하고 구현 선택은 밖으로 옮긴다

서비스 소스는 역할을 알고, 구성은 구현을 고릅니다. 주입 방식보다 변동 선택과 수명 관리가 어느 책임에 놓이는지가 핵심입니다.

SOURCE DEPENDENCY → COMPOSITION → CALL

일반 DI에서 구현 선택과 실행 호출을 나눈다

  1. MemberRegistrationService 소스

    생성자와 필드는 MemberRepository·PasswordHasher 역할만 선언합니다.

  2. 외부 구성의 구현 선택·주입

    구현 클래스는 역할을 implements하고, 수동 구성이나 Spring 구성이 그 객체를 골라 생성자에 전달합니다.

  3. 요청 시점의 call

    서비스가 주입받은 객체의 existsByEmail·save·hash를 호출합니다. 일반 DI 자체는 호출을 매번 중개하지 않지만, 별도 Spring AOP·트랜잭션·보안 proxy는 proxy를 거친 호출을 가로챌 수 있습니다.

역할별 현재 학습 선택과 미래 운영 후보
역할 현재 학습 선택 미래 운영 후보 서비스에 남는 계약
회원 저장 MemoryMemberRepository JdbcMemberRepository MemberRepository
비밀번호 해시 TestPasswordHasher 개념 후보 Pbkdf2PasswordHasher PasswordHasher

TEST DOUBLE BOUNDARY

실제 비밀번호 사용 금지

TestPasswordHasher는 다음 문서에서 다룰 테스트 전용 개념 후보입니다.

이 hash-only 포트에 맞춘 구현은 여기서 제시하지 않으며, 결정적이고 안전하지 않으므로 실제 비밀번호에 사용하면 안 됩니다.

생성자 주입 이후에도 별도로 확인해야 하는 설계 원칙
원칙 확인할 증거 주입만으로 증명되지 않는 이유
DIP 서비스 소스가 구체 클래스가 아닌 역할에 의존 생성자를 써도 매개변수 타입이 구체 클래스면 방향은 그대로임
OCP 새 구현 추가 시 안정된 register는 유지되고 구성만 변경 새 요구가 업무 순서 자체를 바꾸면 서비스 변경이 필요할 수 있음
SRP 서비스가 회원가입 순서라는 한 변경 이유에 응집 여러 업무를 한 클래스에 모아도 생성자 주입은 사용할 수 있음

new는 안정된 지역 객체를 만드는 데 자연스럽습니다. 저장 기술·보안 정책처럼 변동하는 선택이나 공유 수명 관리가 사용 사례 안으로 들어올 때 구성 책임을 밖으로 옮깁니다.


변경 방향 읽기

고수준 객체인 회원가입 서비스는 “회원 저장”과 “비밀번호 해시”라는 업무 역할을 사용합니다.

Map, JDBC, PBKDF2 같은 저수준 기술이 서비스의 생성 코드에 들어오지 않게 합니다.

생성자 주입만 했다고 다음 원칙이 자동으로 증명되지는 않습니다.

  • DIP: 서비스 소스가 MemoryMemberRepository 같은 구체 클래스가 아니라 업무 역할에 의존하는지 확인한다.
  • OCP: 새 구현을 추가할 때 안정된 register 로직은 유지되고 구성의 선택만 바뀌는지 확인한다.
  • SRP: 서비스가 생성자 주입을 쓰는지보다 회원가입 순서라는 하나의 변경 이유에 응집되어 있는지 확인한다.

주입은 이 판단을 돕는 구조일 뿐이며, 역할이 지나치게 크거나 서비스가 여러 업무를 함께 맡으면 원칙은 여전히 깨질 수 있습니다.


객체 그래프

객체와 객체의 연결 모양을 객체 그래프라고 합니다.

[타입/소스 선언]
구현 저장소 클래스 ──implements──> MemberRepository
구현 해시 클래스 ──implements──> PasswordHasher

[구성 시점: 구현 선택과 주입]
MemberRegistrationService ←──inject── 두 역할 객체

[요청 시점: 메서드 호출]
MemberRegistrationService ──call──> MemberRepository
MemberRegistrationService ──call──> PasswordHasher

implements는 타입·소스 선언을, inject는 구성 시점의 구현 선택과 연결을, call은 요청 시점의 실행 흐름을 나타냅니다.

수동 구성이나 Spring 구성에서 구현을 JDBC 저장소와 PBKDF2 해시로 바꿔도 서비스가 보는 역할은 같습니다.

이 그림은 2장 학습용 후보 관계를 설명하며 앞 장 애플리케이션이 이미 같은 객체 그래프로 조립됐다고 주장하지 않습니다.

수동 구성 또는 Spring 구성이 구현 객체를 생성하고 역할에 바인딩해 서비스에 주입하는 객체 그래프와, 일반 DI 자체는 매 호출을 중개하지 않지만 별도로 구성한 Spring AOP·트랜잭션·보안 proxy는 proxy를 거친 호출을 가로챌 수 있음을 구분합니다. 등록 bean의 생명주기 callback은 scope를 따르며 prototype의 파괴 callback은 자동 호출되지 않습니다.

COMPOSITION TIME ≠ REQUEST TIME

객체 그래프는 구성과 사용의 책임을 분리한다

객체를 고르고 연결하는 시점과 업무 메서드를 호출하는 시점은 다릅니다. 관계선의 뜻을 구분해야 Spring의 역할을 과장하지 않습니다.

COMPOSE BEFORE CALL

구성은 객체 그래프를 만들고 요청은 완성된 그래프를 사용한다

  1. 구현 선택

    수동 코드나 Spring 설정이 MemberRepositoryPasswordHasher 구현을 고릅니다.

  2. 생성·바인딩

    구현 객체를 만들고 역할 타입으로 MemberRegistrationService 생성자에 전달합니다.

  3. 호출자 → 서비스

    요청이 오면 호출자가 이미 구성된 서비스의 register를 호출합니다.

  4. 서비스 → 협력자 직접 호출

    서비스가 저장소와 hasher 메서드를 호출합니다. 일반 DI 자체는 호출을 매번 중개하지 않지만, 별도 Spring AOP·트랜잭션·보안 proxy는 proxy를 거친 호출을 가로챌 수 있습니다.

MANUAL COMPOSITION

순수 Java로 유효

main이나 테스트 준비 코드가 구현을 만들고 생성자에 전달할 수 있습니다.

객체 그래프가 작거나 명시적 구성이 더 읽기 쉬우면 Spring이 필수는 아닙니다.

SPRING COMPOSITION

등록 bean만 관리

Spring container는 bean 정의와 scope에 따라 등록 bean을 생성·설정·연결합니다.

단일 주입점은 일치 후보가 하나로 해석되거나 @Qualifier 등으로 구분되어야 합니다. 인터페이스에서 구현이 자동 결정되는 마법이 아닙니다.

생명주기 callback은 scope를 따르며 prototype bean은 전달 뒤 파괴 callback이 자동 호출되지 않습니다. 모든 Java 객체가 bean이 되는 것도 아닙니다.

객체 그래프 표기와 실행 흐름 표기를 읽는 법
표기 시점
IMPLEMENTS 타입 선언 구현 클래스가 역할 계약을 구현 MemoryMemberRepository → MemberRepository
BIND / INJECT 구성 시점 선택한 구현 객체를 역할 자리로 서비스에 연결 repository → service constructor
CALL 요청 시점 서비스가 이미 연결된 협력자의 메서드를 실행 service → repository.save
Spring bean 생명주기의 적용 범위
객체 범주 등록·정의 생성 주체 Spring callback 범위 이 장의 예
등록된 bean @Bean 또는 component scan Spring container · scope에 따라 scope에 따름 · prototype 파괴 callback은 자동 아님 service·repository·hasher를 bean으로 등록한 경우
일반 객체 bean 정의 없음 애플리케이션 코드 대상 아님 · 자동 bean 전환 없음 register 중 생성한 Member

CHANGE CHECKLIST

구현을 바꾸기 전에 경계부터 확인한다

  1. 1 · 변경 압력

    저장 기술, 보안 정책, 테스트 요구 중 무엇이 바뀌는지 적습니다.

  2. 2 · 최소 역할 계약

    서비스가 실제로 필요한 메서드만 역할에 남깁니다.

  3. 3 · 선택·구성 경계

    구현 선택과 생명주기 책임을 사용 사례 밖으로 옮깁니다.

  4. 4 · 회귀 범위

    구현 계약, 구성, 회원가입 업무 순서를 각각 검증합니다.

이 객체 그래프는 2장 학습용 후보 관계입니다. 앞 장 애플리케이션의 실제 조립 상태를 주장하지 않습니다. 일반 DI 자체는 구성 뒤 호출을 매번 중개하지 않지만, 별도 Spring AOP·트랜잭션·보안 proxy는 proxy를 거친 호출을 가로챌 수 있습니다.


연습 문제

회원가입 뒤 환영 메일을 보내야 한다고 가정합니다.

서비스 안에 new EmailSender()를 추가하지 말고 WelcomeNotifier 역할을 정의해 보세요.

메일을 실제로 보내지 않는 테스트 구현을 생성자로 전달했을 때 회원가입 순서가 어떻게 읽히는지도 설명합니다.

다음 문서에서는 이 설계를 Spring 없이 실행 가능한 코드로 만듭니다.