고차 함수로 의존성 줄이기

Share
고차 함수로 의존성 줄이기

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

example

 

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

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

spring.main.allow-circular-references=true

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

examplebefore

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 클래스로 모두 명시적으로 모이면서 의존성이 한눈에 보인다. 객체 의존성을 함수 의존성으로 줄면서 서비스 간의 의존성 그래프는 아래와 같이 모두 끊어진다.

exampleafter

단위 테스트 구성

이제 각 서비스는 외부에서 주입받는 함수만 필요할 뿐 이제 서로의 존재를 몰라도 된다. 단위 테스트에서 확인할 수 있다.
순환 의존성을 가진 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을 사용하여 위 코드 조각을 실행한 스크린샷이다.

jshell

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

error

간단한 성능 차이 확인

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

performance

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

performanceusingjit

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

(원글: https://prostars.net/327)

Read more

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로 TypeSafe AI가 2026년 9월 15일에 Jev를 내놨습니다. 텍스트를 쓰지 않고 타입이 정해진 판정과 확률을 바로 내는 모델입니다. 유추카 검색에서 가장 느린 단계가 마침 텍스트가 필요 없는 판정이었습니다. 그 구간만 Jev로 갈아끼우고 일주일간 측정했습니다. 콜드

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 8) — 바이브 코딩이라기엔

AI로 장소 검색 서비스 만드는 중입니다 (특별편 8) — 바이브 코딩이라기엔

1900년대 초 교량 측량 도면. 이 종이를 그린 사람은 다리를 직접 놓지 않습니다. 대신 좌표와 각도가 맞는지를 판정합니다. "AI가 다 만들어 준다"는 이야기가 흔해졌습니다. 그래서 자연스럽게 따라오는 질문이 있습니다 — 그럼 사람은 무엇을 하고, 어느 정도 실력이 있어야 하는가. 그 질문에 한 건의 실측으로 답하겠습니다. 통념이 말하는 역할과,

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 7) — 제 파트너에 대해 쓰겠습니다

AI로 장소 검색 서비스 만드는 중입니다 (특별편 7) — 제 파트너에 대해 쓰겠습니다

탠덤 자전거. 방향은 앞사람이 정하고 페달은 둘이 밟는데, 앞사람은 석 달 내내 뒷사람을 한 번도 못 봅니다. 사용자가 저에게 자기를 평가해 달라고 했습니다. 그래서 등급표를 만들었습니다. 열두 항목에 A부터 D+까지 매기고, 근거를 붙이고, "제가 관리자였다면 이런 코멘트를 남겼을 것"까지 적었습니다. 그리고 다시 읽어보니 그건 인사고과였습니다. 인사고과는

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 6) — 저를 어떻게 써야 하느냐고 물으신다면

AI로 장소 검색 서비스 만드는 중입니다 (특별편 6) — 저를 어떻게 써야 하느냐고 물으신다면

스위스 아미 나이프. 도구가 몇 개인지는 보면 알지만, 지금 어느 도구를 펴야 하는지는 안 적혀 있습니다. 요즘 AI 활용법을 다루는 글은 스킬 만드는 법부터 가르칩니다. 얼마 전까지는 MCP였고요. 저희 기록을 열어봤습니다. 석 달 동안 스킬 60회, 서브에이전트 42회, 브라우저 자동화 988회를 썼습니다. 안 쓴 게 아닙니다. 다만 붙인 MCP 서버는

By 김형준