Api::V1::BookmarksController#index (avg 50702ms, max 50702ms)
RCA: Api::V1::BookmarksController#index (avg 50702ms, max 50702ms)
Overview#
What Happened#
2026-07-11 04:51 KST 에 production (us-west-2) 의 cupixworks-api 에서 Api::V1::BookmarksController#index 요청이 약 50.7초 동안 실행됨. 같은 review key (4tr8hc) 에 대해 짧은 시간 안에 반복 호출이 관찰되었고 최종적으로 모두 HTTP 200 으로 응답. 단일 tenant (cupix) 에서 1건만 감지됨.
Quick Facts#
| Field | Value |
|---|---|
| resource | Api::V1::BookmarksController#index |
| avg_duration_ms | 50702 |
| max_duration_ms | 50702 |
| top_frame | app/repositories/bookmark_repository.rb:_search → BaseRepository#search (app/repositories/base_repository.rb:70-112) |
| env | production, us-west-2 |
| tenant | cupix |
| sample_trace_id | 3055139774784225119 |
| status | latency (200 OK, not error) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupix | 1 | 단일 사용자가 review 뷰 로딩 시 bookmarks 목록을 약 50초 대기 (또는 client timeout 재시도 유발) |
Timeline#
- 2026-07-11 04:45 ~ 04:51 KST — 같은 review
4tr8hc에 대해/api/v1/reviews/4tr8hc/bookmarks/me와/bookmarks/team이 반복 호출됨 (04:45:14, 04:46:26, 04:49:30, 04:51:47, 04:51:49 ...) — Datadogservice:cupixworks-api BookmarksController결과. - 2026-07-11 04:51:48 KST (=
2026-07-10T19:51:48.656Z) — Datadog APM 이 50702ms 트레이스 (3055139774784225119) 를 기록. clusterfirst_seen=last_seen. - 2026-07-11 04:52:13 ~ 04:52:39 KST — 동일 review 에 대해 추가 재시도 요청 4건 관찰. 모두 200.
- 2026-07-11 05:xx KST 이후 — 이후 시간대 (
/reviews/*/bookmarks/*) 응답은 모두 정상적인 응답 시간대 안에서 200 반환 (04:59:52,05:29:50등).
Error Log#
{
"resource_name": "Api::V1::BookmarksController#index",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 50702,
"max_ms": 50702,
"sample_trace_id": "3055139774784225119"
}
이 cluster 는 status:error 로 기록된 예외가 없으며 APM latency 기반으로만 감지됨. 동일 시간대 (2026-07-10T19:45:00Z ~ 20:00:00Z) 에 service:cupixworks-api status:error 검색 결과 0건.
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-07-11 04:51 KST
- 최근 발생: 2026-07-11 04:51 KST
단일 요청 (occurrence_count 1) 이지만 응답 시간이 50초를 넘겨 대부분의 프런트엔드/CDN/로드밸런서 timeout (통상 30~60초) 에 근접. 같은 review 에 대한 반복 호출 패턴은 client-side 재시도로 인한 부하 증폭 가능성을 보여줌.
Root Cause Summary#
BookmarkRepository#_search 는 Elasticsearch 로 후보 bookmark id 를 조회한 뒤, BaseRepository#search 에서 permission_joins 를 호출해 MySQL 쪽에서 11개 이상의 LEFT JOIN 서브쿼리 + GROUP BY id 로 권한을 재계산한다. review 키가 있는 경로는 review/facility/workspace/team 단위 권한(user, group, system_group, public_access) 을 모두 한 쿼리에 결합한다 (app/repositories/bookmark_repository.rb:36-199). 이 조합이 이번 요청에서 약 50초를 소비했고, MySQL slow query (또는 Elasticsearch 응답 지연) 가 원인일 가능성이 가장 높다. 로그 레벨에서 예외는 발견되지 않아 (status:error 0건) DB/ES latency spike 로 판단된다. 단, @duration 기반의 상세 APM trace span breakdown (ES vs MySQL vs render 시간) 은 확인하지 못했으므로 "MySQL vs ES" 세부 원인 확정은 uncertain — needs verification.
Technical Analysis#
Code Path#
Entry point: app/controllers/api/v1/bookmarks_controller.rb:11-20
def index
bookmark_query_option = Cupix::QueryOption::Bookmark.new(get_query_option(enable_current_team: false), params.merge(bookmark_type: @bookmark_type, owner_type: @owner_type))
bookmarks = repository_instance.search(bookmark_query_option)
render_api Renderable.new({
search_result: bookmarks,
is_collection: true,
serializer_option: @serializer_option
})
end
before_action :set_bookmark 는 index 에서 스킵된다 (except: %i[index create mock]). 따라서 실행 흐름은 set_type → search.
BaseRepository#search (execution flow):
def search(query_option = nil)
_search(query_option) # Elasticsearch 호출
begin
if self.review.present?
contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, review_id: self.review.id, skip_join: _skip_join?)
elsif self.review_id.present?
contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, review_id: self.review_id, skip_join: _skip_join?)
# ...
end
rescue Elasticsearch::Transport::Transport::ServerError => e
raise unless e.message.start_with?('[429]')
# ...
end
end
_search 는 Elasticsearch 호출:
response = ::Bookmark.search(
self.query_option.serializable_hash
).paginate(
per_page: self.query_option.per_page,
page: self.query_option.page
)
이후 review key 경로에서는 ReviewRepository.new(...).show(...) 로 review 를 fetch (bookmark_repository.rb:246) 하고, 이후 permission_joins 로 MySQL 권한 조인을 수행. 핵심 실패지점 후보는 이 MySQL 쿼리:
def self.permission_joins(record, current_user, select: nil, review_id: -1, **kwargs)
# ...
record.joins("
LEFT JOIN ( SELECT reviews.id ... ) AS review_public_permissions ...
LEFT JOIN ( SELECT review_id, permission FROM review_permissions ... ) AS review_user_permissions ...
LEFT JOIN ( SELECT review_id, permission FROM review_permissions LEFT JOIN grouped_users ... ) AS review_group_permissions ...
LEFT JOIN ( ... facility_user_permissions ... )
LEFT JOIN ( ... facility_group_permissions ... )
LEFT JOIN ( ... facility_system_group_permissions ... )
LEFT JOIN ( ... workspace_user_permissions ... )
LEFT JOIN ( ... workspace_group_permissions ... )
LEFT JOIN ( ... team_user_permissions ... )
LEFT JOIN ( ... team_group_permissions ... )
LEFT JOIN ( ... team_system_group_permissions ... )
").group('id').select(_select).where("GREATEST(...) > 0 OR GREATEST(...) > 1")
end
기대 동작: bookmark 후보 (ES 로 이미 제한됨) 에 대해 권한 필터를 MySQL 로 재확인 후 반환. 정상 요청은 수백 ms 이내.
실제 동작: 이번 트레이스에서 총 50.7초 소요. permission_joins 는 사용자별로 grouped_users 를 여러 번 조인하고 GROUP BY id 를 수행하기 때문에 큰 group 소속 사용자에 대해 임시 테이블/파일 정렬을 유발할 수 있음. 또는 review fetch 단계에서 다른 slow query 로 지연되었을 가능성.
Failure point (latency origin, 미확정): app/repositories/bookmark_repository.rb:permission_joins 의 대형 SQL 또는 app/repositories/bookmark_repository.rb:294-299 의 Elasticsearch 요청 중 하나. uncertain — needs verification via APM span breakdown.
Log Evidence#
Datadog query 로 재현:
service:cupixworks-api BookmarksController
range: 2026-07-10T19:45:00Z ~ 2026-07-10T20:00:00Z
동일 review 4tr8hc 에 대한 반복 호출 (재시도 패턴):
2026-07-11 04:51:49 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/me (Api::V1::BookmarksController#index)
2026-07-11 04:52:13 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/me (Api::V1::BookmarksController#index)
2026-07-11 04:52:15 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/team (Api::V1::BookmarksController#index)
2026-07-11 04:52:37 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/me (Api::V1::BookmarksController#index)
2026-07-11 04:52:38 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/team (Api::V1::BookmarksController#index)
2026-07-11 04:52:39 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/team (Api::V1::BookmarksController#index)
2026-07-11 04:49:30 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/me (Api::V1::BookmarksController#index)
2026-07-11 04:49:31 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/team (Api::V1::BookmarksController#index)
2026-07-11 04:46:26 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/me (Api::V1::BookmarksController#index)
2026-07-11 04:46:26 KST [200] GET /api/v1/reviews/4tr8hc/bookmarks/team (Api::V1::BookmarksController#index)
동일 시간 범위 (2026-07-10T19:45:00Z ~ 20:00:00Z) 에서 status:error 검색 결과 0건. 즉, 예외는 발생하지 않고 오직 응답 지연만 있었음:
service:cupixworks-api status:error
range: 2026-07-10T19:45:00Z ~ 2026-07-10T20:00:00Z
=> Found 0 logs
sample trace id 3055139774784225119 자체는 로그 index 에 인덱싱된 필드로 매칭되지 않아 직접 조회는 실패 (Datadog 로그에서 trace_id 는 attribute 로만 존재하며, span 세부 정보는 APM 링크에서 확인해야 함):
service:cupixworks-api "3055139774784225119"
=> Found 0 logs
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | BookmarkRepository#permission_joins 의 대형 LEFT JOIN + GROUP BY 쿼리가 특정 사용자/review 조합에서 MySQL slow query 를 유발해 응답 시간이 50초에 도달 |
bookmark_repository.rb:36-199 에 11개 이상의 LEFT JOIN 서브쿼리와 GROUP BY id 존재; APM latency 50702ms; 같은 endpoint 의 다른 요청은 정상 응답; status:error 로그 0건이라 예외 없이 순수 지연 |
APM span breakdown 을 로그로 확인하지 못해 DB 시간 비중이 실제로 얼마인지 미확인 | Inconclusive (most likely) |
| H2 | Elasticsearch (::Bookmark.search) 응답 지연이 50초 대부분을 차지 |
bookmark_repository.rb:294-299 에서 ES 호출이 동기적으로 이루어짐; ES circuit breaker(429) 는 rescue 로 별도 처리되므로 timeout 이 아닌 지연은 그대로 노출됨 |
ES circuit breaker 발생 시 Elasticsearch::Transport::Transport::ServerError 를 raise 하고 Cupix::Logger.error("Elasticsearch circuit breaker: ...") 로그를 남기지만 (base_repository.rb:88-93) 해당 로그도 발견되지 않음. ES 지연이었다면 다른 endpoint (예: reviews search) 도 함께 느려졌을 텐데 같은 시간 다른 서비스에 error 없음 |
Rejected |
| H3 | 외부 dependency (S3/EFS/upstream service) 장애 | — | status-board 결과 scope=svc:cupixworks-api::unknown 이고 active=null. 같은 시간대에 dep:* scope 도 없음. status:error 0건 |
Rejected |
| H4 | 이 요청은 실제로 성공했고 client 재시도로 인해 부하가 증폭됨 (root cause 는 아니지만 관련 관찰) | 04:45 ~ 04:52 사이 동일 review 4tr8hc 에 대한 10건 이상의 반복 호출 로그. 각 호출은 me/team 페어. 정상 사용자 세션이라면 페이지 로딩 1회당 me+team 2회이지 4~5분 동안 동일 review 를 반복 호출하지는 않음 |
재시도 패턴이 slow response 의 원인은 아니고 결과 (사용자 새로고침) 로 해석 가능 | Confirmed as contributing factor, not root cause |
| H5 | 배포/deploy spike 로 인한 warm-up 지연 | — | 배포 시각/SHA 정보를 확인할 수 있는 배포 로그를 이 조사에서 확인하지 못함 | Inconclusive |
Fix Recommendation#
즉시 조치 (Critical)#
- APM span breakdown 확인: 링크된 Datadog APM trace (
3055139774784225119) 를 열어 span 별 소요 시간 (ES 쿼리 / MySQL 쿼리 / Ruby render) 을 실제로 확인. 이 데이터 없이는 H1/H2 를 확정할 수 없음. 담당자가 수동으로 확인 필요. - 대규모 review 케이스 재현: review
4tr8hc의 team 규모, bookmark 개수,grouped_users/review_permissionsrow 수를 staging 이나 read-replica 에서 조회해 실제 slow query 재현 여부 확인. 필요 파일:app/repositories/bookmark_repository.rb:36-199(permission_joins).
단기 개선 (1주 이내)#
permission_joins쿼리 리팩토링 검토: 11개 LEFT JOIN 을 한 쿼리에 모두 결합하는 대신, 사용자 권한을 미리 계산 (current_user.readable_review_ids,readable_facility_ids등) 하고record.where(id: authorized_ids)방식으로 축소하는 방향. 이미mock경로 (base_repository.rb:188-296) 에서는directly_readable_*_ids를 사용하는 유사 패턴이 있음 — 이를 참고.- MySQL slow query 알림 강화:
bookmarks+permission_joinsSQL 시그니처에 대해 slow_query_log threshold (예: 3s) 초과 시 알림 걸기. BookmarksController#indextimeout 방어: 컨트롤러/미들웨어 단에서 30초 이상 걸리는 요청은 명시적으로 timeout 반환하도록 검토 (client timeout 이 재시도 폭증을 유발하지 않게).
장기 개선 (재발 방지)#
- Bookmark 검색은 Elasticsearch 로 이미 제한된 후보를 다시 MySQL 로 권한 검증하는 두 단계 구조. 권한 정보를 Elasticsearch 인덱스에 embed 하거나 (
Searchable::Bookmarkmapping 확장), 별도 permission cache layer 도입을 검토. - APM 기반 latency SLO 도입:
resource_name:"Api::V1::BookmarksController#index"의 p99 를 SLO 로 설정하고 error budget 소진 시 페이지.
Monitoring#
Bookmarks endpoint p95 지연 시간 시계열 (release dashboard timeseries widget 용):
avg:trace.rack.request.duration.by_http_status{service:cupixworks-api,resource_name:api::v1::bookmarkscontroller#index}
Bookmarks endpoint slow request (>5s) 개수 (log-based):
service:cupixworks-api BookmarksController @duration:>5000000000
전체 API p99 latency:
p99:trace.rack.request.duration{service:cupixworks-api} by {resource_name}
MySQL bookmarks table slow query rate (참고, 실제 태그 이름은 팀 환경에 따라 조정 필요):
sum:mysql.performance.slow_queries{service:cupixworks-api}.as_rate()
Risk Assessment#
- Risk level: low — 발생 횟수 1건, 최종 응답 200, 예외 없음. 그러나 재발 시 사용자 체감 (bookmark 목록 50초 대기) 및 client 재시도 증폭 위험 있음.
- 예상 복잡도: standard — 즉시 확인 단계 (APM breakdown) 는 trivial 이지만, 실제 fix 는
permission_joins리팩토링을 수반할 수 있어 신중한 회귀 테스트 필요.