2.2.4 데이터 계층 설계 - 임피던스 불일치 해결 전략
요약
이 문서는 데이터 접근 계층의 임피던스 불일치 솔루션으로 sqlc를 도입한 의사결정 과정과 그 결과를 기술합니다.
결과적으로 프로젝트 내 총 95개 테이블, 명령 438개, 조회 149개 쿼리에 적용되었고 아래 성과를 달성했습니다.
- 타입 매핑 오류 0건 (2026.04 기준)
- SQL 가시성 확보: 디버깅 시 실행 SQL 즉시 확인 가능
- 정적 안정성: 잘못된 컬럼, 테이블, 스키마 참조 및 타입 매핑 오류 제거
- LLM 친화성: 실행 SQL을 맥락으로 직접 전달 가능; 환각 출력은 컴파일 타임에 차단
선정 기준
상용 소프트웨어는 시간이라는 축을 고려해야 한다. 배포 완료가 끝이 아니라, 그 이후의 운영·유지보수까지 설계 대상이다.
구현만 보면 ORM의 빠른 CRUD 생산성이 더 유리할 수 있다. 운영 단계로 넘어가면 아래 요구가 점점 커진다.
- "실제로 어떤 SQL이 나가느냐"를 빠르게 확인할 수 있어야 한다
- nullable/schema mismatch를 가능한 빠르게 잡아야 한다
- 장애나 성능 이슈가 생겼을 때 EXPLAIN ANALYZE 대상으로 바로 가져갈 수 있어야 한다
- 최근에는 LLM이 필수 워크플로우로 사용된다. 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는 다음 둘 중 하나를 다시 우리 손에 남긴다.
- 컬럼 참조를 문자열로 관리하는 비용
- 결과 매핑과 타입 검증을 직접 책임져야 하는 비용
즉, Query Builder는 "조건 조합" 문제는 잘 풀지만, "결과 타입 안정성"까지 자동으로 해결해주지는 않는 경우가 많다.
4. SQLC
SQLC가 좋았던 이유는 Raw SQL의 장점은 유지하고, manual scan이 남기던 타입 매핑 불일치 오류를 컴파일 타임 단계로 끌어올리기 때문이다.
SQLC는 대략 이런 식으로 동작한다.
- 스키마와 SQL 파일을 입력으로 읽는다
- 쿼리에서 참조한 테이블/컬럼/파라미터/반환 타입을 검증한다
- 각 쿼리에 대응하는 Go 메서드, 파라미터 타입, 반환 타입을 생성한다
예를 들어 SQL은 그대로 코드베이스에 남는다.
-- name: GetUser :one
SELECT id, email, deleted_at
FROM users
WHERE id = $1;
그러면 Go 쪽에는 이런 식의 함수가 자동으로 생긴다.
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가 스키마와 타입을 컴파일 타임에 검증하므로, LLM이 존재하지 않는 컬럼을 참조하거나 타입을 잘못 매핑한 경우 배포 전에 차단됐다.
2. ORM 생태계의 편의성 기능은 직접 구축해야 한다
SQLC는 요즘 ORM 생태계 편의를 거의 주지 않는다.
- 연관관계 탐색 (user.posts, order.customer 같은)
- 커넥션, 트랜잭션 관리
- custom type / nullable / JSON / enum 매핑 규칙
- migration 및 lifecycle hook
이 영역의 컨벤션이 없으면 "SQL은 보이지만 계층 설계는 제각각인 코드베이스"가 된다.
즉, SQLC는 타입 매핑 불일치 문제만 해결해주지 데이터 접근 계층 설계를 대신해주지 않으므로, 이 계층 구현 규칙을 명확히 직접 잡을 준비가 되어 있어야 한다. 이 프로젝트는 Data Access Layer를 직접 구현 했으며 해당 문서는 데이터 접근 계층 설계를 참조하면 된다.
