왜 팔도감은 마이크로서비스에서 모놀리스로 전환했을까?

Server Engineer, 이상민 님
이상민's avatar
Jun 18, 2025
왜 팔도감은 마이크로서비스에서
모놀리스로 전환했을까?

안녕하세요 라포랩스 커머스 플랫폼 팀 서버 엔지니어 이상민입니다.
라포랩스는 4050을 위한 패션 플랫폼 “퀸잇”과 식품 플랫폼 “팔도감”을 운영하고 있습니다.

오늘은 팔도감 서비스의 효율적인 개발 및 운영을 위해 Microservice Architecture로 구성된 시스템을 Modulith로 전환한 사례를 소개해 드리겠습니다. 일반적으로 서비스는 모놀리스 아키텍처로 시작하여, 성장하며 다양한 한계를 극복하기 위해 MSA 구조로 진화하는데요. 팔도감은 왜 이와 반대되는 결정을 내렸는지, 구체적인 전환 전략은 무엇이었는지 설명해 드리겠습니다.

퀸잇

팔도감

1. 팔도감 서비스 개요

팔도감은 라포랩스의 사내 벤처 프로젝트로, 기존에 운영 중이던 패션 플랫폼 퀸잇을 포크(fork)하여 출발했습니다. 퀸잇은 이미 다양한 커머스 기능과 관리자 기능을 갖추고 있었기 때문에, 완전히 새로 개발하는 것보다 이를 기반으로 삼아 서비스 출시까지의 시간을 크게 단축할 수 있었습니다. 두 서비스는 취급하는 상품군이 다르지만, 동일한 온라인 커머스 플랫폼이기 때문에 변경 사항이 많지 않을 것이라 기대했습니다. 하지만 현실은 그렇지 않았습니다.

패션 (퀸잇)

식품 (팔도감)

재고 관리

옵션(사이즈) = 재고

예) M, L 각각이 개별 재고

옵션은 재고와 1:N
예) 1 kg·3 kg은 선택지, 총 n kg에서 차감

대량 주문

대량 주문 수요 거의 없음

B2B 대량 주문 수요 존재

선물 구매

주로 본인 사용 목적

선물용 구매 비중 존재

배송 정책

주문 즉시 출고

신선식품은 익일 도착 가능만 출고

반품 정책

반품 가능

신선식품은 반품 불가

퀸잇과 팔도감의 정책 차이 중 일부

팔도감 출시 이후 2년간 서비스가 성장하면서 두 플랫폼은 취급 상품과 사업 전략의 차이로 각자 다른 방향으로 발전하며 코드베이스에도 상당한 격차가 발생했습니다. 또한 독립적으로 운영되면서 두 조직의 소통 방식, 개발 프로세스, 배포 과정까지 달라져 기능 공유와 협업은 갈수록 어려워졌습니다.

그럼에도 두 서비스 모두 커머스라는 공통 기반을 바탕으로 하고 있었기에, 팔도감 팀은 고유 기능은 유지하되 퀸잇의 기능을 적극 활용하고 싶었습니다. 결국 퀸잇과 팔도감의 코드베이스 간 격차를 해소하기로 했고, 이를 ‘퀸잇 따라잡기’(이하 ‘퀸따’) 라고 이름 지었습니다. 퀸따에는 큰 난관이 있었습니다. 바로 퀸잇의 마이크로서비스 전환입니다. 팔도감 팀이 이 난관을 어떻게 해결했는지 설명해 드리겠습니다.


2. 퀸잇의 Microservice Architecture 전환

팔도감 출시 이후 퀸잇은 모놀리스 아키텍처를 마이크로서비스 아키텍처로 전환했습니다. MSA는 복잡한 애플리케이션을 특정 비즈니스 기능 단위의 독립적인 작은 서비스들로 나누어, 각각을 개별적으로 개발·배포하고 확장할 수 있도록 하는 아키텍처 스타일입니다.

팔도감 백엔드 엔지니어는 3명이지만 퀸잇은 약 열 배에 달하는 인력을 보유했습니다. 퀸잇은 인력 규모와 세분화된 팀 구조로 인해 개발·운영 부담이 커졌고, 이를 해소하기 위해 MSA를 도입했습니다. 그러나 상대적으로 인원이 적은 팔도감 입장에서는 MSA 전환의 이점보다 다음과 같은 단점이 더 크게 부각되었습니다.

💡

MSA의 단점

  • 레포지토리·서버 수 세분화로 인한 개발 과정 증가

  • 서비스 간 통신을 위한 API 스펙 정의 및 API 클라이언트와 핸들러 구현 반복 작업

  • 서비스 간 API 호환성 문제를 컴파일 시점에 확인 불가

  • 배포 및 트러블슈팅 복잡도 상승

  • 늘어난 배포 단위 및 통신으로 인한 비용 증가

결국 팔도감은 모놀리스 구조를 유지하기로 하였고, 정확히는 모듈리스(Modulith) 구조로 프로젝트를 재구성하고자 했습니다.


3. Modulith(모듈리스)란?

2010년대에 마이크로서비스 아키텍처(MSA)가 대세가 되며 국내외 많은 조직이 MSA로 전환했습니다. 그러나 MSA는 장점만큼 단점도 뚜렷해 전환을 반대하는 목소리도 나왔습니다. GitHub의 전 CTO는 "지난 10년간 가장 큰 아키텍처 실수는 풀 마이크로서비스로 전환한 것”이라고 말해 화제가 되기도 했습니다.

이러한 반대 의견 속에서 모듈리스(Modular Monolith, Modulith)라는 대안이 주목받았습니다. 모듈리스는 모놀리식 아키텍처와 MSA의 중간 형태로, 모듈화하되 분산 배포는 하지 않는 구조입니다. 주요 특징은 다음과 같습니다.

💡

모듈리스의 특징

  • 모듈화 구조 — 도메인 별로 경계를 뚜렷이 나눠 모듈화

  • 모듈 간 느슨한 결합 — 분리된 인터페이스 또는 비동기 메시지로 통신

  • 단일 배포 단위 — 여러 모듈이 하나의 실행 파일로 빌드·배포

  • 점진적 마이크로서비스 진화 — 필요시 일부 모듈을 쉽게 마이크로서비스로 분리 가능

모듈리스 아키텍처를 위해서는 프로젝트 전반에 걸친 구조 개편이 필요합니다. 퀸잇 코드 베이스는 모듈리스를 고려한 설계가 아니었기에, 모듈리스로 전환해야 하기 위해선 다양한 전략이 필요했습니다. 그중 대표적인 다섯 가지 전략을 소개해 드리겠습니다.

  1. Git Submodule

  2. Gradle Composite Build

  3. Java·Dependency Version 호환성 유지

  4. OpenRewrite로 FQCN 충돌 해결

  5. BeanNameGenerator로 Bean 이름 충돌 해결


4. 팔도감 모듈리스 구현 전략

4-1. Git Submodule

퀸잇은 기존 시스템을 멀티 레포 마이크로서비스로 전환했습니다. 각 서비스의 코드베이스는 별도의 Git 레포지토리로 분리되어 독립적으로 빌드·배포 합니다. 팔도감에서 모듈리스를 구현하려면 우선 분리된 코드베이스를 하나로 통합해야 합니다. 이를 위해 Git Submodule 기능을 활용했습니다. Git Submodule은 Git 저장소 안에 다른 Git 저장소를 하위 디렉토리 형태로 포함하는 기능입니다. 이때 각 저장소의 커밋은 독립적으로 관리됩니다. 간단한 예시를 통해 서브모듈 기능을 알아보겠습니다.

git submodule add <repository> <path> 명령어를 통해 서브모듈을 추가합니다. 이때 <repository>는 로컬과 리모트 모두 가능합니다. 예시를 위해 my-otherapp 로컬 레포지토리를 생성하고 이를 modulith-example 레포지토리에 서브모듈로 추가해보겠습니다.

# my-otherapp git repo를 생성 후 더미 파일 생성 및 커밋
$ mkdir my-otherapp 
$ cd my-otherapp
$ git init
$ touch test.txt
$ git add -A && git commit -m "test"
$ cd ..

# modulith-example git repo를 생성 후 더미 파일 생성 및 커밋
$ mkdir modulith-example
$ cd modulith-example
$ git init
$ touch test.txt
$ git add -A && git commit -m "test"

# modulith-example에 my-otherapp을 git submodule로 clone
$ git submodule add ../my-otherapp/ my-otherapp
Cloning into '~/modulith-example/my-otherapp'...
done.

추가 후 my-otherapp 하위 디렉토리가 생성되고 .gitmodules 설정 파일이 생성된 것을 확인할 수 있습니다.

# modulith-example
$ ls
my-otherapp    test.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   .gitmodules
	new file:   my-otherapp
	
$ cat .gitmodules
[submodule "my-otherapp"]
	path = my-otherapp
	url = ../my-otherapp/

서브 모듈이 포함 되어있는 리모트 레포지토리를 clone하는 경우에는 서브 모듈도 함께 clone할 수 있도록 명령어를 실행해야합니다.

# 별도 명령어로 실행하는 경우
$ git clone <repository URL>
$ git submodule init
$ git submodule update #서브모듈에 변경이 있을때마다 실행

# 하나의 명령어로 실행하는 경우
git clone --recurse-submodules <repository URL> # 이후 update는 여전히 필요

이처럼 Git Submodule을 활용해 멀티 레포 마이크로서비스 코드베이스를 하나 통합할 수 있었습니다. 아래는 이를 도식화한 다이어그램 입니다. 코드베이스를 하나의 Git 레포지토리로 통합했지만 각 코드베이스는 아직 단일 어플리케이션으로 빌드되지는 않습니다.

4-2. Gradle Composite Build

Git submodule을 통해 코드베이스를 통합했지만, 모듈리스를 구현하기 위해서는 모든 코드를 하나의 어플리케이션으로 빌드해야 합니다. 그러기 위해선 빌드 시스템 차원에서 통합이 필요했고, 이를 위해 Gradle Composite Build 기능을 활용했습니다.

JVM 환경에서 Gradle 빌드 시스템으로 서브 프로젝트을 구성하는 멀티 프로젝트 구조는 익숙하실 텐데요. 멀티 프로젝트는 프로젝트 최상위에 settings.gradle.kts 파일에서 전체 프로젝트를 정의하고, 각 서브 프로젝트의 build.gradle.kts 파일에서 의존성 및 빌드 태스크를 설정 합니다.

my-app
├── app
│   ├── build.gradle.kts **(1)**
│   └── src
│       └── main
├── core
│   ├── build.gradle.kts
│   └── src
│       └── main
├── gradle/
├── gradle.properties
└── settings.gradle.kts **(2)**
// (1) build.gradle.kts에서 프로젝트의 의존성 및 태스크 설정
plugins { ... }
repositories { ... }
dependencies { 
	implementation(project(":core")) // 다른 프로젝트를 의존성으로 추가
}

// (2) settings.gradle.kts에서 프로젝트와 각 서브 프로젝트 정의
rootProject.name = "my-app"
include("app")
include("core")

컴포지트 빌드는 여러 개의 독립 빌드를 하나의 멀티 프로젝트 구성으로 포함하는 기능입니다. 멀티 프로젝트 구조와 달리, 각 빌드를 별도로 관리할 수 있어 대규모 프로젝트를 보다 작은 단위로 분할한 뒤, 필요에 따라 개별적으로 또는 전체를 한 번에 빌드할 수 있습니다.

composite-app **(1)**
├── gradle/
├── my-app **(2)**
│   ├── app
│   │   ├── build.gradle.kts
│   │   └── src
│   │       └── main/
│   ├── core
│   │   ├── build.gradle.kts
│   │   └── src
│   │       └── main/
│   ├── gradle/
│   ├── gradle.properties
│   └── settings.gradle.kts
├── my-otherapp **(3)**
│   ├── app
│   │   ├── build.gradle.kts **(4)**
│   │   └── src
│   │       └── main/
│   ├── gradle/
│   ├── gradle.properties
│   └── settings.gradle.kts
└── settings.gradle.kts **(5)**

컴포지트 빌드를 적용한 프로젝트에서도 implementation으로 빌드 간 의존성을 지정할 수 있습니다. 멀티 프로젝트가 소스 코드를 직접 참조하는 반면, 컴포지트 빌드는 외부 라이브러리를 가져오듯 바이너리(아티팩트) 의존성으로 선언합니다.

또한 서브 프로젝트는 settings.gradle 에서 include로 설정하는 반면, 컴포지트 빌드는 includeBuild를 통해 설정합니다.

// (1) 멀티 빌드를 포함하는 컴포지트 빌드 프로젝트
// (2) git submodule에서 사용한 예시 레포지토리
// (3) 새롭게 추가한 프로젝트

// (4) /composite-app/my-otherapp/app/build.gradle.kts
plugins { ... }
repositories { ... }
dependencies { 
	implementation("org.example:core:1.0") // 아티팩트 의존성 선언
}

// (5) /composite-app/settings.gradle.kts
rootProject.name = "composite-example"
includeBuild("my-app")
includeBuild("my-otherapp")

이렇게 Git 서브모듈과 Gradle 컴포지트 빌드를 활용해, 멀티 레포 기반 마이크로서비스인 ‘퀸잇’을 모노 레포 기반 모듈리스인 ‘팔도감’으로 전환할 수 있었습니다. 외부 API를 호출하던 API Gateway를 내부 코드를 직접 호출하는 API Handler로 대체하고, 컴포지트 빌드 설정도 완료한 모듈리스 구조를 도식화하면 아래와 같습니다.

4-3. Java·Dependency Version 호환성 유지

퀸잇 마이크로 서비스들은 각기 다른 Java 및 라이브러리 버전을 사용하기 때문에, Gradle 컴포지트 빌드 과정에서 버전 불일치 문제가 발생했습니다. Java는 하위 호환만 지원하고 상위 호환은 보장하지 않습니다. 예를 들어 앞선 컴포지트 빌드 예시에서 my-otherapp을 Java 17로, my-app을 Java 21로 설정한 뒤 my-otherapp을 실행하면 다음과 같은 오류가 납니다:

 > Could not resolve org.example:core:1.0.
   Required by:
       project :my-otherapp:app
    > Dependency resolution is looking for a library compatible with JVM runtime version 17, but 'project :my-app:core' is only compatible with JVM runtime version 21 or newer.

또한, Java에서는 바이너리 호환성 문제로 인해 서로 다른 버전의 라이브러리 간에 오류가 발생할 수 있습니다. 아래 코드 블록은 API 호환성은 유지되었지만, 바이너리 호환성이 깨진 경우입니다. 클라이언트 코드를 라이브러리 버전 1.0 기준으로 컴파일한 뒤, 해당 코드를 다시 컴파일하지 않고 라이브러리만 1.1 버전으로 교체했을 때, 런타임에서 오류가 발생하는 상황을 보여줍니다.

// 라이브러리 1.0
fun doSomething() {
	...
}

// 라이브러리 1.0 바이트코드
public final static doSomething()V
 L0
  LINENUMBER 5 L0
  RETURN
  MAXSTACK = 0
  MAXLOCALS = 0

// 라이브러리 1.1
fun doSomething(): Int {
	...
	return 1
}

// 라이브러리 1.1 바이트코드
public final static doSomething()I
 L0
  LINENUMBER 5 L0
  ICONST_1
  IRETURN
  MAXSTACK = 1
  MAXLOCALS = 0

// client code compiled with 1.0
// 1.1은 API 호환성을 지키지만 바이너리 호환성이 깨지기 때문에 
// 런타임에 라이브러리 jar를 1.1 버전으로 변경시 문제 발생
fun main() {
	doSomething()
}

java -cp build/lib-1.1.jar:build/app.jar demo.MainKt
> Exception in thread "main" java.lang.NoSuchMethodError: 'void demo.doSomething()'

클래스에 필드를 추가하는 등 일부 변경은 바이너리 호환성을 유지하지만, 메소드의 반환 타입을 변경하는 경우에는 유지되지 않습니다. 라이브러리는 대체로 API 호환성은 보장하나 바이너리 호환성까지는 보장하지 않기 때문에 모듈리스에서도 호환성 문제가 발생했습니다. 하위 빌드가 우선 컴파일 되고 이후 최상위 빌드를 컴파일 과정에서 라이브러리 의존성 버전이 바뀌면서 컴파일 타임에는 괜찮지만 어플리케이션 런타임에 문제가 발생했습니다.

이 문제를 방지하기 위해, 바이너리 호환성 문제가 발생하지 않는 Java 및 라이브러리 버전을 사용하도록 의존성을 수정했습니다. 업스트림 퀸잇 코드는 팔도감에서 직접 수정이 어려워 Git 서브모듈을 복제하여 이를 컴포지트 빌드에 포함시키고, 빌드 스크립트를 수정하여 해결했습니다.

4-4. OpenRewrite로 FQCN 충돌 해결

패키지 네임스페이스가 명확히 분리되지 않은 여러 코드베이스를 통합하면, 서로 다른 모듈에서 동일한 Fully-Qualified Class Name(FQCN)을 갖는 중복 클래스가 존재할 수 있습니다. 각 코드 베이스를 개별적으로 실행할 때는 문제가 없지만, 함께 사용하면 런타임 시점에 어떤 클래스를 로드할지 예측할 수 없고, 클래스 충돌로 인해 ClassNotFoundException, NoSuchMethodError 같은 오류가 발생합니다.

// modulith의 클래스 로더에 둘 다 포함되면 문제 발생

// product 서비스
package kr.rapportlabs.queenit

class Product {
	fun a() {}
}

// order 서비스
package kr.rapportlabs.queenit

class Product {
	fun b() {}
}

퀸잇 마이크로서비스는 중복된 패키지 네임스페이스를 가져 팔도감 모듈리스도 이러한 문제가 발생했고, OpenRewrite를 통해 각 서비스가 고유한 패키지 네임스페이스를 가지도록 변경하여 해결했습니다.

💡

OpenRewrite란?

  • Java/Kotlin 등 JVM 언어용 대규모 코드 리팩터링·마이그레이션 도구

  • LST 기반으로 동작해 단순 문자열 수정이 아닌 버전 수정, deprecated API 변경 등 자동화 가능

  • 참고) OpenRewrite

아래 코드 블록은 kr.rapportlabs.queenit 으로 중복되는 패키지 스페이스를 주문 서버에 고유한 kr.rapportlabs.queenit.microservice_order 으로 변경하는 레시피(OpenRewrite 작업 단위) 예시입니다.

type: specs.openrewrite.org/v1beta/recipe
name: kr.rapportlabs.queenit.ChangePackageExample
recipeList:
  - org.openrewrite.java.ChangePackage: # 주문 서비스가 고유한 패키지 네임스페이스를 설정하지 않아 변경
      oldPackageName: kr.rapportlabs.queenit
      newPackageName: kr.rapportlabs.queenit.microservice_order

이제 빌드 스크립트에서 Rewrite를 설정하면 Gradle을 통해 중복 FQCN을 해결할 수 있습니다.

plugins {
  id("org.openrewrite.rewrite") version "7.8.0"
}

rewrite {
  activeRecipe(
      "org.openrewrite.java.ChangePackage",
  )
}

$ ./gradlew rewriteRun

4-5. BeanNameGenerator로 Bean 이름 충돌 해결

라포랩스의 API 서버는 Spring Framework를 사용하며, Spring은 컴포넌트 스캔을 통해 어노테이션 기반으로 Bean을 자동 등록 및 주입합니다. 이때 Bean의 이름은 기본적으로 클래스명(단순 클래스 이름)으로 설정되므로, 패키지 네임스페이스가 고유해도 중복 클래스명으로 인해 Bean 충돌 문제가 발생할 수 있습니다.

이는 AnnotationBeanNameGenerator를 확장해 기본 Bean 이름 생성 전략을 앞서 OpenRewrite를 통해 변경한 패키지 네임스페이스 + 클래스명으로 변경해 해결 했습니다.

// ExampleModulithBeanNameGenerator.kt
class ExampleModulithBeanNameGenerator : AnnotationBeanNameGenerator() {
    override fun generateBeanName(definition: BeanDefinition, registry: BeanDefinitionRegistry): String {
        val beanName = super.generateBeanName(definition, registry)
		val packageName = // 패키지명 파싱
        return "$packageName.$beanName"
    }
}

// SpringApplication.kt
@SpringBootApplication(nameGenerator = ExampleModulithBeanNameGenerator::class)
class SpringApplication

// TestClass.kt
package kr.rapportlabs.queenit.microservice_order

@Component
// 기본 Bean 이름 -> testClass
// 커스텀 기본 Bean 이름 -> microservice_order.testClass
class TestClass 

이외에도 마이크로서비스 간 Flyway 스크립트 버전 충돌을 방지하기 위해 prefix를 지정하고, 빌드 간 중복 모듈명으로 인한 충돌을 해소하기 위해 멀티 모듈의 이름을 재정의하는 등 다양한 빌드 전략을 적용해 멀티 레포 기반 마이크로서비스를 모노레포 모듈리스 구조로 전환했습니다.


5. 정리 & 마무리

팔도감 팀은 조직 규모와 운영 현실에 맞춰 마이크로서비스 대신 모듈리스 아키텍처로 전환했고, 그 결과 다음과 같은 효과를 얻었습니다.

  1. 개발 생산성 ↑

    코드 탐색·빌드·테스트 속도가 빠르며, 퀸잇에 구현된 기능을 손쉽게 활용할 수 있게 됐습니다.

  2. 운영 안정성 ↑

    배포·모니터링 절차가 단순하고, 장애 발생 시 원인을 추적해야 하는 범위가 작습니다.

  3. 비용 ↓

    분산 환경에 비해 서비스·인프라 자원을 적게 사용해 클라우드 비용이 낮습니다.

물론, 이 전환 과정이 간단했던 것은 아닙니다. 퀸잇과 팔도감이 서로 다른 조직으로 운영되던 당시에는, 업스트림 퀸잇 코드 베이스를 직접 수정할 수 없어 상당히 높은 복잡도를 가졌습니다.

하지만 작년 12월, 두 조직이 하나로 통합되면서 이러한 제약이 모두 해소되었습니다. 앞서 언급한 FQCN 충돌 문제 역시, 이제는 업스트림 코드를 직접 수정해 깔끔하게 해결했고, 현재는 팔도감과 퀸잇의 정책 차이를 코드 차원에서 일원화하기 위해 코드 베이스를 전면 병합하는 작업을 진행 중입니다. 앞으로는 두 서비스 간의 이격이 더욱 줄어들고, 그동안 필요했던 복잡한 전략들 역시 대폭 단순화할 수 있을 것으로 기대합니다.

라포랩스는 궁극적으로 두 서비스를 단일 코드베이스에서 동작하는 멀티 프로덕트 구조로 전환하여, 개발과 운영의 효율성·안정성을 극대화하는 플랫폼을 구축하고자 합니다.

팔도감의 모듈리스 전환은 단순한 기술 선택이 아니라, 팀이 더 빠르고 유연하게 일할 수 있도록 하기 위한 전략적인 결정이었습니다. 저희는 이렇듯이 복잡한 문제를 단순하게 풀고 구조를 개선해, 조직 전반에 긍정적인 변화를 만들기 위해 항상 고민하고 있습니다.

라포랩스는 4050을 위한 새로운 라이프 스타일 플랫폼을 만들어가는 여정에 있습니다. 다양한 문제를 마주하고 과감하게 도전하며 해결하고 싶다면, 지금 합류하세요 🚀

Share article

라포랩스