그래서 지속 가능한 시스템이 뭔데요?

인사 관리를 하기 위해선 인사 관리 시스템이 필요하고, 재무 업무를 수행하기 위해선 재무 관리 시스템이 필요합니다. 그리고 이러한 시스템이 원활하게 운영되기 위해서 많은 인력의 개발자가 필요합니다. 요구사항 변경에 따른 유지보수 작업도 필요하고, 시스템 내부적인 버전 업데이트로 인해 불가피한 코드 수정이 동반 되기도 합니다. 또한 사용자의 요구사항을 수렴하여 기능 변경이 일어날 수도 있습니다. 이렇듯, 시스템은 외부 세계와의 소통이 매우 잦고 빈번합니다. 이러한 이유로 저는 시스템의 생명주기가 매우 짧다고 생각하는 사람입니다. 아무도 관리하지 않는 시스템 혹은 변경에 용이하지 않게 작성된 시스템은 지속 가능하기 힘들기 때문입니다. 이러한 이유로 이론적으로 이상적인 시스템 설계는 확장 가능해야 하며 지속 가능하도록 설계 하라고 합니다. 변경에도 용이하고 확장 하기도 쉬운 시스템이 도대체 무엇일까요? 그리고 이러한 시스템은 어떻게 만들 수 있는 것일까요? 오늘 다룰 글은 이러한 시스템에 대한 제 생각과 대비하여 실제로 제가 경험한 것들을 바탕에 기반하여 회고를 가볍게 다뤄볼까 합니다.

How is it different?

저는 현재까지 이직을 2번 했습니다. 즉, 현재까지 총 3개의 회사를 근무해본 셈입니다. 제가 경험한 회사의 서비스는 제각기 다릅니다. 각 회사의 서비스에 대한 설명을 조금씩 한 뒤, 왜 이직을 했는지, 그리고 다양한 시스템을 경험하며 제가 생각하는 이상적인 시스템 설계는 무엇인지 말씀드리려고 합니다. 첫 회사는 모니터링 시스템을 개발하여 업체에 제공하는 B2B 회사입니다. 조금만 더 구체적으로 말씀드리면, 어떤 어플리케이션의 성능을 측정하여 시각화 해주는 모니터링 시스템이라고 생각하시면 될 것 같습니다. 초 단위의 시계열 데이터를 볼 수도 있고, 다양한 차트를 사용자가 커스터마이징 할 수도 있는 기능을 제공해 주는 시스템을 개발했습니다. 그만큼 모니터링 하고자 하는 대상에 대한 구체적인 분석과 단단한 설계가 필요로 했습니다. 두 번째 회사는 에듀테크 회사입니다. 초등학생부터 고등학생, 더 나아가 성인까지 다양한 교육 및 교재를 제공하는 서비스 회사입니다. 이 회사에서는 좋은 퀄리티의 교육을 제공하는 플랫폼 시스템도 중요하지만, 한 회원의 생명주기를 관리하는 시스템 또한 매우 중요 했습니다. 즉 데이터의 정합성과, 오류가 났을 때 이를 수동 혹은 자동으로 복구할 수 있는 많은 내부 제품을 필요로 했습니다. 마지막 세 번째 회사. 즉 현재 근무하고 있는 회사는 인터넷 은행입니다. 말 그대로 사용자가 어디서든 은행 업무를 볼 수 있도록 금융 플랫폼을 제공하는 시스템입니다. 그 무엇보다도 데이터의 정합성이 중요하며 은행 규정에 맞는 내부 시스템을 운영해야 하기 때문에 다양한 모니터링 및 고객 관리 시스템이 필요하고 여신 및 수신 업무에 필요한 시스템이 운영됩니다. 그리고 저는 이 곳에서 여신 업무에 활용되는 대출 심사 시스템을 개발 및 운영하고 있습니다. 이렇듯 제가 경험한 시스템은 각 회사에 부합하는 도메인의 성격이 짙고, 서로 성격이 매우 다릅니다. 하지만 개발자 관점에서 보면 크게 다르지 않은 듯 보이기도 합니다. 모니터링 시스템이라는 것은 결국 어딘가에 대용량의 데이터가 저장되고, 이를 클라이언트에 보여주는 것입니다. 교육 플랫폼 역시 교육 관련 데이터를 클라이언트에게 보여주는 것 입니다. 인터넷 뱅킹 또한 마찬가지 입니다. 고객이 대출을 받기 위해선 제출한 서류를 적재하고 해당 데이터를 심사 시스템을 통해 내부 심사자에게 보여준 뒤, 외부 기관과 통신하여 심사를 진행하는 것 입니다. 이렇듯 서로 다른 도메인 이지만 개발자 관점에서 보면 꽤 비슷한 부분도 존재합니다.

How to design it?

시스템을 어떻게 설계해야 확장에 용이하게 하여 시스템의 수명을 늘려줄 수 있을까요? 지금부터 제가 경험 했던 시스템의 좋았던 점과 다소 아쉬운 부분에 대해 이야기 하려 합니다. 에듀테크 회사에서는 다음과 같은 기능이 존재했습니다.

선생님이 학생이 제출한 답안을 확인한 후, 코맨트 혹은 채점을 한 경우, 학생에게 푸시 알림이 가야 한다.

요구사항대로 구현하고자 한다면 사실 크게 어렵지 않은 기능입니다. 푸시 기능은 FCM 모듈을 사용하고 있었기 때문에 단순히 채점이나 코맨트를 남겼을 때 해당 상황에 맞는 데이터를 FCM에 전송 하기만 하면 됩니다. 하지만 제가 근무했을 당시, 기존에 사용하던 FCM API 가 조만간 deprecated 된다는 메일을 받았습니다. 푸시 서버는 Spring Boot 1.5 로 작성이 되어 있었고, Java8을 사용하고 있었습니다. 사실 FCM API를 마이그레이션 하는 일은 크게 어렵지 않은 일입니다. 하지만 단순 API 마이그레이션임에도 불구하고 꽤 많은 비용을 들여야만 했는데요. 그 이유는 다음과 같습니다.

  1. Spring Boot 1.5는 더 이상 지원하지 않는다. Java11과 호환 되지 않는다. 즉 다양한 자바 라이브러리의 버전 호환를 기대하기 어렵다.
  2. 팀 내에 Spring Boot를 할줄 아는 사람이 없기 때문에 Spring Boot 버전을 업데이트 하더라도 운영에 대한 기술부채가 존재한다. 즉 이 시스템은 지속 가능하기 어려울 가능성이 크다.
  3. JPA와 같은 ORM을 아는 인원이 팀 내에 존재하지 않는다. 이러한 이유로 저는 이 시스템이 운영이 지속되리란 어렵다고 해도 과언이 아니리라 판단했습니다. 제가 입사했을 당시, Spring Boot 서버 운영을 담당하던 사람은 제가 유일무이 했으며, 이미 개발 부서 전채는 공통 스택으로 Node.js를 선택 했었습니다. 더 이상 Spring Boot 스택을 고집할 이유가 없었습니다. 이러한 이유로 FCM API deprecated 덕분에 해당 시스템의 비즈니스 로직 전부 전면 재작성을 해야만 했습니다. 이 시스템은 무엇이 문제 였을까요? 팀 내 Spring Boot와 JPA에 대한 기술 공유가 제대로 이루어지지 않은 것도 문제이지만, 제가 파악한 문제점들은 다음과 같습니다. 푸시 서버 내부 구현체는 3-Tier Layered Architecture로 되어 있었습니다. 표면적으로는 크게 문제가 될 것 같진 않습니다만, 저희 회사는 내부 인터널 앱을 포함한 대고객 서비스 등, 정말 많은 곳에 푸시를 보내고 있었습니다. 또한 FCM 서버에 푸시를 보내는 API 통신 구현부 조차도 WebClient를 활용한 중복구현이 꽤 다수 존재 했었습니다. 프로젝트 내에 존재하는 수많은 Domain Entity와 Service Class가 존재했고 그 안에 미묘하게 다른 중복구현들이 다수 존재 했습니다. 장담컨데, Spring Boot와 JPA에 능숙했던 사람이더라도, 이 시스템의 내부 구현을 단기간에 파악하기란 쉽지 않았으리라 확신합니다. 이러한 문제를 방지하기 위해 이 푸시 서버에 필요했던 적절한 Architecture는 무엇이었을까요? 왜 그렇게 중복 구현이 많을 수 밖에 없었던 걸까요?

인터넷 은행 회사에서는 다음과 같은 요구 사항이 있었습니다.

각 심사 스템의 순서는 보장이 되어야 하며, 외부 기관의 응답이 오지 않았을 경우
해당 심사 페이지로 접근이 불가해야 한다.

얼핏 요구사항만 확인 했을 땐 이 또한 그리 어렵지 않은 기능 같습니다. 각 심사 스텝에 대한 상태들이 정의된다면 큰 문제 없이 제어할 수 있을 듯 합니다. 즉, FSM만 제대로 설계한다면 그렇게 어렵지 않겠다는 생각이 듭니다. 하지만 현재 이 기능은 저희 팀의 오랜 기술부채로 남아있습니다. 각 대출을 정의하는 LoanApp이라는 테이블 안에 Status와 LoanAppStep 이라는 상태를 서로 엮어놨기 때문입니다. 이 요구사항을 만들 당시에는 한 상품만 존재했습니다만, 상품이 여러개로 늘어난 현 시점에선 LoanApp과 LoanAppStep 각각의 상태를 더 이상 추적할 수 없을 만큼 얽혀버리게 된 것입니다. 이러한 상황에서 적절한 Architecture, 혹은 적절한 Design Pattern은 무엇이었을까요? 무엇이 이러한 기술부채를 만들게 했던 것일까요?

Architecture

시스템은 코드가 쌓일 수록, 즉 모듈의 수가 증가할 수록 새로운 기능을 구현하기 쉬워야 한다고 생각합니다. 이게 무슨 의미일까요? 설명을 위해 이 블로그 서버를 운영하고 있는 hobom-backend 의 구현체 중 일부분을 살펴 보겠습니다.

package com.hobom.hobominternal.adapter.outbound.persistence.mail

import com.hobom.hobominternal.domain.mail.MailRequest
import com.hobom.hobominternal.port.outbound.mail.SendMailPort
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.stereotype.Component

@Component
class SendMailAdapter(
   private val mailSender: JavaMailSender,
) : SendMailPort {
   override fun send(request: MailRequest) {
       val message = SimpleMailMessage().apply {
           setTo(request.to)
           subject = request.title
           text = request.content
       }
       mailSender.send(message)
   }
}

위의 코드는 JavaMailSender를 활용하여 메일 전송을 구현하고 있는 단순한 구현체입니다. 사용하는 측에서는 SendMailAdapter의 구현체를 알 필요 없이 그저 send라는 메소드의 인풋에 맞춰 호출만 하면 되는 구조입니다. 여기서 중요한 점은 메일을 보내는 책임을 담당하고 있는 계층이 Adapter 레이어라는 것입니다. 고전적인 방식으로는 어떻게 했을까요? 아마 대부분의 분들이 익숙한 모습일 텐데요. 높은 확률로 Adapter 계층이 아닌 Service 계층에서 구현할 것입니다.

@Service
class SendMailService(
   private val mailSender: JavaMailSender,
) {
   fun send(request: MailRequest) {
       val message = SimpleMailMessage().apply {
           setTo(request.to)
           subject = request.title
           text = request.content
       }
       mailSender.send(message)
   }
}

그리고 이를 활용하는 곳에서는 다른 서비스를 의존성 주입받고 Facade 레이어를 구현하게 되겠죠. 아무런 문제가 없는 듯 합니다만, 이처럼 서비스 레이어간의 의존성이 강해질 수록 테스트 하기 어려워질 뿐더러 서비스 레이어의 특정 메소드만 사용하고 싶은 경우에도 전체 서비스를 의존성 주입해야 하는 경우가 생깁니다. 즉 테스트 코드를 작성할 때 모킹이 매우 어려워 진다는 단점이 있습니다. 또한 이렇게 시스템의 규모가 커질수록 순환참조 이슈가 발생하지 않으리란 보장도 없습니다. Hexagonal과 같은 Architecture가 주는 장점은 무엇이 있을까요? Hexagonal Architecture와 같은 Clean Architecture에서는 Service 레이어가 아니어도 되는 것에 대한 계층을 구분하고, Service레이어는 하나의 책임만 지게 하는 것입니다. 또한 시스템에서 inbound와 outbound를 구분하여 Client로 들어오는 input과, DB 혹은 Kafka와 같은 infrastructure 레이어롸 output으로 구분하는 것입니다. 이렇게 명확한 책임 구분을 통해 모듈간의 격리가 명확해지는 것이 핵심이라고 볼 수 있겠습니다. 이러한 모듈간의 책임을 명심하며 다시 에듀테크 때의 마주했던 이슈를 생각해 보겠습니다. 무엇이 문제였을까요? Service 레이어 간 무자비한 의존성 주입이 시스템을 복잡하게 했습니다. 그리고 외부 세계와의 통신(FCM)이 명확하게 책임으로 나뉘어 지지 않았습니다. FCM과 통신을 하는 모듈은 언제든지 재활용 가능한 모듈이어야 했으며, 특정 요구사항을 수행하는 Service 레이어는 Use-case가 됐다면 어땠을까요? 비록 초기 작업 시 많은 양의 코드를 작성해야 한다는 단점이 있을 수 있겠으나, Architecture라는 것은 쌓일 수록 그 장점이 극대화 된다고 생각합니다. 작성된 코드가 많을 수록 해당 시스템에 새로운 기능을 도입할 때 코드 작성이 용이해야 하며, 모듈 간의 책임 경계는 명확할 수록 사이드 이펙트가 줄어든다고 생각합니다. 다음글에서는 이러한 저의 철학이 담긴 HoBom System의 전체적인 아키텍처에 대해 알아보도록 하겠습니다. 감사합니다.