쿠폰이 DB를 죽이고 있었던 사연 - MySQL 뷰(view)에서 index 가 적용되지 않는 이유
1. 시작하며
작년 12월 입사했을 때부터,
시스템에서 가장 병목이 되고 있던 부분은 쿠폰 기능이었다.
정확히는 쿠폰을 불러오는 쿼리였다.
앱 홈화면에 들어갈 때마다 유저 데이터를 조회하고,
거기에 쿠폰을 묶어서 가져오는 쿼리가 붙어 있었는데,
이게 전부 MySQL의 view로 묶여 있었다.
이게 얼마나 심각한 문제였냐면:
- 알림톡을 단 3,000건만 보내도
- 동시접속자를 감당하지 못해서
- DB CPU 사용률이 70~80%까지 치솟고
- 슬로우 쿼리 로그가 날아오기 시작했다.
작은 이벤트 하나라도 열 때마다
앱 서버가 터질까봐 벌벌 떨어야 했던 상황이었다.
2. 문제의 원인
당시 쿠폰 조회 로직은
다수의 테이블을 조인해서 구성된 view를 통해 처리되고 있었다.
select * from user_coupon_viewwhere user_id = ?and available = true;
표면적으로는 단순한 select 같아 보이지만,
이 user_coupon_view
안에서는 다중 조인, 조건 필터링, case when 로직까지 복잡하게 얽혀 있었다.
문제는, 이 view가 인덱스를 거의 타지 않는 구조였다는 것.
왜 인덱스를 안 타는가? - MySQL 옵티마이저는 그렇게 똑똑하지 않다
MySQL에서 view는 말 그대로 "미리 저장된 select 쿼리"고,
실제로 실행할 때는 그걸 내부에서 쭉 펼쳐서 옵티마이저가 다시 처리하게 된다.
문제는 이 과정에서:
- where 절이 view 외부에서 정의될 경우
- 옵티마이저는 내부 조인 테이블의 선택도(selectivity) 를 제대로 추정하지 못하고,
- 복잡한 join, group by, case 구문이 포함되면 index merge 최적화가 무력화되며,
- 결국 풀 테이블 스캔(full table scan) 으로 밀어붙이게 된다.
특히
where
,limit
,join
이 복합적으로 들어가면 인덱스 타기 어려움.
(MySQL 공식 문서에서도, 성능이 중요한 경우 view 사용에 주의하라고 명시되어 있음.)
예를 들어 설명하자면:
- 누가 "냉장고에서 딸기우유좀 가져와줘" 라고 하면,
- 정상적인 두뇌라면 바로 딸기우유만 집어서 꺼낸다.
- 그런데 MySQL은 이럴 때,
- 냉장고 문짝 뜯어서 안에 있는 걸 다 꺼낸 다음, 그중에서 딸기우유를 골라내는식으로 일한다.
즉, view는 조건을 view 바깥에서 주면 내부 필터링을 똑똑하게 못 한다.
왜냐하면 옵티마이저는 이걸:
select * from (복잡한 조인들...)where user_id = ?
이렇게 처리하지 않고, 그냥
"일단 다 가져오자" → "그다음에 필터하자"
이런 단계를 밟는다.
(정확히는 서브쿼리 안의 join이나 필터조건이 복잡할수록 index pushdown이 어려워진다)
그래서 생기는 현상은:
- 매 요청마다 view 전체 풀스캔
- 쿠폰 테이블 → 유저 쿠폰 → 쿠폰 정책 → 유효기간 체크...
- 다 조회하고 나서야 user_id = ? 확인
- 이미 CPU는 연기 나기 시작함
3. 진짜 문제는 그게 '홈화면에 붙어 있었다는 것'
이 view 기반 쿼리는
앱 홈화면 진입 시 항상 호출되었다.
= 모든 유저가 앱을 열 때마다 view → 풀스캔.
결과는:
- 알림톡 3,000건 날려서 동시접속자 수 증가
- 쿠폰 조회 요청 증가
- 병렬 쿼리 경쟁
- DB CPU 사용률이 70~80%까지 치솟고
- 슬로우 쿼리 로그 날아오기 시작
- 전체 서비스가 느려지고
- 슬슬 DB가 죽겠다는 느낌이 옴
- 홈화면 진입 지연 발생
- 사용자 이탈..
4. 대처: "일단 view부터 제거하자"
그때 판단은 빠르게 내려졌다.
가장 명확하고, 가장 빠르게 고칠 수 있는 것부터 하자.
= 쿠폰 view 제거.
기존에 user_coupon_view
가 하던 일을
직접 SQL로 다시 작성했다.
조건이 훨씬 명확해졌고,
직접 인덱스를 타는 쿼리로 바꿨다.
select ....from user_couponleft join coupon_policy on coupon_policy.id = user_coupon.coupon_policy_idleft join user_coupon_usage_history on user_coupon.id = user_coupon_usage_history.user_coupon_idwhere ....and user_id = ? # 기존 view에서 사용하던 조건추가and available = true; # 기존 view에서 사용하던 조건추가group by user_coupon.id;
view 안의 복잡한 조인 로직 대신
쿼리를 직접 관리하면서,
인덱스를 타는 구조를 보장할 수 있게 만들었다.
5. 결과: "DB CPU가 조용해졌다"
같은 조건, 같은 데이터셋에서
알림톡 3,000건을 보내도 DB CPU가 30~40% 수준으로 안정화.
(뷰를 제거한 1월 14일 이후부터 CPU 점유율이 눈에 띄게 낮아졌다!)
아직 CPU는 30~40% 수준으로 병목이 남아있긴하지만
그래도 서비스 레벨에서 체감될 정도로 쾌적해졌고,
슬로우 쿼리 로그도 훨씬 줄어들었다.
사실 쿼리 자체가 엄청 복잡했던 건 아니지만,
이전 구조가 너무 비효율적이었고,
그걸 명확히 진단해서 개선한 것만으로도
시스템 전체에 여유가 생겼다.
6. 회고
스타트업 시스템은 초기에 기능을 붙이는 데 집중된 설계가 많다.
당장은 빠르게 결과를 내야 하니까,
효율보다 동작이 먼저 온다.
근데 그 구조가 사용자가 늘어난 뒤에도 남아 있으면
그건 기술 부채가 아니라, 기술 채무 추심자가 되버린다.
이번엔 다행히 빠르게 진단하고,
가장 목 조이던 부분을 잘라냈다.
그리고 배웠다:
"성능 최적화는 나중에 하는 게 아니라,
느려졌을 땐 이미 늦은 거다."
요약
- view는 편리하지만 성능 최적화에 불리한 구조
- 옵티마이저는 view 내부를 깊게 최적화하지 못함
- 조인 + 조건 필터링이 복잡해지면 index 무력화
- 조회빈도가 높은 페이지에서 view는 "성능 암살자"가 될 수 있음