데이터베이스 기초 Q&A — 트레이드오프 12선과 34문답
선택지 열두 개가 각각 무엇을 얻고 무엇을 내주는지, 장애 아홉 가지가 어떤 증상으로 드러나는지를 표로 모으고, 저장소 선택·모델링·SQL·트랜잭션에 걸친 34개 질문에 결론부터 답한다.
데이터베이스 설계에서 어려운 부분은 개별 기능을 모르는 것이 아니라, 아는 것들 사이에서 무엇을 내주고 무엇을 받을지 정하는 일이다. 정규화를 어디까지 밀 것인가, 인덱스를 몇 개까지 둘 것인가, 격리수준을 올릴 것인가 쿼리를 고칠 것인가 — 답이 하나로 정해지지 않는 자리들이다.
이 글은 앞 네 편의 결론을 선택지 표 · 장애 표 · 질문 형태로 다시 세운다. 저장소를 고르는 자리에 있거나, 이미 돌고 있는 시스템에서 원인 불명의 지연·불일치를 쫓고 있는 백엔드 엔지니어를 위한 글이다. 답은 결론부터 쓰고 근거를 뒤에 붙이는 순서로 적었다.
각 주제의 전체 맥락은 앞 네 편에 있다 — 관계형 데이터베이스와 NoSQL, 데이터 모델링과 정규화, SQL 실행과 인덱스, 트랜잭션과 동시성 제어.
선택지별 트레이드오프
| 선택지 | 얻는 것 | 내주는 것 | 언제 쓰나 |
|---|---|---|---|
| 정규화(3NF) | 중복 제거, 이상현상 차단, 저장공간 절약 | 조인이 늘어 조회 성능이 떨어진다 | OLTP의 기본값 |
| 반정규화 | 조인·집계 비용 절감 | 동기화를 놓치면 값이 어긋나고, 갱신 비용이 오른다 | 측정한 뒤, 조회 편중이 확인됐을 때 |
| RDBMS | 무결성과 트랜잭션 보장, 표준 SQL | 스키마 변경 비용, 수평 확장의 어려움 | 금융·커머스·기간계 |
| NoSQL | 스키마 유연성, 확장성, 조인이 없어 단순한 코드 | 일관성이 보장되지 않고, 모델만 봐서는 업무를 읽어내기 어렵다 | 로그·클릭스트림·캐시·문서형 |
| Literal SQL | (없음) | 하드 파싱 폭증, Shared Pool 단편화 | 쓰지 않는다. 값 분포가 극단적으로 치우친 배치 SQL만 예외 |
| 바인드 변수 | 실행계획 재사용, 메모리 절약, SQL Injection 방어 | 값 분포가 치우치면 엉뚱한 계획을 공유한다(bind peeking) | 기본값 |
| 인덱스 추가 | 조회 성능 향상 | INSERT·UPDATE·DELETE 비용 증가, 저장공간, 옵티마이저 선택지 증가 | 조회 패턴을 실측한 뒤 최소한으로 |
| Nested Loop | 부분범위 처리, 첫 행이 빨리 나온다 | 대량이면 랜덤 액세스가 폭증한다 | OLTP 소량 |
| Hash Join | 각 테이블을 1회만 읽어 대량에 강하다 | 등치 조인만 가능, build 테이블이 크면 디스크로 넘친다 | 배치·대량 집계 |
| 격리수준 상향 | 읽기 일관성 강화 | 동시성 저하, 락 경합과 데드락 증가 | 데이터 유실이 치명적인 트랜잭션에 한정 |
| 비관적 락 | 충돌 시 확실하게 보호된다 | 블로킹과 데드락 | 재고·좌석 선점 |
| 낙관적 락 | 대기가 없어 처리량이 높다 | 충돌 시 재시도 책임이 애플리케이션으로 온다 | 충돌이 드문 갱신 |
열두 행을 가로로 읽으면 열한 행은 교환이다 — 조회를 싸게 만들면 쓰기가 비싸지고, 보장을 강화하면 동시성이 준다. 예외가 정확히 한 행 있다. Literal SQL만 「얻는 것」 열이 비어 있다. 교환이 아니라 배제 항목이라는 뜻이고, 그래서 이 행만 「언제 쓰나」가 조건이 아니라 금지로 적혀 있다. 열두 개 중 하나가 트레이드오프가 아니라는 이 배정은 이 글의 정리다.
장애 아홉 가지 — 증상에서 원인으로
| 증상 | 실제 원인 | 어떻게 확인하나 | 대응 |
|---|---|---|---|
| DB 전체가 느려지고 CPU가 치솟는다. 특정 쿼리가 느린 것이 아니다 | Literal SQL 반복으로 하드 파싱이 폭증 | Shared Pool에 쌓인 유사 SQL 개수, 파싱 관련 대기 이벤트 | 바인드 변수 전환, ORM·MyBatis의 파라미터 바인딩 점검 |
| 인덱스가 있는데도 느리다 | 복합 인덱스 선두 컬럼이 조건에 없거나 컬럼이 가공됐다 | 실행계획에 access 없이 filter만 붙는지 | 인덱스 컬럼 순서 재설계, 조건절의 컬럼 가공 제거 |
| 어제까지 빠르던 SQL이 갑자기 느려졌다 | 통계 노후 또는 분포 변화로 실행계획이 바뀌었다 | 예상 Rows와 실제 Rows의 괴리 | 통계 갱신, 필요하면 계획 고정(Plan Baseline·힌트) |
| 배치가 TEMP 부족으로 죽는다 | 대량 정렬 또는 Hash Join의 디스크 스필 | 실행계획의 조인 방식과 TEMP 사용량 | build 테이블 축소, 메모리 파라미터 조정, 조인 순서 변경 |
| 특정 시간대에 응답이 밀리고 타임아웃이 급증한다 | 블로킹 — 긴 트랜잭션이 락을 쥐고 있다 | 락 대기 세션 트리 조회 | 트랜잭션 축소, 트랜잭션 안의 외부 호출 제거 |
Deadlock found 가 간헐적으로 뜬다 | 두 트랜잭션의 자원 접근 순서가 어긋난다 | 데드락 로그에 찍힌 두 SQL 대조 | 접근 순서를 팀 규칙으로 고정 + 재시도 |
| 배치가 데이터를 조용히 잃는다 | 「읽고 → 옮기고 → 지운다」 패턴의 Phantom Read | 처리 건수와 원본 건수 대사 | 삭제 범위를 처리한 키로 한정, 그 트랜잭션만 SERIALIZABLE |
| 재고가 마이너스로 내려간다 | 갱신 손실 — 조회와 갱신 사이의 경합 | 동시 요청 재현 테스트 | 조건절을 단 갱신 또는 버전 컬럼, 그리고 rowcount 확인 |
| 마이그레이션 뒤 값 불일치가 발견된다 | 반정규화 컬럼의 동기화 로직 누락 | 원본과 사본 컬럼의 대사 쿼리 | 동기화 주체를 문서에 명시, 정합성 점검 배치 |
아홉 행을 「무엇으로 알아차리는가」로 배정하면 두 무리로 갈린다. 여섯 행은 느려지거나 에러를 내서 스스로 드러나고(16행), 세 행은 아무 신호도 내지 않는다(79행). 뒤의 셋은 SQL이 전부 정상 종료했고 로그에도 아무것도 남지 않는다. 그래서 앞의 여섯은 모니터링이 잡아 주지만 뒤의 셋은 대사(對査) 쿼리를 따로 돌리지 않으면 영원히 모른다. 여섯과 셋으로 가른 이 배정은 이 글의 정리다.
저장소와 용어 — 4문답
| 질문 | 결론 |
|---|---|
| DB와 DBMS는 무엇이 다른가 | DB는 공유·통합을 목적으로 관리되는 데이터의 집합이고, DBMS는 여러 사용자가 그 집합을 쓰게 해 주는 소프트웨어다 |
| 관계형 모델에서 「관계」는 물리적으로 어디에 저장되나 | 별도의 객체가 아니라 자식 테이블의 외래키 컬럼 값이다. 그래서 M:M은 교차 테이블로 분해하지 않으면 저장할 자리가 아예 없다 |
| NoSQL 네 유형은 각각 어디에 맞나 | Key-Value는 캐시·세션, Column은 분석·시계열, Document는 스키마가 자주 바뀌는 앱, Graph는 관계 탐색이다. 강점이 저장 구조가 아니라 접근 패턴에서 나온다 |
| 저장소를 무엇으로 고르나 | 문제 해결·비용·유지·보수 세 축이다. 특히 세 번째가 장애 복구 시간을 결정한다 — 운영 인력이 다루지 못하는 제품은 성능과 무관하게 위험하다 |
네 유형이 왜 그 영역에 자리 잡았는지, 그리고 세 축을 회의에서 쓸 수 있는 질문 형태로 편 표는 관계형 데이터베이스와 NoSQL의 「NoSQL 네 유형」과 「DBMS를 고르는 세 기준」 절에 있다.
모델링과 정규화 — 6문답
| 질문 | 결론 |
|---|---|
| 정규화를 하면 무엇이 좋아지나 | 입력·삭제·수정 이상현상이 사라진다. 무결성과 일관성이 목적이고 저장공간 절약은 부수 효과다 |
| 1NF·2NF·3NF를 한 줄로 정의하면 | 1NF는 모든 속성이 단일 값, 2NF는 비식별자 속성이 식별자 전체에 종속, 3NF는 비식별자 속성끼리의 종속 제거다 |
| BCNF는 3NF와 어디가 다른가 | 3NF를 만족해도 후보키가 아닌 결정자가 남을 수 있다. BCNF는 결정자가 예외 없이 후보키일 것을 요구한다 |
| 반정규화는 언제 하나 | 순서가 규칙이다 — 먼저 정규화하고, 실행계획과 응답시간으로 측정한 뒤, 중복 값을 누가 맞출지 정하고 나서 한다 |
| 1:1·1:M·M:M을 물리 모델로 어떻게 옮기나 | 1:1은 외래키에 UNIQUE를 걸고, 1:M은 M쪽에 외래키를 두며, M:M은 교차 테이블이 필수다 |
| 데이터 표준화는 왜 하나 | 이름 통일이 목적이 아니라 접미어(도메인)로 타입과 길이를 예측 가능하게 만드는 일이다. 분석 전 Clean 작업이 전체 시간의 70%를 먹는 문제를 줄인다 |
정규형을 1NF부터 5NF까지 한 줄 정의·위반 예·해소 방법으로 편 표와, 실무 기준선이 왜 3NF에서 멈추는지는 데이터 모델링과 정규화의 「정규형 단계별 비교」 절에 있다. 반정규화 카드 다섯 개가 각각 무슨 대가를 지불하는지도 같은 글의 「반정규화를 언제 하는가」 절에 표로 있다.
SQL·인덱스·실행계획 — 10문답
| 질문 | 결론 |
|---|---|
| SQL 처리 3단계는 무엇인가 | Parse → Execute → Fetch다. Parse 안에서 문법·의미 검사, 옵티마이저의 계획 수립, 바인드가 차례로 일어난다 |
| 하드 파싱과 소프트 파싱은 왜 중요한가 | 실행계획을 새로 만드느냐 캐시된 것을 쓰느냐의 차이다. 하드 파싱이 반복되면 개별 쿼리가 아니라 인스턴스 전체가 CPU를 태운다 |
| 바인드 변수를 왜 쓰나 | 계획 재사용, Shared Pool 단편화 방지, SQL Injection 차단 세 가지다. 다만 캐시 키가 SQL 텍스트라 대소문자와 공백까지 같아야 공유된다 |
| DB 접근 횟수를 줄이는 SQL 기법은 | CASE·DECODE, MERGE, Multi Table Insert, 분석함수다. 공통점은 같은 결과를 테이블 1회 읽기로 끝낸다는 것이다 |
| B-Tree 인덱스는 어떤 구조인가 | 루트-브랜치-리프 계층이고, 리프에 정렬된 키와 ROWID가 담기며 리프끼리 양방향으로 이어진다. 리프 깊이가 모두 같아 어느 키든 탐색 비용이 일정하다 |
| 인덱스가 있는데 안 타는 경우는 | 컬럼 가공·연산, 묵시적 형변환, 부정형 조건, 선행 % LIKE, 선두 컬럼 누락이 대표적이다. 여기에 낮은 선택도가 하나 더 있는데 이것만은 정상 판단이다 |
| 복합 인덱스 컬럼 순서는 어떻게 정하나 | 등치 조건 컬럼을 앞에, 범위 조건을 뒤에 둔다. 선두 컬럼이 조건에 없으면 Range Scan 자체가 성립하지 않는다 |
| 실행계획에서 무엇을 먼저 보나 | Cost 값이 아니라, 조건이 인덱스로 찾아간 것(access)인지 읽어온 뒤 버린 것(filter)인지다. 버려지는 조건을 인덱스 쪽으로 넘기는 작업이 튜닝이다. 예상 Rows와 실제 Rows가 크게 어긋나면 통계를 의심한다 |
| NL·Sort Merge·Hash 조인을 비교하면 | NL은 소량에 강하고 inner 인덱스가 필수이며 부분범위가 가능하다. Sort Merge는 부등호 조인이 되지만 정렬 비용을 낸다. Hash는 등치만 되는 대신 각 테이블을 1회만 읽는다 |
| Hash Join이 Sort Merge를 밀어낸 이유는 | 정렬과 랜덤 액세스를 둘 다 없앴기 때문이다. 대가는 등치 조인으로 제한된다는 점이다 |
인덱스를 못 타는 케이스 여덟 가지를 「왜 못 타나」와 대응까지 붙여 편 표는 SQL 실행과 인덱스의 「인덱스를 못 타는 대표 케이스」 절에 있고, 세 조인 방식을 열 개 항목으로 비교하면서 각각의 실패 모드까지 적은 표는 같은 글의 「세 방식 비교」 절에 있다. 실행계획에서 access 와 filter 를 각각 무엇으로 읽는지는 「실행계획 읽는 법」 절이다.
트랜잭션과 동시성 — 10문답
| 질문 | 결론 |
|---|---|
| ACID는 각각 무엇으로 구현되나 | 원자성은 Undo, 일관성은 제약조건, 격리성은 Lock과 MVCC, 지속성은 Redo와 WAL이다 |
| 격리수준 4단계와 각 단계의 이상현상은 | READ UNCOMMITTED는 셋 다 허용, READ COMMITTED는 Dirty Read만 막고, REPEATABLE READ는 Phantom만 남기며, SERIALIZABLE은 셋 다 막는다. 기본값은 대체로 READ COMMITTED이고 MySQL InnoDB만 한 단계 위다 |
| Phantom Read는 무엇이 다른가 | 값이 흔들리는 층위가 다르다. Non-Repeatable은 이미 읽은 행 하나의 값이 달라지는 것이고, Phantom은 조건에 걸리는 행의 개수가 달라지는 것이다. 그래서 앞은 행을 잠가 막고 뒤는 범위를 잠가야 막힌다 |
| MVCC란 무엇이고 대가는 무엇인가 | Undo로 시점별 스냅샷을 만들어 읽기가 쓰기를 막지 않게 한다. 대가는 Undo 유지 비용과, 트랜잭션이 길어지면 옛 이미지가 이미 재사용돼 복원에 실패하는 문제다 |
| 읽는 시점을 무엇으로 잡나 | SELECT는 실행 시점을 고정해서 읽고(Consistent), DML은 최종 커밋된 값을 읽고 쓴다(Current). 다만 갱신 대상을 골라내는 단계는 고정된 시점을 쓰므로, 이 어긋남이 갱신 실패를 만든다 |
| 비관적 락과 낙관적 락 중 무엇을 쓰나 | 충돌 빈도로 갈린다. 재고·좌석은 비관적으로 잠그고, 일반 수정은 버전 컬럼으로 낙관적으로 간다. 어느 쪽이든 rowcount 확인이 빠지면 둘 다 무의미하다 |
| S Lock과 X Lock은 언제 충돌하나 | S-S 조합만 호환된다. 나머지 세 조합은 모두 대기가 걸린다 |
| Intention Lock은 왜 필요한가 | 테이블 락을 요청할 때 하위 수백만 행을 일일이 볼 수 없기 때문이다. 상위 노드에 의도를 표시해 충돌 판정을 상수 시간으로 만든다 |
| 데드락은 어떤 조건에서 나고 어떻게 피하나 | 상호 배제·점유와 대기·비선점·순환 대기가 모두 성립해야 난다. 실무에서 가장 먼저 손대는 것은 순환 대기를 깨는 쪽 — 자원 접근 순서를 전역 규칙으로 정하는 일이다 |
| 블로킹과 데드락은 무엇이 다른가 | 블로킹은 기다리면 언젠가 풀리는 정상 동작이고, 데드락은 개입 없이는 풀리지 않아 DBMS가 한쪽을 강제 종료한다 |
격리수준 4단계를 이상현상 3종과 교차시킨 표, 그리고 그 표를 가로로 읽었을 때 나오는 실무 판단은 트랜잭션과 동시성 제어의 「격리수준 4단계 교차표」 절에 있다. 데드락 4조건을 각각 어떻게 깨는지까지 적은 표는 같은 글의 「Coffman 4조건」 절이고, Consistent 모드가 Undo에서 옛 이미지를 복원하는 분기는 「Current 모드와 Consistent 모드」 절에 도식으로 있다.
여기까지가 30문항이다. 저장소 4 · 모델링 6 · SQL 10 · 트랜잭션 10으로 배정했고, 남은 네 개는 한 줄로 답할 수 없어 아래에 따로 뒀다. 주제별로 묶고 개수를 배정한 것은 이 글의 정리다 — 원 자료는 이 질문들을 1번부터 차례로 번호를 매긴 한 장의 표로 두고 있다.
Q. 인덱스를 추가했더니 오히려 느려졌다. 어디부터 보나
그 인덱스가 쓰이고 있는지부터 확인한다. 「인덱스를 만들었다」와 「인덱스를 탄다」는 다른 사건이고, 둘을 구분하지 않으면 진단이 첫 단계에서 갈라진다.
| 순서 | 확인 | 그렇다면 |
|---|---|---|
| 1 | 실행계획에 그 인덱스가 등장하는가 | 등장하지 않으면 2번으로, 등장하는데 느리면 3번으로 |
| 2 | 선두 컬럼이 조건에 있는가, 컬럼이 가공됐는가, 타입이 어긋나 묵시적 형변환이 걸렸는가 | 세 가지 중 하나다. 조건절을 고치면 해결된다 |
| 3 | 전체의 30% 이상을 읽어야 하는 조건인가 | 그렇다면 옵티마이저가 인덱스를 버린 것이 정상 판단이다. 랜덤 액세스 비용이 통째 읽기를 넘어선다 |
| 4 | 그 테이블에 쓰기가 많은가 | 인덱스 하나마다 INSERT·UPDATE·DELETE에 갱신 비용이 붙는다. 조회 이득보다 쓰기 손해가 클 수 있다 |
| 5 | 이미 인덱스가 여러 개인가 | 선택지가 늘수록 옵티마이저가 틀린 계획을 고를 확률도 오른다 |
3번이 가장 자주 오해받는 자리다. 실행계획에 Full Scan이 보인다는 이유로 인덱스를 더 만들면, 옵티마이저가 이미 옳게 내린 판단을 되돌리려 애쓰는 셈이 된다.
4번과 5번은 성격이 다르다. 1~3번이 「이 쿼리가 왜 느린가」를 묻는다면, 이 둘은 인덱스를 추가한 대가를 다른 쿼리들이 나눠 내고 있다는 이야기다. 그래서 인덱스는 조회 패턴을 실측한 뒤 최소한으로 만들고, 새로 만들기 전에 기존 인덱스에 컬럼을 붙여 덮을 수 있는지를 먼저 본다. 컬럼 추가는 인덱스 개수를 늘리지 않으므로 4번과 5번의 비용이 발생하지 않는다.
Q. 재고가 마이너스로 떨어진다. 어디를 고치나
검사와 차감을 한 문장으로 합친다. 원인은 갱신 손실이다. 재고를 읽어서 애플리케이션이 판정하고 다시 쓰는 사이에 다른 트랜잭션이 끼어들면, 두 요청 모두 「재고가 남았다」고 결론 내고 각각 차감한다.
UPDATE item_stock
SET remain_qty = remain_qty - :orderQty
WHERE item_id = :itemId
AND remain_qty >= :orderQty;DBMS는 갱신 시점에 Current 모드로 조건을 다시 확인하므로, 경합이 있어도 조건을 만족하지 못한 요청은 0건 갱신으로 끝난다. 애플리케이션은 영향 행 수가 0이면 품절로 처리하면 된다.
한 문장으로 합칠 수 없을 만큼 판정 로직이 복잡한 경우의 대안은 아래와 같다.
| 방법 | 무엇으로 막나 | 대가 |
|---|---|---|
| 조건절 갱신 | DBMS가 갱신 직전에 조건을 재확인한다 | 판정 로직이 SQL 안에 들어가야 한다 |
| 비관적 락 | 대상 행을 미리 잠가 다른 트랜잭션을 대기시킨다 | 블로킹, 그리고 접근 순서가 어긋나면 데드락 |
| 낙관적 락 | 버전 컬럼을 조건에 넣어 충돌을 갱신 실패로 바꾼다 | 충돌 시 재시도 책임이 애플리케이션에 온다 |
세 방법의 공통 조건이 하나 있다. 영향 행 수가 0인 경우를 반드시 처리해야 한다. 세 방법 모두 충돌을 「에러」가 아니라 「0건 갱신」으로 알리기 때문에, 반환값을 보지 않으면 실패가 성공처럼 지나간다. 실제 사고의 상당수가 락을 안 걸어서가 아니라 이 반환값을 안 봐서 난다.
여기까지 해도 남는 문제가 있다. 인기 상품 하나에 요청이 몰리면 그 행 자체가 직렬화 지점이 된다. 락이 정확하게 동작할수록 대기 줄이 길어지는 구조라, 정확성 문제가 처리량 문제로 바뀔 뿐이다. 그때는 재고를 여러 행으로 쪼개거나, 인메모리 카운터로 선점만 먼저 처리하고 DB에는 확정분만 반영하는 구조로 넘어간다. 후자의 구현과 그 한계는 캐시 무효화와 동시성의 「분산 락」 절에 있다 — 그 글은 Redis 락을 세운 뒤 정합성의 최종 확정은 DB로 넘긴다는 결론으로 끝난다.
⚠️ 그 글의 「락」과 이 글의 「락」은 다른 물건이다. 그쪽은 만료 시간을 건 Redis 키라 시간 가정 위에 서 있고, 이쪽은 트랜잭션 경계에 묶인 DBMS의 행 잠금이다.
Q. SERIALIZABLE이 가장 안전한데 왜 기본값은 READ COMMITTED인가
대부분의 트랜잭션에는 그만한 보장이 필요 없고, 그 보장은 범위 단위로 값을 치르기 때문이다. SERIALIZABLE은 읽은 데이터의 갱신뿐 아니라 그 범위에 새 행이 들어오는 것까지 막는다. 잠기는 단위가 행에서 범위로 커지면 동시 처리량이 떨어지고, 락 경합이 늘면서 블로킹과 데드락 발생률도 함께 오른다.
| 트랜잭션의 모양 | 필요한 것 | 왜 |
|---|---|---|
| 단건 조회 후 단건 갱신 | 조건절 하나 | 상태 값을 조건에 넣으면 경합이 갱신 실패로 드러난다 |
| 같은 행을 두 번 읽고 그 차이에 의존 | REPEATABLE READ | 값이 중간에 바뀌면 안 된다 |
| 범위를 읽어 옮기고 원본을 지운다 | 범위 고정 | 읽은 뒤 들어온 행까지 지워진다 |
세 번째가 실제로 사고가 나는 자리다. 「읽고 → 옮기고 → 지운다」 순서로 도는 배치는 두 SQL이 모두 정상 종료하기 때문에 로그에 아무 흔적도 남지 않은 채 데이터가 사라진다.
그래서 처방은 전역 설정을 올리는 쪽이 아니다. 문제가 되는 트랜잭션을 특정한 뒤 둘 중 하나를 쓴다. 하나는 그 트랜잭션에 한해 격리수준을 최상위로 지정하는 것이고, 다른 하나는 지울 대상을 아래처럼 못 박는 것이다.
-- 옮긴 키만 지운다 — 격리수준이 무엇이든 범위가 흔들리지 않는다
DELETE FROM staging_tbl
WHERE staging_id IN (:movedIds);뒤쪽이 대체로 낫다. 격리수준을 올린다는 것은 읽은 범위를 DBMS가 트랜잭션 종료까지 붙들고 있게 만드는 선택이다. 대상을 못 박는다는 것은 그 정보를 애플리케이션이 이미 갖고 있으니 DBMS에 짐을 지우지 않겠다는 뜻이다. 옮긴 키 목록이 배치 손에 남아 있다면 뒤쪽이 성립하고, 치르는 성능 대가도 훨씬 작다.
Q. 느린 쿼리는 안 보이는데 서비스 전체가 느리다. 무엇을 의심하나
개별 쿼리가 다 빠른데 전체가 느리면 원인은 쿼리 실행이 아니라 그 앞뒤에 있다. 후보는 두 개고, 확인 방법이 서로 다르다.
| 후보 | 무엇이 일어나나 | 어떻게 확인하나 | 조치 |
|---|---|---|---|
| 파싱 부하 | 값이 박힌 SQL이 반복돼 매번 실행계획을 새로 만든다. Shared Pool이 재사용 불가능한 SQL로 차면서 자주 쓰는 계획이 밀려난다 | 캐시에 올라간 SQL 중 앞부분이 같고 상수만 다른 것이 수천 개 쌓였는지 본다 | 해당 코드를 바인드 변수로 전환 |
| 락 경합 | 트랜잭션 안의 외부 호출이 락 보유 시간을 네트워크 지연만큼 늘리고, 그 뒤로 대기가 쌓인다 | 락 대기 세션 트리에서 선두 세션이 무엇을 하고 있는지 본다 | 트랜잭션 경계를 다시 긋는다 |
두 후보를 구분하는 기준은 CPU가 타는가 아니면 노는가다. 파싱 부하는 CPU를 태우고, 락 경합은 CPU가 한가한데도 응답이 밀린다. 이 한 가지만 봐도 절반이 갈린다.
파싱 부하가 확인됐는데 ORM을 쓰고 있다면 두 자리를 본다. 동적 쿼리를 문자열로 조립하는 구간, 그리고 IN 절 파라미터 개수가 요청마다 달라지는 구간이다. 후자는 프레임워크가 바인딩을 제대로 하고 있어도 발생한다 — 개수가 다르면 SQL 텍스트 자체가 달라지기 때문이다.
락 경합 쪽은 SQL 튜닝으로 풀리지 않는다는 점이 중요하다. 쿼리는 원래 빨랐고, 문제는 그 쿼리가 불필요하게 긴 트랜잭션 안에 들어 있다는 것이다. 트랜잭션 경계 안에서 외부 API를 부르거나 파일을 만지거나 사람의 응답을 기다리는 코드를 밖으로 빼는 것이 처방이고, 이것은 쿼리를 한 글자도 건드리지 않는 작업이다.
정리
이 시리즈를 관통하는 판단 기준은 세 개다.
| 기준 | 어디에 적용되나 |
|---|---|
| 읽기와 쓰기 중 어느 쪽이 값을 치르나 | 정규화와 반정규화, 인덱스 개수, 조인 방식 |
| 보장을 DBMS에 맡길 것인가 애플리케이션이 질 것인가 | 격리수준, 비관적·낙관적 락, 삭제 범위 고정 |
| 실패했을 때 알아차릴 수 있나 | 장애 표의 뒤쪽 세 행, rowcount 확인, 반정규화 컬럼의 동기화 |
세 번째가 가장 늦게 배운다. 앞의 두 기준은 잘못 고르면 느려지거나 에러가 나서 스스로 드러나지만, 세 번째를 놓치면 시스템은 아무 문제 없이 계속 돌면서 데이터만 조금씩 틀어진다. 격리수준을 잘못 잡은 배치도, rowcount를 안 본 재고 차감도, 동기화를 빠뜨린 반정규화 컬럼도 전부 정상 종료한다.
그래서 설계를 끝낼 때 마지막으로 물어야 할 것은 「이게 빠른가」가 아니라 **「이게 틀렸을 때 무엇이 그것을 알려 주는가」**다. 답이 없으면 그 자리에 대사 쿼리나 검증 배치를 하나 만들어 둔다. 몇 달 뒤에 어긋난 값의 출처를 거슬러 올라가는 것보다 그 편이 싸다.