PHpullh

PRACTICAL LANGUAGE GUIDE

SQL은 모든 서비스 개발자가 시스템의 데이터 흐름을 이해하기 위해 익혀야 하는 언어다

SQL의 데이터 모델링, 쿼리 성능, 트랜잭션과 잠금, 시스템 설계·면접 준비를 정리한 실무 가이드입니다.

SQL백엔드·데이터·분석을 넘나드는 모든 제품 개발자

루프를 버리는 훈련

SQL은 대개 두 번째 언어로 배웁니다. 이미 반복문과 조건문으로 문제를 푸는 습관이 몸에 붙은 상태에서 시작하기 때문에, 문법은 하루 만에 읽히는데 사고방식은 몇 달이 걸립니다. SELECT와 JOIN을 쓸 줄 아는 것과 SQL로 생각하는 것 사이의 거리가 그만큼 멉니다. 이 글은 그 거리를 줄이는 데 초점을 둡니다.

SQL이 기본값인 자리, 그리고 아닌 자리

주문·회원·결제처럼 여러 요청이 같은 데이터를 동시에 읽고 고치는 곳에서는 관계형 데이터베이스와 SQL이 사실상 기본값입니다. 잘못된 상태를 애초에 저장하지 못하게 막는 제약 조건, 여러 변경을 하나로 묶는 트랜잭션, 조회 비용을 줄이는 인덱스를 애플리케이션 코드보다 훨씬 적은 분량으로 얻습니다. 정산 리포트나 어드민 조회 화면처럼 집계가 중심인 화면도 마찬가지입니다. 여기서 SQL을 피하면 같은 기능을 직접 다시 만들게 됩니다.

반대로 SQL이 답이 아닌 경우도 분명합니다. 형태가 요청마다 다른 반정형 이벤트를 쌓기만 하는 수집 파이프라인, 키 하나로 값 하나를 찾는 캐시 계층, 임베딩 유사도 검색처럼 관계 대수와 성격이 다른 연산은 다른 저장소가 편합니다. 깊이가 정해지지 않은 그래프 탐색을 재귀 쿼리로 흉내 내는 것도 대개 좋은 선택이 아닙니다. 다만 이런 시스템에서도 원본 데이터는 관계형 DB에 남아 있는 경우가 많아서, SQL을 모른 채 오래 우회하기는 어렵습니다.

집합으로 생각한다는 것

애플리케이션 코드에서는 행을 하나 읽고, 판단하고, 다음 행으로 넘어갑니다. SQL에는 그 순서가 없습니다. WHERE는 조건을 만족하는 행 전체를 한 덩어리로 남기고, JOIN은 두 집합을 짝짓고, GROUP BY는 그 집합을 다시 잘게 나눕니다. 어떤 행이 몇 번째로 처리되는지는 정의되어 있지 않고, 그걸 정의하려는 시도는 대부분 잘못된 방향입니다.

이 차이는 곧바로 성능으로 나타납니다. 사용자 1만 명을 순회하며 각자의 주문 합계를 구하는 코드는 쿼리를 1만 번 보냅니다. 네트워크 왕복, 커넥션 점유, 계획 수립이 1만 번 반복됩니다. 같은 일을 GROUP BY 한 문장으로 쓰면 데이터베이스는 한 번의 스캔과 한 번의 집계로 끝냅니다. 커서와 애플리케이션 쪽 반복문이 나쁜 습관으로 불리는 이유가 여기에 있습니다. 코드가 길어서가 아니라, 데이터베이스가 잘하는 일을 굳이 빼앗아 오기 때문입니다.

윈도우 함수를 익히면 남아 있던 반복문도 대부분 사라집니다. 직전 값과의 차이, 그룹 안에서의 순위, 누적 합처럼 예전에는 결과를 받아 코드로 돌면서 계산하던 것들이 OVER 절 하나로 표현됩니다. 그룹별 최신 행 한 건만 뽑는 패턴은 실무에서 가장 자주 쓰는 형태 중 하나입니다.

옵티마이저가 하는 일과 하지 않는 일

SQL은 무엇을 원하는지 적는 언어입니다. 어떻게 가져올지는 옵티마이저가 테이블 통계를 보고 정합니다. 그래서 글자가 완전히 같은 쿼리가 개발 DB에서는 즉시 끝나고 운영 DB에서는 몇 분씩 걸리는 일이 생깁니다. 코드가 바뀐 게 아니라 행 수와 값 분포가 달라서 실행 계획이 달라진 것입니다. 이 사실을 받아들이지 않으면 성능 문제를 계속 코드 탓으로 돌리게 됩니다.

옵티마이저가 해주지 않는 것도 알아둬야 합니다. 없는 인덱스를 만들어주지 않고, 통계가 낡으면 잘못된 선택을 하며, 조건절에서 함수로 감싼 열은 인덱스를 쓸 수 없게 만듭니다. 인덱스는 마법이 아니라 정렬된 자료구조입니다. 정렬 순서의 앞쪽 열부터 범위를 좁혀 들어가기 때문에 복합 인덱스는 열 순서가 결과를 좌우하고, 인덱스를 늘릴수록 쓰기 비용과 저장 공간이 함께 늘어납니다. 인덱스를 더 만들지 말지는 조회 이득과 쓰기 손해를 같이 놓고 정하는 문제입니다.

트랜잭션도 선언적입니다. 격리 수준을 올리면 이상 현상은 줄지만 잠금 대기와 충돌 재시도가 늘어납니다. 여러 테이블을 함께 고치는 작업마다 접근 순서가 제각각이면 교착 상태가 생기므로, 잠금 순서는 개인 취향이 아니라 팀 규칙으로 문서에 적어 둡니다. 이런 판단은 데이터 모델링 면접 정리와 시스템 디자인 준비에서 다루는 논의와 그대로 이어집니다.

NULL과 세 값 논리

다른 언어에서 온 사람이 가장 자주 데는 곳입니다. SQL에서 비교의 결과는 참과 거짓만이 아니라 UNKNOWN까지 세 가지입니다. NULL과 NULL을 등호로 비교하면 참이 아니라 UNKNOWN이고, WHERE 절은 참인 행만 남기므로 그 행은 조용히 사라집니다. NULL을 찾으려면 IS NULL을 써야 합니다.

영향은 넓게 번집니다. status가 특정 값이 아닌 행을 찾겠다고 쓴 부정 조건은 status가 NULL인 행을 빼먹습니다. NOT IN 목록에 NULL이 하나 섞이면 결과가 통째로 비어버립니다. COUNT(*)와 COUNT(열)은 NULL 때문에 값이 다르고, 평균은 NULL을 0으로 세는 대신 계산에서 아예 제외합니다. 여기에 외부 조인이 만들어낸 NULL까지 겹치면 원인을 추적하기가 어려워집니다. 그래서 열을 정의할 때 NOT NULL을 기본으로 두고, NULL을 허용한다면 그 NULL이 값 없음인지 아직 모름인지 해당 없음인지 정해 두는 편이 낫습니다.

쿼리 한 문장을 읽는 법

SELECT o.customer_id, SUM(o.total_amount) AS revenue
FROM orders AS o
WHERE o.created_at >= :from
  AND o.created_at < :to
  AND o.status = 'paid'
GROUP BY o.customer_id
ORDER BY revenue DESC
LIMIT 20;

-- 후보 인덱스: (status, created_at, customer_id)
-- 실제 적용 전에는 EXPLAIN으로 선택도와 비용을 확인한다.

이 쿼리를 이렇게 쓴 데는 이유가 있습니다. 첫째, 기간 조건을 created_at에 함수를 씌워 날짜로 자르는 대신 범위 비교 두 개로 적었습니다. 열을 함수로 감싸는 순간 그 열의 인덱스는 쓸 수 없게 됩니다. 둘째, 상한을 이하가 아니라 미만으로 잡아 경계 날짜의 시·분·초를 흘리거나 중복해서 세지 않게 했습니다. 셋째, 후보 인덱스를 status, created_at, customer_id 순서로 적었습니다. 등호 조건인 status가 앞, 범위 조건인 created_at이 뒤에 오는 순서입니다. 다만 주석에 적힌 대로 이건 후보일 뿐이고, 실제 채택 여부는 실행 계획으로 확인합니다.

반복문을 대체하는 형태

고객별 최근 주문 한 건만 뽑기

SELECT customer_id, order_id, created_at
FROM (
  SELECT o.customer_id,
         o.order_id,
         o.created_at,
         ROW_NUMBER() OVER (
           PARTITION BY o.customer_id
           ORDER BY o.created_at DESC
         ) AS rn
  FROM orders AS o
  WHERE o.status = 'paid'
) AS ranked
WHERE rn = 1;

고객 목록을 돌면서 최신 주문을 한 명씩 조회하던 코드가 이 한 문장으로 대체됩니다.

PARTITION BY는 집합을 고객 단위로 나누고, ORDER BY는 그 안에서만 정렬 순서를 정합니다. 바깥에서 rn이 1인 행만 남기면 고객마다 가장 최근 주문 한 건이 남습니다. 여기서도 순서를 정한 것은 그룹 안의 정렬 기준일 뿐, 전체 처리 순서를 지정한 것이 아닙니다.

느린 쿼리를 실제로 잡는 순서

추측으로 인덱스를 붙이는 대신 순서를 지킵니다. 먼저 느린 쿼리 로그에서 실제로 시간을 쓰는 문장을 고릅니다. 이때 한 번에 2초 걸리는 쿼리보다, 20밀리초짜리가 초당 수백 번 도는 쪽이 더 큰 문제인 경우가 흔하므로 단건 시간이 아니라 총합으로 봅니다.

다음은 EXPLAIN입니다. 여기까지는 계획만 보여줍니다. 실제로 몇 행을 읽었는지 알려면 EXPLAIN ANALYZE로 실행까지 시켜야 합니다. 이때 눈여겨볼 것은 총 소요 시간보다 예상 행 수와 실제 행 수의 차이입니다. 둘이 크게 벌어지면 통계가 낡았거나, 조건 자체가 옵티마이저가 추정하기 어려운 형태입니다.

그다음 읽은 행 수와 최종 반환 행 수를 비교합니다. 100만 행을 읽어 20행을 돌려주고 있다면 인덱스나 쿼리 형태를 손볼 자리입니다. 정렬 때문에 디스크 임시 공간을 쓰고 있는지, 조인이 큰 테이블부터 시작하는지도 같이 봅니다. 고친 뒤에는 같은 방법으로 다시 재고, 인덱스 추가와 스키마 변경은 마이그레이션 도구로 코드처럼 버전 관리합니다.

운영에서는 커넥션 풀 크기도 함께 봅니다. 풀을 무작정 키우면 대기가 애플리케이션에서 데이터베이스로 옮겨갈 뿐 사라지지 않습니다. PostgreSQL과 MySQL은 실행 계획의 표시 형식, 지원하는 인덱스 종류, 기본 격리 수준이 서로 다릅니다. 배운 감각을 옮길 때는 원리만 옮기고 세부는 각 문서에서 확인하는 습관이 안전합니다. 서비스 전체 그림 안에서 이 부분이 어디에 놓이는지는 백엔드 로드맵에서 함께 보면 정리가 빠릅니다.

수십 행짜리 개발 DB에서 잰 속도는 성능 판단의 근거가 되지 못합니다. 행이 적으면 옵티마이저는 인덱스를 건너뛰고 전체 스캔을 고르는 편이 빠르다고 판단하고, 그 계획은 운영 데이터에서 그대로 유지되지 않습니다. 자릿수가 비슷한 데이터 규모와 비슷한 값 분포를 스테이징에 만들어 두고 다시 재세요. 고객 한 명이 전체 주문의 큰 몫을 차지하는 식의 치우침은 균등하게 생성한 더미 데이터로는 재현되지 않고, 그런 치우침이야말로 운영에서 계획을 뒤집는 요인입니다.

지금 배울지 판단하기

  • 서비스에 저장·조회·집계가 있고 그 데이터를 직접 만진다면 지금이 맞습니다. ORM 뒤에 숨어 있어도 결국 생성된 SQL을 읽어야 하는 날이 옵니다.
  • 어드민 화면이나 운영 지표를 만들고 있다면 SQL 한 문장이 코드 수십 줄을 대체합니다. 투자 대비 회수가 가장 빠른 구간입니다.
  • 백엔드나 데이터 직군 면접을 준비 중이라면 인덱스, 실행 계획, 격리 수준 세 가지는 거의 매번 나옵니다.
  • 지금은 아니다: 첫 언어의 반복문과 함수가 아직 손에 익지 않았다면 미루세요. 집합 사고를 동시에 얹으면 둘 다 흐려집니다. 한 언어의 기본기를 먼저 끝내는 쪽이 낫습니다.
  • 지금은 아니다: 다루는 데이터가 설정 파일 수준이고 앞으로도 그럴 예정이라면 SELECT와 간단한 JOIN 정도로 충분합니다.
  • 지금은 아니다: 특정 DB 벤더의 함수 목록을 외우는 방식으로 시작하려 한다면 방향을 바꾸세요. 관계형 모델, 인덱스, 트랜잭션을 먼저 잡아야 제품이 바뀌어도 지식이 따라옵니다.

INTERVIEW PREP

SQL 면접에서 설명할 질문

복합 인덱스의 열 순서는 어떻게 정하나요?

동등 조건, 범위 조건, 정렬·조인 조건과 데이터 분포를 함께 봅니다. 자주 쓰이는 쿼리의 실행 계획을 기준으로 정하고, 쓰기 비용과 인덱스 수 증가도 트레이드오프로 말합니다.

트랜잭션 격리 수준은 왜 필요한가요?

동시에 읽고 쓰는 요청에서 더티 리드·반복 불가 읽기·팬텀 같은 이상 현상과 성능 사이를 조절합니다. 재고 차감처럼 불변식이 중요한 흐름은 잠금·조건부 갱신·재시도를 포함해 설계합니다.