hejbro · 1편

선언과 문자열의 경계에 다리를 놓기

2026. 8. 19. · 11분 · DongHyeon Yu

선언과 문자열의 경계에 다리를 놓기

요즘 제 코드는 대부분 AI 에이전트가 씁니다. 지난 회고에 적었듯 일주일에 267개의 PR이 머지되는 방식으로요. 그런데 코드를 맡기면서 무뎌진 줄 알았던 감각이, 데이터베이스 앞에서는 다시 예민해집니다. 에이전트가 프로덕션 DB에 붙어 스키마를 고치고 데이터를 지우는 걸 보고 있으면, 아무리 잘해도 어딘가 불편해요. 이 글은 그 불편함의 정체를 따라가 본 기록입니다.

코드는 왜 괜찮았나

코드를 AI에 맡기는 게 편해진 건 AI가 실수를 안 해서가 아닙니다. 실수해도 되는 구조 위에서 일하기 때문입니다.

에이전트가 아무리 이상한 코드를 써도, 머지 전까지는 아무 일도 일어나지 않습니다. 변경은 diff라는 리뷰 가능한 형태로 도착하고, 리뷰에서 걸리면 버리면 그만이고, 머지한 뒤에 문제가 드러나도 Git이 언제 무엇이 왜 바뀌었는지를 전부 들고 있으니 되돌릴 수 있습니다. AI 쓰는 법에 정답은 없다고 썼지만, 코드 쪽에서 하나는 확실합니다. 우리가 AI를 믿는 게 아니라, 실수의 비용을 구조가 낮춰 주는 것입니다.

DB 앞에서 다시 예민해지는 이유

그런데 MCP로 에이전트를 데이터베이스에 붙이는 순간, 그 구조가 통째로 사라집니다.

실행이 곧 반영입니다. 머지 전이라는 유예가 없고, diff라는 리뷰 단위도 없고, 무슨 일이 있었는지는 대화 로그 어딘가에 흩어집니다. 그래서 질문이 남습니다. 에이전트가 잘못 지우면요? 잘못 고치면요? 그때의 정책은 뭐죠?

혹시 저만 불안한가 싶어 생태계가 내놓은 답을 찾아봤습니다.

MCP 스펙에는 복구라는 개념 자체가 없습니다. 안전장치는 "사람이 승인 루프에 있어야 한다(SHOULD)"는 권고와, 도구에 붙이는 destructiveHint 같은 어노테이션이 전부입니다. 하지만 스펙이 곧바로 덧붙입니다. 신뢰된 서버가 아닌 한, 클라이언트는 이 어노테이션을 신뢰해서는 안 된다(MUST)고요. "이 도구는 파괴적입니다"라는 표시조차 힌트일 뿐이라는 뜻입니다.

Supabase MCP 문서가 주는 안전 수단은 read-only 모드, 프로젝트 스코핑, 그리고 반복되는 경고입니다. 프로덕션 데이터에는 절대 연결하지 말라는 것이죠. 잘못됐을 때 복구하는 방법은 문서 어디에도 없습니다. 파괴적 SQL에 사람의 승인 영수증을 요구하자는 외부 제안도 있었지만 채택되지 않았고요.

공식 레퍼런스 Postgres MCP 서버는 아예 read-only 전용이었고, 지금은 아카이브됐습니다. 안전한 쓰기를 푼 게 아니라 쓰기를 포기한 설계였습니다.

Neon은 스키마 변경을 임시 브랜치에서 돌리는, 제가 본 것 중 가장 진지한 접근을 합니다. 하지만 그 문서의 결론도 같습니다. 개발과 테스트에만 쓰고, 프로덕션에는 붙이지 말라는 것입니다.

Google Cloud의 MCP 보안 가이드는 정직하게 말합니다. 방어가 뚫릴 경우의 복구 전략이 반드시 있어야 한다고요. 그런데 그 수단으로 드는 것은 백업, PITR, 스냅샷 등 전부 MCP 바깥의 전통적인 DB 운영 도구들입니다.

정리하면 이렇습니다. 생태계의 답은 "읽기 전용으로 써라", "프로덕션엔 붙이지 마라", "매번 사람이 승인해라"입니다. 전부 사고를 막는 장치이고, 사고가 난 다음의 정책은 "백업 있으시죠?"로 끝납니다. 위험하다고 단정하려는 게 아닙니다. 다만 잘못 지웠을 때와 잘못 고쳤을 때의 정책이 명확하지 않은 채로 프로덕션 DB를 맡기는 건, 적어도 저에게는 아직 불편합니다.

그리고 이 불편은 유난이 아닙니다. 에이전트가 프로덕션 데이터베이스를 지워버린 고백 스레드에는 천 개가 넘는 댓글이 달렸고, Supabase MCP로 비공개 테이블 전체가 유출된 사건은 Hacker News를 달궜습니다. AI에게 DB를 직접 열어주는 게 무서웠다며 방어 도구를 직접 만들었다는 글이 지금도 이어지고요.

저는 원래 Supabase를 선언적으로 써왔습니다

고백하자면 저는 이 고민을 MCP 때문에 시작한 게 아닙니다. AI가 오기 한참 전부터, Supabase를 쓰면서요.

Supabase는 훌륭한 제품이지만 스키마 관리만은 기본 제공이 헐겁습니다. 공식 방식은 결국 마이그레이션 SQL 파일을 손으로 쓰는 것인데, SQL은 쓰는 것보다 읽는 게 문제입니다. 몇백 줄짜리 마이그레이션을 리뷰할 때 저는 제가 승인을 하고 있는 건지 훑는 시늉을 하고 있는 건지 스스로도 헷갈리거든요. 그래서 처음부터 그 공백을 메울 도구를 찾았고, Prisma와 Drizzle을 놓고 비교한 끝에 제 스타일에 더 맞는 Drizzle을 골랐습니다. 원하는 상태를 TypeScript로 선언하면 마이그레이션 SQL이 생성됩니다. 코드가 리뷰의 대상이 되고, SQL은 산출물이 되죠.

그리고 AI 시대가 왔을 때, 제 개발 흐름에서 바뀐 것은 하나뿐이었습니다. 선언 코드를 쓰는 손이 저에서 에이전트로 바뀐 것입니다. 에이전트는 Drizzle 선언을 고치고, SQL은 생성되고, 저는 diff를 리뷰합니다. 안전하려고 일부러 챙겨둔 게 아니었는데, 돌아보니 앞에서 찾아 헤맨 구조인 유예, 리뷰 단위, 이력이 이미 거기 있었습니다. 비를 피하려고 챙긴 게 아니라, 들고 다니다 보니 우산이었던 셈입니다.

참아온 것들

그럼 그냥 계속 쓰면 되는 거 아닌가? 맞습니다. 실제로 그래 왔고요. 다만 이 우산에는 구멍이 숭숭 뚫려 있었고, 저는 그걸 꽤 오래 참았습니다.

Drizzle은 Supabase의 전부를 덮지 못합니다. 테이블과 RLS까지는 선언으로 커버되는데, 그 밖은 아닙니다. 제일 아픈 건 RPC, 즉 Postgres 함수입니다.

RPC의 장점은 실재합니다. 여러 번의 왕복이 한 번의 호출로 줄고(서버와 DB가 대륙을 사이에 두면 왕복 하나가 곧 지연입니다), 함수 안의 문장들은 한 트랜잭션으로 묶여 원자성이 보장되고, 민감한 로직을 DB 안에 숨기고 실행 권한만 내어줄 수 있습니다. 포기할 수 있는 장점이 아니에요. 그런데 비용이 있습니다. 로직이 plpgsql로 들어가는 순간 디버깅과 테스트가 어려워지고, 러닝 커브가 생기고, 무엇보다 선언의 세계 밖으로 나갑니다. Supabase CLI가 타입을 생성해 주지만 그건 시그니처까지고, 함수 본문은 여전히 타입 검사도 리뷰 도구도 없는 SQL 문자열입니다.

grants도 마찬가지입니다. Drizzle 팀의 공식 안내가 "권한은 빈 마이그레이션 파일에 raw SQL로 직접 쓰라"입니다. 그래서 제 레포에는 지금도 "Drizzle이 못 다루는 것들을 SQL 문자열로 적어 마이그레이션에 손으로 덧붙이는" 디렉터리가 삽니다. 선언의 세계와 문자열의 세계가 한 프로젝트 안에서 동거하고, 리뷰의 밀도는 그 경계에서 뚝 떨어집니다. 에이전트에게 일을 시켜 보면 이 경계가 더 선명해집니다. 선언 쪽 변경은 diff가 말이 되는데, 문자열 쪽 변경은 결국 제가 SQL을 처음부터 다시 읽어야 하니까요.

참을 수는 있었습니다. 몇 년을 참았으니까요.

그래서 다리를 짓기 시작했습니다

계속 참을 수도 있었지만, 이번엔 만들기로 했습니다. 지난 회고에서 예고한 오픈소스가 이것입니다. hejbro. 테이블, RLS, 함수, 트리거, 뷰, grants까지, 데이터베이스의 전부를 TypeScript로 선언하고, 그 diff에서 결정적인 마이그레이션 SQL을 생성하는 도구입니다. 이름의 유래(스웨덴어로 "안녕, 다리")는 지난 글에 적었는데, 지향도 그대로입니다. 선언의 세계와 문자열의 세계로 갈라져 있던 데이터베이스를 한쪽 편으로 다 건너오게 하는 다리요.

핵심은 함수입니다. 지금까지 마이그레이션 속 SQL 문자열로 살던 RPC가, 타입 있는 TypeScript 코드가 됩니다.

export const publishPost = defineFunction("ddland", "publish_post", {
	args: { postId: uuid() },
	returns: posts,
	security: "definer",
	grants: ["authenticated"],
}, (ctx, { postId }) => {
	const post = ctx.row(select(posts).where(eq(posts.id, postId)));

	ctx.if(isNotNull(post.publishedAt), () => {
		ctx.raise("already published: %", postId);
	});

	ctx.return(
		update(posts)
			.set({ publishedAt: now() })
			.where(eq(posts.id, postId))
			.returning()
	);
});

이 코드는 plpgsql로 컴파일됩니다. AI가 쓰는 것은 이 코드뿐이고, DB에 닿는 것은 생성된 SQL뿐이고, 사람이 보는 것은 diff뿐입니다. 잘못 지우면요? 리뷰에서 걸리면 머지하지 않으면 되고, 머지한 뒤라도 무엇이 언제 왜 바뀌었는지 Git이 전부 들고 있습니다. 앞에서 찾아 헤맨 "잘못됐을 때의 정책"이, 여기서는 도구의 기능이 아니라 구조의 기본값입니다.

솔직한 상태를 적어두면: 아직 pre-alpha이고, 아무것도 배포되지 않았습니다. 코어는 제네릭 Postgres로 만들고 Supabase는 첫 번째 프리셋으로 붙습니다(Neon, Nile 프리셋도 계획에 있습니다). 로드맵과 설계 결정 전부가 레포에 공개돼 있어요.

한 가지 더. 이 프로젝트는 AI 에이전트 팀이 짓고 있습니다. 브레인스톰과 스펙은 저와 함께, 구현은 단계마다 계획, TDD, 리뷰, PR 사이클로요. 착수 첫날 스캐폴드에서 plpgsql 컴파일러가 도는 데까지 왔습니다. AI에게 데이터베이스를 맡겨도 되는가를 고민해 만드는 도구를, AI에게 맡겨 짓고 있는 셈입니다. 그 아이러니가 싫지 않습니다. 코드는 맡겨도 되는 구조가 이미 있다는 걸, 이 프로젝트가 매일 스스로 증명하고 있으니까요.