[디자인] 썸네일 제작 요청
이전 Tech 카테고리 콘텐츠와 동일하게 템플릿 적용하여 작업 요청드립니다. (두 사진 중 택 1)
- 텍스트: @Tech, #12


안녕하세요, AI솔루션1팀 박진만입니다.
저희는 데이터 분석부터 모델 학습, 서빙까지 AI/ML 워크플로우 전 과정을 다루는 MLOps 플랫폼 Woodpecker의 백엔드 아키텍처를 설계·구축·운영하고 있습니다. 이번 글에서는 하나의 거대한 플랫폼을 15개의 독립 서비스로 분리하며 마주한 고민들, 그리고 Spring Cloud 생태계와 Python ML 서비스를 어떻게 하나로 묶어냈는지 그 과정을 공유하고자 합니다.
단순히 "MSA를 도입했다"는 결과를 나열하는 것이 아니라, 왜 그렇게 설계했는지와 운영 과정에서 무엇을 배웠는지에 초점을 맞춰 코드 레벨까지 살펴보겠습니다. MSA(Microservices Architecture)도입을 고민하시는 분이나 여러 솔루션의 아키텍처를 비교·검토 중인 분이라면, 공감하실 만한 지점이 있을 것입니다.
1. “왜 모놀리식이 아니라 MSA로 시작했을까?”
Woodpecker는 처음부터 MSA로 설계했습니다. 이전에 다른 제품들을 모놀리식 아키텍처로 개발·운영하며 겪은 문제들이 많았기 때문입니다. 데이터 분석, 모델 관리, 쿠버네티스 리소스 제어, 파일 처리처럼 성격이 전혀 다른 기능들이 하나의 코드베이스에 뒤엉키는 상황을 반복하지 않기 위해, Woodpecker는 시작 단계부터 각 기능을 독립적인 서비스로 분리하는 MSA 구조를 택했습니다.
모놀리식이었다면 부딪혔을 문제는 크게 세 가지였습니다.
- 배포 리스크: 파일 업로드 로직 하나만 수정해도 전체 플랫폼을 재배포해야 했고, 작은 변경이 전체 장애로 번질 위험이 늘 존재했습니다.
- 언어의 제약: 데이터 분석과 모델 학습은 Python으로 하는 게 당연한데, 모놀리식은 전체가 Java 한 덩어리로 빌드되고 배포됩니다. 이 안에 Python을 끼워 넣을 방법이 없으니, 결국 ML 코드만 따로 Python 서비스로 떼어내야 했습니다.
- 확장의 비효율: AI 분석 요청이 몰릴 때 분석 기능만 확장하고 싶어도, 모놀리식에서는 전체를 통째로 스케일 아웃해야 했습니다.
모놀리식 vs MSA
비교 항목 | 모놀리식(Before) | MSA(After) |
배포 단위 | 전체 애플리케이션 일괄 | 서비스별 독립 배포 |
기술 스택 | 단일 언어 강제(Java) | 서비스별 자유 선택(Java + Python) |
장애 격리 | 한 곳 장애 → 전체 다운 | 서비스 단위 격리 |
확장성 | 전체 스케일 아웃 | 필요한 서비스만 확장 |
운영 복잡도 | 코드베이스 비대화 | 네트워크·통신 복잡도 증가 |
그러나 MSA는 만능 해법이 아닙니다. 서비스를 나누는 순간 네트워크 장애, 분산 트레이싱, 서비스 디스커버리 같은 새로운 복잡도가 따라옵니다. 저희는 이 복잡도를 Spring Cloud 생태계로 흡수하는 전략을 택했습니다. 또한 위 표의 '장애 격리'는 애플리케이션 계층에서의 부분 격리를 뜻합니다. 현재는 단일 DB를 공유하고 서킷 브레이커가 없어 데이터 계층 장애나 Cascading Failure까지 막지는 못하며, 이 부분은 9장에서 향후 과제로 다룹니다.
2. Spring Cloud 기반 Polyglot구조
저희가 선택한 핵심 전략은 선택적 Polyglot 아키텍처입니다. 무분별하게 언어를 늘리는 것이 아니라, 신뢰성이 중요한 영역은 Java로 통일하고 데이터 사이언스 영역만 Python으로 분리했습니다. 서비스 간 통신과 운영은 Spring Cloud로 표준화했습니다.
폴리글랏(Polyglot) 아키텍처란, 하나의 시스템을 구축할 때 특정 언어에 국한되지 않고 각 마이크로서비스의 목적에 가장 적합한 언어와 프레임워크를 조합하는 설계를 의미합니다.
- Java(Spring Boot): 인증, 권한, 쿠버네티스 제어, 파일/Git 연동 등 플랫폼의 신뢰성이 중요한 영역을 담당합니다.
- Python(FastAPI): 노코드 ML 파이프라인 등 데이터 사이언스 생태계의 민첩성이 필요한 영역을 담당합니다.
- Vue(Nuxt 3): 프론트엔드는 SSR이 가능한 Nuxt 3 기반 Vue 3로 구성합니다.
2-1. 서비스 전체 구성
서비스 | 스택 | 역할 |
web-front-service | Nuxt 3 / Vue 3 | 프론트엔드 SPA (PrimeVue + Tailwind) |
gateway-service | Spring Cloud Gateway | API 게이트웨이, JWT 인증 |
discovery-service | Spring Cloud Eureka | 서비스 디스커버리 |
auth-service | Spring Boot | 인증·토큰 발급 |
batch-service | Spring Batch | 배치 작업 처리 |
kubernetes-service | Spring Boot | K8s 리소스 관리 |
bff-service | Spring Boot | BFF 허브 (28개 도메인) |
file-service | Spring Boot | 파일 업로드·다운로드 |
license-service | Spring Boot | 라이선스 관리 |
query-service | Spring Boot | 쿼리 에디터 |
git-service | Spring Boot | Git 저장소 연동 |
mlflow-service | Spring Boot | MLflow 연동 프록시 |
work-service | Spring Boot | 워크플로우·작업 관리 |
custom-service | Spring Boot | 고객 맞춤 서비스 |
analysis-service | Python / FastAPI | 노코드 ML 파이프라인 |
2-2. 시스템 아키텍처(System Architecture)
모든 외부 요청은 게이트웨이라는 단일 관문을 거친 뒤, 인증·BFF·비즈니스·AI/ML 레이어로 이어지는 계층 구조를 따릅니다.
디자인 요청(레이아웃)👇🏻


3. API Gateway - 단일 관문의 중앙화
3-1. 문제: 분산된 진입점과 인증 파편화
MSA 초기에 클라이언트가 각 서비스를 직접 호출하는 구조를 떠올렸지만, 곧 한계가 명확해졌습니다. 15개 서비스의 주소를 클라이언트가 모두 알아야 하고, 인증 로직이 모든 서비스에 중복되며, 서비스 주소가 바뀔 때마다 클라이언트까지 수정해야 했습니다.
이를 해결하기 위해 인증·라우팅이라는 공통 관심사를 애플리케이션에서 분리해 Gateway 계층으로 이관했습니다.
3-2. 해결: gateway-service의 세 가지 역할
디자인 요청(레이아웃)👇🏻


① JWT 인증 필터
보호 경로와 공개 경로를 명확히 분리합니다.
핵심은 게이트웨이가
auth-service와 동일한 시크릿 키를 공유한다는 점입니다. 덕분에 토큰 검증을 위해 매번 auth-service를 호출하지 않고 게이트웨이가 로컬에서 즉시 검증합니다. 인증을 위한 추가 네트워크 홉이 사라져 게이트웨이가 병목이 되지 않습니다. 도식 하단에 표시한 것처럼, 유효하지 않은 토큰은 하위 서비스(백엔드)에 도달하기 전에 게이트웨이에서 401로 차단됩니다.② 경로 재작성
검증을 통과한 요청은 경로가 재작성되어 전달됩니다. 클라이언트는
/gateway/bff-service/api/<resource>로 요청하지만, 실제 서비스에는 /api/<resource>로 전달됩니다. 서비스는 자신이 어떤 prefix로 노출되는지 알 필요가 없어 독립성이 보장됩니다.③ CORS 중앙 처리
브라우저에서 동작하는 web-front-service와 Gateway는 출처가 다르므로 CORS 처리가 필요합니다. 각 서비스가 제각기 CORS를 설정하면 정책이 분산되어 일관성이 깨질 수 있으므로, Gateway가 허용 출처·메서드·헤더(
Authorization 등)를 한 곳에서 일괄 관리합니다. 덕분에 하위 서비스들은 CORS를 신경 쓸 필요가 없습니다. (lb://를 통한 동적 라우팅도 Gateway가 수행하지만, 이는 Eureka와 맞물리는 기능이라 다음 장에서 설명하겠습니다.)4. Service Discovery - 동적 위치 관리
4-1. 문제: 쿠버네티스에서 IP는 고정되지 않는다
쿠버네티스 환경에서는 Pod가 재시작될 때마다 IP가 바뀝니다. 고정 IP를 설정 파일에 하드코딩하면 매번 재배포해야 합니다. 이 문제를 해결하기 위해 Eureka 기반 서비스 레지스트리를 도입했습니다.
4-2. 해결: 등록과 조회의 흐름
디자인 요청(레이아웃)👇🏻


4-3. 디테일: self-preservation을 끈 이유
Eureka의 self-preservation란, 기본값이
true로, heartbeat 실패율이 높아질 때 서비스를 섣불리 제거하지 않도록 보호하는 기능입니다.Woodpecker는 쿠버네티스 환경에서 운영하므로 Pod가 죽으면 즉시 레지스트리에서 제거되어야 합니다. 원리는 이렇습니다. Eureka 서버는 heartbeat 누락만으로는 인스턴스가 진짜 죽은 건지, 살아있는데 네트워크만 끊긴 건지 구분할 수 없습니다. 그래서 들어오는 heartbeat 총량이 기대치의 일정 비율(기본 85%) 밑으로 급감하면, 개별 인스턴스가 죽은 게 아니라 대규모 네트워크 문제가 생겼다고 보고, 만료된 인스턴스라도 제거하지 않고 레지스트리를 보존합니다. 살아있는 인스턴스를 오판으로 지워 대규모 장애로 번지는 것을 막는, ‘확실하지 않으면 지우지 말자’는 보수적 전략입니다.
디자인 요청(레이아웃)👇🏻


하지만 이 보호 전략은 K8s에서는 오히려 불리하게 작용합니다. K8s에서 포드는 배포(롤링 업데이트)·오토스케일링·노드 축출 등으로 수시로 사라지고 새로 뜨는 게 장애가 아닌 정상 동작이며, 죽은 포드는 같은 IP로 돌아오지 않습니다. 이때 self-preservation이 켜져 있는 경우와 포드 여러 개가 짧은 시간에 사라지는 경우 heartbeat 유입이 기대치 아래로 떨어져 Eureka가 자기 보호 모드로 들어가고, 이미 존재하지 않는 죽은 포드의 등록 정보가 레지스트리에 계속 남습니다. 그러면 게이트웨이는 그 죽은 IP로 트래픽을 보내 요청이 실패하거나 타임아웃까지 매달립니다. 즉 원래 ‘살아있는 걸 잘못 지우는’ 것을 막는 기능이, K8s에서는 반대로 ‘이미 죽은 유령 인스턴스를 끌어안고 트래픽을 흘려보내는’ 문제를 만듭니다.
게다가 포드의 생사는 이미 kubelet probe가 직접 판단해 Service 엔드포인트에서 빼주므로, Eureka가 "heartbeat 누락"이라는 간접 신호로 보수적으로 판단할 이유가 약합니다. 그래서 저희는 self-preservation을 꺼, 만료된 인스턴스를 보수적으로 끌어안기 보다 빨리 제거해 죽은 서비스로 트래픽이 새는 시간을 줄였습니다. (단, 실제 네트워크 파티션이 발생하면 살아있는 인스턴스까지 제거될 수 있다는 위험은 존재합니다. 다만 K8s에서는 kubelet probe가 포드의 생사를 1차로 판단해 Service 엔드포인트에서 빼주므로 실제 영향은 제한적이며, 현재는 짧은 연결 타임아웃으로 완화하고 있습니다. 서킷 브레이커를 통한 보다 근본적인 보완은 향후 과제로 두고 있습니다. 보다 자세한 내용은 9장을 참고해주세요.)
디자인 요청(레이아웃)👇🏻


5. BFF - 프론트엔드 전용 백엔드
5-1. 문제: 한 화면을 위한 N번의 API 호출
프론트엔드가 대시보드 한 화면을 그리기 위해 user, project, model, alarm 데이터를 각기 다른 서비스에서 가져와야 한다면, 클라이언트가 직접 4번 호출하고 응답을 조합하는 로직까지 떠안아야 합니다.
디자인 요청 👇🏻


5-2. 해결: bff-service의 적용하여 28개 도메인 관리
디자인 요청 👇🏻


bff-service는 이 프로젝트의 실질적 비즈니스 허브로, 다수의 도메인을 담당하며 상황에 따라 데이터 조회와 하위 서비스 호출을 조합합니다.6. 인증 흐름: JWT Security Offloading
6-1. 전체 흐름
디자인 요청 👇🏻


6-2. 핵심: 시크릿 키는 auth와 gateway만 공유한다
auth-service와 gateway-service가 동일한 Base64 시크릿을 공유함으로써, 게이트웨이가 백엔드 호출 없이 토큰을 검증합니다. 유효하지 않은 토큰은 백엔드에 도달하기 전에 게이트웨이에서 차단되어, 하위 서비스들은 인증 로직 없이 비즈니스 로직에만 집중할 수 있습니다.다만 대칭키(HS256) 로컬 검증은 발급된 토큰을 서버가 즉시 무효화(블랙리스트)하기 어렵다는 트레이드오프가 있습니다. 저희는 access 토큰의 만료 시간을 30분으로 짧게 둔 것으로 이를 보완합니다.
7. 분산 트레이싱: Zipkin으로 요청의 여정 추적
✔️ 문제: 요청이 어디서 느려지는가
단일 요청이 gateway → bff → kubernetes → batch 처럼 여러 서비스를 거칠 때, 병목 지점을 찾기 어렵습니다.
분산 트레이싱(Distributed Tracing)은 하나의 요청이 여러 서비스를 거치는 전체 경로를 단일 트레이스로 묶어 추적하는 기법입니다. 각 구간(span)의 소요 시간을 시각화해 병목을 진단합니다.
✔️ 해결: B3 전파 + Zipkin 수집
디자인 요청 👇🏻


각 서비스가 B3 헤더에서
traceId를 추출하고 하위 호출 시 그대로 전파합니다. 모든 span이 Zipkin 서버에 모여 전체 호출 트리로 시각화됩니다. local 환경은 probability: 0.0으로 비활성화해 개발 오버헤드를 제거했습니다. 다만 prod의 전수 수집(1.0)은 트래픽이 커질수록 스토리지·네트워크 오버헤드가 늘어나므로, 현재 트래픽 규모에서는 감당 가능하다는 판단 아래 적용한 값이며, 규모 확대 시 적응형 샘플링으로 전환할 계획입니다.8. analysis-service(Python)
2장에서 선언한 폴리글랏 전략이 말뿐이 아님을 증명하는 서비스입니다. 13개의 Java 서비스 사이에서 유일하게 Python/FastAPI로 살아있는 이 서비스는, 코드 작성 없이 데이터 로드부터 모델 학습까지 처리하는 노코드 ML 파이프라인을 담당합니다. 데이터 분석에 필요한 Python 생태계(pandas, scikit-learn, LightGBM 등)를 그대로 쓸 수 있는 것은, 서비스를 분리한 덕분에 각 서비스가 자기 도메인에 가장 적합한 언어와 도구를 자유롭게 고를 수 있기 때문입니다. Java 모놀리식이었다면 이 생태계를 끌어들일 방법이 없었을 것입니다. 즉 analysis-service는 "MSA라서 언어를 섞을 수 있다"는 명제의 산증인입니다.
디자인 요청 👇🏻



✔️ 언어는 감추고 계약만 드러난다
MSA의 본질은 서비스가 내부적으로 무슨 언어를 쓰든, 정해진 인터페이스로만 통신하면 된다는 것입니다. analysis-service가 이를 잘 보여줍니다. bff-service는 이 서비스가 Python인지 Java인지 알 필요가 없고, 오직 정해진 HTTP 엔드포인트(예:
POST /api/analyze 형태의 분석 요청 계약)만 알면 됩니다. 언어는 서비스 내부의 사적인 결정이고, 외부에는 계약만 노출되기 때문에 서로 다른 언어로 만들어진 서비스들이 하나의 시스템으로 묶일 수 있습니다.✔️ 언어 자유의 대가: 분산의 비용
디자인 요청 👇🏻



그러나 경계를 넘는 순간 분산 고유의 비용도 따라옵니다. bff-service가 ML 학습을 호출할 때는 학습이 오래 걸려, 현재 규모에서는 메시지 큐 없이 직접 동기 호출하되 읽기 타임아웃을 35분(connect 30초)까지 두는 방식을 택했습니다. 다만 이 방식은 스레드 점유가 길다는 한계가 있어, 트래픽이 늘면 메시지 큐 기반 비동기 처리로 전환하는 것을 검토하고 있습니다. 모놀리식이라면 함수 호출이라 고민할 필요 없었을 타임아웃·스레드 점유가, 원격 호출이 되면서 생긴 분산 고유의 비용입니다. 즉 언어 자유라는 이점과 네트워크 경계라는 비용은 동전의 양면과 같습니다.
9. 운영하며 마주한 한계와 개선 과제
솔직하게, 현재 구조에도 개선할 점이 있습니다. 코드를 분석하며 발견한 트레이드오프와 향후 과제를 공유합니다.
✔️ 단일 DB 공유
모든 서비스가 하나의 PostgreSQL을 공유합니다. MSA의 이상은 서비스별 독립 DB지만, 사실 Database per Service의 이점(독립 확장·장애 격리·스키마 독립)은 트래픽이 크거나 여러 팀이 동시에 스키마를 건드리는 대규모 환경에서 주로 체감됩니다. 현재 규모에서는 단일 PostgreSQL로 충분히 감당되고, 무리하게 나누면 서비스 간 조인이 사라지고 분산 트랜잭션(Saga 등) 복잡도만 커지므로 단일 DB가 합리적인 선택입니다. 다만 코드와 배포는 갈라놓고 DB만 공유하면 서비스 경계가 데이터 계층에서 허물어진다는 약점은 인지하고 있습니다. 그래서 지금 당장의 과제라기보다, 트래픽 증가나 팀 분화 시점에 분리를 검토할 옵션으로 두고 있습니다.
✔️ analysis-service의 Eureka 미등록
현재 Python 서비스는 서비스 디스커버리 밖에서 고정 주소로 참조되고 있어, K8s 사이드카 패턴을 활용해 디스커버리 체계에 통합하는 작업을 검토·진행 중입니다. 이를 통해 서비스 위치 관리의 일관성을 높일 계획입니다.
✔️ 서킷 브레이커 부재
장애 격리를 한층 강화하기 위해 Resilience4j 기반 서킷 브레이커 도입을 진행하고 있습니다. 도입 전에는 하위 서비스의 응답 지연이 상위로 전파될 여지(Cascading Failure)가 있어, 짧은 타임아웃으로 완화하면서 서킷 브레이커로 근본적으로 보완하는 방향입니다.
마치며
MSA는 서비스를 잘게 쪼개는 작업이 아니라, 공통 관심사(인증·디스커버리·트레이싱)를 인프라 계층으로 끌어올려 각 서비스가 본질에만 집중하게 만드는 과정이었습니다. 그 핵심 가치는 폴리글랏에 있습니다. 신뢰성은 Java, 데이터 사이언스는 Python, 프론트엔드는 Vue가 맡되 Spring Cloud Gateway와 Eureka가 이들을 하나로 묶었습니다. 앞으로는 Database per Service 분리, 서킷 브레이커 도입, Python 서비스의 디스커버리 통합을 단계적으로 풀어갈 계획입니다. 복잡한 분산 시스템을 안정적으로 운영하는 일은 곧 제품의 품질을 높이는 일이고, 품질 높은 제품은 고객 만족으로 이어진다고 믿습니다. 그래서 분산 환경의 안정적 운영을 제품의 경쟁력으로 여기며, 오늘도 더 나은 아키텍처를 고민하고 있습니다.
함께 보면 좋은 아티클