Dev

고차 함수로 의존성 줄이기

prostars 2022. 9. 22. 11:02

2023년 8월 3일 추가: 이 내용을 포함한 카카오 테크밋에서 발표한 영상이 올라왔습니다.

 

스프링을 사용한 프로젝트에서 종종 보이는 어노테이션을 사용한 의존성 주입의 남용과 오랜 세월의 흐름으로 의도치 않게 서비스 간의 의존성 그래프가 복잡하게 강결합으로 묶이면서 코드를 읽기도 어렵고 단위 테스트를 구성하기도 어려운 상황이 생긴다. 아래는 어떤 백엔드 서비스의 의존성 그래프다. 순환 종속성이 포함된 복잡한 왼쪽의 의존성 그래프를 오른쪽의 단순한 의존성 그래프로 리팩터링하여 라이브 서비스에 반영하였다. 이번 글은 오랜 세월의 흐름으로 서비스 의존성 그래프가 복잡해진 라이브 서비스를 리팩터링한 내용을 일반화하여 작은 예제로 만들어서 정리한다.

 

 

이 글을 읽는데 필요한 배경지식으로 자바의 함수형 인터페이스를 이해하고 있다고 가정한다. (참고: Functional Interfaces in Java 8)

 

이 글에서 사용하는 예제는 BeforeRefactoring과 AfterRefactoring으로 2개의 프로젝트로 나누어서 구성했고, 전체 코드는 여기에 있다.
스프링을 사용하지 않고 예제를 구성하려 해봤지만, 스프링을 사용하지 않고는 아래에서 보게 될 순환 종속성을 만들기도 쉽지 않다. 심지어, 예제로 만든 BeforeRefactoring 프로젝트는 스프링 부트 2.6에서 순환 종속성(Circular Dependency)이라고 실행을 거부하며, application.properties 파일에 아래 설정을 추가해야만 한다. 어떤 에러인지 궁금하면, 아래 설정을 삭제하고 실행해보면 볼 수 있다. 참고로, 생성자 인젝션을 사용하면, 아래 설정을 추가해도 순환 종속성 에러로 실행되지 않으므로 예제는 필드 인젝션을 사용한다.

spring.main.allow-circular-references=true

 

예제 프로젝트 BeforeRefactoring은 아래와 같은 순환 의존성 그래프를 만든다.

BeforeRefactoring에서 각 서비스의 메소드가 다른 서비스의 메소드를 사용하기 위해서 다른 서비스의 참조를 필드로 가지고 있으면서, 서비스 간의 순환 종속성을 만들어낸다. 풀어내는 방법은 다양하겠지만, 여기서는 자바 8부터 지원하는 함수형 인터페이스를 사용해서 고차 함수로 풀 것이다. 의존성을 완전히 제거할 수 있다면 더 좋겠지만, 제거할 수 없다면 의존성을 가능한 한 작게 유지하는 것이 좋다. 자바의 함수형 인터페이스가 다른 함수형 언어의 고차 함수와는 달리 결국 클래스 인터페이스로 구현되지만, 그래도 명시적인 클래스 인터페이스보다는 함수형 인터페이스가 더 작고, 약한 결합이다.

리팩터링

여기서 전체 코드의 수정 과정을 설명하지 않고, 간단히 1개의 객체 의존성만 함수 의존성으로 수정해본다. BeforeRefactoring의 ServiceA가 가지고 있는 ServiceB에 대한 의존성을 함수형 인터페이스 의존성으로 수정해보자.

@Service
public class ServiceA {
    @Resource
    ServiceB serviceB;

    public void methodA(Integer paramFirst) {
        Output.printf("'pass %d to ServiceB and get %s' by ServiceA\n", paramFirst, serviceB.methodB(paramFirst));
    }

    public Integer getValue() {
        return 10;
    }
}

 

위 클래스를 아래와 같이 ServiceA::methodA()의 시그니처를 수정하고, serviceB.methodB() 메서드 호출을 함수형 인터페이스 apply() 호출로 바꾼다. 이제 ServiceA는 ServiceB에 의존하지 않으니 serviceB 필드는 삭제한다.

@Service
public class ServiceA {
    public void methodA(Integer paramFirst, Function<Integer, Integer> methodB) {
        Output.printf("'pass %d to ServiceB and get %s' by ServiceA\n", paramFirst, methodB.apply(paramFirst));
    }

    public Integer getValue() {
        return 10;
    }
}

위에서 ServiceA::methodA()의 시그니처를 수정했으니 Handler::execute() 메서드에서 컴파일 에러가 발생할 것이다. 맞춰서 수정하자.

@Component
public class Handler {
    private final ServiceA serviceA;

    public Handler(final ServiceA serviceA) {
        this.serviceA = serviceA;
    }

    public void execute(long count) {
        for (long cnt = 0; cnt < count; cnt++) {
            serviceA.methodA(2);
        }
    }
}

아래와 같이 ServiceB에 대한 의존성을 Handler로 옮겨오고, 생성자 인젝션으로 주입한다. 그리고, serviceA.methodA() 메서드 호출부에 2번째 아규먼트로 serviceB::methodB를 고차 함수로 넘기면, 위에서 수정한 ServiceA::methodA() 시그니처에 만족하면서 에러가 사라진다.

@Component
public class Handler {
    private final ServiceA serviceA;
    private final ServiceB serviceB;

    public Handler(final ServiceA serviceA, final ServiceB serviceB) {
        this.serviceA = serviceA;
        this.serviceB = serviceB;
    }

    public void execute(long count) {
        for (long cnt = 0; cnt < count; cnt++) {
            serviceA.methodA(2, serviceB::methodB);
        }
    }
}

 

위와 같은 방식으로 나머지 ServiceB->ServiceC와 ServiceC->ServiceA에 대한 의존성을 제거할 수 있다. 작업하면서 ServiceA::methodA()의 시그니처가 추가로 수정될 것이다. 모든 작업이 끝나면 서비스 간의 의존성이 모두 사라지면서, 순환 종속성도 사라진다. 추가로 얻는 이점은 Handler가 동작하려면 모두 필요한 ServiceA, ServiceB, ServiceC에 대한 의존성이 각 서비스에 흩어져서 가려져 있던 부분이 Handler 클래스로 모두 명시적으로 모이면서 의존성이 한눈에 보인다. 객체 의존성이 함수 의존성으로 줄면서 서비스 간의 의존성 그래프는 아래와 같이 모두 끊어진다.

단위 테스트 구성

이제 각 서비스는 외부에서 주입받는 함수만 필요할 뿐 이제 서로의 존재를 몰라도 된다. 단위 테스트에서 확인할 수 있다.
순환 의존성을 가진 BeforeRefactoring을 단위 테스트하려면, 아래와 같이 @SpringBootTest를 사용하고 Bean을 스프링에 의존해 생성하거나,

@SpringBootTest
class UsingSpringTests {
    @Resource
    ServiceB serviceB;

    @Resource
    Handler handler;

    @Test
    void testServiceB() {
        // 성공하지만, main()이 실행된 후라서 Output::isPrintable의 상태가 기본값이 아니다.
        Integer result = serviceB.methodB(2);
        assertThat(result, equalTo(12));
    }

    @Test
    void testHandler() {
        // 성공하지만, main()이 실행된 후라서 Output::isPrintable의 상태가 기본값이 아니다.
        handler.execute(1);
    }
}

아래와 같이 @Mock, @InjectMocks를 사용하고 Mockito에 의존해 객체 목킹을 해서 테스트를 구성해야 한다. 

class UsingMockTests {
    @Mock
    private ServiceC serviceC;

    @InjectMocks
    private ServiceB serviceB;

    @InjectMocks
    private Handler handler;

    @BeforeEach
    public void createServiceB() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void testServiceB() {
        int first = 10;
        when(serviceC.methodC(first)).thenReturn(first + 30);

        Integer result = serviceB.methodB(first);
        assertThat(result, equalTo(40));
    }

    @Test
    public void testHandler() {
        // 실패한다. 성공시키려면, ServerA, ServerB, ServerC 의존성에 대한 Mocking을 처리해야 한다.
        handler.execute(1);
    }
}

 

하지만, 함수 의존성을 사용한 AfterRefactoring을 단위 테스트하는 건 스프링과 모키토 없이 아래와 같이 바로 생성해서 테스트를 구성할 수 있다.

class SampleUnitTests {
    @Test
    public void testServiceB() {
        ServiceB serviceB = new ServiceB();

        Integer result = serviceB.methodB(2, () -> 10, (a, b) -> a + b.getAsInt());
        assertThat(result, equalTo(12));
    }

    @Test
    public void testHandler() {
        Handler handler = new Handler(new ServiceA(), new ServiceB(), new ServiceC());

        handler.execute(1);
    }
}

JShell에서 코드 조각 테스트

위와 같이 간결하게 객체를 생성할 수 있다면, Java 9부터 추가된 JShell에서도 객체를 바로 생성할 수 있다. JShell에서 간단히 객체를 만들고 테스트해보자.

import com.example.afterrefactoring.Handler;
import com.example.afterrefactoring.service.ServiceA;
import com.example.afterrefactoring.service.ServiceB;
import com.example.afterrefactoring.service.ServiceC;


ServiceA serviceA = new ServiceA();
ServiceB serviceB = new ServiceB();
ServiceC serviceC = new ServiceC();

System.out.println(serviceA.getValue());
System.out.println(serviceC.methodC(2, serviceA::getValue));
System.out.println(serviceB.methodB(2, serviceA::getValue, serviceC::methodC));

Handler handler = new Handler(serviceA, serviceB, serviceC);
handler.execute(1);

아래는 IntelliJ에서 JShell을 사용하여 위 코드 조각을 실행한 스크린샷이다.

아래와 같은 에러가 발생한다면, 여기를 참고 바란다.

간단한 성능 차이 확인

모든 정리가 끝났고, 객체 의존성을 함수 의존성으로 바꾸면서 발생하는 비용을 비교해보자. 객체 의존성일 때는 필요 없었던 함수형 인터페이스의 파라미터 전달이 생기면서 호출 비용이 증가한다. 간단히 확인해보자.

아래 스크린샷의 왼쪽이 객체 의존성 BeforeRefactoring의 실행 시간, 오른쪽이 함수 의존성 AfterRefactoring의 실행 시간이다.

극명한 차이는 없지만, 호출 비용이 증가하면서 성능이 떨어졌다.
위 실행 결과는 JVM 옵션 -XX:TieredStopAtLevel=1 이 설정된 상태로 실행된 결과다. IntelliJ에서 스프링 프로젝트를 실행하면 기본값으로 설정된다. IntelliJ의 ’Run/Debug Configurations’ 설정에서 ‘Disable launch optimization’를 설정해서 JIT Compiler가 최적화하도록 하면 아래와 같이 성능이 달라지는 것을 볼 수 있다.

약간의 성능 차이는 여전히 발생하지만, 많이 줄었다. 글의 시작에서 언급했듯이 라이브 서비스에 반영하였고, 성능 저하는 발생하지 않았다.

의존성 정리에 도움이 되길 바란다.

 

 

반응형