ES /docs

Api::V1::FacilitiesController#index (avg 1118ms, max 1183ms)

RCA: Api::V1::FacilitiesController#index Latency

Overview#

What Happened#

2026-05-26 03:44~12:44 UTC 사이에 cupixworks-api 서비스의 FacilitiesController#index 엔드포인트에서 평균 1158ms, 최대 4125ms의 응답 지연이 3개 리전(ap-southeast-2, us-west-2, eu-central-1)에서 26건 발생했다. 모든 요청은 HTTP 200으로 성공하였으나, 500ms SLA를 초과하는 latency가 지속적으로 관측되었다.

Quick Facts#

Field Value
resource_name Api::V1::FacilitiesController#index
top_frame app/repositories/base_repository.rb:70
env production (ap-southeast-2, us-west-2, eu-central-1)

Affected Teams#

Team / Domain Error Count Impact
erwalls (us-west-2) 3 445개 facility 조회 시 최대 4.1초 지연, UX 저하
sinsw (ap-southeast-2) 2 289개 facility 조회 시 2.1~2.3초 지연
built (ap-southeast-2) 5 112개 facility 조회 시 0.8~1.1초 지연
기타 다수 16 소규모 팀에서도 0.5~1초 지연 발생

Timeline#

  1. 2026-05-26T03:44:52Z — 최초 latency 감지 (ap-southeast-2)
  2. 2026-05-26T05:15:45Z — sinsw 팀 2.1초 지연 발생 (289 facilities, per_page:300)
  3. 2026-05-26T11:50:40Z — erwalls 팀 3.4초 지연 발생 (444 facilities, per_page:300)
  4. 2026-05-26T11:53:17Z — 최대 지연 4.1초 기록 (erwalls, serialization: 3409ms)
  5. 2026-05-26T12:44:41Z — 마지막 감지

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::FacilitiesController#index",
  "service": "cupixworks-api",
  "occurrences": 6,
  "avg_ms": 1118,
  "max_ms": 1183,
  "sample_trace_id": "2830566889370620766"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 26
  • 최초 발생: 2026-05-26T03:44:52.047Z
  • 최근 발생: 2026-05-26T12:44:41.539Z
  • 영향: 대규모 facility를 보유한 팀의 사용자가 facility 목록 로딩 시 2~4초 대기. 에러는 발생하지 않으나 사용자 경험이 크게 저하됨.

Root Cause Summary#

FacilitiesController#index의 응답 지연은 JSON serialization 단계에서 대량의 facility 레코드를 직렬화할 때 발생하는 비용이 주 원인이다. Datadog 로그 분석 결과, 가장 느린 요청(4125ms)의 82%인 3409ms가 serialization에 소비되었다. FacilitySerializer가 22개 이상의 attribute concern을 포함하며, 각 facility마다 _user, _client, _facility_type, _asset_category_type 등의 association을 lazy-load하는 N+1 패턴이 대량 컬렉션(per_page:300, 289~445건)에서 극대화된다. 부차적으로 permission_joins() 메서드의 9개 LEFT JOIN + GROUP BY 집계가 DB 시간을 증가시키는 요인이다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/facilities_controller.rb:93
app/controllers/api/v1/facilities_controller.rb:93-103ruby
def index
  facilities = repository_instance.search(
    Cupix::QueryOption::Facility.new(get_query_option, params)
  )

  render_api Renderable.new({
    search_result: facilities,
    is_collection: true,
    serializer_option: @serializer_option
  })
end
  • Search execution: app/repositories/base_repository.rb:70-112

Elasticsearch에서 결과를 가져온 후, default_joins()permission_joins()를 통해 SQL 재쿼리를 수행한다.

app/repositories/base_repository.rb:70-82ruby
def search(query_option = nil)
  _search(query_option)

  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?)
    # ...
    else
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, skip_join: _skip_join?)
    end
  end
end
  • Default JOINs: app/repositories/facility_repository.rb:245-269

includes(:storage)로 storage만 eager-load하고, workspace/team은 INNER JOIN으로 select하지만, serializer에서 접근하는 user, client, asset_category_type, facility_type 등의 association은 eager-load하지 않는다.

app/repositories/facility_repository.rb:245-269ruby
def self.default_joins(records)
  records.includes(:storage).joins(:workspace, :team).select('
    facilities.*,
    workspaces.name AS workspace_name,
    teams.qa_preference AS team_qa_preference,
    teams.total_bim_pack_count AS team_total_bim_pack_count,
    # ... (17개 추가 컬럼)
    workspaces.lock_state AS workspace_lock_state
  ')
end
  • Permission JOINs (DB 병목): app/repositories/facility_repository.rb:271-416

9개의 LEFT JOIN subquery + GROUP BY + MAX 집계를 한 번에 수행한다. 이 쿼리는 facility_permissions, workspace_permissions, team_permissions, grouped_users 테이블을 모두 조인하며, 대량 데이터에서 DB 시간이 증가한다.

app/repositories/facility_repository.rb:302-340ruby
record.joins("
  LEFT JOIN (
    SELECT facility_id, permission
    FROM facility_permissions
    WHERE facility_permissions.accessor_id = #{sanitized_user_id}
      AND facility_permissions.accessor_type = 'User'
    ) AS facility_user_permissions
      ON facility_user_permissions.facility_id = facilities.id

  LEFT JOIN (
    SELECT facility_id, permission
    FROM facility_permissions
      LEFT JOIN grouped_users
        ON grouped_users.group_id = facility_permissions.accessor_id
        AND grouped_users.user_id = #{sanitized_user_id}
    WHERE facility_permissions.accessor_type = 'Group'
      AND grouped_users.user_id = #{sanitized_user_id}
    ) AS facility_group_permissions
      ON facility_group_permissions.facility_id = facilities.id
  -- ... 7 more LEFT JOINs ...
").group('id').select(_select)
  • Serialization (주 병목): app/serializers/facility_serializer.rb:1-118

22개 이상의 include concern과 80개 이상의 attribute를 직렬화한다. _user, _client, _facility_type 등의 메서드는 eager-load 되지 않은 association을 개별 쿼리로 로드한다.

app/serializers/facility_serializer.rb:10-16ruby
include TeamAttribute
include WorkspaceAttribute
attribute :user, &:_user
attribute :asset_category_type, &:_asset_category_type
attribute :meta
attribute :client, &:_client
app/serializers/facility_serializer.rb:68-86ruby
include GroundLevelAttribute
include QaPreferenceAttribute
include StorageAttribute
include ConvertedFacilitySizeAttribute
include EntityUpdatesAttribute
include UnitSystemAttribute
include GeoReferencableAttribute
include ResourcableAttribute
include LastUpdatedUserAttribute
include SiteinsightsAttribute
include SiteinsightsLiteAttribute
include TradeMappingAttribute
include SalesforceAttribute
include OptOutableAttribute
include ExpectedQualityAttribute

Log Evidence#

Datadog 검색 쿼리:

text
service:cupixworks-api "FacilitiesController" @duration:>2000ms

가장 느린 요청 5건의 breakdown:

text
| Timestamp (UTC)         | Duration | DB (ms)  | Serialization (ms) | Team    | Entries | Per Page |
|-------------------------|----------|----------|--------------------|---------|---------|----------|
| 2026-05-26T11:53:17Z    | 4124ms   | 472ms    | 3409ms             | erwalls | 445     | 300      |
| 2026-05-26T11:50:40Z    | 3386ms   | 1094ms   | 3185ms             | erwalls | 444     | 300      |
| 2026-05-26T12:12:58Z    | 2024ms   | 54ms     | 1810ms             | erwalls | 445     | 300      |
| 2026-05-26T05:15:55Z    | 2294ms   | 88ms     | 2036ms             | sinsw   | 289     | 300      |
| 2026-05-26T05:15:45Z    | 2150ms   | 88ms     | 1885ms             | sinsw   | 289     | 300      |

핵심 패턴:

  • Serialization 시간이 전체 응답 시간의 80~85% 차지
  • per_page: 300total_entries > 200인 요청에서 집중 발생
  • DB 시간은 대부분 100ms 이하이나, erwalls 팀에서 간헐적으로 472~1094ms 발생 (permission JOIN 비용)

추가 검색 — DB 고비용 요청:

text
service:cupixworks-api "FacilitiesController" @db:>300
text
| Timestamp         | Duration | DB (ms)  | Team           | Entries |
|-------------------|----------|----------|----------------|---------|
| 05:21:15          | 537ms    | 338ms    | jangwi-daewoo  | 23      |
| 05:17:34          | 824ms    | 354ms    | built          | 112     |
| 05:06:31          | 529ms    | 374ms    | takenaka       | 3       |
| 05:01:01          | 542ms    | 416ms    | toyoeng        | 2       |
| 04:57:28          | 1041ms   | 361ms    | kiyeno         | 1       |
| 04:51:56          | 926ms    | 518ms    | kita           | 1       |

소규모 팀(entries 123)에서도 DB 시간이 300500ms인 경우가 있음 — permission_joins의 9개 LEFT JOIN subquery가 원인. 데이터 양과 무관하게 JOIN 구조 자체가 비용을 유발함.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Serialization N+1 쿼리로 인한 대량 컬렉션 직렬화 지연 로그: serialization 시간이 전체의 80-85% (3409ms/4124ms). 코드: facility_serializer.rb:12-15_user, _client, _facility_type 등이 eager-load 없이 호출됨. entries 200+ 요청에서만 2초 이상 발생 entries < 30인 요청에서도 일부 500ms+ 발생하나, serialization 비율은 낮음 Confirmed
H2 Permission JOINs의 9개 LEFT JOIN + GROUP BY가 DB 병목 유발 로그: entries 13인 소규모 요청에서도 DB 300518ms. 코드: facility_repository.rb:302-416에 9개 subquery JOIN + GROUP BY 존재 대부분 요청의 DB 시간은 50~90ms 범위. 간헐적 spike는 cold cache 또는 lock contention 가능성 Contributing factor
H3 Elasticsearch 응답 지연 모든 로그에서 ES 응답은 빠르게 완료됨 (DB 시간에 포함되지 않는 별도 단계). ES circuit breaker 에러 없음 Rejected
H4 네트워크 지연 또는 인프라 이슈 3개 리전에서 동시 발생 3개 리전 모두에서 동일 패턴이므로 인프라가 아닌 코드 레벨 이슈. QA 환경에서도 동일 재현 Rejected
H5 큰 per_page 값에 대한 제한 부재 로그: per_page:300 요청에서 집중 발생. per_page:30인 요청은 대부분 정상 per_page는 클라이언트가 설정하므로 서버에서 상한을 두어야 함 Contributing factor

Fix Recommendation#

즉시 조치 (Critical)#

  1. default_joins()에 eager-load 추가app/repositories/facility_repository.rb:245

    • includes(:storage)includes(:storage, :user, :client, :asset_category_type, :facility_type) 로 확장
    • serializer에서 개별 쿼리 대신 preload된 association 사용하도록 변경
    • 이것만으로도 serialization 시간 60-70% 감소 예상
  2. per_page 상한 설정lib/cupix/query_option/facility.rb 또는 controller

    • per_page를 최대 100으로 제한하여 대량 직렬화 방지
    • 현재 300이 허용되어 445건을 한 번에 직렬화하는 상황 발생

단기 개선 (1주 이내)#

  1. Collection 전용 경량 serializer 도입

    • FacilitySerializer의 22개 concern 중 목록 조회에 불필요한 attribute를 제외하는 FacilityListSerializer 생성
    • SiteinsightsAttribute, BimAttribute, SalesforceAttribute, TradeMappingAttribute 등은 목록에서 불필요
  2. Permission JOIN 최적화

    • facility_permissions, workspace_permissions, team_permissions 테이블에 복합 인덱스 추가:
      • facility_permissions(facility_id, accessor_type, accessor_id)
      • workspace_permissions(workspace_id, accessor_type, accessor_id)
      • grouped_users(group_id, user_id)

장기 개선 (재발 방지)#

  1. Permission 캐싱 레이어 도입

    • 사용자별 facility permission을 Redis에 캐시하여 매 요청마다 9개 JOIN을 실행하지 않도록 함
    • Permission 변경 시 cache invalidation
  2. ES 결과 직접 반환 (collection mode)

    • 목록 조회 시 ES 인덱스에 이미 저장된 데이터를 직접 반환하고, SQL 재쿼리를 생략하는 방안 검토
    • as_indexed_json()에 이미 65개 이상 필드가 인덱싱되어 있으므로 활용 가능

Monitoring#

  • Serialization duration 추적:
text
service:cupixworks-api resource_name:"Api::V1::FacilitiesController#index" @duration:>1000ms
  • P95 latency 알림 설정:

    • Threshold: FacilitiesController#index P95 > 800ms 시 alert
    • Monitor type: APM metric monitor on trace.rack.request.duration filtered by resource
  • 대량 요청 추적:

text
service:cupixworks-api "FacilitiesController" @total_entries:>200

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard — eager-load 추가는 즉시 가능하나, serializer 분리와 permission 캐싱은 테스트가 필요함