Skip to content

[FEAT] 모임 이미지 단 건 삭제 구현#114

Merged
LimdaeIl merged 4 commits intomainfrom
feat/image-one-delete
Dec 16, 2025
Merged

[FEAT] 모임 이미지 단 건 삭제 구현#114
LimdaeIl merged 4 commits intomainfrom
feat/image-one-delete

Conversation

@LimdaeIl
Copy link
Collaborator

@LimdaeIl LimdaeIl commented Dec 16, 2025

📝 Pull Request

📌 PR 종류

해당하는 항목에 체크해주세요.

  • 기능 추가 (Feature)
  • 버그 수정 (Fix)
  • 문서 수정 (Docs)
  • 코드 리팩터링 (Refactor)
  • 테스트 추가 (Test)
  • 기타 변경 (Chore)

✨ 변경 내용

모임에서 이미지 단 건 삭제 기능을 구현합니다. key가 아니라 url 전체를 모두 입력받아서 삭제합니다.

🔍 관련 이슈

🧪 테스트

변경된 기능에 대한 테스트 범위 또는 테스트 결과를 작성해주세요.

  • 유닛 테스트 추가 / 수정
  • 통합 테스트 검증
  • 수동 테스트 완료

🚨 확인해야 할 사항 (Checklist)

PR을 제출하기 전에 아래 항목들을 확인해주세요.

  • 코드 포매팅 완료
  • 불필요한 파일/코드 제거
  • 로직 검증 완료
  • 프로젝트 빌드 성공
  • 린트/정적 분석 통과 (해당 시)

🙋 기타 참고 사항

리뷰어가 참고하면 좋을 만한 추가 설명이 있다면 적어주세요.

Summary by CodeRabbit

  • 새로운 기능

    • 그룹에서 단일 이미지 삭제 기능 추가(호스트만 가능, URL로 지정 가능)
    • 이미지 삭제 API가 키 또는 URL을 선택적으로 지원
  • 개선 사항

    • 이미지 URL 추출 및 검증 강화(잘못된 URL/키 차단)
    • 관련 오류 코드 및 사용자 안내 메시지 추가
  • 테스트

    • 그룹 이미지 삭제 시나리오를 검증하는 통합 HTTP 테스트 추가

✏️ Tip: You can customize this high-level summary in your review settings.

이미지 단 건 삭제를 key 또는 url로 가능하도록 기능을 구현합니다. 해당 API는 S3 안에 있는 이미지 삭제 용도로 모임에서 사용하는 삭제가 아닙니다.
모임에서 이미지 삭제는 URL을 Request Param으로 요청받은 후에 삭제합니다.
@LimdaeIl LimdaeIl self-assigned this Dec 16, 2025
Copilot AI review requested due to automatic review settings December 16, 2025 00:53
@LimdaeIl LimdaeIl added the ✨enhancement New feature or request label Dec 16, 2025
@LimdaeIl LimdaeIl moved this from Backlog to In progress in WeGo-Together Backend Dec 16, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 16, 2025

Caution

Review failed

The pull request is closed.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

모임 이미지 단 건 삭제 기능이 추가되었습니다. 인증된 호스트만 URL로 지정한 그룹 이미지를 검증 후 삭제하고, 관련 에러 코드 및 저장소/이미지 서비스 메서드가 확장되었습니다.

Changes

Cohort / File(s) 변경 요약
GroupController: 새 엔드포인트
src/main/java/team/wego/wegobackend/group/presentation/v1/GroupController.java
DELETE /{groupId}/images/one 엔드포인트 추가; @AuthenticationPrincipal 사용자, groupId, url 파라미터를 받아 groupService.deleteOne(...) 호출하고 204 응답 반환
GroupService: 삭제 로직 추가
src/main/java/team/wego/wegobackend/group/application/service/v1/GroupService.java
public void deleteOne(CustomUserDetails userDetails, Long groupId, String url) 추가: 호스트 검증, URL 비어있음 검사, 그룹-이미지 존재 확인, 이미지 레코드 삭제 후 이미지 서비스에 삭제 위임
GroupErrorCode: 에러 코드 추가
src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java
IMAGE_URL_REQUIRED, GROUP_IMAGE_NOT_FOUND 에러 코드 추가 및 기존 enum 구분자 조정
GroupImageRepository: 쿼리 메서드 추가
src/main/java/team/wego/wegobackend/group/domain/repository/v1/GroupImageRepository.java
findByGroupAndImageUrl, deleteByGroupAndImageUrl, existsByGroupAndImageUrl 메서드 추가 (Group + imageUrl 기반 조회/삭제/존재확인)
ImageUploadService: key/url 처리·삭제 개선
src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java
extractKeyFromUrl 개선(쿼리 제거, URI 파싱), deleteOne(key, url) 추가(키/URL 상호검증, URL->key 해석), validateKey 유효성 검사 도입
ImageExceptionCode: 이미지 오류 코드 확장
src/main/java/team/wego/wegobackend/image/domain/exception/ImageExceptionCode.java
KEY_OR_URL_REQUIRED, KEY_AND_URL_CONFLICT, INVALID_IMAGE_KEY, KEY_REQUIRED 등 에러 코드 추가
ImageController: 삭제 API 시그니처 변경
src/main/java/team/wego/wegobackend/image/presentation/ImageController.java
deleteOne 시그니처를 keyurl 선택적 파라미터로 변경하고 서비스의 deleteOne(key, url) 호출로 교체
테스트: HTTP 시나리오 추가/확장
src/test/http/group/group-image-delete.http, src/test/http/image/image-api.http
그룹 이미지 업로드/생성/삭제(권한·에러 케이스 포함)를 검증하는 종합 HTTP 테스트 추가 및 이미지 삭제 관련 시나리오 확장

Sequence Diagram

sequenceDiagram
    participant Client
    participant GroupController
    participant GroupService
    participant GroupImageRepository
    participant ImageUploadService
    participant Storage as S3

    Client->>GroupController: DELETE /{groupId}/images/one?url={url}
    GroupController->>GroupService: deleteOne(userDetails, groupId, url)
    GroupService->>GroupService: findActiveGroup(groupId)
    GroupService->>GroupService: verifyHost(userDetails)
    alt url is blank
        GroupService-->>Client: IMAGE_URL_REQUIRED (400)
    else
        GroupService->>GroupImageRepository: existsByGroupAndImageUrl(group, url)
        alt not exists
            GroupService-->>Client: GROUP_IMAGE_NOT_FOUND (404)
        else
            GroupService->>ImageUploadService: deleteOne(null, url)
            ImageUploadService->>ImageUploadService: extractKeyFromUrl(url)
            ImageUploadService->>ImageUploadService: validateKey(key)
            ImageUploadService->>Storage: delete object by key
            Storage-->>ImageUploadService: deletion result
            ImageUploadService-->>GroupService: deletion confirmed
            GroupService->>GroupImageRepository: deleteByGroupAndImageUrl(group, url)
            GroupImageRepository-->>GroupService: deleted
            GroupService-->>Client: 204 No Content
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • 주의 검토 포인트:
    • ImageUploadService.extractKeyFromUrlvalidateKey의 URL 파싱/검증 경계 조건
    • GroupService.deleteOne의 트랜잭션 경계와 저장소 삭제 순서(이미지 레코드 ↔ 외부 저장소)
    • 새로운 에러 코드의 HTTP 상태·메시지 정확성 및 국제화 영향
    • 컨트롤러와 서비스의 파라미터 유효성(빈 URL 처리 등)

Possibly related PRs

Poem

🐰 URL 한 줄 건네받고 훌쩍,
호스트인지 먼저 킁킁 맡아보고,
경로 따라 열쇠 찾으면 삭- 지워요,
레코드도 톡! 저장소도 휙!
204로 끝내는 깔끔한 당근 정리 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '[FEAT] 모임 이미지 단 건 삭제 구현'은 변경사항의 핵심인 모임 이미지 단 건 삭제 기능 추가를 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed PR의 모든 코드 변경사항이 이슈 #113의 요구사항을 충족합니다: GroupController에 DELETE 엔드포인트 추가, URL 기반 이미지 삭제 로직 구현, 필요한 에러 처리 및 검증 로직 포함.
Out of Scope Changes check ✅ Passed 모든 변경사항이 이슈 #113의 범위 내에 있습니다. ImageUploadService의 deleteOne 메서드 개선, GroupService와 GroupController의 이미지 삭제 기능, 그리고 테스트 파일 추가는 모두 '모임 이미지 단 건 삭제' 기능 구현을 직접 지원합니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7094a97 and 5ca0052.

📒 Files selected for processing (2)
  • src/main/java/team/wego/wegobackend/group/application/service/v1/GroupService.java (2 hunks)
  • src/main/java/team/wego/wegobackend/group/presentation/v1/GroupController.java (1 hunks)

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (6)
src/main/java/team/wego/wegobackend/group/domain/repository/v1/GroupImageRepository.java (1)

11-15: 성능 최적화를 위한 인덱스 고려가 필요합니다.

새로 추가된 세 메서드(findByGroupAndImageUrl, deleteByGroupAndImageUrl, existsByGroupAndImageUrl)는 모두 imageUrl을 조회 조건으로 사용합니다. imageUrl은 일반적으로 긴 문자열이며, 인덱스가 없을 경우 테이블 풀 스캔이 발생할 수 있습니다.

GroupImage 엔티티의 imageUrl 컬럼에 복합 인덱스 추가를 고려하세요:

@Table(indexes = {
    @Index(name = "idx_group_image_url", columnList = "group_id, image_url")
})

또는 개별 인덱스:

@Column(name = "image_url")
@Index(name = "idx_image_url")
private String imageUrl;
src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java (1)

440-455: URL에서 key 추출 실패 시 에러 처리를 명확히 하세요.

Line 451에서 extractKeyFromUrl(url)null을 반환할 수 있지만, 이 경우에 대한 명시적인 에러 처리가 없습니다. validateKey에서 예외가 발생하긴 하지만, 더 명확한 에러 메시지를 제공하는 것이 좋습니다.

다음과 같이 명시적인 검증을 추가하세요:

     String targetKey = hasUrl ? extractKeyFromUrl(url) : key;
+    
+    if (targetKey == null || targetKey.isBlank()) {
+        throw new ImageException(ImageExceptionCode.INVALID_IMAGE_KEY, url != null ? url : "null");
+    }

     validateKey(targetKey);
     delete(targetKey);
src/main/java/team/wego/wegobackend/group/application/service/v1/GroupService.java (1)

652-658: 동시성 이슈(TOCTOU)가 발생할 수 있습니다.

Lines 652와 658 사이에 Time-of-Check to Time-of-Use (TOCTOU) 경합 조건이 존재합니다. 두 개의 요청이 동시에 같은 이미지를 삭제하려 할 때:

  1. 요청 A: existsByGroupAndImageUrl → true
  2. 요청 B: existsByGroupAndImageUrl → true
  3. 요청 A: deleteByGroupAndImageUrl → 성공
  4. 요청 B: deleteByGroupAndImageUrl → 아무것도 삭제하지 않음 (0 rows affected)

현재 코드는 요청 B가 404를 반환하지 않고 성공으로 처리됩니다.

다음 중 하나를 선택하세요:

옵션 1: 비관적 락 사용

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<GroupImage> findByGroupAndImageUrl(Group group, String imageUrl);

옵션 2: 삭제 결과 확인 (더 간단함)

// Repository에 추가
@Modifying
@Query("DELETE FROM GroupImage gi WHERE gi.group = :group AND gi.imageUrl = :imageUrl")
int deleteByGroupAndImageUrl(@Param("group") Group group, @Param("imageUrl") String imageUrl);

// Service에서
int deletedCount = groupImageRepository.deleteByGroupAndImageUrl(group, url);
if (deletedCount == 0) {
    throw new GroupException(GroupErrorCode.GROUP_IMAGE_NOT_FOUND, groupId);
}

옵션 2가 더 단순하고 효율적입니다.

src/test/http/group/group-image-delete.http (1)

1-172: 테스트 시나리오가 포괄적으로 잘 작성되었습니다.

이 테스트 파일은 다음 시나리오를 잘 커버하고 있습니다:

  • 호스트 권한 검증 (line 146-148)
  • URL 파라미터 필수 검증 (line 150-152)
  • 존재하지 않는 이미지 처리 (line 154-156)
  • 중복 삭제 멱등성 (line 158-164)

HTTP 테스트에 명시적인 응답 검증을 추가하면 더욱 견고해집니다:

### 4-1. MEMBER가 모임 이미지 삭제 시도 -> 실패해야 함
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img1_main}}
Authorization: Bearer {{member1AccessToken}}

> {%
  client.test("Should return 401 or 403", function() {
    client.assert(response.status === 401 || response.status === 403, "Expected unauthorized");
  });
%}
src/test/http/image/image-api.http (1)

200-203: 테스트 의도가 불명확합니다.

Lines 200-203의 테스트 제목이 "key, url 모두 포함"이지만, 실제로는 url만 전달하고 있습니다.

만약 KEY_AND_URL_CONFLICT 에러를 테스트하려는 의도라면:

### 이미지 단 건 삭제 - key와 url 동시 전달 (에러 케이스)
DELETE http://localhost:8080/api/v1/images/one
    ?key=test/20251216092723_0_xxx.jpeg
    &url=https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/20251216092723_0_4ead2fbf-2109-4351-98c4-54ec2b1c8353.jpeg
Authorization: Bearer {{accessToken}}

> {%
  client.test("Should return 400 for conflict", function() {
    client.assert(response.status === 400, "Expected BAD_REQUEST");
  });
%}

만약 정상 케이스를 테스트하는 것이라면, 제목을 "이미지 단 건 삭제 (url 사용)"로 변경하세요.

src/main/java/team/wego/wegobackend/image/presentation/ImageController.java (1)

106-111: 입력 파라미터 검증을 컨트롤러 레벨에서 추가하고 문서화를 개선하세요.

서비스 레이어에서 이미 null 검증 및 파라미터 충돌 검증이 구현되어 있으나, REST API 설계의 베스트 프랙티스를 따르려면 컨트롤러 레벨에서의 검증이 필요합니다:

  1. 빠른 실패(Fail-Fast): 컨트롤러에서 검증하면 잘못된 요청에 대해 400 Bad Request를 즉시 반환할 수 있습니다.
  2. 명확한 API 계약: 파라미터 사용 규칙을 문서화하면 API 사용자에게 명확한 지침을 제공할 수 있습니다.

다음과 같이 개선하는 것을 권장합니다:

+    /**
+     * 이미지를 단 건 삭제합니다.
+     *
+     * @param key S3 객체 키 (key 또는 url 중 정확히 하나만 제공)
+     * @param url 이미지 전체 URL (key 또는 url 중 정확히 하나만 제공)
+     * @return 204 No Content
+     */
     @DeleteMapping("/one")
     public ResponseEntity<Void> deleteOne(
             @RequestParam(value = "key", required = false) String key,
             @RequestParam(value = "url", required = false) String url
     ) {
+        boolean hasKey = key != null && !key.isBlank();
+        boolean hasUrl = url != null && !url.isBlank();
+        
+        if (!hasKey && !hasUrl) {
+            throw new IllegalArgumentException("key 또는 url 중 하나는 반드시 제공해야 합니다.");
+        }
+        if (hasKey && hasUrl) {
+            throw new IllegalArgumentException("key와 url은 동시에 제공될 수 없습니다.");
+        }
         imageUploadService.deleteOne(key, url);
         return ResponseEntity.noContent().build();
     }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 03bbdfe and 7094a97.

📒 Files selected for processing (9)
  • src/main/java/team/wego/wegobackend/group/application/service/v1/GroupService.java (2 hunks)
  • src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java (1 hunks)
  • src/main/java/team/wego/wegobackend/group/domain/repository/v1/GroupImageRepository.java (1 hunks)
  • src/main/java/team/wego/wegobackend/group/presentation/v1/GroupController.java (1 hunks)
  • src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java (3 hunks)
  • src/main/java/team/wego/wegobackend/image/domain/exception/ImageExceptionCode.java (2 hunks)
  • src/main/java/team/wego/wegobackend/image/presentation/ImageController.java (1 hunks)
  • src/test/http/group/group-image-delete.http (1 hunks)
  • src/test/http/image/image-api.http (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: CodeQL analysis (java)
  • GitHub Check: Agent
🔇 Additional comments (1)
src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java (1)

32-33: 형식 문자열 처리가 올바르게 구현되어 있습니다. GroupService에서 Long 타입의 groupId가 전달되며, String.format()이 %d와 함께 Long 타입을 자동으로 처리하므로 추가 조치는 불필요합니다.

Comment on lines 389 to 412
public String extractKeyFromUrl(String imageUrl) {
if (imageUrl == null || imageUrl.isBlank()) {
return null;
}
if (imageUrl == null || imageUrl.isBlank()) return null;

String clean = imageUrl;
int qIdx = clean.indexOf('?');
if (qIdx >= 0) clean = clean.substring(0, qIdx);

String endpoint = awsS3Properties.getPublicEndpoint();
if (endpoint != null && !endpoint.isBlank()) {
String prefix = endpoint.endsWith("/") ? endpoint : endpoint + "/";
if (imageUrl.startsWith(prefix)) {
return imageUrl.substring(prefix.length());
if (clean.startsWith(prefix)) {
return clean.substring(prefix.length());
}
}

// fallback: 마지막 "/" 이후를 key로 사용
int idx = imageUrl.lastIndexOf('/');
if (idx >= 0 && idx + 1 < imageUrl.length()) {
return imageUrl.substring(idx + 1);
try {
URI uri = URI.create(clean);
String path = uri.getPath(); // "/dir/xxx.webp"
if (path == null || path.isBlank()) return null;
return path.startsWith("/") ? path.substring(1) : path;
} catch (IllegalArgumentException e) {
return clean; // url이 아니면 key로 간주
}

// 그래도 안 되면 전체를 key로 시도 (최악의 경우)
return imageUrl;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

URL 파싱 실패 시 입력값을 그대로 반환하는 로직을 검토하세요.

Line 410에서 URI 파싱 실패 시 정제된 입력값(clean)을 그대로 key로 간주하여 반환합니다. 이는 URL도 아니고 유효한 key 형식도 아닌 값을 그대로 통과시킬 수 있습니다.

다음과 같이 개선을 고려하세요:

     try {
         URI uri = URI.create(clean);
         String path = uri.getPath(); // "/dir/xxx.webp"
         if (path == null || path.isBlank()) return null;
         return path.startsWith("/") ? path.substring(1) : path;
     } catch (IllegalArgumentException e) {
-        return clean; // url이 아니면 key로 간주
+        // key로 간주하되, 기본 검증 수행
+        if (clean.contains("..") || clean.startsWith("/")) {
+            return null;
+        }
+        return clean;
     }

Comment on lines +457 to +467
private void validateKey(String key) {
if (key == null || key.isBlank()) {
throw new ImageException(ImageExceptionCode.KEY_OR_URL_REQUIRED);
}
if (key.contains("..") || key.startsWith("/")) {
throw new ImageException(ImageExceptionCode.DIR_INVALID_TRAVERSAL);
}
if (!key.matches("[a-zA-Z0-9_\\-./]+")) {
throw new ImageException(ImageExceptionCode.INVALID_IMAGE_KEY, key);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

잘못된 에러 코드를 사용하고 있습니다.

Line 459에서 key가 null이거나 blank일 때 KEY_OR_URL_REQUIRED를 던지고 있습니다. 하지만 이 메서드는 이미 key 또는 url이 제공된 후 호출되는 내부 검증 메서드이므로, 더 적절한 에러 코드는 KEY_REQUIRED 또는 새로운 INVALID_IMAGE_KEY입니다.

다음과 같이 수정하세요:

     private void validateKey(String key) {
         if (key == null || key.isBlank()) {
-            throw new ImageException(ImageExceptionCode.KEY_OR_URL_REQUIRED);
+            throw new ImageException(ImageExceptionCode.KEY_REQUIRED);
         }
         if (key.contains("..") || key.startsWith("/")) {
             throw new ImageException(ImageExceptionCode.DIR_INVALID_TRAVERSAL);
         }
         if (!key.matches("[a-zA-Z0-9_\\-./]+")) {
             throw new ImageException(ImageExceptionCode.INVALID_IMAGE_KEY, key);
         }
     }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java
around lines 457 to 467, the validation currently throws
ImageExceptionCode.KEY_OR_URL_REQUIRED when key is null or blank but this
internal validator is only called after a key or URL is known to be required;
change the thrown code to ImageExceptionCode.KEY_REQUIRED (or use the existing
INVALID_IMAGE_KEY if you prefer a single code) so the error accurately reflects
that the key is missing, keeping the other checks (directory traversal and
allowed characters) unchanged.

Comment on lines +49 to +59
KEY_OR_URL_REQUIRED(HttpStatus.BAD_REQUEST,
"이미지: key 또는 url 중 하나는 필수입니다."),

KEY_AND_URL_CONFLICT(HttpStatus.BAD_REQUEST,
"이미지: key와 url은 동시에 보낼 수 없습니다. 둘 중 하나만 보내주세요."),

INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST,
"이미지: 잘못된 key 형식입니다. key=%s"),

KEY_REQUIRED(HttpStatus.BAD_REQUEST,
"이미지: key가 존재하지 않습니다.");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Search for usage of KEY_OR_URL_REQUIRED and KEY_REQUIRED error codes
echo "=== KEY_OR_URL_REQUIRED 사용처 ==="
rg -n "KEY_OR_URL_REQUIRED" --type java -C3

echo ""
echo "=== KEY_REQUIRED 사용처 ==="
rg -n "KEY_REQUIRED" --type java -C3

echo ""
echo "=== ImageExceptionCode.java 전체 구조 확인 ==="
wc -l src/main/java/team/wego/wegobackend/image/domain/exception/ImageExceptionCode.java

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 3793


validateKey() 메서드의 에러 코드를 수정하세요.

ImageUploadService.java 라인 459에서 KEY_OR_URL_REQUIRED 대신 KEY_REQUIRED를 사용해야 합니다. 현재는 validateKey() 메서드 내에서 key가 null이거나 비어있을 때 "key 또는 url 중 하나는 필수입니다"라는 부적절한 메시지를 반환하고 있습니다. 또한 KEY_REQUIRED는 정의되었으나 사용되지 않는 dead code이므로, 이 라인을 수정하여 정의된 에러 코드를 활용하세요.

🤖 Prompt for AI Agents
In
src/main/java/team/wego/wegobackend/image/domain/exception/ImageExceptionCode.java
lines 49-59 and in
src/main/java/team/wego/wegobackend/image/service/ImageUploadService.java at
line 459: validateKey() currently throws KEY_OR_URL_REQUIRED when the key is
null/empty; change validateKey() to throw ImageExceptionCode.KEY_REQUIRED
instead of KEY_OR_URL_REQUIRED so the defined KEY_REQUIRED enum is used (no enum
deletion needed), and ensure the thrown error includes the key value/context as
expected by KEY_REQUIRED's format.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a single image deletion feature for groups, allowing group hosts to delete individual images by providing the full URL. The implementation includes authorization checks to ensure only the group host can delete images, and adds comprehensive validation for both key and URL-based deletion.

Key Changes:

  • Added deleteOne endpoints to both ImageController and GroupController that accept URL parameters for image deletion
  • Enhanced ImageUploadService with URL-to-key extraction logic and key validation
  • Added new error codes for missing/conflicting parameters and invalid keys

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
src/test/http/image/image-api.http Added authentication setup and image deletion test case
src/test/http/group/group-image-delete.http Comprehensive test scenarios for group image deletion including permission checks and edge cases
src/main/java/team/wego/wegobackend/image/presentation/ImageController.java Updated deleteOne endpoint to accept both key and URL parameters
src/main/java/team/wego/wegobackend/image/domain/exception/ImageExceptionCode.java Added new error codes for validation (KEY_OR_URL_REQUIRED, KEY_AND_URL_CONFLICT, INVALID_IMAGE_KEY, KEY_REQUIRED)
src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java Implemented deleteOne method with key/URL validation and enhanced extractKeyFromUrl to handle query parameters
src/main/java/team/wego/wegobackend/group/presentation/v1/GroupController.java Added new deleteOne endpoint for group-specific image deletion with authorization
src/main/java/team/wego/wegobackend/group/domain/repository/v1/GroupImageRepository.java Added repository methods for finding, checking existence, and deleting images by group and URL
src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java Added error codes for missing URL and image not found scenarios
src/main/java/team/wego/wegobackend/group/application/service/v1/GroupService.java Implemented deleteOne method with host permission validation and transactional deletion

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 128 to 134
public ResponseEntity<Void> deleteOne(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long groupId,
@RequestParam(value = "url", required = false) String url
) {
groupService.deleteOne(userDetails, groupId, url);
return ResponseEntity.noContent().build();
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type of deleteOne is inconsistent with the deleteGroup method. The deleteGroup method returns ResponseEntity<ApiResponse<Void>> (line 115) while deleteOne returns ResponseEntity<Void> (line 128). For consistency within the API, both delete endpoints should use the same response wrapper pattern. Consider changing the return type to match deleteGroup.

Suggested change
public ResponseEntity<Void> deleteOne(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long groupId,
@RequestParam(value = "url", required = false) String url
) {
groupService.deleteOne(userDetails, groupId, url);
return ResponseEntity.noContent().build();
public ResponseEntity<ApiResponse<Void>> deleteOne(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long groupId,
@RequestParam(value = "url", required = false) String url
) {
groupService.deleteOne(userDetails, groupId, url);
return ResponseEntity
.status(HttpStatus.NO_CONTENT)
.body(ApiResponse.success(HttpStatus.NO_CONTENT.value(), null));

Copilot uses AI. Check for mistakes.
Comment on lines 657 to 658
imageUploadService.deleteOne(null, url);
groupImageRepository.deleteByGroupAndImageUrl(group, url);
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of operations is problematic here. If the S3 deletion succeeds but the database deletion fails (due to a transaction rollback or database error), the system will be in an inconsistent state where the file is deleted from S3 but the database still references it. The database deletion should be performed first, then the S3 deletion should occur after the transaction commits successfully. Consider moving the S3 deletion outside the transaction or using a transactional event listener to execute it after commit.

Copilot uses AI. Check for mistakes.

private void validateKey(String key) {
if (key == null || key.isBlank()) {
throw new ImageException(ImageExceptionCode.KEY_OR_URL_REQUIRED);
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message KEY_OR_URL_REQUIRED is misleading in this context. At this point in validateKey(), we already know that either a key or URL was provided (checked in deleteOne() at line 444), but the extracted key turned out to be null or blank after URL parsing. A more appropriate error would be INVALID_IMAGE_KEY or a new error code like KEY_EXTRACTION_FAILED that clearly indicates the URL couldn't be parsed into a valid key.

Suggested change
throw new ImageException(ImageExceptionCode.KEY_OR_URL_REQUIRED);
throw new ImageException(ImageExceptionCode.INVALID_IMAGE_KEY, key);

Copilot uses AI. Check for mistakes.
Comment on lines +56 to 60
"이미지: 잘못된 key 형식입니다. key=%s"),

KEY_REQUIRED(HttpStatus.BAD_REQUEST,
"이미지: key가 존재하지 않습니다.");

Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error code KEY_REQUIRED appears to be unused in the codebase and its purpose is unclear given the existence of KEY_OR_URL_REQUIRED (line 49). If this error code is intended to represent a different scenario, consider renaming it to be more specific (e.g., KEY_EXTRACTION_FAILED), or remove it if it's not needed.

Suggested change
"이미지: 잘못된 key 형식입니다. key=%s"),
KEY_REQUIRED(HttpStatus.BAD_REQUEST,
"이미지: key가 존재하지 않습니다.");
"이미지: 잘못된 key 형식입니다. key=%s");

Copilot uses AI. Check for mistakes.
throw new ImageException(ImageExceptionCode.KEY_OR_URL_REQUIRED);
}
if (key.contains("..") || key.startsWith("/")) {
throw new ImageException(ImageExceptionCode.DIR_INVALID_TRAVERSAL);
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error code DIR_INVALID_TRAVERSAL is semantically incorrect here. This error code was designed for directory path validation (preventing directory traversal attacks with ".." patterns), but it's being reused for validating S3 object keys. The error message "이미지: 잘못된 디렉토리 경로입니다." doesn't accurately describe the issue when a key starts with "/" or contains "..". Consider using the more appropriate INVALID_IMAGE_KEY error code or creating a new error code specific to key validation.

Suggested change
throw new ImageException(ImageExceptionCode.DIR_INVALID_TRAVERSAL);
throw new ImageException(ImageExceptionCode.INVALID_IMAGE_KEY, key);

Copilot uses AI. Check for mistakes.
if (path == null || path.isBlank()) return null;
return path.startsWith("/") ? path.substring(1) : path;
} catch (IllegalArgumentException e) {
return clean; // url이 아니면 key로 간주
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic in the catch block (line 410) returns the cleaned URL as a key when URI parsing fails, which could lead to invalid S3 operations. If a malformed URL is provided, it will bypass validation and be used directly as a key. Instead of returning the clean string as-is, the method should return null to indicate extraction failure, allowing the validateKey method to properly handle and reject the invalid input.

Suggested change
return clean; // url이 아니면 key로 간주
return null; // URI parsing failed, extraction failed

Copilot uses AI. Check for mistakes.
@@ -28,7 +28,9 @@ public enum GroupErrorCode implements ErrorCode {
"모임: 현재 참여 인원 수(%s)보다 작은 값으로 최대 인원을 줄일 수 없습니다."),
NO_PERMISSION_TO_DELETE_GROUP(HttpStatus.UNAUTHORIZED, "모임: 삭제할 수 있는 권한이 없습니다."),
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error code NO_PERMISSION_TO_DELETE_GROUP has an inconsistent message format. It doesn't include placeholders for groupId and userId like other similar error codes (e.g., NO_PERMISSION_TO_UPDATE_GROUP on line 25-26), but the code passes these parameters when throwing this exception (line 645 in GroupService). Add placeholders to the message template: "모임: 삭제할 수 있는 권한이 없습니다. 모임 ID: %s 회원 ID: %s"

Suggested change
NO_PERMISSION_TO_DELETE_GROUP(HttpStatus.UNAUTHORIZED, "모임: 삭제할 수 있는 권한이 없습니다."),
NO_PERMISSION_TO_DELETE_GROUP(HttpStatus.UNAUTHORIZED, "모임: 삭제할 수 있는 권한이 없습니다. 모임 ID: %s 회원 ID: %s"),

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +142
### 3-1. 모임 이미지 단 건 삭제 - 440x240 (sortOrder=0)
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_main}}
Authorization: Bearer {{hostAccessToken}}

### 3-2. 모임 이미지 단 건 삭제 - 100x100 (sortOrder=0)
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_thumb}}
Authorization: Bearer {{hostAccessToken}}

### 3-3. 모임 상세 조회(삭제 후) - sortOrder=0 이미지 제거 확인
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment label is duplicated. There are two sections labeled "3-1" (lines 129 and 134). The second one should be labeled "3-2" to maintain sequential numbering.

Suggested change
### 3-1. 모임 이미지 단 건 삭제 - 440x240 (sortOrder=0)
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_main}}
Authorization: Bearer {{hostAccessToken}}
### 3-2. 모임 이미지 단 건 삭제 - 100x100 (sortOrder=0)
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_thumb}}
Authorization: Bearer {{hostAccessToken}}
### 3-3. 모임 상세 조회(삭제 후) - sortOrder=0 이미지 제거 확인
### 3-2. 모임 이미지 단 건 삭제 - 440x240 (sortOrder=0)
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_main}}
Authorization: Bearer {{hostAccessToken}}
### 3-3. 모임 이미지 단 건 삭제 - 100x100 (sortOrder=0)
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_thumb}}
Authorization: Bearer {{hostAccessToken}}
### 3-4. 모임 상세 조회(삭제 후) - sortOrder=0 이미지 제거 확인

Copilot uses AI. Check for mistakes.
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_main}}
Authorization: Bearer {{hostAccessToken}}

### 3-2. 모임 이미지 단 건 삭제 - 100x100 (sortOrder=0)
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment label is incorrect. This should be labeled "3-3" instead of "3-2" to follow the sequential numbering from the previous test cases.

Suggested change
### 3-2. 모임 이미지 단 건 삭제 - 100x100 (sortOrder=0)
### 3-3. 모임 이미지 단 건 삭제 - 100x100 (sortOrder=0)

Copilot uses AI. Check for mistakes.
DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/images/one?url={{img0_thumb}}
Authorization: Bearer {{hostAccessToken}}

### 3-3. 모임 상세 조회(삭제 후) - sortOrder=0 이미지 제거 확인
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment label is incorrect. This should be labeled "3-4" instead of "3-3" to follow the sequential numbering from the previous test cases.

Suggested change
### 3-3. 모임 상세 조회(삭제 후) - sortOrder=0 이미지 제거 확인
### 3-4. 모임 상세 조회(삭제 후) - sortOrder=0 이미지 제거 확인

Copilot uses AI. Check for mistakes.
삭제 순서로 인한 데이터 불일치 위험이 있습니다.

Lines 657-658에서 S3 삭제를 먼저 수행하고 DB 삭제를 나중에 수행합니다. 만약 DB 삭제(line 658)가 실패하면:

S3 객체는 이미 삭제됨 (롤백 불가)
DB 레코드는 여전히 존재
결과: 존재하지 않는 이미지를 가리키는 DB 레코드 발생 (dangling reference)
또한 imageUploadService.deleteOne() 내부에서 예외가 발생하면 트랜잭션이 롤백되지만, 이미 S3에서 삭제된 객체는 복구할 수 없습니다.

다음과 같이 DB 삭제를 먼저 수행합니다.

쿼리 파라미터의 required 속성이 실제 동작과 일치하지 않습니다.

Line 131에서 url 파라미터를 required=false로 선언했지만, GroupService.deleteOne()은 url이 null이거나 blank이면 IMAGE_URL_REQUIRED 예외를 던집니다. 이는 API 계약과 실제 구현 사이의 불일치를 만듭니다.

다음 중 하나를 선택하세요:

옵션 1: required=true로 변경 (권장)
@LimdaeIl LimdaeIl merged commit e03acef into main Dec 16, 2025
1 check was pending
@LimdaeIl LimdaeIl deleted the feat/image-one-delete branch December 16, 2025 01:08
@github-project-automation github-project-automation bot moved this from In progress to Done in WeGo-Together Backend Dec 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[FEAT] 모임 이미지 단 건 삭제 구현

1 participant