본문 바로가기
이펙티브 자바

자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

by 이상한나라의개발자 2024. 1. 10.

해당 원칙은 의존성 주입(Dependency Injection, DI) 의 중요성에 대해서 강조하고 있습니다. 의존 객체 주입은 클래스가 자신의 의존성을 직접 생성하지 않고 외부에서 제공 받는 방식입니다. 

 

  • 유연성과 재사용성 : 의존 객체 주입을 사용하면, 클래스가 특정 구현에 강하게 결합되는 것을 방지할 수 있습니다. 예를들어, 데이터베이스 연결이나 네트워크 리소스와 같은 자원에 대한 구체적인 구현 대신 인터페이스를 주입 받으면 클라이언트 코드에 영향 없이 다른 구현체로 쉽게 교체할 수 있습니다.
  • 테스트 용이성 : 의존 객체 주입을 사용하면 단위 테스트 시에 실제 구현 대신 모의 객체(mock)이나 스텁을 주입할 수 있어 테스트가 용이해집니다.

 

유연성과 재사용성

ClientService 클래스는 EmailService를 사용하여 이메일을 보낸다고 가정합니다. 만약 ClientService 클래스가 EmailService 클래스의 구체적인 구현을 직접 생성하면 유연성이 떨어지게 됩니다.

 

public interface EmailService {
    void sendEmail(String subject, String body, String email);
}

--------
public class SimpleEmailService implements EmailService {
    @Override
    public void sendEmail(String subject, String body, String email) {
        System.out.println("subject = " + subject + ", body = " + body + ", email = " + email);
    }
}

--------
public class ClientService {
    private EmailService emailService = new SimpleEmailService();

    public void sendEmail(String subject, String body, String email) {
        emailService.sendEmail(subject, body, email);
    }

    public static void main(String[] args) {
        ClientService clientService = new ClientService();
        clientService.sendEmail("subject", "body", "email");
    }
}

 

위 코드에서 ClientService 클래스는 SimpleEmailService에 강하게 결합 되어 있습니다. 이로인해, EmailService의 다른 구현체를 쉽게 교체하기가 어려워집니다. 

만약 EmailService 클래스의 다른 구현을 사용하고 싶다면 ClientService의 코드를 변경해야 합니다. 이는, OCP(개방폐쇠원칙) 원칙을 위반하게 됩니다. ( 클라이언트 코드에는 변화가 없어야 합니다 )

 

아래 코드에서 개선된 점을 구성하였습니다.

public class ClientService {
    private EmailService emailService;

    public ClientService(EmailService emailService) {
        this.emailService = emailService;
    }

    public void sendEmail(String subject, String body, String email) {
        emailService.sendEmail(subject, body, email);
    }

    public static void main(String[] args) {
        ClientService clientService = new ClientService(new SimpleEmailService());
        clientService.sendEmail("subject", "body", "email");
    }
}

 

외부에서 주입을 받아 사용하게 변경한 코드 입니다. 이로인해, 클라이언트 코드에는 아무런 변화 없이 사용 가능하게 됩니다. 

  • EmailService 인터페이스의 다른 구현체로 쉽게 교체할 수 있습니다.
  • 단위 테스트시 MockEmailService 와 같은 테스트 클래스를 만들어 사용할 수 있게 됩니다. ( 이 부분은 아래 "테스트 용이성" 에서 예시를 만들어 볼께요)
  • ClientService는 EmailService의 구체적인 구현에 대해 알 필요가 없으니 자연적으로 결합도가 줄어듭니다. ( 결합도가 높으면 소스 하나를 고치면 여러개의 소스에서 영향을 받게 됩니다.)

 

테스트 용이성

 

유저를 신규로 생성하는 도메인 객체가 있다고 하면 코드에서 나오는 실제 유저의 생성 시간은 테스트를 하기 어려워집니다. 이는 테스트가 보내는 신호로 받아들여야 하는데요. 

@Getter
public class User {

    private String name;
    private String email;
    private String password;
    private LocalDateTime createdAt;

    @Builder
    public User(String name, String email, String password, LocalDateTime createdAt) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.createdAt = createdAt;
    }

    public static User create(String name, String email, String password) {
        return User.builder()
                .name(name)
                .email(email)
                .password(password)
                // 실제 저장되는 시간 이므로 상용에서는 LocalDateTime.now()를 강제로 사용해야 한다.
                .createdAt(LocalDateTime.now())
                .build();
    }
}

-------
class UserTest {

    @Test
    void 신규_유저를_생성할_수_있다() {

        // given
        String name = "test";
        String email = "test@naver.com";
        String password = "1234";

        // when
        User user = User.create(name, email, password);
        
        // then
        assertThat(user.getName()).isEqualTo(name);
        assertThat(user.getEmail()).isEqualTo(email);
        assertThat(user.getPassword()).isEqualTo(password);
        //assertThat(user.getCreatedAt()).isEqualTo(?); -- 테스트 어떻게 ?
    }
}

 

위 코드 예시처럼 실제 등록 시간은 테스트하기 어려워 집니다. 그럼 이 부분을 외부 주입으로 개선하도록 하겠습니다.

 

@Getter
public class User {

    private String name;
    private String email;
    private String password;
    private LocalDateTime createdAt;

    @Builder
    public User(String name, String email, String password, LocalDateTime createdAt) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.createdAt = createdAt;
    }
	
    // 외부에서 주입 받도록 변경
    public static User create(String name, String email, String password, ClockHolder clockHolder) {
        return User.builder()
                .name(name)
                .email(email)
                .password(password)
                // 실제 저장되는 시간 이므로 상용에서는 LocalDateTime.now()를 강제로 사용해야 한다.
                .createdAt(clockHolder.now())
                .build();
    }
}


-------
// 외부 주입을 위한 인터페이스 
public interface ClockHolder {

    LocalDateTime now();
}


-------
// 테스트를 위한 클래스 생성
public class TestClockHolder implements ClockHolder {

    private LocalDateTime now;

    public TestClockHolder(LocalDateTime now) {
        this.now = now;
    }
    @Override
    public LocalDateTime now() {
        return this.now;
    }
}


-------
// 실제 상용에서 사용하기 위한 클래스
public class LocalDateTimeClockHolder implements ClockHolder {
    @Override
    public LocalDateTime now() {
        return LocalDateTime.now();
    }
}



-------
// 테스트 코드
    @Test
    void 신규_유저를_생성할_수_있다() {

        // given
        String name = "test";
        String email = "test@naver.com";
        String password = "1234";

        // when
        // 외부 주입 사용
        ClockHolder clockHolder = new TestClockHolder(LocalDateTime.now());

        User user = User.create(name, email, password, clockHolder);

        // then
        assertThat(user.getName()).isEqualTo(name);
        assertThat(user.getEmail()).isEqualTo(email);
        assertThat(user.getPassword()).isEqualTo(password);
        assertThat(user.getCreatedAt()).isEqualTo(clockHolder.now());
    }
}

 

위 코드에서 유저 생성일시의 의존성 주입의 대상은 ClockHolder 인터페이스 입니다. 

  • 의존성 : ClockHolder 인터페이스를 사용함으로써 실제 시스템 시간에 의존하지 않으므로 예측 가능한 테스트가 됩니다.
  • 결과보장 : TestClockHolder 구현체를 통해 항상 고정된 시간을 사용함으로써, 테스트가 실행될 때마다 동일한 결과를 보장합니다.
  • 테스트 환경과 실제 환경의 분리 : LocalDateTimeClockHoler, TestClockHolder를 사용함으로 테스트와 실제 환경을 분리할 수 있습니다. 
  • 유연성과 재사용성 : 시간을 제공하는 로직이 User 도메인 외부에서 정의 되므로 User 클래스는 다른 시간 관련 기능을 쉽게 교체할 수 있습니다.

위와 같은 방식은 의존성을 외부로부터 주입받으면, 테스트를 쉽게 작성할 수 있고, 다양한 시나리오에서 클래스의 동작을 검증할 수 있습니다.

 

요약

클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동장에 영향을 준다면 싱글턴과 정적 유틸리티 클래스는 사용하지 않는 것이 좋다. 이 자원들을 클래스가 직접 만들게 해서도 안 된다. 대신 필요한 자원을(혹은 그 자원을 만들어주는 팩터리를) 생성자에(혹은 정적 팩터리나 빌더에) 넘겨주자. 의존 객체 주입이라 하는 이 기법은 클래스의 유연성, 재사용성, 테스트 용이성을 기가막히게 개선해준다.

- Effective Java -