초기 성능 개선은 종종 더 나은 스키마 설계에서 옵니다: 올바른 테이블, 키, 제약조건은 느린 쿼리와 나중의 비용 큰 재작성을 예방합니다.

앱이 느리게 느껴질 때, 처음 드는 본능은 종종 “SQL을 고치자”입니다. 한 쿼리는 눈에 보이고 측정 가능하며 비난하기 쉽기 때문에 그 충동은 이해가 됩니다. EXPLAIN을 실행하고, 인덱스를 추가하고, JOIN을 조정하면 때로 즉각적인 성과를 볼 수 있습니다.
하지만 제품 초기에는 속도 문제가 특정 쿼리 문장보다 데이터의 형태에서 오는 경우가 많습니다. 스키마가 데이터베이스와 싸우게 만들면 쿼리 튜닝은 두더지 잡기 게임이 됩니다.
스키마 설계는 데이터를 어떻게 조직하는지에 관한 것입니다: 테이블, 컬럼, 관계, 규칙. 여기에는 다음과 같은 결정이 포함됩니다:
좋은 스키마 설계는 질문하는 자연스러운 방식이 곧 빠른 방식이 되게 합니다.
쿼리 최적화는 데이터를 가져오거나 업데이트하는 방식을 개선하는 것입니다: 쿼리를 재작성하고, 인덱스를 추가하고, 불필요한 작업을 줄이며, 큰 스캔을 유발하는 패턴을 피하는 일입니다.
이 글은 “스키마가 좋고 쿼리가 나쁘다”는 주장이 아닙니다. 우선순위의 문제입니다: 데이터베이스 스키마의 기본을 먼저 맞추고, 진짜로 필요한 쿼리들을 튜닝하세요.
여기서는 왜 스키마 결정이 초기 성능을 좌우하는지, 스키마가 실제 병목인지 알아내는 방법, 그리고 앱 성장에 맞춰 안전하게 스키마를 진화시키는 방법을 알려드립니다. 대상은 제품팀, 창업자, 실무 개발자입니다—데이터베이스 전문가만을 위한 글이 아닙니다.
초기 성능 문제는 대개 기발한 SQL 문제가 아니라 데이터베이스가 건드려야 하는 데이터의 양과 관련이 있습니다.
쿼리는 데이터 모델이 허용하는 한에서만 선택적일 수 있습니다. 상태(status), 타입(type), 소유자(owner)를 느슨하게 구조화된 필드에 저장하거나 일관성 없는 테이블에 흩어놓으면 데이터베이스는 일치하는지를 알아내기 위해 훨씬 많은 행을 스캔해야 합니다.
좋은 스키마는 검색범위를 자연스럽게 좁힙니다: 명확한 컬럼, 일관된 데이터 타입, 잘 범위가 정해진 테이블 덕분에 쿼리는 더 빨리 필터링하고 디스크나 메모리에서 더 적은 페이지를 읽습니다.
기본 키와 외래 키가 없거나 강제되지 않으면 관계가 추측성으로 변합니다. 그러면 쿼리 레이어에 작업이 밀립니다:
제약조건이 없으면 불량 데이터가 쌓이고—테이블에 행이 추가될수록 쿼리는 점점 느려집니다.
인덱스는 외래 키로 조인하거나 잘 정의된 컬럼으로 필터링하거나 공통 필드로 정렬할 때 가장 유용합니다. 핵심 속성을 잘못된 테이블에 저장하거나 하나의 컬럼에 의미를 섞어 놓거나 텍스트 파싱에 의존하면 인덱스는 구해주지 못합니다—여전히 너무 많은 것을 스캔하고 변환해야 합니다.
관계가 깔끔하고 식별자가 안정적이며 테이블 경계가 합리적이면 많은 일상 쿼리는 '기본적으로 빠르다'가 됩니다. 즉, 더 적은 데이터를 건드리고 단순한 인덱스 친화적 조건을 사용하기 때문입니다. 그럴 때 쿼리 튜닝은 빈번한 소방이 아니라 마무리 단계가 됩니다.
초기 제품에는 “안정된 요구사항”이 없고—실험이 있습니다. 기능이 배포되고, 다시 쓰여지거나 사라집니다. 작은 팀은 로드맵 압박, 지원, 인프라 작업을 동시에 처리하면서 옛 결정을 다시 볼 시간이 부족합니다.
보통 SQL 텍스트가 먼저 바뀌지 않습니다. 데이터의 의미가 먼저 바뀝니다: 새로운 상태, 새로운 관계, “추가로 기록해야 할 것” 필드, 출시 때에는 상상도 못했던 전체 워크플로우. 이런 변화는 정상이며—초기에는 스키마 선택이 특히 중요한 이유입니다.
쿼리 재작성은 보통 가역적이고 국소적입니다: 개선을 배포하고, 측정하고, 필요하면 롤백할 수 있습니다.
스키마 재작성은 다릅니다. 실제 고객 데이터를 저장한 후에는 모든 구조 변경이 프로젝트가 됩니다:
좋은 도구가 있어도 스키마 변경은 조정 비용을 가져옵니다: 앱 코드 업데이트, 배포 순서, 데이터 검증.
데이터베이스가 작을 때 서투른 스키마는 괜찮아 보일 수 있습니다. 그러나 행 수가 수천에서 수백만으로 늘어나면 같은 설계가 더 큰 스캔, 더 무거운 인덱스, 더 비싼 조인을 만들어냅니다—그다음 모든 새 기능이 그 위에 쌓입니다.
초기 목표는 완벽이 아닙니다. 변경을 흡수하면서 매번 위험한 마이그레이션을 강요하지 않는 스키마를 선택하는 것입니다.
초기 단계의 대부분 느린 쿼리 문제는 SQL 요령이 아니라 데이터 모델의 모호성 때문입니다. 스키마가 레코드가 무엇을 나타내는지, 레코드가 어떻게 연관되는지를 불분명하게 만들면 모든 쿼리는 더 비싸게 작성되고 실행되며 유지보수됩니다.
제품이 정상적으로 작동하기 위해 정말로 필요한 몇 가지 대상을 먼저 이름짓습니다: 사용자, 계정, 주문, 구독, 이벤트, 인보이스 등. 그런 다음 관계를 명시적으로 정의하세요: 일대다, 다대다(보통 조인 테이블로), 소유권(누가 무엇을 포함하는가).
실용적 점검: 각 테이블에 대해 “이 테이블의 한 행은 ___을(를) 나타낸다” 문장을 완성할 수 있어야 합니다. 못하면 그 테이블은 개념을 섞고 있을 가능성이 높아 나중에 복잡한 필터와 조인을 강요합니다.
일관성은 우발적 조인과 혼란스러운 API 동작을 막습니다. 컨벤션(snake_case vs camelCase, *_id, created_at/updated_at)을 선택하고 지키세요.
또한 필드의 소유자를 결정하세요. 예를 들어 “billing_address”는 주문(구매 시 스냅샷)에 속하는가, 아니면 사용자(현재 기본 주소)에 속하는가? 둘 다 유효할 수 있지만 명확한 의도 없이 섞으면 진실을 알아내기 위한 느리고 오류가 쉬운 쿼리가 생깁니다.
런타임 변환을 피하는 타입을 사용하세요:
타입이 잘못되면 데이터베이스는 효율적으로 비교할 수 없고, 인덱스가 덜 유용해지며 쿼리가 종종 캐스팅을 필요로 합니다.
같은 사실을 여러 곳에 저장(order_total과 sum(line_items)처럼)하면 데이터 일관성이 깨집니다. 파생 값을 캐시한다면 그것을 문서화하고 진실의 출처를 정의하며(종종 애플리케이션 로직과 제약조건을 통해) 일관되게 업데이트하세요.
빠른 데이터베이스는 보통 예측 가능한 데이터베이스입니다. 키와 제약조건은 “불가능한” 상태—누락된 관계, 중복된 식별, 앱이 생각하는 의미가 아닌 값—을 막아 데이터를 예측 가능하게 만듭니다. 그 정돈은 쿼리 플래너가 더 나은 가정을 할 수 있게 하여 성능에 직접 영향을 줍니다.
모든 테이블에는 기본 키(PK)가 있어야 합니다: 행을 고유하게 식별하고 절대 변하지 않는 컬럼(또는 소수의 컬럼)입니다. 이는 단순한 데이터베이스 이론이 아니라 테이블을 효율적으로 조인하고 안전하게 캐시하며 추측 없이 레코드를 참조할 수 있게 해주는 요소입니다.
안정적인 PK는 값이 없을 때 애플리케이션이 이메일, 이름, 타임스탬프, 여러 컬럼의 묶음으로 행을 “식별”하려는 비싼 우회책을 피하게 합니다—그 결과 넓은 인덱스, 느린 조인, 값이 바뀔 때 생기는 엣지 케이스가 발생합니다.
외래 키(FK)는 관계를 강제합니다: orders.user_id는 반드시 존재하는 users.id를 가리켜야 합니다. FK가 없으면 잘못된 참조가 생기고(삭제된 사용자에 대한 주문 등), 모든 쿼리가 방어적으로 필터링하고 왼쪽 조인하며 널을 처리해야 합니다.
FK가 있으면 쿼리 플래너는 관계가 명시적이고 보장되어 있으므로 조인을 더 자신 있게 최적화할 수 있습니다. 또한 고아 행이 쌓여 테이블과 인덱스를 부풀리는 일을 줄일 수 있습니다.
제약조건은 관료주의가 아니라 가드레일입니다:
users.email).status IN ('pending','paid','canceled')).더 깨끗한 데이터는 더 단순한 쿼리, 더 적은 폴백 조건, 더 적은 ‘혹시 몰라’ 조인을 의미합니다.
users.email과 customers.email): 충돌하는 식별자와 중복 인덱스가 생깁니다.초기에 속도를 원한다면 잘못된 데이터를 저장하기 어렵게 만드세요. 데이터베이스는 더 단순한 실행계획, 더 작은 인덱스, 더 적은 성능 놀라움으로 보답할 것입니다.
정규화는 간단한 아이디어입니다: 각 사실은 한 곳에 저장해 데이터를 여기저기 복제하지 않는 것. 값이 여러 테이블이나 컬럼에 복사되면 업데이트가 위험해집니다—한 복사본은 바뀌고 다른 복사본은 안 바뀌면 앱이 모순된 답을 보여줍니다.
실무에서는 정규화가 엔티티를 분리해 업데이트를 깨끗하고 예측 가능하게 만듭니다. 예를 들어 제품 이름과 가격은 products에 있고 주문의 모든 행에 반복해서 넣지 않습니다. 카테고리 이름은 categories에 두고 ID로 참조합니다.
이 접근은 중복 데이터 감소(저장공간 절약, 불일치 감소), 업데이트 오류 감소(한 번 변경하면 모두 반영됨), ‘어떤 값이 맞는가’ 버그 감소를 가져옵니다.
정규화를 지나치게 하면 데이터가 여러 아주 작은 테이블로 쪼개져 일상적인 화면을 위해 끊임없이 조인해야 할 때 문제가 됩니다. 데이터베이스는 여전히 정확한 결과를 반환할 수 있지만 일반적인 조회가 더 느리고 복잡해집니다.
초기 단계의 전형적인 증상: “간단한” 페이지(예: 주문 내역 목록)를 위해 6–10개 테이블을 조인해야 하고 성능이 트래픽과 캐시 온도에 따라 들쭉날쭉합니다.
현실적인 균형:
products에, 카테고리 이름은 categories에, 관계는 외래 키로 유지하세요.비정규화는 빈번한 쿼리를 저렴하게 만들기 위해 작은 데이터를 의도적으로 중복하는 것입니다(조인 수 감소, 리스트 속도 향상). 핵심은 신중함: 복제된 각 필드는 갱신 유지 계획이 필요합니다.
정규화된 설정 예:
products(id, name, price, category_id)categories(id, name)orders(id, customer_id, created_at)order_items(id, order_id, product_id, quantity, unit_price_at_purchase)주목할 점: order_items가 unit_price_at_purchase를 저장하는 것은(일종의 비정규화) 제품 가격이 나중에 바뀌어도 과거 정확성을 보장하기 때문에 의도적입니다.
가장 흔한 화면이 “상품 요약이 포함된 주문”이라면 order_items에 product_name을 비정규화해 매번 products를 조인하지 않을 수 있습니다—하지만 동기화를 유지할 계획이 있거나 구매 시점의 스냅샷임을 받아들여야 합니다.
인덱스는 마법의 ‘속도 버튼’처럼 취급되지만, 기본 테이블 구조가 합리적일 때만 잘 작동합니다. 컬럼 이름을 바꾸고 테이블을 분할하고 기록 간 관계를 바꾸는 중이라면 인덱스 세트도 계속 바뀝니다. 컬럼과 앱이 해당 컬럼으로 필터/정렬하는 방식이 충분히 안정적일 때 인덱스는 가장 유효합니다.
완벽을 예측할 필요는 없지만, 가장 중요한 쿼리의 짧은 목록은 필요합니다:
이 문장들은 어떤 컬럼에 인덱스를 둘지 직접적으로 말해줍니다. 이들을 말로 표현하지 못하면 보통 스키마 명확성 문제입니다—인덱스 문제가 아닐 가능성이 큽니다.
복합 인덱스는 여러 컬럼을 포함합니다. 컬럼 순서가 중요합니다. 데이터베이스는 왼쪽에서 오른쪽 순서로 인덱스를 효율적으로 사용합니다.
예를 들어 customer_id로 필터하고 created_at으로 정렬한다면 (customer_id, created_at) 인덱스가 유용합니다. 반대 순서 (created_at, customer_id)는 같은 쿼리에 거의 도움이 되지 않을 수 있습니다.
여분의 인덱스마다 비용이 있습니다:
깨끗하고 일관된 스키마는 실제 접근 패턴에 맞는 소수의 ‘정답’ 인덱스만 필요하게 만듭니다—쓰기와 저장공간의 지속적 비용을 내지 않고도요.
느린 앱이 항상 읽기 때문에 느린 것은 아닙니다. 많은 초기 성능 문제는 삽입과 업데이트—사용자 가입, 결제 처리, 백그라운드 작업—에서 나타납니다. 지저분한 스키마는 각 쓰기마다 추가 작업을 만들기 때문입니다.
몇 가지 스키마 선택은 모든 변경의 비용을 몰래 곱합니다:
INSERT 뒤에 숨은 작업(추가 삽입/업데이트)을 만들 수 있습니다. 캐스케이딩 외래 키는 올바르고 유용할 수 있지만 관련 데이터가 많아지면 쓰기 시간 비용이 증가합니다.워크로드가 읽기 중심(피드, 검색 페이지)이면 더 많은 인덱스를 허용하고 선택적 비정규화를 할 수 있습니다. 워크로드가 쓰기 중심(이벤트 수집, 높은 주문량)이면 쓰기를 단순하고 예측 가능하게 유지하는 스키마를 우선시하고 필요할 때만 읽기 최적화를 추가하세요.
실용적 접근:
entity_id, created_at).깨끗한 쓰기 경로는 여유 공간을 주고—나중에 쿼리 최적화를 훨씬 쉽게 만듭니다.
ORM은 데이터베이스 작업을 손쉽게 느끼게 합니다: 모델을 정의하고 메서드를 호출하면 데이터가 나옵니다. 단점은 ORM이 비싼 SQL을 숨길 수 있다는 것입니다—문제가 생길 때까지 드러나지 않습니다.
두 가지 흔한 함정:
.include()나 중첩 직렬화가 넓은 조인, 중복 행, 큰 정렬로 이어질 수 있습니다—특히 관계가 명확하게 정의되지 않았을 때.잘 설계된 스키마는 이러한 패턴이 나타날 가능성을 줄이고, 나타났을 때 감지하기 쉽게 합니다.
테이블에 명시적인 외래 키, 유니크 제약, NOT NULL 규칙이 있으면 ORM은 더 안전한 쿼리를 생성할 수 있고 코드도 일관된 가정에 의존할 수 있습니다.
예: orders.user_id가 존재해야 한다(FK)고 강제하고 users.email이 유니크하다고 하면(제약), 애플리케이션 레벨에서 발생하는 많은 엣지 케이스와 추가 쿼리 작업을 막을 수 있습니다.
API 설계는 스키마의 하류에 있습니다:
created_at + id)으로 정렬할 때 가장 잘 동작합니다.스키마 결정을 일급 공학 작업으로 다루세요:
React 앱과 Go/PostgreSQL 백엔드를 채팅 기반으로 빠르게 생성하는 워크플로(예: Koder.ai)로 빠르게 개발할 때도 “스키마 리뷰”를 초기 대화의 일부로 포함하면 도움이 됩니다. 빠르게 반복할 수 있지만 트래픽이 오기 전에 제약, 키, 마이그레이션 계획은 신중히 정하세요.
일부 성능 문제는 ‘나쁜 SQL’이라기보다 데이터의 모양과 싸우고 있는 데이터베이스의 신호입니다. 여러 엔드포인트와 리포트에서 동일한 문제가 반복되면 스키마 신호일 가능성이 높습니다.
단순한 필터(고객으로 주문 찾기, 생성일로 필터)가 지속적으로 느리면 누락된 관계, 타입 불일치, 혹은 인덱스화하기 어려운 컬럼 때문일 수 있습니다.
또 다른 적색 신호는 폭발하는 조인 개수입니다: 기본적으로 2–3개의 테이블을 조인하면 될 쿼리가 6–10개 테이블을 체인으로 연결하는 경우(과도한 정규화, 폴리모픽 패턴, 또는 ‘모든 것 하나의 테이블’ 설계 때문인 경우)가 많습니다.
또한 enum처럼 동작하는 컬럼의 값이 일관되지 않은 경우(예: “active”, “ACTIVE”, “enabled”, “on”)를 주목하세요. 불일치는 방어적 쿼리(LOWER(), COALESCE(), OR-체인)를 강요하여 얼마나 튜닝해도 느리게 만듭니다.
먼저 현실 점검을 하세요: 테이블별 행 수, 주요 컬럼의 카디널리티(고유 값 수). 상태 컬럼이 4개의 예상 값이 있어야 하는데 40개가 나오면 이미 복잡성이 스키마로 새고 있는 것입니다.
그다음 느린 엔드포인트의 쿼리 플랜을 보세요. 조인 컬럼에서 반복적으로 순차 스캔이 나타나거나 큰 중간 결과 집합이 보이면 스키마와 인덱싱이 근본 원인일 가능성이 큽니다.
마지막으로 슬로우 쿼리 로그를 활성화하고 검토하세요. 서로 다른 많은 쿼리가 유사한 방식으로 느리다면(같은 테이블, 같은 조건) 모델 수준에서 구조적으로 고치는 것이 가치 있습니다.
초기 스키마 결정은 현실 사용자와 만나면서 거의 살아남지 못합니다. 목표는 “완벽하게” 만드는 것이 아니라 생산 환경을 깨지지 않게, 데이터 손실 없이, 팀이 일주일 동안 멈추지 않고 변경하는 것입니다.
대부분의 스키마 변경은 복잡한 롤아웃 패턴을 필요로 하지 않습니다. “확장하고 수축하는(expand-and-contract)” 방식을 선호하세요: 코드가 옛 것과 새것을 모두 읽을 수 있게 하고 자신이 생기면 쓰기를 전환하세요.
피크 트래픽, 긴 백필, 여러 서비스가 관련된 경우에만 기능 플래그나 이중 쓰기를 사용하세요. 이중 쓰기를 한다면 드리프트를 감지하기 위한 모니터링을 추가하고 충돌 시 어느 쪽이 우선인지 정의하세요.
안전한 롤백은 되돌릴 수 있는 마이그레이션으로 시작합니다. “되돌리기” 경로를 연습하세요: 새 컬럼을 드롭하는 것은 쉽지만 덮어쓴 데이터를 복구하는 것은 아닙니다.
마이그레이션을 현실적인 데이터 볼륨에서 테스트하세요. 노트북에서 2초 걸리는 마이그레이션이 프로덕션에서는 몇 분 동안 테이블을 잠글 수 있습니다. 프로덕션과 유사한 행 수와 인덱스를 사용해 실행 시간을 측정하세요.
플랫폼 도구가 위험을 줄여줄 수 있습니다: 신뢰할 수 있는 배포, 스냅샷/롤백, 필요 시 코드 내보내기 기능은 스키마와 앱 로직을 함께 반복하는 것을 더 안전하게 만듭니다. Koder.ai를 사용 중이라면 잠재적으로 주의가 필요한 마이그레이션을 도입할 때 스냅샷과 계획 모드를 활용하세요.
짧은 스키마 로그를 유지하세요: 무엇을 변경했고, 왜 했으며, 어떤 트레이드오프를 받아들였는지. /docs나 리포지토리 README에서 링크하세요. 예: “이 컬럼은 의도적으로 비정규화되었다” 또는 “외래 키는 2025-01-10에 백필 후 추가됨” 같은 메모를 포함하면 미래 변경이 같은 실수를 반복하지 않게 합니다.
쿼리 최적화는 중요하지만 스키마가 싸우고 있으면 투자 대비 효과가 떨어집니다. 테이블에 명확한 키가 없고 관계가 일관되지 않거나 “하나의 행에 하나의 대상” 원칙이 위반되어 있으면 쿼리 튜닝에 몇 시간을 써도 다음 주에 쿼리를 다시 쓸 가능성이 큽니다.
스키마 차단 요소부터 고치세요. 올바른 쿼리를 어렵게 만드는 항목(기본 키 누락, 일관성 없는 외래 키, 여러 의미를 섞는 컬럼, 진실의 출처가 중복된 경우, 현실과 맞지 않는 타입(예: 문자열로 저장된 날짜))을 먼저 해결하세요.
접근 패턴을 안정화하세요. 데이터 모델이 앱 동작(그리고 다음 몇 스프린트 동안의 동작)을 반영하면 쿼리 튜닝은 지속성을 갖습니다.
상위 쿼리들만 최적화하세요—모든 쿼리가 아닙니다. 로그/APM으로 느리고 빈번한 쿼리를 식별하세요. 하루에 10,000번 호출되는 단일 엔드포인트가 가끔 실행되는 관리자 리포트보다 우선입니다.
대부분 초기 성과 향상은 소수의 작업에서 옵니다:
SELECT * 피하기, 특히 와이드 테이블에서).성능 작업은 끝나지 않습니다. 목표는 예측 가능하게 만드는 것입니다. 깔끔한 스키마가 있으면 새 기능이 부하를 점진적으로 추가합니다; 엉성한 스키마가 있으면 새 기능이 복합적인 혼란을 추가합니다.
SELECT *를 교체하세요.