카카오톡 Java App Server Refactoring 후기

안녕하세요, 카카오톡 메시징 파트에서 메시징 서버를 개발하고 있는 Soo입니다. 취미가 직업이 된 지 어느덧 8,000일이 넘어가고 있는 개발자입니다. 2019년 말에 톡 메시징 파트에 합류하여 기술 부채를 정리하는 데 관심이 많아, 제 업무 중 내부 코드의 개선 작업의 비중이 높은 편입니다. 또한, 저는 평소에 유지 보수가 용이하고, 테스트하기 쉬운 코드를 구현하는데 관심이 많았습니다. 최근에는 카카오톡 라이브 서비스에 사용하는 Java App Server에 있는 레거시 코드를 리팩토링 하는 업무를 담당했고, 어렵고 복잡한 코드를 성능 저하 없이 유지 보수와 테스트가 쉬운 코드로 만들기 위해 다양한 방법을 사용해 보았습니다. 그렇게 리팩토링을 한 결과 라이브 서비스의 성능 저하 없이 유지 보수와 테스트가 쉬운 코드로 변경하는 작업을 진행할 수 있었는데요, 이 글에서는 제가 레거시 코드에서 흔히 찾아볼 수 있는 오래된 세월의 흔적으로 많아진 가변 Context 클래스들, 복잡하게 물고 물리는 의존성, 그리고 높아진 코드 복잡도를 어떻게 정리했는지 예제와 함께 설명드리고자 합니다.

가변 Context 클래스는 신중하게 사용하자

최근에 저는 주로 라이브 서비스에 사용하는 레거시 코드를 리팩토링 하는 업무를 담당하고 있습니다. 레거시 코드를 분석해 보니, 가변 상태를 관리하는 Context 클래스를 과도하게 사용하고 있었습니다. 이렇게 많은 수의 가변 Context 클래스를 사용하면 유지, 보수 측면에서 문제를 일으킬 수 있기 때문에 정리가 필요한 상황이었습니다.

가변 상태 Context 사용 시 문제점

가변 상태를 가지는 Context 클래스가 2, 3개도 아닌 10개가 넘어가게 되면 유지, 보수 측면에서 문제가 될 수 있다고 생각합니다. 필요 이상으로 많은 Context 클래스들이 서로 물고 물리는 종속성을 가지면, 각기 다른 클래스들이 서로 변수를 넘겨주고 넘겨받는 상황이 일어납니다. 이때, 가변 Context의 레퍼런스가 다양한 함수로 전달되면서 전역 변수처럼 사용되게 되고, 이 가변 Context를 어딘가에서 A가 set을 하고, 다른 곳에서는 B가 get을 하는 상황이 발생하게 됩니다. 이런 상태에서는 코드를 읽고 동작을 이해하는 게 어려워집니다. 결국 Context를 수정해야 하는 상황이 오면, Context를 사용하는 모든 사용처를 추적하기가 어려워서, 코드를 수정하는 게 어려워지게 되고, 운영 과정에서 문제가 발생하면 디버깅도 역시 어려워진다는 문제가 생깁니다.

다른 것보다 시급했던 이 문제를 먼저 해결하기로 결정하였습니다. Context 클래스를 정리하는 리팩토링을 점진적으로 진행하여, 전체에서 사용하고 있던 10개의 Context 클래스를 현재 3개로 줄일 수 있었습니다. 코드 측면에서 볼 때, 수백 라인의 코드를 삭제하였지만, 그 기능은 동일하게 유지할 수 있었습니다.

Context를 정리하는 리팩터링 과정을 보다 상세하게 설명드리고 싶지만, 아쉽게도 사내 프로덕션 코드를 예시로 보여드릴 수 는 없습니다. 그래서, 어떠한 상황에서 Context를 어떻게 수정했는지에 대한 이해를 높이기 위해, 비슷한 상황을 담은 3가지의 간단한 슈도코드 예제를 가지고 정리해 보겠습니다. 현업에서 레거시 코드를 많이 다루었던 분들이라면 아시겠지만, 실제 프로덕션 코드에서 Context가 과도하게 사용되는 상황은 주로 레거시 코드에서 마주치기 쉽고, 결합도가 예제보다 매우 높은 것이 일반적입니다. 실무에서 다루는 레거시 코드는 굉장히 복잡해서  코드를 따라가며 각 종속 관계를 확인하고 정리할 내용을 확인하는 시간이, 코드를 수정하는 시간보다 더 많이 들 정도인 경우도 있습니다. 이 글에서는  Context 클래스를 수정하는 방법을 좀 더 이해하기 쉽도록 단순화한 예시와 함께 설명드리겠습니다.

다음은 예제로 사용할 Context 클래스입니다. 제가 리팩토링을 진행하였던 10개 Context 모두에 대한 예시를 들 수는 없으므로, 아래와 같이 방어 로직 및 예외 처리가 생략된, 컴파일되지 않는 슈도코드를 가져왔습니다.

				
					class CarContext {
  private Car car;
  private List<People> passengers;
  private List<People> visitors;
  private SomethingBig somethingBig
  private BlahBlah
  ... 많은 필드들

  ... 생성자
  ... 다양한 get/set 들
}
				
			

함수가 Context 객체에 set을 목적으로 Context 객체를 파라미터로 받지 말고 리턴으로 처리하자

코드 작성 시에 구현이 편하다는 이유로 Context를 객체를 파라미터로 받으면, 결합도가 높아지고 유지 보수하기 어려운 코드가 만들어집니다. 아래와 같이, 함수 내부에서 만드는 새로운 값 또는 상태를 함수의 안에서 외부 객체의 상태 변경에 직접 적용하는 것은 좋지 않습니다. 가능하면 리턴으로 받아서 처리하는 방식이 좋습니다.

				
					Result createCar(CarContext carContext, Something A, Something B) {
  ...
  carContext.setCar(new Car());
  ...
  return new Result(someValue);
}

				
			

그러므로, 아래처럼 CarContext의 의존성을 코드의 큰 변경 없이 제거할 수 있습니다. Pair 리턴 또한 아름답게 구현되지는 않았지만, Context를 함수 내부에서 직접 set을 하는 것보다는 좋은 구현 방법입니다. 함수에서 리턴 받을 내용이 많아서 Pair로 해결이 안 된다면, data class 형식을 생각해 볼 수도 있겠지만, 그보다는 함수를 기능별로 좀 더 세분화하는 것을 먼저 검토하는 것이 좋겠습니다.

				
					Pair<Result, Car> createCar(Something A, Something B) {
  ...
  return new Pair<>(new Result(someValue), new Car());
}

				
			

함수에서 값을 받을 때 편하다고 Context 객체를 파라미터로 받지 말고, 필요한 것만 명시적으로 받자

함수를 사용하는 입장에서는, Context 객체를 파라미터로 받는 함수가 Context 객체의 어떤 내용에 접근하는지, 혹은 무엇을 수정하는지 알 수 없습니다. 특히, Context가 불변 클래스가 아니라면 함수를 사용할 때  Context에 영향을 미칠 수도 있기 때문에 더더욱 불안합니다.

				
					void doSomethingForPassengers(CarContext carContext, Something A, Something B) {  
  List<People> passengers = carContext.getPassengers();
  SomeValue someValue = carContext.getSomeValue();
  …
}

				
			

이를 보완하기 위해서는, 위와 같이 Context를 파라미터로 받아서 꺼내 사용하는 방법 대신, 아래와 같이 Context에서 필요한 것만 명시적으로 받는 방법이 좋습니다. 이렇게 하면, 함수의 의도가 명확해지고, 코드 간 결합도는 줄어들게 됩니다. 이런 방법을 사용했을 때는 파라미터 개수가 계속 증가하는 것 같아서 마음이 불편할 수 있습니다. 그럴 경우에는 일단 외부 종속을 끊고, 함수를 더 작은 책임 단위로 나누어 구현하는 것을 검토하는 게 좋습니다. 함수는 한 번에 하나의 목적만을 수행하는 게 좋기 때문입니다.

				
					void doSomethingForPassengers(List<People> passengers, SomeValue someValue, Something A, Something B) {
  ...
}

				
			

루프 최적화를 위해서 캐싱하고 싶다면 Context에 넣지 말고, 루프 밖으로 뺄 수 있는지부터 보자

아래의 복잡한 코드는 DB에서 읽은 값을 메모리에 올려놓고 계속해서 사용하려는 의도에서 구현되었습니다. 의도는 좋지만, 다른 방법으로 구현하는 것이 좋습니다.

				
					void prepareVisitors(CarContext carContext, Condition condition) {
  if (carContext.getVisitors() == null) {
    // 메모리에 올리기 전이면 DB에서 읽는다.
    carContext.setVisitors(readFromDB(condition));
  }
}

void doSomeProcess(CarContext carContext, Condition condition) {
  ...
  prepareVisitors(CarContext carContext, Condition condition);
  ...
  List<People> visitors = carContext.getVisitors()
  ...
}

// CarContext carContext는 새로 생성되어서 넘어온다. 즉, CarContext::visitors는 비어있으니 DB에서 읽어서 Context에 넣고 재사용한다.
void doSomethingForVisitors(CarContext carContext, List<Something> somethings, Condition condition) { 
  for (Something something : somethings) {
    doSomeProcess(carContext, condition); 
    ...
    List<People> visitors = carContext.getVisitors();
    ...
  }
}
				
			

위와 같은 흐름에서 prepareVisitors() 함수에서 구현된 것처럼, DB에서 읽은 값을 메모리에 올려놓고 사용하려고 Context에 캐싱 책임을 추가하는 것보다는 해당 부분을 반복 호출 밖의 지역 변수로 꺼내는 것을 먼저 검토하는 것이 좋습니다. 만약, 꺼낼 수 없는 상황이라도 캐싱을 위한 변수를 Context에 넣지 말고 다른 클래스를 만듭시다.

이제 다음과 같이 doSomethingForVisitors()에서 사용하지 않는 CarContext 파라미터를 삭제하고, 불필요해진 CarContext::visitorsget, set 메서드를 삭제하여 리팩토링 합니다.

				
					public void doSomeProcess(List<People> visitors) {
  ...
}

public void doSomethingForVisitors(List<Something> somethings, Condition condition) { 
  List<People> visitors = readFromDB(condition);

  for (Something something : somethings) {
    doSomeProcess(visitors);  
    ...    
  }
}

				
			

단순한 내용을 길게 정리하게 되었습니다. 요약하면 다음의 2가지를 주의하여 Context를 사용하면 좋습니다.

  • Context 클래스를 전역 변수 저장소처럼 사용하지 않습니다.
  • 되도록 Context 객체를 파라미터로 함수에 전달해서 사용하지 않습니다.

고차 함수로 의존성 줄이기

스프링을 사용한 프로젝트에서 종종 다음 2가지의 상황을 마주할 수 있습니다. 첫째, 어노테이션에 의한 의존성 주입 남용입니다. 둘째, 오랜 세월의 흐름으로 의도치 않게 서비스 간의 의존성이 복잡하게 강한 결합(Tight Coupling)으로 묶이면서, 코드를 읽기도 어렵고 단위 테스트를 구성하기도 어려운 상황이 생깁니다.

아래는 제가 작업했었던 카카오톡 Java App Server 서비스의 의존성 그래프입니다. 왼쪽의 그림처럼, 복잡했던 기존의 순환 종속성(Circular Dependencies)을 가지는 의존성 그래프를 오른쪽의 단순한 의존성 그래프로 리팩토링 하여, 라이브 서비스에 반영하였습니다. 이번 장에서는 오랜 세월의 흐름으로 서비스 의존성 그래프가 복잡해진 라이브 서비스를 리팩토링 한 내용을, 작은 예제들을 통해 일반화하여 정리해 보겠습니다.

이번 장을 이해하려면 자바의 함수형 인터페이스에 대한 지식이 필요합니다. (참고: Functional Interfaces in Java 8)

이번 장에서 사용하는 예제는 BeforeRefactoring과 AfterRefactoring, 2개의 프로젝트로 나누어서 구성했고, 전체 코드는 여기에서 확인할 수 있습니다.

제가 스프링을 사용하지 않고 예제를 구성하려 해 봤지만, 스프링을 사용하지 않고서는 아래에서 보게 될 순환 종속성을 만들기 쉽지 않았습니다. 심지어, 예제로 만든 BeforeRefactoring 프로젝트는 스프링 부트 2.6 버전 기준에서 순환 종속성을 가진다는 이유로 프로젝트 실행이 거부됩니다. 예제 프로젝트를 실행하려면 application.properties 파일에 아래 설정을 추가합니다. 구체적으로 어떤 에러인지 궁금하시면, 아래 설정을 삭제하고 실행해 보면 확인해 볼 수 있습니다. 참고로, 생성자 주입(Constructor Injection)을 사용하면, 아래 설정을 추가해도 순환 종속성 에러로 실행되지 않습니다. 대신 필드 주입(Field Injection)을 사용하면 리플렉션(Reflection) API를 사용하기 때문에 에러가 발생하지 않습니다. 그래서 이 예제에서는 필드 주입을 사용합니다.

				
					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() 메서드에서 컴파일 에러가 발생할 것입니다. 이 Handler 메서드 부분도 아래 코드에서처럼, 오류가 발생하지 않도록 수정해 주도록 합니다.

				
					@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로 옮겨오고, 생성자 인젝션(Constructor Injection)으로 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)에 의존하여, 객체를 목킹(Mocking)해서 테스트를 구성해야 합니다.

				
					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을 이용해 Service 객체들을 바로 생성할 수 있습니다. 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를 최적화하면 아래와 같이 성능이 달라지는 것을 볼 수 있습니다.

약간의 성능 차이는 여전히 발생하고 있지만 많이 줄어든 것을 확인할 수 있습니다. 글의 시작에서 언급 드렸듯이, 리팩토링한 코드를 라이브 서비스에 반영한 후에도 운영상의 성능 저하는 발생하지 않았습니다. 독자분들이 의존성을 정리하는 데 해당 내용이 도움이 되길 바랍니다.

코드 복잡도 줄이기 (Cyclomatic Complexity, NPath Complexity)

이번 장에서는 실제 백엔드 서비스 코드를 리팩토링 했을 때 적용한 내용을 바탕으로, 코드 복잡도를 줄인 리팩토링 방법 대한 내용을 정리하겠습니다.

위에서 언급드렸던 ‘가변 Context 클래스는 신중하게 사용하기’‘고차 함수로 의존성 줄이기’ 등의 방법을 적용하면, 코드 내의 의존성 문제들이 많이 해소된 상태이기 때문에 본격적으로 복잡도를 줄일 수 있게 됩니다.

아래는 어떤 백엔드 서비스 코드의 리팩토링 전과 후의 코드 복잡도 Cyclomatic Complexity와 NPath Complexity의 수치 변화입니다. 기존 대비 복잡도가 많이 줄어든 것을 확인할 수 있습니다.

실제로 작업했던 코드를 지면에 소개할 수는 없으니, 조금은 억지스러운 예제를 일반화해서 내용을 정리하겠습니다.

내용에서 다루는 전체 코드는 여기에서 확인하실 수 있습니다.

코드 복잡도

코드 복잡도를 수치로 계산하는 방법들이 많이 있겠지만, 그중에서 Cyclomatic Complexity(이하 CC)와 NPath Complexity(이하 NPath)를 자세한 공식 등은 생략하고 골격만 간단히 소개하겠습니다. 더 자세한 내용은 아래를 참고하시기 바랍니다.

사용한 공식은 아래와 같이 골격만 간단히 정리할 수 있습니다. 물론, 위의 세부적인 계산식과 다른 수치가 나올 수 있지만, 큰 오차 없이 가볍게 코드의 상태를 가늠할 수 있습니다. CC는 함수에 제어문(분기, 루프 등)이 없다면 1점, 있다면 제어문마다 1점을 부여합니다. 또한, 조건식 안의 논리식도 1점으로 계산하여 각각의 점수를 모두 더합니다. NPath는 코드를 실행할 수 있는 비순환 경로의 수를 의미합니다. 설명이 어렵지만, 간단하게는 분기마다 2점을 부여하고 각 점수를 곱하는 방식으로 계산합니다. 2점이 아닌 케이스도 있는데 if / else if 조합은 실행할 수 있는 경로의 수가 3가지이므로 3으로 계산됩니다.

IntelliJ를 사용한다면 Complexity reducer Plugin을 설치하면 아래와 같이 CC와 NPath를 계산해서 보여줍니다. 다만, 해당 플러그인의 최신 버전인 0.1.7은 IntelliJ 213.* 버전까지만 지원하기 때문에 최신 버전의 IntelliJ에서는 동작하지 않는다는 점은 참고해 주시기 바랍니다.

java/util/regex/Matcher.java in adoptopenjdk-11

위 코드를 보면, CC는 5점(reset + for + for + for + if), NPath는 12점(for * for * (for + if))으로 계산됩니다. 여기서 3점이 약간 의아한 계산일 수 있습니다. for/if 조합의 실행 경로는 for를 안 타는 경우, for를 타고 if를 안 타는 경우, for와 if 모두 타는 경우의 3갈래이기 때문에 3점으로 계산됩니다.

이 내용을 대략 이해하고 아래 예제 코드를 보도록 하겠습니다.

				
					public Data buildData(boolean isConditionA, boolean isConditionB, boolean isConditionC, String extraCondition) {
    int someValue;
    if (isConditionA) {
        someValue = 10;
    }
    else {
        if (extraCondition.equals("ForceB") || isConditionB) {
            someValue = 20;
        } else {
            if (isConditionC) {
                someValue = 30;
            } else {
                someValue = 40;
            }
        }
    }

    Data data = new Data();
    data.setA(someValue + 1);
    if (someValue == 30) {
        data.setB(someValue + 2);
    } else {
        data.setB(someValue + 4);
    }
    data.setC(someValue + 3);
    return data;
}

				
			

위의 buildData() 함수는 CC가 6, NPath가 8점입니다. 높은 수치는 아니지만, 예제로 더 복잡하게 만들기도 어려우니, 이 함수의 복잡도를 줄여보겠습니다. 코드의 복잡도를 줄이는 건 반드시 특별할 필요가 없습니다. 기본은 동일합니다. 하나의 함수에 많은 코드가 있다는 건, 코드 내부에서 필요 이상의 책임을 가지고 있다는 것입니다. 함수가 현재 가지고 있는 많은 책임을 더 작은 단위의 책임으로 나눈 뒤, 각 함수가 각 1개의 책임만 담당하도록 한다면, 복잡도 역시 각 함수가 나누어 가지게 됩니다. 그러면 이후에 추가되는 개별 함수들의 복잡도 또한 낮아지게 됩니다.

함수 추출하기

함수 추출하기(Extract Function)는 리팩터링 도서에 다양한 추출 케이스에 대해서 자세하게 설명하고 있습니다.

위의 예제 코드에서, buildData() 함수가 크게 2가지 책임을 가지고 있는 것을 확인할 수 있습니다. someValue의 값을 구하고, Data 객체를 생성하는 역할입니다. 이 2개의 책임을 각각의 함수로 추출하겠습니다.

먼저 someValue의 값을 구하는 부분을 아래와 같이 getSomeValue() 함수로 추출합니다.

				
					private int getSomeValue(boolean isConditionA, boolean isConditionB, boolean isConditionC, String extraCondition) {
    int someValue;
    if (isConditionA) {
        someValue = 10;
    }
    else {
        if (extraCondition.equals("ForceB") || isConditionB) {
            someValue = 20;
        } else {
            if (isConditionC) {
                someValue = 30;
            } else {
                someValue = 40;
            }
        }
    }
    return someValue;
}

				
			

이로써, getSomeValue() 함수는 CC가 5, NPath가 4인 함수가 되었습니다. 그리고, 이어서 Data 객체를 생성하는 부분도 아래와 같이 makeData() 함수로 추출합니다.

				
					private Data makeData(int base) {
    Data data = new Data();
    data.setA(base + 1);
    if (base == 30) {
        data.setB(base + 2);
    } else {
        data.setB(base + 4);
    }
    data.setC(base + 3);
    return data;
}

				
			

여기서 makeData() 함수는 계산에 의하면 CC가 2, NPath가 2인 함수입니다.

이렇게 buildData() 함수에 몰려있던 코드를 getSomeValue(), makeData() 2개의 함수로 모두 추출하여, 책임과 복잡도는 2개의 함수가 나누어 가져갔고, buildData() 함수는 아래와 같이 단출해지면서 코드 복잡도라고 수치로 뽑을 것이 남지 않게 되었습니다.

				
					public Data buildData(boolean isConditionA, boolean isConditionB, boolean isConditionC, String extraCondition) {
	return makeData(getSomeValue(isConditionA, isConditionB, isConditionC, extraCondition));
}
				
			

중첩 조건문을 보호 구문으로 바꾸기

중첩 조건문을 보호 구문으로 바꾸기(Replace Nested Conditional with Guard Clauses)는 리팩터링 도서에 다양한 케이스에 대해서 자세히 설명하고 있습니다.

막상 getSomeValue() 함수를 추출하고 보니, 추가로 코드를 더 정리할 수 있을 것 같습니다. 먼저 someValue를 제거하도록 하겠습니다.

				
					private int getSomeValue(boolean isConditionA, boolean isConditionB, boolean isConditionC, String extraCondition) {
    if (isConditionA) {
        return 10;
    }
    else {
        if (extraCondition.equals("ForceB") || isConditionB) {
            return 20;
        } else {
            if (isConditionC) {
                return 30;
            } else {
                return 40;
            }
        }
    }
}

				
			

someValue를 제거하고 모두 return 문으로 치환하고 보니, 중첩된 if 문들을 정리할 수 있을 것 같습니다.

				
					private int getSomeValue(boolean isConditionA, boolean isConditionB, boolean isConditionC, String extraCondition) {
    if (isConditionA) {
        return 10;
    }
    if (extraCondition.equals("ForceB") || isConditionB) {
        return 20;
    }
    if (isConditionC) {
        return 30;
    }    
    return 40;
}

				
			

코드가 한결 보기 편해졌습니다. 혹 Complexity reducer Plugin을 활성화해 두고 리팩토링 작업을 따라왔다면 중첩된 if 문들을 정리하면서 getSomeValue() 함수의 NPath가 4에서 8로 2배가 증가한 것을 확인할 수 있었을 것입니다. 코드의 로직은 동일하고 가독성도 좋아졌는데 오히려 코드 복잡도를 나타내는 수치가 증가했습니다.

그 이유는 첫 번째 if 문의 조건이 만족하면 바로 return 하여 함수를 나오기 때문에, 두 번째 if 문을 타지 않는다는 것을 계산에 포함하지 않고 나온 수치라서 그렇습니다. 계산 수치를 개선하기 위해 다시금 아래와 같이 수정해 보겠습니다.

				
					private int getSomeValue(boolean isConditionA, boolean isConditionB, boolean isConditionC, String extraCondition) {
    if (isConditionA) {
        return 10;
    }
    else if (extraCondition.equals("ForceB") || isConditionB) {
        return 20;
    }
    else if (isConditionC) {
        return 30;
    }
    return 40;
}

				
			

코드에서 명확하게 첫 번째 if 문의 조건이 만족할 경우,  두 번째 if 문을 타지 않는다고 조건문으로 명시하면 getSomeValue() 함수의 NPath가 다시 4로 감소하는 것을 확인할 수 있습니다.

이 케이스처럼, 코드 복잡도 수치가 실제 코드가 가진 복잡도보다 높게 나오는 케이스가 있습니다. 그렇기 때문에 각 함수의 적절한 가독성을 유지하는 선에서 코드 복잡도를 개선하는 수위를 조율할 필요성이 있습니다.

마무리

지금까지 카카오톡 Java App Server를 리팩토링 하면서 사용했던 방법과 느꼈던 점을 일반화한 예제와 함께 설명하고 공유드렸습니다. 라이브 서비스의 레거시 코드를 성능 저하 없이 리팩토링하는 작업은 항상 고민을 하게 만드는 일인 것 같습니다. 저는 앞서 설명드린바와 같이, ‘가변 Context 클래스를 신중하고 사용하기’, ‘고차 함수로 의존성 줄이기’ 과정을 적용해 복잡도를 줄이고, CC와 NPath 수치를 측정해 코드를 단순화 하는 방법을 사용했습니다. 제가 설명드린 이 과정이 현업에서 리팩토링 과정에 관심이 많으신 분이나, 오래된 레거시 코드를 유지 및 보수해야 하는 분들에게 많은 도움이 되었으면 좋겠습니다. 감사합니다.

카카오톡 공유 보내기 버튼

Latest Posts

제5회 Kakao Tech Meet에 초대합니다!

Kakao Tech Meet #5 트렌드와 경험 및 노하우를 자주, 지속적으로 공유하며 개발자 여러분과 함께 성장을 도모하고 긴밀한 네트워크를 형성하고자 합니다.  다섯 번째

테크밋 다시 달릴 준비!

(TMI: 이 글의 썸네일 이미지는 ChatGPT와 DALL・E로 제작했습니다. 🙂) 안녕하세요, Kakao Tech Meet(이하 테크밋)을 함께 만들어가는 슈크림입니다. 작년 5월에 테크밋을 처음 시작하고,