본문으로 건너뛰기

안동민 개발노트

본문 시작

역할과 구현 분리

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

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

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

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

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

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

HTML 다이어그램: /docs/spring/ch2/ch2-1/1.html

회원가입 요구사항

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

  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);
    boolean matches(String rawPassword, String passwordHash);
}

MemberRepository는 저장 역할만 말하고 Map이나 SQL을 드러내지 않습니다.

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

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


구현을 직접 고르는 문제

다음 코드는 실행되지만 서비스가 두 구현을 직접 선택합니다.

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() 때문에 구현 교체가 서비스 수정으로 번집니다.

HTML 다이어그램: /docs/spring/ch2/ch2-1/2.html

생성자로 협력자 받기

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

MemberRegistrationService.java - 생성자 주입
public final class MemberRegistrationService {
    private final MemberRepository members;
    private final PasswordHasher passwordHasher;

    public MemberRegistrationService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        this.members = members;
        this.passwordHasher = passwordHasher;
    }

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

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

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

Spring은 애플리케이션이 커졌을 때 이 전달을 일관되게 수행해 줍니다.


변경 방향 읽기

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

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

이 구조는 다음 원칙을 자연스럽게 만족시킵니다.

  • 서비스는 회원가입 순서가 바뀔 때 수정한다.
  • 저장 구현과 해시 구현은 각 기술이 바뀔 때 수정한다.
  • 새 구현을 추가해도 서비스의 register는 그대로 둔다.

이 원칙을 나중에 SRP, OCP, DIP라는 이름으로 다시 만나지만 이름보다 먼저 변경 범위를 이해해야 합니다.

HTML 다이어그램: /docs/spring/ch2/ch2-1/3.html

객체 그래프

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

MemberRegistrationService
 ├─ MemberRepository → MemoryMemberRepository
 └─ PasswordHasher   → TestPasswordHasher

화살표 왼쪽은 사용하는 역할이고 오른쪽은 현재 선택한 구현입니다.

구현을 JDBC 저장소와 운영용 해시로 바꿔도 서비스가 보는 역할은 같습니다.

HTML 다이어그램: /docs/spring/ch2/ch2-1/4.html

연습 문제

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

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

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

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