1년째 초록불이던 죽은 서비스: 하드코딩 대시보드를 DB 기반 앱 런처로 바꾼 기록
사이드 프로젝트가 하나둘 늘다 보니 어느새 웹앱 9개, 모바일 앱 3개가 됐습니다. 이걸 모아 보여주는 개인 포털의 대시보드를 오랜만에 열어봤더니 — 1년 전에 은퇴시킨 트레이딩 봇 카드가 여전히 초록색 "Online" 배지를 달고 있었습니다. 이 글은 앱 카드가 전부 하드코딩된 낡은 대시보드를, 인벤토리 DB에서 카드를 자동 생성하고 가동 상태를 실측하는 앱 런처로 바꾼 기록입니다.
1. 발단: 카드는 늘 어제의 진실이다
포털 대시보드는 흔한 구조였습니다. HTML 템플릿에 앱 카드가 한 장 한 장 손으로 박혀 있는 형태 — 카드마다 SVG 아이콘, 설명 문구, 기능 태그, 그리고 "Online"이라고 적힌 상태 배지까지 전부 정적 마크업이었습니다. 카드 하나가 30~40줄이니 카드 10개면 템플릿이 340줄쯤 됩니다.
문제는 세 가지였습니다.
- 죽은 카드: 은퇴한 트레이딩 봇, 내려버린 웹터미널 카드가 그대로 남아 있었습니다. 클릭하면 502가 나는데 배지는 초록색 "Online". 배지가 실제 상태를 보는 게 아니라 그냥 글자였기 때문입니다.
- 추가 비용: 새 웹앱을 하나 올리면 라우팅 설정, 프로세스 등록에 더해 "대시보드 카드 HTML 작성 + CSS 그라데이션 추가"가 따라붙습니다. 귀찮으니 안 하게 되고, 대시보드는 점점 현실과 어긋납니다.
- 정보의 이중화: 별도로 운영 기록용 앱 인벤토리 DB(어떤 앱이 어느 포트에서 뭘로 돌고 있는지)를 이미 관리하고 있었는데, 대시보드는 그걸 전혀 안 보고 자기만의 낡은 목록을 유지하고 있었습니다. 같은 사실이 두 곳에 적혀 있으면 반드시 한쪽이 거짓말을 하게 됩니다.
2. 설계: 화면이 아니라 데이터를 고친다
UI만 현대화할 수도 있었지만, 그러면 "새 앱마다 HTML 수정"이라는 근본 문제가 그대로 남습니다. 그래서 방향을 바꿨습니다: 카드를 코드에서 데이터로 옮긴다.
마침 운영 기록용으로 쓰던 앱 인벤토리 테이블이 있었습니다. 이름·경로·포트·상태 같은 운영 필드가 이미 있으니, 표시용 필드만 몇 개 보태면 됩니다.
ALTER TABLE app_inventory ADD COLUMN title TEXT; -- 카드 제목
ALTER TABLE app_inventory ADD COLUMN icon TEXT; -- 이모지 아이콘
ALTER TABLE app_inventory ADD COLUMN blurb TEXT; -- 한 줄 설명
ALTER TABLE app_inventory ADD COLUMN category TEXT; -- 앱/도구/외부/모바일
ALTER TABLE app_inventory ADD COLUMN portal INTEGER DEFAULT 0; -- 노출 여부
ALTER TABLE app_inventory ADD COLUMN sort INTEGER DEFAULT 100; -- 정렬
포털 쪽에는 이 테이블을 읽는 작은 레지스트리 모듈을 하나 뒀습니다. portal=1인 행만 골라 URL을 조립하고(외부 도메인이면 새 탭), 각 앱의 포트에 실제로 TCP 연결을 시도해 가동 여부를 판정합니다. 페이지가 뜰 때마다 전 포트를 찌르면 낭비니 결과는 30초 캐시합니다. 실측이 일시적으로 실패해도 빈 대시보드가 되지 않도록 직전 캐시를 유지하는 폴백도 넣었습니다.
def portal_apps(force=False):
# 30초 캐시 + 실패 시 직전 캐시 유지
rows = "SELECT * FROM app_inventory WHERE portal=1 AND status != 'retired'"
for r in rows:
alive = tcp_connect("127.0.0.1", r.port, timeout=0.3) # 상태는 실측
템플릿은 카테고리 섹션을 돌며 카드를 찍는 20줄짜리 루프가 전부입니다. 아이콘은 SVG 대신 이모지로 통일해 카드당 마크업을 30줄에서 8줄로 줄였고, 죽은 앱은 흐림 처리에 "응답없음" 표기가 자동으로 붙습니다. 검색창은 서버 왕복 없이 data-search 속성을 클라이언트에서 필터링합니다.
3. 결과: 새 앱 추가가 CLI 한 줄
이제 새 웹앱을 포털에 올리는 작업은 이렇게 끝납니다.
inv set my-new-app kind=웹앱 path=/newapp/ port=8123 \
portal=1 title="새 앱" icon=🎨 blurb="한 줄 설명" category=앱
HTML도 CSS도 재배포도 없습니다. 대시보드는 다음 요청 때 DB에서 새 카드를 읽어옵니다. 앱을 은퇴시키면(status=retired) 카드도 자동으로 사라집니다. "죽은 카드가 1년 살아남는" 일이 구조적으로 불가능해졌습니다.
덤으로 두 가지를 더 얹었습니다.
- 모바일 앱 카드: 스토어 내부테스트로 배포 중인 안드로이드 앱 3종의 설치 링크를 같은 테이블에 행으로 추가했습니다. 외부 도메인이라 자동으로 새 탭 처리 — 코드 수정 없이 등록만으로 "모바일" 섹션이 생겼습니다.
- 데스크톱 앱 즉석 패키징: 데스크톱 앱은 스토어가 없으니 다운로드 라우트를 하나 팠습니다. 요청이 오면 그 시점의 소스를 tar로 묶어 내려줍니다(의존성 폴더 제외라 0.02초). 미리 만들어둔 압축본이 낡는 문제가 원천적으로 사라집니다.
4. 교훈
- 자주 안 바뀌는 화면일수록 데이터로 빼라. "가끔 바뀌는" 화면은 하드코딩의 유혹이 가장 큰 곳이고, 그래서 가장 잘 썩는 곳입니다. 변경 빈도가 낮다는 건 방치 기간이 길다는 뜻이기도 합니다.
- 상태는 주장하지 말고 실측하라. "Online"이라고 적어두는 것과 지금 포트에 연결해보는 것은 완전히 다른 정보입니다. 실측 + 짧은 캐시면 비용도 거의 없습니다.
- 이미 있는 운영 데이터를 UI의 소스로 써라. 운영 기록과 화면이 같은 테이블을 보면 문서화가 곧 배포가 됩니다. 같은 사실을 두 곳에 적지 않는 것이 동기화의 가장 싼 구현입니다.
FAQ
Q. DB가 죽으면 대시보드도 죽지 않나요?
레지스트리가 마지막 성공 캐시를 메모리에 유지합니다. DB가 일시적으로 잠기거나 읽기가 실패하면 직전 목록을 그대로 보여줍니다. 완전 최초 기동에서 DB까지 죽어 있는 최악의 경우에만 빈 목록이 되는데, 그때는 대시보드보다 먼저 고칠 게 많은 상태일 겁니다.
Q. 포트 실측이 서비스에 부담을 주지 않나요?
0.3초 타임아웃의 TCP 연결 시도 하나이고, 30초 캐시라 분당 최대 2회입니다. 헬스체크 크론이 이미 하고 있는 일에 비하면 오차 범위입니다.
Q. 카드 순서나 노출을 바꾸려면요?
sort 숫자를 바꾸거나 portal=0으로 끄면 됩니다. 전부 CLI 한 줄, 반영은 다음 페이지 로드입니다.
Q. 왜 정적 사이트 생성이나 프론트 프레임워크를 안 썼나요?
이 포털은 서버 렌더링 Flask 앱이고, 카드 10여 개짜리 화면입니다. 템플릿 루프 20줄이면 충분한 곳에 빌드 파이프라인을 들일 이유가 없었습니다. 규모가 문제를 만들 때 도구를 키우는 편입니다.


