Skip to content

[Feature] ProductImage 도메인 구현#180

Merged
growingpsy merged 22 commits into
developfrom
feature/#177-product-image-domain
Oct 28, 2025
Merged

[Feature] ProductImage 도메인 구현#180
growingpsy merged 22 commits into
developfrom
feature/#177-product-image-domain

Conversation

@growingpsy

@growingpsy growingpsy commented Oct 23, 2025

Copy link
Copy Markdown
Contributor

🛠️ Issue Number

closes #177

📌 작업 내용 및 특이사항

상품 이미지 CRUD 구현 (대표 이미지 지정 포함)

공통

  • FileStorageService 구현 (MultipartFile 저장, 경로 및 확장자 검증, 예외 처리)
  • FileProperties 추가 및 PropertiesConfig에 반영

상품 이미지 등록

  • 단일 상품에 대한 다중 이미지 업로드 구현
  • 대표 이미지 중복 등록 방지 로직 추가
  • 파일명 정규화 및 UUID 기반 저장

상품 이미지 조회

  • 상품 ID로 이미지 목록 조회 구현
  • 대표 이미지 우선 정렬

상품 이미지 수정

  • 이미지 수정 시 기존 파일 삭제 후 신규 파일 저장
  • 대표 이미지 변경 시 기존 대표 이미지 자동 해제 처리

상품 이미지 삭제

  • 이미지 ID를 통한 단일 삭제 구현
  • 파일 스토리지 내 물리적 파일 삭제 포함

📚 참고사항

  • FileStorageService 로컬 저장소 기준으로 동작 (추후 S3 등 외부 저장소 확장 고려)
  • REST Docs 문서화 완료 (product-image-* 문서 추가)

@growingpsy growingpsy self-assigned this Oct 23, 2025

@willjsw willjsw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

수정사항 확인해주세요!

@Moses249 Moses249 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

고생하셨습니다!

public void update(String imageUrl, boolean isDefault) {
this.imageUrl = imageUrl;
this.isDefault = isDefault;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: 현재 업데이트는 대표 사진만 변경하고자 할 때 (null, true)로 업데이트 되어 이미지가 사라질 수 있습니다.
if (imageUrl != null) { this.imageUrl = imageUrl; } if (isDefault != null) { this.isDefault = isDefault; }
를 사용하는게 어떨까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

좋은 포인트 감사합니다 🙂
기존 update()는 두 필드를 한 번에 수정하다 보니 대표 이미지 변경 시 null 값이 들어올 위험이 있어서 updateImageUrl(), markAsDefault(), unmarkAsDefault()로 역할을 명확히 분리했습니다.

@Bal1oon Bal1oon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

리뷰 코멘트 확인 부탁드려요

Comment thread src/main/java/com/irum/come2us/domain/product/domain/entity/ProductImage.java Outdated
@growingpsy
growingpsy requested a review from willjsw October 24, 2025 07:43

@Bal1oon Bal1oon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR #181 에서 남긴 코멘트들이 반영이 안되어서 다시 리뷰 남겨드려요. 리뷰 확인해주시면 감사하겠습니다 :)

Comment on lines +22 to +27
@PostMapping
public void addProductImage(
@PathVariable UUID productId, @Valid @RequestBody ProductImageCreateRequest request) {
log.info("상품 이미지 추가 요청: productId={}, isDefault={}", productId, request.isDefault());
productImageService.addProductImage(productId, request);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: 다른 PR에서 리뷰했었는데 저희는 이미지 주소가 이미지는 파일 자체를 받아야 하기 때문에 ProductImageCreateRequest가 아닌 MultipartFile를 통해 받아야 해요.
파일 업로드 시에는 @RequestBody로 받지 않기 때문에 공부해보시면 좋을 거 같습니다.

P3: 추가로 현재는 한 번에 한 장씩 등록하고 있는데, 여러 이미지를 한 번에 업로드할 수 있도록 변경하는 방향으로도 고려해보시면 좋을 거 같습니다.

#181

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

앗 해당 부분 미처 반영을 못했네요 다시 한번 확인부탁드립니다!
@RequestBody 대신 @RequestPart("files") List로 변경하여 실제 파일 업로드가 가능하도록 했고,
consumes = MediaType.MULTIPART_FORM_DATA_VALUE를 추가해 multipart 요청을 처리하도록 수정했습니다.

@Bal1oon Bal1oon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

고생하셨습니다 👏
Multipart 적용이 잘 되어 있고, 전체적인 구조가 괜찮은 것 같습니다!
몇 가지 코멘트 남겼으니 한 번 확인 부탁드립니다 :)

@Bal1oon Bal1oon added the ✨ feature 새 기능을 추가합니다. label Oct 25, 2025
@Bal1oon Bal1oon added this to the Sprint 3 milestone Oct 25, 2025

@Bal1oon Bal1oon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💪

Comment on lines 97 to 103
if (image.isDefault()) {
productImageRepository
.findTopByProductIdOrderByCreatedAtDesc(productId)
productImageRepository.findTopByProductIdOrderByCreatedAtDesc(productId)
.ifPresent(ProductImage::markAsDefault);
}

fileStorageService.delete(image.getImageUrl());
productImageRepository.delete(image);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: 이미지 삭제 시, 대표 이미지를 삭제하면 최신 이미지를 대표 이미지로 지정하도록 되어 있는데, findTopByProductIdOrderByCreatedAtDesc()
삭제되는 이미지 자신을 포함해 조회될 수 있는 타이밍 이슈가 있습니다.

productImageRepository.delete(image) 이후에 조회하도록 순서를 바꾸는 것이 더 안전할 듯 합니다.
아니면 flush()로 DB 반영 후 조회하는 방법이 있습니다

validateFile(file);

// 실제 파일 저장 (로컬 또는 S3)
String storedUrl = fileStorageService.save(file);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: 이 부분(fileStorageService.save(file);) I/O작업이라 DB 트랜잭션과 분리해야 하는 부분인데요, GPT로 생성한 코드입니다. 참고하시면 좋을 것 같습니다 :)

    /**
     * 상품 이미지 업로드
     * - 파일 저장은 트랜잭션 밖에서 선행
     * - DB insert는 트랜잭션 안에서 수행
     */
    public void uploadProductImages(UUID productId, List<MultipartFile> files, Boolean isDefault) {
        // 1️⃣ 파일 저장 (트랜잭션 X)
        List<String> storedUrls = files.stream()
                .map(file -> {
                    validateFile(file);
                    return fileStorageService.save(file);
                })
                .toList();

        // 2️⃣ DB 저장 (트랜잭션 O)
        saveProductImages(productId, storedUrls, isDefault);
    }

    @Transactional
    protected void saveProductImages(UUID productId, List<String> storedUrls, Boolean isDefault) {
        Member member = memberUtil.getCurrentMember();
        Product product = findValidProduct(productId);

        memberUtil.assertMemberResourceAccess(product.getStore().getMember());

        if (Boolean.TRUE.equals(isDefault)) {
            productImageRepository.findByProductId(productId).stream()
                    .filter(ProductImage::isDefault)
                    .findFirst()
                    .ifPresent(ProductImage::unmarkAsDefault);
        }

        for (String storedUrl : storedUrls) {
            ProductImage productImage = ProductImage.create(product, storedUrl, isDefault);
            productImageRepository.save(productImage);
            log.info("상품 이미지 등록 완료: productId={}, storedUrl={}, isDefault={}",
                    productId, storedUrl, isDefault);
        }
    }

@Slf4j
public class FileStorageService {

private static final String UPLOAD_DIR = "uploads/";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Postman으로 테스트해봤는데 디렉토리 경로가 Tomcat 임시 디렉토리 기준 상대경로로 인식되기 때문에 파일 저장이 안되네요. 해결 방법에 대해서는 여러 방법이 있는데 로컬 개발 환경에서는 프로젝트 루트 기준으로 경로를 잡는 것이 안전하다고 하네요.

private static final String UPLOAD_DIR =
        System.getProperty("user.dir") + "/uploads/";

제 로컬에서도 한번 해보겠습니다

@Bal1oon

Bal1oon commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

현재 Postman으로 테스트 했을 때에 제가 겪는 문제들 남겨놓습니다:

  • 여러 이미지를 isDefault=true로 했을 경우 500 에러 발생 -> unique 제약으로 인한 에러 발생
    • 파일 저장과 트랜잭션 분리가 되지 않아, 파일은 저장되었는데 DB 등록은 되지 않는 현상 발생
       ERROR: duplicate key value violates unique constraint "p_product_image_product_id_is_default_key"
       Detail: Key (product_id, is_default)=(ac1e0125-9a1b-1c5e-819a-1bdca7c80001, t) already exists.
      
  • isDefault=true 이미지가 존재할 때, 추가적으로 isDefault=true로 API 호출 시 unique 제약으로 인한 에러 발생
  • isDefault=false로 여러 이미지 등록 시에 unique 제약으로 인한 에러 발생

willjsw
willjsw previously approved these changes Oct 25, 2025

@willjsw willjsw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

고생하셨습니다! 로컬 파일 저장 관련 코멘트 참고해보시면 도움되실 듯 합니다!
@Bal1oon 님 지적하신 부분 해결 부탁드립니다!

@Slf4j
public class FileStorageService {

private static final String UPLOAD_DIR = "uploads/";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: 파일 경로와 같은 상수 값들은 직접 변수로 선언하기 보다는, Constants.class와 같은 불변 클래스나 레코드를 통해 관리하는게 유지보수 및 안정성 측면에서 좋아보입니다! 참고해주세요!

Comment on lines +22 to +24
String uniqueName = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path path = Paths.get(UPLOAD_DIR + uniqueName);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P4: 현재 사용자 입력을 그대로 받아 파일경로로 사용하고 있는데, "../../etc"와 같이 상대 경로가 포함된 악의적인 경로를 통해 입력되면, 의도치 않은 폴더로 사용자의 파일이 이동할 수 있을 것 같습니다.
Spring에서 지원하는 StringUtils를 사용하면 경로를 안전하게 처리할 수 있다고 하네요!

String cleanFilename = StringUtils.cleanPath(file.getOriginalFilename());

또한 문자열을 "+" 로 연결해 최종 경로로 사용하는 것보다, Path 타입에서 지원하는 .resolve() 를 사용해서 경로를 처리하는게 좋아보입니다.

다만 S3에서는 파일 경로를 기반으로 처리하진 않으니, 참고만 해보셔도 좋을 듯 합니다!

@willjsw
willjsw dismissed their stale review October 25, 2025 17:13

파일 저장 관련 이슈 미해결

@willjsw
willjsw self-requested a review October 25, 2025 17:14

@willjsw willjsw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

.

@Bal1oon Bal1oon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

저도 한 번 해결해보겠습니다.

public void uploadProductImages(
@PathVariable UUID productId,
@RequestPart("files") List<MultipartFile> files,
@RequestPart("isDefault") Boolean isDefault) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: isDefault 없이 구현하는 건 어떠신가요? 들어오는 이미지들에 대해서는 모두 isDefault false를 기본으로 하고 상품에 이미지가 없는 경우 제일 처음에 들어온 이미지를 true로 바꿔 저장하는 로직으로 구현하면 좀 더 편할 것 같습니다.
역할 분리도 명확해지고요.

Comment on lines +60 to +67
if (Boolean.TRUE.equals(isDefault)) {
boolean alreadyHasDefault =
productImageRepository.findByProductId(productId).stream()
.anyMatch(ProductImage::isDefault);
if (alreadyHasDefault) {
throw new CommonException(ProductImageErrorCode.DUPLICATE_DEFAULT_IMAGE);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: 여러 이미지 업로드 시 isDefault=true의 경우 해당 이미지들이 모두 true로 처리되어 for문을 돌고 있습니다.
해당 로직에 경우 마지막 이미지만 대표 이미지로 남고, 앞서 들어온 이미지들은 true -> false로 바뀌어 DB에 저장이 되거나 flush 타이밍에 따라 무시되는데 지금 실행해본 결과 후자인 것 같습니다. 추가로 계속 DB 조회를 하는 것이 문제가 될 수 있을 것 같네요.

isDefault=true일 경우

  1. 첫 번째 이미지만 대표로 지정하도록 명시해서 for문을 돌게하거나
  2. isDefault를 파라미터에서 제외시켜 무조건 false로만 생성 + 상품에 기존 이미지가 아무것도 없으면 첫장만 true로 하는게 구현하시기 편하실 거 같습니다.

Comment on lines +39 to +52
public void uploadProductImages(UUID productId, List<MultipartFile> files, Boolean isDefault) {
// 파일 저장
List<String> storedUrls =
files.stream()
.map(
file -> {
validateFile(file);
return fileStorageService.save(file);
})
.collect(Collectors.toList());

// DB 저장
saveProductImages(productId, storedUrls, isDefault);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: 트랜잭션 분리
saveProductImages()@Transactional이 붙어 있긴 하지만, 같은 클래스 내부에서 호출하고 있기 때문에 Spring AOP가 적용되지 않습니다.

Spring의 @Transactional은 “프록시 기반 AOP”로 동작하는데, 프록시가 적용되려면 외부 Bean이 해당 메서드를 호출해야 합니다.

지금 구조에서는 uploadProductImages()this.saveProductImages()로 직접 호출하므로 프록시를 거치지 않고 실행되어, 트랜잭션이 전혀 시작되지 않습니다.


P2: 스토리지와 DB 불일치 발생
예외 복구 로직이 없어 파일 저장은 되었지만 DB에는 저장이 되지 않는 상황이 생길 수 있습니다.

예로 fileStorageService.save(file)이 수행되어 파일 저장이 이뤄졌는데 saveProductImage()에서 MemberUtil의 사용자 인증을 통과하지 못하는 경우가 있을 수 있네요.

@Getter
@Table(
name = "p_product_image",
uniqueConstraints = {@UniqueConstraint(columnNames = {"product_id", "is_default"})})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: 이 조건이 is_default가 false로 중복되어도 안된다고 하네요. 제가 제안드렸던 건데 없애는 게 맞는 것 같습니다. 만약 제약을 추가하고 싶다면 DB 레벨에서 직접 추가하는 방식을 사용해야 한대요

@Bal1oon

Bal1oon commented Oct 26, 2025

Copy link
Copy Markdown
Contributor

@willjsw 님이 코멘트 주신 부분들 해결해주시면 될 것 같습니다.
저는 일단 트랜잭션 분리 및 등록 에러만 잡아놓은 거라, 지적해주신 파일 경로에 대해서 해결해보시면 좋을 듯 합니다.

@growingpsy

@willjsw willjsw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

고생많으셨습니다!!

growingpsy and others added 13 commits October 27, 2025 09:48
- 대표 이미지 추가 시 기존 대표 자동 해제 로직 추가
- changeDefaultImage 로직에서 기존 대표 해제 후 지정 이미지 대표로 설정
- deleteProductImage 시 대표 이미지 삭제 시 최신 이미지 자동 대표 지정
- findValidProduct, findValidImage 유효성 검증 메서드 추가
- @RequestBody → @RequestPart("files")로 수정하여 다중 파일 업로드 지원

- consumes = MediaType.MULTIPART_FORM_DATA_VALUE 적용

- 서비스 로직에서 MultipartFile 직접 처리 가능하도록 구조 변경

- 불필요한 ProductImageCreateRequest DTO 제거
- FileStorageService 추가하여 파일 I/O 로직 분리 (S3 확장 대비 구조)
- 이미지가 없을 경우 예외 대신 빈 리스트 반환하도록 수정
- 파일명 충돌 방지를 위해 UUID prefix 적용
- 파일 확장자 및 크기 검증 로직 추가 (.jpg, .jpeg, .png / 10MB 이하)
- ProductImageErrorCode 정리 및 FILE_SAVE_FAILED 추가
- FileStorageService: 정규식 "(?i).*(\\.jpg|\\.jpeg|\\.png)$" 적용으로 대소문자 구분 제거
- 파일 확장자 및 크기 검증 로직 강화
- ProductImageService: 대표 이미지 삭제 시 flush() 후 최신 이미지 조회하도록 순서 수정
- I/O 작업(fileStorageService.save) 트랜잭션 분리
- DB insert는 별도 @transactional 메서드에서 수행
- 롤백 시 파일 I/O 영향 최소화
- Tomcat 임시 폴더 문제 해결 (System.getProperty("user.dir") 사용)
- isDefault=true 중복 등록 시 예외 처리 추가
- 파일 저장 트랜잭션 외부 분리로 안정성 향상
- StringUtils.cleanPath(), Path.resolve() 적용
- 파일 저장 상수 FileStorageConstants로 분리
- ProductImageService에서 상수 직접 선언 제거
- 파일 크기/확장자 검증 로직 정리
- FileStorageService 리팩터링
  - 경로 하드코딩 제거 및 FileProperties 연동 (환경별 설정 분리)
  - FileStorageConstants 추가로 정책 상수(확장자, 최대 용량 등) 분리
  - StringUtils.cleanPath 및 Path.resolve() 적용으로 경로 조작 방지
  - Objects.requireNonNull 적용으로 null-safe 파일명 처리
  - 파일 확장자/용량 검증 로직 강화
  - delete(String) 메서드 추가로 안전한 파일 삭제 지원
  - S3 업로드 분기 구조 설계 (후속 구현 예정)

- ProductImageService 개선
  - 트랜잭션-IO 로직 분리 (파일 저장과 DB 저장 분리)
  - @lazy self 주입을 통한 트랜잭션 프록시 적용
  - 업로드 중 예외 발생 시 스토리지 롤백 복구 처리
  - 순환참조(BeanCurrentlyInCreationException) 문제 해결
  - 대표 이미지 변경 및 삭제 로직 안정화
@growingpsy
growingpsy force-pushed the feature/#177-product-image-domain branch from 3b15f9a to 6f16170 Compare October 27, 2025 00:50
@Bal1oon
Bal1oon marked this pull request as draft October 27, 2025 00:52
@Bal1oon

Bal1oon commented Oct 27, 2025

Copy link
Copy Markdown
Contributor

@growingpsy Approved 이후 변경이 있어, 임시로 Draft 전환했습니다.

@growingpsy
growingpsy force-pushed the feature/#177-product-image-domain branch from 971ae9d to 121c3ad Compare October 28, 2025 01:03
growingpsy and others added 2 commits October 28, 2025 11:21
- build.gradle에 QueryDSL 의존성 버전 명시 (:5.0.0:jakarta)
- Q클래스 생성 경로(build/generated/querydsl) 인식 추가
- Lombok annotationProcessor 설정 보완
- PropertiesConfig 중복 @EnableConfigurationProperties 제거
@growingpsy
growingpsy marked this pull request as ready for review October 28, 2025 04:32
@growingpsy
growingpsy merged commit d78c764 into develop Oct 28, 2025
1 check passed
@growingpsy
growingpsy deleted the feature/#177-product-image-domain branch October 28, 2025 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feature 새 기능을 추가합니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Product Image 도메인 구현

4 participants