이번 글은 관계형 데이터베이스를 사용한 경험이 있는 개발자분에게 적합합니다.
SQL 사용할 수 있지만 그 원리에 대해 좀 더 깊게 탐구하길 원하시는 분께 도움이 될 것이라 예상합니다.
이제 백엔드 개발을 한지 1년 6개월이 되어 간다. 응용 프로그램 개발 공부는 꾸준히 했는데, 데이터베이스 공부는 그러지 못했지 않았나 싶다.
우리 서비스에는 모든 데이터를 관계형 데이터베이스에 보관하는데, 어느 날 조회에 있어서 문제를 발생시키기 시작했다.
대체로 집계 함수를 통한 통계나 로그 데이터들을 요청 할 때, 문제가 발생했다.
나는 오롯이 관계형 데이터베이스를 사용했기 때문에 발생하는 문제라고 생각했다.
이력 데이터는 관계형 데이터베이스에 적합하지 않다는 이야기를 많이 들었기 때문이다. 실제로 많은 다른 서비스에서는 NoSQL을 이력 데이터에 별개로 사용하는 것을 보아 왔다.
집계 함수는 Apache parquet 포멧이나 AWS Athena같은 서비스를 이용해서 극복해야 하지 않을까 생각했다.
하지만 최근 데이터베이스를 공부 하면서 생각이 바뀌였다.
우리가 선택해야하는 답은 RDB를 버리는 것이 아니라, RDB를 잘 써야 하는 것 이였다.
이 글은 RDB를 잘 쓰기 위해 시도한 것들을 정리한다.
최적화 이전에 알아야 할 것들
우리가 결국 원하는 것은 DB가 고장나지 않고 빠르게 동작하는 것이다. 이를 위해서 인덱싱, 파티셔닝, 캐싱등의 기법들을 이용하고 SQL을 튜닝(최적화)한다.
하지만 그 이전에 이런 기술들이 어떤 원리로 동작하는지 기반이 되는 지식들이 있다. 이러한 이론을 살펴보자.
1. 관계형 모델
관계형 모델은 수학의 집합론에 근거하여 실제 세계의 데이터를 관계라는 개념을 사용해 표현한 데이터 모델이다.
그리고 관계형 데이터베이스는 관계형 모델이라는 논리적인 컨셉으로 만들어졌다. 이 말이 어렵게 느껴질 것만 같지만 비유하자면 자동차를 응용 프로그램으로 구현한 것과 같다. 자동차는 엑셀을 밟으면 속도가 증가하고 시간에 따라 가속 한다. 응용 프로그램에서 이를 프로그램으로 구현할 수 있다.
마찬가지로 관계형 데이터베이스는 관계형 모델이라는 현실의 개념에서 착안하여 만든 응용프로그램이라고 볼 수 있다.
이 때, 중요한 포인트는 관계형 데이터베이스는 관계형 모델이 아니라는 것이다. 어디까지나 최대한 비슷하게 만들었지만 응용 프로그램은 모든 예외에 대처할 수 없다. 이 때문에 우리는 관계형 데이터베이스를 사용함에도 불구하고 관계형 모델이라는 강력한 데이터 모델을 사용하지 못하는 경우가 발생한다. 이는 곧 성능을 떨어뜨거나 정합성을 잃어버리는 등 RDB의 장점들을 놓치기 쉽게 만든다.
- 집합론
수학에서, 집합(集合, 영어: set)은 특정한 조건에 맞는 원소들의 모임이며, 명확한 기준에 의하여 주어진 서로 다른 대상들이 모여 이루는 새로운 대상이다. 어떤 대상이 집합에 속하는지 여부는 명확해야 하며, 집합 위에는 순서나 연산 따위의 구조가 주어지지 않는다. - 위키백과 -
관계형 모델은 집합론을 기초로 만들어진 개념이다. 그렇기 때문에 집합을 아는 것이 곧 관계형 모델을 이해하는 것과 다름이 없다.
위의 정의를 다시 한번 정리해보자.
•
특정한 조건에 맞는 원소들의 모임
•
명확한 기준에 의하여 주어진 서로 다른 대상들이 모여 이루는 새로운 대상
•
어떤 대상이 집합에 속하는지 여부는 명확
•
집합 위에는 순서나 연산 따위의 구조가 주어지지 않는다
특정한 조건에 맞는 원소들의 모임이나 명확한 기준에 의하여 주어진 서로 다른 대상들이 모여 이루는 새로운 대상와 같은 문장은 당연하게 여겨진다. 실제로 where API를 이용하여 특정한 조건을 검색하며 이를 통해 기존의 테이블에서 새로운 테이블을 생성해낸다.
반면 네 번째 정의는 order by 나 count(*) 등의 조회 API를 완전히 부정한다.
그리고 마지막 남은 세 번째 정의는 조금 모호하다.
- 어떤 대상이 집합에 속하는지 여부는 명확해야 한다.
어떤 대상이 집합에 속하는지 여부가 명확하다는 것은 모른다라는 답이 없다는 것을 의미한다.
예를 들면 1반에서 키가 큰 남자를 조회해야 한다고 가정하자. 이는 명확한 기준이 없는 명제일 뿐만 아니라, 그 결과를 알 수 없다. SQL에서도 역시 이러한 값을 조회하는 것은 불가능하다. 명확하게 170이상 혹은 180이하 라는 기준이 필요하다.
나는 위의 예제가 당연하게 느껴졌다. 모른다는 것이 얼마나 이상한 요청이며, 그 결과가 유해한 것인지도 말이다.
그런데 지금까지 나도 모르는 사이에 유해한 것처럼 보이는 모르는 값을 데이터베이스에 마구 축적하고 있었다.
- NULL의 오해
데이터베이스의 NULL은 값이 아니고 요소가 무엇인지 모르는 즉, 알 수 없는 표시이다.
사용하는 어떤 RDB라도 좋다. 결과가 존재하지 않는 count와 avg 쿼리를 각각 실행해보자.
아래의 예시는 100억원보다 비싼 상품을 집계하여 조회하는 쿼리이다. 그런 경우도 물론 있겠지만, 우리 예시에는 그런 비싼 물건은 유감스럽게도 판매하지 않는다.
select count(price) from product
where price > 10000000000
select avg(price) from product
where price > 10000000000
SQL
위의 결과는 count에는 0 , avg에는 null을 각각 반환한다.
100억원 이상인 상품은 0개임이 분명하다. 하지만 0개의 상품의 평균은 0이 아니다. 수학에서 역시 0으로 나눌 수 없다. RDB에서는 조회할 대상이 없기 때문에 그 답을 알 수 없다. 라는 평가를 한다.
그렇기 때문에 NULL을 반환한다.
제약 API (where)에서 NULL은 IS 혹은 IS NOT등을 사용하는 것 역시 NULL이라는 특수한 표시를 별개로 처리해야 하기 때문이다.
나는 NULL을 없다의 의미로 자주 사용했었다. 예를 들면 price의 값으로 null을 넣는 것이 그러하다.
하지만 이는 상품의 가격이 없다. 가 아닌 상품의 가격을 모른다. 로 해석되어야 맞다.
- order by, count(*), NULL 은 모두 관계형 모델에 적합하지 않다.
우리가 관계형 모델의 장점을 100% 얻기 위해서 위와 같은 키워드들은 사실 모두 적합하지 않다. 그렇다고 해서 이러한 키워드들을 모두 사용하지 않을 수는 없다. 다만 이러한 것들이 관계형 모델을 무너뜨린다는 사실을 안다면, 적재적소에 사용할 수 있는 힘을 기를 수 있다. 우리는 가능한 관계형 모델을 지키며, 필요한 순간에는 이를 벗어나서 기능의 편리함과 성능을 필요한 만큼 얻어 갈 수 있어야 한다.
2. 정규화
데이터베이스의 정규화에 대해선 많이들 들어봤을 것이다.
데이터베이스에서 정규화가 필요한 이유는 다음과 같다.
•
불필요한 데이터(data redundancy)를 제거해 불필요한 중복을 최소화한다.
•
삽입/갱신/삭제 시 발생할 수 있는 각종 이상 현상(Anomaly) 을 방지하기 위해서, 테이블의 구성을 논리적이고 직관적으로 한다.
정규화를 해야 하는 이유는 충분해 보인다.
불필요한 데이터를 제거한다면 디스크의 용량을 줄일 수 있다. 또한 조회시에 스캔해야 할 데이터가 줄어들기 때문에 더 빠를 것임을 예측할 수 있다.
이상 현상을 방지 하는 것 역시 당연해 보인다. 믿을 수 없는 데이터라면 무슨 의미가 있을까.
정규화의 기계적인 방법을 위해서 NF(Normal Form), 정규형이라는 개념을 도입한다.
정규형은 1NF부터 6NF까지 종류가 있는데, 중요한 특징 중 하나는 높은 단계의 정규형은 자동으로 그 이전의 정규형 조건을 만족한다는 것이다.
1NF
제 1정규형의 요건은 릴레이션이어야 할 것 이다.
릴레이션이란 관계형 모델에서의 개념이다. RDB에선 Table과 매치되는 개념으로 개체를 표현하기 위한 데이터 구조로써 2차원 테이블로 표현하며, heading(스키마)와 body(본체)로 구성된다.
여기서 릴레이션의 개념보다는 릴레이션이 관계형 모델에서 왔다는 점이 중요하다. 이는 곧 집합이라는 개념에 부합해야 한다는 의미이기 때문이다.
테이블이 1NF가 되기 위한 요건은 다음과 같다.
1.
행이 위에서 아래로 정렬되어 있지 않다.
2.
열이 왼쪽에서 오른쪽으로 정렬되어 있지 않다.
3.
중복되는 행이 존재하지 않는다.
4.
값은 원자성을 가진다.
5.
모든 열의 값은 정의된 것이어야 하고 각 행은 항상 존재한다. ( NULL인 값이 존재해서는 안된다. )
1. 테이블의 정렬되어 있지 않아야 한다.
테이블의 행과 열은 이미 정렬이 존재한다. 1번과 2번의 조건은 DB 테이블 구조로는 이미 만족할 수 없다.
하지만 이 점은 문제가 되지 않게 할 수 있다. 행과 열을 조회할 때, 순서에 의존하지 않는다면 말이다.
SELECT * FROM PRODUCTS -- 열의 순서에 의존하는 쿼리. *는 등록된 컬럼 순으로 값을 반환하기 때문
SQL
SELECT id FROM PRODUCTS ORDER BY 1 -- 행의 순서에 의존하는 쿼리.
SQL
위와 같이 순서에 의존하는 쿼리를 사용하지 않으면 1NF의 조건을 충족한다.
2. 중복되는 행이 존재하지 않는다.
해당 조건은 테이블에 기본키 혹은 유니크키와 같은 고유키가 존재한다면 조건을 만족한다.
3. 값의 원자성
값의 원자성이란 의미가 있는 한 묶음을 값의 한 단위로 취급한다는 의미이다.
예를 들어 상품의 상품명인 "컴퓨터" 는 원자값이다. "컴" "퓨터"로 나누는게 무슨 의미인가!
반면 상품명에 "컴퓨터, 커피"는 원자값이 아니다. "컴퓨터" 와 "커피"는 각각 분리되어야 한다.
위의 예제처럼 하나의 컬럼에 복수의 값들이 들어가지 않는 행의 모임을 가져야 한다.
4. 모든 열의 값은 정의된 것이어야 하고 각 행은 항상 존재한다.
집합론의 정의 중 "어떤 대상이 집합에 속하는지 여부는 명확해야 한다." 에 해당되는 부분이다. 각 행은 NULL과 같은 값이 존재하면 안된다. 모든 열은 이미 데이터베이스 어플리케이션이 제한하는 영역이기 때문에 신경쓸 필요가 없다.
1NF의 조건을 모두 살펴보았다. 그런데 곰곰히 생각해보면, 정규화 과정이 옳은 관계형 모델로 가는 것과 유사하지 않은가? 실제로 정규화는 관계형 모델을 보완하기 위해 나온 개념이라고도 한다!
함수 종속성(FD)
2NF부터 BCNF까지는 종속을 제거하는 정확하게는 릴레이션 내의 함수 종속성을 배제해 나가는 과정이다.
그렇다면 함수 종속이란 무엇일까?
어떤 릴레이션 R에 존재하는 필드들의 부분집합을 각각 X와 Y라고 할 때, X의 한 값이 Y에 속한 오직 하나의 값에만 사상될 경우에 "Y는 X에 함수 종속 (Y is functionally dependent on X)"이라고 하며, X→Y라고 표기한다. - 블로그 참고 -
예를 들어, 테이블에 [생일]과 [나이]라는 필드가 존재할 경우에 [나이] 필드는 [생일] 필드에 함수 종속이다. 즉, 생일을 알고 있다면, 나이에 대한 필드를 참조하지 않거나, 아예 필드를 유지하지 않아도 나이를 결정할 수 있다.
2NF
2NF는 후보키의 진부분집합에서 키가 아닌 속성에 함수 종속성을 제거하는 작업이다.
여기서 진 부분집합이란 부분집합 중에 원래 자신의 집합을 제외한 것을 말한다.
예를 들자면 그림에서 B는 {1,2,3} 일 때, A는 다음과 같은 부분집합이 될 수 있다.
{}, {1}, {2}, {3}, {1, 2}, {1, 3}, {2, 3}, {1, 2, 3}
이 중에서 원래 자기 자신인 {1, 2, 3 } 을 제외한 부분 집합이다.
Default view
Search
해당 릴레이션에는 { 이름 } → { 나이 }이라는 종속이 존재한다. 키가 아닌 속성이 후보키의 부분 집합이 되었기 때문에 갱신하는 과정해서 변칙이 생기는 원인이 된다. 또한 중복되는 값을 추가하여 디스크의 용량이나 성능에 영향을 줄 수 있다. 이는 테이블을 분리해서 제거 해야한다.
Default view
Search
Default view
Search
3NF
3NF는 추이 함수 종속성(Transitive Dependency)라는 함수 종속성을 제거하는 작업이다. 추이 함수 종속성은 키가 아닌 속성 사이의 종속성을 나타낸다.
Default view
Search
구매 커피와 가격은 모두 후보키가 아니다. 그리고 구매 커피는 단 하나의 가격만이 정해져 있따.
이는 {구매 커피} → { 가격 }라는 함수 종속성이 있다고 볼 수 있다.
이 역시 2NF에서 하듯이 테이블을 분리하면 된다.
이 이외에도 BCNF(보이스코드 정규형)등이 존재한다. 이는 후보키가 키가 아닌 속성에 종속될 때 발생한다. 마찬가지로 테이블을 나누고 종속을 제거하면 된다.
테스트
일단 실험을 위한 많은 데이터가 필요했다. 한 10GB 정도를 기준으로 잡았다.
select datname, pg_size_pretty(pg_database_size('postgres')) from pg_database;
SQL
위 쿼리는 데이터베이스의 용량을 알려준다.
해당 쿼리를 날리니 기본적으로 8MB가 조금 안되는 양이 기본적으로 저장되어 있다.
CREATE TABLE users(
id SERIAL PRIMARY KEY,
email VARCHAR(40) NOT NULL UNIQUE
);
INSERT INTO users(email)
SELECT
'user_' || seq || '@' ||'gmail' || '.com' AS email
FROM GENERATE_SERIES(N, M) seq;
SQL
위의 결과는 각각 테이블을 생성했을 때와 각각 100, 1000, 10000개의 행을 생성했을때의 결과 값이다.
2개의 컬럼을 가진 행이 10000개당 1.3MB정도의 자리를 차지하는 것으로 확인된다.
이러한 이론을 바탕으로라면 1.3GB를 생성하기 위해선 약 1천만줄의 행이 필요한 것으로 추측하였고 직접 생성해보았다.
는 실패! 1000만개를 한 번에 생성하려 하니 타임아웃으로 실패하고 말았다.
1. 행 하나의 얼마만큼의 용량을 차지할까?
안전한 결과를 위해 100만개씩 나누어서 INSERT를 진행하였다.
Default view
Search
결과적으로 평균 100만개당 78~79MB가 추가되었고, 평균 12.21s의 시간이 걸렸다.
2. 인덱스는 INSERT에 영향을 미칠까? 만약 그렇다면 얼마정도일까?
Default view
Search
결과적으로 평균 100만개당 146~164MB가 추가되었고, 평균 28s의 시간이 걸렸다.
1과 2의 실험을 통해 알아낸 놀라운 사실은 인덱스가 생성에 있어서 많은 것을 좌지우지 한다는 것이다.
10GB를 채우기 위해 다음과 같은 세팅을 진행하였다.
Default view
Search
조회 테스트
Q. 기본 로우 수는 조회에 얼마만큼 영향을 미칠까
explain select * from users
Seq Scan on users (cost=0.00..17323.00 rows=1000000 width=25)
SQL
explain select * from posts
Seq Scan on posts (cost=0.00..173278.44 rows=10000244 width=26)
SQL
explain select * from comments
Seq Scan on comments (cost=0.00..1833334.80 rows=100000080 width=38)
SQL
위의 결과를 보면 cost가 100줄당 약 1.7323 정도로 비례해서 증가하고 있음을 확인 할 수 있다.
cost 추정치는 (disk pages read * seq_page_cost) + (rows scanned * cpu_tuple_cost)로 계산되어 진다. 기본적으로, seq_page_cost 는 1.0 and cpu_tuple_cost 는 0.01이며 그러므로 is (358 * 1.0) + (10000 * 0.01) = 458. 와 같이 계산되어 진다. postgres 공식문서 참고
공식 문서에 따라 users 테이블을 실제로 계산해보면 다음과 같은 결과를 가진다.
SELECT relname, relkind, reltuples, relpages
FROM pg_class WHERE relname = 'users';
-- users r 1000000 7323
SQL
( 7,323 * 1 ) + ( 1,000,000 * 0.01 ) = 17323
정확하게 일치하는 것을 확인할 수 있다.
어? 그렇다면 cpu_tuple_cost와 disk pages read, seq_page_cost를 낮출 수 있다면 성능을 향상 시킬 수 있을까?
아니다. planer가 계산을 하기 위한 상대적인 수치를 위한 상수 일 뿐이지, 성능과는 관계가 없다고 한다. 참고 자료
그러니 서비스에 select문을 사용할 때, Limit에 대한 것을 항상 고려하자.
explain select * from users limit 100
Limit (cost=0.00..1.73 rows=100 width=25)
-> Seq Scan on users (cost=0.00..17323.00 rows=1000000 width=25)
SQL
explain select * from posts limit 100
Limit (cost=0.00..1.73 rows=100 width=26)
-> Seq Scan on posts (cost=0.00..173278.44 rows=10000244 width=26)
SQL
이 경우에 테이블의 수에 상관 없이 1.73만큼 비용이 발생하는데 이는 disk pages read의 비용이 발생하지 않는 만큼 줄어 들기 때문이다.
Q. where절이 조회에 얼마만큼 영향을 미칠까
explain select * from users where email = 'user_7@gmail.com'
Gather (cost=1000.00..13531.43 rows=1 width=25)
Workers Planned: 2
-> Parallel Seq Scan on users (cost=0.00..12531.33 rows=1 width=25)
Filter: ((email)::text = 'user_7@gmail.com'::text)
SQL
email을 equal 조건으로 쿼리를 날리면 다음과 같은 결과가 발생한다.
사용 가능한 Worker Process 수 (Workers Planned: 2)만큼 디스크 스캔을 병렬로 실행하는 Parallel Seq Scan가 발생했다. 병렬 실행을 위한 12531.33 를 역산하면, 프로세스 하나 당 약 520833 만큼 Scan했다는 것을 알 수 있다.
두 프로세스의 스캔 후 합산하는데 Startup Cost 가 1000으로 총 13531만큼의 비용이 들었다.
프로세스 하나가 Seq Scan 했을 때 17323에 비해서 3792만큼 최적화에 성공했다.
이 때, Row의 위치라던지 (user_999944@gmail.com) 조건에 해당하는 값이 없을 때도, 같은 결과를 가졌다.
like문이라면 어떨까
explain select * from users where email like 'user_7@gmail.com'
Gather (cost=1000.00..13541.33 rows=100 width=25)
Workers Planned: 2
-> Parallel Seq Scan on users (cost=0.00..12531.33 rows=42 width=25)
Filter: ((email)::text ~~ 'user_7@gmail.com'::text)
SQL
range 조건이 없다면 eqaul 조건과 같은 결과를 보인다.
explain select * from users where email like 'user_7@%'
Gather (cost=1000.00..13541.33 rows=100 width=25)
Workers Planned: 2
-> Parallel Seq Scan on users (cost=0.00..12531.33 rows=42 width=25)
Filter: ((email)::text ~~ 'user_7@%'::text)
explain select * from users where email like 'user_7%'
Seq Scan on users (cost=0.00..19823.00 rows=111111 width=25)
Filter: ((email)::text ~~ 'user_7%'::text)
SQL
range은 조회수에 따라 다른 결과를 보였는데, users 테이블의 수가 작아서 실제로 조회도 그러한지 확인 하기 위해 comments 테이블로 다시 한 번 조회해보았다.
explain select * from comments where body like '80312241Here some comment'
Gather (cost=1000.00..1355167.85 rows=1 width=38)
Workers Planned: 2
-> Parallel Seq Scan on comments (cost=0.00..1354167.75 rows=1 width=38)
Filter: ((body)::text ~~ '80312241Here some comment'::text)
explain select * from comments where body like '80312241Here some comment%'
Gather (cost=1000.00..1356167.75 rows=10000 width=38)
Workers Planned: 2
-> Parallel Seq Scan on comments (cost=0.00..1354167.75 rows=4167 width=38)
Filter: ((body)::text ~~ '80312241Here some comment%'::text)
explain select * from comments where body like '80312241H%'
Gather (cost=1000.00..1356167.75 rows=10000 width=38)
Workers Planned: 2
-> Parallel Seq Scan on comments (cost=0.00..1354167.75 rows=4167 width=38)
Filter: ((body)::text ~~ '80312241H%'::text)
explain select * from comments where body like '8031%'
Gather (cost=1000.00..1356167.75 rows=10000 width=38)
Workers Planned: 2
-> Parallel Seq Scan on comments (cost=0.00..1354167.75 rows=4167 width=38)
Filter: ((body)::text ~~ '8031%'::text) -- 30.337s
explain select * from comments where body like '80%'
Gather (cost=1000.00..1456177.95 rows=1010102 width=38)
Workers Planned: 2
-> Parallel Seq Scan on comments (cost=0.00..1354167.75 rows=420876 width=38)
Filter: ((body)::text ~~ '80%'::text)
explain select * from comments where body like '8%'
Seq Scan on comments (cost=0.00..2083335.00 rows=11111120 width=38)
Filter: ((body)::text ~~ '8%'::text)
SQL
조건에 부합하는 row수에 따라 explain의 결과가 다르다.
Default view
Search
결과는 신기하게도 정말 이렇게 나왔다. 정말 관련이 있는지 어떤지는 스스로의 판단에 맡기겠다.
별개로 In query 역시 비슷한 결과를 나타냈다.
explain select * from users where email = 'user_7@gmail.com'
Gather (cost=1000.00..13531.43 rows=1 width=25)
Workers Planned: 2
-> Parallel Seq Scan on users (cost=0.00..12531.33 rows=1 width=25)
Filter: ((email)::text = 'user_7@gmail.com'::text)
explain select * from users where email in ('user_999944@gmail.com', 'user_7@gmail.com', 'user_791@gmail.com')
Gather (cost=1000.00..14052.47 rows=3 width=25)
Workers Planned: 2
-> Parallel Seq Scan on users (cost=0.00..13052.17 rows=1 width=25)
Filter: ((email)::text = ANY ('{user_999944@gmail.com,user_7@gmail.com,user_791@gmail.com}'::text[]))
SQL
별개로 줄이는 것은 보이는 것과 같이 더 적은 cost를 가진다.
explain select * from comments where body like '%1'
Gather (cost=1000.00..1356167.75 rows=10000 width=38)
Workers Planned: 2
-> Parallel Seq Scan on comments (cost=0.00..1354167.75 rows=4167 width=38)
Filter: ((body)::text ~~ '%1'::text)
explain select * from comments where body like '%1' limit 1
Limit (cost=0.00..208.33 rows=1 width=38)
-> Seq Scan on comments (cost=0.00..2083335.00 rows=10000 width=38)
Filter: ((body)::text ~~ '%1'::text)
SQL
Like쿼리의 Cacheing
select * from comments where body = '17409283Here some comment '1m 43s|1m 43s
select * from comments where body like '17409283Here some comment '1m 43s| 1m 43s
select * from comments where body like '17409283%' 1m 43s|1m 43s
select * from comments where body like '1740%' 2.3s|38ms
SQL
위는 네개의 쿼리와 각각 첫번째 실행 결과와 두번 째 실행 결과를 나타낸다.
재미있는 점은 마지막 하나 이상의 like에서는 buffer_cache 가 존재한다는 점이다.
Like쿼리와 Limit
select * from comments where body like '2%' limit 1 11.2s
select * from comments where body like '3%' 19.93 s
select * from comments where body like '4%' limit 1 13.45s
select * from comments where body like '5%' 19.555s
select * from comments where body like '6%' limit 1 12.134
select * from comments where body like '7%' 12.242s
-- No Limit
Seq Scan on comments (cost=0.00..2083335.00 rows=11111120 width=38)
Filter: ((body)::text ~~ '6%'::text)
-- Limit
Limit (cost=0.00..0.19 rows=1 width=38)
-> Seq Scan on comments (cost=0.00..2083335.00 rows=11111120 width=38)
Filter: ((body)::text ~~ '6%'::text)
SQL
어느 정도 Limit이 효과가 있는 것처럼 보이나, 일관성을 보이진 않았다.
또한 쿼리 플랜도 맞지 않는 경우가 많았다.