모놀리식인데, 도메인 하나만 따로 띄웁니다

명령 한 줄로 뜨는 것
저희 백엔드에서 이슈 도메인은 프로덕션에서 혼자 돌지 않습니다. 앞에 Gateway가 있고, 옆에 인증 서버가 있고, 채번이나 조직 조회 같은 걸 다른 도메인 서비스에 물어봅니다. 정상입니다. 실서비스는 원래 그렇게 얽혀 돌아갑니다.
문제는 그 이슈 도메인의 API 하나를 고칠 때 생깁니다. 상태 전이 규칙을 손봤다고 해 봅시다. 고친 게 맞는지 확인하려면, 방금 말한 그 앞뒤 옆을 다 띄워야 할까요. Gateway를 올리고, 인증 서버를 올리고, 토큰을 발급받고, 옆 도메인 서비스까지 기동한 다음에야 요청 한 번을 넣어 볼 수 있다면, 고침 한 번에 검증 한 번을 붙이는 그 짧은 순환이 통째로 무거워집니다. 사람도 지치지만, 특히 AI 에이전트에게 이 순환의 길이는 곧 일할 수 있느냐 없느냐의 문제입니다. 고치고, 돌려 보고, 결과를 읽고, 다시 고치는 그 루프가 여기서 끊깁니다.
저희는 이렇게 합니다.
./gradlew :issue:standalone-app:bootRun이 한 줄이면 이슈 도메인만 따로 뜹니다. MySQL이 컨테이너로 올라오고, 스키마가 적용되고, 샘플 데이터가 채워지고, Swagger UI와 간단한 화면까지 서빙됩니다. Gateway도, 인증 서버도, 다른 도메인 서비스도 없습니다. 그런데도 이슈 도메인의 비즈니스 로직은 프로덕션과 같은 코드로 돕니다.
여기서 눈여겨볼 것은 편리함이 아닙니다. 저희 백엔드는 한 프로세스로 뜨는 모놀리식인데, 그 안의 도메인 하나만 마치 별도 서비스처럼 떼어 기동했다는 점입니다. 전체 앱도 아니고, 도메인들을 나눠 배포하는 MSA도 아닌데, 기동 단위는 도메인 하나로 좁혀졌습니다. 어떻게 이게 가능한지, 그리고 이 "기동 단위를 도메인으로 가른다"가 왜 구조가 준 장점인지가 이 글입니다.
모놀리식은 원래 통째로 뜹니다
먼저 왜 이게 특별한지부터입니다. 보통 모놀리식 애플리케이션은 통째로 뜹니다. 모든 도메인의 코드가 한 실행 단위에 담겨 하나의 프로세스로 올라오죠. 이슈만 보고 싶어도 조직도 계약도 함께 올라옵니다. 부분만 떼어 세울 재료 자체가 없습니다.
모듈러 모놀리식은 그 안을 물리적인 모듈로 쪼갠 구조입니다. 저희 백엔드가 그렇습니다. 도메인마다, 또 계층마다 별개의 빌드 모듈로 나뉘어 있고, 모듈끼리의 의존은 빌드 파일에 적어야만 성립합니다. 그런데 이렇게 쪼개 두어도 배포와 기동은 여전히 한 통입니다. 프로덕션에서는 이 모듈들을 다 모아 하나의 애플리케이션으로 올리니까요. 그러니 겉보기 실행 모델은 보통 모놀리식과 같습니다.
다른 건 딱 하나, 쪼개 두었기 때문에 골라 담을 수 있다는 점입니다. 모듈이 경계로 나뉘어 있으니, 그중 일부만 집어 새 실행 단위로 조립할 재료가 생깁니다. standalone은 바로 이 재료를 씁니다. 이슈 도메인에 필요한 모듈만 골라 담아, 도메인 하나 크기의 애플리케이션을 따로 조립해 기동합니다. 도메인별로 저장소를 쪼개 나눠 배포하는 MSA로 가지 않고도, 검증할 때만큼은 MSA처럼 도메인 하나만 떼어 세우는 겁니다.

핵심은 여기 있습니다. 모듈러 모놀리식이 코드를 물리적 경계로 쪼개 둔 덕에, 기동 단위를 전체 앱과 도메인 슬라이스 사이에서 고를 수 있게 됐습니다. 보통 모놀리식에는 없던 선택지입니다.
기동 시간은 하루에 얼마나 쌓이나
이 선택지가 왜 중요한지는 기동 시간을 세어 보면 드러납니다.
전체 모놀리식을 올리면 수십 개 도메인과 그 인프라 연결이 다 초기화될 때까지 기다려야 합니다. 도메인 슬라이스는 이슈에 필요한 것만 뜨니 그 시간이 확 짧아집니다. 문제는 이 기다림이 한 번으로 끝나지 않는다는 데 있습니다. 도입에서 말한 그 순환, 고치고 기동하고 확인하고 다시 고치는 그 루프는 하루에도 여러 번 돕니다. 회당 차이가 작아 보여도 반복 횟수를 곱하면 이야기가 달라집니다.
간단히 추산해 보겠습니다. 아래는 실측이 아니라 가정값을 넣은 자체 추산입니다. 전체 기동을 90초, 슬라이스 기동을 20초로 놓고, 고침을 확인하는 순환을 하루 20회 돈다고 가정하면, 회당 70초씩 하루 약 23분이 기동 대기로 사라집니다. 한 주(영업일 5일)면 두 시간, 한 달(20일)이면 여덟 시간에 가깝습니다. 개발자 한 명이 한 달에 하루치 근무 시간을 기동 화면만 바라보며 보내는 셈입니다.

이 추산에는 순환이 끊길 때 딸려 오는 손해가 빠져 있습니다. 90초짜리 기다림 동안 주의가 다른 데로 새면, 돌아와 맥락을 다시 잡는 비용이 그 90초 위에 더 붙습니다. 그러니 실제 손해는 이 대기 시간보다 큽니다. 여기서 세어 본 건 눈에 보이는 대기분만이고, 그것만으로도 한 달에 하루치입니다.
조립의 청사진은 빌드 파일에 있습니다
그럼 "이슈 도메인만" 뜬다는 건 무슨 뜻일까요. 그 경계를 정하는 건 빌드 파일입니다. standalone 모듈의 빌드 파일이 무엇을 끌어오고 무엇을 막는지에, 세 가지 결정이 담겨 있습니다.
첫째, 필요한 도메인만 골라 조립합니다. 이슈 도메인의 api, service, infrastructure, 저장소 어댑터를 끌어옵니다. 다른 도메인은 부르지 않습니다. 전체 애플리케이션이 아니라 도메인 하나 크기의 조각을 조립하는 겁니다.
둘째, 크로스 도메인 의존은 최소한만 허용합니다. 이슈 코드를 채번하려면 채번 도메인이 필요합니다. 그래서 채번 도메인의 service와 저장소 어댑터까지는 딸려 옵니다. 이슈가 혼자 설 수 없는 딱 그만큼만 옆 도메인을 데려오고, 그 이상은 안 데려옵니다. 이 목록이 곧 "이슈 도메인이 진짜로 뭘 필요로 하는가"의 명세이기도 합니다.
셋째, 프로덕션 전용 모듈은 명시적으로 막습니다. 도메인 모듈들을 끌어오다 보면, 그 전이 의존을 타고 프로덕션에서만 쓰는 권한 프로토콜 같은 게 딸려 들어오려 합니다. standalone에서는 그게 필요 없을뿐더러 있으면 오히려 조립을 어지럽힙니다. 그래서 빌드 파일에서 그 모듈을 이름으로 콕 집어 배제합니다. 무엇을 안 넣을지를 코드로 못 박아 두는 거죠.
이 세 줄짜리 결정이 하는 일은, 말로 하면 "이슈 도메인의 경계가 어디까지인지"를 빌드가 강제하게 만드는 것입니다. 조립 목록에 없는 도메인은 클래스패스에 아예 없으니, 실수로라도 끌어다 쓸 수 없습니다.
트랜잭션이 달라져도 비즈니스 코드는 그대로입니다
채번을 데려오면 곧바로 걸리는 게 하나 있습니다. 이슈를 만들 때 채번으로 번호를 받고 그 이슈를 저장하는데, 이 둘은 한 몸이어야 합니다. 저장이 실패했는데 번호만 나가 버리면 그 번호에 빈자리가 생기니까요. 채번과 저장을 하나의 원자적 작업으로 묶는 것, 흔히 Unit of Work라고 부르는 개념입니다. 보통은 이 경계를 프레임워크의 트랜잭션 애너테이션에 암묵적으로 맡기지만, 저희는 그 경계 자체를 도메인이 소유하는 인터페이스로 코드에 드러냈습니다. 작업을 클로저로 받아 통째로 실행하는, execute { 채번과 저장 } 꼴의 슬롯입니다.
경계를 슬롯으로 뽑아 둔 이유는 조립 때문입니다. "채번과 저장을 한 작업으로 묶어라"라는 계약은 어느 배포에서나 같지만, 그 계약을 실제로 무엇으로 지키느냐는 두 도메인이 어떻게 조립됐느냐에 따라 달라집니다.

채번과 이슈가 한 앱에 조립된 프로덕션에서는 로컬 단일 트랜잭션 부품을 꽂습니다. 도메인만 떼어 빠르게 돌려 보는 슬라이스에서도 같은 트랜잭션 부품이면 충분합니다. 채번이 외부 API로 떨어져 나가면 로컬 트랜잭션으로는 둘을 못 묶으니, 분산 경계를 다루는 부품으로 바꿉니다. 저장이 실패하면 발급한 번호를 되돌리는 Saga 보상 트랜잭션일 수도, 저장 의도를 먼저 기록하고 채번을 뒤로 미뤄 흘려보내는 Outbox일 수도 있습니다.
여기서 중요한 건 어떤 부품이 있느냐가 아니라, 이 차이가 비즈니스 코드에 한 줄도 새지 않는다는 점입니다. 이슈를 만드는 코드는 슬롯을 호출할 뿐, 지금 자기가 슬라이스로 떠 있는지 프로덕션에 합쳐져 있는지, 채번이 같은 앱에 있는지 외부 API 너머에 있는지 모릅니다. 트랜잭션을 로컬로 걸지 Saga로 보상할지는 조립하는 쪽이 부품 하나로 정하고, 그 결정이 호출부까지 올라오지 않습니다. 그래서 빠르게 검증하려고 분리해 띄우든 운영하려고 합쳐 올리든, 처리 방식이 달라져야 하는 그 부담을 비즈니스 로직이 짊어지지 않습니다.
자기 완결적이라는 것
조립만으로는 아직 "뜨기만" 합니다. 실제로 시나리오를 돌려 보려면 데이터가 있어야 하고, 요청을 넣을 창구가 있어야 합니다. standalone은 이걸 스스로 갖춥니다.
먼저 데이터입니다. standalone 앱이 뜰 때 시드 데이터가 자동으로 채워지는데, 여기 결정이 하나 숨어 있습니다. 시드를 만들 때 저장소에 직접 행을 꽂지 않고, 도메인의 UseCase를 호출해서 만듭니다. 이슈를 하나 생성하고 상태를 바꾸는 그 시드 과정이, 실제 API가 타는 경로를 그대로 탑니다. 채번이 돌고, 상태 전이 규칙이 검사되고, 이벤트가 발행됩니다. 저장소에 바로 꽂았다면 규칙을 우회한 데이터가 생겼을 텐데, UseCase로 만들면 시드 자체가 도메인 검증을 한 번 통과한 결과물이 됩니다. 검증 환경을 만들면서 검증 경로로 데이터를 만드는 셈입니다.
데이터베이스는 로컬에 MySQL을 깔아 둘 필요가 없습니다. 빌드 태스크에 컨테이너를 하나 쓰겠다는 선언 한 줄이 들어가 있고, 그 한 줄이면 MySQL 컨테이너가 뜨고 접속 정보가 앱에 자동으로 주입됩니다. 처음 받은 저장소에서 바로 이 명령 하나로 도는 이유입니다.
창구는 둘입니다. Swagger UI가 이슈 도메인의 모든 API를 문서로 뽑아 주니, 브라우저에서 바로 요청을 넣어 볼 수 있습니다. 그리고 간단한 프론트엔드가 함께 서빙됩니다. 프론트 빌드 결과물은 빌드 과정에서 정적 리소스로 복사되게 엮여 있습니다. 명령 한 줄로 데이터베이스가 뜨고, 스키마가 잡히고, 시드가 채워지고, API 문서와 화면까지 함께 올라옵니다. 다른 무엇에도 기대지 않는 환경이 이렇게 만들어집니다.
전체 없이 부분을 검증한다는 사고
standalone을 편리한 개발 도구로만 보면 절반만 본 겁니다. 이건 저희가 여러 자리에서 반복하는 한 가지 사고방식의 한 사례입니다. 전체를 다 올리지 않고, 검증하려는 부분만 떼어 내 조립한다는 것.
같은 사고를 저희는 다른 축에서도 씁니다. 어떤 검증은 시간을 갈아 끼웁니다. 지금이 아닌 특정 시점인 것처럼 앱을 돌려, 그때 실행됐어야 할 로직을 재현합니다. 어떤 검증은 공간을 갈아 끼웁니다. 요청이 향하는 목적지를 바꿔, 실제 외부 시스템 대신 통제된 대역으로 흘려보냅니다. standalone은 여기서 기동 구조를 갈아 끼우는 쪽입니다. 전체 시스템을 올리는 대신 도메인 슬라이스 하나만 조립해 세웁니다.
세 축을 관통하는 발상은 같습니다. 검증에 필요한 최소한만 진짜로 두고, 나머지는 교체 가능한 경계 뒤로 밀어 둔다. 시간이든, 공간이든, 구조든, 갈아 끼울 수 있는 자리를 미리 만들어 두었기에 부분만 떼어 검증할 수 있습니다. Port/Adapter는 그 갈아 끼우는 자리를 구조 축에서 제공하는 장치였던 겁니다.
도메인 슬라이스가 명령 한 줄로 재현 가능하게 서면, 그 위에 자동화된 시나리오 검증을 얹을 자리가 생깁니다. AI가 만든 변경에 "이 환경에서 이런 시나리오가 통과했다"는 증거를 붙이는 일도 여기서 출발합니다. 그건 이 조립이 열어 주는 다음 칸이고, 지금 다룬 것은 그 앞 칸, 도메인 하나를 어떻게 떼어 세우는가 까지입니다.
처음 질문으로
처음의 그 명령 한 줄로 돌아가 보죠. 한 프로세스로 뜨는 모놀리식인데, 그 안의 이슈 도메인 하나만 별도 서비스처럼 떼어 기동한다는 것.
이제 그게 어떻게 가능했는지 답할 수 있습니다. 모듈러 모놀리식이 코드를 물리적 모듈로 쪼개 두었기에, 그중 이슈에 필요한 것만 골라 새 실행 단위로 조립할 수 있었습니다. 필요한 도메인만 담고, 곁가지 의존은 최소로 데려오고, 프로덕션 전용 모듈은 막고, 도메인만 떼면서 걸리는 인증은 Adapter를 갈아 대신하고, 데이터는 검증 경로를 그대로 태워 채웁니다. 이 조립의 결과가 도메인 하나 크기의 애플리케이션입니다.
그래서 저희에게 기동 단위는 하나로 고정돼 있지 않습니다. 프로덕션에서는 전체를 한 통으로 올리고, 도메인 하나를 고쳐 확인하고 싶을 때는 그 도메인만 떼어 세웁니다. MSA로 저장소를 쪼개 나눠 배포하지 않고도, 검증할 때만큼은 도메인 하나짜리 서비스를 얻는 겁니다. 보통 모놀리식이라면 전체를 다 띄우는 것 말고는 길이 없었을 자리에서, 모듈로 쪼개 둔 구조가 "무엇을 기동 단위로 삼을지"라는 선택지를 열어 주었습니다. 도메인 하나를 고치는데 시스템 전체를 띄우지 않아도 되는 이유가 여기 있습니다.
그리고 이 선택지는 검증할 때만 열리는 게 아닙니다. 어떤 도메인의 부하가 커져 정말로 MSA로 떼어내야 하는 날이 오면, 그 이행은 이미 슬라이스로 수백 번 조립해 본 그 경계를 따라갑니다. 담을 모듈, 데려올 옆 도메인, 막을 프로덕션 전용 모듈은 그대로이고, 채번과의 트랜잭션처럼 한 앱을 벗어나며 달라지는 것은 부품을 갈아 끼우면 됩니다. 로컬 트랜잭션 부품을 Saga나 Outbox로 바꾸는 그 교체는 비즈니스 코드 바깥에서 일어납니다. 그래서 모듈러 모놀리식으로 시작해 나중에 도메인을 MSA로 분리하더라도, 이슈를 만드는 로직은 한 줄도 고칠 필요가 없습니다. 언제 쪼갤지는 부하가 정하고, 어떻게 쪼갤지는 이미 조립이 답해 둔 셈입니다.

🚀플렉스팀 채용페이지 바로가기
☕flex Private Talk 신청하기