본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
2장 : 객체지향 설계와 Spring

역할과 구현 분리

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

객체는 데이터와 동작을 함께 가진 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);
    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() 때문에 구현 교체가 서비스 수정으로 번집니다.


생성자로 협력자 받기

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

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라는 이름으로 다시 만나지만 이름보다 먼저 변경 범위를 이해해야 합니다.


객체 그래프

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

MemberRegistrationService
 ├─ MemberRepository → MemoryMemberRepository
 └─ PasswordHasher   → TestPasswordHasher

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

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


연습 문제

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

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

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

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