새 고객사가 들어오면 담당자가 하는 일이 정해져 있었다. 템플릿 폴더 default를 고객사 이름으로 복사하고, 그 고객사가 쓰지 않을 앱 폴더를 하나씩 지운 뒤 Git에 올린다. 그다음 관리 화면에서 테넌트를 만든다. 앱은 인증, 모바일 백엔드, 커뮤니티 시설, 주차 연동까지 스무 개쯤 된다. 어떤 고객사에 무엇을 줬는지는 그 고객사의 폴더를 열어 봐야 알 수 있었다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트였고, 테넌트를 버튼 하나로 띄우는 파이프라인은 버튼 한 번에 고객사 전용 환경이 뜨기까지에서 정리했다. 그 파이프라인이 돌기 직전에 사람이 폴더를 지우는 단계가 하나 남아 있었다. 처음에는 폴더 지우는 스크립트를 짜면 된다고 봤다. 그런데 스크립트가 무엇을 지울지 알려면 누군가 목록을 줘야 하고, 그 목록을 정하는 시점이 여전히 “테넌트를 만들 때”다. 반복 작업이 생긴 건 손이 느려서가 아니라 앱 목록을 정하는 자리가 너무 아래에 있어서였다.
화면에 띄울 후보는 레지스트리와 템플릿의 교집합이다
이 플랫폼은 세 계층으로 되어 있다. 앱그룹은 “어떤 앱을 제공할지”와 이미지 저장소를 정하고, 앱플레인은 그 앱들이 올라갈 EKS 클러스터이고, 테넌트는 고객사마다 하나씩 생기는 네임스페이스다. 앱 목록이 테넌트마다 따로 정해지니 테넌트 수만큼 결정을 반복하고 테넌트 수만큼 실수할 기회가 생겼다. 그래서 목록을 앱그룹에서 정하고, 화면에서 앱마다 켜고 끄게 했다.
후보로 처음 생각한 건 이미지 레지스트리에 있는 이미지 전부였다. 그런데 레지스트리에는 배포 대상이 아닌 이미지도 많이 들어 있다. 앱 하나가 실제로 뜨려면 이미지와 saas-app 템플릿의 배포 설계도(Rollout, Service, HPA 등)가 둘 다 있어야 해서, 후보는 두 곳에 모두 있는 것만으로 했다. 여기에 앱마다 화면에 보여줄 이름(별칭)과 필수 여부를 담은 글로벌 이미지 설정을 병합한다.
교집합으로 정한 덕에 템플릿이 관문이 됐다. 당시 SaaS에 맞지 않거나 이전 작업에서 오류가 났던 모듈 6개를 tenants/default/apps/에서 tenants/default_hold/apps/로 옮겼다. 템플릿 조회는 default/apps/ 아래만 보기 때문에 폴더를 옮기는 것만으로 화면의 후보가 21개에서 15개로 줄었다. 화면 코드는 한 줄도 바꾸지 않았다.
{
"PK": "APP#registry-abc123",
"SK": "METADATA#DEFAULT",
"docker_infos": [
{ "repository_name": "saas/dev/auth", "tag": "1.0.0-RELEASE",
"is_use": true, "alias": "인증", "is_required": true, "template_dir": "auth-back" },
{ "repository_name": "saas/dev/c2c-park", "tag": "1.0.0-RELEASE",
"is_use": false, "alias": "주차 연동", "is_required": false, "template_dir": "c2c-park-back" }
]
}
DynamoDB에 들어가는 앱그룹 한 건이다. is_use가 토글 상태이고, template_dir은 이미지 이름(c2c-park)과 템플릿 폴더 이름(c2c-park-back)을 이어 주는 열쇠다. 저장할 때마다 SK = HISTORY#{저장 시각} 레코드가 따로 생기고, 여기에 변경 요약과 변경 직전의 앱 목록, 저장한 사람이 남는다.
앱플레인이 아니라 테넌트 레코드를 READY로 만들었다
이 플랫폼의 관리 화면은 파이프라인을 직접 부르지 않는다. DynamoDB에 status=READY인 레코드를 쓰면 EventBridge가 그걸 보고 Step Functions와 CodeBuild를 깨운다. 첫 버전의 전파 API는 이 트리거를 일부러 피해서 앱플레인 레코드만 갱신하고 status는 SUCCESS로 뒀다. 실제 재배포는 나중에 CodeBuild를 직접 호출하는 방식으로 붙일 생각이었다. 앱 하나를 끄고 저장하자 이력에는 비활성화가 잘 찍혔는데, 클러스터에는 그 앱이 그대로 떠 있었다. 아무것도 깨우지 않았으니 당연하다.
답은 테넌트 설정을 바꿀 때 쓰던 기존 패턴에 있었다. 현재 레코드를 HISTORY로 내리고, 같은 내용에 status=READY와 change_spec을 실은 새 레코드를 쓰면 기존 파이프라인이 알아서 돈다. 정할 건 어느 레코드를 READY로 만드느냐였다. 앱그룹에 연결된 건 앱플레인이니 앱플레인 레코드가 자연스러워 보인다. 하지만 EventBridge는 READY 레코드를 보면 무조건 반응하고, Step Functions는 그게 무엇의 레코드인지에 따라 길을 고른다. 앱플레인 레코드가 READY가 되면 네트워크, 피어링, EKS, 노드 그룹까지 클러스터 전체를 다시 프로비저닝하는 길로 간다. 그래서 앱플레인 레코드는 건드리지 않고, 앱플레인 아래의 활성 테넌트를 찾아 테넌트 레코드만 새로 썼다.
# 각 테넌트에 대해 재온보딩 트리거 (change_spec 패턴 활용)
for tenant in active_tenants:
# 기존 CURRENT → HISTORY
table.update_item(...) # record_type = "HISTORY"
# 새 CURRENT 레코드 생성 (status=READY로 EventBridge 트리거)
new_tenant = copy.deepcopy(tenant)
new_tenant.update({
"record_type": "CURRENT",
"action": "onboarding",
"status": "READY",
"change_spec": f"app-images:update:{app_id}", # 이번 변경은 앱 목록만 바뀐 것
})
table.put_item(Item=new_tenant)
첫 버전은 아무것도 깨우지 않아서 실패했고, 앱플레인 레코드를 썼다면 너무 많은 것을 깨웠을 것이다.
새 파이프라인 대신 분기 하나를 넣었다
테넌트 레코드가 READY가 되자 파이프라인이 돌았고, 꺼 둔 앱이 클러스터에서 빠졌다. 그런데 그 길은 전체 온보딩이었다. Terraform init, plan, apply를 다 거치고, 온보딩 스크립트는 saas-tenants의 테넌트 디렉토리를 통째로 지우고 템플릿에서 다시 만든다. 전용 파이프라인을 새로 만드는 방법도 있었지만 트리거, 권한, 로그를 하나 더 만들고 관리해야 해서 접었다. 앱을 켜고 끄는 일은 AWS 인프라와 무관하니, 기존 파이프라인에서 change_spec이 app-images:로 시작하면 Terraform을 건너뛰고 onboarding-v6.sh 대신 app-update.sh라는 경량 스크립트를 돌리게 했다. change_spec은 CodeBuild에 CHANGE_SPEC 환경변수로 넘어온다.
이 조건 한 줄은 같은 날 두 번 고쳤다.
# 1차: bash 문법. CodeBuild의 기본 셸(sh)에서는 조건이 성립하지 않아 전체 온보딩으로 빠졌다
if [[ "${CHANGE_SPEC:-}" == app-images:* ]]; then
# 2차: sh에서도 도는 형태로
if echo "${CHANGE_SPEC:-}" | grep -q "^app-images:"; then
# 3차: ACTION 조건 추가 (현재)
if [ "${ACTION}" = "onboarding" ] && echo "${CHANGE_SPEC:-}" | grep -q "^app-images:"; then
echo "앱 이미지 변경 전용 모드: Terraform 단계를 건너뜁니다."
else
terraform init ... && terraform plan ... && terraform apply ...
fi
3차는 오프보딩이 실패해서 넣었다. 전파로 앱을 바꾼 테넌트를 삭제하려 했는데, 테넌트의 현재 레코드에 전파 때 실린 change_spec=app-images:update:...가 그대로 남아 있었다. 분기는 이걸 보고 Terraform을 건너뛰었고, 오프보딩 마지막의 terraform apply는 init도 안 된 상태에서 실패했다. 분기 조건은 “언제 건너뛰나”만이 아니라 “언제 건너뛰면 안 되나”까지 적어야 한다.
app-update.sh는 이미 떠 있는 앱의 YAML을 일절 건드리지 않는다. 지금 배포된 앱 폴더와 켜져 있어야 할 앱 목록을 비교해서 꺼진 앱의 폴더는 지우고 새로 켜진 앱은 템플릿에서 복사한 뒤 ApplicationSet을 켜진 앱만 남겨 갱신한다. PDB, DestinationRule, 앱별 Secret처럼 ArgoCD가 만들지 않은 리소스는 ArgoCD가 지워 주지 않으니 스크립트가 직접 지운다. 커밋 메시지에는 무엇이 빠지고 들어왔는지가 남는다.
app-update: tenant-a (removed: c2c-park-back)
app-update: tenant-a ( added: c2c-park-back)
4월 6일 첫 커밋부터 7월 중순까지 이 형식의 커밋이 62개 쌓였다. 테스트 테넌트 것이 대부분이지만, 어느 테넌트에서 언제 어떤 앱이 빠졌는지 git log 한 줄로 찾을 수 있다.
ApplicationSet을 갱신하는 단계는 한 번 크게 흔들렸다. 앱을 끌 때마다 손대지 않은 다른 앱이 재시작됐고, ApplicationSet 갱신이 원인이라고 보고 앱마다 개별 Application으로 쪼갰다가 13일 뒤 되돌린 일도 있었다. 진짜 원인은 Karpenter의 노드 정리였고, 그 과정은 앱 하나를 껐는데 옆 앱이 재시작됐다에 따로 적었다.
같은 앱 목록이 세 곳에 있었다
기능이 돌기 시작하자 앱 목록(docker_infos)이 세 군데에 저장돼 있다는 게 드러났다. DynamoDB 앱그룹 레코드가 원본이고, Secrets Manager에는 CodeBuild가 실행 중에 DOCKER_INFOS로 읽는 사본이 있다. 파이프라인은 DynamoDB를 직접 조회하지 않으니 앱그룹을 저장할 때 이 둘을 함께 갱신한다.
세 번째는 앱플레인을 만들 때 앱그룹에서 한 번 복사해 둔 앱플레인 레코드의 스냅샷이었다. 앱그룹을 바꿔도 따라오지 않아서, 앱그룹에서 목록을 줄여도 앱플레인 상세 화면은 옛 목록을 그대로 보여줬다. 처음에는 전파할 때 이 스냅샷도 같이 갱신하게 고쳤는데, 만들고 보니 사본을 하나 더 관리하겠다는 뜻이었다. 그 코드는 되돌리고, 앱플레인 상세 화면을 열 때 앱그룹을 직접 조회해서 그 값으로 그리게 했다. 스냅샷은 앱그룹 조회가 실패할 때만 대신 쓴다.