Data

최소한의 성능 보장을 위한 쿼리 및 테이블 작성

LibStake Dev 2026. 9. 23. 18:08

성능과 유지보수를 위한 최소한의 테이블 및 쿼리 작성 원칙

들어가며: 소규모 조직에서의 데이터베이스 설계는 누가 하는가

규모가 작은 조직에서의 서비스의 설계부터 개발까지 진행함에 있어 실무 개발자(특히 백엔드)가 데이터베이스의 설계를 맡는 경우가 많습니다. 이 경우 자기가 맡은 도메인의 여러 모델링 레퍼런스를 참조하게 되고 이를 우리 도메인에 맞게 진화시키는 경우가 많습니다. 엔티티 모델링부터 실제 서비스 로직 개발에 이르기까지, 우리가 고려해야 할 사항은 매우 많습니다. 특히 다른 업무를 같이 진행하는 경우 사소한 것까지 챙기기는 어려운 상황이 잦습니다.
이는 결국 실제 런타임에서의 성능 저하, 에러의 원인이 됩니다. 결국 우리는 우리가 설계한 스키마를 다시 리뷰하게 되며 문제가 되는 부분을 개선하게 되며, 최악의 경우 스키마 전체에 대한 재설계가 이루어져야 하는 경우도 있습니다. 아래 케이스들은 제가 실무에서 겪은 일들입니다.

  • 자주 사용될 읽기 쿼리를 고려하여 복합 인덱스를 설정했으나 실제로 쓰이지 않는다 - 인덱스 컬럼 순서가 실제 조회 조건과 맞지 않는다.
  • 읽기와 쓰기가 빈번한 테이블에 대해서 읽기 인덱스를 과하게 고려하여 매 테이블 업데이트 시마다 인덱스 설정이 과도하게 발생하여 쓰기 성능이 떨어진다.
  • 내가 설계한 엔티티 간의 관계가 실제 서비스 코드에 적용하기 번거롭거나 기존 서비스의 설계 원칙을 위반한다.
  • 너무 과도한 정규화로 인해 런타임에 성능이 과도하게 떨어진다.

위 각 케이스에 대해서 초기 스키마 설계 시 최소한의 체크리스트를 둠으로써 어느 정도 위의 문제를 최소화할 수 있었습니다.

초기 설계가 어려운 이유 - "데이터가 없다"

특정한 서비스, 기능을 최초로 설계할 경우 여러 가지 난점이 있습니다. 1. 실제 데이터가 없기 때문에 설계된 스키마가 제대로 동작할지, 빠진 것은 없는지 항상 고려해야 하고, 2. 데이터가 사용되는 양상에 대한 실측 데이터가 없기 때문에 유스케이스를 추측하여 설계되어야 합니다. 눈에 보이는 것 없이 일단 설계하고 현실과 부딪혀 봐야 하는 겁니다. 과소 설계가 된 스키마는 기능이 빠지거나, 추후 기능 추가에 있어서 확장성이 떨어지게 되며, 과대 설계가 된 스키마는 서비스 코드 작성에 있어서 동시성, 엣지 케이스에 대한 문제가 발생하고 전체적인 런타임 성능을 크게 떨어트리게 됩니다.

바쁜 우리는 최초 설계에 최대한의 커버리지를 갖도록 하여야 합니다. 보통 일반적인 업무 조직에서 충분한 리뷰의 시간을 갖는 건 어렵습니다. 슬프지만 이러한 문제를 최소화하는 것은 경험의 영역입니다. 지속적으로 이러한 문제에 시달려 보지 않으면(설사 시달려 보더라도) 완벽한 해결책을 찾는 건 불가능하다는 게 제 의견입니다. 개인적인 개발 경험에 있어서 이러한 문제를 최소화할 수 있는 고려사항을 작성해 보려 합니다.

주요 고려사항 - 도메인 투영, 읽기 성능, 쓰기 성능

1. 도메인 투영

비즈니스적 관점에서는 데이터베이스 설계는 곧 문제 해결의 수단을 제공하는 것입니다. 아무리 성능 좋은 설계라도 실제 비즈니스 도메인을 제대로 투영하지 못하면 안 됩니다. 데이터 관점에서 어떻게 현실의 상태를 데이터화할 것인지가 가장 중요한 고려사항입니다. 아무리 성능이 좋아도 비즈니스를 제대로 투영할 수 없는 설계는 실패한 설계입니다. 다른 무엇보다(심지어 성능보다) 비즈니스의 올바른 투영이 가장 중요합니다. 사용자는 복잡한 비즈니스 영역에 대한 긴 응답 시간은 어느 정도 감내할 만한 요소로 받아들입니다. 또한 스키마 설계가 적절하다는 가정하에 다른 방법으로 개선할 여지도 있습니다.

2. 읽기 성능

데이터베이스의 가장 빈번한 작업은 읽기입니다. 따라서 자주 참조되는 테이블을 설계함에 있어서 어떤 식으로 읽기 성능을 최적화할지는 항상 고려하여야 합니다. 서비스 설계 관점에서는 캐시의 도입 등으로 추가적인 성능 개선의 여지를 가져볼 수 있지만, 데이터베이스 모델링에서의 과도한 최적화 실패는 따라오는 어떠한 성능 개선 작업으로도 보상할 수 없을 수 있습니다.

3. 쓰기 성능

어떤 테이블들은 런타임에 쓰기가 빈번할 수 있습니다. 이는 도메인 특성이 될 수 있고, 기술적으로 필요한 테이블이기 때문일 수 있습니다. 데이터 쓰기는 두 가지 양상으로 나타납니다. 1. ROW에 대한 데이터 UPDATE, 2. 신규 ROW의 INSERT. 즉 쓰기 특성은 테이블이 INSERT가 주인지, UPDATE가 주인지도 고려할 필요가 있습니다. 실제 런타임에서는 1개 테이블에 대한 쓰기 작업으로 끝나는 게 아니라 연쇄된 여러 테이블에 대한 쓰기 작업이 순차 또는 동시적으로 이루어지므로 각각 테이블 단위의 쓰기 성능 향상은 중요한 과제입니다.

위 고려사항을 "고려"하기 전에 "고려"되어야 할 점은 우리가 어떤 DBMS를 쓰느냐입니다. 위 사항은 항상 우리가 사용 중인 데이터베이스 시스템에 대한 이해를 우선합니다. 예를 들어 PostgreSQL과 MySQL(InnoDB)은 기본 Isolation Level이 다르며, 각 Isolation Level로 해결할 수 있는 문제점이 다릅니다. 즉 기본적으로 우리가 사용하는 데이터베이스 엔진의 특성을 바탕으로 위 사항들을 고려하여야 합니다. 이 포스트는 데이터베이스의 특성을 고려하지 않은 부분만을 작성할 예정이며, 기술의 향상으로 DB 내에서 자동으로 좋은 방식으로 처리되는 항목에 대한 내용은 고려하지 않습니다. 어떤 RDBMS를 사용하든, "기본은 해야 한다"가 이 글의 목적입니다.

적절한 도메인 설계가 주어져 있고, 조직의 설계 원칙이 정해져 있다는 전제로 아래 체크리스트를 확인하면 좋을 것 같습니다.

DDL 체크리스트

테이블 생성 시 체크리스트입니다.

정렬 가능한 PK를 쓸 것

너무 당연합니다. 주키는 항상 정렬 가능한 값을 써서 인덱스 업데이트의 비용을 최소화해야 합니다. 일반적인 AUTO_INCREMENT형 숫자를 쓰거나, UUID를 쓰는 경우 v7 버전을 쓰세요.

설계에서의 도메인 경계와 스키마 경계가 일치하는지 고려할 것 - 생명주기를 중심으로

커머스에서는 설계에 따라 다르지만, 보통 상품은 이미지와 함께 생성되고 상품이 삭제될 때 이미지도 함께 삭제됩니다. 이 경우 두 엔티티는 같은 생명주기를 공유한다고 할 수 있습니다. 반면 주문은 상품이 삭제되더라도 해당 상품의 정보를 유지해야 하고, 상품 가격이 바뀌더라도 주문 당시의 가격을 그대로 가지고 있어야 합니다. 상품과 주문은 분명히 연관되어 있지만 생명주기는 다릅니다. 즉 스키마에서 무엇을 함께 묶을지는 연관이 있느냐가 아니라 생명주기를 같이하느냐로 판단해야 합니다.

이 기준이 어긋나면 문제는 양방향으로 나타납니다. 먼저 생명주기가 같은데 스키마에서는 떨어져 있는 경우입니다. 상품 이미지를 Files라는 범용 첨부파일 테이블에 저장하고, Files가 참조 대상의 종류와 ID를 컬럼으로 가지고 상품을 직접 가리킨다고 생각해 보겠습니다. 이런 방식은 참조 대상이 상황에 따라 바뀌기 때문에 FK를 걸 수 없고, 결국 상품과 이미지는 스키마상 서로를 모르는 데이터가 됩니다. 그러면 상품이 삭제될 때 이미지 삭제는 첨부파일 쪽 서비스 코드가 따로 처리해야 하고, 상품의 생명주기에 관여하는 모든 코드에 첨부파일 관련 처리가 함께 들어가야 합니다. 상품 로직을 캡슐화하기 어려워지는 것은 물론이고, 이런 부수 효과를 개발자가 일일이 챙겨야 하는 부담이 생깁니다.

이런 이유로 실무에서는 상품과 파일 사이에 ProductImage 같은 중간 테이블을 두는 경우가 많습니다. ProductImage는 상품에 묶여 상품과 생명주기를 같이하고, Files는 업로드된 파일이라는 자기만의 생명주기를 가집니다. 상품이 삭제되면 ProductImage는 함께 정리되고, 더 이상 참조되지 않는 파일은 파일 쪽에서 따로 정리하면 됩니다. 같은 범용 테이블을 쓰더라도 상품이 파일을 직접 붙잡지 않고, 각자의 생명주기에 맞춰 스키마 경계를 나눈 셈입니다.

다음은 생명주기가 다른데 스키마에서 강하게 묶인 경우입니다. 어제 주문한 상품의 가격이 오늘 바뀌었다고 해서 어제 주문의 가격까지 바뀌는 것은 말이 되지 않습니다. 그런데 주문이 상품명이나 가격을 상품 테이블에서 그때그때 가져오도록 설계되어 있다면, 상품의 수정이나 삭제가 곧바로 주문 이력에 영향을 줍니다. 이를 막으려면 상품을 수정하거나 삭제하는 코드 곳곳에 "이 상품이 주문에 쓰였는지" 확인하는 방어 로직을 두어야 하고, 상품 쪽 변경 하나가 주문 쪽의 사정까지 신경 써야 하는 구조가 됩니다. 생명주기가 다른 엔티티를 스키마에서 강하게 묶은 대가를 서비스 코드가 치르는 셈입니다.

그래서 일반적으로는 주문이 상품을 ID로만 참조하고, 주문 시점의 상품명, 가격, 옵션처럼 주문에 필요한 정보는 주문 쪽에 복사해 보관합니다. 이렇게 하면 주문은 주문이 발생한 순간의 모습을 스스로 간직하게 되고, 상품은 주문을 의식하지 않고 가격을 바꾸거나 판매를 중지할 수 있습니다. 상품은 판매 대상으로서의 생명주기를, 주문은 거래 기록으로서의 생명주기를 각자 가지게 됩니다. 연관은 ID로만 남기고, 각자의 생명주기에 맞춰 스키마 경계를 나눈 셈입니다.

생명주기가 같은 것을 떼어 놓으면 코드가 흩어진 것을 일일이 붙여야 하고, 생명주기가 다른 것을 묶어 두면 코드가 묶인 것을 매번 떼어내야 합니다. 방향은 다르지만 결과는 같습니다. 스키마가 지켜야 할 규칙을 서비스 코드가 떠안게 되고, 그 규칙은 코드 곳곳에 흩어져 추적하기조차 어려워집니다. 실제 개발에서 생명주기와 경계 문제로 코드가 스파게티가 되는 경우가 생각보다 많습니다. 이 부분은 항상 유의해야 합니다.

정규화는 조회 경로를 고려하여 멈출 것

정합성을 주 무기로 하는 RDBMS 특성상 정규화는 반드시 필요합니다. 들어가기 전에 밝혀 두면, 이 내용은 "정규화를 하지 마라"가 아니라 "적당히 하라"는 이야기입니다. 과도한 정규화는 읽기 시점에 다수의 조인을 동반합니다. 조인 자체가 나쁜 것은 아닙니다. RDBMS에서 조인은 필수적이고, 인덱스가 적절하다면 대부분은 충분히 빠릅니다. 문제는 정규화로 쪼개진 테이블이 자주 호출되는 조회 경로에 걸릴 때입니다. 이때는 쿼리 결과 페이로드의 중복이 커지고(상품 -> 옵션 -> 옵션값처럼 1:M:N 관계를 한 번에 조인하면 결과는 M×N행이 되고, 상품 하나를 조회했을 뿐인데 상품명 같은 상품 정보가 M×N번 반복해서 실려 옵니다), 필터와 정렬, 페이지네이션에서 인덱스를 활용하기 어려워지거나 쿼리 작성이 번거로워집니다. 그래서 테이블을 쪼개기 전에, 이 데이터가 어떤 화면과 API에서 읽히는지, 그 조회가 얼마나 자주 호출되는지를 먼저 확인해야 합니다.

이런 상황은 초기 설계보다 서비스가 진화하면서 더 자주 찾아옵니다. 상품에 가격 컬럼 하나면 충분했는데, 가격 변경 이력을 남겨야 한다는 요구가 생겼다고 해 보겠습니다. 가격을 별도 테이블로 떼어내 1:N으로 만들고 최신 행을 현재 가격으로 쓰면 구조는 깔끔합니다. 하지만 그 순간부터 상품을 읽는 모든 곳에서 "상품마다 최신 가격 1건"을 찾아야 하고, 가격으로 정렬하거나 필터링하는 목록 조회는 더 복잡해집니다. 드문 요구 때문에 가장 빈번한 조회가 비싸지는 셈입니다.

이럴 때는 두 데이터가 어디서 읽히는지를 나눠 보면 답이 보입니다. 현재 가격은 상품 목록과 상세, 정렬과 필터까지 거의 모든 조회 경로에 등장합니다. 반면 가격 이력은 관리자 화면이나 통계처럼 일부 경로에서만 읽힙니다. 그렇다면 현재 가격은 상품에 그대로 두어 주요 조회 경로를 지키고, 이력만 독립된 가격 로그 테이블에 쌓는 편이 낫습니다. 가격을 바꿀 때 두 곳을 같은 트랜잭션에서 기록해야 하는 수고는 생기지만, 드문 쓰기에 약간의 비용을 더해 빈번한 읽기를 가볍게 유지하는 것은 충분히 이득이라고 봅니다. 정규화를 어디서 멈출지는 데이터를 쪼갤 수 있느냐가 아니라, 그 데이터가 어떤 경로로 읽히느냐로 정해야 합니다.

초기 인덱스는 필수 조건부터 갖출 것

앞서 이야기했듯 초기 설계에는 실제 데이터가 없습니다. 어떤 조회가 얼마나 자주 일어날지 모르는 상태에서 거는 인덱스는 결국 추측입니다. 문제는 추측으로 건 인덱스가 실제로 쓰이지 않을 수는 있어도, 그 비용은 확실히 발생한다는 점입니다. 테이블에 인덱스가 있다는 것은 데이터를 쓸 때마다 모든 인덱스에 새 항목을 함께 기록해야 한다는 뜻이고, 인덱스가 늘어날수록 쓰기 하나의 비용도 커집니다. "있을 법하다"에 근거해 인덱스를 추가하는 것은 테이블이 무거운 짐을 진 채로 시작하게 만드는 것 같습니다.

그래서 테이블을 처음 선언할 때는 아래 세 가지 정도만 선제적으로 추가하는 편이 좋아 보입니다. 우리 YAGNI(You aren't gonna need it) 원칙에 의거합시다.

  1. 키에 대한 인덱스: PK와 UNIQUE 제약은 대부분의 RDBMS에서 인덱스가 자동으로 생성됩니다. 놓치기 쉬운 것은 FK 컬럼입니다. 엔진에 따라 FK에 인덱스를 자동으로 만들지 않는 경우가 있으므로(PostgreSQL), 사용하는 DB 기준으로 생성 여부를 확인하세요.
  2. 자주 쓰는 타임스탬프 하나: 생성 시각처럼 정렬과 기간 조회에 거의 항상 쓰이는 컬럼입니다.
  3. 가장 확실한 조회 경로 하나: 앞 섹션에서 이야기한, 서비스에서 가장 자주 호출될 것이 분명한 조회 경로 하나에 대한 인덱스입니다.

이 기준은 테이블이 읽기 위주인지 쓰기 위주인지와 상관없이 출발점으로 삼을 수 있습니다. 물론 테이블의 특성이 처음부터 명확하다면 그에 맞춰 더하거나 덜어낼 수 있습니다. 나머지 인덱스는 실제 데이터와 쿼리가 쌓인 뒤, 느린 쿼리를 근거로 추가해도 늦지 않았던 것 같습니다.

복합 인덱스는 쿼리의 Column 순서를 고려할 것

복합 인덱스는 컬럼의 순서대로 정렬된 목록입니다. 즉 선두 컬럼에 조건이 없는 경우 인덱스를 제대로 활용하기 어렵습니다. 일반적인 상황에서 항목들을 특정 조건으로 묶고 그 안에서 생성일순으로 정렬하는 경우가 많습니다. 복합 인덱스는 이러한 정렬 순서를 항상 고려해야 합니다. 사용할 법한 쿼리에서 공통적으로 사용한 Column들을 뽑아 보고, 그중 자주 사용되는 순서대로 복합 인덱스화하여야 합니다.

주문 목록이 대표적입니다. "사용자의 주문 목록"과 "사용자의 최근 주문"은 모두 사용자 ID로 찾고 생성일로 정렬하게 되는데, 이 경우 사용자 ID, 생성일 순서의 복합 인덱스 하나로 두 쿼리를 모두 처리할 수 있습니다. 사용자 ID로 범위를 좁힌 뒤, 그 안의 데이터가 이미 생성일순으로 정렬되어 있으니 별도의 정렬 없이 순차적으로 가져오면 됩니다. 반대로 생성일을 선두에 두었다면 사용자 ID로 범위를 좁히는 것부터 인덱스를 타기 어렵습니다. 이렇게 순서를 잘 잡은 복합 인덱스는 여러 개의 인덱스를 대신할 수 있습니다. 사용자 ID, 생성일 인덱스가 있다면 사용자 ID만으로 찾는 쿼리도 이 인덱스로 처리할 수 있으니, 사용자 ID 단독 인덱스를 따로 만들 필요가 없습니다. 앞서 이야기했듯 인덱스는 하나하나가 쓰기 비용이므로, 복합 인덱스의 순서를 고민하는 것은 곧 인덱스의 개수를 줄이는 일이기도 합니다.

이 항목은 주로 정렬을 고려하여 말씀드렸으나 WHERE 절도 마찬가지입니다. 다만 WHERE 절의 경우 쿼리 옵티마이저가 어느 정도 보정을 해 주므로 자세히 설명하지 않았으며, 관련된 내용은 아래 SELECT 관련 리스트에서 자세히 설명하겠습니다.

쓰기 중심 테이블은 인덱스를 최소화할 것

인덱스가 많으면 테이블 업데이트에 따른 인덱스 업데이트 비용이 증가한다는 것은 위에서 설명하였습니다. 쓰기가 많은 테이블의 경우 이는 성능과 직결됩니다. 아웃박스는 쓰기가 매우 빈번한 테이블의 좋은 예제입니다. 매 작업을 시작하자마자 매번 아웃박스 테이블에 기록하고 작업이 종료된 후 결과를 다시 아웃박스에 업데이트하게 됩니다. 추가적으로 작업 목록 조회를 위해 읽기 또한 상당히 빈번하게 일어나게 되는 특성이 있습니다.
모든 작업은 아웃박스를 기반으로 작업의 여부를 결정하게 되며 이는 곧 모든 트래픽이 아웃박스 테이블에 묶이는 일종의 Bottleneck이 된다는 의미입니다. 즉 인덱스는 일반적으로 걸리는 인덱스를 제외하고 아웃박스 조회용 쿼리에 대응되는 인덱스 하나만으로 유지하여야 합니다. 또한 자주 업데이트되는 Column에 대한 인덱스 사용을 피하여야 합니다.

SELECT 쿼리 작성 체크리스트

생성된 테이블에 대해 범용 SELECT 쿼리 한 개쯤은 작성할 것입니다. 이때 위 DDL 체크리스트가 잘 체크가 되었다는 가정하에, SELECT 쿼리 작성 시 최소한의 주의 사항에 대해 작성해 보려 합니다. 현대 백엔드 개발에 있어서 ORM 사용은 매우 당연시됩니다. raw 쿼리 작성에 들어가는 수고로움을 줄여 주고 대부분은 ORM이 알아서 잘 매핑됩니다. 몇몇 특수한 쿼리들에 한해 raw 쿼리로 작성하는 일이 잦습니다. 이것을 전제로 말해 보겠습니다.

일단 가능한 한 ORM 쿼리 빌더로 작성해 볼 것

초기 개발 단계에서 세부적인 성능을 위해 처음부터 raw 쿼리를 고집할 필요는 없습니다. 대부분의 ORM 쿼리 빌더는 일반적인 조회에서 충분한 기본 성능을 내도록 잘 만들어져 있고, 정말로 성능이 문제가 되는 쿼리는 나중에 근거를 가지고 고쳐도 늦지 않습니다.

반면 ORM이 주는 유지보수상의 이점은 처음부터 누릴수록 큽니다. 스키마가 변경되면 타입 체크나 빌드 단계에서 기존 코드의 오류를 알려 주기 때문에, 문제를 런타임 이전에 검출할 수 있습니다. raw 쿼리는 문자열이라 컬럼명이나 타입이 바뀌어도 실행해 보기 전까지는 어디가 깨졌는지 알 수 없습니다. 초기 개발은 스키마가 가장 자주 바뀌는 시기이므로, 이 시기에 raw 쿼리가 늘어날수록 스키마 변경 하나하나가 부담이 됩니다. 아직 확인되지 않은 약간의 성능을 위해 확실한 유지보수 비용을 지는 셈입니다.

이렇게 확보한 여유는 정말 중요한 쿼리에 투자하는 것이 좋습니다. 모든 쿼리가 똑같이 중요하지는 않습니다. 앞서 이야기한 자주 호출되는 조회 경로, 예를 들어 상품 목록이나 사용자의 주문 목록처럼 서비스의 대부분의 트래픽이 지나가는 쿼리는 소수입니다. 모든 쿼리를 raw로 다듬는 데 시간을 나눠 쓰기보다, 이런 핵심 쿼리 몇 개의 실행 계획을 꼼꼼히 확인하고 인덱스를 맞추는 데 집중하는 편이 서비스 전체 성능에는 훨씬 큰 효과를 냅니다.

그 외의 쿼리는 쿼리 빌더로 먼저 작성하고, 실제 데이터와 트래픽 속에서 느린 쿼리가 확인되면 그때 개선하면 됩니다. 개선 방법도 raw 쿼리가 첫 번째는 아닙니다. 인덱스를 추가하거나 쿼리 빌더 안에서 조회 방식을 바꾸는 것만으로 해결되는 경우가 많고, 그래도 안 될 때 해당 쿼리만 raw로 옮기면 됩니다. 복잡한 집계나 특정 DB 엔진의 기능처럼 처음부터 쿼리 빌더로 표현하기 어려운 경우라면 raw 쿼리가 맞지만, 이때도 여기저기 흩어 두지 말고 한 곳에 모아 관리하는 것이 좋습니다.

다만 이는 쿼리 빌더가 어떤 SQL을 만드는지 몰라도 된다는 뜻은 아닙니다. 사용하는 ORM의 동작 특성은 이해하고 있어야 하고, 생성되는 SQL은 로그로 확인하는 습관을 들이는 것이 좋습니다. 그래야 나중에 개선이 필요할 때 무엇을 고쳐야 할지 알 수 있고, 생성된 SQL이 인덱스를 제대로 활용하는지도 함께 확인할 수 있습니다.

인덱스 사용에 유의할 것

앞서 DDL 체크리스트에서 미뤄 둔 WHERE 절 이야기입니다. WHERE 절에 조건을 적는 순서는 옵티마이저가 알아서 판단하지만, 어떤 컬럼에 어떤 형태의 조건이 걸리느냐는 인덱스 활용에 직접적인 영향을 줍니다. 인덱스를 잘 만들어 두었더라도 조건을 어떻게 쓰느냐에 따라 그 인덱스를 제대로 쓰지 못할 수 있습니다.

복합 인덱스에서 가장 먼저 알아야 할 것은 등치 조건과 범위 조건의 차이입니다. 등치 조건(=)이 걸린 컬럼은 그 안에서 다음 컬럼으로 계속 범위를 좁혀 갈 수 있지만, 범위 조건(>, <, BETWEEN 등)이 걸린 컬럼 뒤에 오는 컬럼은 탐색 범위를 좁히는 데 쓰이지 못합니다. "사용자의 결제 완료 주문 중 최근 한 달"을 찾는다면 사용자 ID와 상태는 등치, 생성일은 범위 조건입니다. 인덱스가 사용자 ID, 상태, 생성일 순서라면 세 조건 모두 인덱스로 범위를 좁힐 수 있습니다. 하지만 사용자 ID, 생성일, 상태 순서라면 생성일의 범위 조건에서 탐색이 멈추고, 상태는 읽어 온 데이터를 하나씩 걸러 내는 데만 쓰입니다. 같은 컬럼으로 만든 인덱스라도 조건의 형태와 컬럼 순서가 맞지 않으면 효과가 크게 줄어드는 것입니다.

다음으로 흔한 실수는 조건에서 컬럼을 가공하는 것입니다. 인덱스는 컬럼의 원래 값으로 정렬되어 있기 때문에, 컬럼을 함수나 연산으로 감싸면 그 정렬을 활용할 수 없습니다. 생성일에서 날짜만 뽑아 오늘과 비교하거나, 이메일을 소문자로 바꿔 비교하거나, 컬럼에 값을 더하고 빼서 비교하는 경우가 대표적입니다. 해결 방법은 가공을 컬럼이 아니라 비교하는 값 쪽으로 옮기는 것입니다. "생성일의 날짜가 오늘인 것" 대신 "생성일이 오늘 0시 이상, 내일 0시 미만인 것"처럼 원래 컬럼에 범위 조건을 걸면 인덱스를 그대로 활용할 수 있습니다.

함수가 눈에 보이지 않아 놓치기 쉬운 경우도 있습니다. 컬럼과 비교 값의 타입이 달라 DB가 몰래 형변환을 하는 경우, 예를 들어 문자열 컬럼을 숫자와 비교하는 경우에는 컬럼 쪽에 변환이 일어나 인덱스를 타지 못할 수 있습니다. 앞쪽에 와일드카드가 붙은 LIKE 검색('%키워드')도 마찬가지입니다. 인덱스는 값의 앞부분부터 정렬되어 있기 때문에, 앞부분을 모르는 검색은 처음부터 끝까지 훑을 수밖에 없습니다.

이런 문제는 쿼리를 눈으로만 봐서는 알아채기 어렵고, ORM을 쓴다면 대소문자를 무시한 검색처럼 쿼리 빌더가 컬럼을 감싸는 SQL을 만들어 내기도 합니다. 앞 항목에서 이야기한 것처럼 생성된 SQL을 확인하고, 중요한 쿼리라면 실행 계획으로 인덱스가 의도대로 쓰이는지 직접 확인하는 것이 가장 확실합니다.

개선은 어떤 식으로 하는가

본 문서는 서비스 초기 개발 단계 또는 특정 기능 전체에 대한 초기 프로토타이핑에 적합한 내용이라고 볼 수 있을 것 같습니다. 이후 어떤 식으로 개선이 이루어지는지는 팀의 작업 특성에 따라 결정하면 될 것 같습니다. 이 부분은 정말로 팀 바이 팀이므로 잘할 것이라 생각합니다.

마치며 - 체크리스트 간소화

위 내용 중에 테이블 뭉치 하나, 쿼리 하나 작성하고 체크해 볼 만한 것들을 모아 봤습니다.

DDL 체크리스트

  • 강결합인지 아니면 너무 따로 노나?
  • 너무 많이 쪼갠 건 아닐까?
  • 진짜 이 인덱스 필요해?
  • 복합 인덱스 순서 맞나?
  • 인덱스가 너무 많지는 않나?

SELECT 체크리스트

  • 굳이 raw 쿼리로 작성할 필요가 있나?
  • 범위 조건 컬럼 뒤에 등치 조건 컬럼이 오지 않는지?
  • 쓸데없는 함수로 WHERE 절 감싼 건 아닌지?

완벽한 초기 설계라는 건 있을 수 없고, 위 내용으로 최소한의 설계 여유는 확보가 되었으면 합니다.