Dev

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

prostars 2022. 4. 16. 12:51

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

 

요즘 라이브 서비스의 레거시 코드 리팩터링을 하고 있다. 흔히 가변 상태를 관리하는 Context 클래스가 레거시 코드에 있는 건 새삼스럽지 않았지만, 과도하게 사용하고 있어서 정리가 필요했다.

 

가변 상태 Context 사용 시 문제점

가변 상태를 가지는 Context 클래스가 2, 3개도 도 아니고 10개쯤 되면 과하다고 생각한다. 이렇게 많은 Context 클래스들이 서로 물고 물리는 종속성을 가지고 각기 다른 클래스에 넘기고, 넘겨받고, 가변 Context의 레퍼런스가 다양한 함수들로 넘겨져 전역 변수처럼 여기저기서 사용되면서 어딘가에서 A가 set을 하고 다른 곳에서는 B가 get을 하는 상황은 코드를 매우 읽기 어렵게 만들었다. 읽기 어렵다는 것은 Context를 수정할 때, 사용처를 모두 추적하는 것이 어렵고, 문제 발생 시 디버깅 역시 어렵다는 것이다. 

 

다른 것보다 이걸 먼저 해결하기로 했다. 이 Context 클래스를 정리하는 리팩터링을 점진적으로 진행하여 총 10개에서 6개를 삭제하고 2개는 사이즈를 많이 줄였다. 프로덕션 코드에서 총 568라인을 삭제했다. 당연히 기능에 변화는 없다. Context를 정리하는 리팩터링이 일단락되어 내용을 정리한다. 여기서 프로덕션 코드를 가지고 이야기를 할 수는 없으니, 어떤 상황에서 어떻게 수정했는지에 대한 이야기를 비슷한 정황으로 3가지만 간단한 슈도 코드 예제를 가지고 정리한다. 실제 프로덕션 코드에서 이와 같은 상황은 주로 레거시 코드에 존재하고 결합도가 예제보다 매우 높지만, 아래는 설명을 위한 예시이니 단순화했다. 하지만, 실무에서는 코드를 따라가며 각 종속 관계를 확인하고 정리할 내용을 확인하는 시간이 코드를 수정하는 시간보다 더 많이 들 것이다.

 

거창하고 화려한 내용은 없다. 기본적인 내용만 지켜도 클래스의 책임은 작게, 결합도는 낮게 유지하면서 테스트도 쉬운 코드를 만들 수 있다.

예제로 사용할 Context 클래스다. 하나다. 여기서까지 10개를 보고 싶지는 않다. 예제에서 방어, 예외 처리는 모두 생략했고, 슈도 코드다. 컴파일되지 않는다.

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

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

 

함수가 Context 객체에 뭔가 넣어주는 목적으로 Context 객체를 통으로 받지 말고, 리턴으로 처리하자.

편하다고 통으로 받으면 결합도만 높고 유지 보수하기 어려운 코드가 만들어진다. 함수에서 만드는 새로운 값 또는 상태를 아래와 같이 외부 객체에 직접 적용하는 것은 딱히 좋지 않다. 가능하면 리턴으로 받아서 처리하자.

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

큰 수정 없이 CarContext의 의존성은 아래처럼 제거할 수 있다. Pair 리턴도 아름답지는 않지만, Context에 직접 넣는 것보다는 낫다. 리턴 받을 내용이 많아서 Pair로 해결이 안 된다면, data class 형식을 생각해볼 수도 있겠지만 그보다는 함수를 나누는 것을 먼저 검토하는 것이 좋겠다.

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

 

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

함수를 사용하는 입장에서 Context 객체를 통으로 받는 함수가 Context 객체의 어떤 내용에 접근하는지, 뭘 수정하는지 알 수 없다. 특히, Context가 불변 클래스가 아니라면 더더욱 불안하다.

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

위와 같이 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();
    ...
  }
}

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

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);  
    ...    
  }
}

이제 doSomethingForVisitors()에서 사용하지 않는 CarContext 파라미터를 삭제하고, 불필요해진 CarContext::visitors와 get/set 메서드를 삭제한다.

 

단순한 내용을 길게도 정리했다. 요약하면, Context 클래스를 전역 변수 저장소처럼 사용하지 말고, 되도록 Context 객체를 통으로 함수에 전달해서 사용하지 말자.

반응형