앞 글에서 부분 갱신에서 값이 사라지는 문제를 고쳤다. 그 뒤 코드 검수를 실행했는데 같은 뿌리를 가진 결함이 여러 곳에서 나왔다. 원인은 catch 후 빈 값을 반환하는 패턴이다.
const envs = await coolify.listEnvs(app.uuid).catch(() => []);
이 한 줄은 "조회에 실패했다"를 "환경변수가 없다"로 바꾼다. 두 상태의 후속 처리가 완전히 다른데도 호출하는 쪽은 구분할 방법이 없다.
반납이 지우지 못한 것을 지웠다고 보고했다
포털에서 슬롯을 반납하면 코드와 히스토리, 환경변수, 데이터베이스, 영구 볼륨 데이터를 모두 지우고 다음 사용자가 점유할 수 있게 풀어 준다. 되돌릴 수 없는 작업이다.
const envs = await coolify.listEnvs(app.uuid).catch(() => []);
let removed = 0;
for (const e of Array.isArray(envs) ? envs : []) {
if (!e.uuid) continue;
await coolify.deleteEnv(app.uuid, e.uuid).catch(() => {});
removed += 1; // 실패해도 세었다
}
log(`환경변수 ${removed}건 제거`); // "환경변수 0건 제거"
목록 조회가 실패하면 []로 넘어가 반복문이 한 번도 돌지 않고 로그에 "환경변수 0건 제거"가 남는다. 작업은 완료로 끝나고 슬롯은 빈 슬롯이 된다. 알림으로 "반납이 끝났습니다. 다음 사용자가 점유할 수 있습니다"가 나간다.
이전 사용자의 API 키와 데이터베이스 비밀번호가 그대로 남은 슬롯이 빈 슬롯으로 풀린다. 다음 점유자는 그 슬롯의 소유자가 되므로 값 보기 권한으로 그 값을 읽을 수 있다. 영구 볼륨 목록 조회도 같은 형태였다. 그쪽이 실패하면 이전 사용자가 저장한 파일이 그대로 남는다.
삭제 실패도 마찬가지였다. deleteEnv의 실패를 완전히 삼키면서 removed는 증가시켰다. 로그의 숫자가 실제로 지운 개수와 무관하다.
무엇을 지워야 하는지 모르는 상태에서는 지웠다고 말할 수 없다. 두 단계로 나눠 고쳤다.
// 목록 조회 실패는 즉시 던진다. 무엇이 남는지 모른다.
const envs = await coolify.listEnvs(app.uuid).catch((err) => {
throw new Error(`환경변수 목록을 조회하지 못했습니다. 시크릿이 남을 수 있어 중단합니다: ${err.message}`);
});
// 삭제 실패는 세어 두고 마지막에 던진다. 지울 수 있는 것은 최대한 지운다.
try {
await coolify.deleteEnv(app.uuid, e.uuid);
removed += 1;
} catch (err) {
failures.push(`환경변수 ${e.key || e.uuid}`);
}
던지면 워커가 작업을 실패로 기록하고 슬롯은 점유 상태로 되돌아간다. 남은 자원은 사용자나 관리자가 확인해야 한다.
삭제 예정 목록이 "없음"을 표시했다
반납 확인 화면은 무엇이 사라지는지 실제 값으로 보여준다. 일반 문구로는 규모를 실감하지 못하기 때문이다.
const [envsRaw, storages, dbsRaw] = await Promise.all([
coolify.listEnvs(uuid).catch(() => []),
coolify.listStorages(uuid).catch(() => []),
coolify.listDatabases().catch(() => []),
]);
세 호출이 동시에 나가므로 API가 흔들리면 셋이 함께 실패한다. 그러면 화면이 환경변수 "없음", 데이터베이스 "없음", 저장 공간 "없음"을 그린다. 사용자는 어차피 지울 것이 없다고 판단하고 체크박스 두 개와 슬롯명 입력을 통과한다. 되돌릴 수 없는 작업 앞에서 나올 수 있는 최악의 표시다.
피해가 하나 더 있다. 이 값이 실행 전 스냅샷으로 감사 로그와 점유 이력에 저장된다. 데이터를 되살릴 수 없어도 무엇이 있었는지는 남아야 하는데 그 기록까지 비어 버린다.
Promise.allSettled로 바꿔 실패를 값으로 올렸다. 조회하지 못한 항목은 null로 두고 이름을 따로 모은다.
return {
envKeys, // null 이면 조회 실패
storages, // null 이면 조회 실패
databases, // null 이면 조회 실패
unchecked, // ["환경변수", "저장공간"] 처럼 확인 못한 항목 이름
};
화면은 null을 "확인하지 못했습니다"로 그린다. 리포 코드 항목은 원래부터 이렇게 구분하고 있었는데 나머지 항목에 같은 규칙을 적용하지 않은 상태였다.
<dt>환경변수</dt>
<dd>
<!-- null 은 조회 실패. "없음" 과 반드시 다르게 보여준다 -->
확인하지 못했습니다
</dd>
확인하지 못한 항목이 있으면 실행 버튼을 제출 버튼이 아닌 비활성 버튼으로 바꾼다. 화면에서 막는 것만으로는 게이트가 되지 않으므로 서버도 예약을 거부한다.
if (Array.isArray(preview.unchecked) && preview.unchecked.length) {
const err = new Error(
`${preview.unchecked.join(', ')} 을 확인하지 못해 반납을 예약하지 않았습니다. 잠시 뒤 다시 시도해 주세요.`,
);
err.status = 503;
throw err;
}
알림 전송 실패가 "경고했다"로 기록됐다
슬롯에 점유 기한을 두고 기한이 지나면 자동으로 반납한다. 사용자 확인 없이 데이터를 지우는 유일한 동작이라 기한 전에 경고를 보낸다.
await notify.send(`${row.name} 점유 기한이 ${left}입니다. ...`).catch(() => {});
q.markWarned(row.name, [...sent, due]);
q.audit('system', 'slot.expiry.warned', row.name, { remaining: left, threshold: due });
notify.send는 예외를 던지지 않는다. 실패를 반환값으로 준다.
export async function send(text) {
if (!config.teamsWebhookUrl) return { sent: false, reason: 'webhook_not_configured' };
// ...
return { sent: res.status === 200 || res.status === 202, status: res.status };
}
그 반환값을 아무도 읽지 않았다. 웹훅이 설정되지 않았거나 전송이 실패해도 markWarned가 임계값을 기록하므로 다시 시도되지 않는다. 감사 로그에는 경고했다고 남아서 관리자는 사용자가 무시한 것으로 판단한다. 그 뒤 자동 반납이 경고 0회로 실행된다.
전송 성공을 확인한 뒤에 기록하게 바꿨다. 실패하면 기록하지 않고 다음 주기에 다시 시도한다.
const result = await notify.send(...);
if (!result.sent) {
q.audit('system', 'slot.expiry.warn_failed', row.name, { reason: result.reason });
continue;
}
q.markWarned(row.name, [...sent, due]);
포털 화면에도 기한 경고가 있어서 알림 하나에만 의존하지는 않는다. 그래도 기록이 사실과 다르면 판단이 어긋난다.
만들어 둔 검증 결과를 버렸다
환경변수를 저장할 때 값이 실제로 Production 항목에 들어갔는지 확인하는 코드가 있었다. is_preview를 빼면 Preview 쪽에만 들어가서 앱이 빈 값을 받는 문제를 겪은 뒤 넣은 것이다.
const hit = after.find((e) => e.key === key && !e.is_preview);
const ok = Boolean(hit && String(hit.value || '').length > 0);
q.audit(viewer.upn, 'env.set', name, { key, ok }, via);
return { key, ok, needsRedeploy: true };
필드 이름이 ok였고 API 응답 봉투가 이렇게 생겼다.
const ok = (res, data) => res.json({ ok: true, ...data });
data를 뒤에 펼치므로 result.ok가 봉투의 ok를 덮어쓴다. 값이 들어가지 않았을 때 응답이 이렇게 나간다.
{ "ok": false, "message": "FOO 를 저장하고 재배포를 시작했습니다. 1~3분 뒤 적용됩니다" }
ok가 false인데 메시지에는 저장했다고 적혀 있다. MCP 경로도 같은 구조여서 Claude가 그 문장을 사용자에게 그대로 전달한다. 필드를 verified로 개명하고 봉투에서는 ok를 마지막에 고정했다. 검증이 실패하면 502로 알린다.
재배포 실패도 아홉 곳에서 삼키고 있었다.
await service.redeploy(name, req.user).catch(() => {});
환경변수나 데이터베이스를 바꾼 뒤 재배포가 실패하면 바꾼 값이 컨테이너에 반영되지 않는다. 그런데 화면은 그냥 리다이렉트하고 API와 MCP는 "재배포를 시작했습니다. 1~3분 뒤 적용됩니다"를 반환한다. 사용자는 3분을 기다린 뒤 값을 다시 입력한다.
결과를 돌려주는 함수로 모았다.
export async function tryRedeploy(name, viewer, via = 'web') {
try {
await redeploy(name, viewer, via);
return { started: true, message: '재배포를 시작했습니다. 1~3분 뒤 적용됩니다.' };
} catch (err) {
return {
started: false,
message: '설정은 저장했지만 재배포를 시작하지 못했습니다. 바뀐 값은 다시 배포해야 적용됩니다.',
};
}
}
화면은 리다이렉트에 경고 코드를 붙여 상세에서 안내를 표시한다. 데이터베이스를 만들고 접속 주소 주입에 실패한 경우도 같은 방식으로 알린다. 그전에는 injected를 아무도 확인하지 않고 "주입했습니다"를 단언했다. 사용자는 접속 주소가 있다고 믿은 채 앱을 만들고 앱은 연결 실패로 종료된다.
빈 화면의 이유를 구분하지 않았다
응답시간 그래프에서 측정 실패 샘플은 latency_ms가 null이라 전부 걸러진다.
out[name] = recentLatencies(name, limit)
.map((r) => r.latency_ms)
.filter((v) => v != null);
그래서 24시간 내내 응답이 없던 슬롯과 방금 등록해 샘플이 없는 슬롯이 같은 표시가 된다. 관리자는 모니터링이 아직 시작되지 않은 것으로 읽는다. 실패 건수를 함께 넘겨 구분한다.
if (points.length < 2) {
if (failed > 0) return html`<span style="color:var(--danger)">응답 없음 (${failed}회 실패)</span>`;
return html`<span class="dim">데이터가 모이는 중입니다</span>`;
}
상세 화면 로드도 같은 문제였다.
canSee && slot.app ? service.listEnvs(name, req.user).catch(() => []) : Promise.resolve([]),
이 한 줄이 네 상태를 하나로 합친다. 권한이 없어 비었는지, 앱이 없어 비었는지, API가 실패해 비었는지, 정말 없는지 구분되지 않는다. 사용자는 환경변수가 사라졌다고 믿고 다시 입력한다. 그 과정에서 삭제와 생성 호출이 추가로 나간다. 조회에 실패한 항목 이름을 모아 화면 위에 먼저 알리게 바꿨다.
헬스 배치에서는 반복문 전체가 try 하나로 감싸여 있었다. 한 슬롯에서 예외가 나면 반복문이 중단되어 남은 슬롯은 그 주기의 샘플이 없다. 로그도 "측정 중 오류" 한 줄이라 어디까지 됐는지 알 수 없다. 뒤쪽 슬롯만 만성적으로 샘플이 적어져 가용성이 왜곡된다. try를 반복문 안으로 옮기고 슬롯 이름을 로그에 남겼다.
확인한 사실
이 사례들의 공통점은 HTTP 200이고 로그가 없다는 것이다. 그래서 프래그먼트(서버가 다시 렌더해 돌려주는 화면 일부)의 200만 확인하던 검증에서는 이 결함이 드러나지 않았다. 조회 실패를 값으로 표현하지 않으면 검증할 대상 자체가 생기지 않는다.
파괴적 작업에서는 판단 기준이 다르다. 목록을 읽지 못한 상태에서 삭제를 진행하면 무엇이 남았는지 아무도 모른다. 그래서 반납과 철거는 목록 조회 실패를 던진다. 부분 실패는 완료로 끝내지 않는다.
회귀 테스트로 굳힌 항목은 조회 실패가 null로 올라오는지, 확인 못한 항목이 있을 때 화면과 서버가 모두 실행을 막는지, 목록 조회가 실패하면 슬롯이 빈 슬롯으로 풀리지 않는지다. 25개 항목으로 통과한다.
남은 것은 .catch(() => []) 패턴을 lint 규칙으로 막는 작업이다. 지금은 코드 검수로 찾았는데 같은 형태가 다시 들어오는 것을 막을 장치가 없다.