2026년 8월 15일 · 12분 읽기
1인 개발 기술 스택: iOS 앱과 Go 백엔드, 쿠버네티스를 혼자 운영하는 경계
1인 개발자가 iOS 앱과 Go 백엔드, 셀프호스팅 쿠버네티스를 함께 운영하며 정한 기술 스택 경계입니다. 메모의 세 글자를 두 코드 경로가 다르게 읽은 버그에서 시작합니다.
2026년 8월 23일 업데이트
사용자가 메모에 "알려줘"라고 적었습니다. 앱의 한쪽 코드는 그것을 알림 요청으로 읽었고, 다른 쪽은 읽지 못했습니다. 같은 세 글자를 두 군데가 다르게 해석한 버그였습니다.
원인은 낱말 목록이었습니다. "알려줘"가 목록에 있으면 알림이고 없으면 아니었습니다. "알려주세요"와 "알려줄래"는 목록에 없었습니다. 목록을 늘리는 대신 낱말로 판정하는 코드를 코어에서 걷어냈습니다.
한눈에 보기
| 문제 | 사용자가 메모에 "알려줘"라고 적었습니다. |
| 결정 | 모델에게 무엇을 시킬지가 아니라 무엇을 시키지 않을지를 먼저 정했습니다. |
| 결과 | 에이전트에게 반복을 넘겼으니 그들이 무엇을 했는지 읽어야 합니다. |
| 제약 | 이 비용은 사라지지 않습니다. |
판정을 모델에게 맡겼다가 절반을 틀렸습니다
낱말 목록을 걷어낸 다음 자연스러운 선택지가 있었습니다. 온디바이스 모델에게 물어보는 것입니다. 이 문장이 실행 요청인지 그냥 메모인지 모델이 판단하게 하면 변형마다 깨지는 문제가 사라질 것 같았습니다.
측정해 보니 아니었습니다.
| 물어본 것 | 결과 |
|---|---|
| 원문만 주고 8지선다 | 8/12 |
| 사실을 함께 주고 3지선다 | 9/13 |
| 예/아니오 이진 게이트 | 12/16 |
| 같은 게이트를 200회로 늘려서 | 99/200 |
| 서술문을 실행 요청으로 오판한 비율 | 73~87% |
| 영어 질문 25건 | 정답 0건 |
| 제목을 뽑아 달라고 했을 때 | 12/12 |
처음 세 줄만 보면 나쁘지 않아 보입니다. 그래서 같은 게이트를 200회로 늘렸고, 99번 맞혔습니다. 동전 던지기입니다.
혼동 행렬이 어디서 무너지는지 보여 줬습니다. 서술문을 실행 요청으로 읽은 비율이 73%에서 87% 사이였습니다. 영어로 들어온 질문 25건은 하나도 맞히지 못했습니다. 질문은 논리적으로 요청이기도 해서 "이건 요청인가"라는 물음 자체가 답이 갈리는 물음이었습니다.
모델은 틀린 답에도 90 이상의 확신도를 붙였습니다. 확신도가 낮은 답만 걸러 내는 방식으로는 이 오답을 잡을 수 없습니다.
테스트 세트도 같이 버렸습니다. 200개 문장을 만들 때 장보기 목록, 웹검색, 일정 수정처럼 앱에 없는 기능을 전제했습니다. 사람들이 쓰지 않는 문장으로 재고 있었습니다. 제품의 실제 도메인은 회의, 계약, 견적, 문서, 일정, 알림입니다.
"이 글의 제목을 뽑아라"처럼 답의 형식이 정해진 요청은 열두 번 중 열두 번 맞혔습니다. 실행 여부를 묻는 요청은 200회 중 99번 틀렸습니다.
그래서 순서는 코드가 정합니다
모델에게 무엇을 시킬지가 아니라 무엇을 시키지 않을지를 먼저 정했습니다. 어느 단계를 먼저 돌릴지, 실패하면 어디로 갈지, 사용자에게 물어볼지는 코드가 정합니다. 모델은 각 단계에서 정해진 칸 하나를 채웁니다.
칸의 모양은 미리 고정합니다. 요약 단계라면 제목 하나와 요점 몇 개입니다. 모델이 그 모양을 벗어나면 코드가 거부하고 다시 시킵니다. 세 번 실패하면 원문에서 문장을 그대로 뽑아 자리를 채웁니다.
이 구조에서 모델은 실행 권한을 갖지 않습니다. 캘린더에 일정을 만드는 일은 모델의 출력이 아니라 코드의 정책이 결정합니다. 시각을 확실히 해석하지 못하면 일정을 만들지 않습니다. 틀린 일정은 없는 일정보다 나쁩니다.
없는 자원 위에 판정을 세우지 않았습니다
측정에서 걸린 것이 더 있었습니다.
guardrailViolation은 Apple의 모델이 안전 정책에 걸린 입력을 거부할 때 내는 오류입니다. 평범한 한국어 문장에서도 이것이 났습니다. 모델이 정상으로 답하는 경우만 가정하면 사용자는 아무 반응 없는 화면을 봅니다.
한국어에서는 NLTagger와 NLEmbedding을 쓸 수 없었습니다. 형태소를 나누고 문장을 벡터로 바꾸는 Apple 기본 도구입니다. Translation은 시뮬레이터에서 unsupported를 냅니다. 기기와 언어에 따라 없을 수도 있는 자원 위에 판정을 세우면, 그 자원이 없는 사용자에게는 기능이 통째로 사라집니다.
반대 방향도 하나 있었습니다. 날짜와 시각은 NSDataDetector가 직접 만든 정규식보다 정확했습니다. 한국어 상대 시각 네 번 중 네 번입니다. 직접 만든 정규식은 "내일 아침 9시"에서 실패했습니다. 요일 사전과 월일 정규식 100줄을 지웠습니다. 직접 만든 것이 항상 나은 것은 아닙니다.
모델을 부르는 자리마다 대체 경로를 뒀습니다. 대체 경로는 언제나 같습니다. 실패를 기록으로 남기는 것입니다. 요약을 못 만들어도 사용자가 보낸 글자는 그대로 남습니다.
서비스 스물한 개를 목록으로 관리하지 않습니다
혼자 운영하는 클러스터에 서비스가 몇 개까지 늘어나는지 세어 봤습니다. 두 저장소를 합쳐 Application 선언이 21개입니다. 클러스터에 직접 물어보면 23개가 나옵니다. 목록을 읽는 부모 카드가 root와 clab-app-root 둘 더 있어서입니다. 오늘 23개를 읽어 보니 전부 Synced이고 Healthy였습니다.
Application은 Argo CD가 읽는 배포 카드입니다. 어느 저장소의 어느 경로를 클러스터의 어느 네임스페이스에 올릴지 한 장에 적습니다. Argo CD는 이 카드와 실제 클러스터를 계속 비교해서 다르면 맞춥니다. 저장소에 적힌 것이 정답이 되는 이 방식을 GitOps라고 부릅니다.
카드가 23장이면 카드 목록을 관리하는 일이 생깁니다. 새 서비스를 올릴 때마다 목록에 한 줄 더하는 작업입니다. 사람이 그 목록을 손으로 관리하면 언젠가 빠뜨립니다.
목록을 읽는 카드를 한 장 더 뒀습니다.
# clab-cluster/argocd/root-app.yaml
kind: Application
metadata:
name: root
spec:
source:
path: argocd/applications # 이 디렉터리를 목록으로 읽습니다
directory:
recurse: false # 바로 아래 파일만 봅니다
syncPolicy:
automated:
prune: true # 선언에서 빠지면 클러스터에서도 지웁니다
selfHeal: true # 손으로 바꾼 것은 되돌립니다root는 서비스가 아닙니다. argocd/applications 디렉터리를 읽어 그 안의 카드를 자식으로 만드는 부모입니다. 파일을 하나 추가하면 서비스가 하나 늘어납니다. 부모를 고칠 일은 없습니다. 이 구조를 app-of-apps라고 부릅니다.
prune: true는 양날입니다. 카드를 지우면 클러스터에서도 지워지니, 저장소만 봐도 지금 무엇이 돌고 있는지 압니다. 대신 실수로 파일을 지워도 서비스가 사라집니다.
스물한 개를 이름으로 외우지 않습니다
카드 이름을 외우는 대신 무엇을 하는지로 묶었습니다.
| 묶음 | 서비스 | 네임스페이스 |
|---|---|---|
| 관측 | kube-prometheus-stack, observability | monitoring |
| 인증서 | cert-manager, cluster-issuer | cert-manager |
| 접근 통제 | oauth2-proxy, oauth-gateway | auth, monitoring |
| 메일 | stalwart, postal | mail, postal |
| 제품 | justsend-backend, justsend-console, justsend-ops, justsend-render, justsend-share-web, justsend-web | justsend-platform |
| 사용자 창구 | discourse | justsend-platform |
| 백오피스 | plane | plane |
| 내 것 | baguette, fp-router, hello-clab, posy-weather-web, test-agents | 각자 |
묶음마다 고장 나는 방식이 다릅니다. 관측이 죽으면 다른 게 죽은 것을 모릅니다. 인증서가 만료되면 브라우저가 경고를 띄웁니다. 접근 통제가 뚫리면 백오피스가 열립니다. 메일이 멈추면 사용자 문의가 사라집니다.
혼자 운영하면 이 여덟 묶음이 한 사람의 새벽 시간을 나눠 갖습니다. 여덟을 같은 종류로 취급하지 않았습니다.
차트는 버전을 박아 둡니다
남이 만든 차트를 쓰는 카드는 버전을 고정합니다.
# clab-cluster/argocd/applications/kube-prometheus-stack.yaml
spec:
source:
repoURL: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
targetRevision: 66.3.1 # 최신이 아니라 이 버전
destination:
namespace: monitoringtargetRevision을 비워 두면 차트가 올라갈 때 클러스터도 따라 올라갑니다. 자고 있는 동안 모니터링이 새 버전으로 바뀌는 셈입니다. cert-manager는 v1.16.2, oauth2-proxy는 7.9.2로 박아 뒀습니다.
버전을 올리는 일은 그때부터 사람의 결정이 됩니다. 커밋 하나가 남고, 되돌릴 곳이 생깁니다.
서버를 셋으로 나누고 앱에는 폴백을 남겼습니다
백엔드도 한 덩어리가 아닙니다. 세 개가 각자 배포됩니다.
| 무엇 | 어디에 | 배포 방식 |
|---|---|---|
| 공개 API | api.example.com | StatefulSet |
| 백오피스 콘솔 | admin.example.com | Deployment |
| 렌더러 | 클러스터 안에서만 부릅니다 | Deployment, 복제본 3개 |
StatefulSet은 각 인스턴스가 자기 저장소를 갖는 배포 방식입니다. Deployment는 인스턴스를 아무거나 갈아 끼울 수 있는 방식입니다. 콘솔은 상태가 없으니 후자입니다.
렌더러에는 외부 주소가 없습니다. 백엔드가 환경 변수 JUSTSEND_RENDER_URL에 적힌 클러스터 내부 주소로 부릅니다. 브라우저를 띄워 웹 페이지를 그려야 하는 무거운 일을 여기서 합니다. 복제본 3개가 각자 한 번에 한 장씩만 그리고, 한 장 그리는 데 2.7초에서 4.5초가 걸렸습니다. 백엔드가 기다리는 상한은 60초입니다. 공개 API와 같은 주소에 두면 외부에서 이 무거운 일을 직접 부를 수 있게 됩니다.
링크에서 본문만 뽑는 일은 서버가 합니다
사용자가 링크를 보내면 제목과 본문이 필요합니다. 이 일을 어디서 할지가 결정이었습니다.
엔드포인트는 하나입니다. reader 추출 엔드포인트에 {"url": "..."}를 보내면 title, site, markdown, hero_image_url이 돌아옵니다.
// platform/backend/internal/reader/reader.go:30-31
const maxBody = 4 << 20 // 4MiB까지만 읽고 잘랐다고 표시합니다
const minBodyRunes = 80 // 80자보다 짧으면 본문으로 인정하지 않습니다
상한이 없으면 남의 서버 하나가 우리 메모리를 채울 수 있습니다. 응답이 100MB일 수도 있고 영원히 안 올 수도 있습니다.
// platform/backend/internal/reader/reader.go:147-152
client.Timeout = 10 * time.Second // 10초 안에 못 받으면 포기합니다
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 { // 리다이렉트는 세 번까지
return statusError(http.StatusGatewayTimeout, "UPSTREAM_TIMEOUT", "too many redirects", nil)
}
if err := e.validateURL(req.Context(), req.URL); err != nil {
이 코드를 가져온 이유는 마지막 줄입니다. 리다이렉트를 따라갈 때마다 주소를 다시 검사합니다.
사용자가 보낸 주소를 그대로 부르면 우리 클러스터 내부를 부를 수 있습니다. http://10.0.0.1이나 http://localhost:6379 같은 것입니다. 서버가 자기 내부망을 대신 긁어 주는 이 문제를 SSRF라고 부릅니다.
첫 주소만 검사하면 막지 못합니다. 공격자는 정상 주소를 주고 그 서버가 http://10.0.0.1로 리다이렉트하게 만들면 됩니다. 그래서 매 홉마다 검사합니다.
// platform/backend/internal/reader/reader.go:198-203
if u.Scheme != "http" && u.Scheme != "https" { ... } // 다른 프로토콜은 거부
if u.User != nil { ... } // 주소에 계정 정보가 붙으면 거부
if port != "" && port != "80" && port != "443" { ... } // 다른 포트는 거부
포트를 80과 443으로 묶은 것이 이 검사의 실효입니다. 사설 IP 목록을 아무리 잘 관리해도 http://내부호스트:6379가 통하면 뚫립니다. 그리고 DNS를 해석한 뒤 나온 IP가 예약 대역이면 거부합니다. 도메인 이름만 보고 판정하지 않습니다.
서버가 못 하면 앱이 대신 긁습니다
서버에 뒀다고 서버만 믿을 수는 없습니다. 앱은 세 단계로 내려갑니다.
| 순서 | 무엇을 하는가 | 제한 시간 |
|---|---|---|
| 1 | 서버의 reader에 물어봅니다 | — |
| 2 | 앱이 직접 HTML을 받습니다 | 8초 |
| 3 | 숨은 웹뷰로 페이지를 그려 봅니다 | 15초 |
서버가 "이 주소는 거부한다"고 답하면 앱은 로컬로 내려가지 않습니다. 거부는 결론이니 다시 시도할 이유가 없습니다. 서버에 못 닿았거나 빈 결과가 오면 2단계로 갑니다. 받아 온 본문이 100자보다 짧으면 자바스크립트로 그리는 페이지일 가능성이 있어 3단계로 갑니다.
계정 없는 사용자에게도 이 기능을 열었습니다
링크 하나 붙여넣으려고 회원가입을 요구하지 않기로 했습니다. 앱이 기기 식별 정보로 임시 토큰을 받습니다.
이 토큰에는 24시간에 50건이라는 상한이 있습니다. 무료로 열어 둔 서버를 남이 크롤러로 쓰지 못하게 하는 값입니다. 동시에 이 값이 제품의 상한이 됩니다. 링크를 많이 저장하는 사용자는 하루에 50번을 넘길 수 있습니다.
혼자 운영하면 이 숫자를 올리는 결정도 혼자 합니다. 서버 비용과 남용 위험을 같은 사람이 재야 합니다.
앱 하나가 실제로는 다섯 덩어리입니다
"iOS 앱을 만든다"고 말할 때 머릿속에 떠오르는 것은 화면입니다. 빌드 설정을 열어 보면 다섯 개가 있습니다.
# ios-prod/app/project.yml
packages:
justsend-core:
path: JustSendMemoryCore # 원격 버전이 아니라 저장소 안의 경로
targets:
JustSendKit: { type: framework } # 공통 코드
JustSend: { type: application } # 본 앱
JustSendShare: { type: app-extension } # 공유 시트에서 뜨는 것
JustSendWidgets: { type: app-extension } # 홈 화면 위젯
JustSendTests: { type: bundle.unit-test } # 테스트사용자에게 설치되는 것은 세 개입니다. 본 앱과 공유 확장과 위젯입니다. 나머지 둘은 그 셋을 만들기 위한 것입니다.
| 부르는 말 | 실제로 들어 있는 것 |
|---|---|
| 앱 하나 | 본 앱만 떠올립니다 |
| 제품 셋 | 본 앱, 공유 확장, 위젯 |
| 덩어리 다섯 | 여기에 공통 프레임워크와 테스트 |
숫자가 셋에서 다섯으로 늘어나는 것이 문제가 아닙니다. 셋이 같은 데이터를 본다는 것이 문제입니다.
세 곳이 같은 파일을 씁니다
사파리에서 링크를 공유하면 공유 확장이 실행됩니다. 본 앱과는 다른 프로세스입니다. 위젯도 별개입니다. 셋이 각자 자기 데이터를 갖고 있으면 공유 시트로 저장한 메모가 앱에 나타나지 않습니다.
App Group은 한 개발자의 여러 앱과 확장이 같은 폴더를 함께 쓰게 하는 iOS 기능입니다. 셋을 같은 App Group에 넣으면 같은 SQLite 파일을 엽니다.
셋이 동시에 그 파일에 쓰면 어떻게 되는가. 공유 확장이 저장하는 중에 사용자가 앱을 열면 어떻게 되는가. 앱은 업데이트했는데 위젯은 아직 옛 버전이면 스키마가 다른 두 코드가 같은 파일을 여는 셈입니다.
이 질문들은 화면을 그리기 전에 답해야 합니다. 나중에 답하려면 이미 저장된 사용자 데이터를 옮겨야 합니다.
코어는 저장소 안에 둡니다
packages 아래 한 줄이 이 프로젝트에서 가장 자주 오해받는 곳입니다.
packages:
justsend-core:
path: JustSendMemoryCore # remote 아님, 저장소 안의 디렉터리path는 원격 저장소의 태그를 가리키는 것이 아닙니다. 앱 저장소 안의 디렉터리입니다. AI 요약 코어를 별도 저장소로 분리했지만, 지금 빌드에 들어가는 것은 앱 안에 있는 사본입니다.
코어에 결함이 있으면 앱에서 우회하는 코드를 넣을 수 있습니다. 그 유혹을 막으려고 규칙을 하나 뒀습니다. 코어 결함은 코어에서 고칩니다. 앱에서 덮으면 다음 사람이 두 곳을 다 읽어야 합니다.
이 분리를 왜 했고 무엇을 잃었는지는 다음 편에서 씁니다.
고객센터를 에이전트 여섯에게 넘겼습니다
혼자 만들면 만드는 일보다 만든 뒤가 문제입니다. 앱스토어 리뷰가 열여섯 개 언어로 들어옵니다. 메일이 옵니다. 커뮤니티에 질문이 올라옵니다. 클러스터에서 파드가 죽습니다.
이 넷은 판단이 아니라 반복입니다. 그래서 에이전트에게 넘겼습니다.
| 이름 | 역할 | 채널 | 하는 일 |
|---|---|---|---|
haram |
총괄 | care-lobby | 브리핑, 에스컬레이션 |
ria |
리뷰 | app-reviews | 16개 언어 리뷰 응답 |
daon |
커뮤니티 | community | Discourse 운영, 신고 처리 |
woojin |
메일 | mail-desk | 메일 티켓 1차 응답 |
sena |
지표 | analytics-kpi | 앱 분석, KPI 리포트 |
taesan |
운영 | cluster-ops | 클러스터 상태, 장애 초동 |
여섯이 각자 도구를 갖습니다. ria는 App Store Connect를, daon은 Discourse를, woojin은 메일 서버를, taesan은 쿠버네티스를 봅니다.
권한은 프롬프트 밖에 있습니다
여기서 1장의 측정이 다시 걸립니다. 모델은 절반을 틀리면서 90 이상의 확신도를 붙였습니다. 그런 모델에게 "너는 읽기만 해라"라고 프롬프트로 적어 두는 것은 안전장치가 아닙니다.
그래서 도구를 등록하는 지점에서 막았습니다. scopedServer가 역할에 맞는 도구만 그 에이전트에게 노출합니다. sena에게는 쓰기 도구가 아예 등록되지 않습니다. 프롬프트에 무엇을 적어도 없는 도구는 부를 수 없습니다.
lead와 insight 역할은 읽기 전용입니다. 총괄과 지표는 남의 상태를 읽고 보고만 합니다.
외부에 흔적을 남기는 일은 한 겹 더 있습니다. 리뷰에 답을 달거나 메일을 보내거나 파드를 재시작하는 일입니다. 이런 도구는 환경 변수로 꺼져 있고, 켜져 있어도 사람의 승인을 거칩니다.
무엇을 넘기지 않았는지 적어 둡니다
에이전트가 하는 일 옆에, 사람이 계속 하는 일을 나란히 적었습니다.
| 에이전트가 하는 일 | 사람이 하는 일 |
|---|---|
| 리뷰 답변 초안과 언어 확인 | 제품이 무엇을 약속할지 |
| 커뮤니티 답글, 신고 처리 | 제품 정책 |
| 메일 1차 응답 | 백엔드 API 변경 |
| 지표 리포트 | 어떤 지표를 볼지 |
| 클러스터 상태 읽기 | 배포 매니페스트 변경 |
오른쪽 열이 이 시리즈의 나머지입니다. 코어 계약, 앱의 데이터 경계, 배포 카드의 내용은 넘기지 않았습니다.
시크릿과 클러스터 변경은 사람이 실행합니다. 에이전트는 매니페스트를 만들 수 있지만 적용하지 못합니다.
혼자라서 생긴 제약이 설계를 정했습니다
지금까지의 결정을 다시 보면 공통점이 하나 있습니다. 전부 "한 사람이 동시에 두 곳을 볼 수 없다"에서 나왔습니다.
목록을 사람이 관리하지 않게 한 것은 목록을 빠뜨릴 사람이 하나뿐이기 때문입니다. 차트 버전을 박아 둔 것은 자는 동안 뭐가 올라가는지 볼 사람이 없기 때문입니다. 모델에게 순서를 맡기지 않은 것은 잘못된 실행을 되돌릴 사람이 하나뿐이기 때문입니다.
Plane에 남은 기록도 같은 방향입니다. IOSPROD-9은 작업을 Backend/Runtime과 Product Design/SwiftUI 두 트랙으로 나누고, 화면이 받을 데이터의 모양을 먼저 고정한 뒤 각자 구현하게 했습니다. 두 트랙을 한 사람이 번갈아 하더라도 계약이 먼저 있으면 합칠 때 어긋나지 않습니다.
IOSPROD-11은 실제 실행 경로와 설계 문서의 계약을 나란히 놓고 대조했습니다. 문서에만 있고 코드에 없는 타입을 제품 경로로 착각하지 않으려는 작업입니다.
경계를 나눈 비용은 따로 있습니다
경계를 나눴다고 일이 줄지는 않았습니다.
배포 단위를 나눴으니 어느 저장소의 커밋이 어느 카드를 움직이는지 계속 봐야 합니다. 백엔드와 콘솔이 각자 배포되므로 롤백도 각자입니다. 공개 API와 내부 변환 경로를 분리했으니 호출 관계를 따라가야 합니다.
에이전트에게 반복을 넘겼으니 그들이 무엇을 했는지 읽어야 합니다. 권한을 좁혔으니 막힌 일이 생길 때마다 사람이 대신 해야 합니다.
이 비용은 사라지지 않습니다. 다만 새벽에 깨는 횟수와 바꾼 것입니다.