한 줄 정의
DB 성능 문제의 대부분은 DB 자체가 아니라 조회 패턴과 트래픽 규모를 고려하지 않은 테이블 설계와 쿼리에서 발생합니다.
쉽게 말하면
장서를 관리하는 도서관 사서를 떠올리면 이 장의 기법들이 하나로 꿰어집니다.
책 제목 하나를 찾자고 서가를 처음부터 끝까지 훑는 것이 풀 스캔 입니다. 장서가 500권일 때는 그렇게 해도 아무도 눈치채지 못합니다. 문제는 장서가 1,000만 권이 되고 동시에 찾는 사람이 늘어난 순간에야 드러납니다. 설계할 때 저지른 실수가 몇 년 뒤에 청구되는 구조입니다.
그래서 색인 카드(인덱스) 를 만듭니다. 다만 카드는 사람들이 실제로 찾는 방식대로 만들어야 합니다. 다들 저자로 찾는데 출판사별 색인만 있으면 카드가 있어도 결국 서가를 훑게 됩니다. 게다가 카드를 무한정 만들면 책 한 권이 들어올 때마다 모든 카드를 갱신해야 하므로, 카드는 꼭 필요한 만큼만 만드는 게 맞습니다.
여기서 한 걸음 더 나아간 요령들이 이어집니다. 카드에 쪽수까지 적어 두면 책을 꺼내지 않고도 답할 수 있고(커버링 인덱스), 대출 횟수를 매번 대출 기록을 세는 대신 표지에 적어 두면 셀 일이 없어집니다(미리 집계). “1만 번째 책부터 10권”이라는 요구는 결국 1만 권을 세면서 가야 하지만, “지난번에 본 책 다음부터 10권”이면 그 자리로 바로 갈 수 있습니다(ID 기준 조회).
그래도 감당이 안 되면 서가를 넓히거나(장비 확장), 사서를 한 명 더 두거나(복제 DB), 자주 나가는 책은 대출대 옆에 따로 빼 둡니다(캐시 서버). 그리고 아무도 안 찾는 오래된 자료는 서고로 옮겨 서가 자체를 일정한 크기로 유지합니다.
왜 중요한가?
DB는 여러 서버가 공유하는 단일 자원 입니다. 애플리케이션 서버는 느려져도 그 서버의 요청만 느려지지만, DB가 느려지면 연결된 모든 서버의 응답 시간이 함께 늘어나고 결국 전체 서비스가 먹통이 됩니다.
교사와 학생을 대상으로 하던 H 서비스가 그 전형입니다. 코로나 시기에 가입자가 늘고 개학과 함께 출석 체크 시간인 8시 40분에 트래픽이 몰리자, 응답 시간이 10초를 넘어 에러가 발생할 정도로 느려졌습니다. 원인은 호출 빈도가 높은 기능에 풀 스캔을 유발하는 쿼리가 있었던 것이고, 데이터가 적을 때는 드러나지 않다가 데이터와 트래픽이 함께 늘면서 DB CPU 사용률이 90%를 넘겼습니다.
주목할 점은 DB 자체가 문제인 상황은 많지 않다 는 것입니다. 대부분은 DB를 잘못 사용해서 생긴 문제이고, 그래서 DB 전문가가 아니어도 서버 개발자가 조금만 신경 쓰면 상당 부분을 줄이거나 없앨 수 있습니다.
풀 스캔(full scan)
테이블의 모든 데이터를 순차적으로 읽는 것입니다. where 절 조건에 대응하는 인덱스가 없을 때 발생하고, 인덱스를 타는 것보다 전체를 훑는 게 더 빠르다고 판단될 때도 발생합니다. 데이터가 적을 때는 성능 문제가 겉으로 드러나지 않다가 개수가 늘면 어느 순간 응답 시간이 기하급수적으로 증가 합니다.
핵심 내용
인덱스는 조회 패턴과 트래픽 규모가 결정한다
같은 스키마, 같은 쿼리라도 인덱스가 필요한지는 상황이 정합니다. 카테고리별 게시글 목록을 보여 주는 게시판에서 where category = 10 쿼리를 실행한다고 할 때 판단이 갈리는 지점을 보면 명확합니다.
| 사내 공지 게시판 | 인기 커뮤니티 게시판 | |
|---|---|---|
| 데이터 개수 | 주 1건 × 10년 = 520건 | 하루 5,000건 × 6년 = 1,000만 건 |
| 트래픽 | 직원 1,000명이 5분 내 조회 → 6.67 TPS | 다수 사용자가 동시에 목록 조회 |
| 판단 | 풀 스캔해도 문제 없음, 인덱스 불필요 | 풀 스캔이 동시에 발생해 DB CPU 100% 도달 |
즉 인덱스를 걸지 말지는 취향이 아니라 데이터 개수와 TPS를 곱해 본 결과 로 결정합니다. 반대로 조회 패턴이 존재한다면 그 패턴을 기준으로 인덱스를 설계해야 하며, “내가 작성한 글 목록”처럼 writerId 로 거르는 기능이 있다면 그 칼럼을 포함한 인덱스가 필요합니다.
문자열 검색은 인덱스로 풀리지 않습니다
where title like '%검색어%'처럼 중간에 포함된 단어를 찾는 조건은 풀 스캔을 유발합니다. 엘라스틱서치 같은 검색 엔진을 쓰는 게 정석이지만, 별도 엔진을 구성하기 어렵다면 Oracle Text나 MySQL FULLTEXT 같은 Ch08-5 전문 검색 인덱스 로 풀 스캔 없이 문자열 검색을 실행할 수 있습니다.
단일 인덱스와 복합 인덱스
사용자 활동 로그를 담는 activityLog 테이블에 where userId = 123 and activityDate = '2024-07-31' 을 실행한다고 할 때, userId 단일 인덱스로 충분한지 (userId, activityDate) 복합 인덱스가 필요한지는 사용자 한 명이 가질 수 있는 데이터 개수 로 갈립니다.
| 사용자당 데이터 | 판단 |
|---|---|
| 주 1회 방문 × 5건 × 5년 ≒ 1,500건 | userId 단일 인덱스로 충분. 몇천 건 비교는 0.1초 이내 |
| 매일 방문 × 30건 × 1년 ≒ 1만 건 이상 | (userId, activityDate) 복합 인덱스 필요 |
기준은 인덱스 이름이나 칼럼 개수가 아니라 인덱스로 걸러낸 뒤 실제로 읽어야 하는 행이 몇 개인가 입니다. 이 관점을 잡아 두면 복합 인덱스를 언제 늘려야 하는지도 같은 방식으로 판단할 수 있습니다.
선택도가 낮아도 인덱스가 맞는 경우가 있다
선택도(selectivity) 는 해당 칼럼이 가지는 고유 값의 비율입니다. 회원 테이블의 gender 가 M/F/N 세 값뿐이고 M이 50만 개, F가 50만 개, N이 천 개라면, where gender = 'F' 로 걸러도 여전히 50만 개를 확인해야 하므로 인덱스 효율이 떨어집니다.
그런데 작업 큐 테이블의 status 는 W(대기)/P(처리 중)/C(완료) 세 값뿐이라 선택도가 똑같이 낮은데도 인덱스가 반드시 필요합니다. 대부분의 데이터가 C이고 실제 쿼리는 where status = 'W' 만 조회하는 데다, 작업 실행기가 이 쿼리를 반복해서 실행 하기 때문입니다. 인덱스가 없으면 이 쿼리가 매번 풀 스캔을 일으켜 모든 작업 실행이 지연됩니다.
정리하면 선택도는 참고 지표일 뿐이고, 실제 기준은 그 쿼리가 걸러낸 뒤 읽어야 할 행의 개수 입니다.
커버링 인덱스
쿼리를 실행하는 데 필요한 칼럼이 모두 인덱스에 들어 있으면 실제 데이터를 읽는 과정이 통째로 생략됩니다. (activityDate, activityType) 인덱스가 있을 때 두 쿼리의 차이가 그것입니다.
-- 인덱스로 위치만 찾고 실제 데이터는 읽어 와야 한다
select * from activityLog
where activityDate = '2024-07-31' and activityType = 'VISIT';
-- 필요한 값이 모두 인덱스에 있으므로 실제 데이터에 접근하지 않는다
select activityDate, activityType from activityLog
where activityDate = '2024-07-31' and activityType = 'VISIT';인덱스로 데이터를 선택하는 과정이 빠른 것과, 선택한 데이터를 읽어 오는 과정이 필요한 것은 별개입니다. 커버링 인덱스는 후자를 없애는 기법입니다.
인덱스는 필요한 만큼만
인덱스는 조회를 빠르게 해 주는 대신 추가·변경·삭제할 때마다 관리 비용이 붙고, 인덱스 자체도 데이터이므로 메모리와 디스크 사용량이 함께 늘어납니다. (userId, activityDate) 가 이미 있는 상태에서 (userId, activityDate, activityType) 을 추가하는 것은 한 사용자가 하루에 만드는 데이터가 조회 성능에 영향을 줄 만큼 많을 때만 의미가 있습니다.
새 쿼리가 기존 인덱스를 못 쓴다면 인덱스를 늘리기 전에 요구사항을 조금 바꿔 볼 수 있는지 부터 검토하는 편이 낫습니다. reserveDate 인덱스만 있는 예약 테이블에서 예약자 이름으로 조회하면 풀 스캔이지만, 요구사항을 “특정 일자에 예약한 예약자 이름으로 조회”로 바꾸면 기존 인덱스로 처리됩니다.
같은 칼럼에 대한 인덱스를 또 추가하지 않기
오래 운영된 테이블은 담당자가 바뀌면서 어떤 인덱스가 왜 추가됐는지가 소실되고, 결국 같은 칼럼을 쓰는 인덱스가 중복으로 쌓입니다. 동일한 칼럼의 인덱스가 2개 있다고 조회 성능이 두 배가 되지는 않고 관리 비용만 늘어납니다. 새 인덱스를 추가하기 전에 기존 인덱스 목록부터 확인해야 합니다.
인덱스 밖의 조회 성능 개선
미리 집계하기
설문 목록에 답변 수와 ‘좋아요’ 수를 함께 보여 주려고 select 절에 서브 쿼리를 넣으면, 설문 30개를 조회할 때 논리적으로 61번의 쿼리가 실행됩니다.
쿼리 시간 = 목록 조회 0.01 + (답변 수 0.1 × 30) + (좋아요 수 0.05 × 30) = 4.51초
해법은 단순합니다. survey 테이블에 answerCnt, likedCnt 칼럼을 두고 답변이 등록될 때나 ‘좋아요’를 누를 때마다 값을 증감시키면, 목록 조회에서 서브 쿼리가 사라집니다.
이것은 비정규화이므로 값이 어긋날 수 있습니다. 다만 answer 테이블의 실제 개수가 10,150인데 answerCnt 가 10,149인 것은 목록을 보는 사용자에게 중요한 문제가 아니고, 정확한 값이 필요한 관리 보고서는 언제든 원본 테이블로 다시 구할 수 있습니다. 실시간 집계 칼럼은 이 정도 불일치를 감수하고 얻는 이득이 훨씬 큽니다.
증감 쿼리의 원자성은 확인이 필요합니다
update survey set answerCnt = answerCnt + 1 where surveyId = 1을 5개 클라이언트가 동시에 실행했을 때 값이 정확히 5 증가하는지는 트랜잭션 격리 수준과 DBMS에 따라 다릅니다. 증가/감소 쿼리를 쓸 때는 지정한 격리 수준에서 원자적으로 처리되는지 반드시 검증해야 합니다.
페이지 기준 대신 ID 기준으로 목록을 조회하기
limit 10 offset 99990 을 실행할 때 DB는 어떤 id가 99,991번째인지 알지 못합니다. 그래서 id를 역순으로 99,990개 세고 나서야 10개를 읽습니다. 세는 시간이 그대로 실행 시간에 더해지는 구조입니다.
여기에 where deleted = false 처럼 인덱스에 없는 칼럼이 조건으로 붙으면 더 나빠집니다. 각 행의 deleted 값을 읽어야 하므로 실제 데이터 접근이 발생하고, 삭제된 데이터도 섞여 있으므로 10,000개를 세기 위해 조회하는 행 수는 10,000개를 넘어갑니다.
flowchart LR subgraph A["offset 방식"] A1["id 역순으로<br/>99,990개를 센다"] --> A2["그다음<br/>10개를 읽는다"] end subgraph B["ID 기준 방식"] B1["인덱스에서 id < 9985<br/>위치로 바로 이동"] --> B2["10개를 읽는다"] end
앞 페이지에서 마지막으로 읽은 id를 조건으로 넘기면 세는 과정 자체가 사라집니다.
select * from article
where id < 9985 and deleted = false
order by id desc limit 10;id는 주요 키이므로 DB는 9985보다 작은 9984를 바로 찾아갈 수 있습니다. 페이지 번호 대신 마지막 ID를 주고받는 방식이라 스크롤로 다음 데이터를 읽는 모바일 화면과 특히 잘 맞습니다. 다음 데이터가 더 있는지 알려 줘야 한다면 limit 11 로 하나만 더 읽어 판단하면 됩니다.
조회 범위를 시간 기준으로 제한하기
조회 대상 자체를 줄이는 방법입니다. 뉴스 기사 목록을 일자별로 나눠 제공하거나, 쇼핑몰 주문 내역을 30개씩 순서대로가 아니라 월별로 조회하게 만들면 쿼리가 단순해지고 필요한 인덱스도 (customerId, orderDt) 정도로 명확해집니다.
한 걸음 더 나아가 최신 데이터만 제공 하는 것도 방법입니다. 매달 제공하는 점검 결과는 최근 6개월치만 있어도 되고, 3년 전 공지사항은 사실상 아무도 읽지 않습니다. 구글의 최근 보안 활동 화면도 최근 28일 데이터만 보여 줍니다. 기능을 줄여 구현을 단순화할 수 있는지 제품 담당자와 협의해 볼 가치가 있습니다.
부수 효과도 큽니다. DB는 조회한 데이터를 메모리에 캐시하는데, 조회 범위를 최신 데이터로 좁히면 캐시에 적재될 확률이 높아져 적중률과 응답 속도가 함께 올라갑니다.
전체 개수 세지 않기
목록 화면은 대개 전체 개수를 함께 표시하고, 그러려면 목록 쿼리와 별개로 count(*) 쿼리를 실행해야 합니다. 문제는 count가 조건에 해당하는 모든 데이터를 탐색 한다는 점입니다. 커버링 인덱스를 쓰더라도 전체 인덱스를 스캔해야 하고, 커버링 인덱스가 아니면 실제 데이터를 전부 읽어야 합니다.
조회 속도가 조금씩 느려지는 원인이 여기 있다면 화면에서 전체 개수를 빼는 방향 으로 협의하는 것이 가장 확실한 해결책입니다.
오래된 데이터 삭제 및 분리 보관하기
데이터 개수가 늘면 실행 시간도 늘어납니다. 뒤집으면 데이터 개수를 일정하게 유지하면 실행 시간도 일정하게 유지됩니다.
로그인 시도 내역이 대표적입니다. 이상 징후를 탐지하려는 목적이므로 몇 년 전 패턴은 필요 없고 최근 몇 달치면 충분합니다. 필요한 데이터가 최근 180일치라면 매일 181일 이전 데이터를 삭제해 개수를 일정 수준으로 유지할 수 있습니다. 내부 관리 시스템에서 과거 데이터가 필요하다면 서비스 DB에는 180일치만 두고 그 이전은 별도 저장소로 분리 보관합니다.
단편화와 최적화
DELETE로 데이터를 지워도 DB가 사용하는 디스크 용량은 줄지 않습니다. 삭제 표시만 남기고 공간은 나중에 재사용합니다. 다만 추가·변경·삭제가 반복되면 데이터가 흩어져 저장되는 단편화(fragmentation) 가 생기고, 심해지면 디스크 I/O가 증가해 쿼리 성능이 떨어지고 실제 데이터보다 많은 공간을 쓰게 됩니다. 데이터를 재배치하는 최적화 작업으로 단편화와 디스크 사용량을 함께 줄일 수 있습니다.
장비와 구조로 시간 벌기
설계와 쿼리로 풀리지 않거나 개선에 시간이 필요할 때 쓰는 선택지입니다.
수직 확장 은 클라우드에서 짧은 시간 안에 CPU와 메모리를 키울 수 있어 급한 불을 끄는 데 유용합니다. 비용은 늘지만 서비스를 제대로 제공하지 못하는 것보다는 낫고, DB가 버틸 수 있는 상태가 된 뒤에 효과적인 개선안을 찾아 적용하면 됩니다.
수평 확장 은 조회 트래픽 비중이 높은 서비스에서 주 DB - 복제 DB(Primary-Replica) 구조로 처리량을 늘리는 방법입니다. 변경 쿼리는 주 DB로, 조회 쿼리는 복제 DB로 보내고 조회 트래픽이 늘면 복제 DB를 추가합니다. 다만 DB 서버는 API 서버보다 몇 배 이상 비싼 장비를 쓰므로, 한 번 늘린 고정 비용이 계속 부담이 된다는 점을 함께 봐야 합니다.
별도 캐시 서버 는 DB 확장보다 비용 대비 이득이 큰 경우가 많습니다. 레디스 같은 캐시 서버를 구성하는 편이 DB를 확장하는 것보다 서버 개발자·인프라 엔지니어 입장에서 부담도 적습니다. 코드 수정 비용이 들지만 그 대가로 늘어나는 처리량이 크다면 합리적인 선택입니다.
운영에서 자주 밟는 지뢰
쿼리 타임아웃
응답이 늦어지면 사용자는 몇 초 뒤 재시도하고, 서버는 앞선 요청을 아직 처리 중인 상태에서 새 요청을 받습니다. 이런 식으로 재시도가 반복되면 동시에 처리해야 하는 요청 수가 기하급수적으로 늘어나 부하가 폭증합니다.
쿼리 실행 시간을 제한하면 사용자는 에러 화면을 보게 되지만 서버 입장에서는 해당 요청을 정상적으로 종료한 셈 이 되어 동시 요청 수의 상한이 생깁니다. 다만 타임아웃 값은 기능마다 달라야 합니다. 블로그 글 조회는 몇 초 이내로 짧게 잡아도 되지만, 결제는 처리 중 타임아웃이 나면 후속 처리와 데이터 정합성이 복잡해지므로 더 긴 값이 필요합니다.
상태 변경 기능은 복제 DB에서 조회하지 않기
“변경은 주 DB, 조회는 복제 DB”를 잘못 이해해 모든 SELECT를 복제 DB로 보내면 두 가지 문제가 생깁니다.
첫째, 주 DB의 변경은 네트워크로 전달된 뒤 복제 DB에 반영되므로 그 지연 시간만큼 두 DB의 값이 다릅니다. 변경 직후 복제 DB에서 조회하면 반영 전 데이터를 읽게 됩니다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 participant P as 주 DB participant R as 복제 DB C->>S: API 실행 S->>P: 1.1 변경 S->>R: 1.2 SELECT R-->>S: 아직 반영되지 않은 값 S-->>C: 1.3 조회 실패로 에러 응답 P->>R: 2. 복제
둘째, 복제는 트랜잭션 커밋 시점 에 이뤄집니다. 주 DB의 트랜잭션 범위 안에서 데이터를 변경한 뒤 그 대상을 복제 DB에서 조회하면 당연히 값이 맞지 않습니다.
따라서 회원 가입·변경·삭제처럼 INSERT/UPDATE/DELETE를 실행하는 기능에서 변경 대상 데이터를 조회해야 한다면, 그 SELECT는 주 DB에서 실행해야 합니다.
배치 쿼리 실행 시간 증가
일괄 조회·집계 쿼리는 데이터가 늘수록 실행 시간이 함께 늘어나는데, 집계 쿼리는 특성상 메모리를 많이 쓰기 때문에 특정 임계점을 넘으면 실행 시간이 예측 불가능할 만큼 길어집니다. 30초에 끝나던 쿼리가 몇십 분이 되고, 어느 날부터는 몇 시간이 지나도 끝나지 않습니다.
장비 사양을 높이는 게 가장 빠르지만 항상 가능하지는 않으므로 두 가지 대안을 함께 봅니다.
| 대안 | 효과 |
|---|---|
| 커버링 인덱스 활용 | 집계 대상 칼럼이 인덱스에 있으면 데이터를 직접 읽지 않고 인덱스만 스캔해 집계. 속도와 메모리 사용량이 함께 개선 |
| 데이터를 일정 크기로 나눠 처리 | 한 달치 집계를 하루 단위로 쪼개 실행하고 합침. 하루도 길면 1시간·10분 단위로. 실행 시간을 일정 수준으로 유지 |
나눠 처리하는 방식은 새벽 배치를 10분 간격 증분 집계로 바꾸는 데도 그대로 쓸 수 있습니다. 통계 테이블에 반영된 마지막 시각을 구하고, 그 시각부터 10분 구간의 데이터만 집계해 반영하는 식입니다.
무엇보다 배치 쿼리의 실행 시간을 지속적으로 추적하는 것 이 예방책입니다. 실행 시간이 갑자기 크게 늘었을 때 감지할 수 있어야 임계점을 넘기기 전에 손을 쓸 수 있습니다.
타입이 다른 칼럼 조인 주의
user.userId 는 integer인데 push.receiverId 는 varchar인 상황에서 두 칼럼을 조인하면, DB는 비교를 위해 행마다 타입 변환을 수행 하고 그 결과 receiverId 인덱스를 온전히 활용하지 못합니다. push 테이블 데이터가 많다면 이 변환 작업만으로 실행 시간이 길어집니다.
해결책은 비교 대상 칼럼의 타입을 맞추는 것입니다. MySQL이라면 cast(u.userId as char character set utf8mb4) collate 'utf8mb4_unicode_ci' = p.receiverId 처럼 캐릭터셋과 collation까지 일치시켜야 합니다. 문자열끼리 비교할 때도 캐릭터셋이 다르면 그 자체로 변환이 발생합니다.
테이블 변경은 신중하게
MySQL은 테이블을 변경할 때 새 테이블을 만들고 원본 데이터를 복사한 뒤 교체합니다. 이 복사 과정에서는 UPDATE/INSERT/DELETE가 허용되지 않으므로 복사 시간만큼 서비스가 멈춥니다. DML을 허용하면서 변경하는 온라인 DDL도 있지만 항상 가능한 것은 아닙니다.
데이터가 많은 테이블은 점검 시간을 잡고 변경하는 것이 가장 안정적입니다. 주 DB 변경이 끝나도 복제 DB 변경은 계속 진행되므로, 완료까지 걸리는 시간을 넉넉히 잡아야 합니다. 단 한 줄의 테이블 변경 쿼리가 8시간 가까이 서비스를 중단시키고 며칠간의 데이터 보정과 고객 보상으로 이어진 사례가 있습니다.
DB 최대 연결 개수
API 서버 3대를 4대로 늘렸는데 새 서버에서 DB 커넥션 생성에 실패한다면, DB 자원이 아니라 최대 연결 개수 설정 을 봐야 합니다. 커넥션 풀이 30개일 때 서버 4대면 필요한 커넥션은 120개인데 DB의 최대 연결 개수가 100이면 20개는 연결에 실패합니다.
이때는 최대 연결 개수를 늘려 해결할 수 있지만 조건이 있습니다. DB CPU 사용률이 70% 이상이면 연결 개수를 늘려서는 안 됩니다. 연결 수가 많아질수록 부하가 커져 성능이 더 떨어지므로, 캐시 서버 구성이나 쿼리 튜닝으로 부하를 먼저 낮춘 뒤에 늘려야 합니다.
트랜잭션은 붙이느냐가 아니라 어디까지 묶느냐의 문제
서버 초심자가 자주 놓치는 두 가지가 정확히 반대 방향의 실수입니다.
트랜잭션을 아예 걸지 않는 경우 입니다. 계약 데이터를 contract 테이블에 추가하고 userStatus 테이블의 상태를 변경하는 과정을 트랜잭션 없이 실행하면, 두 번째 단계가 실패했을 때 계약 데이터는 남아 있는데 상태는 바뀌지 않은 상태가 됩니다. 사용자는 상태가 그대로이니 다시 계약을 시도하지만 contract 에는 이미 데이터가 있어 더 이상 진행할 수 없습니다.
트랜잭션 범위가 너무 넓은 경우 도 문제입니다. 스프링의 @Transactional 은 런타임 예외가 발생하면 전체 트랜잭션을 롤백하므로, 회원 가입 메서드 안에서 환영 메일 발송이 RuntimeException 을 던지면 DB에 정상적으로 추가된 회원 데이터까지 함께 롤백됩니다. 메일을 못 보냈다는 이유로 가입 자체가 실패하는 것은 우리가 원한 동작이 아닙니다.
@Transactional // 트랜잭션 범위
public void join(JoinRequest join) {
...
memberDao.insert(member); // DB에 데이터 추가
try {
mailClient.sendMail(...); // 메일 발송
} catch (Exception ex) {
// 메일 발송 오류 무시
// 로그로 기록해 모니터링
}
}결국 판단해야 하는 것은 일부 기능이 실패했을 때 전체를 롤백할 것인가, 커밋할 것인가 입니다. DB 관련 코드를 작성할 때는 트랜잭션의 시작과 종료 경계를 기능의 의미에 맞게 설정했는지 반드시 확인해야 합니다.
비교 / 트레이드오프
조회 성능을 개선하는 선택지들은 무엇을 줄이고 무엇을 내주느냐에서 갈립니다.
| 방법 | 줄이는 것 | 치르는 비용 | 적합한 상황 |
|---|---|---|---|
| 인덱스 추가 | 탐색해야 할 데이터 개수 | 쓰기 시 관리 비용, 메모리·디스크 | 조회 패턴이 명확하고 걸러낸 결과가 작을 때 |
| 커버링 인덱스 | 실제 데이터를 읽어 오는 과정 | 인덱스 칼럼이 늘어 크기 증가 | 조회 칼럼이 적고 고정적일 때 |
| 미리 집계 | 집계·서브 쿼리 실행 | 비정규화로 인한 값 불일치, 동시성 검증 | 목록마다 개수를 표시해야 할 때 |
| ID 기준 조회 | 오프셋만큼 세는 시간 | 임의 페이지로 바로 이동 불가 | 스크롤 기반 최신순 목록 |
| 조회 범위 제한 | 대상 데이터 개수 자체 + 캐시 적중률 상승 | 기획 변경 협의 필요 | 최신 데이터 위주로 쓰는 기능 |
| 과거 데이터 삭제·분리 | 테이블 전체 크기 | 별도 저장소와 배치 운영 | 로그성 데이터 |
| 장비 확장·복제 DB | 즉시 부하 분산 | 고정 비용 지속 증가, 복제 지연 | 근본 개선까지 시간을 벌어야 할 때 |
| 캐시 서버 | DB 접근 횟수 | 코드 수정, 무효화 관리 | 반복 조회가 많을 때 |
내 생각
- 인덱스 설계는 스키마 회의가 아니라 화면 정의서에서 시작합니다. 어떤 조건으로 무엇을 거르는 화면이 있는지가 곧 인덱스 목록이므로, 테이블만 보고 인덱스를 정하는 순간 이미 절반은 어긋납니다.
- “선택도가 낮으면 인덱스를 걸지 마라”는 반쪽짜리 규칙입니다. 작업 큐의
status처럼 값이 세 개뿐이어도 실제 쿼리가 소수 값만 조회하고 그 쿼리가 반복 실행된다면 인덱스는 필수입니다. 판단 기준은 칼럼의 통계가 아니라 쿼리가 읽는 행의 개수입니다. - 미리 집계 칼럼은 도입보다 되돌리기가 훨씬 어렵습니다. 증감 코드가 여러 경로에 흩어지므로, 원자적 UPDATE 검증과 정합성 보정 배치를 처음부터 세트로 계획하지 않으면 시간이 지날수록 값이 조용히 어긋납니다.
- 복제 DB를 도입하는 순간 ‘읽기’가 두 종류로 갈립니다. 단순 조회와 변경 대상 조회를 코드 레벨에서 구분해 두지 않으면, 평소에는 잘 돌다가 부하가 몰려 복제 지연이 커지는 날에만 재현되는 버그가 생깁니다.
- 테이블 변경은 코드 리뷰가 아니라 릴리스 계획의 문제입니다. 한 줄짜리 DDL이 서비스를 몇 시간 멈출 수 있으므로, 데이터가 많은 테이블이라면 온라인 DDL 가능 여부와 복제 DB 반영 시간까지 배포 계획에 포함해야 합니다.
- 트랜잭션 안에 외부 연동을 넣지 않는 것을 기본값으로 둡니다. 메일·푸시·결제 같은 외부 호출은 DB 트랜잭션과 성공 조건이 다르므로, 롤백 대상인지 무시 대상인지를 붙이기 전에 정해야 합니다.
관련 개념
- Ch02 느려진 서비스, 어디부터 봐야 할까 — 응답 시간·처리량과 커넥션 풀, 캐시 전반
- Ch08-2 인덱스란 무엇인가 — 인덱스의 기본 개념과 자료 구조
- Ch08-3 B-Tree 인덱스 — 인덱스 탐색이 빠른 이유
- Ch08-5 전문 검색 인덱스 — 문자열 검색을 풀 스캔 없이 처리하는 방법
- Ch05-1 트랜잭션 — 트랜잭션의 범위와 커밋·롤백