Skip to content

업로드 확인 폴링에 지수 백오프 도입 - #1032

Open
m-a-king wants to merge 14 commits into
devfrom
refactor/upload-poll-backoff
Open

업로드 확인 폴링에 지수 백오프 도입#1032
m-a-king wants to merge 14 commits into
devfrom
refactor/upload-poll-backoff

Conversation

@m-a-king

@m-a-king m-a-king commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • 이미지 등록의 폴링 백스톱이 확인 대상을 매 주기(1초) 다시 집어 S3 HEAD 를 친다. 확인 결과를 어디에도 남기지 않으니, 안 올라온 key 는 만료(7분)까지 같은 질문을 반복해서 받는다.
  • 계산하면 key 하나당 약 405회다. 발급 5장을 받고 업로드를 안 하면 2000회가 나간다. S3 는 요청 건당 과금이라 그대로 비용이다.
  • 발급(presigned URL 생성) 자체는 로컬 서명 계산이라 비용이 없다. 비용은 발급이 아니라 "아직 안 올라왔다"를 서버가 반복해서 확인하는 데서 난다.
  • 백스톱은 confirm 이 유실된 예외 경로를 위한 장치인데, 정상 경로까지 그 비용을 함께 내고 있었다.

Task

  • 확인 빈도를 줄이되 검출은 느려지지 않게 한다. 단순히 주기를 30초로 늘리면 confirm 유실 건의 등록이 그만큼 밀린다.
  • 폴링 상태를 어디에 어떤 형태로 둘지 정한다.

Action

다음 확인 시각을 행에 남긴다

pending_uploadsnext_check_at 을 두고, 헛칠 때마다 뒤로 민다.

fun backOffCheck(now: LocalDateTime) {
    nextCheckAt = now.plus(sinceIssued(now))
}
  • 규칙은 하나다. 지금까지 기다린 만큼 더 기다린다. 그래서 확인 시각이 1, 2, 4, 8 초로 저절로 두 배씩 벌어진다.
  • 시도 횟수 컬럼이 따로 필요 없다. 한 배치의 여러 장이 같은 created_at 을 공유하므로 일정도 함께 움직여, 그룹이 쪼개져 정원 판정이 갈리는 일이 없다.
  • 간격에 상하한을 두지 않는다. 상한 120초를 검토했으나 7분 수명에서 확인을 9회에서 10회로 늘릴 뿐이고 최악 검출 지연도 128초 대 120초라 차이가 없었다. 상한을 넘겨 만료까지 못 보는 건도 expireStale 이 마지막 존재 확인을 하므로 유실되지 않는다.

저장 형태 결정

답하는 질문 판정
next_check_at 언제 다시 볼 것인가 채택. 폴러가 던지는 질문이 이것 하나다
확인 횟수 몇 번이나 헛쳤는가 미채택. 확인에 상한이 없어(종결은 expires_at 이 낸다) 아무도 안 읽는다
백오프 시작 시각 언제부터 재는가 불필요. created_at 이 이미 담고 있다
  • 횟수만 저장하면 조회 서술어가 created_at + f(count) <= now 라는 계산식이 되어 인덱스를 못 쓴다. 결국 파생값을 다시 저장하게 된다.
  • DB 작업 큐들(Solid Queue, GoodJob, Oban, SQS visibility timeout)이 공통으로 "다음에 보일 시각"을 저장하는 것과 같은 이유다. 횟수도 같이 두는 곳이 있지만 그건 재시도 상한과 관측용이지 일정 계산용이 아니다.

함께 잡은 결함

  • 한 배치에서 일부만 올라온 경우 남은 것이 미뤄지지 않았다. 올라온 것은 claim 으로 사라지는데 남은 것은 next_check_at 이 과거인 채 방치돼, 백오프를 넣고도 그 경로만 만료까지 매 주기 두드렸다. 백오프 도입과 함께 들어간 결함이라 같은 PR 에서 잡는다.

정리된 것

  • POLL_GRACE(15초)가 사라졌다. 발급 시 next_check_at = now + 1초 로 박히니 그 개념이 컬럼 안으로 흡수됐고, 조회 파라미터도 하나 줄었다.
  • 첫 확인이 15초에서 1초로 당겨져 confirm 유실 건의 검출은 오히려 빨라졌다.

Result

안 올라온 key 하나당 HEAD 약 405회 약 9회
첫 확인 시각 발급 후 15초 발급 후 1초
  • 부분 그룹 결함은 negative control 로 확인했다. 수정을 되돌리면 새 테스트 하나만 깨진다.
  • 검토 중 발견: PendingUploadimage/domain/ 에 있지만 역할은 작업 큐다(코드 주석도 스스로 그렇게 부른다). 폴링 상태를 얹는 것 자체는 제자리지만 패키지 위치가 역할과 어긋나 있다. 임포트가 여러 곳 바뀌어 별도 건으로 둔다.
  • 후속 후보: 확인 빈도를 더 줄이려면 S3 이벤트 알림이 정석이다(폴링 자체가 없어지고 검출이 즉시가 된다). 인프라가 늘어나므로 백스톱이 실제로 아파질 때 간다. 그 전에 더 싼 개선은 클라이언트의 confirm 재시도다. confirm 은 claim 기반이라 멱등이므로 안전하고, 그것만으로 "confirm 유실" 자체가 급감한다.

연관 이슈

- 판정 기준은 "숙련 개발자가 이 주석 없이 놓칠 정보가 있는가" 하나로 뒀다
- toResponse 위 두 줄은 앞 문장이 함수명 복창이고 뒷 문장은 DTO companion 이 빈을 못 쓴다는 Spring 기본기라 통째로 삭제
- registerFromUrl 의 attach 메타 설명은 fromRegistration 이라는 이름이 이미 등록 전용임을 말하고 실리는 값은 DTO 를 열면 보여 삭제
- confirmImageRegistration 의 201 설명은 바로 아랫줄 @ResponseStatus(CREATED) 의 복창이라 삭제
- presign 의 200 사유만 한 줄로 압축해 남겼다. 애노테이션의 부재는 실수와 구별되지 않아, 없으면 다음 사람이 빠뜨린 줄 알고 201 을 붙인다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 위시(registerFromUrl)와 토너먼트(addItemFromLink)가 parse + verifyRegistrable 을 각자 베껴 쓰고 있었다. 주석까지 거의 글자 그대로 같아, 같은 지식이 두 곳에 있다는 신호로 보고 관문을 뽑았다
- 이 관문의 존재 이유는 순서다. 형식·정책 위반과 중복은 차감 앞에서 걸러야 한다(#973) — 뒤로 가면 등록되지도 않을 요청이 사용자 몫을 깎는다. 두 호출자가 그 순서를 각자 외우던 동안 자격 검사 위치가 이미 어긋나 있었다(위시는 맨 앞, 토너먼트는 verifyRegistrable 뒤)
- 차감은 처음엔 도메인에 두려 했다. 주인과 에러 코드가 도메인마다 달라 옮겨도 인자로 되돌아온다고 봤기 때문인데, 지켜야 할 것이 인자가 아니라 순서라 관문 안으로 넣었다
- 중복 판정만 콜백으로 남겼다. 기준이 도메인마다 다르고(내 위시 대 이 토너먼트) 차감 앞에 와야 해서, 인자로도 밖으로도 뺄 수 없는 자리다
- 반환형은 Item 이 아니라 ProductLink 로 했다. 저장하지 않은 Item 을 돌려주면 만든 것처럼 읽히고, persistLinkItem 이 link 를 받아 되레 풀어야 했다
- 토너먼트의 verifyCanAddItems 를 관문 앞으로 당겨 위시와 순서를 맞췄다. 참여자가 아닌 사람이 차단 도메인 URL 을 넣으면 이전에는 400(미지원 플랫폼), 이제는 권한 오류가 먼저 난다
- DomainAccessPolicy 의존이 위시·토너먼트 양쪽에서 사라졌다. ItemQuotaGuard 는 이미지 presign 이 장수만큼 따로 차감해 남는다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- ItemRegistrar.accept 이 quotaOwner 와 quotaErrorCode 를 따로 받고 있었는데, 관문은 둘을 쓰지 않고 ItemQuotaGuard 에 그대로 흘려보내기만 했다. 순수한 통과 인자 둘이 시그니처에 새어 있던 셈이다
- 둘은 함께 정해져야 하는 한 덩어리다. 문구가 주인을 전제하기 때문이다 - TOURNAMENT-037 은 차감 주체가 오너인데 응답은 게스트 참여자도 받으므로 남의 사용량을 감추는 문구를 쓴다. 따로 넘기면 위시 주인에 토너먼트 코드를 실어도 컴파일된다
- 두 code 의 문구가 실제로 다른 것을 확인하고 파라미터화를 유지했다. 주인이 요청자인지 남인지 하나로 갈라 문구를 공통화하는 안도 있으나 와이어 계약 변경이라 남겨 둔다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 관문이 콜백을 받던 모양을 걷어내고 판정 전용으로 좁혔다. 호출부가 parse - 중복 - accept - persist 로 위에서 아래로 읽힌다
- 차감을 호출 도메인에 두는 안도 검토했으나, 한도는 아이템 등록의 관심사라 위시·토너먼트로 다시 올리는 것은 방향이 반대다. 관문 안에 두고 몫의 주인만 인자로 받는다
- WISH-010 과 TOURNAMENT-037 을 ITEM-006 하나로 합쳤다. 카운터가 하나인데 담는 자리마다 code 를 나눌 이유가 없고, 이 인자가 있어야 했던 유일한 이유가 code 가 둘이라는 것이었다
- 합친 문구는 몫의 주인을 드러내지 않는 쪽으로 골랐다. 토너먼트는 오너 몫에서 깎지만 응답은 참여 게스트도 받으므로, 옛 위시 문구("더 담을 수 없어요")를 그대로 쓰면 남의 사용량이 새는 자리가 된다
- client repo 에서 두 code 참조가 0건인 것을 확인하고 진행했다. 응답 code 값이 바뀌는 와이어 변경이다
- 중복 판정이 관문 밖으로 나오면서 차감 앞이라는 순서는 호출자가 진다. 두 호출자 모두 그 순서를 지키고 있다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- persist 가 이름은 저장을 약속하면서 안에서 다섯 가지를 했다. 붙는 길(attachToShared)과 새로 세우는 길(createFresh)로 갈라 본체를 세 줄로 줄였다
- manualEdit·updateMemo·refresh 가 snapshot·item 을 각자 조회하며 같은 error 문구를 세 벌 쓰고 있어 activeVersionOf 로 묶었다. updateMemo 는 본체가 네 줄이 됐다
- 주석 65줄을 32줄로 줄였다. 남긴 것은 코드가 답할 수 없는 것들이다 - 사전 확인이 락 밖이라 근사치라는 것(중복 검사가 두 번 도는 이유), 중복 판정 기준이 shared 가 아니라 attachment.item 이라는 것(병합 경합의 승자·패자), updateMemo 가 포인터를 안 바꾸는데도 락이 필요한 이유(전 컬럼 UPDATE 라 lost update), saveAll 반환 순서가 계약이 아니라는 것
- persist 의 rejectIfWithdrawnForUpdate 를 지웠다. 이 가드가 실제로 보장하는 것은 "cascade 가 wishes 를 지운 뒤 새 wish 행이 끼어들지 않는다" 하나뿐인데, 파싱 실행·한도 차감·고아 snapshot 은 전부 락 밖이라 못 막는다(item/service 에 유저 검사 0건). #776 본문도 이 선택지를 "경합 자체를 없애진 못하고 창을 좁힌다" 로 적어 뒀다
- 한 구멍만 막고 "탈퇴 경합을 막는다" 로 읽히면 보장 범위를 실제보다 넓게 믿게 된다. 탈퇴 후 남는 wish 행은 배치 정리로 푼다
- 그에 맞춰 UserWithdrawalRaceConcurrencyIntegrationTest 의 URL 등록 경합 케이스를 걷어냈다. 프로필·지연 이미지·FCM 세 경합은 가드가 그대로라 유지된다
- 지연 이미지 경로의 isActiveForUpdate 는 남긴다. 스케줄러 공용이라 예외를 던지면 롤백이 claim 을 되살려 무한 재시도가 되는, 성격이 다른 가드다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- persist 는 Item 을 받으면서 link 가 null 인 경우를 분기하고 있었으나, 호출자 16곳이 전부 Item(link) 를 만들어 넘긴다. 이미지 경로는 persistImagesInternal 로 따로 가므로 link 없는 Item 은 들어올 수 없다
- 받을 수 없는 값을 받는다고 선언해 둔 탓에 널 분기가 생겼고, null 이 두 가지 이유(링크가 없다·붙을 데가 없다)로 뭉개져 읽기 어려웠다. 링크를 직접 받게 하니 본체가 한 줄이 된다
- Item 생성이 createFresh 안으로 들어갔다. 위시는 서비스에서 Item 을 만들고 토너먼트는 영속화 안에서 만들던 비대칭이 함께 정리된다
- WishlistService 는 Item 을 더 이상 알지 않는다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- LINK-001(형식)·LINK-002(스킴)·빈 값·길이 네 갈래가 각각 다른 code·detail 로 나가는데, HTTP 레벨 검증이 없어 파싱 위치를 옮기면 조용히 뭉개진다. 실제로 ProductLink 파싱을 역직렬화로 옮겨 보니 LINK-001 이 COMMON-INVALID-INPUT 으로 바뀌는 것을 이 테스트가 잡았다
- 스킴 없는 상대 URI("example.com/...")는 URI.create 가 통과시켜 형식이 아니라 스킴 오류로 떨어진다. 형식 오류를 재려면 host 에 공백이 든 입력이어야 한다는 것도 함께 고정한다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 두 진입점이 content-type 을 검증해 결과를 버리고, presignRawUploads 가 같은 검증을 다시 했다. "이 중복은 무해하다" 는 주석이 그 구조를 변호하고 있었다
- UploadFormat 을 두어 검증 결과(확장자)를 실어 나른다. 검증이 차감 앞이라는 순서는 그대로고, 결과를 쓰니 중복이 사라져 변호할 주석도 없어졌다
- ImagePresignService 26줄 -> 6줄. 남긴 것은 raw 회수를 여기서 안 한다는 것(부재라 코드가 못 말한다), presign 이 로컬 계산이라 트랜잭션에 묶어도 되는 근거, 두 발급 메서드의 차이, key 정규식이 ProductImage 에서 파생된다는 것
- PendingUploadPollingScheduler 는 @async 로 바꾸면 안 되는 이유만 남겼다. 재진입 가드가 async body 로 들어가 무력해지고 fixedDelay 가 fixedRate 가 되는데, 코드만 봐서는 executor.execute 를 @async 로 "정리" 하고 싶어진다
- 지운 것은 메서드 이름이 이미 말하던 것들이다. 만료 경로의 네 갈래 열거는 아래 if/else 와 로그가 같은 말을 하고 있었다
- PendingUploadClaimer 는 삭제가 곧 claim 이라는 것과 자기 트랜잭션을 열면 안 되는 이유만 남겼다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 앞 커밋이 공유 프리미티브만 정리하고 진입점 두 곳을 빠뜨려 기준이 반쪽으로 적용돼 있었다
- 지운 것은 세 부류다. 메서드 이름과 본문이 이미 말하는 것(발급 설명·확정 설명·만료 처리 요약), 바로 아래 로그가 같은 말을 하는 것(영구 사유·재시도), 다른 주석과 같은 사실을 두 번 말하는 것(정원 판정이 갈린다)
- existsOrNull 을 uploadedOrUnknown 으로 바꿔 "null 은 판단 못 함" 주석을 이름으로 옮겼다
- UploadFormat 은 private 생성자와 of 팩토리가 그 말을 하고 있어 주석을 없앴다
- 남은 것은 넷뿐이다. @async 로 바꾸지 말 것(하지 말라는 지시라 코드에 없다), 등록에 못 매인 raw 를 여기서 안 지운다는 것(부재), presign 이 로컬 계산이라 트랜잭션에 묶어도 된다는 것(외부 호출처럼 보이는 착시), 삭제가 곧 claim 이고 자기 트랜잭션을 열면 안 된다는 것(전파 속성이 코드에 안 보인다)
- 조회·수기수정·삭제 경로의 주석은 별개 플로우라 이번 범위에서 제외했다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- data class 는 생성자가 private 이어도 copy() 가 공개라 검증을 우회한다. @ConsistentCopyVisibility 로 막는다
- requireMember 는 모든 진입 메서드가 첫 줄에서 부르는 손으로 짠 애스펙트라 TODO 로 표시했다
- 발급 시점 차감 근거는 커밋 이력과 이슈에 남으므로 코드에서 뺀다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
# Conflicts:
#	src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt
#	src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt
#	src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt
- 폴링이 안 올라온 pending 을 만료(7분)까지 매 주기(1초) 확인했다. key 하나가 S3 HEAD 를 400회 넘게 냈고, 발급 5장을 버리면 2000회다. 확인 자체가 요청 건당 과금이라 그대로 비용이다
- next_check_at 컬럼을 두고 헛칠 때마다 뒤로 민다. 다음 간격을 "발급 후 이미 지난 시간" 으로 두면 1, 2, 4, 8 초로 저절로 두 배씩 벌어져 시도 횟수 컬럼이 필요 없다. 7분 동안 아홉 번쯤 확인한다
- 첫 확인이 15초에서 1초로 당겨져 confirm 유실 건의 검출은 오히려 빨라졌다. POLL_GRACE 는 발급 시 next_check_at 에 흡수돼 쿼리 파라미터에서 사라졌다
- 한 배치에서 일부만 올라온 경우 남은 것이 미뤄지지 않는 결함을 함께 잡았다. 올라온 것은 claim 으로 사라지는데 남은 것은 next_check_at 이 과거인 채 방치돼, 백오프를 넣고도 그 경로만 매 주기 두드리고 있었다. 되돌리면 해당 테스트만 깨지는 것으로 확인했다
- 횟수·시작시각을 따로 저장하는 안도 검토했다. 시작시각은 created_at 이 이미 담고, 횟수는 확인에 상한을 두지 않아(종결은 expires_at 이 낸다) 지금은 아무도 읽지 않는 사실이 된다. 쿼리도 계산 서술어가 되어 인덱스를 못 쓴다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
@m-a-king m-a-king added the perf 성능 개선 (측정 가능, 외부 동작 불변) label Sep 4, 2026
@m-a-king m-a-king self-assigned this Sep 4, 2026
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Team

Run ID: b1cf6ddf-e345-4670-a9cf-a966451f1d2c


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ackoff

# Conflicts:
#	src/main/kotlin/com/depromeet/piki/image/service/PendingUploadPollingScheduler.kt
- 지난 시간만큼 더한다는 규칙 하나면 충분한데 coerceIn 이 붙어 있어 규칙이 안 읽혔다
- 상한 120초는 7분 수명에서 확인을 9회에서 10회로 늘릴 뿐이고 최악 검출 지연도 128초 대 120초라 차이가 없다. 상한을 넘겨 만료까지 못 보는 건도 expireStale 이 마지막 존재 확인을 하므로 유실되지 않는다
- 하한 1초는 now - createdAt 이 첫 확인 때 이미 1초 근처라 닿을 일이 없다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf 성능 개선 (측정 가능, 외부 동작 불변)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant