본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 17. 06:35

프레임워크 대신 언어를 만든 이유

요약

글쓴이는 여러 프로젝트를 진행하며 반복적으로 발생하는 인증, 세션 관리, 멀티 테넌트 가드 등 '배관(plumbing)' 작업의 비중이 너무 높다는 문제점을 발견했습니다. 이로 인해 실제 제품 기능 개발보다 기반 구조 구축에 더 많은 시간을 할애하게 되었고, 이러한 패턴을 해결하기 위해 기존 프레임워크 대신 새로운 언어(Kilnx)를 만들게 되었다고 설명합니다. 그는 대부분의 웹 애플리케이션이 복잡한 문제에서 오는 것이 아니라, 사용하는 도구(프레임워크/도메인)에서 오는 '복잡성' 때문에 어렵다고 주장하며, Kilnx가 이러한 구조적 문제를 해결하기 위한 시도로서 제시됩니다.

핵심 포인트

  • 대부분의 웹 앱 개발 시간은 실제 비즈니스 로직보다 기반 구조(plumbing) 작업에 할애된다.
  • 반복되는 인증, 세션 관리, 멀티 테넌트 가드 등의 패턴을 추상화하여 해결할 필요성을 느꼈다.
  • 웹 애플리케이션의 복잡성은 문제 자체에서 오는 것이 아니라, 사용하는 도구(프레임워크/도메인)에서 기인하는 경우가 많다.
  • Kilnx는 이러한 구조적 반복 작업을 최소화하고 핵심 로직에 집중하기 위해 설계된 새로운 언어이다.

Kilnx 작업을 시작한 지 3주 차쯤 되었을 때, 한 친구가 왜 이것을 그냥 Express 플러그인으로 작성하지 않았느냐고 물었습니다. 타당한 질문이었습니다. 그것은 지난 한 달 동안 제 자신의 뇌가 스스로에게 던졌던 질문과도 같았습니다. 솔직한 답변을 찾는 데는 인정하고 싶지 않을 만큼 오랜 시간이 걸렸습니다. 이 글은 만약 제가 제가 무엇을 하고 있는지 이미 이해하고 있었더라면, 지난 11월 그에게 말했을 내용입니다.

저를 몰아붙였던 것은 제가 배포해 온 백엔드(backends)들이었습니다. 그중 일부는 작았습니다. 대부분은 그렇지 않았습니다. 모든 쿼리가 org_id로 필터링되어야만 고객 간 데이터 유출을 막을 수 있는 멀티 테넌트 (multi-tenant) CRM. 역할 기반 (role-based) 페이지, 예약된 작업 (scheduled jobs), HMAC으로 서명된 아웃바운드 웹훅 (outbound webhooks)이 포함된 내부 관리 도구. 백그라운드 워커 (background workers), 속도 제한이 걸린 API (rate-limited APIs), 임계 경로 (critical path) 내의 LLM 호출이 포함된 SaaS 대시보드. 이 중 어느 것도 블로그가 아닙니다. 이 모든 것들은 동일한 형태를 띠고 있었습니다.

각 프로젝트에서 흥미로운 작업은 도메인 (domain)이었습니다. 흥미롭지 않은 작업은 동일했습니다. 인증 (Auth) 설정. 세션 관리 (Session management). CSRF 배선 (CSRF wiring). 커넥션 풀 튜닝 (Connection pool tuning). 마이그레이션 스크립트 (Migration script) 명명. 모든 쿼리에 대한 멀티 테넌트 가드 (Multi-tenant guards). 웹훅 서명 검증 (Webhook signature verification). 재시도 과정에서 비용을 낭비하지 않고 백그라운드 작업에서 Claude를 호출하는 올바른 방법. 첫 번째 기능을 배포할 때쯤, 저는 수십 개의 파일에 손을 댔고 40개의 결정을 내렸지만, 그 중 어느 것도 고객이 원하는 것과는 관련이 없었습니다.

저는 세기 시작했습니다. 해당 프로젝트 코드 라인의 3분의 2는 배관 (plumbing, 기반 구조 작업)에 관한 것이었습니다. 나머지 3분의 1이 제품 (product)이었습니다. 이 수치는 세 개의 프로젝트에서 유지되었습니다. 이 수치는 두 개의 스택 (stacks)에서도 유지되었습니다. 배관은 프로젝트의 부산물이 아니었습니다. 그것은 툴체인 (toolchain)의 특징이었으며, 제가 손대는 모든 프로젝트에서 나타나고 있었습니다. 그것이 저를 언어 (language)로 이끈 것이었습니다. 단순한 느낌이 아니었습니다. 팀, 고객, 또는 스택을 바꾸어도 변하지 않는 패턴이었습니다.

헌법 (Constitution)
이 저장소에는 PRINCIPLES.md라는 파일이 있습니다. 다른 원칙들보다 앞서기에 0번으로 번호가 매겨진 첫 번째 원칙은 다음과 같습니다: 복잡함은 도구의 잘못이지, 문제의 잘못이 아니다. 대부분의 웹 앱은 복잡하지 않습니다.

그것들은 리스트, 폼, 대시보드, CRUD입니다. 복잡함은 우리가 해결하려는 문제에서 오는 것이 아니라, 우리가 사용하는 도구에서 옵니다. Kilnx는 이를 증명하기 위해 존재합니다. 이 문장은 하나의 주장입니다. 언어의 나머지 부분은 그 주장에 대한 시험입니다. 만약 당신이 전제(premise)를 받아들인다면, 설계는 그 뒤를 따릅니다. 만약 당신이 전제를 거부한다면, Kilnx의 그 어떤 것도 말이 되지 않습니다. 흥미로운 논쟁점은 설계가 영리한지 여부가 아니라, 그 전제가 참인지 여부입니다. 저는 그것이 대체로 참이라고 생각합니다. 전적으로 그렇지는 않습니다. 어떤 웹 작업은 진정으로 복잡하며, 어떤 도구를 사용하더라도 복잡할 것입니다. 하지만 "복잡한 문제"와 "복잡한 도구" 사이의 경계선은 대부분의 엔지니어들이 인정하고 싶어 하는 것보다 도구 쪽으로 더 치우쳐져 있으며, 주어진 복잡성이 어느 쪽에 속하는지 알아내는 방법은 자기 자신을 덜어내는 도구를 만들어 무엇이 남는지 확인하는 것입니다. 다음은 이 언어의 작동하는 단면(slice)이 어떻게 보이는지에 대한 예시입니다. 인증된 작업 목록, htmx 삭제, 페이지가 나뉜 쿼리(paginated query)가 모두 하나의 파일에 들어 있습니다.

model task title: text required done: bool default false created: timestamp auto auth table: user identity: email password: password login: /login after login: /tasks page /tasks requires auth query tasks: SELECT id, title, done FROM task WHERE owner = :current_user.id ORDER BY created DESC paginate 20 html {{each tasks}} <tr> <td>{title}</td> <td>{{if done}}Yes{{end}}</td> <td><button hx-post="/tasks/{id}/delete" hx-target="closest tr" hx-swap="outerHTML">Delete</button></td> </tr> {{end}} action /tasks/:id/delete requires auth query: DELETE FROM task WHERE id = :id AND owner = :current_user.id respond fragment delete

이 파일이 앱의 전부입니다. 회원가입, bcrypt를 이용한 로그인, 세션(sessions), htmx POST에 대한 CSRF, 모든 쿼리에 대한 파라미터 바인딩(parameter binding), 페이지네이션(pagination), 삭제 시 소유권 확인까지 포함되어 있습니다. Express로 구현한다면 어떤 미들웨어를 복사해서 쓰느냐 혹은 추출하느냐에 따라 8개의 파일에 걸쳐 400줄에서 600줄 사이가 될 것입니다. 명백한 반론은 이렇습니다: "언어가 아니라 프레임워크를 만들어라. Rails가 있고, Phoenix가 있고, Django가 있지 않은가."

백엔드 작업에서 무엇이 잘못되었다고 생각하든, 누군가는 이미 당신이 좋아하는 호스트 언어 (host language)를 더 얇은 무언가로 감싸고 그것을 프레임워크 (framework)라고 불렀을 것입니다. 하나를 골라 기여하십시오. 이것이 저를 포함하여 제가 계속해서 듣게 된 논리의 버전이었습니다. 하지만 이 논리는 한 가지 구조적인 이유 때문에 설득력을 얻지 못했습니다. 프레임워크는 항상 제약 조건 (constraint) 싸움에서 패배하며, 제약 조건 싸움이야말로 Kilnx가 이기고자 하는 바로 그 싸움이기 때문입니다.

프레임워크는 호스트 언어 내부에서 살아갑니다. 호스트 언어는 모든 수준에서 탈출구 (escape hatches)를 제공합니다. 라우터 (router)를 우회하고 싶습니까? HTTP 서버를 직접 호출하십시오. ORM을 건너뛰고 싶습니까? 생 SQL (raw SQL)로 내려가십시오. 테넌트 가드 (tenant guard)를 무시하고 싶습니까? 주석 처리하십시오. 언어는 그것을 허용할 것입니다. 제가 실제로 실무에 적용했던 모든 프레임워크는 6개월 차에 접어들면 코드베이스의 절반은 공식 패턴 (official patterns)으로, 나머지 절반은 탈출구로 채워져 있었습니다. 탈출구는 버그가 아닙니다. 그것은 프레임워크가 범용 언어 (general-purpose language) 내부의 테넌트 (tenant)로서 살아가기 위해 지불하는 대가입니다. 저는 테넌트를 원한 것이 아닙니다. 저는 계약 (contract)을 원했습니다.

언어가 거부할 수 있는 것들. 프레임워크 내부에서는 불가능했던 다섯 가지가 언어 내부에서는 자연스러운 것이 되었습니다. 첫째, 컴파일 타임 (compile-time) SQL 안전성. 어떤 프레임워크에서든 당신의 SQL은 문자열 안에 있거나, 문자열을 컴파일하는 ORM 안에 있거나, 혹은 그렇지 않은 척하는 쿼리 빌더 (query builder) 안에 있습니다. 프레임워크는 컴파일 타임에 당신의 쿼리를 검증할 수 없습니다. 왜냐하면 호스트 언어가 컴파일 타임에 그것들을 볼 수 없기 때문입니다. Kilnx의 쿼리는 프로그램의 나머지 부분을 파싱하는 것과 동일한 컴파일러에 의해 파싱됩니다. 모델에서 컬럼 (column) 이름을 변경하면, 해당 컬럼을 참조하는 모든 쿼리는 컴파일에 실패합니다. SQL 인젝션 (SQL injection)은 탈출 함수 (escape function)에 의해 차단되는 것이 아닙니다. 타입이 지정되지 않은 문자열을 SQL 위치에 보간 (interpolate)하는 것을 문법 (grammar) 자체가 거부함으로써 차단됩니다.

둘째, 구문 (syntax)으로서의 멀티 테넌트 가드 (multi-tenant guards). 제가 출시한 모든 SaaS 백엔드는 동일한 유형의 버그를 가지고 있었습니다. 누군가 쿼리에 WHERE org_id = :current_user.org_id를 추가하는 것을 잊어버리면, 한 테넌트가 다른 테넌트의 데이터를 읽을 수 있게 됩니다. 이러한 버그 유형이 존재하는 이유는 호스트 언어가 누락된 필터를 유효한 코드로 간주하기 때문입니다.

Kilnx에서 모델에 설정된 테넌트 수정자(tenant modifier)는 분석기(analyzer)가 모든 쿼리 경로에서 강제하는 '실패 시 차단(fail-closed)' 가드를 생성합니다. 만약 테넌트 범위를 지정하지 않는 쿼리를 작성하면, 컴파일러는 바이너리 빌드를 거부합니다.

model invoice
  tenant amount: int required
  customer: text required

page /invoices requires auth

query invoices: SELECT id, amount, customer FROM invoice

html {{each invoices}}<p>{customer}: {amount}</p>{{end}}

$ kilnx check app.kilnx
error: query in page /invoices is missing tenant scope
app.kilnx:5: query invoices: SELECT id, amount, customer FROM invoice
hint: tenant model 'invoice' requires WHERE org_id = :current_user.org_id
hint: or use `unscoped: explicit-reason` to opt out for a specific query

프레임워크는 경고를 줄 수 있지만, 언어는 빌드를 거부할 수 있습니다. 이 패턴은 테넌트 롤아웃(tenant rollout) PR에 문서화되어 있습니다.

일급 언어 구성 요소(first-class language constructs)로서의 LLM 에이전트. 제가 지난주에 쓴 글에서는 프로덕션 환경의 에이전트들이 결국 이미 실행 중인 오케스트레이터(orchestrator) 상의 태스크(task)로 귀결된다고 주장했습니다. 동일한 논리가 한 단계 아래 계층에도 적용됩니다. 요청 핸들러(request handler) 내부의 에이전트는 이미 실행 중인 언어 상의 태스크입니다.

mcp linear
command: linear-mcp-server
env: LINEAR_API_KEY=:env.LINEAR_API_KEY
action /tickets/:id/triage

agent classify
prompt: "Classify ticket {ticket.body} into one of: bug, feature, support."
permission-mode: plan
max-budget-usd: 0.25
max-turns: 3

mcp: linear
query: UPDATE ticket SET category = :classify.text, cost_usd = :classify.cost_usd WHERE id = :id
respond fragment ticket-row

에이전트는 Claude CLI 서브프로세스를 생성합니다. :classify.text, :classify.session_id, :classify.cost_usd, :classify.stop_reason은 해당 액션이 지속되는 동안 바인딩(bound)됩니다. max-budget-usd는 런타임(runtime)에 의해 강제됩니다. mcp: linear는 파일 상단에 선언된 MCP 서버를 마운트(mount)합니다. 에이전트를 둘러싼 프레임은 접착제(glue)가 아니라 문법(grammar)입니다.

제어된 표면(controlled surface)으로서의 마이그레이션.

kilnx migrate는 다음 다섯 가지 차원에서 드리프트(drift)를 감지합니다: 고아 컬럼(orphan columns), 타입 불일치(type mismatch), NOT NULL 불일치(NOT NULL mismatch), 단일 컬럼 UNIQUE 불일치(single-column UNIQUE mismatch), DEFAULT 존재 여부 불일치(DEFAULT presence mismatch). 마이그레이션 자체는 가산적(additive)이며, 결코 파괴적(destructive)이지 않습니다. 이 언어는 무엇을 자동으로 수행하는 것이 안전한지, 그리고 무엇이 인간의 확인을 필요로 하는지에 대해 입장을 취했습니다.

$ kilnx migrate app.kilnx applying schema...
warning: orphan column invoice.legacy_status (DB에는 존재하지만, 모델에는 선언되지 않음)
hint: 데이터 마이그레이션 후 수동으로 삭제하십시오
warning: type mismatch user.id (DB: integer, 모델: uuid)
hint: 자동 생성되는 것이 아니라, 데이터 마이그레이션과 ALTER가 필요합니다
warning: NOT NULL mismatch task.due_date (DB: nullable, 모델: required)
hint: 제약 조건을 강화하기 전에 기본값을 채워 넣으십시오(backfill)
warning: UNIQUE mismatch account.slug (DB: unique하지 않음, 모델: unique)
hint: 제약 조건을 추가하기 전에 중복 행을 제거하십시오(dedupe)
warning: DEFAULT presence mismatch task.done (DB: 기본값 없음, 모델: default false)
hint: 새 코드에서 기본값에 의존하기 전에 검토하십시오
migration applied with 5 warnings.

프레임워크는 마이그레이션 도구를 배포할 수 있습니다. 언어는 마이그레이션 도구를 라우트(routes)를 빌드하는 것과 동일한 컴파일 단계(compile pass)의 일부로 만들 수 있습니다. 단일 바이너리 배포(Single-binary deploy). 프레임워크는 당신이 함께 배포해야 하는 런타임(runtime) 위에서 실행됩니다. Node, Python, Ruby 등은 각각 패키지 매니저, 락파일(lockfile), Dockerfile, 그리고 작은 운영체제 크기만한 node_modules 디렉토리를 가져옵니다. Kilnx는 .kilnx 파일을 HTTP 서버, 데이터베이스 드라이버, htmx JavaScript, 그리고 당신의 애플리케이션을 포함하는 15MB 크기의 바이너리로 컴파일합니다. 이를 서버로 scp로 복사하고 실행하기만 하면 됩니다. 배포 과정은 ./myapp 하나로 끝납니다. 프레임워크는 배포 과정을 축소할 수 있지만, 언어는 이를 완전히 붕괴(collapse)시켜 하나로 합칠 수 있습니다.

이 패턴을 주목하십시오. 프레임워크는 이러한 일들을 더 쉽게 만들 수 있습니다. 언어는 그 대안들을 불가능하게 만들 수 있습니다. 이 비대칭성(asymmetry)이 핵심입니다.

비용(What it costs)
언어를 만드는 것은 프레임워크를 만드는 것보다 더 많은 작업이 필요합니다. 이것이 언급해야 할 트레이드오프(trade-off)의 쉬운 절반입니다.

이 저장소(repo)는 19,000줄의 Go 코드와 레이스 탐지(race detection) 기능이 포함된 311개의 테스트로 구성되어 있으며, 서류상의 기능 목록만 보면 약간의 주관이 개입된 웹 프레임워크(web framework)처럼 보이는 무언가를 제공합니다. 만약 제가 작은 웹 프레임워크를 원했다면, 프레임워크를 만드는 것이 정답이었을 것입니다. 더 어려운 나머지 절반은 언어가 스스로를 진지하게 받아들여야 한다는 점입니다. 문법(grammar)은 일관되어야 합니다. 에러 메시지(error messages)는 유용해야 합니다. 툴링(tooling)이 존재해야 합니다. 무언가 부족할 때 다른 사람의 생태계(ecosystem)에 의존할 수 없습니다. LSP 서버를 제공하지 않으면 사용자는 자동 완성(autocomplete)을 사용할 수 없습니다. 테스트 러너(test runner)를 제공하지 않으면 사용자는 테스트를 할 수 없습니다. 플레이그라운드(playground)를 제공하지 않으면 사용자는 언어를 설치하지 않고는 평가(evaluate)할 수 없습니다. 코딩 에이전트(coding agents)를 위해 AGENTS.md를 자동 생성하지 않으면, 사용자는 LLM이 존재하지 않는 키워드를 만들어내는 상황을 겪게 됩니다. 세 번째 비용은 언어는 무언가를 거부한다는 것이며, 무언가를 거부하는 것은 사회적으로 비용이 많이 듭니다. 모든 거부는 해당 언어가 지원하지 않는, 매우 합리적인 사용 사례(use case)를 가진 누군가와의 싸움입니다. 프레임워크는 탈출구(escape hatch)를 통해 그러한 사용 사례를 흡수할 수 있습니다. 언어는 그럴 수 없습니다. 당신은 누군가의 눈을 똑바로 바라보며, 언어가 그것을 수행하지 않을 것이며, 그 이유는 그렇게 하는 것이 계약(contract)을 깨뜨리기 때문이라고 말해야 합니다. 한 친구는 언어를 만드는 과정에서 아무도 경고해주지 않는 부분은, 옳은 말을 하는 수많은 사람에게 '아니오'라고 말해야 한다는 점이라고 말해주었습니다.

돌려받는 것(What it gives back) - 돌려받는 것은 그 비용을 정당화하는 부분입니다. 또한 이는 마케팅 페이지에 담기 어려운 부분이기도 한데, 읽는 것이 아니라 측정되어야 하기 때문입니다. 그러니 측정하십시오. 저장소에 있는 블로그 예제는 단일 파일 내에 94줄로 되어 있습니다. Express의 경우, 어떤 미들웨어(middleware)를 복사하느냐 혹은 추출하느냐에 따라 8개의 파일에 걸쳐 400줄에서 600줄 사이가 됩니다.

Kilnx 블로그 Express + Prisma + EJS 블로그 ────────────────── ────────────────────────────── app.kilnx 94 app.js 62 routes/auth.js 88 routes/posts.js 104 models/Post.js 36 middleware/csrf.js 24 middleware/session.js 31 db/migrations/ 47 views/ 94 ────────── ─── 8 files 486 1 file 94 ~480 Kilnx 버전이 짧은 이유는 앱이 하는 일이 적어서가 아니라, 언어가 나머지를 흡수했기 때문입니다. 두 열 모두 동일한 기능들을 배포합니다. 차이점은 어느 쪽에서 그것들을 작성했느냐입니다. Kilnx 측에서 사라진 것들, 즉 사용자가 작성하지 않은 것들:

bcrypt 비밀번호 해싱 (password hashing) auth에서 자동 처리
세션 쿠키 (session cookies), auth에서 서명(signed) 자동 처리
모든 POST/PUT/DELETE에 대한 CSRF 자동 처리
모든 작업에서 자동 처리
SQL 파라미터 바인딩 (parameter binding)은 문법(grammar)에서만 허용
템플릿에서의 HTML 이스케이핑 (HTML escaping)은 문법에서만 허용
멀티 테넌트 스코핑 (multi-tenant scoping)은 스키마가 누락된 경우 컴파일 타임에 거부됨
라우트 (routes)를 빌드하는 것과 동일한 컴파일 패스 (compile pass)에서 마이그레이션 수행
LLM 에이전트 예산 제한 (budget enforcement) 필수 속성

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0