ADR‐008: 홍보 이미지 키를 동아리 기준으로 잡는다 - Moadong/moadong GitHub Wiki
작성일: 2026-09-20 | 상태: Accepted
결정일자: 2026-09-21 (PR #2084 머지)
이미지를 R2에 올릴 때 쓰는 키(경로)가 홍보만 다른 모양이었습니다.
| 기능 | 키 | 무엇을 기준으로 삼았나 |
|---|---|---|
| 로고 / 커버 / 활동사진 | {clubId}/logo|cover|feed/ |
동아리 (소유자) |
| 우체통 첨부 | feedback/{studentId}/ |
학생 (소유자) |
| 홍보 | promotion/articles/{articleId}/ |
게시글 (레코드) |
나머지는 전부 소유자를 기준으로 삼았는데 홍보만 레코드를 기준으로 삼았어요. 별것 아닌 차이처럼 보이지만 결과가 꽤 큽니다.
동아리나 학생은 이미지보다 항상 먼저 존재합니다. 그런데 게시글은 이미지와 같은 요청에서 생성될 수 있어요. 그래서 홍보만 이런 제약이 생겼습니다.
- 게시글이 먼저 있어야 업로드 URL을 발급받을 수 있습니다. 그래서 작성이 "빈 이미지로 게시글 생성 → articleId 받기 → 업로드 → 수정으로 반영"이라는 두 번의 요청으로 나뉩니다.
- 생성 시점에는 이미지를 검증할 수 없습니다. 접두사를 만들 articleId가 없으니까요. 그래서 #2081에서 저장 경로 검증을 붙일 때 생성만 "이미지는 비어 있어야 한다"로 막아둘 수밖에 없었습니다.
| 시점 | 사건 |
|---|---|
| 2026-01-30 | 홍보게시판 API 추가. images가 @NotEmpty — 이미지가 필수였습니다 |
| 2026-03-21 | 이미지 업로드 API 추가. 키를 promotion/articles/{articleId}/로 |
| 2026-03-29 |
8일 만에 @NotEmpty → @NotNull. 커밋 제목이 "Support image upload during promotion creation"
|
처음 설계는 오히려 한 요청에 다 넣는 쪽이었어요. 이미지가 필수이기까지 했고요. 문제는 그때 우리 API에 이미지를 올릴 수단이 없었다는 것뿐이었습니다.
두 달 뒤 업로드 API를 붙일 때 갈렸습니다. 그 업로드가 이렇게 생겼거든요.
String imageUrl = r2ImageUploadService.upload(...);
promotionArticleRepository.addImageToActiveArticle(articleId, imageUrl); // 게시글에 바로 붙임업로드가 게시글에 즉시 반영되는 형태라 게시글이 먼저 존재해야 했고, 그래서 키도 자연스럽게 게시글 기준이 됐습니다. 그리고 8일 뒤, "생성하면서 이미지를 올리려면" 역설적으로 생성 요청에서 이미지를 뺄 수 있어야 해서 @NotEmpty를 풀었던 거예요.
즉 게시글 기준 키는 의도한 선택이 아니라 업로드 구현 방식의 부산물이었습니다. 2026-09-06에 presigned를 도입하면서 "업로드는 R2에만, 반영은 저장 API가 전담"으로 절반은 정리했는데, 키 기준은 그대로 남아 있었습니다.
홍보 이미지 키도 동아리 기준으로 바꿉니다.
promotion/{clubId}/{yyyy}/{MM}/{uuid}-{파일명}
기준은 하나예요. "이 식별자가 이미지보다 먼저 존재하는가." 동아리는 언제나 먼저 있으니 게시글이 없어도 발급하고 검증할 수 있습니다. 로고·활동사진·우체통과 같은 축이 됩니다.
- POST /api/promotion/{articleId}/upload-url
+ POST /api/promotion/upload-url게시글을 조회할 이유가 없어져서 Mongo 왕복이 하나 줄었고, 권한은 clubId로 확인합니다.
발급 시 "잔여분까지만 내주기"도 없어졌어요. 게시글이 없을 수 있으니 저장된 장수를 셀 수가 없거든요. 대신 요청 수만 상한으로 막고 최종 개수는 게시글 저장이 확인합니다. 활동사진(generateFeedUploadUrls)이 이미 같은 이유로 같은 규칙을 쓰고 있었습니다.
이게 이번 변경의 실제 대가입니다.
지금까지는 접두사가 게시글 단위라 "내 접두사에 속한 객체만 지운다"로 충분했어요. 동아리 기준이 되면 같은 동아리의 다른 게시글 이미지도 접두사가 일치합니다. A 게시글을 고치면서 B의 이미지 URL을 넣었다 빼면 B의 이미지가 지워질 수 있어요.
그래서 접두사는 "홍보 경로인지"만 보고, 실제 보호는 "다른 활성 게시글이 아직 쓰고 있는가" 로 합니다.
if (promotionArticleRepository.existsOtherActiveArticleWithImage(imageUrl, articleId)) {
continue; // 다른 게시글이 쓰고 있으면 두고 간다
}보호 수준은 오히려 촘촘해졌습니다. 예전엔 같은 게시글 접두사면 무조건 지웠는데, 이제는 참조가 남아 있는지까지 봅니다.
다른 대안들:
- ❌ 드래프트 ID (
promotion/drafts/{draftId}/): 한 요청으로 만들 수는 있지만 draft 고아를 정리할 장치가 새로 필요하고, 게시글 생성 후 객체를 옮기려면 R2 복사+삭제로 오퍼레이션이 두 배가 됩니다. 얻는 것에 비해 복잡해요. - ❌ 클라이언트가 articleId를 만들어 보내기: 마이그레이션이 아예 없다는 건 매력적인데, 존재하지 않는 id로 발급 요청이 오면 권한을 결국 clubId로 확인해야 합니다. 그럴 거면 처음부터 clubId로 잡는 게 낫습니다. Mongo
_id를 클라이언트가 정하게 되는 것도 부담이고요. - ❌ 그대로 두기: 작성이 두 번의 요청이라는 건 감수할 수 있지만, 생성 경로에 검증을 영영 못 건다는 게 걸립니다.
장점:
- 작성이 한 번의 요청으로 끝납니다. 업로드 → 게시글 생성 순서로 바뀌고, 중간에 빈 게시글을 만들었다 채우는 단계가 사라집니다.
- 생성과 수정의 검증이 같아집니다. #2081에서 임시로 막아둔 "생성은 이미지 없이"를 걷어냈습니다.
- 네 기능이 모두 소유자 기준이 되어, 새 업로드 기능을 붙일 때 "누가 올리는가"만 보면 키가 정해집니다.
- presign에서 게시글 조회가 사라져 Mongo 왕복이 하나 줄었습니다.
단점/트레이드오프:
- 접두사만으로는 게시글을 구분할 수 없습니다. 삭제 때마다 참조 확인 쿼리가 한 번 더 나갑니다. 빠진 이미지가 있을 때만 도는 쿼리라 부담은 작습니다.
- 구 키가 당분간 공존합니다. 아래에 따로 적었습니다.
- 프론트가 presign 호출 경로를 바꿔야 합니다. 백엔드 → 프론트 순서로 배포해야 해요.
기존에 올라간 이미지는 promotion/articles/{articleId}/... 경로에 그대로 있습니다. 마이그레이션은 하지 않았어요. #2081에서 넣은 "이미 저장돼 있던 URL은 재검증하지 않는다" 덕분에 옮기지 않아도 깨지지 않거든요.
| 구 키 이미지 | 새 이미지 | |
|---|---|---|
| 저장 검증 |
previousImages에 있어 건너뜀
|
새 접두사로 통과 |
| 삭제 |
promotion/ 아래라 정상 삭제
|
정상 삭제 |
| 새로 주장하기 | ❌ 접두사 불일치로 거부 | ✅ |
그래서 구 키 이미지는 수정할 때마다 자연히 줄어듭니다. 이미지를 교체하면 구 객체는 지워지고 새 객체가 새 경로에 생기니까요.
완전히 없앨 수 있는 조건은 이겁니다. 활성 게시글 중 구 키 URL을 들고 있는 게 하나도 없을 때요.
db.promotion_articles.countDocuments({
deleted: { $ne: true },
images: { $regex: "/promotion/articles/" }
})
// 0 이면 R2에서 promotion/articles/ 접두사를 통째로 지워도 됩니다이 값이 0이 되면 R2 정리와 함께 PromotionImageUploadService의 PROMOTION_KEY_ROOT 주석("구 키도 이 아래에 있다")도 지울 수 있습니다. 그전에 지우면 살아 있는 게시글의 이미지가 사라지니 반드시 먼저 세어보세요.