Convex로 실시간 백엔드 만들기: 스키마, Query와 Mutation, Action, 파일 스토리지
이 글의 핵심
실시간 기능을 직접 만들려면 WebSocket과 캐시 무효화를 따로 챙겨야 하는데, Convex는 쿼리를 구독하면 데이터 변경이 자동으로 반영되는 방식으로 이 부담을 줄입니다. Query·Mutation과 Action의 역할을 구분해야 하는 이유, Firebase·Supabase와 비교한 선택 기준, 프로덕션 도입 전 확인할 점을 다룹니다.
이 글의 핵심
Convex로 실시간 백엔드를 만드는 과정을 정리한 글입니다. 타입 안전한 API, 실시간 구독, 파일 스토리지를 예제로 다룹니다.
실무에서 마주치는 문제들
타입 안전성이 부족해요
Firestore는 문서 구조를 강제하지 않아서 doc.data()의 결과가 사실상 any입니다. 타입을 붙이려면 withConverter로 직접 변환기를 만들어야 하고, 그 타입이 실제 저장된 데이터와 맞는다는 보장도 없습니다. Convex는 convex/ 폴더의 스키마와 함수 정의에서 클라이언트용 타입(_generated/api)을 자동 생성하므로, 서버 함수의 인자나 반환 타입을 바꾸면 그 함수를 부르는 React 코드에서 바로 컴파일 에러가 납니다.
실시간 구독이 어려워요
실시간 기능을 직접 만들면 WebSocket 서버, 연결 관리, 어떤 변경이 어떤 클라이언트에게 가야 하는지 계산하는 로직, 재연결 시 누락된 이벤트 처리까지 모두 구현해야 합니다. Convex는 Query 함수가 읽은 데이터를 추적해 두었다가, 그 데이터가 바뀌면 Query를 다시 실행하고 결과를 구독 중인 클라이언트에 밀어 줍니다. 개발자는 “무엇을 읽을지”만 정의하면 됩니다.
백엔드 로직이 필요해요
권한 검사나 여러 문서를 함께 바꾸는 작업을 클라이언트에서 하면 조작되거나 중간에 실패할 수 있습니다. Convex에서는 이런 로직을 서버에서 트랜잭션으로 실행되는 Mutation 함수로 작성합니다.
대가로 데이터와 실행 환경이 Convex 플랫폼에 묶입니다. SQL을 쓸 수 없고 조회는 Convex의 쿼리 API와 인덱스로만 하므로, 복잡한 집계나 임의의 조인이 많은 분석성 워크로드에는 맞지 않습니다. 셀프 호스팅 버전도 공개되어 있지만 운영 부담을 줄이려고 선택하는 서비스인 만큼 대부분은 클라우드 버전을 씁니다.
Convex란?
핵심 특징
Convex는 실시간 백엔드 플랫폼입니다. 주요 장점:
- 타입 안전성: End-to-End TypeScript
- 실시간: 자동 구독
- 서버 함수: Query, Mutation, Action
- 파일 스토리지: 내장
- 인증: Clerk 통합
프로젝트 설정
설치
npm create convex@latest
프로젝트 구조
my-convex-app/
├── convex/
│ ├── schema.ts
│ ├── users.ts
│ └── posts.ts
├── src/
│ └── app/
└── convex.json
개발 중에는 npx convex dev를 켜 두는 것이 기본입니다. 이 명령은 convex/ 폴더의 파일이 바뀔 때마다 함수를 개발용 배포(deployment)에 올리고, convex/_generated/ 폴더의 타입을 다시 만듭니다. 이 프로세스를 끄고 코드를 고치면 api.posts.새함수가 타입에 나타나지 않거나, 클라이언트가 Could not find public function for 'posts:새함수' 에러를 받습니다. 처음 쓰는 분들이 가장 자주 막히는 지점이 이것입니다. _generated/ 폴더는 Git에 커밋해 두는 것이 공식 권장 방식입니다.
Schema 정의
스키마는 선택 사항이지만 정의해 두면 두 가지 효과가 있습니다. 쓰기 시점에 문서 형태를 검증해 잘못된 데이터가 저장되지 않게 하고, ctx.db로 읽은 문서에 정확한 TypeScript 타입이 붙습니다. v.id('users')는 단순 문자열이 아니라 “users 테이블의 문서 ID”라는 타입이라, 다른 테이블의 ID를 넣으면 검증에서 거부됩니다.
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
users: defineTable({
email: v.string(),
name: v.string(),
createdAt: v.number(),
}).index('by_email', ['email']),
posts: defineTable({
title: v.string(),
content: v.string(),
authorId: v.id('users'),
published: v.boolean(),
createdAt: v.number(),
})
.index('by_author', ['authorId'])
.index('by_published', ['published']),
});
_id와 _creationTime 필드는 모든 문서에 자동으로 붙으므로, 사실 createdAt은 _creationTime으로 대체할 수 있습니다. 인덱스는 조회 성능과 직결됩니다. Convex에서 .filter()는 테이블을 순서대로 읽으며 조건을 거르는 방식이라 문서가 많아지면 느려지고 읽기 한도에 걸립니다. by_author 인덱스를 정의해 두면 ctx.db.query('posts').withIndex('by_author', (q) => q.eq('authorId', userId))처럼 필요한 범위만 읽을 수 있습니다. 반면 by_published처럼 true/false 두 값뿐인 필드는 단독 인덱스로 거를 수 있는 양이 적으므로, 실제로는 ['published', 'createdAt'] 같은 복합 인덱스로 만들어 “게시된 글을 최신순으로” 같은 조회에 쓰는 편이 유용합니다.
Query & Mutation
Query
// convex/posts.ts
import { query } from './_generated/server';
import { v } from 'convex/values';
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query('posts').collect();
},
});
export const get = query({
args: { id: v.id('posts') },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
Query는 결정적(deterministic)이고 읽기 전용이어야 합니다. 같은 입력과 같은 데이터에 대해 항상 같은 결과를 내야 Convex가 결과를 캐시하고, 데이터가 바뀌었을 때만 다시 실행할 수 있기 때문입니다. 그래서 Query 안에서는 fetch로 외부 API를 부를 수 없고 DB에 쓸 수도 없습니다. args에 선언한 검증자(v.id('posts'))는 클라이언트에서 온 값을 런타임에 검사하므로, 공개 함수에는 항상 인자 검증을 두어야 합니다.
list처럼 .collect()로 테이블 전체를 가져오는 방식은 예제로는 간단하지만 문서가 늘어나면 문제가 됩니다. 함수 한 번이 읽을 수 있는 문서 수와 바이트에는 한도가 있어 일정 규모를 넘으면 에러가 나고, 그 전에도 목록에 포함된 문서 중 하나만 바뀌어도 Query 전체가 다시 실행되어 모든 구독자에게 목록 전체가 다시 전송됩니다. 실제 서비스에서는 .take(20)이나 .paginate()로 범위를 제한하고 인덱스로 정렬하는 것이 기본입니다.
Mutation
Mutation은 트랜잭션으로 실행됩니다. 함수 안의 모든 읽기와 쓰기가 한 번에 커밋되거나 전부 취소되며, 동시에 실행된 다른 Mutation과 충돌하면 Convex가 자동으로 재시도합니다. 이 재시도 때문에 Mutation도 결정적이어야 하고, 외부 API 호출은 할 수 없습니다. Date.now()와 Math.random()은 Convex가 재실행 시 같은 값이 나오도록 처리하므로 Mutation 안에서 써도 됩니다.
import { mutation } from './_generated/server';
import { v } from 'convex/values';
export const create = mutation({
args: {
title: v.string(),
content: v.string(),
authorId: v.id('users'),
},
handler: async (ctx, args) => {
const postId = await ctx.db.insert('posts', {
...args,
published: false,
createdAt: Date.now(),
});
return postId;
},
});
export const update = mutation({
args: {
id: v.id('posts'),
title: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { title: args.title });
},
});
이 예제에는 중요한 것이 하나 빠져 있습니다. create는 클라이언트가 보낸 authorId를 그대로 믿고, update는 누가 요청했는지 확인하지 않고 아무 글이나 수정합니다. Convex의 공개 함수는 배포 URL만 알면 누구나 호출할 수 있으므로, 실제로는 const identity = await ctx.auth.getUserIdentity();로 로그인 사용자를 확인하고, 그 사용자에 해당하는 users 문서를 찾아 authorId를 서버에서 채우며, 수정 전에 post.authorId가 요청자와 같은지 검사해야 합니다. patch는 지정한 필드만 바꾸고, replace는 문서 전체를 교체한다는 차이도 기억해 둘 만합니다.
React 통합
Provider
// app/ConvexClientProvider.tsx
'use client';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function ConvexClientProvider({ children }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
ConvexReactClient는 Convex 배포와 WebSocket 연결을 하나 유지하며 모든 구독을 그 연결 위에서 처리합니다. 컴포넌트 안에서 new ConvexReactClient()를 만들면 렌더링할 때마다 연결이 새로 생기므로, 예제처럼 모듈 최상위에서 한 번만 만들어야 합니다. Next.js App Router에서는 이 Provider가 클라이언트 컴포넌트여야 하므로 'use client' 파일로 분리한 뒤 루트 레이아웃에서 감쌉니다. NEXT_PUBLIC_CONVEX_URL은 npx convex dev가 .env.local에 자동으로 써 줍니다.
useQuery
'use client';
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
export default function Posts() {
const posts = useQuery(api.posts.list);
if (posts === undefined) return <div>Loading...</div>;
return (
<ul>
{posts.map((post) => (
<li key={post._id}>{post.title}</li>
))}
</ul>
);
}
useQuery는 처음에 undefined를 반환하고, 서버에서 결과가 오면 그 값으로, 이후 데이터가 바뀔 때마다 새 값으로 컴포넌트를 다시 렌더링합니다. Query가 null을 반환하는 경우(예: get으로 없는 ID를 조회)와 아직 로딩 중인 경우를 구분하려면 undefined와 null을 따로 검사해야 합니다. 인자가 준비되지 않았을 때 구독을 건너뛰려면 useQuery(api.posts.get, id ? { id } : 'skip')처럼 'skip'을 넘깁니다. 조건부로 훅을 호출하면 React 훅 규칙을 어기게 되므로 이 방식을 써야 합니다.
useMutation
useMutation이 반환한 함수를 호출하면 Mutation이 서버에서 실행되고, 그 결과로 바뀐 데이터를 읽는 모든 useQuery가 자동으로 새 값을 받습니다. 목록을 다시 불러오는 코드를 따로 쓸 필요가 없다는 점이 REST API와 가장 다른 부분입니다.
'use client';
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
export default function CreatePost() {
const createPost = useMutation(api.posts.create);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await createPost({
title: formData.get('title') as string,
content: formData.get('content') as string,
authorId: 'user-id',
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<textarea name="content" />
<button type="submit">Create Post</button>
</form>
);
}
예제의 authorId: 'user-id'는 자리표시자일 뿐이라 이대로 실행하면 ArgumentValidationError가 납니다. 인자 검증자가 v.id('users')인데 'user-id'는 유효한 문서 ID 형식이 아니기 때문입니다. 앞에서 설명한 것처럼 작성자는 서버에서 인증 정보로 채우고 클라이언트 인자에서는 빼는 것이 올바른 설계입니다. 즉각적인 화면 반응이 필요하면 useMutation(...).withOptimisticUpdate()로 서버 응답 전에 로컬 결과를 먼저 반영할 수 있고, 서버 결과가 도착하면 Convex가 낙관적 값을 실제 값으로 바꿉니다.
Action (외부 API)
Action은 결정적일 필요가 없는 함수로, 외부 API 호출, 결제 처리, AI 모델 호출처럼 부수 효과가 있는 작업을 여기서 합니다. 대신 트랜잭션이 아니고 ctx.db에 직접 접근할 수도 없어서, DB를 읽거나 쓰려면 ctx.runQuery와 ctx.runMutation으로 다른 함수를 호출해야 합니다.
// convex/actions.ts
import { action } from './_generated/server';
import { v } from 'convex/values';
export const sendEmail = action({
args: {
to: v.string(),
subject: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
personalizations: [{ to: [{ email: args.to }] }],
from: { email: '[email protected]' },
subject: args.subject,
content: [{ type: 'text/plain', value: args.body }],
}),
});
return response.ok;
},
});
process.env.SENDGRID_API_KEY는 로컬 .env 파일이 아니라 Convex 대시보드의 환경 변수(또는 npx convex env set)에서 읽힙니다. 함수는 Convex 서버에서 실행되기 때문에, .env.local에만 키를 넣고 “undefined가 들어간다”며 헤매는 경우가 많습니다.
Action은 자동으로 재시도되지 않는다는 점도 설계에 영향을 줍니다. 이메일 발송 도중 네트워크가 끊겨도 Convex는 다시 실행하지 않으므로, 실패를 기록하고 재시도할지를 직접 정해야 합니다. 제가 권하는 방식은 클라이언트가 Action을 직접 부르는 대신, Mutation에서 “발송 대기” 레코드를 쓰고 ctx.scheduler.runAfter(0, internal.actions.sendEmail, ...)로 Action을 예약하는 것입니다. 이렇게 하면 DB 쓰기와 작업 예약이 한 트랜잭션으로 묶여 “글은 저장됐는데 알림 메일 예약이 빠지는” 상황이 생기지 않습니다. 이때 Action은 internalAction으로 정의해 클라이언트에서 직접 호출할 수 없게 막는 것이 좋습니다. 지금 예제의 sendEmail은 공개 함수라 누구나 임의의 주소로 메일을 보낼 수 있습니다.
파일 스토리지
Convex 파일 업로드는 3단계로 이루어집니다. Mutation으로 일회용 업로드 URL을 받고, 클라이언트가 그 URL로 파일을 직접 POST한 뒤, 응답으로 받은 storageId를 다시 Mutation으로 DB에 저장합니다. 파일 데이터가 함수를 거치지 않아 함수 인자 크기 제한을 받지 않습니다.
// convex/files.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
export const saveFile = mutation({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
await ctx.db.insert('files', {
storageId: args.storageId,
createdAt: Date.now(),
});
},
});
// 클라이언트
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const saveFile = useMutation(api.files.saveFile);
const handleUpload = async (file: File) => {
const uploadUrl = await generateUploadUrl();
const response = await fetch(uploadUrl, {
method: 'POST',
body: file,
});
const { storageId } = await response.json();
await saveFile({ storageId });
};
storageId의 검증자는 v.string()이 아니라 v.id('_storage')로 두어야 파일 스토리지 ID라는 타입이 유지됩니다. 또 files 테이블에 쓰려면 3장의 스키마에 files: defineTable({ storageId: v.id('_storage'), createdAt: v.number() })를 추가해야 합니다. 스키마를 정의한 프로젝트에서 선언하지 않은 테이블에 쓰면 배포 또는 실행 단계에서 스키마 검증 에러가 납니다.
업로드 URL은 짧은 시간 뒤 만료되므로 미리 받아 두지 말고 업로드 직전에 요청합니다. fetch에 Content-Type: file.type 헤더를 붙이면 나중에 파일을 내려받을 때 올바른 MIME 타입이 쓰입니다. 화면에 파일을 보여줄 때는 Query에서 ctx.storage.getUrl(storageId)로 URL을 만들어 반환합니다. 여기서도 generateUploadUrl에 인증 검사가 없으면 누구나 스토리지에 파일을 올릴 수 있으므로, 로그인 확인과 파일 크기·형식 검사를 saveFile 단계에 넣는 것이 좋습니다. 업로드는 됐지만 saveFile까지 가지 못한 파일은 어디에서도 참조되지 않은 채 남으므로, 주기적으로 정리하는 스케줄 작업을 두는 경우도 많습니다.
정리 및 체크리스트
핵심 요약
- Convex: 실시간 백엔드
- 타입 안전성: End-to-End TypeScript
- 실시간: 자동 구독
- 서버 함수: Query, Mutation, Action
- 파일 스토리지: 내장
- 인증: Clerk 통합
구현 체크리스트
- Convex 프로젝트 생성
- Schema 정의
- Query 구현
- Mutation 구현
- React 통합
- Action 구현
- 파일 스토리지 구현
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Firebase와 비교하면 어떤가요?
A. Convex는 스키마와 서버 함수에서 타입이 생성되고, 쓰기 로직이 서버 트랜잭션으로 실행된다는 점이 다릅니다. Firebase는 클라이언트가 Firestore에 직접 쓰고 보안 규칙으로 막는 구조이며, 푸시 알림·애널리틱스·호스팅 등 주변 제품이 훨씬 넓습니다.
Q. Supabase와 비교하면 어떤가요?
A. Supabase는 PostgreSQL 위에 있어 SQL, 조인, 확장 기능을 그대로 쓸 수 있고 데이터를 다른 곳으로 옮기기도 쉽습니다. Convex는 SQL 대신 TypeScript 함수로 조회하지만, 반응형 구독과 트랜잭션 처리가 기본으로 맞물려 있어 실시간 협업 화면을 만들 때 코드가 적습니다.
Q. 무료로 사용할 수 있나요?
A. 무료 플랜이 있습니다. 저장 용량, 함수 호출 수, 대역폭 한도는 요금제 개편에 따라 바뀌므로 공식 가격 페이지에서 현재 조건을 확인하세요.
Q. Convex 앱을 배포하기 전에 무엇을 점검해야 하나요?
A. 공개 함수마다 인증·권한 검사를 넣었는지, 목록 Query가 인덱스와 페이지네이션을 쓰는지, 외부 호출 Action의 실패 처리가 있는지를 배포 전에 점검해야 합니다.