Skip to content

fix: 코스 상세·목록 응답에 shareToken 을 채운다 - #260

Merged
sevineleven merged 4 commits into
devfrom
fix/259-share-token-in-read-responses
Aug 13, 2026
Merged

fix: 코스 상세·목록 응답에 shareToken 을 채운다#260
sevineleven merged 4 commits into
devfrom
fix/259-share-token-in-read-responses

Conversation

@sevineleven

Copy link
Copy Markdown
Contributor

Situation

  • 공유 토큰이 저장 응답에만 실렸다. 상세는 필드가 있어도 항상 null 이었고, 목록에는 필드조차 없었다.
  • 그래서 저장하는 그 순간 토큰을 받지 못하면 그 코스는 영영 공유할 수 없었다. 앱을 지웠다 다시 깔거나 기기를 바꾸면 이전에 저장한 코스가 전부 그랬다.
  • 프론트는 저장 응답의 토큰을 기기 로컬(Keychain)에 courseId → shareToken 으로 적어두고 버티는 중이었다. 서버가 채워주면 그 저장소는 지운다.

Task

토큰을 조회 응답에 싣는 것은 처음부터 "저장 응답에만" 으로 못박혀 있던 설계라(코드 주석에 "소유자에게만" 이라고 적혀 있다), 그냥 null 을 채우기 전에 그 판단이 지금도 맞는지부터 봤다. 확인한 것 넷.

질문 확인한 것 결론
목록에 실어도 안전한가 목록·상세 조회가 전부 guestId 범위 쿼리다. 남의 코스가 섞일 길이 없다 안전하다. 싣는다
공유 링크로 여는 응답은 링크를 받은 사람은 수정·삭제를 못 하고, 토큰은 이미 자기가 연 URL 에 있다 계속 싣지 않는다
토큰이 없는 기존 코스는 저장 API 는 항상 토큰을 붙이므로, 공유 행이 없는 코스는 공유 기능 도입(#143) 이전 저장분뿐이다 상세를 열 때 발급한다
목록의 N+1 페이지당 최대 100건. 코스마다 물으면 요청 하나가 쿼리 백 번이 된다 한 번에 모아 읽는다

"토큰이 있다" 와 "공개됐다" 는 다르다. 링크를 넘겨야 비로소 남이 볼 수 있고, 소유자 본인에게 자기 링크를 돌려주는 것이라 노출면이 넓어지지 않는다.

Action

어디에 싣고 어디에 안 싣는가

응답 shareToken 없으면
저장 POST /courses 실린다 (기존) 발급
상세 GET /courses/{id} 실린다 발급
목록 GET /courses 실린다 null 로 둔다
날짜 수정 PATCH /courses/{id} 실린다 발급
공유 열람 GET /public/courses/{token} 싣지 않는다 해당 없음
  • 상세는 발급까지 한다. 없는 것을 null 로 두면 [feat] itinerary — 코스 공유 (웹뷰용 완성 화면 · 보기 전용) #143 이전 코스가 그대로 공유 불가로 남아, 이 PR 이 풀려던 문제가 절반만 풀린다. 조회가 쓰기를 하는 셈이지만 코스당 한 번뿐이고, 두 번째부터는 있는 것을 읽어 준다. 동시 발급 경합은 유니크 제약과 기존 재조회 로직이 이미 흡수한다.
  • 목록은 발급하지 않는다. 페이지당 최대 100건이라 발급까지 하면 조회 한 번이 그만큼의 INSERT 가 된다. 그런 코스는 상세를 한 번 열면 채워진다 — 이 사실을 API 문서에 적었다.
  • 날짜 수정도 싣는다. 응답 모양이 상세와 같아야 한다. 여기서만 빠지면 날짜를 고친 순간 화면의 공유 버튼이 사라진다.

쿼리를 늘리지 않기

  • 코스마다 공유 행을 묻는 대신 findByCourseIdIn 을 port·adapter에 더해 목록당 조회 한 번으로 묶었다. course_id 는 유니크 제약이 걸려 있어 인덱스를 그대로 탄다.
  • 내부용 전체 조회(allCourses)는 응답으로 나가는 길이 아니라 토큰을 아예 읽지 않는다. 읽어봐야 쿼리만 하나 더 는다.
  • 빈 목록이면 IN 절을 부르지 않고 끊는다.

거짓이 된 문서 정정

  • CourseResponse.shareToken 의 스키마 설명이 "저장 응답에만 실린다" 였다. 이 PR 로 거짓이 되므로 함께 고쳤다.
  • 같은 이유로 GeneratedCourse javadoc, CourseStorageApi 의 목록·상세·날짜수정 @Operation 설명을 갱신했다. 목록 항목의 새 필드는 @Schema 로 문서화했다.

검토했지만 하지 않은 것

왜 안 했나
마이그레이션으로 기존 코스 토큰 일괄 backfill 토큰 생성을 SQL 로도 하게 되어 "추측 불가능" 을 보장하는 자리가 둘로 갈린다. 그 불변식을 도메인 팩토리 하나가 갖는 것이 이 코드의 전제다
목록에서도 없는 토큰 발급 조회 한 번에 최대 100 INSERT
목록 항목에 NON_NULL 적용 이미 null 로 나가던 다른 선택 필드(travelDate·dDay 등)가 함께 사라져 기존 계약이 깨진다

Result

  • 프론트는 기기 로컬의 ShareTokenStore 를 지울 수 있다. 재설치·기기 변경 후에도 저장된 코스를 공유할 수 있다.
  • 목록의 shareToken 이 null 인 항목이 남을 수 있다(공유 기능 도입 이전 저장분). 상세를 한 번 열면 채워지며, 이 동작을 API 문서에 적어 뒀다.
  • 검증에서 비자명한 부분: 공유 행이 없는 상태는 저장 API 로 재현되지 않아(항상 토큰이 붙는다) 리포지토리로 직접 코스를 만들어 확인했다. 토큰은 "실린다" 가 아니라 저장 때와 같은 값인지 를 대조한다 — 매번 새로 발급하는 회귀는 먼저 뿌린 링크를 죽이는데, 존재만 보면 그걸 못 잡는다.

작업을 마치기 전 자문 셋

  1. 운영에서 버티는가 — 스키마 변경도 부팅 적재 변화도 없다. 늘어난 것은 목록 요청당 조회 한 번(유니크 인덱스 적중)과, 토큰 없는 코스를 처음 열 때의 INSERT 한 번뿐이다.
  2. 외부 API 한도 — 해당 없음. 외부 호출을 더하지 않았다.
  3. 코스의 완성도 — 코스 구성 자체는 그대로다. 코스와 무관한 공유 경로 개선이다.

연관 이슈

- 토큰이 저장 응답에만 실려, 그 응답을 놓친 코스는 영영 공유할 수 없었다. 앱을 다시 깔거나
  기기를 바꾸면 이전 코스가 전부 그랬고, 프론트는 저장 응답의 토큰을 Keychain 에 적어두고
  버티고 있었다
- 소유자 단건 응답(저장·상세·여행 날짜 수정)에 싣는다. 목록·상세 모두 guestId 범위로만
  조회하므로 남의 토큰이 나갈 길이 없다. 토큰이 있다고 공개되는 것도 아니다 —
  링크를 넘겨야 비로소 남이 볼 수 있다
- 공유 링크로 여는 공개 조회에는 계속 싣지 않는다. 링크를 받은 사람은 수정·삭제를 못 하고
  토큰은 이미 자기가 연 URL 에 있다
- 상세는 공유 행이 없으면 **그 자리에서 발급**한다. null 로 두면 #143 이전에 저장된 코스가
  그대로 공유 불가로 남는다. 조회가 쓰기를 하는 셈이지만 코스당 한 번뿐이고, 경합은
  이미 유니크 제약으로 흡수하고 있다
- 목록은 발급하지 않고 있는 것만 읽는다 — 페이지당 최대 100건이라 발급까지 하면 조회 한 번이
  그만큼의 INSERT 가 된다. 대신 코스마다 묻지 않고 findByCourseIdIn 으로 한 번에 모은다(N+1)
- 거짓이 된 문서를 함께 고쳤다: CourseResponse.shareToken 의 "저장 응답에만 실린다",
  GeneratedCourse javadoc, CourseStorageApi 의 목록·상세·날짜수정 설명
- 상세·목록 응답의 토큰이 저장 때와 **같은 값**인지 대조한다. "실려 있다" 만 보면
  매번 새로 발급하는 회귀를 못 잡고, 그러면 먼저 뿌린 링크가 죽는다
- 남의 게스트 ID 로는 목록이 비고 상세가 404 인지 확인한다 — 이번 변경으로 토큰이
  응답에 실리게 됐으니, 소유자 범위가 새면 링크까지 함께 샌다
- 공유 행이 없는 코스를 리포지토리로 직접 만들어, 상세를 열면 발급되고 두 번째 조회는
  같은 토큰을 주는지 본다. 저장 API 를 거치면 항상 토큰이 붙어 이 상태는 그 길로 재현되지 않는다
- 공개 조회에 토큰이 없다는 기존 단언은 그대로 둔다 — 이번 변경이 그리로 새지 않는지가 그 테스트다
@sevineleven sevineleven added the fix 버그 수정 label Aug 13, 2026
@sevineleven sevineleven self-assigned this Aug 13, 2026
@sevineleven sevineleven linked an issue Aug 13, 2026 that may be closed by this pull request
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sevineleven, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ca7b889d-2b55-4628-abe9-55971bd8bdfb

📥 Commits

Reviewing files that changed from the base of the PR and between 640fa37 and 214e3e2.

📒 Files selected for processing (11)
  • src/main/java/com/offway/core/itinerary/controller/CourseStorageApi.java
  • src/main/java/com/offway/core/itinerary/controller/dto/CourseResponse.java
  • src/main/java/com/offway/core/itinerary/controller/dto/CourseSummaryResponse.java
  • src/main/java/com/offway/core/itinerary/repository/CourseShareJpaRepository.java
  • src/main/java/com/offway/core/itinerary/repository/CourseShareRepository.java
  • src/main/java/com/offway/core/itinerary/repository/CourseShareRepositoryImpl.java
  • src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java
  • src/main/java/com/offway/core/itinerary/service/CourseStorageService.java
  • src/main/java/com/offway/core/itinerary/service/dto/GeneratedCourse.java
  • src/main/java/com/offway/core/itinerary/service/dto/MyCourses.java
  • src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java

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.

@sevineleven

Copy link
Copy Markdown
Contributor Author

리뷰 (CodeRabbit 레이트리밋 대신 수동)

로컬에서 검증했습니다 — 테스트 1,313건 통과 · 실패 0 · 컨벤션 훅 차단 0.

네 가지 설계 판단이 코드와 문서에 근거째 남아 있어 읽기 좋습니다. 확인한 것:

항목 결과
공개 조회에 토큰 미노출 publicView 가 null 로 비우고, 기존 테스트가 doesNotExist 로 잠금
목록 N+1 findByCourseIdIn 한 번 + 빈 목록 가드
타 게스트 격리 guestId 범위 쿼리, 목록·상세 양쪽 테스트
동시 발급 경합 유니크 제약 + 별도 빈의 새 트랜잭션 재조회 (rollback-only 회피를 javadoc 이 설명)
withShareTokenwithFirstDayChange 체이닝 두 메서드 다 전 필드 보존, PATCH 테스트가 잠금
allCourses 가 토큰을 안 읽음 TripOutcomeService 내부용만 사용 — 응답 경로가 아님
낡은 문서 정정 @Schema·@Operation·javadoc 갱신

테스트가 형식적이지 않은 점이 특히 좋습니다. "토큰이 실린다" 가 아니라 저장 때와 같은 값인지 대조하고 두 번 열어도 같은지(멱등성)까지 봅니다 — 매번 새로 발급하는 회귀는 먼저 뿌린 링크를 죽이는데, 존재 확인만으로는 그걸 못 잡습니다.

아래 두 가지를 봐주세요.


① 상세 조회가 토큰 발급 실패에 통째로 500 이 된다 (중간)

CourseStorageService#get

return withBenefits(course, true).withShareToken(shareTokenOf(course.getId()));

shareTokenOf 는 중복이 아닌 제약 위반에서 IllegalStateException 을 던지는데 get() 에 try/catch 가 없습니다.

이 PR 전에는 상세가 읽기 전용이라 course_share 문제로 깨질 수 없었습니다. 이제는 그 테이블에 락 경합·용량 문제가 생기면 공유 버튼 하나 때문에 코스 상세 화면이 통째로 안 뜹니다. 사용자가 잃는 것(코스 전체)이 얻는 것(공유 링크)보다 큽니다.

CLAUDE.md §조용한 실패의 "degrade 해서 넘어갈 때도 왜 degrade 했는지 로그에 남긴다" 가 맞는 자리로 보입니다 — 토큰만 null 로 내리고 warn 을 남기면 화면은 살고 공유 버튼만 빠집니다. 프론트는 이미 shareToken 이 null 인 경우를 목록에서 다루므로 계약도 그대로입니다.

발급 자체를 상세에서 하는 결정은 동의합니다. 실패했을 때 무엇을 포기할지만 다시 봐주세요.

② "목록은 발급하지 않는다" 계약이 테스트로 안 잠겼다 (낮음)

PR 본문·port javadoc·@Operation 세 곳이 "목록에서 발급하면 조회 한 번이 최대 100 INSERT" 를 강조하는데, 정작 그걸 깨는 변경을 막는 테스트가 없습니다.

토큰 없는 코스를 만들고(이미 saveWithoutShare 헬퍼가 있습니다) 목록을 친 뒤 shareToken 이 null 이고 공유 행도 안 생겼는지 보면 잠깁니다. 후자가 핵심입니다 — null 단언만으로는 "발급은 했는데 응답에 안 실었다" 와 구분되지 않습니다.


둘 다 머지를 막을 정도는 아닙니다. ①은 고치는 편이 낫다고 보고, ②는 선택입니다.

- 토큰을 조회 경로에 실으면서 상세·날짜수정이 course_share 에 의존하게 됐다. 그전까지
  그 테이블을 건드리지 않아 거기서 깨질 수 없었다
- 그대로 두면 공유 버튼 하나 때문에 코스 화면이 통째로 안 뜬다. 사용자가 잃는 것(코스
  전체)이 얻는 것(링크)보다 크다
- 날짜 수정에서는 더 나쁘다. 날짜 갱신은 이미 커밋된 뒤라 여기서 던지면 바뀐 것을
  안 바뀌었다고 답하게 된다
- 조용히 넘어가지는 않는다. 사유를 warn 으로 남기고, 발급 못 한 코스는 다음 상세
  조회가 다시 시도한다
- 저장(save)은 그대로 둔다. 거기서 토큰이 비면 클라이언트가 링크를 못 만드는데,
  그 상황은 이 변경이 만든 것이 아니라 원래 그랬다
- 본문·javadoc·@operation 세 곳이 "목록에서 발급하면 조회 한 번이 최대 100 INSERT"
  라고 적어두고 정작 그걸 깨는 변경을 막는 테스트가 없었다
- null 단언만으로는 "발급은 했는데 응답에 안 실었다" 와 구분되지 않아 공유 행이
  안 생겼는지까지 본다
@sevineleven

Copy link
Copy Markdown
Contributor Author

리뷰 대응

두 건 모두 반영했습니다. 테스트 1,313 → 1,314건, 실패 0.

① 상세·날짜수정이 토큰 발급 실패에 500 → f6c2aba

shareTokenOrNull 을 두고 두 자리 모두 여기를 거치게 했습니다. 못 얻으면 토큰만 비우고 코스는 그대로 내립니다.

날짜 수정 쪽이 더 나빴습니다 — 날짜 갱신은 이미 커밋된 뒤라 여기서 던지면 바뀐 것을 안 바뀌었다고 답하게 됩니다.

조용히 넘어가지는 않습니다. 사유를 warn 으로 남기고(예외 객체는 안 찍습니다 — 중복 키 메시지에 토큰이 실려 나옵니다), 발급 못 한 코스는 다음 상세 조회가 다시 시도합니다.

save() 는 그대로 뒀습니다. 거기서 토큰이 비면 클라이언트가 링크를 못 만드는데, 그 상황은 이 PR 이 만든 것이 아니라 원래 그랬습니다. 바꾸려면 저장 응답 계약을 함께 봐야 해서 이 PR 밖으로 뒀습니다.

다만 이 PR 이 들어가면 저장 응답의 토큰이 비어도 상세를 열어 복구할 수 있게 됩니다. save() 도 degrade 로 바꿀 근거가 이 PR 로 생기는 셈이라, 후속으로 볼 만합니다. 지금은 저장 성공 후 500 이 나가면 클라이언트가 재시도해 코스가 중복될 수 있습니다.

② 목록 미발급 계약이 테스트로 안 잠김 → 214e3e2

목록은_없는_공유토큰을_발급하지_않는다 를 더했습니다. shareToken 이 없는 것뿐 아니라 공유 행이 안 생겼는지까지 봅니다 — null 단언만으로는 "발급은 했는데 응답에 안 실었다" 와 구분되지 않습니다.

@sevineleven
sevineleven merged commit c914aa2 into dev Aug 13, 2026
4 checks passed
@sevineleven
sevineleven deleted the fix/259-share-token-in-read-responses branch August 13, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix 버그 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

코스 상세·목록 응답에 shareToken 을 채운다

1 participant