ES /docs

Api::V1::IssuesController#create (avg 2481ms, max 2615ms)

RCA: Api::V1::IssuesController#create Latency (avg 2481ms)

Overview#

What Happened#

2026-05-28 03:5804:22 UTC 사이 cupixworks-api의 Api::V1::IssuesController#create 엔드포인트에서 평균 2481ms, 최대 2615ms의 응답 지연이 ap-southeast-1과 ap-southeast-2 리전에서 2건 발생했다. DB 시간은 1447ms로 정상이며, 외부 Issue Service(AWS Lambda + API Gateway)에 대한 동기 HTTP POST 호출이 전체 응답 시간의 80% 이상을 차지했다.

Quick Facts#

Field Value
resource_name Api::V1::IssuesController#create
top_frame app/services/cupix/issue_service.rb:117
env production, ap-southeast-1 / ap-southeast-2
avg_duration 2481ms
max_duration 2615ms
issue-service infra AWS Lambda (arm64, Node.js) + API Gateway + DynamoDB

Timeline#

  1. 2026-05-28T03:58:31Z — 첫 번째 slow request 발생 (ap-southeast-2, 2344ms)
  2. 2026-05-28T04:09:16Z — 두 번째 slow request 발생 (ap-southeast-2, 2558ms)
  3. 2026-05-28T04:22:43Z — 세 번째 slow request 발생 (ap-southeast-1, 2612ms)
  4. 2026-05-28T04:22:36Z — error-sweeper가 latency cluster로 감지

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::IssuesController#create",
  "service": "cupixworks-api",
  "occurrences": 2,
  "avg_ms": 2481,
  "max_ms": 2615,
  "sample_trace_id": "2779944330653972320"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 2
  • 최초 발생: 2026-05-28T03:58:31.404Z
  • 최근 발생: 2026-05-28T04:22:36.577Z

이 엔드포인트는 캡처 처리 파이프라인에서 자동으로 호출되며 (에러 코드: CGT6016, CGT6003), 동기 호출 시 캡처 파이프라인의 전체 처리 시간이 2초 이상 지연된다. 사용자 대면 영향은 제한적이나, 대량 처리 시 worker thread 점유 시간이 누적될 수 있다.

Root Cause Summary#

IssuesController#createasync: false 파라미터로 호출되면서, 외부 Issue Service에 대한 HTTP POST가 요청 lifecycle 내에서 동기적으로 실행된다. Issue Service는 AWS Lambda(arm64, Node.js) + API Gateway + DynamoDB 기반 서버리스 아키텍처로, POST /issues 요청 처리 시 다음이 순차적으로 실행된다:

  1. Lambda cold start (500~1500ms, 간헐적)
  2. DynamoDB IssueType 조회 (1회, ~30ms)
  3. DynamoDB BatchWrite (2 items, ~30ms)
  4. getIssueWithFullInfo() — 중복 DynamoDB 조회 3회 (~90ms)
  5. sendEmailNotification() — Lambda Invoke API await (~100-500ms)
  6. sendMondayNotification() — Lambda Invoke API await (~100-500ms)

총 DynamoDB I/O만 4~5회(~150ms), Lambda Invoke API 2회(~200-1000ms), 여기에 cold start가 추가되면 일관되게 ~2000ms가 된다. cupixworks-api 측의 Cupix::HttpClient.post()는 이 전체 응답을 동기적으로 대기한다.

Technical Analysis#

Code Path#

Caller (cupixworks-api):

  • Entry point: app/controllers/api/v1/issues_controller.rb:18
  • Controller에서 capture.create_issue(async: false) 호출
  • Issueable concern에서 Cupix::IssueService.create_issue() 호출
  • IssueService가 동기적으로 외부 서비스에 POST 수행
  • Blocking point: app/services/cupix/issue_service.rb:117 — 외부 HTTP POST 응답 대기
app/controllers/api/v1/issues_controller.rb:13-21ruby
def create
  raise Cupix::Errors::Parameter.new(code: 'ARG10000', reason: 'capture_id is required') if params[:capture_id].blank?
  raise Cupix::Errors::Parameter.new(code: 'ARG10000', reason: 'error_code is required') if params[:error_code].blank?

  capture = Capture.find(params[:capture_id])
  issue_data = capture.create_issue(error_code: params[:error_code], issued_by: @current_user.email, validate_reprocess: false, async: false)
  raise Cupix::Errors::Entity.new(code: 'ENT10001', reason: 'issue already exists on this model') if issue_data.nil?

  render_json 200, issue_data['result'].as_json
end

Issue Service (Lambda):

src/lambda/issue/create.ts:65-112typescript
export const handler = async (event: APIGatewayEvent) => {
  const data = JSON.parse(event.body || "{}");
  // ... validation ...

  // Step 1: IssueType lookup (DynamoDB GetItem or Query)
  issueType = await findIssueType(data.issue_type_id, data.error_code);

  // Step 2: BatchWrite (issue + issueType link)
  const client = new DynamoDBClient(dynamoDBClientConfig);
  await client.send(new BatchWriteItemCommand(params));

  // Step 3: Redundant read-back (3 additional DynamoDB queries)
  const issue = await getIssueWithFullInfo(client, issueId);

  // Step 4-5: Sequential await on async Lambda invocations
  await sendEmailNotification(event.headers?.Authorization, issueId);
  if (!issue.issue_type.error_code?.startsWith("AGT")) {
    await sendMondayNotification(issueId);
  }

  return success(issue);
};

주요 병목 — getIssueWithFullInfo() (write-then-read anti-pattern):

src/common/dynamodb-helper.ts:68-98typescript
export const getIssueWithFullInfo = async (client: DynamoDBClient, issue_id: string) => {
  // Query 1: 방금 쓴 issue를 다시 조회
  const queryResult = await client.send(new QueryCommand(issueParams));
  const issue = queryResult?.find(item => item.SK === SortKey.metadata);

  // Query 2: IssueType by ID (이미 findIssueType에서 조회한 데이터)
  const issueTypeById = await getIssueTypById(client, issueTypeId);

  // Query 3: IssueType by errorCode (TODO: remove error_code search — 코드 주석 존재)
  const issueTypeByErrorCode = await getIssueTypByErrorCode(client, errorCode);

  return issue;
};

주요 병목 — Sequential Lambda Invoke await:

src/common/lambda-helper.ts:4-15typescript
export const sendEmailNotification = async (authorization, issueId) => {
  const lambdaClient = new LambdaClient();
  const params = {
    FunctionName: ENV.EMAIL_SEND_LAMBDA_NAME,
    InvocationType: InvocationType.Event,  // async, fire-and-forget
    Payload: JSON.stringify({ authorization, issue_id: issueId })
  };
  await lambdaClient.send(new InvokeCommand(params));  // 그럼에도 await 대기
};

InvocationType.Event는 Lambda 실행 결과를 기다리지 않지만, Lambda Invoke API 자체의 HTTP 응답(202 Accepted)을 await하므로 Lambda 서비스 API 레이턴시(~100-500ms/회)가 추가된다. 이 호출이 순차적으로 2번 실행된다.

주의: await 제거 불가 — Node.js 20.x Lambda async handler는 handler가 return하면 런타임이 실행 환경을 즉시 freeze한다. await 없이 lambdaClient.send()를 호출하면 HTTP 요청이 wire에 전송되기 전에 환경이 frozen될 수 있어, 이메일/Monday 알림이 비결정적으로 누락된다. callbackWaitsForEmptyEventLoop은 callback-style handler에만 적용되며 async handler에서는 무효. 따라서 Promise.all()로 병렬화가 유일한 안전한 최적화 방법이다.

Lambda 인프라 설정:

modules/lambda/main.tf:14hcl
timeout = 120

Lambda timeout은 120초로 충분하나, Provisioned Concurrency 미설정 — cold start 방지 설정 없음.

Log Evidence#

Datadog에서 확인한 요청별 타이밍 분석:

text
service:cupixworks-api resource_name:"Api::V1::IssuesController#create" env:production @duration:>500ms

요청 ID b459c7c5 (2558ms total)의 로그 타임라인:

text
04:09:16.288Z  Cupix::IssueService.create_issue  "create issue for capture"
04:09:18.290Z  Cupix::IssueService._post          "post message to issue service successful."
04:09:18.290Z  Capture.reset_parent_cached_entity_updates
04:09:18.290Z  Class._create_issue_in_worker      "issue is created"
04:09:18.339Z  [request complete]                  duration=2557.91ms

create_issue 시작(16.288Z)부터 _post 완료(18.290Z)까지 약 2002ms — 이 단일 HTTP 호출이 전체 응답 시간의 78%를 차지한다.

세 건의 요청 타이밍 비교:

text
| Timestamp           | Duration | DB Time  | View Time | Region           |
|---------------------|----------|----------|-----------|------------------|
| 2026-05-28T04:22:43Z | 2612ms  | 36.25ms  | 0.2ms     | ap-southeast-1   |
| 2026-05-28T04:09:18Z | 2558ms  | 47.25ms  | 0.14ms    | ap-southeast-2   |
| 2026-05-28T03:58:34Z | 2344ms  | 14.5ms   | 0.15ms    | ap-southeast-2   |

Issue Service 내부 추정 타이밍 (코드 분석 기반):

text
| 단계                            | 예상 소요    | 비고                                      |
|---------------------------------|-------------|-------------------------------------------|
| API Gateway + Lambda cold start | 500~1500ms  | Provisioned Concurrency 미설정             |
| findIssueType (DynamoDB)        | 20~50ms     | GSI Query 1회                              |
| BatchWriteItem                  | 20~30ms     | 2 items                                   |
| getIssueWithFullInfo            | 60~90ms     | 3 DynamoDB queries (2회 중복)              |
| sendEmailNotification (await)   | 100~500ms   | Lambda Invoke API HTTP 응답 대기           |
| sendMondayNotification (await)  | 100~500ms   | Lambda Invoke API HTTP 응답 대기           |
| **합계**                         | **800~2670ms** | Cold start 유무에 따라 변동               |

issue-service는 Datadog에 별도 로그를 전송하지 않아 내부 timing을 직접 확인할 수 없으나, 코드 분석 기반으로 cold start + redundant queries + sequential await가 ~2000ms의 원인으로 판단된다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 외부 Issue Service HTTP POST 동기 호출이 병목 로그에서 _post 호출 2002ms 소요 확인, DB는 14~47ms로 정상, 두 리전 모두 동일 패턴 Confirmed
H2 Lambda cold start가 주요 지연 원인 arm64 + Node.js Lambda, Provisioned Concurrency 미설정, 일관된 ~2000ms 패턴 매번 cold start라면 편차가 더 클 수 있으나, 호출 빈도가 낮아 cold start 가능성 높음 Likely
H3 getIssueWithFullInfo() write-then-read 중복 조회 코드에서 방금 쓴 데이터를 3번 재조회 확인 (dynamodb-helper.ts:68-98), TODO 주석도 존재 (line 89) DynamoDB 자체 latency는 ~90ms로 전체 대비 작음 Contributing
H4 sendEmailNotification/sendMondayNotification sequential await InvocationType.Event임에도 await로 Lambda Invoke API 응답 대기 (lambda-helper.ts:13,26). 순차 실행으로 ~200-1000ms 추가 await 자체는 제거 불가(async handler freeze 문제) — Promise.all()로 병렬화만 가능 Contributing
H5 N+1 쿼리로 인한 DB 지연 (cupixworks-api 측) 코드에서 capture.facility, capture.user 등 lazy load 존재 DB 시간 14~47ms로 전체 대비 미미 Rejected

Fix Recommendation#

Issue Service 측 개선 (Priority 1 — 가장 큰 효과)#

1. getIssueWithFullInfo() 제거 — write-then-read anti-pattern 해소

  • src/lambda/issue/create.ts:101 — 방금 write한 데이터를 다시 read할 필요 없음
  • findIssueType()에서 이미 조회한 issueType 데이터를 in-memory로 조합하여 응답 구성
  • 예상 효과: DynamoDB 3회 조회 제거 (~60-90ms 절감)

2. sendEmailNotification/sendMondayNotification 병렬화 (Promise.all)

  • src/lambda/issue/create.ts:103-105 — 두 호출을 Promise.all()로 병렬 실행
  • await 제거(fire-and-forget)는 불가 — Node.js 20.x async handler에서 await 없이 반환하면 Lambda 런타임이 즉시 실행 환경을 freeze하여 in-flight HTTP 요청이 완료되지 않을 수 있음. InvocationType.Event라도 Lambda Invoke API에 HTTP 요청을 전송하는 단계는 반드시 완료되어야 대상 Lambda가 trigger됨.
  • Best practice: Promise.all([sendEmailNotification(...), sendMondayNotification(...)]) 로 병렬 await — 두 API 호출이 동시에 진행되면서도 모두 완료를 보장
  • 예상 효과: 순차 대기 ~200-1000ms → 병렬 시 ~100-500ms (약 50% 절감)

3. DynamoDBClient 재사용 (singleton)

  • src/lambda/issue/create.ts:99, dynamodb-helper.ts 내 각 함수에서 매번 new DynamoDBClient() 생성
  • Lambda 모듈 스코프에 단일 client 선언하여 warm start 시 connection 재사용
  • 예상 효과: warm start 시 ~5-10ms 절감

cupixworks-api 측 개선 (Priority 2)#

4. async: falseasync: true 전환

  • app/controllers/api/v1/issues_controller.rb:18 — Sidekiq worker로 위임
  • 응답에 issue 데이터가 필요하다면 202 Accepted + polling 또는 webhook 패턴으로 전환
  • 예상 효과: API 응답 시간 2500ms → ~50ms

인프라 개선 (Priority 3)#

5. Lambda Provisioned Concurrency 설정

  • modules/lambda/main.tfprovisioned_concurrent_executions = 1 추가
  • 호출 빈도가 낮아 cold start가 빈번한 상황에 효과적
  • 예상 효과: cold start ~500-1500ms 제거

6. Issue Service에 Datadog 모니터링 추가

  • Lambda에 Datadog Extension 또는 CloudWatch Logs → Datadog 연동
  • 내부 timing을 직접 측정 가능하게 하여 병목 구간 정밀 진단

장기 개선 (재발 방지)#

  • API endpoint에서 외부 서비스 동기 호출 패턴을 제거하는 가이드라인 수립
  • Issue Service에 circuit breaker 패턴 적용

Monitoring#

  • 추가할 메트릭: IssuesController#create p95/p99 latency 알림 (임계값 1000ms)
  • Datadog 쿼리 예시:
text
service:cupixworks-api resource_name:"Api::V1::IssuesController#create" env:production @duration:>1000ms
  • Issue Service Lambda 모니터링 (CloudWatch):
text
AWS/Lambda FunctionName=issue-service-*-issue_create Duration, ColdStart

Risk Assessment#

  • Risk level: low
  • 예상 복잡도:
    • Issue Service 측 (Priority 1): moderate — getIssueWithFullInfo 제거 + Lambda invoke await 제거는 코드 3군데 수정
    • cupixworks-api 측 (Priority 2): trivial — async: falsetrue 1줄 수정 (단, caller가 응답 데이터를 사용하는지 확인 필요)
    • 인프라 (Priority 3): trivial — Terraform 1줄 추가

Revision History#

Revision 1#

Feedback: cupixworks/applications/issue-service 코드를 분석하여 개선여지를 판단하고, 로그에서 어디서 오래걸렸는지 확인

판정:

피드백 항목 판정 근거
issue-service 코드 분석으로 개선여지 판단 수용 src/lambda/issue/create.ts:99-106에서 write-then-read anti-pattern 확인 — getIssueWithFullInfo()가 방금 쓴 데이터를 DynamoDB에서 3회 재조회(dynamodb-helper.ts:77,87,91). lambda-helper.ts:13,26에서 InvocationType.Event임에도 await로 순차 대기. modules/lambda/main.tf:14에서 Provisioned Concurrency 미설정 확인. 총 3가지 개선 포인트 식별.
로그에서 어디서 오래걸렸는지 확인 부분 수용 cupixworks-api 측 Datadog 로그에서 _post 호출이 2002ms 소요 확인(04:09:16.288Z→04:09:18.290Z). 그러나 issue-service 자체는 Datadog에 로그를 전송하지 않아 내부 단계별 timing을 직접 측정할 수 없음. 코드 구조 분석 기반으로 cold start(500-1500ms) + DynamoDB 4-5회(~150ms) + Lambda Invoke API 2회(~200-1000ms)로 추정 타이밍 산출.

변경 사항:

  • Root Cause Summary: issue-service의 구체적 아키텍처(Lambda + API Gateway + DynamoDB)와 내부 처리 단계별 소요 시간 추정 추가
  • Technical Analysis > Code Path: issue-service Lambda 코드 (create.ts, dynamodb-helper.ts, lambda-helper.ts) 분석 추가, write-then-read anti-pattern과 sequential await 병목 식별
  • Technical Analysis > Log Evidence: issue-service 내부 추정 타이밍 테이블 추가
  • Hypotheses Considered: H2(Lambda cold start), H3(write-then-read), H4(sequential await) 가설 추가
  • Fix Recommendation: issue-service 측 코드 개선(Priority 1) 3항목, 인프라 개선(Priority 3) 2항목 추가. 기존 권장사항을 Priority 2로 재배치

추가 조사 내용:

  • /home/ec2-user/repos/cupixworks/applications/issue-service/ 레포 전체 탐색
  • src/lambda/issue/create.ts — POST /issues Lambda handler 분석
  • src/common/dynamodb-helper.ts — DynamoDB 조회 패턴 분석 (getIssueWithFullInfo의 중복 조회, TODO 주석 확인)
  • src/common/lambda-helper.ts — email/monday 알림 호출 패턴 분석
  • modules/lambda/main.tf — Lambda timeout(120s) 및 Provisioned Concurrency 미설정 확인
  • main.tf — DynamoDB PAY_PER_REQUEST 설정, Lambda 모듈 구성 확인
  • Datadog 로그 검색: issue-service는 별도 Datadog 서비스로 등록되어 있지 않음 확인

Revision 2#

Feedback: lambda 에서 await 을 빼면 fire 되기전에 lambda 가 먼저 끝나서 메일 전송이 안되는거 같은데 best practice 가 뭔지 검토

판정:

피드백 항목 판정 근거
await 제거 시 Lambda 종료로 메일 미전송 우려 수용 src/lambda/issue/create.ts:65의 handler는 async (event: APIGatewayEvent) 시그니처로, Node.js 20.x async handler 패턴 사용(variable.tf:34nodejs20.x). AWS Lambda async handler는 handler 함수가 return하면 런타임이 실행 환경을 즉시 freeze한다. lambda-helper.ts:13await lambdaClient.send(new InvokeCommand(params))에서 await를 제거하면, create.ts:108return success(issue)가 먼저 실행되어 handler가 종료되고, Lambda Invoke API로의 HTTP 요청이 wire에 전송되기 전에 환경이 frozen될 수 있다. callbackWaitsForEmptyEventLoop은 callback-style handler에만 적용되며, 이 코드에서는 context 파라미터를 사용하지 않음(handler 시그니처에 context 없음). 따라서 기존 RCA의 "await 제거" 권장은 잘못됨.
best practice 검토 수용 AWS Lambda Node.js best practice: (1) Promise.all()로 독립적인 async 호출을 병렬 await — 모든 호출 완료를 보장하면서 순차 대기 제거. (2) InvocationType.Event + await는 대상 Lambda 실행을 기다리지 않으면서(202 즉시 반환) 호출이 Lambda 서비스에 안전하게 전달됨을 보장하는 올바른 패턴. 현재 코드의 문제는 await 존재가 아니라 순차 실행Promise.all([sendEmailNotification(...), sendMondayNotification(...)])로 변경하면 ~100-500ms 절감 가능.

변경 사항:

  • Technical Analysis > Code Path: await 제거 불가 사유에 대한 상세 설명 추가 (Node.js 20.x async handler freeze 동작, callbackWaitsForEmptyEventLoop 미적용 설명)
  • Hypotheses Considered > H4: "Evidence against" 컬럼에 await 제거 불가 사유 반영
  • Fix Recommendation > Priority 1 항목 2: "await 제거 (fire-and-forget)" 옵션 삭제, Promise.all() 병렬화만 권장으로 수정. 예상 효과도 "~0ms" 제거하고 "~100-500ms (약 50% 절감)"으로 변경

추가 조사 내용:

  • variable.tf:34 — Lambda 런타임 nodejs20.x 확인
  • src/lambda/issue/create.ts:65 — handler 시그니처에 context 파라미터 없음 확인 (callbackWaitsForEmptyEventLoop 미사용)
  • src/common/lambda-helper.ts:1-28 — 전체 코드 재검토, InvocationType.Event + await 조합의 동작 분석
  • AWS Lambda Node.js async handler 동작: handler return 시 event loop drain 없이 즉시 freeze됨 확인