ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]
RCA: ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE panos.state != ?]
Overview#
What Happened#
cupixworks-api (tesla) 의 bulk annotation 생성 경로가 클라이언트가 보낸 pano_id 로 ::Pano.find 를 직접 호출한다. 해당 pano 가 존재하지 않거나 abandoned 상태이면 Statable::Pano 의 default_scope { where.not(state: :abandoned) } 때문에 조회 결과가 0건이 되어 ActiveRecord::RecordNotFound 가 발생한다. 2026-07-09 오전 (KST) 약 37분 동안 한 번의 버스트로 16건 기록되었다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | ActiveRecord::RecordNotFound |
| exception.message | Couldn't find Pano with 'id'=92228359 [WHERE panos.state != ?] |
| top_frame | app/factories/concerns/bulkable_factory/annotation.rb:51 |
| runtime | Ruby on Rails (tesla) |
| env | production |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (tesla) | 16 | bulk annotation 생성 요청이 404 로 실패. 존재하지 않거나 abandoned 된 pano 를 참조한 클라이언트에 한정 |
Timeline#
- 2026-07-09 06:30 KST — 최초 발생 (first_seen).
- 2026-07-09 07:07 KST — 최근 발생 (last_seen). 이후 재발 없음.
- 2026-08-04 — RCA 수행. Datadog 14일 retention 창 밖이라 로그 0건.
Error Log#
Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]
Impact#
- Service:
cupixworks-api - 발생 횟수: 16
- 최초 발생: 2026-07-09 06:30 KST
- 최근 발생: 2026-07-09 07:07 KST
Root Cause Summary#
bulk annotation 생성 경로가 클라이언트 입력 pano_id 를 검증 없이 ::Pano.find 로 조회한다. Statable::Pano 는 default_scope { where.not(state: :abandoned) } 를 적용하므로, pano 가 실제로 없거나 abandoned 상태이면 find 가 0건 결과에서 ActiveRecord::RecordNotFound 를 raise 한다. 이 예외는 bulkable_factory/annotation.rb 의 prepare_items 안에서 발생하는데, 이 지점은 어떤 rescue 로도 감싸져 있지 않다. tesla 컨트롤러 스택에는 rescue_from ActiveRecord::RecordNotFound 가 없어 Rails 기본 rescue_responses 매핑에 따라 HTTP 404 로 응답된다. 즉 서버 로직 결함이 아니라 클라이언트가 유효하지 않은(존재하지 않거나 이미 폐기된) pano 를 참조한 입력 오류다. 같은 기능의 단건 annotation 생성 경로는 이미 PanoRepository#show (Cupix::Errors::NotFound → 403) 로 정상 처리하고 있으므로, 실제 결함은 bulk 경로가 repository show 대신 raw find 를 쓰는 처리 방식 불일치뿐이다.
Technical Analysis#
Code Path#
- Entry point: bulk annotation 라우트.
config/routes.rb:177-179의bulkableconcern 이put '' → action :bulk로 매핑. Api::V1::AnnotationsController→AnnotationRepository#bulk→AnnotationFactory#bulk!.
AnnotationFactory 는 BulkableFactory::Annotation 을 include 하며 자체 bulk! 를 정의한다. 이 안에서 prepare_items 를 호출한다.
def bulk!(params = {}, **kwargs)
self.created_uuids = []
annotations_params = params[:annotations] || []
raise Cupix::Errors::Parameter.new(code: 'ARG10000', reason: 'annotations is required') if annotations_params.blank?
layer_cache = {}
annotations_params.map { |a| a[:annotation_layer_id] }.compact.uniq.each do |layer_id|
layer = ::AnnotationLayer.find(layer_id)
raise Cupix::Errors::PermissionDenied.new(code: 'PERM10000', reason: 'Permission denied') unless layer.facility.creatable_by?(current_user)
layer_cache[layer_id] = layer
end
prepared_items, invalid_items, items_meta = prepare_items(annotations_params, layer_cache)
- Failure point:
app/factories/concerns/bulkable_factory/annotation.rb:51.
form_design = form_design_cache[annotation_params[:form_design_id]] ||= ::FormDesign.includes(:form_fields).find(annotation_params[:form_design_id])
pano = pano_cache[annotation_params[:pano_id]] ||= ::Pano.find(annotation_params[:pano_id])
::Pano.find 는 Statable::Pano 의 default scope 를 그대로 받는다.
default_scope { where.not(state: :abandoned) }
이 scope 때문에 실제 쿼리는 SELECT ... FROM panos WHERE panos.id = ? AND panos.state != ? 형태가 되고, pano 가 없거나 abandoned 이면 0건 → find 가 ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=... [WHERE panos.state != ?] 를 raise 한다. 메시지의 [WHERE panos.state != ?] 조각이 바로 이 default scope 의 흔적이다.
예외는 rescue 없이 전파된다. AnnotationFactory#bulk! 는 이 호출을 감싸지 않고, 상위 BulkableFactory#bulk 래퍼는 StandardError 를 그대로 re-raise 한다.
def bulk(params = {}, **kwargs)
_results = self.bulk!(params, **kwargs)
rescue StandardError => e
raise e
else
_results
end
컨트롤러 스택에는 ActiveRecord::RecordNotFound 를 잡는 핸들러가 없다. ClientErrorController 는 Cupix::Errors::*, ActiveRecord::RecordInvalid, ValueTooLong 등만 rescue 한다.
rescue_from Cupix::Errors::Unknown,
Cupix::Errors::Resource,
Cupix::Errors::Session,
Cupix::Errors::Parameter,
Cupix::Errors::Entity,
Cupix::Errors::Billing,
Cupix::Errors::InvalidState,
Cupix::Errors::Siteinsights, with: :client_400_error
rescue_from ActionController::ParameterMissing,
ActiveRecord::RecordInvalid, with: :invalid_parameter_400_error
# ... ActiveRecord::RecordNotFound 없음
따라서 Rails 기본 config.action_dispatch.rescue_responses (tesla 는 이 매핑을 override 하지 않음) 가 ActiveRecord::RecordNotFound 를 HTTP 404 로 렌더한다.
- 기대 동작: 유효하지 않은 pano_id 는 4xx 클라이언트 오류로 처리되어야 한다.
- 실제 동작: 404 로 응답되지만,
Cupix::Errors::*계열이 아니라 raw ActiveRecord 예외로 표면화되어 Error Tracking 에 집계된다.
대조군 (정상 처리): 단건 annotation 생성 경로는 raw find 대신 repository show 를 사용한다.
elsif params[:pano_id].present?
self.model.annotatable = PanoRepository.new(current_user: self.current_user, review: self.review).show(params[:pano_id])
BaseRepository.show 는 .first 로 nil 을 받으면 Cupix::Errors::NotFound 를 raise → not_found_403_error 로 매핑된다 (ActiveRecord::RecordNotFound 아님).
model = query.merge(scope).first
# ...
if model.nil?
if self.where(attrs).in_trash.present?
raise Cupix::Errors::NotFound.new(code: 'ENT4000', reason: "#{current_class.name} not found")
else
raise Cupix::Errors::NotFound.new(code: 'ARG10002', reason: "#{current_class.name} not found")
end
end
Log Evidence#
이 이슈는 Datadog Error Tracking (et: fingerprint) 항목이다. last_seen 이 2026-07-08 로 14일 retention 창 밖이라 로그 검색은 0건이다.
사용한 쿼리와 결과 (기준 시각 2026-08-04, now-14d):
service:cupixworks-api "Couldn't find Pano" -> 0 logs
service:cupixworks-api "WHERE `panos`.`state`" -> 0 logs
service:cupixworks-api "RecordNotFound" Pano -> 0 logs
service:cupixworks-api "ActiveRecord::RecordNotFound" -> 0 logs
ActiveRecord::RecordNotFound 는 Rails 가 404 로 매핑하고 애플리케이션 error 로그로 남기지 않기 때문에, retention 창 안이라 해도 status:error 로그로는 검색되지 않는다. 이는 위 검색이 모두 0건인 것과 일치한다.
APM span 도 확인했다. service:cupixworks-api status:error span 5000건 중 pano 관련은 3건뿐이며 모두 Pano already stitched (STAT10000, 다른 에러) 로, RecordNotFound span 은 없었다.
service:cupixworks-api status:error -> pano 관련 span 3건, 전부 "Pano already stitched" (무관)
annotation 엔드포인트의 요청 로그는 retention 창 안에 존재한다 (now-14d): [500] 22건, [404] 52건. 다만 이 요청 로그에는 예외 클래스가 실려 있지 않아 이 특정 이슈와 직접 연결할 수는 없다 (uncertain -- 대표 메시지가 retention 밖이므로 요청 로그와의 1:1 매칭은 불가). 대표 메시지의 [WHERE panos.state != ?] 조각은 default_scope 에서 결정론적으로 생성되는 고정 형태이므로, id 값만 변하는 대표 샘플은 신뢰할 수 있다 (stale 아님).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | bulk annotation 경로의 raw ::Pano.find(pano_id) 가 존재하지 않거나 abandoned 된 pano 에서 RecordNotFound 를 raise, 컨트롤러에 핸들러 없어 404 로 표면화 |
bulkable_factory/annotation.rb:51 raw find + statable/pano.rb:8 default_scope where.not(state: :abandoned) (메시지의 [WHERE panos.state != ?] 와 정확히 일치); client_error_controller.rb 에 RecordNotFound rescue 없음; ActiveRecord::RecordNotFound 로그 0건 (Rails 404, error 로깅 안 함) |
— | Confirmed |
| H2 | 단건 annotation 생성 (AnnotationFactory#create!) 이 원인 |
단건도 pano_id 를 받음 | 단건은 parameter/annotation.rb:35 에서 PanoRepository#show 사용 → nil 시 Cupix::Errors::NotFound (403), RecordNotFound 아님. 메시지의 [WHERE panos.state != ?] 는 raw find 경로에서만 나옴 |
Rejected |
| H3 | soft-copied pano 표시 시 base_repository.rb:368 model.class.find(softcopied_from_id) 가 원인 |
동일하게 raw Pano.find + default_scope |
이 경로는 pano show 성공 후 부모 조회에만 도달; 대표 발생 규모(단일 버스트 16건)와 bulk annotation 입력 패턴이 더 부합. 확정 증거 없음 (uncertain), H1 이 더 직접적 |
Rejected |
| H4 | ES/DB 인프라 장애 등 외부 의존성 | — | status-board scope svc:cupixworks-api::unknown, 이 클러스터 대상 active incident 없음 (recent 2026-07-29/30 은 last_seen 이후·무관) |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
없음. 서버 결함이 아니며, 이미 404 (클라이언트 오류) 로 응답되고 있다. 코드 변경 불필요.
단기 개선 (1주 이내)#
- 처리 방식 일관화 (선택):
bulkable_factory/annotation.rb:51의 raw::Pano.find(annotation_params[:pano_id])를, 단건 경로와 동일하게PanoRepository#show를 쓰거나find_by(id:)+ nil 체크로 바꿔Cupix::Errors::Parameter/NotFound(4xx) 로 명시 매핑한다. 그러면 rawActiveRecord::RecordNotFound대신 tesla 표준 에러 코드로 표면화되어 Error Tracking 노이즈가 사라진다. form_design 조회(FormDesign...find) 도 같은 패턴이므로 함께 검토. - 프런트엔드 협의 필요: bulk 요청에 유효하지 않은 pano_id 가 섞여 들어오는 근본 원인(뷰어가 이미 abandoned 된 pano 를 참조)을 확인. 이 항목은 응답 계약 변경을 수반할 수 있어 자동 반영 대상에서 제외.
장기 개선 (재발 방지)#
- bulk 입력 항목별 사전 검증(prepare 단계에서 유효하지 않은 pano_id 를
invalid_items로 분류)으로 부분 실패를 허용하고, 존재하지 않는 참조 하나가 전체 요청을 404 로 실패시키지 않도록 한다.
Monitoring#
Error Tracking 이슈 재발 여부를 요청 로그로 근사 모니터링한다.
annotation bulk 엔드포인트의 404 발생 추이:
service:cupixworks-api "annotations" "[404]"
pano 관련 RecordNotFound 표면화 여부 (retention 창 안 재발 감지):
service:cupixworks-api "Couldn't find Pano with"
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial
Noise Verdict#
noise — 클라이언트가 존재하지 않거나 이미 abandoned 된 pano_id 로 bulk annotation 을 요청해 발생하는 입력 검증 실패이며 이미 404 로 정상 응답되므로 서버 코드 결함이 아니다.