결론부터
이 글은 데이터 접근 계층을 ORM 생태계의 통합 솔루션에 맡기지 않고 직접 설계하면서 sqlc를 도입하기까지의 고민과 결과를 공유합니다.
실제 상용 프로젝트에서 약 100개 테이블, 1,000개 가까운 쿼리에 적용되었으며 쿼리가 코드베이스에 있으니 LLM에게 바로 입력 맥락으로 쓸 수 있었습니다.
결과적으로, 이 선택은 2인 팀 규모에서 중대규모의 프로젝트를 성공적으로 완주하는 밑거름이 되었습니다.
무엇이 문제였나?
상용 소프트웨어는 배포가 끝이 아닙니다. 운영과 유지보수까지 설계 대상입니다.
배포 이전 단계에서는 ORM 생태계의 장점이 명확합니다. 데이터 접근 계층을 거의 통째로 위임할 수 있습니다.
- CRUD 생산성, 연관관계 탐색 (
user.posts,user.comments같은) - 커넥션/트랜잭션 관리
- custom type/nullable/JSON/enum 매핑 규칙
- migration, lifecycle hook
하지만 프로젝트가 운영 단계로 넘어가면 ORM이 숨긴 것들이 꽤나 골칫거리가 됩니다.
운영 단계에서의 문제
-
쿼리가 보이지 않음: 장애나 성능 이슈가 생겼을 땐
EXPLAIN ANALYZE대상으로 바로 꺼낼 수 있어야 합니다. ORM 사용 시 대부분의 실행 쿼리가 코드베이스에 정적으로 남지 않아 런타임 로그를 거쳐야 확인되고 리뷰나 맥락 활용이 어렵습니다. -
세밀한 튜닝의 한계: CTE, 인덱스 힌트처럼 손으로 정교하게 짜야 하는 쿼리는 ORM DSL로 표현하기 어렵거나, 표현하더라도 의도와 다른 SQL이 나갑니다. 결국 Raw SQL을 사용하게됩니다.
Prisma의 TypedSQL처럼 일부 ORM은 RawSQL에 대한 Type Safety를 지원합니다.
LLM 시대의 개발
Agentic Coding이 필수가 된 지금, 실행 SQL이 코드베이스에 없다는 점은 더 큰 약점입니다.
ORM이 런타임에 어떤 쿼리를 만들어내는지, N+1이 발생하는지 LLM은 추측에 의존할 수밖에 없습니다.
반대로 SQL이 파일로 남아 있으면 그대로 입력 맥락이 되어 최적화나 리뷰를 바로 맡길 수 있습니다.
비즈니스와 기술의 분리
이 선택은 도메인(비즈니스)이 영속성(기술)을 모르게 두려는 의도와도 잘 맞았습니다.
ORM으로도 분리는 가능합니다. 다만 엔티티를 그대로 도메인 모델로 쓰는 편한 길이 있어, 영속성 정보가 도메인으로 들어오는 타협이 계속 생깁니다.
sqlc가 생성하는 타입은 단순 데이터라 그대로 도메인 모델로 쓰기 어렵습니다. 구조적으로 도메인 순수성을 유지해야만 합니다.
이 부분의 구체적인 설계는 주제를 벗어나므로 별도 글에서 다루겠습니다.
어떻게 해결했나?
"해결"의 기준
- 쿼리가 코드베이스에 있음: 쿼리를 즉시 확인할 수 있고 LLM이 추측할 필요 없이 바로 입력 맥락으로 쓸 수 있어야 합니다.
- 보안: 쿼리를 사람이 직접 다루는 만큼 SQL injection 공격 표면이 없어야 하고, LLM이 작성한 SQL이라도 같은 보장이 유지돼야 합니다.
여러 대안들
Raw SQL
타입 안정성이 낮아 기각
Raw SQL은 어떤 쿼리가 실행되는지 숨기지 않아 튜닝 포인트를 찾기 쉽습니다.
문제는 SQL 주변의 수작업입니다. Go에서는 쿼리 결과를 struct에 매핑하는 직접 Scan이 필요합니다.
err := row.Scan(&user.ID, &user.Email, &user.DeletedAt)
이 코드는 2개 오류를 컴파일 타임에 잡을 수 없습니다.
SELECT컬럼 순서를 바꾸고Scan순서를 맞추지 않은 경우- nullable 컬럼이 추가됐는데 Go 타입/포인터 처리를 맞추지 않은 경우
조심하면 되는 문제처럼 보입니다. 하지만 쿼리 수와 변경 빈도가 늘어나면 개인의 꼼꼼함 문제가 아니라 오류를 늦게 발견하는 구조적 문제가 됩니다.
보안도 같은 구조입니다. 값을 문자열로 직접 이어 붙이면 SQL injection 표면이 그대로 노출됩니다. placeholder 바인딩으로 막을 수 있지만 이 역시 매번 손으로 챙겨야 하는 규율이라, 쿼리가 늘수록 실수 가능성이 커집니다.
Query Builder
Query Builder는 동적 검색 조건이 많은 구간에서 유용합니다. 관리자 검색, 다중 필터, 선택 정렬 같은 요구사항에서 ORM보다 자연스럽습니다.
"조건 조합" 문제는 잘 풀지만 컬럼 참조를 문자열로 관리하는 비용이나 결과 매핑/타입 검증을 직접 책임지는 비용을 감수해야 하고 결과 타입 안정성까지 자동으로 해결하지는 않습니다.
sqlc
sqlc는 Raw SQL의 가시성을 유지하면서 직접 Scan이 남기던 타입 매핑 불일치 오류를 컴파일 타임에 잡을 수 있습니다.
스키마 정의
테이블 구조와 컬럼 타입을 SQL로 먼저 명시합니다.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
deleted_at TIMESTAMPTZ
);SQL 작성
실제로 실행할 쿼리를 이름과 함께 SQL 파일에 적습니다.
-- name: GetUser :one
SELECT id, email, deleted_at
FROM users
WHERE id = $1;Go 함수 생성
sqlc generate를 실행하면 각 쿼리에 대응하는 Go 함수 시그니처와 타입이 생성됩니다.
// sqlc generate로 생성되는 함수 시그니처
func (q *Queries) GetUser(
ctx context.Context,
id int64,
) (User, error) {
// 자동 생성된 SQL 실행 및 Scan 로직
}실행 SQL이 파일에 그대로 남아 리뷰어와 LLM 모두 읽을 수 있습니다.
결과 타입이 쿼리와 함께 생성되므로 수동 Scan 코드가 사라지고, 스키마/쿼리 간 타입 매핑 불일치를 정적으로 잡을 수 있습니다. LLM에게 쿼리 최적화, 인덱스 제안, 리팩터링을 요청할 때 SQL을 별도로 추출할 필요 없이 파일 그대로 맥락으로 넘깁니다.
값은 항상 $1 같은 placeholder로 바인딩됩니다. 손으로 문자열을 조합하던 Raw SQL과 달리 값이 SQL 문법으로 섞여 들어갈 수 없어, SQL injection 표면이 기본적으로 사라집니다.
결과
sqlc는 최종적으로 실제 상용 프로젝트에서 약 100개 테이블, 1,000개 가까운 쿼리에 적용됐습니다.
선택 기준은 4가지였습니다.
- Query Visibility: 실행 SQL을 숨기지 않고 사람이 직접 읽을 수 있을 것
- Type Safety: 타입 매핑 불일치 오류를 컴파일 타임에 잡을 것
- LLM Friendly: 실행 SQL을 맥락으로 그대로 넘길 수 있을 것
- 보안: 값은 항상 placeholder로 바인딩되어 SQL injection 공격 표면이 없어야 합니다.
sqlc는 이 4가지를 동시에 확보할 수 있었습니다.
한계점
ORM 생태계 편의성
sqlc는 ORM 생태계의 편의를 제공하지 않습니다. 연관관계 탐색, 커넥션/트랜잭션 관리, migration 및 lifecycle hook을 직접 구축해야 합니다.
sqlc는 타입 매핑 불일치 문제만 해결하고 데이터 접근 계층 설계는 직접 정의해야 합니다.
동적 쿼리
sqlc는 정적 쿼리에 강합니다. optional filter, 다중 정렬, 복합 검색 조합이 많아질수록 SQL이 빠르게 장황해집니다.
-- 조건 한 개마다 AND (...) 블록이 늘어난다
WHERE (
@branch_id IS NULL
OR bm.branch_id = @branch_id
)
AND (
@status IS NULL
OR bm.status = @status
)
필터 조합이 늘어나면 NULL OR ... 패턴이 반복되고 유사 SQL이 복제됩니다. 하지만 sqlc가 잘못된 컬럼 참조나 타입 매핑을 컴파일 타임에 차단해주므로 SQL 작성은 LLM에게 맡기고 본인은 검증하는 흐름으로 안전하게 운영했습니다.