사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
일반▾
인터페이스▾
관측성▾
성능▾
네이티브▾
개발▾

쿼리

Akan에서 데이터베이스 쿼리는 <model>.document.ts에 이름을 붙여 둔 필터입니다. service와 slice는 같은 조건을 곳곳에서 다시 만들지 않고 그 이름으로 호출합니다.
구성 요소
filter()
from(cnst.Task, (filter) => …) 안에서 이름 붙은 쿼리 하나를 시작합니다.
.arg(name, Type)
필수 입력입니다. 예: .arg("projectId", ID).
.opt(name, Type)
선택 입력입니다. 모든 .arg() 뒤에 오고, 넘기지 않으면 undefined입니다.
.query((...args, q) => …)
입력을 선언한 순서대로 받고 마지막에 q를 받아 조건을 반환합니다.
q
조건 헬퍼입니다. q.all, q.oneOf, q.between, q.when 등이 있습니다.
sort
기본 제공되는 latest, oldest, relevance 외에 추가하는 정렬입니다. 없으면 {}로 둡니다.

기본 필터

화면에 필요한 목록에서 시작합니다. 예를 들어 프로젝트 페이지는 그 프로젝트에서 보관되지 않은 태스크 목록을 보여 줍니다.
1. document.ts에 선언하기
모델의 filter 클래스에 필터를 추가합니다:
apps/myapp/lib/task/task.document.ts
2. dictionary에 이름 붙이기
필터와 각 인자에 [en, ko] 라벨을 붙입니다. 빠진 항목이 있으면 타입 에러가 납니다:
apps/myapp/lib/task/task.dictionary.ts
3. 이름으로 호출하기
필터마다 그 이름을 딴 메서드가 model과 service에 생깁니다:
apps/myapp/lib/task/task.service.ts
가장 자주 쓰는 메서드는 다음과 같습니다:
listInProject
조건에 맞는 문서 전부입니다. 마지막 인자 { sort, skip, limit }로 페이지를 나눕니다.
findInProject
첫 번째로 맞는 문서입니다. 없으면 null입니다.
pickInProject
첫 번째로 맞는 문서입니다. 없으면 에러를 던집니다.
countInProject
맞는 문서의 수입니다.
existsInProject
맞는 문서 하나의 id입니다. 없으면 null입니다.
queryInProject
아직 실행하지 않은 조건 자체입니다. slice의 exec는 이것을 반환합니다.
  • 같은 규칙으로 여덟 개가 더 있습니다. listIds, findId, pickId, insight, 그리고 hook 없이 쿼리로 바로 쓰는 remove, removeOne, update, updateOne입니다.
  • service에서 부르면 페이지 크기가 없습니다. limit 없이 부른 listInProject()는 맞는 문서를 전부 반환하므로, 계속 늘어나는 목록에는 꼭 넘깁니다.
  • sort에는 정렬 키 이름을 넣습니다. latest, oldest 또는 필터의 sort에 선언한 키를 쓰고, 없는 키는 거절됩니다.

선택 조건

선택 입력은 사용자가 실제로 무언가를 골랐을 때만 조건을 붙여야 합니다. q.when(condition, query)는 condition이 참일 때만 query를 붙이고, 아니면 아무것도 붙이지 않습니다:
apps/myapp/lib/task/task.document.ts
  • q.when은 조건이 거짓이어도 두 번째 인자를 먼저 만듭니다. q.oneOf(undefined)는 에러를 던지므로, 위 코드는 assigneeIds ?? []를 넘깁니다.
  • undefined 값은 에러입니다. assigneeId가 없으면 { assignee: assigneeId }는 거절되므로 q.when으로 감쌉니다.
  • q.oneOf([])는 아무것도 찾지 않습니다. 값이 있는지만 보지 말고 ?.length까지 확인해야 빈 선택이 목록을 비워 버리지 않습니다.

범위와 OR

기간에는 q.between, OR 조건에는 q.any를 씁니다. 날짜 대시보드와 상태 보드가 읽기 쉬워집니다:
apps/myapp/lib/task/task.document.ts
  • 양 끝값이 포함됩니다. q.between(from, to)는 >= from AND <= to로 바뀝니다.
  • 날짜는 받은 그대로 넘깁니다. Date 인자는 Dayjs로 들어오고, 날짜는 epoch 밀리초로 비교됩니다.
  • key가 여러 개인 객체 하나는 이미 AND입니다. { project: projectId, status: "done" }에는 q.all이 필요 없습니다.

Raw 쿼리

헬퍼로 표현할 수 없는 조건에만 q.raw를 씁니다. 작은 SQL 조각 하나로 유지하고, 값은 모두 파라미터로 넘깁니다:
apps/myapp/lib/post/post.document.ts
  • 값은 문자열이 아니라 배열에 넣습니다. ?마다 다음 값이 바인딩되므로 사용자 입력이 SQL이 되는 일이 없습니다.
  • 조각 하나에 조건 하나. 괄호로 감싸져 다른 조건처럼 이어지고, ;가 든 조각은 거절됩니다.
  • 쓰는 데이터베이스에 맞춰 작성합니다. 위 코드는 SQLite 문법입니다. Postgres는 _doc을 jsonb로 두고 field를 텍스트로 읽으므로, 같은 조건은 ("_doc" #>> '{score}')::numeric > ?입니다.

SQL로 바뀌는 방식

Akan은 모델의 field를 JSON 컬럼 하나, _doc에 담고, filter 객체를 SQL WHERE 절로 컴파일합니다. 개발자는 document 모양으로 쓰고, SQL은 데이터베이스 adaptor가 씁니다.
field가 저장되는 곳
_doc
모델이 선언한 field를 모두 담는 JSON 컬럼입니다. SQLite에서는 json_extract(_doc, '$.field')로 읽습니다.
idcreatedAtupdatedAtremovedAt
실제 컬럼 네 개입니다. "updatedAt" >= ?처럼 바로 비교합니다.
  • 삭제한 document는 결과에 나오지 않습니다. 모든 조회와 쿼리 단위 쓰기에 "removedAt" IS NULL이 붙으며, 삭제된 행만 만족하는 조건은 can never match 오류를 냅니다. 삭제된 행을 읽으려면 { withRemoved: true }를 넘깁니다.
  • 아래 SQL은 단순화한 SQLite 형태입니다. Postgres에서는 같은 필터가 _doc #> '{status}' 같은 jsonb 연산자로 바뀝니다.
  • 값은 파라미터로 남습니다. ?마다 따로 바인딩되므로, 사용자 입력이 SQL 문자열에 붙여 넣어지지 않습니다.
값 비교
값 그대로
같다는 뜻입니다. 객체 하나에 key가 여러 개면 AND로 묶입니다.
q.eq
같음을 명시해서 쓴 형태로, 값을 그대로 쓴 것과 같습니다.
q.ne
같지 않습니다.
q.oneOf
목록의 값 중 하나와 같습니다. 빈 목록은 아무것도 찾지 않습니다.
q.notOneOf
목록의 어떤 값과도 같지 않습니다. 빈 목록은 모두 찾습니다.
q.gt
주어진 값보다 큽니다.
q.gte
크거나 같습니다.
q.lt
주어진 값보다 작습니다.
q.lte
작거나 같습니다.
q.between
범위 안에 있습니다. 양 끝값도 포함합니다.
값의 유무
q.exists
저장된 JSON에 key가 있습니다. 값이 null이어도 해당합니다.
q.missing
저장된 JSON에 key가 없습니다. field보다 먼저 쓰인 행을 찾을 때만 씁니다.
q.empty
값이 없습니다. key가 없거나 null을 담고 있습니다.
배열과 텍스트
q.has
배열 field가 그 값을 담고 있습니다.
배열 field
배열 field에서는 값 그대로나 q.oneOf도 항목 안을 검사합니다.
q.contains
텍스트가 그 값을 포함합니다. 값은 %release%로 바인딩됩니다.
q.search
text 역할 field의 전문 검색으로, WHERE가 아닌 JOIN이 됩니다. 모든 데이터베이스 모드에서 동작합니다.
조건 조합
q.all
모든 조건이 참입니다. null, undefined, false 항목은 건너뜁니다.
q.any
조건 중 하나 이상이 참입니다.
q.not
조건이 거짓입니다.
q.when
조건이 참이면 쿼리를 붙이고, 거짓이면 아무것도 붙이지 않습니다.
경로와 raw SQL
중첩 경로
점으로 이은 key로 중첩 객체 안을 봅니다.
기본 컬럼
id, createdAt, updatedAt, removedAt은 실제 컬럼으로 비교합니다.
q.raw
직접 쓴 SQL 조각입니다. 괄호로 감싸져 그대로 들어가므로, 쓰는 데이터베이스의 문법으로 씁니다.
왜 JSON document인가요?
가벼운 스키마 변경
작은 field를 더할 때 보통 테이블 마이그레이션이 필요 없어서, 제품 코드를 더 빨리 바꿀 수 있습니다.
쿼리 우선 설계
함께 읽는 데이터를 함께 저장해서, 추가 join과 service의 연결 코드가 줄어듭니다.
자연스러운 중첩 구조
설정, 이력, 옵션, 스냅샷이 제 모양을 유지하면서도, 중요한 경로는 그대로 필터링할 수 있습니다.
자주 쓰는 경로에만 인덱스
목록과 상세 화면에 맞춰 일부러 비정규화하고, 트래픽이 몰리는 경로에만 인덱스를 추가합니다.

쿼리 작성 습관

필터를 찾기 쉽고 빠르게 유지하는 네 가지 습관입니다:
  • 필터 이름은 전치사로 시작합니다. inProject, inPeriod, byStatuses처럼 목록의 범위를 말하고, getXInY나 listX는 쓰지 않습니다.
  • page에서 쿼리를 조립하지 않습니다. page와 service는 필터를 이름으로 부르므로, 조건은 한 곳에만 있습니다.
  • raw SQL보다 헬퍼를 먼저 씁니다. 헬퍼는 SQLite와 Postgres 모두에서 동작하고 값을 알아서 바인딩합니다.
  • q.contains는 모든 행을 읽습니다. 인덱스를 쓸 수 없는 LIKE '%…%' 스캔이므로, 검색창에는 q.search를 씁니다.
자주 쓰는 경로에 인덱스
트래픽이 몰리는 필터가 생기면, 그 필터가 비교하는 field에 인덱스를 겁니다. 인덱스는 모델의 _onSchema에서 선언합니다:
apps/myapp/lib/task/task.document.ts
  • 인덱스는 필터가 쓰는 식 그대로 만들어집니다. SQLite에서 schema.index({ project: 1 })는 json_extract(_doc, '$.project')에 인덱스를 걸고, { project } 조건이 그 인덱스를 씁니다. Postgres는 같은 식을 Postgres 문법으로 바꾼 형태에 인덱스를 겁니다.
  • 정렬 키에는 인덱스가 자동으로 걸립니다. 필터의 sort에 선언한 정렬마다 removedAt과 함께 인덱스가 생기지만, 조건에 쓰는 field에는 생기지 않습니다.

MIT 라이선스 하에 배포되었습니다.

내 AI에 이 문서 연결하기

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js 모든 권리 보유.시스템 관리자bassman