개발 업계에서 MSA 라는 용어가 화제가 된 지 꽤 시간이 지났다. MSA는 마이크로 서비스 아키텍처의 약자이며 쉽게 말해, 서버를 잘게 쪼개가지고 애플리케이션을 구성하는 접근방식이다. 어디까지 쪼개야 MSA라고 불러야 될지 모르지만, 필자는 모놀리틱한 서비스 구성에서 일해보기도 했고, 이를 다시 여러개의 분리된 역할의 서버로 쪼개는 경험도 해 봤다. MSA의 장점에 대해서 소개하는 글들은 많지만, 실제로 이를 행했을 때의 어려움과 단점에 대해서 얘기하는 글은 잘 보이지 않는다. 이는 그 과정에서 필자가 실제적으로 와닿은 힘든 점들을 써보려고 한다. 아마도 이러한 하지마라 시리즈로 계속해서 글을 연재해나갈 생각이긴 하다. (생각만..) 지금 당장 떠오르는 것들은..
뭐 요 정도 있다. 하지만 오해는 하지마라, 실제로 나는 이 중 대다수를 운영레벨에서 쓰고 있는 회사에서 일하고 있고, 제목도 어그로성이다. 다만 짧은 개발자 경력동안 느낀 것은, 기술이란 것은 결국 특정상황에서 더 유용한 기술이 있을 수 있지만, 모든 상황에서 정답인 기술이나 아키텍처는 없을 뿐더러, 해당 기술들은 명백히 특정 상황, 문맥에서 더 유용하다고 생각할 뿐이다. 암튼 이야기 시작하겠다.
단일 서버로 운영했을 때와 달리 여러개의 서버로 분리한 아키텍처를 가지게 되었을 때, 거기에 해당하는 문제점이 튀어나오기 마련이다. 당장 분리된 서버들끼리 통신할 때, 어떤 프로토콜을 사용할지부터가 고민이다. 범용적인 HTTP 프로토콜을 사용해야 하나? 아니면 중간에 메시지 큐 같은 서버를 하나 둬서 이용할 수 도 있겠다. AWS 같은 클라우드 위에 구축되어있다면, AWS SQS나 SNS같은 벤더 종속적인 서비스도 고려대상이다. 여기서는 가장 범용적이라고 판단되는 HTTP를 이용하고 HTTP Client로서 Spring webclient를 예시로 들겠다.
분산 서버 아키텍처를 다룰 때, 필자가 생각하기에 가장 까다로운 것은 트랜잭션 처리이다.
예를 들어 서버 A와 서버 B가 있다고 치자.
서버 A에는 spring webclient를 통해 서버 B를 호출하고 있으며 @Transactional 어노테이션을 통해 선언적으로 트랜잭션을 관리하고 있다.
만약 서버 B에서 실패가 떨어졌을 시, 서버 A에서 실행했던 결과들도 롤백시키고 싶다면?
롤백이 될까? 결론적으로 안 된다. @Transactional 어노테이션과 함께 Reactive 스트림을 사용할 때, 에러가 발생해도 스프링의 선언적 트랜잭션 관리는 subscribe 블록 내부에서 예외를 던져도 롤백되지 않는다. Reactive 프로그래밍 모델에서는 에러 핸들링이 다르게 동작하기 때문이다. 이럴경우, .block()을 통해 결과가 올 때까지 동기적으로 기다려야 된다.
이럴 경우, 정상적으로 @Transactional 이 동작한다. 그러나 외부 API 호출의 결과를 기다려야 다음 로직이 진행된다는 게 맘에 안 든다. 다른 방법이 없을까?
이럴 경우 @Transactional 대신 직접 TransactionManager를 주입받아서 좀 더 명시적으로 트랜잭션을 처리해줄 수도 있다.
이렇게 하면, 원하는 결과가 나온다. 그러나 원하는 결과가 나왔다는 거랑, 과연 좋은 해결방법이냐는 거는 다른 문제이다. 이런 식으로 처리했을 시 발생할 수 있는 단점은 뭐가 있을까.
비즈니스 로직에서, transaction 처리 코드가 명시적으로 끼어들면서, 코드의 가독성이 현저히 떨어진다.
같은 DB라면 상관없지만, 만약 MSA끼리 서로 다른 데이터베이스를 사용한다면 통하지 않는다.
만약 서버 B의 실행 결과가 오래 걸린다면?
doOnError 또는 doOnSuccess가 호출되어 트랜잭션이 커밋 또는 롤백되기 전까지, 시작된 트랜잭션은 열려있는 상태로 유지되게 된다. 결론적으로, 외부 API의 처리 시간동안 트랜잭션 커넥션이 물리고 있게 된다. 이러한 접근 방식은 트랜잭션 리소스를 오랫동안 점유하게 만들어, 시스템의 성능이나 다른 트랜잭션의 처리에 영향을 줄 수 있다.
이처럼 분리된 여러 서비스 간의 조합으로 비즈니스 로직이 처리될 때, 데이터의 일관성을 유지하는 것은 어려운 일이다. 네트워크 지연, 서비스 장애, 데이터 불일치 등의 이유로 트랜잭션이 부분적으로만 성공하는 상황이 발생할 때 쉽게 롤백을 시키기 어렵다.
그럴 경우, 실제로 트랜잭션을 롤백시키는 게 아닌, 논리적인 개념으로 롤백시킬 수 있게 따로 작업 단위를 선정할 수 있다. "보상 트랜잭션(Compensating Transaction)"의 개념을 사용하여 이미 커밋된 트랜잭션의 효과를 취소하거나 반대되는 작업을 수행함으로써 사실상의 "롤백"을 구현하는 거다.
그러면 위와 같이 고칠 수 있을 것이다. 장애 발생 시 복원력을 제공하기 위해 보상 로직의 정의와 관리를 추가적으로 작성해줌으로써 데이터 일관성을 챙길 수 있었다. 주의할 점은, 리액티브 영역에서는 이미 트랜잭션이 끝났으므로, 일반적인 JPA 메서드를 활용하려고 든다면, 트랜잭션이 필요하다는 에러메시지가 뜰 것이다. 이럴 경우, 이 부분에서 따로 트랜잭션을 새로 시작하다든가, Native Query를 그대로 실행하는 방안들이 있을 것이다. 그리고 통신하는 서버도 장애가 발생했을 시 반드시 에러를 명시적으로 응답해줘야 됨은 물론이다.
보상 트랜잭션을 구성하는데 드는 추가적인 개발 유지관리의 비용을 제외하고도 보상 트랜잭션 자체에서 에러가 발생할 수 도 있다. 이런 부분까지 고려해야 된다는 것은 그만큼 복잡성을 증가시킨다. SAGA 패턴이라든가 여러가지 아키텍처는 다 이러한 부분을 해결하기 위한 고민의 발버둥이다.
이번엔 조금 더 복잡한 상황을 가정해보자. 주문이 들어오면, 주문에 대해서 전처리를 하고, 주문 엔티티 휘하의 아이템들을 후처리하는 외부 API가 있다. 외부 API의 모든 작업결과가 성공일 때만 주문을 성공처리시키려고 한다.
보다시피 주문아이템들을 B 서버에게 넘겨준다. 순차적으로 실행할 필요가 없으므로, 병렬적으로 넘겨주고, 해당 B 서버의 결과가 모두 성공일 때의 케이스와, 일부 실패했을 때의 케이스를 분리했다. 여기서는 시나리오를 단순화해서 보여주고 있지만, 더 복잡해지고 해당 트랜잭션들의 순서가 중요할 경우, 분산 아키텍처에서 트랜잭션을 관리하는 것은 정말 많은 예외 케이스들을 염두에 둬야 한다.
또 하나의 커다란 난관은 통합 테스트의 어려움이다. 서버를 배포하기 전, 해당 비즈니스 로직이 잘 동작하는지 테스트를 해보기 마련이다. 테스트에도 여러가지 단계가 있다. 실제 개발서버에 새로운 버전의 서버를 배포한 뒤, 사람이 직접 테스트하는 인수 테스트도 있고, 그 전에 코드 레벨에서 로직을 검증하는 테스트도 있다. MSA 환경에서 통합 테스트 코드를 짜는 것은 모놀리틱한 구조에 비해서 훨씬 어렵다. 하나의 비즈니스 로직을 처리하기 위해 각 마이크로서비스가 독립적인 단위로 배포되고 운영되기에, 소스코드를 수정하고 실제로 테스트하기 위해서는, 거기에 관여하는 해당 마이크로 서비스들의 의존성이 모두 필요하다. 실제로 이를 원활히 구성하기가 몹시 힘들다. 때문에 이 대신 Service Mocking과 Stubbing을 사용하여 실제 마이크로서비스 대신 테스트 더블을 구현할 수 있다. 이를 통해 의존 서비스 없이 특정 서비스 또는 통합 포인트를 격리하여 테스트할 수 있지만, Mocking에 대해서는 언제나 필자는 회의적이다. 이미 결과를 정해놓고, 테스트를 하는 것이 무슨 의미인가. 결국 해당 서버 내에서의 로직을 테스트하는 것에 지나지 않는다. 이는 통합 테스트의 의미를 퇴색시키고 결국 단위 테스트에 불과하다는 의미와 같다.
운영 단계에서 발생할 수 있는 버그를 100% 걸러주는 사전 테스트란 원래도 존재하지 않는다는 것은 잘 알지만, MSA 환경에서 통합테스트의 유효성은 훨씬 떨어진다. Docker와 Kubernetes와 같은 컨테이너 툴을 활용해서, 실제 서비스와 유사한 Test Environment을 만들 수도 있지만, 구현 복잡성과 관리가 극도로 높아진다. 나는 이런 Test Environment을 구성하는 데 내 시간을 소모하기 싫다. 그동안 실제 프로덕트에 유용한 기능개발에 더 집중하고 싶다. 아마도 멋진 훌륭한 DevOps 팀이 있는 환경이라면, 이러한 점들이 별 이슈가 되지 않을 수 있지만, 모두가 그런 환경에 놓여있는 것은 아니다.
마지막으로 느낀 실질적인 어려움은 배포 난이도의 상승이다. 단순히 얘기해서, 관리해야 될 서버 대수가 늘어난다. 각각의 서비스는 독립적으로 운영되며 배포되므로, 각각의 서비스를 위한 별도의 배포 파이프라인 필요할 수도 있다는 것을 의미한다. 관리 포인트가 늘어난다는 것은 그 자체로 극심한 스트레스이다. 운영단계에서 안정적인 서비스를 유지할려면, 해당 어플리케이션에 대한 지속적인 모니터링이 꼭 필요한데, 서버가 늘어난다는 것은, 그 모든 서버의 지표들, (로그, 매트릭)을 한곳에 모아서 관리해야할 또 다른 서버가 필요하다는 의미와 같다. 흔히, CI/CD로 불리는 지속적 통합, 관리까지 거창하게 가고 싶지 않다. 단순히 로그파일들을 보는 데도, 해당 서비스가 배포되는 환경을 명확히 구분하고 인지해야 된다. 또한 해당 MSA들끼리 공유하는 모듈이 따로 있을 경우, 그 부분을 서로 일치시켜주지 않을 시 발생할 문제도 있다. 만일 단일 기술스택을 공유하고 있다면, 예를 들어 스프링 개발자라면 멀티 모듈 같은 서비스를 이용해, 공유 모듈을 따로 빼놓을 수 있지만, 같은 기술스택을 공유하지 않는다면, 이 또한 힘들다. 어디 하나를 수정했을 시, 다른 서비스는 그걸 인지하지 못해서 발생할 버그의 위험성도 높아진다는 것이다.
1: 확장성
서비스가 독립적으로 배포되므로, 특정 서비스에 대한 수요가 증가할 때 해당 서비스만을 확장할 수 있다. 여기에는 숨겨진 비용이 따른다. 서비스가 분산되어 있어 네트워크 지연 시간이 발생하고, 각 서비스 간의 통신 오버헤드가 증가한다. 시스템 전체의 복잡성은 확장성을 향상시키려는 원래의 목적을 방해하는 요소로 작용할 수 있다.
2: 기술 다양성
각 마이크로서비스가 서로 다른 기술 스택으로 구성될 수 있다는 점은, 기술 선택의 자유를 의미한다. 이론적으로는 각 팀이 자신의 작업에 가장 적합한 기술을 선택할 수 있다. 이론적으로는. 실제로는 이를 감당할 개발팀의 성숙도가 없는 경우, 기술 부채와 운영 복잡성을 증가시키는 주범이 되는 경우가 더 많다. 서로 다른 프로그래밍 언어, 데이터베이스, 도구를 사용하면, 시스템을 유지보수하고 모니터링하는 작업이 어려워진다. 결국, 이러한 "다양성"은 통합과 협업의 장벽이 된다.
3: 복원력
한 서비스의 실패가 전체 시스템의 다운타임으로 이어지지 않는다. 예를 들어, 결제 서버가 장애가 나도, 결제를 이용하지 않는 유저들은 원활하게 시스템을 이용할 수 있다. 그러나 이는 모든 서비스가 완벽하게 격리되어 있고, 모든 의존성이 적절히 관리될 때만 가능한 일이다. 서비스 간 의존성이 복잡하게 얽혀 있고, 한 서비스의 실패가 연쇄적으로 다른 서비스에 영향을 미치는 경우가 많다면 의미가 없는 이야기다. 복원력을 달성하기 위해선, 각 서비스 간의 인터페이스를 엄격하게 관리하고, 격리 수준을 높이는 추가 작업이 필요한데 이는 정말 쉽지 않은 일이다.
4: 빠른 개발
서비스가 독립적으로 배포될 수 있기 때문에, 새로운 기능을 빠르게 출시할 수 있다는 점. 역시 이것도 각 서비스의 인터페이스가 잘 정의되어 있고, 서비스 간의 의존성이 최소화될 때만 가능하다. 실제로는 서비스 간의 통합 테스트, API 버전 관리, 배포 파이프라인의 복잡성 등이 프로젝트의 진행 속도를 늦춘다.
결국 MSA가 내세우는 가장 큰 줄기는 느슨한 결합과 최소한의 의존성 이다. 이는 소프트웨어 개발원칙에서도 항상 강조하는 원칙 중 하나다. 그러나 느슨한 결합과 최소한의 의존성을 달성하기 위해서는 생각보다 고려해야 될 점이 훨씬 많으며, 때로는 강한 결합이 훨씬 더 유용한 경우가 있다.
말하자면, 특정 상황이 충족되지 않으면 쓰는 것보다 쓰지 않는 게 더 좋다는 얘기이다. 그리고 내가 절대 스타트업에서 일하고 있어서 그런 게 아니라, 해당 문제는 대부분의 스타트업에 해당할 것이다. 필자는 지금 소규모 스타트업에서 벡엔드 개발자로 일하고 있는데, 현재 개발 팀에 벡엔드 개발자가 나 포함 2명이다. 아니 지금은 3명으로 늘어나긴 했다. 그마저도 담당 서비스가 둘로 나눠져 있어서, 실제로 내가 담당하는 프로덕션 서비스의 벡엔드는 전적으로 나 혼자 개발하고 운영하는 판국이다.
MSA로 구성하면, 유지보수성이 높아지고 개발생산성이 빨라진다.
MSA로 구성하면, 유지보수성이 나빠지고 개발생산성이 느려진다.
아이러니하게도 둘 다 맞는 말이다.
하지만, 서비스 규모가 일정 정도를 넘어서고, 회사에 개발팀이 충분히 갖춰지게 될시, MSA 구조가 추후 협업 및 개발 에 있어서 더욱 유리한 구조라는 것에는 동의한다. 물론 그렇게 넘어가는 것은 그때 가서 고민해봐도 늦지 않는다는 게 내 의견이고, 추후 쪼개기 쉽도록 단일 코드 베이스 내에서 패키지 수준의 의존관계만 잘 고민해서 분리해줘도 상관없다는 것이고.
내 생각에 MSA 가 각광받는 이유 중 가장 커다란 것은 다른 데 있는데, 그것은 우리가 지금 클라우드 시대에 살고 있다는 점이다. 클라우드 시대에 무엇이 가장 큰 이슈일까? 이슈는 클라우드가 비싸다는 점이다. 클라우드는 온프레미스에 비하면 너무 비싸다. 필자가 현재 운영해보면서 느낀 점 중 가장 강렬한 점이다. 물론 클라우드가 절약해주는 눈에 보이지 않는 비용이 있기 때문에, 클라우드를 사용하는 것이지만, 지금 물리적으로 눈에 보이는 것은 비용이다.
따라서 클라우드 인프라 안에서 얼마만큼 비용 효율적으로 구성할 지가 벡엔드 개발자에게 있어서 정말 큰 필요역량 중 하나라고 생각한다. 필자는 인프라 팀이 따로 갖춰져 있는 환경에서 일해본 적이 없다. 첫번째 직장은 작은 외주회사였고, 현재 직장도 작은 스타트업이므로, 개발자 1명당 부여되는 역할의 범위가 매우 넓은 편이다. 아무튼 이러한 환경에서 효율적인 서버 배치를 위해선 모놀리틱 구조는 불리할 수 밖에 없다.
사족이지만 golang이 이러한 시대에서 가장 큰 혜택을 받은 언어라고 생각한다. 반면 JVM 기반 언어와 스프링이라는 프레임워크는 이러한 시대흐름과는 좀 맞지 않는 언어와 프레임워크 라는 생각도 들고, 심지어 나는 코틀린이라는 언어를 너무 좋아하고 (언어 자체의 디자인 측면에서) 스프링이라는 프레임워크가 서버사이드 개발에 있어서 정말로 뛰어난 거의 완벽한 육각형 프레임워크라고 생각하지만, 어쩔 수 없는 시대의 흐름이 있다고 생각한다. 이는 기술적으로 이 기술이 저 기술과 비교해서 더 후지거나 나빠서가 아니다.
나는 GoLang을 찍먹하면서, 언어 디자인 측면에서 마음에 안 드는 점들이 참 많이 있지만, 그 외의 부분. 컴파일 속도라든가, 별도의 VM 필요없이 실행가능한 싱글 바이너리로 파일로 바로 빌드된다는 점이라든가, 적은 메모리, 웜업 과정 필요없이 빠른 스피드 등등이 클라우드 시대에 강점이라는 점을 부정할 수가 없다. golang은 아직 익숙치 않고, 잘 모른다. 하지만 분명 코틀린만큼 즐거운 기분을 느낄 수가 없다. 굉장히 따분하고 장황하고, 언어 디자인 적으로 분명 더 나은 선택지가 있었다고 생각한다. 그러나 이 강점들
JVM은 미친 물건이지만, Docker의 등장으로 그 필요성이 예전보다 떨어지는 편이며, 클라우드 시대에는 경량 배포가 트렌드다. JVM을 설치해야 된다는 것은 분명 그 흐름과는 맞지 않다. 스프링은 정말 강력한 프레임워크지만, MSA 시대에는 스프링의 그 강력한 기능들이 그렇게 필요하지가 않다. 스프링의 가장 큰 장점이라고 한다면, 인터페이스를 만족한 빈들을 추가해주면서, 기존 서버코드의 변경없이 유연하게 끝없이 확장할 수 있다는 점일 텐데, 이 점들이 요새 시대에는 그렇게 필요하지 않다는 느낌이다.
그래도 나는 스프링 좋아한다. 앞으로 가능한 이걸로 밥 벌어 먹고 살았으면 싶다. 최근 JVM 이 Golang 처럼 변할려고 시도를 많이 하고 있다. 실행가능한 단일 바이너리 파일 빌드라든지, 하지만 아직 부족하다. JVM이 계속 일을 해서, 모든 부분을 만족하는 육각형이 되길 원하다. 그리고 코틀린!, 코틀린으로 다 하는 세상이 오길 원한다. 코틀린으로 머신러닝 개발. 내가 머신러닝 공부를 안 하는 것 중 상당부분 파이썬 때문도 있다. 필자는 파이썬과 JS는 암만 봐도 정이 안 가드라. 물론 지금도 코틀린용 딥러닝 프레임워크가 따로 있긴 하지만, 원하는 건 생태계에서 유의미하게 점유율이다. 아니면 최소한 반반정도. 사족이다..
좋은 글 감사합니다