역할과 구현 분리
회원가입을 순수 Java 객체로 나누며 역할·구현·의존성의 뜻과 Spring이 객체 연결을 돕는 이유를 처음부터 익힙니다.
객체는 데이터와 동작을 함께 가진 Java 인스턴스입니다. 한 객체가 일을 끝내려고 다른 객체를 쓰면 두 객체 사이에 의존성(dependency)이 있다고 말합니다. 예를 들어 회원가입 서비스는 회원을 저장해야 하므로 회원 저장소에 의존합니다. 역할은 "무엇을 할 수 있는가"를, 구현은 "그 일을 어떻게 하는가"를 나타내며, Java에서는 보통 인터페이스로 역할을, 클래스로 구현을 표현합니다.
이 장은 역할과 객체 연결을 설명하기 위해 Spring 없이 순수 Java로 진행합니다. 1장에서 만든 애플리케이션의 회원가입을 다시 나누어 보지만, 이 장의 예제가 1장의 파일과 그대로 합쳐져 빌드된다는 뜻은 아닙니다. 1장의 코드는 그대로 두고, 이 장에서는 설명에 필요한 부분만 새로 작게 만듭니다.
회원가입 요구사항
게시판의 회원가입은 이메일이 이미 쓰이고 있는지 확인하고, 원문 비밀번호를 해시한 뒤, 이메일과 이름, 비밀번호 해시를 가진 회원을 저장하는 순서로 진행됩니다. 이 순서에 등장하는 기술은 바뀌는 이유가 서로 다릅니다. 저장 방식은 메모리에서 데이터베이스로 바뀔 수 있고, 해시 알고리즘은 보안 정책에 따라 바뀔 수 있습니다. 서비스 안에서 두 구현을 직접 만들면, 어느 쪽이 바뀌든 서비스를 고쳐야 합니다.
public interface MemberRepository {
boolean existsByEmail(String email);
Member save(Member member);
}public interface PasswordHasher {
String hash(String rawPassword);
}MemberRepository는 이메일 중복 확인과 저장이라는 일만 정하고, Map을 쓰는지 SQL을 쓰는지는 드러내지 않습니다. PasswordHasher도 해시라는 역할만 정하고 구체적인 알고리즘은 드러내지 않습니다. 호출하는 쪽은 이 두 인터페이스만 알면 됩니다.
이 장에서는 메모리 저장소를 지금 고른 구현으로 쓰고, JDBC 저장소와 PBKDF2 해시는 나중에 구성에서 고를 수 있는 운영용 구현 후보로 둡니다. 다음 글에서 다룰 TestPasswordHasher는 테스트 전용 구현 후보이며, 이 글에서는 이름만 등장합니다. 이 구현은 항상 같은 결과를 내고 일부러 안전하지 않게 만들기 때문에 실제 비밀번호에는 절대 쓰지 않습니다.
구현을 직접 고르는 문제
다음 코드는 그대로 실행할 완성 예제가 아니라, 직접 생성하는 방식과 주입받는 방식을 비교하기 위한 축약 스케치입니다. 1장의 명령 객체와 검증은 일부러 빼고, TestPasswordHasher도 이름만 빌려 씁니다.
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));
}
}코드에 new가 있다는 것 자체가 문제는 아닙니다. 수명이 짧고 바뀔 일이 없는 값 객체나 내부 도우미를 그 자리에서 만드는 것은 자연스럽습니다. 이 코드의 문제는 저장 기술과 해시 정책처럼 바뀔 가능성이 큰 선택, 그리고 그 객체들을 얼마나 오래 쓸지라는 수명 관리까지 서비스 안으로 들어와 있다는 점입니다. register의 업무 순서는 저장 구현이나 해시 알고리즘과 상관이 없는데도, new MemoryMemberRepository()와 new TestPasswordHasher() 때문에 구현을 바꿀 때마다 서비스를 고쳐야 합니다.
생성자로 협력자 받기
서비스가 구현을 직접 만들지 않고 생성자로 받으면, 역할을 쓰는 코드와 구현을 고르는 코드를 나눌 수 있습니다. 아래 코드에서 앞과 달라진 곳은 필드의 new 초기화가 사라지고 생성자가 생긴 것입니다. 필드 타입은 여전히 두 인터페이스이며, Objects.requireNonNull은 구현을 넘기지 않은 실수를 서비스 객체를 만드는 순간 NullPointerException으로 드러냅니다.
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의 업무 코드는 앞과 같다.
}필요한 객체를 생성자로 바깥에서 넘겨받는 방식을 생성자 주입(constructor injection)이라고 합니다. 이것은 Spring 전용 문법이 아니라 평범한 Java 사용법이며, main 메서드나 테스트 준비 코드에서 구현을 직접 만들어 생성자에 넘기는 수동 구성도 완전히 올바른 방법입니다.
Spring은 애플리케이션이 커졌을 때 이 일을 대신해 줍니다. 등록된 빈 정의와 그 빈의 스코프(scope)에 따라 빈을 만들고 설정하고 연결하며, 초기화와 종료 콜백도 스코프에 따라 호출합니다. 예를 들어 프로토타입(prototype) 스코프의 빈은 초기화한 뒤 넘겨주고 나면 종료 콜백이 자동으로 호출되지 않습니다. 또 Spring이 관리하는 것은 서비스나 저장소처럼 빈으로 등록한 객체뿐이며, register 도중에 만들어지는 Member 같은 객체까지 모두 빈이 되는 것은 아닙니다.
연결이 끝난 뒤 요청이 들어오면 서비스는 저장소와 해시 객체를 직접 호출합니다. 의존성 주입 자체가 두 객체 사이의 호출을 매번 가로채지는 않습니다. 다만 뒤에서 배울 Spring AOP나 트랜잭션, 보안을 위한 프록시(proxy)를 따로 구성하면, 프록시를 거쳐 들어온 호출에 부가 기능이 끼어들 수 있습니다.
DEPENDENCY DIRECTION · IMPLEMENTATION CHOICE
역할에 의존하고 구현 선택은 밖으로 옮긴다
서비스 소스는 역할을 알고, 구성은 구현을 고릅니다. 주입 방식보다 변동 선택과 수명 관리가 어느 책임에 놓이는지가 핵심입니다.
SOURCE DEPENDENCY → COMPOSITION → CALL
일반 DI에서 구현 선택과 실행 호출을 나눈다
-
MemberRegistrationService소스생성자와 필드는
MemberRepository·PasswordHasher역할만 선언합니다. -
외부 구성의 구현 선택·주입
구현 클래스는 역할을
implements하고, 수동 구성이나 Spring 구성이 그 객체를 골라 생성자에 전달합니다. -
요청 시점의
call서비스가 주입받은 객체의
existsByEmail·save·hash를 호출합니다. 일반 DI 자체는 호출을 매번 중개하지 않지만, 별도 Spring AOP·트랜잭션·보안 proxy는 proxy를 거친 호출을 가로챌 수 있습니다.
| 역할 | 현재 학습 선택 | 미래 운영 후보 | 서비스에 남는 계약 |
|---|---|---|---|
| 회원 저장 | MemoryMemberRepository |
JdbcMemberRepository |
MemberRepository |
| 비밀번호 해시 | TestPasswordHasher 개념 후보 |
Pbkdf2PasswordHasher |
PasswordHasher |
역할별 현재 학습 선택과 미래 운영 후보
회원 저장
현재 학습 선택: MemoryMemberRepository
미래 운영 후보: JdbcMemberRepository
서비스에 남는 계약: MemberRepository
비밀번호 해시
현재 학습 선택: TestPasswordHasher 개념 후보
미래 운영 후보: Pbkdf2PasswordHasher
서비스에 남는 계약: PasswordHasher
TEST DOUBLE BOUNDARY
실제 비밀번호 사용 금지
TestPasswordHasher는 다음 문서에서 다룰 테스트 전용 개념 후보입니다.
이 hash-only 포트에 맞춘 구현은 여기서 제시하지 않으며, 결정적이고 안전하지 않으므로 실제 비밀번호에 사용하면 안 됩니다.
| 원칙 | 확인할 증거 | 주입만으로 증명되지 않는 이유 |
|---|---|---|
| DIP | 서비스 소스가 구체 클래스가 아닌 역할에 의존 | 생성자를 써도 매개변수 타입이 구체 클래스면 방향은 그대로임 |
| OCP | 새 구현 추가 시 안정된 register는 유지되고 구성만 변경 |
새 요구가 업무 순서 자체를 바꾸면 서비스 변경이 필요할 수 있음 |
| SRP | 서비스가 회원가입 순서라는 한 변경 이유에 응집 | 여러 업무를 한 클래스에 모아도 생성자 주입은 사용할 수 있음 |
생성자 주입 이후에도 별도로 확인해야 하는 설계 원칙
DIP
확인할 증거: 서비스 소스가 구체 클래스가 아닌 역할에 의존
주입만으로 증명되지 않는 이유: 생성자를 써도 매개변수 타입이 구체 클래스면 방향은 그대로임
OCP
확인할 증거: 새 구현 추가 시 안정된 register는 유지되고 구성만 변경
주입만으로 증명되지 않는 이유: 새 요구가 업무 순서 자체를 바꾸면 서비스 변경이 필요할 수 있음
SRP
확인할 증거: 서비스가 회원가입 순서라는 한 변경 이유에 응집
주입만으로 증명되지 않는 이유: 여러 업무를 한 클래스에 모아도 생성자 주입은 사용할 수 있음
new는 안정된 지역 객체를 만드는 데 자연스럽습니다. 저장 기술·보안 정책처럼 변동하는 선택이나 공유 수명 관리가 사용 사례 안으로 들어올 때 구성 책임을 밖으로 옮깁니다.
그림의 위쪽은 서비스가 두 역할 인터페이스에만 의존하고, 구현 클래스들이 그 역할을 구현하는 관계를 보여 줍니다. 가운데 상자들은 구현을 고르고 주입하는 일과 요청 시점의 호출이 서로 다른 일임을, 아래쪽 표는 역할마다 지금 고른 구현과 앞으로의 운영 후보를 보여 줍니다. 마지막 표는 생성자 주입을 썼다는 것만으로 설계 원칙이 지켜졌다고 볼 수 없는 이유를 정리하며, 이어지는 절에서 자세히 설명합니다.
변경 방향 읽기
회원가입 서비스는 "회원 저장"과 "비밀번호 해시"라는 업무 역할을 씁니다. Map이나 JDBC, PBKDF2 같은 기술은 서비스를 만드는 코드에 들어오지 않게 합니다. 그런데 생성자 주입을 썼다고 해서 다음 원칙들이 저절로 지켜지지는 않습니다.
| 원칙 | 확인할 것 | 생성자 주입만으로 부족한 이유 |
|---|---|---|
| DIP(의존 역전 원칙) | 서비스의 소스가 구체 클래스가 아니라 역할에 의존하는가 | 생성자 파라미터의 타입이 구체 클래스라면 의존 방향은 그대로다 |
| OCP(개방 폐쇄 원칙) | 새 구현을 추가할 때 register는 그대로 두고 구성만 바꾸는가 | 새 요구가 업무 순서 자체를 바꾸면 서비스도 바뀔 수 있다 |
| SRP(단일 책임 원칙) | 서비스가 회원가입 순서라는 한 가지 변경 이유에 모여 있는가 | 여러 업무를 한 클래스에 모아 두어도 생성자 주입은 쓸 수 있다 |
주입은 이런 판단을 돕는 구조일 뿐입니다. 역할 인터페이스가 지나치게 크거나 서비스가 여러 업무를 함께 맡고 있다면, 생성자 주입을 쓰더라도 원칙은 여전히 깨질 수 있습니다. OCP와 DIP는 OCP·DIP와 생성자 주입의 경계에서 다시 자세히 다룹니다.
객체 그래프
객체와 객체가 연결된 모양을 객체 그래프(object graph)라고 합니다. 이 장의 회원가입 객체 그래프는 세 가지 관계로 읽을 수 있습니다.
[타입/소스 선언]
구현 저장소 클래스 ──implements──> MemberRepository
구현 해시 클래스 ──implements──> PasswordHasher
[구성 시점: 구현 선택과 주입]
MemberRegistrationService ←──inject── 두 역할 객체
[요청 시점: 메서드 호출]
MemberRegistrationService ──call──> MemberRepository
MemberRegistrationService ──call──> PasswordHasherimplements는 소스 코드의 타입 선언을, inject는 구성 시점에 구현을 골라 연결하는 일을, call은 요청 시점의 실행 흐름을 나타냅니다. 수동 구성이든 Spring 구성이든 구현을 JDBC 저장소와 PBKDF2 해시로 바꾸어도 서비스가 보는 역할은 같습니다.
COMPOSITION TIME ≠ REQUEST TIME
객체 그래프는 구성과 사용의 책임을 분리한다
객체를 고르고 연결하는 시점과 업무 메서드를 호출하는 시점은 다릅니다. 관계선의 뜻을 구분해야 Spring의 역할을 과장하지 않습니다.
COMPOSE BEFORE CALL
구성은 객체 그래프를 만들고 요청은 완성된 그래프를 사용한다
-
구현 선택
수동 코드나 Spring 설정이
MemberRepository와PasswordHasher구현을 고릅니다. -
생성·바인딩
구현 객체를 만들고 역할 타입으로
MemberRegistrationService생성자에 전달합니다. -
호출자 → 서비스
요청이 오면 호출자가 이미 구성된 서비스의
register를 호출합니다. -
서비스 → 협력자 직접 호출
서비스가 저장소와 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 |
객체 그래프 표기와 실행 흐름 표기를 읽는 법
IMPLEMENTS
시점: 타입 선언
뜻: 구현 클래스가 역할 계약을 구현
예: MemoryMemberRepository → MemberRepository
BIND / INJECT
시점: 구성 시점
뜻: 선택한 구현 객체를 역할 자리로 서비스에 연결
예: repository → service constructor
CALL
시점: 요청 시점
뜻: 서비스가 이미 연결된 협력자의 메서드를 실행
예: service → repository.save
| 객체 범주 | 등록·정의 | 생성 주체 | Spring callback 범위 | 이 장의 예 |
|---|---|---|---|---|
| 등록된 bean | @Bean 또는 component scan |
Spring container · scope에 따라 | scope에 따름 · prototype 파괴 callback은 자동 아님 | service·repository·hasher를 bean으로 등록한 경우 |
| 일반 객체 | bean 정의 없음 | 애플리케이션 코드 | 대상 아님 · 자동 bean 전환 없음 | register 중 생성한 Member |
Spring bean 생명주기의 적용 범위
등록된 bean
등록·정의: @Bean 또는 component scan
생성 주체: Spring container · scope에 따라
Spring callback 범위: scope에 따름 · prototype 파괴 callback은 자동 아님
이 장의 예: service·repository·hasher를 bean으로 등록한 경우
일반 객체
등록·정의: bean 정의 없음
생성 주체: 애플리케이션 코드
Spring callback 범위: 대상 아님 · 자동 bean 전환 없음
이 장의 예: register 중 생성한 Member
CHANGE CHECKLIST
구현을 바꾸기 전에 경계부터 확인한다
- 변경 압력
저장 기술, 보안 정책, 테스트 요구 중 무엇이 바뀌는지 적습니다.
- 최소 역할 계약
서비스가 실제로 필요한 메서드만 역할에 남깁니다.
- 선택·구성 경계
구현 선택과 생명주기 책임을 사용 사례 밖으로 옮깁니다.
- 회귀 범위
구현 계약, 구성, 회원가입 업무 순서를 각각 검증합니다.
이 객체 그래프는 2장 학습용 후보 관계입니다. 앞 장 애플리케이션의 실제 조립 상태를 주장하지 않습니다. 일반 DI 자체는 구성 뒤 호출을 매번 중개하지 않지만, 별도 Spring AOP·트랜잭션·보안 proxy는 proxy를 거친 호출을 가로챌 수 있습니다.
그림은 이 세 관계가 서로 다른 시점에 일어난다는 것을 나누어 보여 줍니다. 구성은 요청보다 먼저 객체 그래프를 완성하고, 요청은 완성된 그래프를 씁니다. 그림 아래쪽은 수동 구성도 순수 Java로 충분히 올바르다는 것과, Spring 컨테이너는 등록된 빈만 관리하며 한 주입 지점에 맞는 후보가 하나로 정해지거나 @Qualifier 등으로 구분되어야 한다는 것을 보여 줍니다. 인터페이스만 보고 구현이 마법처럼 정해지는 것은 아닙니다. 마지막 체크리스트는 구현을 바꾸기 전에 무엇이 바뀌는지, 서비스가 정말 필요한 메서드만 역할에 남겼는지, 구현 선택을 사용 사례 밖으로 옮겼는지, 각각을 어떤 테스트로 확인할지를 차례로 점검하게 합니다.
정리
- 역할은 인터페이스로, 구현은 클래스로 나타내며, 서비스는 역할에만 의존하게 합니다.
new자체가 나쁜 것은 아니며, 바뀔 가능성이 큰 구현의 선택과 수명 관리가 사용 사례 안에 들어올 때 밖으로 옮깁니다.- 생성자 주입은 Spring 전용 기능이 아니라 평범한 Java 사용법이고,
main이나 테스트에서 직접 연결하는 수동 구성도 올바른 방법입니다. - Spring은 등록된 빈만 스코프에 따라 만들고 연결하며, 의존성 주입 자체는 요청 시점의 호출을 가로채지 않습니다.
- 생성자 주입을 썼다고 DIP, OCP, SRP가 저절로 지켜지지는 않으므로 의존 방향과 변경 이유를 따로 확인합니다.
연습 문제
1. 환영 알림 역할 추가하기
회원가입 뒤에 환영 메일을 보내야 한다고 해 봅시다. 서비스 안에 new EmailSender()를 넣지 말고, void welcome(Member member) 메서드 하나를 가진 WelcomeNotifier 역할을 정의하세요. 알림은 저장소가 저장된 회원을 정상적으로 돌려준 뒤에만 한 번 호출하고, 저장이 예외로 끝나면 호출하지 않아야 합니다. 테스트용 구현은 메일을 보내지 않고 받은 회원과 호출 횟수만 기록합니다. 알림까지 회원가입과 하나로 묶는 기능은 이번 조건에 없습니다. 이렇게 했을 때 서비스의 회원가입 순서가 어떻게 읽히는지 설명하세요.
해설 보기
서비스 생성자는 기존 두 역할에 더해 WelcomeNotifier를 받습니다. 업무 흐름은 중복 확인, 해시, 저장, 저장소가 돌려준 회원으로 알림, 회원 반환의 순서가 됩니다. 저장 결과를 지역 변수로 받아 그 값으로 알림을 보내야, 저장에 실패했는데도 입력값으로 알림을 보내는 경로가 생기지 않습니다. save가 예외를 던지면 그 뒤의 알림 호출에 도달하지 않으므로 조건도 지켜집니다. 실제 발송 구현을 쓸지 기록용 테스트 구현을 쓸지는 구성 코드가 고릅니다.
그래서 JDBC 저장소와 기록용 알림 구현을 넘겨도 서비스는 SQL이나 메일 서버 설정을 알 필요가 없고, 서비스 코드를 읽으면 회원가입 순서만 보입니다. 다만 저장한 뒤 알림이 실패해도 이미 저장된 회원이 자동으로 지워지지는 않습니다. 외부 발송의 재시도나 저장과의 원자성은 따로 설계해야 합니다.
2. 구현 교체와 역할 변경
메모리 저장소를 JDBC 저장소로 바꾸는 일과, MemberRepository에 findByEmail 메서드를 새로 추가하는 일은 각각 어떤 코드를 바꾸게 하나요?
해설 보기
JDBC 저장소로 바꾸는 것은 구현 교체입니다. 서비스 생성자의 파라미터는 여전히 MemberRepository이고 register의 순서도 같으므로, 구성 코드에서 넘기는 구현만 달라집니다. 반면 역할에 추상 메서드를 새로 추가하는 것은 역할 자체를 바꾸는 일이어서, 그 역할을 구현한 메모리 저장소와 JDBC 저장소, 테스트용 구현을 모두 고쳐야 합니다. 두 변화를 같은 것으로 보지 않아야 역할 인터페이스를 필요한 만큼만 작게 유지할 수 있습니다.
다음 글 순수 Java 회원가입에서는 이 설계를 Spring 없이 실행할 수 있는 코드로 만듭니다.