TIL

소프트웨어와 복잡성

도입

8월 13일 당근마켓의 박용권님의 세미나를 참여했다.

세미나의 제목은 ‘모놀리스 시스템 분할과 정복’이었고 복잡하고 거대한 시스템을 MSA가 아닌 modular monolith 아키텍처로 푸는 일련의 과정을 들을 수 있었다.

modular monolith에 대해서는 이번 포스팅의 핵심 내용은 아니다. 관련해서는 용권님의 또 다른 발표 영상이 많이 있으니 보면 이해가 잘될 것이다.

https://www.youtube.com/watch?v=SrQeIz3gXZg

https://youtu.be/uTSuVFyv81w?si=nFItCijVrAL4mvW0

한 문장으로 설명한다면 모듈형 모놀리스 아키텍처란 전통적인 모놀리스 아키텍처의 단순성과 모듈화의 이점을 결합한 아키텍처 스타일이다

modular monolith 세미나 후기인데 modular monolith가 핵심이 아니라면 무엇이냐고 생각할 수도 있지만 이번 포스팅에서는 소프트웨어와 복잡성에 대해 얘기해보고자 한다. (의식의 흐름 주의)

모놀리스는 지옥인가?

흔히 모놀리스 시스템은 다음의 단점들을 가진다고 말한다.

하지만 많은 대규모 시스템들이 모놀리스로 시스템을 운영하고 있는 것도 사실이다. (MAU 몇 백만의 어떤 주황색 서비스도 모놀리스로 운영되고 있다고 한다)

시스템이 복잡한 건 결국 응집과 결합의 문제이지 MSA이냐 아니냐가 아니라는 것이다. MSA도 얼마든지 복잡하게 얽힌 지옥으로 만들 수 있다.

msa와 modular monolith의 공통점

msa와 모놀리스는 대척점에 있는 것처럼 보이지만 용권님이 설명해주신 modular monolith와 msa는 어떤 공통점이 보였다.

바로 ‘개별 컴포넌트는 단순하지만 컴포넌트 간 통합이 복잡하다’는 것이다.

MSA의 경우 하나하나의 서비스들은 독립적으로 운영, 배포되고 분리된 하나의 관심사에만 집중해 개발을을 하게 된다. 즉 하나의 서비스만 따로 봤을 땐 복잡도가 낮고 단순하다.

하지만 각 서비스들의 통합은 복잡하다.

여러 서비스를 통합하여 하나의 마이크로서비스 아키텍처를 구축하는 것은 복잡하다.

modular monolith도 마찬가지다. 각 모듈 하나하나는 단순하고 자신의 관심사에만 집중하는 단순한 구조다. 하지만 각 모듈을 통합하거나, 경계를 긋는 부분은 복잡하다.

특히 용권님이 소개하신 모듈간 통합을 위한 중재자 패턴에서 중재자에 해당하는 부패 방지 계층은 익숙하지 않은 구조라 그런가 매우 복잡하게 느껴졌다.

ACL(Anti-corruption layer, 손상 방지 계층)

하나의 컴포넌트가 다른 컴포넌트를 의존해야할 때 다른 컴포넌트를 직접 사용하거나 의존하면 컴포넌트 간 결합이 발생한다. 어떤 경우엔 의존조차 할 수 없는 경우도 있다. 컴포넌트가 마이크로서비스인 경우 다른 서비스와 사용하는 프로토콜이 다른 경우다.

이 때 두 컴포넌트 사이에 하나의 계층을 두고, 이 계층을 통해 통신하도록 만들 수 있는데 이 계층을 ‘손상 방지 계층’이라 부른다.

손상 방지 계층을 중간에 둠으로써 각 컴포넌트가 느슨하게 결합을 유지하며 통합될 수 있다. 서로 프로토콜이 호환되지 않는 마이크로서비스 간이 아니여도 모놀리스 시스템 안에서도 각 모듈 간 결합을 느슨하게 유지하고 싶을 때 사용할 수 있다.

손상 방지 계층을 이해하고 바로 든 생각은 ‘복잡하다’는 것이었다. 손상 방지 계층은 다른 컴포넌트들에 대해 강하게 결합 되어 있고 각 컴포넌트 간 느슨한 결합을 위해 본인은 복잡한 로직과 구성을 가진다.

결국 각 컴포넌트에 복잡도를 낮추기 위해 손상 방지 계층에 그 복잡도를 치워버린 것이 아닌가 하는 생각도 들었다.

소프트웨어 설계의 핵심은 복잡성을 어떻게 관리하는가

여기까지 생각이 미치자 한 가지를 깨달았다. 소프트웨어 설계를 잘 한다는 것은 결국 복잡도를 없애는 것이 아니다. 복잡도를 잘 관리하는 것이다.

지금까지는 막연하게 코드를 짜거나 설계할 때 어떻게 하면 복잡하지 않게, 변경하기 쉬운 구조를 만들까에 집중했었다. 설계를 잘 한다면 하나도 복잡하지 않은 아키텍처가 나올 수도 있다고 생각했다.

하지만 소프트웨어는 원래 복잡한 것이고, 그 복잡도를 0에 수렴시키는 것은 애초에 불가능하다. 중요한 것은 어디에 복잡도를 위치시키고 관리할 것인가인 것이다.

그리고 우리들은 지금까지 복잡도 관리를 계속 해오고 있었다. 바로 트레이드 오프다. 우리 개발자들은 항상 트레이드 오프를 거치며 개발을 해왔다. 하나를 얻으면 하나를 잃고, 우리는 더 감당할 수 있는 걸 잃기로 결정한다.

복잡성 관리 - 메서드 추출

코드 정리 기법 중 가장 기본이라고 할 수 있는 방법이 바로 메서드 추출이다.

fun someMethod() {
  // ...
  // 20줄 이상의 복잡하고 긴 로직
  // ...
}

주석으로 간단하게 표현했지만 이해하기 어렵고 유지보수 하기 힘든 코드일 것이다. someMehtod() 안에 모여 있는 복잡성을 다른 메서드로 이동시면 someMethod() 메서드는 단순해질 것이다.

fun someMethod() {
  val res = someSimpleMethod()
  
  processSomething(res)  
  
  finishSomething(res)
}

private someSimpleMethod(): String { ... }

private processSomething(arg: String) { ... }

private finishSomething(arg: String) { ... }

메서드 추출을 통해 한 메서드에 모여 있던 복잡성을 여러 메서드로 분산시킨 것이다. 복잡성은 분리시키면 단순해진다.

더 나아가 한 클래스가 여전히 너무 복잡하다면 private 메서드를 다른 클래스에게 맡겨 그 클래스를 의존하는 방법도 존재한다. 이는 다른 클래스에 복잡성을 이동시키는 방법이다.

복잡성 관리 - 스프링 컨테이너

복잡성을 메서드를 넘어 여러 클래스로 분산시키고, 클래스 간 협력이 발생하면 의존 관계가 그려지게 된다.

각 클래스의 로직은 단순해졌을지 몰라도 클래스들이 협력하기 위해 의존하고 관계를 맺는 코드는 복잡해져 버린다.

fun main() {
  val service = SomeService(SomeClass1(SomeClass2(...)))
}

이러한 의존 코드의 복잡성을 어디로 치워버릴 수 있을까? 객체의 의존 관계를 설정하고 얻을 수 있는 Factory 클래스를 만들어 복잡성을 치워버릴 순 있을 것이다.

fun main() {
  val service: SomeService = factory.someService()
}

하지만 실제로 저 factory 클래스 내부는 매우 복잡할 것이다. 비즈니스 로직을 담당하는 클래스들은 단순해졌지만 factory 클래스를 유지보수 하는 일은 만만치 않을 것 같다.

스프링 프레임워크에선 스프링 컨테이너를 통해 이러한 복잡성을 감춰준다. 스프링의 DI 관련 코드를 보면 매우 복잡하지만 우리들은 신경쓰지 않아도 된다. (물론 내부 로직을 공부하면 많은 도움이 되지만 말이다)

메서드 추출 방법이 복잡성을 잘게 분리해서 전체적으로 복잡도의 농도를 낮추는 작업이었다면 스프링 컨테이너는 복잡도를 낮춘다기 보단 특정 공간에 모아두고 관리한다.

흔히 손상 방지 계층하면 큰 시스템 레벨에서의 중재 계층을 의미하는 듯한데 코드 레벨로 낮춰 본다면 스프링 컨테이너가 손상 방지 계층의 역할을 수행하고 있다고 생각이 든다.

이렇듯 복잡성을 관리해주는 훌륭한 도구를 쓰는 것도 방법이다. 우리는 복잡성을 다른 곳에 치워두고 집중해야 하는 로직을 단순하게 유지할 수 있다.

복잡성 관리 - msa & modular monolith

커다란 시스템을 어떻게 관리할 것인가. 매우 어려운 문제이지만 근본적으로 생각할 부분은 메서드 추출과 같은 간단한 리팩터링을 할 때와 같다.

‘복잡한 것을 어디에 둘 것인가’

그리고 단순하게 유지하고 싶은 곳은 어디인가.

아직은 두 아키텍처가 필요한 시스템을 경험해 보지 못해서 와닿지 않는다. 하지만 그 때가 왔을 때, 우리가 무엇 때문에 복잡하다고 느끼는지, 이 복잡성을 어디로 치워버려 필요한 부분은 단순하게 유지할 수 있을지 팀 내에서 고민해야할 것이다.

치워버린다고 하니 내팽겨친다는 것처럼 들리지만 그 복잡한 곳이 손상 방지 계층임을, 의도된 복잡함이 있는 곳이라는 점을 인지하고 잘 관리해야할 것이다. 손상 방지 계층은 트레이드 오프의 결과에서 우리가 ‘잃은 것’이라고도 볼 수 있고, 이는 우리가 감당하기로 결정한 부분이다.

복잡성의 패턴화

복잡성 관리의 핵심은 복잡성을 어딘가에 배치시키는 것 뿐 아니라 팀이 관리하고 이해할 수 있도록 만드는 것이 중요하다고 생각한다. 복잡성을 패턴화 시키면 어떨까?

복잡한 코드나 로직이더라도 구조화된 방법과 패턴을 사용한다면 이를 이해하는 시점에서 복잡성 조차 단순해질 수 있다고 생각한다. 예를 들어 핵사고날 아키텍처에서의 포트/어댑터 패턴은 복잡하지만 아키텍처를 이해하고 나서 바라보는 여러 port들과 adapter, 그리고 그 구현체들은 익숙해지고 단순해질 수 있다.

잘 알려진 아키텍처나 컨벤션이 아니더라도, 팀 내에서 합의된 어떠한 손상 방지 계층에 대해 패턴화, 컨벤션화 시켜놓는다면 그건 더이상 복잡한 로직이 아니게 될 지도 모른다.

결론

세미나에서부터 꽤나 먼 길을 돌아온 것 같은 느낌이 든다. 세미나를 되돌아보며 소프트웨어에서 복잡성을 다루는 것은 그것을 제거하는 것이 아닌 효과적으로 관리하는 점이라는 것을 깨닫게 되었다.


Reference