MSA 품질관리 CI게이트로 풀어내기

MSA 품질관리 CI게이트로 풀어내기

1. 품질관리체계를 CI에 두기로 한 이유

저는 DevOps 엔지니어 역할로 현재 한 MSA 컨설팅에 참여하고 있습니다. 컨설팅이라고 하면 보통 서비스 경계나 도메인 분리 같은 설계 얘기를 떠올리실 텐데, 제가 맡은 영역은 그쪽이 아닌 품질관리체계였습니다. 이 글은 그 작업에 대한 기록입니다.

품질관리라고 하면 코드 스타일, 정적분석, 테스트 커버리지, 이미지 취약점 스캔 같은 항목들이 떠오릅니다. 항목 자체는 새로울 게 없습니다. 정작 어려운 건 "이걸 어디에서, 어느 정도의 강제력으로 돌릴 것인가"였습니다. MSA에서는 이 질문이 생각보다 훨씬 무겁습니다.

서비스를 잘게 나누면 배포 단위도 그만큼 늘어납니다. 어제까지 하나였던 배포가 열 개, 스무 개로 쪼개지고, 각 서비스가 제각각의 주기로 운영에 올라갑니다. 이런 상황에서 품질 항목을 "되도록 지키자" 수준의 권장사항으로 두면, 서비스가 늘어나는 속도를 빈틈이 따라잡습니다. 실제로 처음 합류했을 때가 딱 그런 상태였습니다.

그래서 품질을 관리할 자리부터 다시 잡았습니다. 결론은 CI였습니다.

CI는 모든 변경이 반드시 한 번은 지나가는 길목입니다. 이 길목에 품질 기준을 게이트로 박아두면, 누가 작업하든 어떤 서비스든 같은 기준을 거치게 됩니다. 사람의 성실함이 아니라 파이프라인이 기준을 들고 있게 되는 셈입니다. 품질을 "지키면 좋은 것"에서 "통과하지 못하면 다음으로 못 넘어가는 것"으로 바꿀 수 있는, 가장 자연스러운 위치가 여기였습니다.

아래는 그 관점에서 품질 항목들을 CI 안에 어떻게 엮었는지에 대한 이야기입니다.

2. 에이전트 코딩과 품질

게이트 설계에 들어가기 전에 깔고 간 전제가 하나 있습니다. 요즘은 코드를 짜는 방식 자체가 빠르게 바뀌고 있다는 점입니다. 개발의 상당 부분이 AI 에이전트와 함께 이뤄집니다. 저는 이 변화가 품질 입장에서는 위협이 아니라 기회라고 봤습니다.

테스트 코드가 대표적입니다. 예전에는 테스트를 충분히 짜는 것 자체가 비용이었습니다. 커버리지를 80%까지 올리려면 경계값과 예외 케이스를 사람이 일일이 만들어야 했고, 그 시간이 아까우니 핵심 경로만 대충 덮고 넘어가기 일쑤였습니다. 가장 미뤄지던 작업이 테스트였죠.

에이전트를 붙이면 이 지점이 달라집니다. 경계 조건이나 예외 흐름까지 포함한 테스트를 빠르게 뽑아낼 수 있고, 반복적이고 지루해서 미뤄지던 작업일수록 효과가 큽니다. 같은 시간에 커버리지를 더 높게, 더 촘촘하게 가져갈 수 있게 된 겁니다. 도구가 좋아진 만큼 품질의 목표선도 같이 올려 잡을 수 있다는 뜻입니다.

다만 전제가 하나 붙습니다. 에이전트가 만든 코드든 테스트든, 그게 정말 의미 있는 검증인지를 누군가는 일관되게 판정해야 합니다. 이 판정을 사람 눈에만 맡기면 생산 속도는 빨라지는데 검증 속도가 못 따라갑니다. 만들어내는 양은 늘었는데 거르는 손은 그대로니까요.

그래서 "에이전트로 더 빨리, 더 많이 만든다"와 "CI 게이트로 기준을 일관되게 강제한다"는 한 쌍으로 가야 합니다. 만드는 속도가 빨라질수록, 그 결과물을 올릴지 말지를 기계적으로 판정해 주는 게이트의 값어치는 오히려 올라갑니다. 이 글의 나머지는 그 게이트를 어떻게 짰는지에 대한 내용입니다.

3. CI 품질 게이트

설계 원칙 자체는 단순합니다. 결함은 앞에서 잡을수록 싸고 뒤로 갈수록 비쌉니다. 이 사실을 파이프라인 구조에 그대로 박는 것이 전부입니다.

그래서 품질 게이트를 하나의 큰 검사로 두지 않고, 책임이 다른 네 단계로 쪼갰습니다.

조기 차단 → 기준선 확정 → 운영 후보 검증 → 승격 판정

단계마다 통과의 의미가 다릅니다. 조기 차단을 넘으면 병합이 열리고, 기준선 확정을 넘으면 공식 품질 기준선으로 등록되고, 운영 후보 검증까지 통과해야 비로소 승격 심사 대상이 됩니다.

여기서 하나는 분명히 못 박아 두었습니다. 브랜치 전략이 무엇이든 품질 통제의 기준만큼은 동일하게 간다는 점입니다. 전략은 단계와 상황에 따라 바뀔 수 있지만, 게이트가 요구하는 품질 수준이 전략 따라 흔들리면 그건 기준이라고 부를 수 없으니까요.

이 프로젝트는 구축(SI) 단계에서는 릴리즈 통제가 중요해 GitFlow를 기본으로 두고, 운영(SM) 단계에서 자동화가 충분히 성숙하면 Trunk-based로 넘어가는 경로를 함께 그렸습니다. 그래서 게이트는 공통 모델로 정의해 두고, 실행 지점만 브랜치 전략에 맞춰 매핑했습니다. 전략을 갈아끼워도 검사 내용은 그대로 따라오게요.

개인적으로는, MSA를 모노레포로 운영하는 환경이라면 결국 Trunk-based가 더 잘 맞는다고 생각합니다. 모노레포에서는 여러 서비스가 한 저장소를 공유하는데, 여기에 GitFlow처럼 release·develop·feature 브랜치가 길게 갈라지면 서비스 수만큼 통합 지점이 늘어납니다. 충돌과 머지 비용도 같이 불어나고요. Trunk-based는 짧게 작업하고 자주 트렁크로 합치니, 변경들이 멀리 떨어져 있다가 한꺼번에 부딪히는 상황 자체가 줄어듭니다.

여기에 뒤에서 이야기할 selective CI가 더해지면 궁합이 꽤 좋습니다. 저장소에 서비스가 많아도 변경 영향 범위만 골라 검증하니, 자주 통합하면서도 CI 비용이 감당 가능한 선에서 유지됩니다. 통합 주기가 짧아 생기는 리스크는 품질 게이트가 받쳐주고요. 그래서 이 프로젝트도 SI에서는 GitFlow로 시작하되, 운영이 안정되면 Trunk-based로 옮겨가는 쪽을 권고안으로 정리해 두었습니다.

[ 그림 1. 브랜치 전략과 무관하게 동작하는 품질 게이트 단계 ]

image1.png

이렇게 단계와 책임을 갈라놓고 나니, "이 변경이 지금 어느 단계의 무엇을 통과해야 하는가"가 분명해졌습니다. 리뷰어 한 사람이 모든 걸 머리로 떠안던 구조에서, 파이프라인이 1차 방어선을 맡는 구조로 넘어간 셈입니다.

게이트를 세우면서 현실적으로 부딪힌 문제가 하나 더 있었습니다. 서비스가 많은 모노레포에서는 변경 하나마다 전체를 다 검증하면 CI 시간이 금세 통제 불능이 됩니다. 커밋 한 줄 고치고 20분을 기다리게 만들면 아무도 게이트를 좋아하지 않습니다.

그래서 selective CI를 적용했습니다. 변경된 모듈만 빌드하고, 의존 그래프를 따라 영향을 받는 서비스만 테스트 대상에 넣는 방식입니다. 대신 무작정 좁히지는 않았습니다. 공통 라이브러리나 이벤트 계약, 인프라가 바뀌거나 릴리즈 후보를 만들 때는 범위를 좁히지 않고 전체를 돌리도록 예외를 명시했습니다. 속도 욕심에 회귀를 놓치는 게 제일 무서우니, "좁히되 위험한 변경은 반드시 넓힌다"는 선을 분명히 그었습니다.

그런데 selective CI로 시간을 줄여도 부족한 순간이 옵니다. 검증을 다 거칠 여유 없이 지금 당장 나가야 하는 상황 말입니다. 긴급 핫픽스가 그렇고, 데모나 시연 일정에 쫓기는 날도 그렇습니다. 이건 의지의 문제가 아니라 그냥 현장에 늘 있는 일입니다.

그래서 품질 게이트의 검증 항목들을 옵션으로 켜고 끌 수 있게 만들었습니다.

정적분석, 통합 테스트, 커버리지 검사, 취약점 스캔처럼 무거운 검증은 파이프라인 파라미터로 노출해서 상황에 따라 일부를 건너뛸 수 있게 했습니다. 다급할 때 게이트가 통째로 발목을 잡으면, 사람들은 결국 CI 바깥으로 우회하는 더 위험한 길을 찾습니다. 그럴 바엔 "이번엔 무엇을 끄고 간다"를 파이프라인 안에서 드러내놓고 고르게 하는 편이 낫다고 봤습니다.

물론 끄는 데는 대가를 달았습니다. 어떤 검증을 생략했는지 빌드 기록에 남기고, 빌드 실패나 단위 테스트 실패 같은 핵심 차단은 애초에 옵션 목록에서 빼서 어떤 상황에서도 못 끄게 했습니다. 옵션화의 목적은 통제를 푸는 게 아니라, "빠르게 가야 하는 상황"마저 게이트 설계 안으로 끌고 들어와 보이게 만드는 것이었습니다.

4. 차단 강도(Hard Block/ Soft Block / Warning)

게이트를 만들면서 가장 오래 고민한 건 의외로 "무엇을 막을까"가 아니라 "무엇을 막지 않을까"였습니다.

전부 강하게 막으면 파이프라인은 빠르게 미움받습니다. 포매팅 하나 어긋났다고 배포가 막히면, 다들 게이트를 우회할 궁리부터 합니다. 그래서 차단의 강도를 세 단계로 나눴습니다.

계층

의미

대표 항목

Hard Block

예외 승인 없이는 병합·승격 불가

빌드 실패, 단위 테스트 실패, Critical/High 취약점, 필수 승인 미충족

Soft Block

보완 계획을 기록하면 조건부 승인

커버리지 미달, 성능 회귀, Medium 취약점, 불안정한 통합 테스트

Warning

막지는 않고 추세만 관리

포매팅, 낮은 심각도, 문서 누락, 스타일 이슈

이렇게 나누니 게이트가 "무조건 막는 벽"이 아니라 "위험의 크기만큼 반응하는 체계"가 됐습니다. 사소한 건 흘려보내고, 위험한 건 확실히 잡는 식으로요.

강도를 낮춘 만큼 빠져나갈 틈도 생깁니다. 그래서 보완 규칙을 같이 뒀습니다. 같은 항목이 두 번 넘게 연속으로 실패하면 Soft를 Hard로 올리는 걸 검토하고, 결제나 정산처럼 핵심 비즈니스 경로가 걸린 실패는 테스트 계층이 뭐든 무조건 Hard로 처리했습니다. 똑같은 실패라도 어디서 났느냐에 따라 무게가 다르니까요.

결국 "막지 않을 것"을 정하는 일과 "절대 양보하지 않을 경로"를 정하는 일은 한 묶음입니다. 강도를 셋으로 나눈 것도 이 둘의 균형을 잡으려는 장치였습니다.

5. 정적분석과 테스트

게이트의 뼈대를 세운 다음에는 각 게이트가 실제로 무엇을 검사할지를 채워야 했습니다.

코드베이스가 Kotlin이라, 정적분석 도구를 굳이 둘로 나눴습니다. Kotlin 언어 특화 분석은 detekt가, 포매팅과 린트는 ktlint가 맡고, 커버리지 측정은 Kover로 따로 뺐습니다.

도구를 늘리는 게 목적은 아니었습니다. detekt는 Kotlin 특유의 관용 표현이나 의미 기반 결함을 잘 잡고, ktlint는 스타일을 기계적으로 일관되게 강제합니다. 잘하는 일이 서로 다릅니다. 분리해 두면 "이 위반이 어느 도구에서 걸렸나"가 명확해져서, 게이트의 판정 기준을 세우기도 훨씬 깔끔했습니다.

테스트는 계층마다 도는 시점과 책임을 다르게 뒀습니다. 모든 테스트를 모든 단계에서 돌리면 비용을 감당할 수 없기 때문입니다. 빠르고 싼 검사는 앞에, 느리고 비싼 검사는 뒤에 두는 게 기본 골격입니다.

테스트 계층

수행 시점

목적

Unit Test

PR / 기준선 확정

모듈 내부 로직 정확성

Integration (Testcontainers)

PR 중심

서비스 경계·연동 회귀 조기 검출

E2E smoke

기준선 확정

배포 전 얕은 회귀 검증

E2E 확장

stage 이후

배포판 기준 핵심 흐름 검증

커버리지는 절대 점수로 다루지 않았습니다. 테스트가 닿지 않은 위험 구간을 비춰주는 지표 정도로 봤습니다. 기본선은 전체 70%, 핵심 도메인과 신규 코드는 80%로 잡았지만, 숫자 자체보다 핵심 경로가 검증되지 않은 상태를 더 큰 위험으로 취급했습니다. 숫자에만 매달리면 통과만을 위한 빈 테스트가 늘어나기 마련입니다.

그래서 운영 기준을 하나 정했습니다. 커버리지 숫자를 게이트의 절대 컷으로 삼지 않는다는 것입니다. 신규 코드 커버리지는 회귀를 미리 막는 선행 지표라 비교적 엄격하게 보되, 기존 코드의 낮은 수치는 한 번에 막아서는 대신 핵심 경로부터 단계적으로 끌어올리는 식으로 운영했습니다. 앞에서 말한 대로, 에이전트를 활용하면 이 목표선을 현실적으로 더 높게 잡아도 된다고 봤기 때문입니다.

6. immutable artifact

품질을 아무리 잘 검사해도, 검사한 것과 다른 게 운영에 올라가면 검증은 전부 무의미해집니다. 의외로 여기서 새는 경우가 많습니다.

그래서 운영 승격은 재빌드하지 않는 것을 원칙으로 못 박았습니다. stage에서 검증을 통과한 바로 그 산출물을 같은 digest 그대로 retag해서 운영으로 올립니다. 검증한 커밋의 SHA와 승격 대상의 SHA가 다르면 승격 자체를 막습니다. "운영용으로 다시 빌드" 같은 건 아예 경로에서 빼버렸습니다.

사소해 보여도 MSA에서는 이 원칙의 무게가 다릅니다. 서비스별 독립 배포가 잦을수록, "검증한 그 바이트가 그대로 운영에 올라갔다"는 보장이 장애 분석과 롤백의 신뢰성을 좌우합니다. 이 보장이 없으면 장애가 났을 때 "검증본과 운영본이 같긴 한가"부터 의심해야 하니까요.

[ 그림 2. 재빌드 없이 동일 digest로 승격하는 흐름 ]

image2.png

취약점 관리도 같은 자리에 끼워 넣었습니다. Trivy를 CI 러너 안에서 이미지 스캔에 돌리고, Critical과 High 취약점은 이미지가 이미 레지스트리에 올라가 있더라도 승격 대상에서 제외했습니다. 결과는 SARIF로 모아 중앙에서 추세를 봤고요. 보안 역시 "권장"이 아니라 통과 조건의 일부로 둔 것입니다.

7. AI 코드 리뷰

품질 게이트를 정리하던 무렵, CI 단계에서 AI가 코드 리뷰를 보조해 준다는 걸 알게 돼서 직접 붙여봤습니다.

PR이 올라오면 AI가 변경 내용을 훑어서 결함 후보, 회귀 위험, 테스트 누락, 규칙 위반 가능성 같은 걸 1차로 짚어주는 형태입니다. 파이프라인에 연결해 돌려봤는데, 솔직히 동작은 기대 이상이었습니다.

가장 인상적이었던 건 한두 관점에서 그치지 않는다는 점이었습니다. 사람이 리뷰할 때 훑는 순서를 거의 그대로 따라가며 다각도로 짚어줬습니다. PR 하나를 올리면 대략 이런 식으로 정리해 줍니다.

  • 변경 분석 — 어떤 파일이 무엇 때문에 바뀌었는지를 먼저 한 줄로 요약

  • 잠재 버그·개선점 — 문서화가 모호한 부분, 누락되면 배포 시 오류로 번질 수 있는 설정 의존성 등을 지목

  • 보안 이슈 — API 키나 자격증명 같은 민감 정보가 섞여 들어갈 여지가 없는지 점검

  • 구조·결합도 — 상속 관계 변화나 다른 모듈과의 결합도 증가, 그에 따른 영향 범위를 확인하라고 제안

  • 중복·최적화 — 유사 로직 반복이나 미사용 코드를 리팩토링 후보로 표시

  • 테스트 제안 — 변경 영향에 비해 커버리지가 충분한지 되묻고, 필요한 케이스 추가를 권고

문서에 Marker 한 줄 추가하는 수준의 작은 PR을 올렸을 때도 "이 변경이 다른 코드에 어떤 영향을 주는가"까지 따라가 줬습니다. 리뷰어 입장에서는 검토의 출발선이 한 칸 앞당겨지는 느낌입니다. 곁가지를 먼저 한 번 걸러주고 시작하니, 사람은 정작 중요한 판단에 더 집중할 수 있었습니다.

그런데 이 기능은 결국 실제 운영에는 넣지 않았습니다.

이유는 보안이었습니다. 다루는 데이터의 민감도가 높은 환경이라 코드와 거기 얽힌 정보가 외부로 나가는 경로에 예민할 수밖에 없었고, 변경 내용을 외부 AI 서비스로 보내는 방식은 그대로 들이기 어려웠습니다. 기능이 잘 도는 것과 이 환경에 실제로 들여도 되는지는 별개의 문제였습니다.

그래서 "검증은 했고 효과도 확인했지만, 보안 기준을 먼저 충족해야 운영에 올린다"는 선에서 멈춰 두었습니다. 도입한다면 데이터를 밖으로 내보내지 않는 사설 LLM 기반이어야 하고, 그 경우에도 AI는 어디까지나 보조이고 최종 승인은 사람 리뷰어가 진다는 원칙은 그대로 가져갈 생각입니다.

기술적으로 되는 것과 이 현장에 들일 수 있는 것 사이의 거리를 한 번 재본 셈인데, 그 거리를 메울 기준을 남겨둔 것 자체가 나름의 결과라고 생각합니다.

8. 마치며

돌아보면 이번 작업은 거창한 도구를 들인 게 아니었습니다. 품질 항목들이 매번 같은 기준을 거치도록 CI에 자리를 잡아준 것, 그게 핵심이었습니다.

작업하면서 특히 체감한 것들을 정리하면 이렇습니다.

  • 품질 항목 대부분은 코드가 들어오는 길목인 CI에서 다룰 때 가장 자연스럽고 일관됩니다.

  • 에이전트로 코딩하는 흐름은 품질에 위협이 아니라 기회입니다. 잘 쓰면 커버리지와 검증 밀도를 오히려 더 높게 가져갈 수 있습니다.

  • 게이트는 무엇을 막을지보다 무엇을 막지 않을지를 함께 설계해야 미움받지 않고 살아남습니다.

  • 검증한 산출물을 그대로 올리는 immutable 원칙이 빠지면, 앞단의 모든 검증이 한순간에 무력해집니다.

  • 기술적으로 되는 것과 이 환경에 들일 수 있는 것은 다릅니다. AI 리뷰처럼 효과를 보고도 보안 때문에 보류한 경우가 그 예입니다.

MSA 컨설팅에서는 서비스 설계나 배포 체계처럼 눈에 띄는 결과물이 먼저 주목받습니다. 하지만 그 결과물이 시간이 지나도 일정한 품질을 유지하도록 밑에서 받치는 건, 매일 조용히 도는 CI 품질 게이트라고 생각합니다.

Tim

Site footer