KIM MINSIK
Why SQLC

2026.04.08

Why SQLC

데이터 계층 설계 시 sqlc를 선택한 이유와 적용 결과, 한계점을 정리한 기록입니다.

도입 전 고민 3주, 도입 후 후회 0일

들어가며

데이터 접근 계층 기술 스택으로 sqlc를 선택한 이유와 적용 결과를 정리한 기록입니다. 최종적으로 95개 테이블, 587개 쿼리(명령 438개, 조회 149개)에 적용되었습니다. 실행 SQL이 코드에 그대로 남아 사람도 LLM도 바로 읽을 수 있고, 스키마·타입 오류와 LLM 환각 출력은 컴파일 타임에 차단됩니다.

선정 기준

상용 소프트웨어는 시간이라는 축을 고려해야 합니다. 배포 완료가 끝이 아니라, 그 이후의 운영·유지보수까지 설계 대상입니다. 구현만 보면 ORM의 빠른 CRUD 생산성이 더 유리할 수 있습니다. 하지만 운영 단계로 넘어가면 아래 요구가 점점 커집니다.

  • "실제로 어떤 SQL이 나가느냐"를 빠르게 확인할 수 있어야 합니다
  • nullable/schema mismatch를 가능한 빠르게 잡아야 합니다
  • 장애나 성능 이슈가 생겼을 때 EXPLAIN ANALYZE 대상으로 바로 가져갈 수 있어야 합니다
  • LLM이 필수 워크플로우가 된 지금, 실행 SQL을 맥락으로 그대로 넘길 수 있어야 하고 환각 출력 시 배포 전에 차단할 수 있어야 합니다

그래서 SQL 가시성정적 안정성을 높은 우선순위에 두게 됐습니다.

다양한 대안들

1. Raw SQL + 직접 파싱: 쿼리는 가장 잘 보이지만, 오류도 가장 늦게 드러납니다

Raw SQL의 장점은 분명합니다.

  • 어떤 쿼리가 실행되는지 숨기지 않습니다
  • 튜닝 포인트를 찾기 쉽습니다
  • DB 기능을 온전히 활용할 수 있습니다

문제는 SQL 자체보다 그 주변의 수작업입니다. Go에서는 쿼리 결과를 struct에 꽂아 넣는 과정이 대개 직접 Scan으로 남습니다.

err := row.Scan(&user.ID, &user.Email, &user.DeletedAt)

이 코드는 간단해 보이지만, 아래 오류를 런타임까지 미룹니다.

  • SELECT 컬럼 순서를 바꿨는데 Scan 순서를 같이 바꾸지 않은 경우
  • nullable 컬럼이 추가됐는데 Go 타입이나 포인터 처리 규칙을 맞추지 않은 경우

"조심하면 되는 문제"처럼 보입니다. 하지만 쿼리 수와 변경 빈도가 늘어나면, 이건 개인의 꼼꼼함 문제가 아니라 오류를 늦게 발견하게 되는 구조적 문제가 됩니다.

2. ORM: 생산성은 높지만, SQL이 런타임에 숨습니다

ORM의 강점도 분명합니다.

  • CRUD를 빠르게 붙일 수 있습니다
  • relation 탐색, preload, hook, migration 같은 생태계 편의가 좋습니다
  • 팀에 ORM 경험이 많다면 초기 온보딩도 쉽습니다
  • 최근에는 마이그레이션, 관리형 클라우드 등 편의성 기능도 제공합니다

하지만 조회가 복잡해지고 성능 요구가 올라가는 구간에서는 다른 비용이 생깁니다.

  • 코드만 보고 최종 SQL을 바로 떠올리기 어렵습니다
  • N+1, 불필요한 join, preload 남용 같은 문제가 추상화 뒤에 숨어들기 쉽습니다
  • 성능 이슈가 생기면 결국 실행 SQL을 꺼내서 다시 읽어야 합니다

ORM은 "모델을 중심으로 개발할 때" 생산성이 좋지만, 실행 쿼리를 중심으로 판단해야 할 때는 오히려 사고 비용을 늘릴 수 있습니다. LLM에게 넘길 맥락도 추상화 뒤에 가려진 SQL을 먼저 꺼내야 하므로, LLM 협업 흐름에도 마찰이 생깁니다.

3. Query Builder: 동적 조합에는 강하지만 타입 안정성은 애매합니다

Query Builder는 동적 검색 조건이 많은 구간에서 매우 유용합니다. 특히 관리자 검색, 다중 필터, 선택 정렬 같은 요구사항에서는 ORM보다 자연스럽습니다. 다만 많은 Query Builder는 다음 둘 중 하나를 다시 우리 손에 남깁니다.

  • 컬럼 참조를 문자열로 관리하는 비용
  • 결과 매핑과 타입 검증을 직접 책임져야 하는 비용

"조건 조합" 문제는 잘 풀지만, "결과 타입 안정성"까지 자동으로 해결해주지는 않는 경우가 많습니다.

4. SQLC

SQLC는 Raw SQL의 가시성을 유지하면서, 직접 Scan이 남기던 타입 매핑 불일치 오류를 컴파일 타임으로 끌어올립니다.

01

스키마 정의

테이블 구조와 컬럼 타입을 SQL로 먼저 명시합니다.

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
deleted_at TIMESTAMPTZ
);
02

SQL 작성

실제로 실행할 조회 쿼리를 이름과 함께 SQL 파일에 적습니다.

-- name: GetUser :one
SELECT id, email, deleted_at
FROM users
WHERE id = $1;
03

Go 함수 생성

sqlc generate를 실행하면 각 쿼리에 대응하는 Go 함수 시그니처와 타입이 생성됩니다.

// `sqlc generate`로 생성되는 함수 시그니처
func (q *Queries) GetUser(
  ctx context.Context,
  id int,
) (GetUserRow, error)

이 구조가 중요한 이유는 단순히 "자동 생성"이라서가 아닙니다.

  • 실행 SQL이 파일에 그대로 남아 리뷰어와 LLM 모두가 읽을 수 있습니다
  • 결과 타입이 쿼리와 함께 생성되므로 수동 Scan 작성 코드가 사라지고, 스키마/쿼리 간 타입 매핑 불일치가 런타임보다 훨씬 앞선 단계에서 드러납니다
  • LLM에게 쿼리 최적화, 인덱스 제안, 리팩터링을 요청할 때 SQL을 별도로 추출할 필요 없이 파일 그대로 맥락으로 넘길 수 있습니다

도입 결과

sqlc는 최종적으로 프로젝트 내 총 95개 테이블, 587개 쿼리(명령 438개, 조회 149개)에 적용되었습니다. 데이터 접근 계층의 타입 매핑 불일치 오류는 배포 이후 현재(2026.04 기준)까지 0건입니다. SQLC를 선택한 이유는 단순히 트렌디해 보여서가 아니었습니다. 운영 단계까지 고려했을 때 아래 세 기준이 가장 중요했습니다.

  • 가시성: 실행 SQL을 숨기지 않고 사람이 직접 읽을 수 일 것
  • 정적 안정성: 타입 매핑 불일치 오류를 가능한 한 컴파일 타임에 잡을 것
  • LLM 친화성: 실행 SQL을 맥락으로 그대로 넘길 수 있을 것

SQLC는 이 세 가지를 동시에 확보할 수 있는 선택이었습니다.

한계점 (Trade-off)

1. 동적 쿼리 생성이 불가능합니다

SQLC는 정적 쿼리에 강합니다. 반대로 optional filter, 다중 정렬, 복합 검색 조합이 많아질수록 SQL이 빠르게 비대해집니다.

-- 2가지 조건일 때 예제
-- 조건 한 개마다 AND (...) 블록이 늘어난다.
WHERE (
    @branch_id IS NULL
    OR bm.branch_id = @branch_id
)
AND (
    @status IS NULL
    OR bm.status = @status
)

필터 조합이 늘어나면 NULL OR ... 패턴이 반복되고 유사 SQL이 복제될 수 있습니다. 다만 SQL 작성 자체는 LLM에게 위임했기 때문에 실질적인 부담은 크지 않았습니다. LLM이 SQL을 생성하고, 본인은 검증하는 흐름으로 운영했고, sqlc가 컴파일 타임에 잘못된 컬럼 참조나 타입 매핑을 차단해줬습니다.

2. ORM 생태계의 편의성 기능은 직접 구축해야 합니다

SQLC는 요즘 ORM 생태계가 주는 편의를 거의 제공하지 않습니다.

  • 연관관계 탐색 (user.posts, order.customer 같은)
  • 커넥션, 트랜잭션 관리
  • custom type / nullable / JSON / enum 매핑 규칙
  • migration 및 lifecycle hook

이 영역의 컨벤션이 없으면 "SQL은 보이지만 계층 설계는 제각각인 코드베이스"가 됩니다. SQLC는 타입 매핑 불일치 문제만 해결해줄 뿐, 데이터 접근 계층 설계를 대신해주지 않습니다. 이 프로젝트는 Data Access Layer를 직접 구현했으며, 자세한 내용은 데이터 접근 계층 설계를 참조하시면 됩니다.