7분

스키마 우선 설계: 초기 쿼리 튜닝보다 더 빠른 앱

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

스키마 우선 설계: 초기 쿼리 튜닝보다 더 빠른 앱

스키마 대 쿼리 최적화: 우리가 의미하는 것

앱이 느리게 느껴질 때, 처음 드는 본능은 종종 “SQL을 고치자”입니다. 한 쿼리는 눈에 보이고 측정 가능하며 비난하기 쉽기 때문에 그 충동은 이해가 됩니다. EXPLAIN을 실행하고, 인덱스를 추가하고, JOIN을 조정하면 때로 즉각적인 성과를 볼 수 있습니다.

하지만 제품 초기에는 속도 문제가 특정 쿼리 문장보다 데이터의 형태에서 오는 경우가 많습니다. 스키마가 데이터베이스와 싸우게 만들면 쿼리 튜닝은 두더지 잡기 게임이 됩니다.

스키마 설계(쉽게 말하면)

스키마 설계는 데이터를 어떻게 조직하는지에 관한 것입니다: 테이블, 컬럼, 관계, 규칙. 여기에는 다음과 같은 결정이 포함됩니다:

  • 어떤 “대상”이 자체 테이블을 가질 자격이 있는가(사용자, 주문, 이벤트 등)
  • 테이블 간 관계는 어떻게 되는가(일대다, 다대다)
  • 무엇이 고유하거나 필수인지(제약조건)
  • 상태와 이력을 어떻게 표현할지(타임스탬프, 상태 필드, 감사 레코드)

좋은 스키마 설계는 질문하는 자연스러운 방식이 곧 빠른 방식이 되게 합니다.

쿼리 최적화(쉽게 말하면)

쿼리 최적화는 데이터를 가져오거나 업데이트하는 방식을 개선하는 것입니다: 쿼리를 재작성하고, 인덱스를 추가하고, 불필요한 작업을 줄이며, 큰 스캔을 유발하는 패턴을 피하는 일입니다.

둘 다 중요하지만—순서가 더 중요합니다

이 글은 “스키마가 좋고 쿼리가 나쁘다”는 주장이 아닙니다. 우선순위의 문제입니다: 데이터베이스 스키마의 기본을 먼저 맞추고, 진짜로 필요한 쿼리들을 튜닝하세요.

여기서는 왜 스키마 결정이 초기 성능을 좌우하는지, 스키마가 실제 병목인지 알아내는 방법, 그리고 앱 성장에 맞춰 안전하게 스키마를 진화시키는 방법을 알려드립니다. 대상은 제품팀, 창업자, 실무 개발자입니다—데이터베이스 전문가만을 위한 글이 아닙니다.

왜 스키마 설계가 초기 성능을 좌우하는가

초기 성능 문제는 대개 기발한 SQL 문제가 아니라 데이터베이스가 건드려야 하는 데이터의 양과 관련이 있습니다.

구조가 얼마나 많이 스캔하는지를 결정합니다

쿼리는 데이터 모델이 허용하는 한에서만 선택적일 수 있습니다. 상태(status), 타입(type), 소유자(owner)를 느슨하게 구조화된 필드에 저장하거나 일관성 없는 테이블에 흩어놓으면 데이터베이스는 일치하는지를 알아내기 위해 훨씬 많은 행을 스캔해야 합니다.

좋은 스키마는 검색범위를 자연스럽게 좁힙니다: 명확한 컬럼, 일관된 데이터 타입, 잘 범위가 정해진 테이블 덕분에 쿼리는 더 빨리 필터링하고 디스크나 메모리에서 더 적은 페이지를 읽습니다.

키가 없으면 비용이 큰 작업이 생깁니다

기본 키와 외래 키가 없거나 강제되지 않으면 관계가 추측성으로 변합니다. 그러면 쿼리 레이어에 작업이 밀립니다:

  • 조인이 더 커집니다. 신뢰할 수 있는 인덱스화된 조인 경로가 없기 때문입니다.
  • 필터가 더 복잡해집니다. 중복, 널, “거의 일치하는” 값을 보정해야 합니다.

제약조건이 없으면 불량 데이터가 쌓이고—테이블에 행이 추가될수록 쿼리는 점점 느려집니다.

인덱스는 스키마를 따릅니다(모든 것을 고칠 수는 없습니다)

인덱스는 외래 키로 조인하거나 잘 정의된 컬럼으로 필터링하거나 공통 필드로 정렬할 때 가장 유용합니다. 핵심 속성을 잘못된 테이블에 저장하거나 하나의 컬럼에 의미를 섞어 놓거나 텍스트 파싱에 의존하면 인덱스는 구해주지 못합니다—여전히 너무 많은 것을 스캔하고 변환해야 합니다.

기본적으로 빠르게

관계가 깔끔하고 식별자가 안정적이며 테이블 경계가 합리적이면 많은 일상 쿼리는 '기본적으로 빠르다'가 됩니다. 즉, 더 적은 데이터를 건드리고 단순한 인덱스 친화적 조건을 사용하기 때문입니다. 그럴 때 쿼리 튜닝은 빈번한 소방이 아니라 마무리 단계가 됩니다.

초기 단계의 현실: 변화는 계속됩니다

초기 제품에는 “안정된 요구사항”이 없고—실험이 있습니다. 기능이 배포되고, 다시 쓰여지거나 사라집니다. 작은 팀은 로드맵 압박, 지원, 인프라 작업을 동시에 처리하면서 옛 결정을 다시 볼 시간이 부족합니다.

가장 자주 바뀌는 것

보통 SQL 텍스트가 먼저 바뀌지 않습니다. 데이터의 의미가 먼저 바뀝니다: 새로운 상태, 새로운 관계, “추가로 기록해야 할 것” 필드, 출시 때에는 상상도 못했던 전체 워크플로우. 이런 변화는 정상이며—초기에는 스키마 선택이 특히 중요한 이유입니다.

나중에 스키마를 고치는 것은 쿼리를 고치는 것보다 어렵습니다

쿼리 재작성은 보통 가역적이고 국소적입니다: 개선을 배포하고, 측정하고, 필요하면 롤백할 수 있습니다.

스키마 재작성은 다릅니다. 실제 고객 데이터를 저장한 후에는 모든 구조 변경이 프로젝트가 됩니다:

  • 테이블 잠금이나 피크 시간에 쓰기 지연을 일으키는 마이그레이션
  • 새로운 컬럼을 채우거나 파생 데이터를 재구축하는 백필(backfill)
  • 전환 중 앱을 유지하기 위한 이중 쓰기 또는 섀도우 테이블
  • 온라인으로 변경할 수 없을 때의 다운타임 위험

좋은 도구가 있어도 스키마 변경은 조정 비용을 가져옵니다: 앱 코드 업데이트, 배포 순서, 데이터 검증.

초기 결정이 쌓이는 방식

데이터베이스가 작을 때 서투른 스키마는 괜찮아 보일 수 있습니다. 그러나 행 수가 수천에서 수백만으로 늘어나면 같은 설계가 더 큰 스캔, 더 무거운 인덱스, 더 비싼 조인을 만들어냅니다—그다음 모든 새 기능이 그 위에 쌓입니다.

초기 목표는 완벽이 아닙니다. 변경을 흡수하면서 매번 위험한 마이그레이션을 강요하지 않는 스키마를 선택하는 것입니다.

느린 쿼리를 예방하는 설계 기본 원칙

초기 단계의 대부분 느린 쿼리 문제는 SQL 요령이 아니라 데이터 모델의 모호성 때문입니다. 스키마가 레코드가 무엇을 나타내는지, 레코드가 어떻게 연관되는지를 불분명하게 만들면 모든 쿼리는 더 비싸게 작성되고 실행되며 유지보수됩니다.

핵심 엔티티 소수로 시작하세요

제품이 정상적으로 작동하기 위해 정말로 필요한 몇 가지 대상을 먼저 이름짓습니다: 사용자, 계정, 주문, 구독, 이벤트, 인보이스 등. 그런 다음 관계를 명시적으로 정의하세요: 일대다, 다대다(보통 조인 테이블로), 소유권(누가 무엇을 포함하는가).

실용적 점검: 각 테이블에 대해 “이 테이블의 한 행은 ___을(를) 나타낸다” 문장을 완성할 수 있어야 합니다. 못하면 그 테이블은 개념을 섞고 있을 가능성이 높아 나중에 복잡한 필터와 조인을 강요합니다.

명명과 소유권을 지루할 만큼 일관되게 하세요

일관성은 우발적 조인과 혼란스러운 API 동작을 막습니다. 컨벤션(snake_case vs camelCase, *_id, created_at/updated_at)을 선택하고 지키세요.

또한 필드의 소유자를 결정하세요. 예를 들어 “billing_address”는 주문(구매 시 스냅샷)에 속하는가, 아니면 사용자(현재 기본 주소)에 속하는가? 둘 다 유효할 수 있지만 명확한 의도 없이 섞으면 진실을 알아내기 위한 느리고 오류가 쉬운 쿼리가 생깁니다.

현실에 맞는 데이터 타입을 선택하세요

런타임 변환을 피하는 타입을 사용하세요:

  • 시간대 규칙을 이해한 타임스탬프를 사용하세요.
  • 금액에는 float가 아닌 decimal을 사용하세요.
  • 알려진 카테고리에는 enum/참조 테이블을 사용하세요.

타입이 잘못되면 데이터베이스는 효율적으로 비교할 수 없고, 인덱스가 덜 유용해지며 쿼리가 종종 캐스팅을 필요로 합니다.

계획 없이 사실을 중복 저장하지 마세요

같은 사실을 여러 곳에 저장(order_total과 sum(line_items)처럼)하면 데이터 일관성이 깨집니다. 파생 값을 캐시한다면 그것을 문서화하고 진실의 출처를 정의하며(종종 애플리케이션 로직과 제약조건을 통해) 일관되게 업데이트하세요.

키와 제약조건: 속도는 데이터 무결성에서 시작됩니다

빠른 데이터베이스는 보통 예측 가능한 데이터베이스입니다. 키와 제약조건은 “불가능한” 상태—누락된 관계, 중복된 식별, 앱이 생각하는 의미가 아닌 값—을 막아 데이터를 예측 가능하게 만듭니다. 그 정돈은 쿼리 플래너가 더 나은 가정을 할 수 있게 하여 성능에 직접 영향을 줍니다.

기본 키: 모든 테이블에 안정적인 식별자가 필요합니다

모든 테이블에는 기본 키(PK)가 있어야 합니다: 행을 고유하게 식별하고 절대 변하지 않는 컬럼(또는 소수의 컬럼)입니다. 이는 단순한 데이터베이스 이론이 아니라 테이블을 효율적으로 조인하고 안전하게 캐시하며 추측 없이 레코드를 참조할 수 있게 해주는 요소입니다.

안정적인 PK는 값이 없을 때 애플리케이션이 이메일, 이름, 타임스탬프, 여러 컬럼의 묶음으로 행을 “식별”하려는 비싼 우회책을 피하게 합니다—그 결과 넓은 인덱스, 느린 조인, 값이 바뀔 때 생기는 엣지 케이스가 발생합니다.

외래 키: 옵티마이저에 도움이 되는 무결성

외래 키(FK)는 관계를 강제합니다: orders.user_id는 반드시 존재하는 users.id를 가리켜야 합니다. FK가 없으면 잘못된 참조가 생기고(삭제된 사용자에 대한 주문 등), 모든 쿼리가 방어적으로 필터링하고 왼쪽 조인하며 널을 처리해야 합니다.

FK가 있으면 쿼리 플래너는 관계가 명시적이고 보장되어 있으므로 조인을 더 자신 있게 최적화할 수 있습니다. 또한 고아 행이 쌓여 테이블과 인덱스를 부풀리는 일을 줄일 수 있습니다.

깨끗하고 빠른 데이터를 위한 제약조건

제약조건은 관료주의가 아니라 가드레일입니다:

  • UNIQUE는 앱이 추가 조회와 정리를 하게 만드는 중복을 막습니다(예: canonical한 users.email).
  • NOT NULL은 세 상태 로직을 피하고 쿼리에서의 깜짝 널 처리를 줄입니다.
  • CHECK는 값이 알려진 집합 내에 있도록 합니다(예: status IN ('pending','paid','canceled')).

더 깨끗한 데이터는 더 단순한 쿼리, 더 적은 폴백 조건, 더 적은 ‘혹시 몰라’ 조인을 의미합니다.

성능을 떨어뜨리는 일반적인 안티패턴

  • 외래 키 없음: 나중에 고아 데이터를 정리하는 잡과 복잡한 쿼리 로직을 치르게 됩니다.
  • 중복된 이메일 필드(예: users.emailcustomers.email): 충돌하는 식별자와 중복 인덱스가 생깁니다.
  • 자유 형식 상태 문자열: "Cancelled" vs "canceled" 같은 오탈자는 필터와 리포팅을 망가뜨립니다.

초기에 속도를 원한다면 잘못된 데이터를 저장하기 어렵게 만드세요. 데이터베이스는 더 단순한 실행계획, 더 작은 인덱스, 더 적은 성능 놀라움으로 보답할 것입니다.

정규화 vs 비정규화: 실용적 균형

학습하며 크레딧 받기
Koder.ai에 대한 콘텐츠를 만들거나 신규 사용자를 추천하면 크레딧을 획득하세요.

정규화는 간단한 아이디어입니다: 각 사실은 한 곳에 저장해 데이터를 여기저기 복제하지 않는 것. 값이 여러 테이블이나 컬럼에 복사되면 업데이트가 위험해집니다—한 복사본은 바뀌고 다른 복사본은 안 바뀌면 앱이 모순된 답을 보여줍니다.

정규화(기본): 하나의 사실, 하나의 집

실무에서는 정규화가 엔티티를 분리해 업데이트를 깨끗하고 예측 가능하게 만듭니다. 예를 들어 제품 이름과 가격은 products에 있고 주문의 모든 행에 반복해서 넣지 않습니다. 카테고리 이름은 categories에 두고 ID로 참조합니다.

이 접근은 중복 데이터 감소(저장공간 절약, 불일치 감소), 업데이트 오류 감소(한 번 변경하면 모두 반영됨), ‘어떤 값이 맞는가’ 버그 감소를 가져옵니다.

과도한 정규화가 해로울 때

정규화를 지나치게 하면 데이터가 여러 아주 작은 테이블로 쪼개져 일상적인 화면을 위해 끊임없이 조인해야 할 때 문제가 됩니다. 데이터베이스는 여전히 정확한 결과를 반환할 수 있지만 일반적인 조회가 더 느리고 복잡해집니다.

초기 단계의 전형적인 증상: “간단한” 페이지(예: 주문 내역 목록)를 위해 6–10개 테이블을 조인해야 하고 성능이 트래픽과 캐시 온도에 따라 들쭉날쭉합니다.

실용적 접근: 핵심 사실은 정규화, 핫한 조회는 조심스럽게 비정규화

현실적인 균형:

  1. 핵심 사실과 소유권은 정규화(진실의 출처). 제품 속성은 products에, 카테고리 이름은 categories에, 관계는 외래 키로 유지하세요.
  2. 가장 많이 조회되는 경로에는 신중하게 비정규화—혜택과 유지 방안을 설명할 수 있을 때만.

비정규화는 빈번한 쿼리를 저렴하게 만들기 위해 작은 데이터를 의도적으로 중복하는 것입니다(조인 수 감소, 리스트 속도 향상). 핵심은 신중함: 복제된 각 필드는 갱신 유지 계획이 필요합니다.

예: 제품, 카테고리, 주문 항목

정규화된 설정 예:

  • 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_itemsunit_price_at_purchase를 저장하는 것은(일종의 비정규화) 제품 가격이 나중에 바뀌어도 과거 정확성을 보장하기 때문에 의도적입니다.

가장 흔한 화면이 “상품 요약이 포함된 주문”이라면 order_itemsproduct_name을 비정규화해 매번 products를 조인하지 않을 수 있습니다—하지만 동기화를 유지할 계획이 있거나 구매 시점의 스냅샷임을 받아들여야 합니다.

인덱스 전략은 스키마를 따른다, 그 반대가 아니다

인덱스는 마법의 ‘속도 버튼’처럼 취급되지만, 기본 테이블 구조가 합리적일 때만 잘 작동합니다. 컬럼 이름을 바꾸고 테이블을 분할하고 기록 간 관계를 바꾸는 중이라면 인덱스 세트도 계속 바뀝니다. 컬럼과 앱이 해당 컬럼으로 필터/정렬하는 방식이 충분히 안정적일 때 인덱스는 가장 유효합니다.

앱이 자주 묻는 질문으로 시작하세요

완벽을 예측할 필요는 없지만, 가장 중요한 쿼리의 짧은 목록은 필요합니다:

  • “이메일로 사용자를 찾기.”
  • “고객의 최근 주문 보여주기.”
  • “상태별 인보이스 목록, 최신순.”

이 문장들은 어떤 컬럼에 인덱스를 둘지 직접적으로 말해줍니다. 이들을 말로 표현하지 못하면 보통 스키마 명확성 문제입니다—인덱스 문제가 아닐 가능성이 큽니다.

복합 인덱스, 쉽게 설명하면

복합 인덱스는 여러 컬럼을 포함합니다. 컬럼 순서가 중요합니다. 데이터베이스는 왼쪽에서 오른쪽 순서로 인덱스를 효율적으로 사용합니다.

예를 들어 customer_id로 필터하고 created_at으로 정렬한다면 (customer_id, created_at) 인덱스가 유용합니다. 반대 순서 (created_at, customer_id)는 같은 쿼리에 거의 도움이 되지 않을 수 있습니다.

모든 것에 인덱스를 다는 것은 피하세요

여분의 인덱스마다 비용이 있습니다:

  • 느린 쓰기: 삽입/업데이트마다 각 인덱스도 갱신해야 합니다.
  • 더 많은 저장공간: 인덱스는 DB 크기의 큰 부분이 될 수 있습니다.
  • 더 많은 복잡성: 인덱스가 많으면 유지보수와 튜닝이 어려워집니다.

깨끗하고 일관된 스키마는 실제 접근 패턴에 맞는 소수의 ‘정답’ 인덱스만 필요하게 만듭니다—쓰기와 저장공간의 지속적 비용을 내지 않고도요.

쓰기 성능: 지저분한 스키마의 숨은 비용

채팅에서 스키마 설계
엔티티, 키, 제약조건을 PostgreSQL 스키마로 변환하며 빌드하세요.

느린 앱이 항상 읽기 때문에 느린 것은 아닙니다. 많은 초기 성능 문제는 삽입과 업데이트—사용자 가입, 결제 처리, 백그라운드 작업—에서 나타납니다. 지저분한 스키마는 각 쓰기마다 추가 작업을 만들기 때문입니다.

쓰기가 비싸지는 이유

몇 가지 스키마 선택은 모든 변경의 비용을 몰래 곱합니다:

  • 와이드 로우: 수십(또는 수백) 컬럼을 하나의 테이블에 집어넣으면 보통 로우가 커지고 더 많은 I/O와 캐시 교체가 발생합니다—대부분 컬럼이 거의 사용되지 않더라도.
  • 너무 많은 인덱스: 인덱스는 읽기를 빠르게 하지만 삽입/업데이트마다 각 인덱스를 갱신해야 합니다.
  • 트리거와 캐스케이드: 트리거는 간단한 INSERT 뒤에 숨은 작업(추가 삽입/업데이트)을 만들 수 있습니다. 캐스케이딩 외래 키는 올바르고 유용할 수 있지만 관련 데이터가 많아지면 쓰기 시간 비용이 증가합니다.

읽기 중심 vs 쓰기 중심: 고통을 의도적으로 선택하세요

워크로드가 읽기 중심(피드, 검색 페이지)이면 더 많은 인덱스를 허용하고 선택적 비정규화를 할 수 있습니다. 워크로드가 쓰기 중심(이벤트 수집, 높은 주문량)이면 쓰기를 단순하고 예측 가능하게 유지하는 스키마를 우선시하고 필요할 때만 읽기 최적화를 추가하세요.

쓰기를 해치는 초기 패턴

  • 감사 로그: 규정 준수에 좋지만 업데이트마다 큰 스냅샷을 로깅하는 것은 피하세요.
  • 이벤트 테이블: append-only 테이블은 잘 확장되지만 중복된 페이로드를 저장하면 부피가 커집니다.
  • 소프트 딜리트: 편리하지만 인덱스 크기를 늘리고 업데이트/쿼리를 느리게 할 수 있습니다.

기록을 보존하면서 쓰기를 단순하게 유지하기

실용적 접근:

  • “현재 상태”는 하나의 테이블에 저장하고, **이력(history)**은 별도의 append-only 테이블에 둡니다.
  • 이력 행은 좁게 유지하세요(정말 필요한 것만: 누가/언제/무엇을 변경했는지).
  • 이력에 필요한 인덱스는 실제 접근 패턴에 기반해 추가하세요(보통 entity_id, created_at).
  • 감사를 위해 처음에는 트리거를 사용하지 말고 애플리케이션에서 명시적으로 쓰는 편이 비용을 가시화하고 테스트하기 쉽습니다.

깨끗한 쓰기 경로는 여유 공간을 주고—나중에 쿼리 최적화를 훨씬 쉽게 만듭니다.

ORM과 API가 스키마 결정을 증폭시킵니다

ORM은 데이터베이스 작업을 손쉽게 느끼게 합니다: 모델을 정의하고 메서드를 호출하면 데이터가 나옵니다. 단점은 ORM이 비싼 SQL을 숨길 수 있다는 것입니다—문제가 생길 때까지 드러나지 않습니다.

ORM: 편의성이 숨기는 느린 패턴들

두 가지 흔한 함정:

  • 비효율적인 조인: 겉보기에는 단순한 .include()나 중첩 직렬화가 넓은 조인, 중복 행, 큰 정렬로 이어질 수 있습니다—특히 관계가 명확하게 정의되지 않았을 때.
  • N+1 쿼리: 50개의 레코드를 가져왔는데 ORM이 관련 데이터를 로드하기 위해 조용히 50개의 추가 쿼리를 실행합니다. 개발 환경에서는 작동하다가 실제 트래픽에서 무너집니다.

잘 설계된 스키마는 이러한 패턴이 나타날 가능성을 줄이고, 나타났을 때 감지하기 쉽게 합니다.

명확한 관계는 ORM 사용을 더 안전하게 만듭니다

테이블에 명시적인 외래 키, 유니크 제약, NOT NULL 규칙이 있으면 ORM은 더 안전한 쿼리를 생성할 수 있고 코드도 일관된 가정에 의존할 수 있습니다.

예: orders.user_id가 존재해야 한다(FK)고 강제하고 users.email이 유니크하다고 하면(제약), 애플리케이션 레벨에서 발생하는 많은 엣지 케이스와 추가 쿼리 작업을 막을 수 있습니다.

API는 스키마 선택을 제품 동작으로 변환합니다

API 설계는 스키마의 하류에 있습니다:

  • 안정적인 ID(일관된 키 타입)는 URL, 캐싱, 클라이언트 상태를 단순하게 만듭니다.
  • 페이지네이션은 인덱스된 단조(monotonic) 컬럼(보통 created_at + id)으로 정렬할 때 가장 잘 동작합니다.
  • 필터링은 컬럼이 실제 속성을 나타내고(문자열 오버로드나 JSON 블랍이 아니고) 제약조건이 값을 깨끗하게 유지할 때 예측 가능해집니다.

구출 작업이 아니라 워크플로로 만드세요

스키마 결정을 일급 공학 작업으로 다루세요:

  • 모든 변경에 대해 마이그레이션을 사용하고 코드처럼 리뷰하세요(/blog/migrations).
  • 새로운 엔드포인트에 대해 가벼운 “스키마 리뷰”를 하세요: 어떤 테이블, 어떤 키, 어떤 제약, 쿼리 형태는 어떤가.
  • 스테이징에서 ORM 쿼리를 로깅하고 프로덕션 전에 N+1 패턴을 플래그하세요(/blog/orm-performance-checks).

React 앱과 Go/PostgreSQL 백엔드를 채팅 기반으로 빠르게 생성하는 워크플로(예: Koder.ai)로 빠르게 개발할 때도 “스키마 리뷰”를 초기 대화의 일부로 포함하면 도움이 됩니다. 빠르게 반복할 수 있지만 트래픽이 오기 전에 제약, 키, 마이그레이션 계획은 신중히 정하세요.

스키마가 병목일 때의 조기 경고 신호

일부 성능 문제는 ‘나쁜 SQL’이라기보다 데이터의 모양과 싸우고 있는 데이터베이스의 신호입니다. 여러 엔드포인트와 리포트에서 동일한 문제가 반복되면 스키마 신호일 가능성이 높습니다.

주의할 일반적 증상

단순한 필터(고객으로 주문 찾기, 생성일로 필터)가 지속적으로 느리면 누락된 관계, 타입 불일치, 혹은 인덱스화하기 어려운 컬럼 때문일 수 있습니다.

또 다른 적색 신호는 폭발하는 조인 개수입니다: 기본적으로 2–3개의 테이블을 조인하면 될 쿼리가 6–10개 테이블을 체인으로 연결하는 경우(과도한 정규화, 폴리모픽 패턴, 또는 ‘모든 것 하나의 테이블’ 설계 때문인 경우)가 많습니다.

또한 enum처럼 동작하는 컬럼의 값이 일관되지 않은 경우(예: “active”, “ACTIVE”, “enabled”, “on”)를 주목하세요. 불일치는 방어적 쿼리(LOWER(), COALESCE(), OR-체인)를 강요하여 얼마나 튜닝해도 느리게 만듭니다.

스키마 체크리스트(빠르게 확인 가능)

  • 외래 키에 인덱스 누락(조인이 커지면 테이블 전체 스캔 발생).
  • 잘못된 데이터 타입(예: ID를 문자열로 저장, 날짜를 텍스트로 저장, 금액을 float로 저장).
  • 핵심 데이터에 EAV(Entity–Attribute–Value) 테이블 사용: 초기에는 유연하지만 필터/정렬이 많은 조인과 인덱스가 어려운 조건으로 바뀝니다.

도구에 의존하지 않는 간단한 진단

먼저 현실 점검을 하세요: 테이블별 행 수, 주요 컬럼의 카디널리티(고유 값 수). 상태 컬럼이 4개의 예상 값이 있어야 하는데 40개가 나오면 이미 복잡성이 스키마로 새고 있는 것입니다.

그다음 느린 엔드포인트의 쿼리 플랜을 보세요. 조인 컬럼에서 반복적으로 순차 스캔이 나타나거나 큰 중간 결과 집합이 보이면 스키마와 인덱싱이 근본 원인일 가능성이 큽니다.

마지막으로 슬로우 쿼리 로그를 활성화하고 검토하세요. 서로 다른 많은 쿼리가 유사한 방식으로 느리다면(같은 테이블, 같은 조건) 모델 수준에서 구조적으로 고치는 것이 가치 있습니다.

성장하면서 스키마를 안전하게 진화시키기

언제든 소스 코드 내보내기
스키마와 기능이 진화할 때 소스 코드를 내보내어 통제권을 유지하세요.

초기 스키마 결정은 현실 사용자와 만나면서 거의 살아남지 못합니다. 목표는 “완벽하게” 만드는 것이 아니라 생산 환경을 깨지지 않게, 데이터 손실 없이, 팀이 일주일 동안 멈추지 않고 변경하는 것입니다.

가벼우면서 반복 가능한 변경 프로세스

  1. 모델링: 새로운 형태(테이블/컬럼, 관계, 진실의 출처)를 문서화하세요. 예시 레코드와 엣지 케이스를 포함하세요.
  2. 마이그레이션: 역호환 방식으로 새 구조를 먼저 추가하세요(새 컬럼/테이블 추가; 즉시 삭제나 이름 변경은 피함).
  3. 백필: 기존 데이터로 새 필드를 배치 단위로 채우세요. 진행 상황을 기록해 재개할 수 있게 하세요.
  4. 검증: 데이터가 깨끗해진 후 제약조건을 추가하세요(예: NOT NULL, 외래 키). 옛 출력과 새 출력을 비교하는 검사 실행.

기능 플래그와 이중 쓰기(절제해서 사용)

대부분의 스키마 변경은 복잡한 롤아웃 패턴을 필요로 하지 않습니다. “확장하고 수축하는(expand-and-contract)” 방식을 선호하세요: 코드가 옛 것과 새것을 모두 읽을 수 있게 하고 자신이 생기면 쓰기를 전환하세요.

피크 트래픽, 긴 백필, 여러 서비스가 관련된 경우에만 기능 플래그이중 쓰기를 사용하세요. 이중 쓰기를 한다면 드리프트를 감지하기 위한 모니터링을 추가하고 충돌 시 어느 쪽이 우선인지 정의하세요.

롤백과 실제를 반영한 마이그레이션 테스트

안전한 롤백은 되돌릴 수 있는 마이그레이션으로 시작합니다. “되돌리기” 경로를 연습하세요: 새 컬럼을 드롭하는 것은 쉽지만 덮어쓴 데이터를 복구하는 것은 아닙니다.

마이그레이션을 현실적인 데이터 볼륨에서 테스트하세요. 노트북에서 2초 걸리는 마이그레이션이 프로덕션에서는 몇 분 동안 테이블을 잠글 수 있습니다. 프로덕션과 유사한 행 수와 인덱스를 사용해 실행 시간을 측정하세요.

플랫폼 도구가 위험을 줄여줄 수 있습니다: 신뢰할 수 있는 배포, 스냅샷/롤백, 필요 시 코드 내보내기 기능은 스키마와 앱 로직을 함께 반복하는 것을 더 안전하게 만듭니다. Koder.ai를 사용 중이라면 잠재적으로 주의가 필요한 마이그레이션을 도입할 때 스냅샷과 계획 모드를 활용하세요.

다음 사람(그리고 미래의 자신)을 위한 문서화

짧은 스키마 로그를 유지하세요: 무엇을 변경했고, 왜 했으며, 어떤 트레이드오프를 받아들였는지. /docs나 리포지토리 README에서 링크하세요. 예: “이 컬럼은 의도적으로 비정규화되었다” 또는 “외래 키는 2025-01-10에 백필 후 추가됨” 같은 메모를 포함하면 미래 변경이 같은 실수를 반복하지 않게 합니다.

언제 쿼리를 최적화할지(합리적 작업 순서)

쿼리 최적화는 중요하지만 스키마가 싸우고 있으면 투자 대비 효과가 떨어집니다. 테이블에 명확한 키가 없고 관계가 일관되지 않거나 “하나의 행에 하나의 대상” 원칙이 위반되어 있으면 쿼리 튜닝에 몇 시간을 써도 다음 주에 쿼리를 다시 쓸 가능성이 큽니다.

실용적 우선순위 순서

  1. 스키마 차단 요소부터 고치세요. 올바른 쿼리를 어렵게 만드는 항목(기본 키 누락, 일관성 없는 외래 키, 여러 의미를 섞는 컬럼, 진실의 출처가 중복된 경우, 현실과 맞지 않는 타입(예: 문자열로 저장된 날짜))을 먼저 해결하세요.

  2. 접근 패턴을 안정화하세요. 데이터 모델이 앱 동작(그리고 다음 몇 스프린트 동안의 동작)을 반영하면 쿼리 튜닝은 지속성을 갖습니다.

  3. 상위 쿼리들만 최적화하세요—모든 쿼리가 아닙니다. 로그/APM으로 느리고 빈번한 쿼리를 식별하세요. 하루에 10,000번 호출되는 단일 엔드포인트가 가끔 실행되는 관리자 리포트보다 우선입니다.

초기 쿼리 튜닝의 80/20

대부분 초기 성과 향상은 소수의 작업에서 옵니다:

  • 핫한 필터와 조인을 위한 적절한 인덱스 추가(실제로 사용되는지 검증).
  • 반환 컬럼 수 줄이기(SELECT * 피하기, 특히 와이드 테이블에서).
  • 불필요한 조인 피하기—때로는 조인이 있는 이유가 스키마가 기본 속성을 ‘발견’하도록 강제하기 때문입니다.

기대 설정: 지속적인 작업이지만 기반이 먼저입니다

성능 작업은 끝나지 않습니다. 목표는 예측 가능하게 만드는 것입니다. 깔끔한 스키마가 있으면 새 기능이 부하를 점진적으로 추가합니다; 엉성한 스키마가 있으면 새 기능이 복합적인 혼란을 추가합니다.

이번 주 체크리스트

  • 가장 느린 5개가장 빈번한 5개 쿼리를 나열하세요.
  • 각 쿼리에 대해 확인하세요: 기본 키 존재, 조인이 키 대 키, 타입이 맞는가.
  • 지배적인 필터/정렬에 맞는 하나의 인덱스를 추가하세요.
  • 한 개의 핫 패스에서 SELECT *를 교체하세요.
  • 재측정하고 다음 스프린트용으로 간단한 “전후” 노트를 남기세요.

자주 묻는 질문

SQL 쿼리를 최적화하기 전에 스키마를 먼저 수정해야 하나요?

스키마부터 정비하세요. 명확한 테이블, 안정적인 ID, 외래 키, 올바른 데이터 유형은 모든 쿼리가 처리해야 할 작업을 줄여 줍니다. 그다음 로그에서 실제로 중요하다고 드러난 쿼리를 튜닝하세요.

스키마 때문에 쿼리가 느린지 어떻게 알 수 있나요?

서로 관련 없는 엔드포인트가 여럿 느리고, 단순한 필터가 큰 테이블을 스캔하거나, 기본 화면에 조인이 많이 필요하다면 스키마 문제일 가능성이 큽니다. 반복되는 형 변환, 텍스트 파싱, 방어적인 null 검사도 경고 신호입니다.

모든 테이블에 기본 키가 필요한 이유는 무엇인가요?

모든 테이블에는 각 행을 고유하게 식별하고 변경되지 않는 기본 키가 있어야 합니다. 이름, 이메일, 타임스탬프 대신 그 ID를 조인과 참조에 사용하세요.

외래 키가 데이터베이스 성능에 도움이 되나요?

외래 키는 존재하지 않는 사용자를 가리키는 주문처럼 잘못된 관계를 막아 줍니다. 데이터를 더 깔끔하게 유지하고 조인을 이해하기 쉽게 만들지만, 자주 사용하는 조인에 쓰이는 외래 키 열에는 여전히 인덱스를 만들어야 합니다.

모든 열에 인덱스를 추가해야 하나요?

아니요. 앱이 레코드를 찾거나 조인하거나 정렬할 때 정기적으로 사용하는 열에 인덱스를 만드세요. 인덱스가 늘어날수록 저장 공간을 더 쓰고 삽입과 업데이트 작업도 늘어납니다.

복합 인덱스란 무엇이며, 열 순서가 중요한 이유는 무엇인가요?

복합 인덱스는 여러 열을 특정 순서로 저장합니다. customer_id로 필터링하고 created_at으로 정렬하는 쿼리라면, customer_id 다음에 created_at을 둔 인덱스가 접근 패턴에 잘 맞는 경우가 많습니다.

언제 데이터베이스를 비정규화해야 하나요?

먼저 핵심 사실을 정규화하세요. 각 사실에 명확한 저장 위치를 하나씩 정하고 테이블은 ID로 연결합니다. 자주 발생하고 측정된 읽기 작업을 위해서만 비정규화하고, 복사한 값이 실시간 값인지 과거 스냅샷인지 문서화하세요.

어떤 데이터 유형이 흔한 성능 문제를 막아 주나요?

금액에는 소수 유형을 사용하고, 시간에는 실제 날짜 또는 타임스탬프 유형을 사용하며, 관련 테이블끼리는 ID 유형을 맞추세요. 날짜나 ID를 텍스트로 저장하면 변환이 필요하고 인덱스를 효율적으로 사용하지 못할 수 있습니다.

운영 환경의 스키마를 변경하는 가장 안전한 방법은 무엇인가요?

먼저 변경 사항이 이전 버전과 호환되도록 만드세요. 새 테이블이나 열을 추가하고, 데이터를 배치로 채우며, 데이터를 검증한 뒤 애플리케이션의 읽기와 쓰기를 점진적으로 전환하세요. 새 경로의 신뢰성이 확인된 후에만 이전 구조를 제거하세요.

스키마가 안정된 뒤에는 무엇을 최적화해야 하나요?

가장 느리고 가장 자주 실행되는 쿼리를 찾고, 실행 계획을 살펴본 다음 먼저 스키마를 확인하세요. এরপর 목적에 맞는 인덱스를 추가하고, 필요한 열만 가져오며, 불필요한 조인을 제거한 뒤 다음 변경을 하기 전에 결과를 측정하세요.

Related posts