본문 바로가기

Java

Java 의존성 역전하기 - Dependency Inversion (사례를 통한 학습)

이 글은 팀원들의 적극적인 코드 리뷰 참여를 계기로 쓰게 되었다.

코드 리뷰에서 나온 사례를 통해 의존성 역전하기를 구현해보기에 앞서 이 코드 리뷰 경험은 어느 정도 각색되었음을 밝힌다.

코드 리뷰 사례에서 나를 포함해 개발자 3명이 등장하는데 편의상 A와 B라고 부르겠다.

A가 아래와 비슷한 형태의 코드를 GitHub Repository에 Pull Request를 날렸다.

 

package param;

import entity.Customer;

public class CustomerParam {

    private final String name;
    private final Integer age;

    // A가 Refactoring 하기 전에는 Customer 생성 로직이 Service Class에 존재했다.
    public Customer convertToCustomer() {
        Customer customer = new Customer();
        customer.setName(this.name);
        customer.setAge(this.age);
        return customer;
    }
}

 

package entity;

public class Customer {

    private String name;
    private Integer age;

    public void setName(String name) {
        this.name = name;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

}

 

B는 A의 PR에 Comment를 남겼다.

B: Entity의 생성 책임을 View(Param)에게 위임하는 것이 적절한가?

Entity의 생성 책임을 View에게 위임했다는 것은 CustomerParam View에서 Customer Entity를 생성하기 때문이다.

 

이에 나는 Entity의 생성은 중요한 Domain Knowledge라 생각하여 캡슐화(은닉화)를 위해 다음과 같이 Comment를 남겼다.

나: 이런 경우에는 Entity에 View를 인자로 받는 생성자를 만들어 사용하곤 했습니다.

매우 허접한 Comment였다.

 

그러자 B는 다음과 같은 질문을 던졌다.

B: (나처럼 할 경우에) Entity가 View에 의존성을 갖게 되는 것에 대해서는 어떤가요?

 

그렇게 나는 의존성과 캡슐화 사이에서 깊은 고민에 빠지며 잠을 설치게 되었다.

다음날 든 생각은 이러했다.

'Entity와 View 사이에 Data Transfer Object(이하 DTO)를 두어 의존성을 해결하면 되지 않을까?'

그림으로 표현하면 다음과 같다.

 

의존 관계도 - 화살표의 방향이 의존하는 방향

 

Entity(Customer)와 View(CustomerParam) 사이에 Adapter로 사용할 DTO(CustomerDto)를 두었다.

화살표의 방향은 의존 관계 방향을 의미하므로 이제 Entity는 View에 의존하지 않는다.

변동성이 큰 구체인 View에게 변경 사항이 발생해도 Entity는 DTO에만 의존하므로 안전하고 원래 내가 원하던 바대로 캡슐화까지 할 수 있는 방법이라 생각했다.

하지만 이것은 대단한 착각이었다. 위에서 화살표가 의존 관계 방향을 나타낸다고 했다. Entity가 View에 의존하던 관계는 사라졌지만(정확히 말하자면 사라진 것이 아니라 감춰진) '추이적 의존성'이라는 것이 존재하게 되었다.

 

나는 매번 운이 좋게도 책을 읽을 때면 내가 지금 심각하게 고민하는 해결법들이 대부분 책에 나와 있다.

Clean Architecture라는 책에서 마침 SOLID를 읽고 있었는데 '추이 종속성'이라는 단어가 나왔다.

추이 종속성 - transitive dependency - 추이적 의존성 모두 같은 말이다.

Class A가 Class B에 의존하고, 다시 Class B가 Class C에 의존한다면 결국 Class A는 Class C에 의존하게 된다는 의미다.

 

그래서 의존성, 추이적 의존성 다 없애려면 어떻게 해야 할까?

SOLID의 D(DIP - Dependency Inversion Principle - 의존성 역전 원칙)에서 항상 꾸준히 이야기하고 있듯이 의존 관계를 역전시키면 된다. 모르는 사람은 없을 거다. Spring이나 여타 많은 Framework가 DIP를 통해 IoC Container를 만들어 냈으니..

Clean Architecture에서는 구체화한 대상(구현, 구체 Class) 간에 서로 의존하게 하는 대신 추상화된 대상(Abstract Class, Interface)을 사용하여 의존성을 역전하라고 설명되어 있다.

 

아래 코드는 Entity가 DTO와 View에 의존하던 관계를 지우고 추상화된 Abstract DTO를 참조하도록 하여 의존 관계를 역전시켜본 예제이다.

 

Entity는 추상 인터페이스인 Abstract CustomerDto를 참조한다. 각각 구체화된 CustomerDto Class들은 Abstract CustomerDto으로부터 파생되어 생성되기 때문에 비즈니스적 변동을 충족하기 위해 Class의 변경 대신 확장을 통한 해결이 가능하다. Abstract CustomeDto의 변동 가능성은 구체화된 CustomerDto Class들 보다 비교적 적다. 이제 Customer Entity는 구체화된 CustomerDto Class들을 알지 못하니 의존관계가 역전되었다고 할 수 있다.

 

Package Structure

 

Diagram - 빨간 선을 기준으로 의존 관계가 역전됨

 

package controller;

import entity.Customer;
import param.CustomerParam;
import service.CustomerService;
import service.CustomerServiceFactory;

public class CustomerController {

    private final CustomerServiceFactory customerServiceFactory;

    public CustomerController(CustomerServiceFactory customerServiceFactory) {
        this.customerServiceFactory = customerServiceFactory;
    }

    /**
     * POST /api/customer
     */
    public Customer create(CustomerParam customerParam) {
        CustomerService customerService = customerServiceFactory.getCustomerService(customerParam.getType());
        return customerService.create(customerParam);
    }

}

 

package dto;

public abstract class CustomerDto {

    private String name;
    private Integer age;

    public CustomerDto() {}

    public CustomerDto(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return this.age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

 

package dto;

import param.CustomerParam;

public class ForeignCustomerDto extends CustomerDto {

    private ForeignCustomerDto(CustomerParam customerParam) {
        super();
        super.setName(customerParam.getFirstName() + " " + customerParam.getLastName());
        super.setAge(customerParam.getAge());
    }

    public static ForeignCustomerDto of(CustomerParam customerParam) {
        return new ForeignCustomerDto(customerParam);
    }
}

 

package dto;

import param.CustomerParam;

public class LocalCustomerDto extends CustomerDto {

    private LocalCustomerDto(CustomerParam customerParam) {
        super(customerParam.getName(), customerParam.getAge());
    }

    public static LocalCustomerDto of(CustomerParam customerParam) {
        return new LocalCustomerDto(customerParam);
    }

}

 

package entity;

import dto.CustomerDto;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class Customer {

    private final String name;
    private final Integer age;
    private final LocalDateTime regDt;

    private Customer(CustomerDto customerDto) {
        this.name = customerDto.getName();
        this.age = customerDto.getAge();
        this.regDt = LocalDateTime.now();
    }

    public static Customer of(CustomerDto customerDto) {
        return new Customer(customerDto);
    }

    public String getName() {
        return this.name;
    }

    public Integer getAge() {
        return this.age;
    }

    public String convertToStringFromRegDt() {
        DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME;
        return this.regDt.format(formatter);
    }

}

 

package param;

public class CustomerParam {

    private String name;
    private Integer age;

    // View 가 변경되어 필드가 추가됨
    private Type type;
    private String firstName;
    private String lastName;

    public CustomerParam(String name, Integer age, Type type) {
        this.name = name;
        this.age = age;
        this.type = type;
    }

    public CustomerParam(String firstName, String lastName, Integer age, Type type) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
        this.type = type;
    }

    public String getName() {
        return this.name;
    }

    public Integer getAge() {
        return this.age;
    }

    public Type getType() {
        return this.type;
    }

    public String getFirstName() {
        return this.firstName;
    }

    public String getLastName() {
        return this.lastName;
    }

    public enum Type {
        LOCAL,
        FOREIGN;

        public boolean isLocal() {
            return this == LOCAL;
        }

        public boolean isForeign() {
            return this == FOREIGN;
        }
    }
}

 

package repository;

import entity.Customer;

public class CustomerRepository {

    public Customer save(Customer customer) {
        System.out.printf("Saved Customer {\n name: %s,\n age: %d,\n regDt: %s\n}", customer.getName(), customer.getAge(), customer.convertToStringFromRegDt());
        return customer;
    }

}

 

package service;

import entity.Customer;
import param.CustomerParam;

public interface CustomerService {

    Customer create(CustomerParam customerParam);

}

 

package service;

import param.CustomerParam;

public interface CustomerServiceFactory {

    CustomerService getCustomerService(CustomerParam.Type type);

}

 

package service;

import param.CustomerParam;
import repository.CustomerRepository;

public class CustomerServiceFactoryImpl implements CustomerServiceFactory {

    private final LocalCustomerService localCustomerService;
    private final ForeignCustomerService foreignCustomerService;

    public CustomerServiceFactoryImpl(CustomerRepository customerRepository) {
        this.localCustomerService = new LocalCustomerService(customerRepository);
        this.foreignCustomerService = new ForeignCustomerService(customerRepository);
    }

    @Override
    public CustomerService getCustomerService(CustomerParam.Type type) {
        if (type.isLocal()) {
            return localCustomerService;
        } else if (type.isForeign()) {
            return foreignCustomerService;
        }
        return localCustomerService;
    }
}

 

package service;

import dto.ForeignCustomerDto;
import entity.Customer;
import param.CustomerParam;
import repository.CustomerRepository;

public class ForeignCustomerService implements CustomerService {

    private final CustomerRepository customerRepository;

    public ForeignCustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @Override
    public Customer create(CustomerParam customerParam) {
        ForeignCustomerDto foreignCustomerDto = ForeignCustomerDto.of(customerParam);
        return customerRepository.save(Customer.of(foreignCustomerDto));
    }
}

 

package service;

import dto.LocalCustomerDto;
import entity.Customer;
import param.CustomerParam;
import repository.CustomerRepository;

public class LocalCustomerService implements CustomerService {

    private final CustomerRepository customerRepository;

    public LocalCustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @Override
    public Customer create(CustomerParam customerParam) {
        LocalCustomerDto localCustomerDto = LocalCustomerDto.of(customerParam);
        return customerRepository.save(Customer.of(localCustomerDto));
    }

}

 

package controller;

import entity.Customer;
import param.CustomerParam;
import repository.CustomerRepository;
import service.CustomerServiceFactory;
import service.CustomerServiceFactoryImpl;

public class CustomerControllerTest {

    private static CustomerController customerController;

    public static void main(String[] args) {
        init();
        testCreateLocalCustomer();
        testCreateForeignCustomer();
    }

    private static void init() {
        CustomerRepository customerRepository = new CustomerRepository();
        CustomerServiceFactory customerServiceFactory = new CustomerServiceFactoryImpl(customerRepository);
        customerController = new CustomerController(customerServiceFactory);
    }

    private static void testCreateLocalCustomer() {
        System.out.println("\n# Start CustomerControllerTest::testCreateLocalCustomer Test!");

        // given
        String givenName = "장화평";
        Integer givenAge = 31;
        CustomerParam.Type givenType = CustomerParam.Type.LOCAL;
        CustomerParam givenCustomerParam = new CustomerParam(givenName, givenAge, givenType);

        // when
        Customer expectedCustomer = customerController.create(givenCustomerParam);

        // then
        assertForUpdate(givenCustomerParam, expectedCustomer);

        System.out.println("\n# End CustomerControllerTest::testCreateLocalCustomer Test!");
    }

    private static void testCreateForeignCustomer() {
        System.out.println("\n# Start CustomerControllerTest::testCreateForeignCustomer Test!");

        // given
        String givenFirstName = "Hwapyeong";
        String givenLastName = "Jang";
        Integer givenAge = 99;
        CustomerParam.Type givenType = CustomerParam.Type.FOREIGN;
        CustomerParam givenCustomerParam = new CustomerParam(
                givenFirstName, givenLastName, givenAge, givenType);

        // when
        Customer expectedCustomer = customerController.create(givenCustomerParam);

        // then
        assertForUpdate(givenCustomerParam, expectedCustomer);

        System.out.println("\n# End CustomerControllerTest::testCreateForeignCustomer Test!");
    }

    private static void assertForUpdate(CustomerParam givenCustomerParam, Customer expectedCustomer) {
        assert givenCustomerParam.getName().equals(expectedCustomer.getName());
        assert givenCustomerParam.getAge().equals(expectedCustomer.getAge());
    }
}

 

테스트 결과

 

의존성과 캡슐화에 대한 고민의 시작은 코드 리뷰에서 시작되었다. 코드 리뷰는 토론의 장이면서 배움의 장이기도 하다.

함께 일하게 된 지는 얼마 되지 않았지만 모두 실력이 뛰어난 거 같다. (나는 쭈구리가 된 기분...)

나의 Comment에 의존성에 대한 의문을 남겨준 유팔복 님 그리고 DTO를 Adapter로 표현해 주심으로써 더 생각해 볼 시간을 갖게 해준 오봉근 님께 이 글을 통해 감사 인사드린다.

 

GitHub Code - https://github.com/hwapyeong-jang/solid