SQL 포매터가 실제로 결정하는 것들
JSON과 달리 SQL에는 정본 직렬화가 없어서, 포매터는 사실상 스타일 결정들의 묶음입니다. 첫 번째는 절 배치입니다. 주요 절(SELECT, FROM, WHERE, GROUP BY, ORDER BY)을 한 줄에 하나씩 두면 쿼리의 논리적 파이프라인이 훑어볼 수 있는 세로 개요가 됩니다. 두 번째는 절 내부 처리입니다 — 선택된 컬럼마다 줄을 줄지, JOIN 조건을 JOIN 줄에 둘지 그 아래 들여쓸지, 서브쿼리를 얼마나 깊이 중첩시킬지.
다음은 정답 없는 논쟁들입니다. 후행 콤마(col,)는 대부분의 언어가 쓰는 방식이고, 다음 줄 첫머리의 선행 콤마(, col)는 SQL 분석가들의 오랜 전통입니다. 마지막 컬럼을 포함해 어떤 컬럼이든 주석 처리해도 문장이 깨지지 않고, 컬럼 추가가 한 줄짜리 diff가 되기 때문입니다. 키워드 정렬도 갈립니다. "강(river)" 스타일은 SELECT, FROM, WHERE가 같은 칼럼에서 끝나도록 키워드를 오른쪽 정렬해 쿼리를 따라 흐르는 빈 물길을 만듭니다(Simon Holywell의 sqlstyle.guide가 대중화한 스타일). 반면 대부분의 자동 포매터는 편집 후 전체 재정렬이 필요 없는 단순 왼쪽 정렬을 선호합니다.
실용적 가치는 미학이 아니라 정규화(canonicalization)입니다. 모든 쿼리가 같은 포매터를 거치면 텍스트 diff가 의미 diff로 줄어들고, 코드 리뷰 코멘트에서 스타일 얘기가 사라지며, 쿼리의 두 변형을 기계적으로 비교할 수 있습니다. 이를 강제하는 팀은 SQLFluff 같은 린터를 씁니다. Prettier나 Black이 JS와 Python에 하듯, CI에서 방언별로 포매팅과 린트를 수행합니다.
-- trailing commas -- leading commas (analyst style)
SELECT SELECT
user_id, user_id
email, , email
created_at , created_at
FROM users; -- , last_login ← safe to toggle
FROM users;대문자 키워드, 그리고 식별자 접힘(folding)의 함정
키워드 대문자화는 SQL에서 가장 오래된 관례로, 구문 강조가 없는 흑백 터미널 시대에 태어났습니다. 대문자 SELECT와 WHERE만이 텍스트 벽에서 구조를 도드라지게 하는 유일한 수단이었죠. 표준은 이를 요구하지 않습니다 — SQL 키워드는 어디서나 대소문자 구분이 없습니다 — 그리고 모든 에디터가 키워드를 색으로 강조하는 지금, 일부 현대 스타일 가이드는 전부 소문자로 돌아섰습니다. 어느 쪽이든 타당하지만, 한 코드베이스 안에서의 혼용은 아닙니다.
케이스가 위험해지는 곳은 식별자입니다. 데이터베이스가 이를 조용히 다시 쓰기 때문입니다. SQL 표준은 따옴표 없는 식별자를 대문자로 접는다(fold)고 규정하며, Oracle과 Db2가 정확히 그렇게 합니다. my_table은 MY_TABLE로 저장됩니다. PostgreSQL은 의도적으로 표준을 어기고 소문자로 접습니다. MY_TABLE이 my_table이 됩니다. MySQL은 세 번째 동작을 더합니다 — 테이블명이 파일에 매핑되므로 대소문자 구분이 파일시스템과 lower_case_table_names 설정에 달려 있습니다. macOS에서는 되는데 Linux에서 깨지는 코드의 고전적 원인입니다.
식별자에 따옴표를 치면("UserAccounts") 접힘이 억제되고 정확한 케이스가 보존됩니다 — 영구히요. PostgreSQL에서 따옴표 친 혼합 케이스 테이블을 하나 만들면 그 테이블이 사는 동안 모든 쿼리가 따옴표를 붙여야 합니다. 따옴표 없는 참조는 useraccounts로 접혀서 빗나가니까요. 포매터가 식별자 따옴표나 문자열 리터럴 내부를 절대 건드리면 안 되는 이유입니다. 키워드 대문자화는 안전하지만, 식별자 대문자화는 쿼리가 가리키는 객체 자체를 바꿔버릴 수 있습니다. 이 모든 것에서 벗어나는 지루하지만 이식성 있는 탈출구는, 어디서나 따옴표 없는 snake_case 식별자입니다.
-- PostgreSQL folds unquoted identifiers DOWN
CREATE TABLE MyTable (id int); -- stored as: mytable
SELECT * FROM MYTABLE; -- works, folds to mytable
-- Oracle folds unquoted identifiers UP
CREATE TABLE MyTable (id int); -- stored as: MYTABLE
-- Quotes freeze the case forever (both systems)
CREATE TABLE "MyTable" (id int);
SELECT * FROM MyTable; -- ERROR: relation not found
SELECT * FROM "MyTable"; -- only this works방언별 특이점 — ANSI, T-SQL, MySQL, PostgreSQL은 서로 다르다
방언을 모르면 포매터는 SQL을 올바르게 토큰화조차 할 수 없습니다. 식별자 인용만 해도 세 가지입니다. ANSI 표준은 큰따옴표("order"), MySQL은 기본적으로 백틱 인용(큰따옴표는 ANSI_QUOTES 모드를 켜야만 동작), T-SQL은 큰따옴표와 함께 대괄호([order])를 씁니다. 대괄호로 감싼 T-SQL 식별자를 MySQL식 포매터에 넣으면 대괄호를 문법 오류로 취급하거나, 더 나쁘게는 멋대로 재배치합니다.
페이지네이션은 가장 눈에 띄는 분기입니다. MySQL, PostgreSQL, SQLite는 LIMIT 10 OFFSET 20을 쓰고, SQL Server는 전통적으로 SELECT TOP 10을 썼으며 지금은 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY(ORDER BY 필수)를 지원합니다. ANSI SQL:2008 형식인 FETCH FIRST 10 ROWS ONLY는 PostgreSQL, Db2, Oracle 12c 이상에서 동작합니다. 문자열 연결도 세 갈래입니다. ||가 표준(PostgreSQL, Oracle, SQLite), T-SQL은 +, MySQL에서는 CONCAT()을 호출해야 합니다. PIPES_AS_CONCAT을 켜지 않는 한 ||는 논리 OR을 뜻하기 때문입니다. 불리언조차 다릅니다 — PostgreSQL은 진짜 TRUE/FALSE 리터럴이 있지만 SQL Server는 BIT 1/0으로 표현합니다.
주석에는 마지막 함정이 있습니다. --와 /* */는 보편적이지만 #은 MySQL에서만 주석을 시작하고, MySQL의 --는 뒤에 공백이 와야만 주석으로 인정됩니다. 쿼리를 포매팅하거나 이식할 때의 실무 교훈: 어느 방언으로 쓰고 있는지 알고, 대상이 지원한다면 ANSI 형식(FETCH FIRST, ||, 표준 따옴표)을 선호하고, 모든 벤더 특화 구문은 알고서 짊어지는 이식성 부채로 취급하세요.
-- "Rows 21-30, newest first" in three dialects
-- MySQL / PostgreSQL / SQLite
SELECT id, title FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
-- SQL Server (T-SQL)
SELECT id, title FROM posts
ORDER BY created_at DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- ANSI SQL:2008 (PostgreSQL, Db2, Oracle 12c+)
SELECT id, title FROM posts
ORDER BY created_at DESC
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;긴 쿼리 포매팅 — CTE, JOIN, CASE
포매팅 규칙은 아픈 쿼리에서 밥값을 합니다. 조인 다섯 개, 서브쿼리 세 단계, CASE 사다리 같은 것들이요. 가독성을 가장 크게 끌어올리는 한 가지는 중첩 서브쿼리를 공통 테이블 표현식(CTE)으로 바꾸는 것입니다. 중첩 쿼리는 안쪽부터 읽어야 합니다 — 가장 안쪽 SELECT를 찾아 거꾸로 거슬러 올라가야 하죠. 반면 WITH 체인은 이름 붙은 단계로 위에서 아래로 읽힙니다. raw_events, 그다음 sessionized, 그다음 daily_totals. CTE마다 블록 하나, 블록 사이 빈 줄로 포매팅하면 CTE 이름이 곧 문서가 되고, 마지막 SELECT만 바꾸면 각 중간 단계를 따로 실행해 검사할 수 있습니다.
JOIN 포매팅은 실수를 보이게 만드는 일입니다. JOIN마다 한 줄을 주고 ON 조건은 짧으면 같은 줄에, 길면 바로 아래 들여쓰기로 두세요. ON 절이 자기 JOIN에서 너무 멀어져 짝이 안 보이는 일은 절대 없어야 합니다. 이 배치는 고전적인 조인 버그 두 가지를 도드라지게 합니다. ON 조건 누락으로 인한 우발적 카티전 곱, 그리고 LEFT JOIN의 오른쪽 테이블 필터를 ON이 아닌 WHERE에 둔 경우 — 매칭 안 된 행의 NULL이 WHERE 검사에서 탈락하면서 left join이 조용히 inner join으로 변합니다.
CASE 표현식은 WHEN ... THEN 쌍마다 한 줄씩 주고 ELSE와 END를 CASE 아래 정렬하세요. 한 줄짜리 CASE는 분기 로직뿐 아니라 잊기 쉬운 사실 — ELSE가 없으면 NULL이 나온다는 것 — 도 숨깁니다. 그리고 포매팅된 쿼리가 100줄이나 CTE 4~5개를 넘으면 설계 신호로 받아들이세요. 뷰, 구체화된 중간 테이블, 애플리케이션 측 조합이 더 깊은 들여쓰기보다 다음 독자에게 나을 수 있습니다.
미니파이된 SQL, ORM 로그, 그리고 디버깅 왕복
SQL을 포매팅하는 가장 흔한 이유는 그것을 기계가 썼기 때문입니다. ORM과 쿼리 빌더 — Hibernate, Prisma, SQLAlchemy, ActiveRecord — 는 실행한 쿼리를 $1이나 ? 같은 위치 플레이스홀더가 박힌 한 줄로 로그에 남깁니다. 때로는 수천 자에 달하죠. 디버깅 루프는 기계적입니다. 로그에서 그 줄을 복사하고, 여기서 포매팅해 절 구조를 드러내고, 로그에 남은 파라미터 값을 플레이스홀더에 되끼운 뒤, DB 클라이언트에서 EXPLAIN ANALYZE로 실행합니다. 포매팅은 읽을 수 없는 산출물을 추론 가능한 대상으로 바꾸는 단계입니다. ORM이 불필요한 중첩 SELECT를 내보냈거나 같은 테이블을 두 번 조인했다는 사실은 포매팅된 쿼리에서만 또렷이 보입니다.
역방향 작업인 미니파이가 존재하는 이유는 쿼리가 어딘가에 박히기 때문입니다. 리터럴 줄바꿈을 이스케이프해야 하는 JSON 설정 파일, 환경 변수, 한 줄짜리 셸 heredoc, 차트마다 쿼리 하나를 저장하는 대시보드. 미니파이는 조잡한 정규화기이기도 합니다 — 공백을 접으면 쿼리의 두 변형을 텍스트 diff로 비교할 수 있습니다.
공백 접기에 대한 정직한 경고 하나: SQL 문자열 리터럴에는 의미 있는 연속 공백이 들어갈 수 있고, 전역 공백 정규식을 적용하는 순진한 미니파이어는 그것까지 접어서 WHERE name = 'a b'를 조용히 다른 술어로 바꿔버립니다. 문자열 리터럴이 포함된 것을 미니파이한 뒤에는 리터럴을 다시 확인하세요. 마지막으로 도구의 경계를 기억하세요. 포매터는 정확성에 대해 아무것도 증명하지 않습니다. 삭제된 컬럼을 참조하거나 엉뚱한 키로 필터링하는 쿼리도 기꺼이 예쁘게 만들어줍니다 — 검증은 데이터베이스, SQLFluff 같은 린터, 또는 테스트 스위트의 몫입니다.
-- What the ORM logged (one line, placeholders):
SELECT "u"."id", "u"."email" FROM "users" "u" LEFT JOIN "orders" "o" ON "o"."user_id" = "u"."id" WHERE "u"."created_at" >= $1 AND "o"."status" = $2
-- After formatting + substituting $1, $2:
SELECT "u"."id", "u"."email"
FROM "users" "u"
LEFT JOIN "orders" "o"
ON "o"."user_id" = "u"."id"
WHERE "u"."created_at" >= '2026-01-01'
AND "o"."status" = 'paid' -- ← WHERE on right table:
-- this LEFT JOIN acts as INNER