본문 바로가기
MSA (Micro Service Architecture)/Legacy to Domain-Driven Platform

[MSA 말고 Modular Monolith] 1편 — PHP Monolith의 한계, 전환을 결정한 이유

by kellis 2026. 6. 26.

 


이 시리즈는 글쓴이 본인이 수행한 레거시 서비스를 리뉴얼 전환한 과정에서 수행한 의사 결정 사항들을 정리한 글입니다. 
실제 운영 중인 PHP 레거시 에듀테크 플랫폼을 Python + React + K3S 환경으로 전환하는 과정에서 
아키텍처 설계와 인프라 구성에 대한 내용이 포함되어 있습니다. 

 

목차

  • 1편 — PHP Monolith의 한계, 전환을 결정한 이유 
  • 2편 — DDD로 도메인 경계 찾기: 레거시 DB 테이블 분석

 

 

 


 

 

1. 기존 레거시 시스템 AS-IS

1-1 서비스 구성

운영 중인 플랫폼은 크게 네 개의 서비스로 이루어져 있다. 

 

Service A   교사용 학습 관리 서비스
Service B   학생용 학습 서비스
Service C   가맹점 기반 학습 관리 서비스 (Service A 계열)
Service D   가맹점 기반 학습 서비스 (Service B 계열)

 

각 서비스들은 각각의 관리자 서비스가 별도로 존재하며, Service A, Service C의 경우 지사용 관리 서비스가 추가로 존재한다.

즉, 유지보수 대상 애플리케이션은 총 10개다.

 

 

1-2 기술 스택

언어         PHP (자체개발 MVC 프레임워크 사용)
웹서버       Apache
주 DB        MySQL
보조 DB      Berkeley DB
버전관리     SVN (거의 사용 안 함)
배포         bin/source_push.sh (rsync 파일 복사 방식)

 

 

1-3 서버 구성

서버는 운영 서버 2대, 개발 서버 1대를 운영중이며 Self-managed VPS 로 사용중이다. 

web01   사용자 서비스 운영
        ├── Service A LMS
        ├── Service A branch
        ├── Service B Student
        ├── Service C F LMS
        ├── Service C F Branch
        ├── Service D F Student
        └── Berkeley DB (임시저장소)

db01    DB 서버 + 관리자 서비스
        ├── MySQL
        │   ├── svc-a DB
        │   ├── svc-b DB
        │   ├── svc-c DB
        │   ├── svc-d DB
        │   └── common DB
        ├── Berkeley DB
        ├── Service A Admin
        ├── Service B Admin
        ├── Service C Admin
        └── Service D Admin

 

web01에는 사용자 서비스 및 지사용 서비스가 올라가 있고, db01에는 MySql 데이터베이스와 관리자 서비스가 존재한다. 

본사에서만 접근하는 관리자 화면이라 DB서버에 함께 올라간 게 아닐까 추정한다. 

 

이미지로 보면 아래와 같다. 

 

 

1-4 디렉토리 구조

서버 내부를 열어보면 구조는 아래와 같다. 그리고 해당 구조는  web01과 db01 모두 동일하다.

/home/platform/
├── apps/        서비스별 웹 애플리케이션
├── lib/         공용 비즈니스 로직 + DB 모델
├── frm/         FrameX 프레임워크 + 기반 라이브러리
├── data/        파일 저장소 (PDF, 교재, 강의자료)
└── bin/         배포 스크립트

 

apps/ 아래 서비스 중인 애플리케이션들은 거의 동일한 구조를 가진다. Service A를 예로 보자면 다음과 같다.

apps/svc-a/
├── htdocs/       웹 루트, index.php 진입점
├── conf/         설정 파일 (경로, DB 연결, 서비스 설정)
├── controllers/  컨트롤러
├── views/        화면 템플릿
└── bin/          배치 스크립트

 

그리고 모든 앱들은 lib/와 frm/ 아래의 공용 코드를 함께 바라보고 있다.

이것이 이 구조의 핵심이고, 전환을 결심하게 된 시작점이기도 하다.

 

 

1-5 Request 처리 흐름 

사용자가 서비스에 접근하게 되면 요청은 아래와 같은 순서로 처리된다.

Request
  ↓
htdocs/index.php            진입점
  ↓
conf/dir.php                절대 경로 설정 (/home/platform/...)
  ↓
frm/libs/framex/fx.php      FrameX 프레임워크 로드
  ↓
conf/web.php                서비스별 DB 연결, 모델 디렉터리 설정
  ↓
fx()->service()             URL → Controller/Method 라우팅
  ↓
controllers/*.php
  ↓
views/*.php
  ↓
Response

 

라우팅은  URL의 마지막 두 경로를 controller/method로 해석한다.

/classroom/list가 들어오면 ClassroomController의 list() 메서드를 호출하는 식이다.

(PHP 레거시 프레임워크의 기본 패턴)

 

 


 


2. 기존 시스템 구조의 문제들 

2-1 Shared Framework Monolith — 경계가 없는 공유 구조

현재 구조를 한 마디로 표현하면 이러하다.

여러 서비스 앱이 하나의 프레임워크와 공용 코드베이스를 공유하는 구조

 
 
Service A App ──┐
Service B App ──┤
Service C App ──┤──► lib/app/
Service D App ──┤    (공용 인증, 결제, 업로드, 리포트, 외부 API)
     ...    ────┘
                       ↓
               lib/models.*
               (서비스별 ActiveRecord 모델)
                       ↓
               frm/libs/framex
               (FrameX 프레임워크)

 

lib/app/ 아래를 실제로 열어보면 이런 파일들이 있다.

svc_a_auth.php         Service A 인증
svc_b_auth.php         Service B 인증
svc_c_auth.php         Service C 인증
svc_a_payment.php      Service A 결제
svc_b_payment.php      Service B 결제
svc_a_report.php       리포트
svc_a_uploader.php     파일 업로드
lib_udb.php            Berkeley DB 접근 래퍼
lib_api.php            외부 api 연동 래퍼
...

 

 

현재 구조에서 인증 로직 하나를 바꿔야한다고 가정해보자. svc_a_auth.php 를 수정하면 Service A에만 영향이 가는 것처럼 보이지만, 실제로는 이 파일이 어디에서 어떻게 참조되고 있는지 전부 확인해야 한다. 인증, 결제 등등 다른 로직도 마찬가지다. 

 

변경의 영향 범위를 예측하기 어렵다. 코드를 고치는 것보다 "이 코드를 고쳐도 되는가" 를 확인하는 데 더 많은 시간이 걸리고, 이것이 바로 Shared Framework Monolith 가 커졌을 때 나타나는 가장 전형적인 증상이다. 

 

 

 

2-2 Cross DB Access — PHP가 DB 경계를 무너뜨렸다

DB는 서비스 별로 분리되어있다. 이것만 보면 서비스가 어느 정도 잘 나뉘어 있는 것처럼 보인다.

svc-a DB   Service A 데이터
svc-b DB   Service B 데이터
svc-c DB   Service C 데이터
svc-d DB   Service D 데이터
common DB  공통 콘텐츠 (교재, 문항, 커리큘럼 등)

 

 

그러나 실상 모델 파일 목록을 보면 이야기가 달라진다. Service A 모델 폴더 안에는 이런 파일들이 있다.

SBStudent.php          → Service B DB 참조
SBPayment.php          → Service B DB 참조
SCAcademy.php          → Service C DB 참조
SCStudent.php          → Service C DB 참조

 

반대로 Service B 모델 폴더 안에는 이런 파일들이 있다.

SATeacher.php          → Service A DB 참조
SAStudent.php          → Service A DB 참조
SAClassroom.php        → Service A DB 참조

 

Service A와 Service B가 서로의 DB를 양방향으로 직접 참조하고 있다. 

 

이건 PHP ActiveRecord 가 코드 레벨에서 DB 연결을 자유롭게 전환할 수 있기 때문에 가능했던 방식이다. 

코드레벨에서 구현함에 있어서는 굉장히 편리한 방식이지만, 

결론적으로 두 서비스가 논리적으로만 분리되어 있을 뿐 데이터레벨에서는 완전히 결합된 형태가 되어버린다. 

 

이게 무엇이 문제냐고 한다면, 

 

Service A의 Students라는 테이블 스키마를 바꿨다고 가정한다면, Service B의 코드도 함께 바꿔야하고 반대의 경우도 마찬가지다. 즉, 두 서비스를 항상 함께 배포해야 하고, 독립적으로 변경하거나 확장하는 것이 불가능하다는 것을 알 수 있다. 

 

 

 

2-3 Berkeley DB — 건드릴 수 없는 제약

Berkeley DB는 학생별 문항 풀이 데이터를 저장하는 저장소이다. student_id 값을 Key로 사용한다. 

 

문제는 크게 두 가지이다. 

 

첫째, Key 체계를 변경할 수 없다. Service A와 Service B의 student_id는 각자의 MySQL DB에서 독립적으로 생성된 값이다. 신규 시스템에서 통합 사용자 개념을 도입하더라도 기존 BDB(Berkeley DB)에 쌓인 데이터의 key는 절대 바꿀 수 없다. 바꾸려면 수백, 수천만 건의 데이터를 전부 새 key로 마이그레이션해야 하는데, 이는 운영중인 환경에서 할 수 있는 작업은 아니다. 

 

 

둘째, PHP에서 파일을 직접 접근하는 방식으로 사용 중이다. lib 폴더 아래에 존재하는 lib_api.php, lib_udb.php 가 BDB 파일에 직접 접근하는 래퍼 역할을 하고 있다. 리뉴얼을 진행하게 되면 기술 스택이 변경될텐데, 파일 직접 접근 방식은 언어가 바뀌면 그대로 가져갈 수 없다. 

 

 

 

 

2-4 배포와 운영 — 수동이 기본값인 구조

현재 배포 방식은 아래와 같다

코드 수정
  ↓
bin/source_push.sh 실행
  ↓
rsync로 파일 복사
  ↓
(필요시) Apache 재시작

 

CI/CD가 없고, 배포 히스토리가 존재하지 않는다. 또한 롤백 기준이 없다. 장애가 나면 "어디서 무엇이 바뀌었는지" 추적이 어렵다.

모니터링도 마찬가지다. 현재 서버 상태를 실시간으로 볼 방법이 없다. 

응답이 느려지고 있진 않은지, 에러가 늘어나고 있는지를 선제적으로 파악하는 구조가 아니다. 

장애가 발생하더라도 사용자의 신고로 알게 되는 구조라는 말이다. 

 

 


 

 

3. 전환을 결정한 트리거들

구조적 문제는 글쓴이가 베타 버전 서비스를 인수할때부터 존재했다. 그러나 베타서비스에서 이미 사용자가 유입되었기 때문에 빠른 정식 서비스의 오픈이 중요했고, 이로 인해 레거시 구조를 유지한 채로 기능의 확장이 이루어졌다. 

이 과정에서 Cross DB 의존도는 더욱 높아졌고, 서비스간의 결합도 역시 높아져갔다. 

 

이제는 전환을 해야되겠다라고 결정한 데는 아래와 같은 트리거가 있었다. 

 

트리거 1.실시간 알림 기능의 요구사항이 생겼다. 교사와 학생이 각각 서로 다른 웹 서비스를 이용하다보니, 실시간 상호작용 기능이 필요해졌다. 시험지가 출제되었거나, 학생이 학습을 완료했거나, 혹은 알림장이 발송되었다거나. 기존 Polling 방식으로는 더 이상 만족스러운 사용자 경험을 제공할 수 없었고, 구조 자체를 바꾸지 않으면 해결이 되지 않았다. 

 

트리거 2. 모바일 앱 구축이 필요했다. 앱의 필요성에 대한 요구사항이 대두되었고, PHP 뷰 템플릿으로는 앱 대응이 불가능했다. 별도의 API 서버가 필요했다. 

 

트리거 3. 개발 생산성의 한계가 왔다. 기능 하나를 추가할 때마다 여러 서비스에 걸쳐 영향 범위를 확인해야 했고, 여기서 놓치면 변경하지 않은 서비스에서 오류가 발생하는 경우가 발생했다. CrossDB 의존성 때문에 Service A를 바꾸려면 Service B 코드도 봐야하니 변경이 두려운 구조가 되었다. 

 


 

 

4. 어떻게 바꿀 것인가 — TO BE

4-1 아키텍처 방향

이번 전환의 목표는 단순히 PHP를 걷어내는 언어 변환 작업이 아니다.

FrameX 기반 Shared Framework Monolith → DDD 기반 Modular Monolith Platform으로 전환

 

 

 

아키텍처의 방향은 아래 4가지를 함께 가져간다. 

 

  • DDD(Domain-Driven Design) 도메인 주도 설계 
    • 도메인 경계를 DB명 기준이 아닌, 업무 책임 단위로 정의한다. "어떻게 나눌 것인가" 보다는 "어디서 나눌 것인가"가 먼저!
  • Modular Monolith 
    • MSA처럼 서비스를 잘게 쪼개지 않는다. 하나의 FastAPI 안에서 도메인 경계를 지키는 것이 현재 서비스 규모와 팀 규모에 맞는 현실적인 선택! ( 이에 대한 근거는 MSA Trend 2026 글을 참고! )   
  • Vertical Slice Architecture
    • 레이어( Controller → Service → Repository ) 단위가 아닌, 기능( Feature ) 단위로 코드를 묶는다. 하나의 기능을 수정할땐, 하나의 폴더만 열면 된다.
  • Event-Driven Architecture 
    • 도메인 간 직접 호출 대신 이벤트 통신을 이용한다. 기존 CrossDB Access 문제를 이벤트로 해소한다. 

 

 

4-2 서비스별 전환 전략

서비스 요구사항에 맞춰 아래와 같이 서비스를 가져간다.

Service A, B   React + FastAPI로 재구축
               실시간 기능에 대한 요구사항이 존재하고, 모바일 앱 구축이 필요하다.
               구조적 문제가 가장 심각한 서비스들이다.

Service C, D   기존 PHP 그대로 유지, 신규 서버로 이관만
               추가 기능에 대한 요구사항이 없고, 운영 안정성이 중요하므로, 재구축할 이유가 없다.
               컨테이너화만 해서 옮긴다.

 

Service C, D를 재구축하지 않는 이유는 간단하다. 재구축이 필요할 만큼의 추가 기능에 대한 요구 사항이 없으며, 안정적인 운영이 중요한 서비스이다. 재구축으로 얻는 이점이 없고, 오히려 리스크만 존재하기 때문에 그대로 유지한다. 

 

 

 

4-3 전체 시스템 구조 

신규 서버에 구축될 TO BE 시스템 구조는 아래와 같다.

 

 

 

4-4 Cross DB Access, 이벤트로 해소한다.

기존 방식은 이러했다.

[AS-IS]
Service A 코드 ──────────────────────► Service B DB 직접 참조
Service B 코드 ──────────────────────► Service A DB 직접 참조

 

이것을 이벤트 기반으로 바꾼다.

[TO-BE]
교사가 시험지 출제
      ↓
Assessment Domain → exam.assigned 이벤트 발행
      ↓
Redis Streams (Event Bus)
      ↓
Notification Domain 구독
      ↓
SSE (웹) + FCM (앱) 전달
      ↓
학생 화면에 실시간 알림 도착

 

두 도메인이 서로의 DB를 직접 바라보지 않는다. Notification Domain은 "시험지가 출제되었다"는 이벤트만 받고, 그것을 전달하는 일에만 집중한다.

 

주요 이벤트 흐름은 두 가지다.

교사 시험 출제 → 학생 알림

Assessment Domain → exam.assigned → Redis Streams
→ Notification Domain → SSE + FCM → 학생


학생 학습 완료 → 교사 알림

Learning Domain → study.completed → Redis Streams
→ Notification Domain → SSE + FCM → 교사

 

실시간 처리에 있어 WebSocket이 아니라 SSE(Server-Sent Events)를 선택한 데에는 이유가 있다.

 

우리가 필요한 건 서버 → 클라이언트 단방향 알림이다.

 

교사가 시험지를 출제하면 학생 브라우저에 알림이 가는 것처럼, 이 패턴은 서버에서 클라이언트로 데이터를 밀어주는 단방향이다. 따라서, WebSocket의 양방향 통신은 이 케이스에서 오버스펙이라는 결론이다.

 

SSE는 HTTP 위에서 동작하고 브라우저 기본 지원이 되며, FastAPI에서 StreamingResponse로 깔끔하게 구현할 수 있다.

 

 

 

4-5 Berkeley DB는 어떻게 다룰 것인가

BDB의 key 체계는 변경할 수 없다. 따라서 아래와 같이 접근한다. 

 

신규 시스템에서 canonical student_id를 새로 생성한다.
  ↓
기존 svc-a student_id, svc-b student_id는
각 서비스의 external key (udb_key)로 매핑한다.
  ↓
Berkeley DB 접근은 berkeley-api 서비스로 추상화한다.
PHP(Service C, D)와 Python(platform-api) 모두 HTTP로 접근한다.
  ↓
BDB 파일 자체는 건드리지 않는다.

 

berkeley-api는 일종의 Legacy Adapter다. BDB를 수정하지 않으면서도 신규 시스템에서 기존 데이터를 그대로 쓸 수 있게 해주는 중간 레이어이다.

 

 

 

4-6  K3S를 선택한 이유

배포대상 컨테이너만 세어보아도 이미 15개가 넘는다.

svc-a-lms, svc-a-admin
svc-b-web, svc-b-admin
platform-api, berkeley-api
svc-c, svc-d (PHP 컨테이너)
redis
prometheus, grafana, loki
traefik (k3s 기본 내장)

 

K8S는 단순 컴포즈 관리를 위해서만 세팅하기에는 과도한 오버스펙이라는 판단이었다.

그리고 Docker Compose는 단일 호스트를 전제로 한 도구다.

현재 서버 규모에서 Health Check, Service Discovery, Rolling Update 를 Docker Compose로 관리하려면 전부 수작업이 된다. 

 

그래서 K3S를 선택하게 되었다. 

K3S는 이것들을 선언적으로 처리하고, Treafic Ingress도 기본 내장되어 있으므로 서비스 라우팅을 자동으로 해준다. 노드를 추가할 때에도 기존 구성 변경 없이 워커 노드만 붙일 수 있다. 또한 K3S는 단일 바이너리로 설치되며 기존 K8S와 API가 호환되기 때문에, 추후에 규모가 커져서 K8S 전환이 필요하게 되더라도 자연스럽게 전환이 가능하다. 

 

 

 


 

5. AS IS vs TO BE 한눈에 비교


항목 AS IS TO BE
아키텍처 Shared Framework Monolith DDD 기반 Modular Monolith
언어/프레임워크 PHP + FrameX (자체 프레임워크) Python + FastAPI / React
서비스 경계 없음 (공용 코드 공유) 도메인 단위 명확한 경계
DB 접근 방식 Cross DB Access (직접 참조) 도메인 내 소유, 이벤트로 통신
실시간 기능 Polling SSE + Redis Streams + FCM
배포 방식 파일 복사 (rsync 수동) Docker + k3s + CI/CD
버전관리 SVN Git
모니터링 없음 Prometheus + Grafana + Loki
서버 구성 web01 + db01 app01 (k3s) + db01 (DB 전용)
프론트엔드 PHP View 템플릿 React Monorepo
모바일 없음 React Native

 

 

 


 

 

6. 전환 원칙 : 무엇을 바꾸지 않아야 하는가

"무엇을 바꾸는가"만큼 "무엇을 바꾸지 않는가"도 중요하다.

 

  • Berkeley DB는 건드리지 않는다. 기존 Key 체계를 그대로 유지. berkeley-api로 추상화하고 BDB 내부 구조는 손대지 않는다.
  • Service C, D는 리팩토링하지 않고 그대로 이관한다. 컨테이너 내부에 기존 디렉터리 구조를 그대로 재현하며 코드는 건드리지 않는다. 
  • 바꿔치기 방식으로 전환한다. 신규 서버에 구축한 뒤, DNS 또는 로드밸런서 레벌에서 트래픽을 전환한다. 언제든 롤백 가능하도록 기존 서버를 유지한 채로 진행한다. 
  • 개발 서버에서 먼저 검증한다. 개발에서 전체 아키텍처 구조가 제대로 동작하는 것을 확인한 뒤 운영 서버에 적용한다. 

 

 


 

 

마치며

이 글에서는 설계 결정 사항들을 나열했다. 그리고 이 결정들이 어떠한 근거로 나왔는지는 다음 편에서 더 구체적으로 다룬다. 특히 도메인 경계는 레거시 DB의 테이블을 직접 분석한 결과에서 나온것으로, DB 테이블 분석을 통해 도메인 경계를 찾는 과정을 다루고자 한다. 

레거시 시트메에서 DDD의 Bounded Context를 어떻게 도출하는 지, 그 과정을 기록한다. 

 

 

 

 

 

** 참고하면 좋은 자료 

 

Modular Monolith — Simon Brown
원전 글: 모듈러 모놀리스는 모든 코드가 단일 소스 트리에 존재하는 모놀리식 애플리케이션을 의미하며, 잘 정의된 컴포넌트는 마이크로서비스로 가는 디딤돌이 될 수 있다는 개념을 정립했어요.
https://simonbrown.je/modular-monolith/ Spcmagazine

 

Simon Brown - Modular monolith

An alternative to package by layer, package by feature, and ports & adapters/hexagonal architecture This is a republish of a blog post that was originally published on the "Coding the Architecture" blog in 2016, which itself was based on content I'd writte

simonbrown.je

 

Vertical Slice Architecture — Jimmy Bogard
"슬라이스 간 결합은 최소화하고, 슬라이스 내부 결합은 최대화하라"는 원칙으로 알려진 이 패턴을 처음 제시한 글이에요.
https://www.jimmybogard.com/vertical-slice-architecture

 

Vertical Slice Architecture

Many years back, we started on a new, long term project, and to start off with, we built the architecture around an onion architecture. Within a couple of months, the cracks started to show around this style and we moved away from that architecture and tow

www.jimmybogard.com

 

 

Strangler Fig — Martin Fowler
2001년 호주 퀸즐랜드 열대우림에서 본 스트랭글러 무화과 나무에서 착안해, 레거시 시스템을 점진적으로 교체하는 방식의 비유로 만든 개념이에요. 2024년에 글이 개정됐어요.
https://martinfowler.com/bliki/StranglerFigApplication.html

 

bliki: Strangler Fig

Inspired by the strangler figs in Australia, a strangler fig application gradually draws behavior out of its host legacy application

martinfowler.com

 

SSE (Server-Sent Events) — MDN
WebSocket과 달리 서버에서 클라이언트로만 데이터가 전달되는 단방향 방식이라, 클라이언트가 메시지 형태로 데이터를 보낼 필요가 없을 때 좋은 선택이라고 설명해요.
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events

 

Server-sent events - Web APIs | MDN

 

developer.mozilla.org

 

 

k3s 공식 문서
모든 Kubernetes 컨트롤 플레인 구성 요소가 단일 바이너리와 프로세스로 캡슐화되어 있어 복잡한 클러스터 운영을 자동화한다고 소개해요. 단일 노드 설치만으로 완전한 기능의 클러스터가 된다는 점도 확인돼요.
https://docs.k3s.io

 

K3s - Lightweight Kubernetes | K3s

Lightweight Kubernetes. Easy to install, half the memory, all in a binary of less than 100 MB.

docs.k3s.io

 

'MSA (Micro Service Architecture) > Legacy to Domain-Driven Platform' 카테고리의 다른 글

MSA Trend 2026  (4) 2026.06.16

댓글