Claude Code로 실무 GraphQL 개발하기: SDL, 타입 Resolver, DataLoader, 테스트
Claude Code로 GraphQL API를 설계하고 타입 Resolver, N+1, 권한, 복잡도 제한, 오류, 테스트까지 구현합니다.
Claude Code에 GraphQL API를 맡길 때 먼저 정할 것은 타입 이름이 아닙니다. 공개 계약을 SDL에 둘지, 기존 resolver와 서비스 계층에서 출발할지, 권한 검사를 어느 계층에서 할지, N+1을 어떻게 막을지, 너무 깊거나 비싼 쿼리를 어떻게 거절할지부터 정해야 합니다. 이 경계가 없는 상태로 “GraphQL API 만들어줘”라고 요청하면 데모는 동작하지만, 운영에서는 느린 쿼리, 권한 누락, 타입 불일치, 불안정한 오류 응답이 남을 수 있습니다.
이 글은 Claude Code를 단순 코드 생성기가 아니라 GraphQL 구현 파트너로 쓰는 방법을 정리합니다. SDL은 GraphQL 스키마를 언어에 독립적인 텍스트 계약으로 쓰는 방식입니다. resolver는 필드 값을 실제로 가져오는 함수입니다. DataLoader는 한 요청 안에서 같은 종류의 읽기를 모아 N+1을 줄이는 도구입니다.
근거는 공식 문서에 둡니다: GraphQL Schemas and Types, Pagination, Authorization, Security, GraphQL specification Response Format, DataLoader README, GraphQL Code Generator TypeScript Resolvers, Claude Code docs를 확인하세요. 관련 글로는 API 개발, API 테스트, 프롬프트 기법이 있습니다.
schema-first와 resolver-first 선택
GraphQL 프로젝트는 보통 두 방식으로 시작합니다. schema-first는 SDL을 먼저 쓰고 공개 계약으로 삼은 뒤 resolver와 테스트를 구현합니다. resolver-first는 기존 도메인 서비스, TypeScript 함수, DB 접근 계층에서 출발해 스키마를 맞춥니다. 새 공개 API, 모바일 API, 여러 팀이 쓰는 API는 schema-first가 검토하기 쉽습니다. 기존 REST나 내부 서비스를 GraphQL로 감싸는 작업은 resolver-first가 현실적일 수 있습니다.
세 가지 예가 있습니다. SaaS 관리 화면은 팀, 결제, 감사 로그처럼 권한 경계가 많으므로 schema-first로 공개 필드를 먼저 고정합니다. 모바일 앱 API는 왕복 요청을 줄일 수 있지만 pagination, depth limit, error shape이 먼저 필요합니다. 내부 분석 도구는 자유도가 중요하므로 resolver-first가 편할 수 있지만, 테넌트와 개인정보 권한은 서비스 계층의 단일 규칙으로 처리해야 합니다.
Claude Code에는 설계 선택까지 포함해서 요청합니다.
Design this GraphQL API schema-first.
Requirements:
- Put SDL in src/graphql/schema.graphql
- Type TypeScript resolvers with GraphQL Code Generator
- Use cursor-based connections for list fields
- Keep authorization in repo/service functions, not scattered through resolvers
- Create DataLoader instances per request inside createContext
- Include depth and complexity validation, safe error formatting, and Vitest tests
- Report verification commands and remaining pitfalls after implementation
여기서 중요한 것은 특정 서버 라이브러리보다 계약, 권한, 부하 제어, 테스트입니다. Apollo Server, GraphQL Yoga, Mercurius, GraphQL.js 중 무엇을 쓰든 이 네 가지는 Claude Code가 임의로 정하게 두면 안 됩니다.
SDL을 계약으로 둔다
아래 SDL은 상품 리뷰 API의 작은 예입니다. GraphQL 공식 pagination 가이드의 connection model을 따라 edges, node, cursor, pageInfo를 둡니다. Product.reviews 같은 중첩 필드는 편리하지만 N+1과 깊은 쿼리의 시작점이기도 하므로 DataLoader와 demand control을 함께 넣습니다.
npm install graphql @graphql-tools/schema dataloader
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers vitest tsx typescript
# src/graphql/schema.graphql
interface Node {
id: ID!
}
type Product implements Node {
id: ID!
title: String!
isPublic: Boolean!
averageRating: Float
reviews(first: Int = 10, after: String): ReviewConnection!
createdAt: String!
}
type Review implements Node {
id: ID!
product: Product!
rating: Int!
body: String!
authorId: ID!
createdAt: String!
}
type ProductEdge {
node: Product!
cursor: String!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type ReviewEdge {
node: Review!
cursor: String!
}
type ReviewConnection {
edges: [ReviewEdge!]!
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
input CreateReviewInput {
productId: ID!
rating: Int!
body: String!
}
type Query {
product(id: ID!): Product
products(first: Int = 10, after: String): ProductConnection!
}
type Mutation {
createReview(input: CreateReviewInput!): Review!
}
GraphQL 타입은 모든 검증을 대신하지 않습니다. rating: Int!는 정수라는 뜻일 뿐 1부터 5까지라는 뜻은 아닙니다. 리뷰 본문 길이, HTML 처리, 비공개 상품 접근 권한도 SDL만으로 끝나지 않습니다. Claude Code에는 SDL에서 표현할 제약과 resolver/service에서 지킬 제약을 나눠 쓰게 하는 것이 좋습니다.
타입이 있는 resolver 생성
TypeScript GraphQL에서 흔한 실수는 parent, args, context를 any로 받는 것입니다. 처음에는 빠르지만 스키마 필드 변경, nullable 변경, context 확장 때 런타임 버그가 됩니다. GraphQL Code Generator의 typescript-resolvers는 SDL에서 resolver 타입을 생성합니다.
// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "src/graphql/schema.graphql",
generates: {
"src/graphql/generated/resolvers-types.ts": {
plugins: ["typescript", "typescript-resolvers"],
config: {
contextType: "../context#GraphQLContext",
mappers: {
Product: "../models#ProductRow",
Review: "../models#ReviewRow",
},
useIndexSignature: true,
},
},
},
};
export default config;
{
"scripts": {
"codegen": "graphql-codegen --config codegen.ts",
"test:graphql": "npm run codegen && vitest run test/graphql.test.ts"
}
}
mappers는 GraphQL 타입과 저장소 row 타입을 연결합니다. GraphQL의 Product에는 reviews와 averageRating이 있지만 DB의 ProductRow에는 없을 수 있습니다. 이 차이를 타입으로 고정하면 Claude Code가 저장소 모델에 불필요한 필드를 추가하는 일을 줄일 수 있습니다.
DataLoader로 N+1 방지
N+1은 리스트를 한 번 가져온 뒤 각 row의 관련 데이터를 한 건씩 다시 조회하는 문제입니다. 상품 10개를 가져오고 리뷰를 10번 조회하는 식입니다. DataLoader는 같은 요청 안의 .load() 호출을 batch로 묶고 key 순서에 맞춰 값을 돌려줍니다. 단, DataLoader 캐시는 요청 단위입니다. Redis 대체품도 아니고 사용자 간 공유 캐시도 아닙니다.
// src/graphql/models.ts
export type Viewer = { id: string; role: "user" | "admin" } | null;
export type ProductRow = {
id: string;
title: string;
ownerId: string;
isPublic: boolean;
createdAt: string;
};
export type ReviewRow = {
id: string;
productId: string;
rating: number;
body: string;
authorId: string;
createdAt: string;
};
const products: ProductRow[] = [
{ id: "prod_1", title: "Claude Code Playbook", ownerId: "user_1", isPublic: true, createdAt: "2026-06-02T00:00:00.000Z" },
{ id: "prod_private", title: "Internal Prompt Pack", ownerId: "user_2", isPublic: false, createdAt: "2026-06-02T00:00:00.000Z" },
];
const reviews: ReviewRow[] = [
{ id: "rev_1", productId: "prod_1", rating: 5, body: "Practical.", authorId: "user_1", createdAt: "2026-06-02T00:10:00.000Z" },
{ id: "rev_2", productId: "prod_1", rating: 4, body: "Useful for reviews.", authorId: "user_2", createdAt: "2026-06-02T00:20:00.000Z" },
];
function canSeeProduct(viewer: Viewer, product: ProductRow) {
return product.isPublic || viewer?.role === "admin" || viewer?.id === product.ownerId;
}
export const repo = {
productById(id: string, viewer: Viewer) {
const product = products.find((item) => item.id === id);
return product && canSeeProduct(viewer, product) ? product : null;
},
products(viewer: Viewer) {
return products.filter((product) => canSeeProduct(viewer, product));
},
reviewsByProductIds(productIds: readonly string[], viewer: Viewer) {
const visibleIds = new Set(
products.filter((product) => productIds.includes(product.id) && canSeeProduct(viewer, product)).map((product) => product.id),
);
return reviews.filter((review) => visibleIds.has(review.productId));
},
createReview(input: { productId: string; rating: number; body: string }, viewer: NonNullable<Viewer>) {
const product = products.find((item) => item.id === input.productId);
if (!product || !canSeeProduct(viewer, product)) return null;
if (input.rating < 1 || input.rating > 5) return null;
const review: ReviewRow = {
id: `rev_${reviews.length + 1}`,
productId: input.productId,
rating: input.rating,
body: input.body.slice(0, 1000),
authorId: viewer.id,
createdAt: new Date().toISOString(),
};
reviews.push(review);
return review;
},
};
// src/graphql/context.ts
import { randomUUID } from "node:crypto";
import DataLoader from "dataloader";
import { repo, type ReviewRow, type Viewer } from "./models";
function createLoaders(viewer: Viewer) {
return {
reviewsByProductId: new DataLoader<string, ReviewRow[]>(async (productIds) => {
const rows = repo.reviewsByProductIds(productIds, viewer);
const grouped = new Map<string, ReviewRow[]>();
for (const productId of productIds) grouped.set(productId, []);
for (const review of rows) grouped.get(review.productId)?.push(review);
return productIds.map((productId) => grouped.get(productId) ?? []);
}),
};
}
export type GraphQLContext = {
viewer: Viewer;
requestId: string;
loaders: ReturnType<typeof createLoaders>;
};
export function createContext(options: { viewer?: Viewer; requestId?: string } = {}): GraphQLContext {
const viewer = options.viewer ?? null;
return {
viewer,
requestId: options.requestId ?? randomUUID(),
loaders: createLoaders(viewer),
};
}
// src/graphql/resolvers.ts
import { GraphQLError } from "graphql";
import type { Resolvers } from "./generated/resolvers-types";
import { repo, type ReviewRow } from "./models";
function encodeCursor(id: string) {
return Buffer.from(id).toString("base64url");
}
function decodeCursor(cursor?: string | null) {
if (!cursor) return null;
try {
return Buffer.from(cursor, "base64url").toString("utf8");
} catch {
return null;
}
}
function connectionFromRows<T extends { id: string }>(rows: T[], args: { first?: number | null; after?: string | null }) {
const first = Math.min(Math.max(args.first ?? 10, 1), 50);
const afterId = decodeCursor(args.after);
const startIndex = afterId ? rows.findIndex((row) => row.id === afterId) + 1 : 0;
const slice = rows.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + first + 1);
const page = slice.slice(0, first);
return {
edges: page.map((node) => ({ node, cursor: encodeCursor(node.id) })),
pageInfo: {
hasNextPage: slice.length > first,
endCursor: page.length > 0 ? encodeCursor(page[page.length - 1].id) : null,
},
};
}
export const resolvers: Resolvers = {
Node: {
__resolveType(parent) {
return "rating" in parent ? "Review" : "Product";
},
},
Query: {
product: (_parent, args, context) => repo.productById(args.id, context.viewer),
products: (_parent, args, context) => connectionFromRows(repo.products(context.viewer), args),
},
Product: {
reviews: async (product, args, context) => {
const rows = await context.loaders.reviewsByProductId.load(product.id);
return connectionFromRows(rows, args);
},
averageRating: async (product, _args, context) => {
const rows = await context.loaders.reviewsByProductId.load(product.id);
if (rows.length === 0) return null;
return rows.reduce((sum: number, review: ReviewRow) => sum + review.rating, 0) / rows.length;
},
},
Review: {
product: (review, _args, context) => {
const product = repo.productById(review.productId, context.viewer);
if (!product) {
throw new GraphQLError("Product is not available.", { extensions: { code: "FORBIDDEN" } });
}
return product;
},
},
Mutation: {
createReview: (_parent, args, context) => {
if (!context.viewer) {
throw new GraphQLError("Login required.", { extensions: { code: "UNAUTHENTICATED" } });
}
const review = repo.createReview(args.input, context.viewer);
if (!review) {
throw new GraphQLError("Review could not be created.", { extensions: { code: "BAD_USER_INPUT" } });
}
return review;
},
},
};
이 예제의 권한 판단은 repo 계층에 모았습니다. GraphQL 공식 Authorization 문서도 운영 코드에서는 권한 로직을 business logic layer에 위임하라고 설명합니다. 그래야 다른 query 경로로 같은 데이터에 접근해도 같은 규칙이 적용됩니다.
depth와 complexity 제한
GraphQL은 클라이언트가 필요한 필드만 요청할 수 있어서 강력합니다. 동시에 깊은 nesting, 큰 first, 많은 alias, batch 요청은 서비스 거부 위험이 됩니다. GraphQL Security 문서는 trusted documents, pagination, depth limiting, breadth/batch limiting, rate limiting, query complexity analysis를 계층적으로 쓰는 방식을 설명합니다.
아래 rule은 GraphQL.js validation에 넣을 수 있는 간단한 예입니다. literal first와 last를 complexity에 반영합니다. 변수 값은 validation 시점에 항상 알 수 없으므로 resolver에서도 반드시 first를 상한으로 자릅니다.
// src/graphql/demandControl.ts
import {
GraphQLError,
Kind,
type ASTVisitor,
type FieldNode,
type FragmentDefinitionNode,
type SelectionSetNode,
type ValidationContext,
} from "graphql";
type Limits = {
maxDepth: number;
maxComplexity: number;
defaultListSize: number;
maxListSize: number;
};
const defaultLimits: Limits = {
maxDepth: 6,
maxComplexity: 300,
defaultListSize: 10,
maxListSize: 50,
};
export function depthAndComplexityRule(limits: Partial<Limits> = {}) {
const merged = { ...defaultLimits, ...limits };
return (context: ValidationContext): ASTVisitor => {
const fragments = new Map<string, FragmentDefinitionNode>();
return {
FragmentDefinition(node) {
fragments.set(node.name.value, node);
},
OperationDefinition(node) {
const score = scoreSelectionSet(node.selectionSet, fragments, new Set(), 0, 1, merged);
if (score.depth > merged.maxDepth) {
context.reportError(new GraphQLError(`GraphQL operation is too deep: ${score.depth}`, { nodes: node }));
}
if (score.complexity > merged.maxComplexity) {
context.reportError(
new GraphQLError(`GraphQL operation is too complex: ${score.complexity}`, { nodes: node }),
);
}
},
};
};
}
function listMultiplier(field: FieldNode, limits: Limits) {
const arg = field.arguments?.find((item) => item.name.value === "first" || item.name.value === "last");
if (!arg) return 1;
if (arg.value.kind !== Kind.INT) return limits.defaultListSize;
return Math.min(Number.parseInt(arg.value.value, 10), limits.maxListSize);
}
function scoreSelectionSet(
selectionSet: SelectionSetNode,
fragments: Map<string, FragmentDefinitionNode>,
seenFragments: Set<string>,
depth: number,
multiplier: number,
limits: Limits,
) {
let maxDepth = depth;
let complexity = 0;
for (const selection of selectionSet.selections) {
if (selection.kind === Kind.FIELD) {
const nextMultiplier = multiplier * listMultiplier(selection, limits);
complexity += nextMultiplier;
if (selection.selectionSet) {
const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth + 1, nextMultiplier, limits);
maxDepth = Math.max(maxDepth, child.depth);
complexity += child.complexity;
}
}
if (selection.kind === Kind.INLINE_FRAGMENT) {
const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth, multiplier, limits);
maxDepth = Math.max(maxDepth, child.depth);
complexity += child.complexity;
}
if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragmentName = selection.name.value;
if (seenFragments.has(fragmentName)) continue;
const fragment = fragments.get(fragmentName);
if (!fragment) continue;
seenFragments.add(fragmentName);
const child = scoreSelectionSet(fragment.selectionSet, fragments, seenFragments, depth, multiplier, limits);
maxDepth = Math.max(maxDepth, child.depth);
complexity += child.complexity;
}
}
return { depth: maxDepth, complexity };
}
첫 번째 클라이언트만 쓰는 API라면 trusted documents가 더 강력할 수 있습니다. 운영에서는 승인된 operation ID만 받게 하고, 공개 API라면 HTTP rate limit, timeout, list cap, 로그를 함께 둡니다.
오류 형식과 테스트 고정
GraphQL 응답은 data, errors, extensions를 중심으로 합니다. GraphQL spec은 실행 전 오류와 실행 중 필드 오류를 구분하고, 필드 오류에서는 일부 data와 errors가 함께 나올 수 있다고 설명합니다. 운영에서는 DB 예외나 stack trace를 그대로 반환하지 말고 extensions.code와 requestId를 고정합니다.
// src/graphql/schema.ts
import { readFileSync } from "node:fs";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { resolvers } from "./resolvers";
export const schema = makeExecutableSchema({
typeDefs: readFileSync(new URL("./schema.graphql", import.meta.url), "utf8"),
resolvers,
});
// src/graphql/execute.ts
import {
GraphQLError,
execute,
parse,
specifiedRules,
validate,
type ExecutionResult,
} from "graphql";
import { createContext, type GraphQLContext } from "./context";
import { depthAndComplexityRule } from "./demandControl";
import { schema } from "./schema";
import type { Viewer } from "./models";
type ExecuteOptions = {
source: string;
variables?: Record<string, unknown>;
viewer?: Viewer;
requestId?: string;
};
export async function executeGraphQL(options: ExecuteOptions): Promise<ExecutionResult> {
const context = createContext({ viewer: options.viewer, requestId: options.requestId });
const document = parse(options.source);
const validationErrors = validate(schema, document, [
...specifiedRules,
depthAndComplexityRule({ maxDepth: 6, maxComplexity: 300 }),
]);
if (validationErrors.length > 0) {
return { errors: validationErrors.map((error) => maskError(error, context)) };
}
const result = await execute({
schema,
document,
variableValues: options.variables,
contextValue: context,
});
return {
data: result.data,
errors: result.errors?.map((error) => maskError(error, context)),
};
}
function maskError(error: GraphQLError, context: GraphQLContext) {
const code = typeof error.extensions.code === "string" ? error.extensions.code : "INTERNAL_SERVER_ERROR";
const message = process.env.NODE_ENV === "production" ? "GraphQL request failed." : error.message;
return new GraphQLError(message, {
nodes: error.nodes,
path: error.path,
originalError: error.originalError,
extensions: {
code,
requestId: context.requestId,
},
});
}
// test/graphql.test.ts
import { describe, expect, it } from "vitest";
import { executeGraphQL } from "../src/graphql/execute";
const viewer = { id: "user_1", role: "user" as const };
describe("GraphQL API", () => {
it("returns products with reviews through typed resolvers", async () => {
const result = await executeGraphQL({
viewer,
source: `
query Products {
products(first: 10) {
edges {
node {
id
title
averageRating
reviews(first: 5) {
edges { node { id rating body } }
}
}
}
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.products.edges[0].node.title).toBe("Claude Code Playbook");
});
it("does not expose a private product to another user", async () => {
const result = await executeGraphQL({
viewer,
source: `query PrivateProduct { product(id: "prod_private") { id title } }`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.product).toBeNull();
});
it("requires login for mutations", async () => {
const result = await executeGraphQL({
source: `
mutation CreateReview {
createReview(input: { productId: "prod_1", rating: 5, body: "Great" }) { id }
}
`,
});
expect(result.errors?.[0].extensions?.code).toBe("UNAUTHENTICATED");
});
it("rejects overly deep operations before execution", async () => {
const result = await executeGraphQL({
viewer,
source: `
query TooDeep {
products(first: 50) {
edges { node { reviews(first: 50) { edges { node { product { reviews(first: 50) { edges { node { id } } } } } } } } }
}
}
`,
});
expect(result.errors?.[0].message).toContain("too");
});
});
테스트는 정상 query만 있으면 부족합니다. 비로그인 mutation, 비공개 데이터, 잘못된 입력 범위, 깊거나 비싼 operation, pagination cap, extensions.code까지 확인하도록 Claude Code에 명시하세요.
안전한 Claude Code 프롬프트
나쁜 프롬프트는 “보안도 신경 써줘”, “N+1 처리해줘”, “테스트도 추가해줘”처럼 넓고 모호합니다. 좋은 프롬프트는 범위, 금지사항, 완료 조건을 줍니다.
Improve the GraphQL implementation in this repository.
Allowed files:
- src/graphql/**
- test/graphql.test.ts
Design rules:
- Schema-first; schema.graphql is the public contract
- Use GraphQL Code Generator's Resolvers type; do not add any `any` types
- Keep authorization in repo/service functions, not scattered across resolvers
- Create DataLoader instances inside createContext for each request
- Clamp list fields to first between 1 and 50 and return connection objects
- Add depth and complexity validation
- Shape errors as message, path, extensions.code, extensions.requestId
- Add Vitest coverage for happy path, unauthenticated mutation, private data, and overly deep queries
At the end, report changed files, commands run, remaining risks, and official docs to re-check.
팀에서는 이 내용을 CLAUDE.md best practices에 맞춰 프로젝트 규칙으로 옮기면 다음 GraphQL 수정도 같은 기준으로 진행됩니다.
배포 전 막아야 할 함정
첫째, DataLoader를 전역으로 두면 사용자 간 캐시가 섞여 비공개 데이터가 보일 수 있습니다. 둘째, SDL의 기본값만 믿고 first 상한을 두지 않으면 클라이언트가 큰 값을 보낼 수 있습니다. 셋째, 권한 조건을 resolver마다 흩뿌리면 다른 경로에서 조건이 어긋납니다. 넷째, depth limit만 있고 complexity limit이 없으면 얕지만 넓은 query가 비싸질 수 있습니다.
다섯째, 내부 오류를 그대로 반환하면 DB명, stack trace, schema 힌트가 노출됩니다. 여섯째, Codegen을 CI에서 빼면 schema와 resolver 타입이 따로 움직입니다. schema 변경, codegen, 테스트는 같은 작업 단위여야 합니다.
GraphQL 품질은 수익에도 연결됩니다. 유료 콘텐츠, 템플릿, Gumroad 링크, 기업 문의 폼, 관리자 화면은 모두 정확한 권한과 예측 가능한 API 비용에 의존합니다. 먼저 무료 체크리스트로 Claude Code 검토 흐름을 고정하고, 재사용 가능한 프롬프트와 템플릿은 products에서 확인하세요. 팀 단위의 schema 설계, 권한, 테스트, CI는 Claude Code training/consultation으로 실제 저장소 기준에서 정리할 수 있습니다.
실제로 해본 결과
Masa가 작은 상품 리뷰 API에서 이 흐름을 시험했을 때 가장 도움이 된 것은 DataLoader 코드 자체가 아니었습니다. 처음 요청에 권한 경계, first 상한, depth/complexity, error shape, test name을 넣은 것이 효과적이었습니다. 예전에는 Product.reviews를 먼저 만들고 나중에 N+1과 비공개 데이터 경로를 찾았습니다. 이 방식에서는 schema, resolver, validation, test가 같은 diff에 들어가 리뷰가 짧고 구체적이었습니다.
정리
Claude Code로 GraphQL을 개발할 때는 schema-first와 resolver-first를 의도적으로 고르고, SDL을 계약으로 두고, resolver를 타입으로 묶고, DataLoader를 요청 단위로 만들고, 권한을 업무 로직에 두며, depth와 complexity를 제한하세요. 그리고 오류 형식과 실패 경로 테스트까지 같은 작업에 포함하세요. GraphQL의 유연성은 강력하지만 운영에는 경계가 필요합니다. 그 경계를 Claude Code에 먼저 전달해야 결과물이 데모가 아니라 검토 가능한 구현이 됩니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.