상품 이미지 업로드 기능을 개발할 때는 단순히 파일을 저장하는 것에서 끝나지 않습니다. 저장소 선택, 업로드 방식, 그리고 사용되지 않는 리소스`(고아객체)`의 정리까지 종합적으로 고려해야 합니다. 특히 AWS S3와 같은 외부 스토리지를 사용하는 경우, 고아 객체를 방치하면 스토리지 비용은 물론, 전체 시스템의 관리 복잡도까지 높아질 수 있습니다.

이번 포스팅에서는 고아 객체란 무엇인지, 상품 등록 과정에서 발생할 수 있는 고아 객체를 어떻게 처리할 수 있는지 A부터 Z까지 상세하게 알아보도록 하겠습니다.

 

1. 고아 객체(Orphan Object)란?

`고아 객체(Orphan Object)`란, 더 이상 애플리케이션에서 참조하지 않지만 스토리지에는 여전히 남아있는 데이터를 의미합니다.

 

다음과 같은 상황으로 고아 객체가 발생할 수 있습니다.

  • 사용자가 상품 등록 중 `이미지만 업로드한 뒤 페이지를 이탈`한 경우
    • 이미지 파일은 S3에 업로드되고 DB에도 저장되지만, 실제로는 어떤 상품과도 연결되지 않은 상태
  • `상품 삭제` 이후
    • 상품과 이미지 간 연관관계 매핑이 되어 있다면 DB단의 처리는 강제적이지만, 외부 스토리지인 S3에는 여전히 남아있는 상태

 

이러한 고아 객체는 사용자 입장에서는 보이지 않지만 내부적으로는 다음과 같은 문제를 유발합니다.

  • `불필요한 S3 저장 공간 차지`
  • `스토리지 비용 증가`
  • `장기적인 시스템 관리의 혼란`

 

2. 고아 객체 처리 전략

구현 예제를 통해 알아보기 전 어떤 고아 객체 처리 전략이 있는지 각 방식과 동작흐름, 장단점에 대해 먼저 알아보도록 하겠습니다.

 

2-1. 트랜잭션 일괄 처리

트랜잭션 일괄 처리 동작흐름 이미지

먼저, 트랜잭션 일괄 처리로 `하나의 요청에서 상품 정보와 이미지를 함께 요청`하여 SpringBoot는 S3에 이미지를 먼저 업로드한 후, 성공 시 DB에 상품 정보와 함께 이미지 정보를 저장합니다.`

 

장점

  • `일관된 처리`: 하나의 API 호출로 상품과 이미지를 모두 처리합니다.
  • `데이터 일관성 유지`: 이미지 업로드와 상품 등록이 하나의 트랜잭션 범위에서 처리합니다.

 

단점

  • 상품 등록과 이미지 업로드 작업 중 하나가 실패할 가능성을 고려해야 하며, 이 중 `하나라도 실패 시 전체 트랜잭션이 롤백되어야 하므로 예외 처리 및 롤백, 재시도 로직 구현 복잡도가 증가`할 수 있습니다.
  • `보상 트랜잭션 필요`: S3와 같은 외부 스토리지는 데이터베이스 트랜잭션과 분리되어 있어, 보상 트랜잭션과 같은 추가 설계가 필요합니다.
  • `사용자 경험(UX) 저하`: 이미지 용량이 크거나 다수일 경우 트랜잭션 점유 시간이 증가하며, 사용자는 업로드가 완료될 때까지 무기한 대기 가능성이 존재합니다.

 

트랜잭션 일괄 처리 방식은 구현이 단순하고 정합성에 강한 구조입니다. 초기 프로젝트나 단일 이미지 업로드에 적합하며, 보상 트랜잭션 제외 별도의 정리 로직이 필요 없습니다.

하지만 이미지 개수가 많거나 대용량 파일이 많은 서비스의 경우, AWS S3 업로드 지연으로 인해 전체 트랜잭션 성능에 영향을 줄 수 있어, 보상 로직, 시간 분리 전략 등을 함께 고민해야합니다.

 

2-2. 트랜잭션 분리 매핑 처리

트랜잭션 분리 매핑 처리 동작흐름 이미지
트랜잭션 분리 매핑 처리 동작흐름 이미지

이 방식은 이미지 업로드와 상품 등록을 완전히 분리된 트랜잭션으로 처리합니다.

사용자가 상품등록 중 이미지 업로드 시 API 요청을 통해 이미지를 AWS S3에 업로드하고 DB에 `product_id=null인 상태로 임시 저장`합니다.

이후 `사용자가 상품 등록을 완료할 때, 해당 이미지들과 상품 간의 관계가 매핑`되며 실제 상품에 연결됩니다.

등록되지 않은 이미지들은 일정 시간이 지나면 스케줄러를 통해 AWS S3와 DB에서 모두 제거됩니다.

 

장점

  • `업로드와 등록의 독립성`: 이미지 업로드와 상품 등록이 별도 트랜잭션으로 동작하므로 실패 시 서로 영향을 주지 않습니다.
  • `에러 복구 용이`: 어떤 단계에서 문제가 발생하더라도 로직을 개별적으로 분리해 롤백이나 재처리 로직 구현이 비교적 쉽습니다.
  • `고아 객체 정리 로직 명확성`: product_id IS NULL 조건으로 DB에서 고아 이미지 식별이 가능하여 삭제 대상 판별이 명확합니다.

 

고려사항

이 방식은 구조적으로 명확한 단점이 분류되지 않지만, 안정적인 운영을 위해 반드시 고려되어야 할 요소가 존재합니다.

  • `임시 이미지 정리 기준 설정`: 매 정각 스케줄러 동작 시 59분에 이미지를 업로드 한 유저가 상품 등록 중 이미지가 삭제될 수 있어 정리 기준을 설정해야합니다. ex) createdAt 기준 ~일 이상 매핑되지 않은 항목에 대하여
  • `정합성 보장 로직 구현`: product_id가 null인 이미지들을 매핑하기 위한 별도 로직 구현이 필요합니다.

 

트랜잭션 분리 매핑 방식은 `단점 보다는 설계 난이도가 비교적 높은 방식`입니다. 정리 로직, 매핑 로직, 동시성 처리 등 다소 복잡한 구조를 필요로 하지만, 그만큼 유연성과 확장성이 좋은 전략이며, 사용자 경험을 해치지 않으면서 고아 객체를 안정적으로 관리할 수 있는 구조입니다.

 

2-3. 임시 저장소 분리

임시 저장소 분리 처리 동작흐름 이미지
임시 저장소 분리 처리 동작흐름 이미지

이 방식은 이미지 업로드 시 S3의 정식 상품 이미지 경로 `/product/...`가 아닌 임시 경로 `/temp/...`에 먼저 업로드합니다.

이후 사용자가 상품 등록을 완료하면 해당 이미지들을 `/product/...` 경로로 복사하고, `/temp/...` 경로에 있는 이미지를 제거합니다.

트랜잭션 분리 매핑과 마찬가지로 `/temp/...` 경로에 있는 일정 주기가 지난 이미지들은 스케줄러를 통해 제거합니다.

 

장점

  • `DB 저장 없이 관리 가능`: 이미지의 상태를 DB가 아닌 AWS S3의 경로로 구분하여 시스템 간 연동 복잡도가 줄어듭니다.
  • `구조적 명확함`: /temp는 미등록 이미지, /product는 등록 완료 이미지로 구분되어 시각적으로도 기준이 명확합니다.
  • `클라이언트 업로드 구조에 적합`: presigned URL을 통해 클라이언트에서 직접 업로드 후, key만 서버에 전달하는 방식으로도 확장이 가능합니다.

 

단점

  • `이동 비용 발생`: 상품 등록 시 AWS S3에서는 이동 기능이 없어, copy → delete 방식으로 처리되어 네트워크 및 요청 비용이 발생합니다.

 

이 방식은 구조적으로 명확하고 직관적인 장점이 많지만, AWS S3에서의 파일 이동은 실제로는 복사 후 삭제로 처리되기 때문에 네트워크 요청 수가 2배로 증가하며, 요청 비용과 처리 시간이 다소 증가할 수 있다는 점을 고려해야 합니다.

이미지 용량이나 업로드 빈도가 높을수록 이러한 비용은 누적될 수 있으므로, 요청 최적화 및 비용 전략을 함께 고민해야합니다.

 

항목 트랜잭션 일괄 처리 트랜잭션 분리 매핑 임시 저장소 분리
구현 난이도 중~상 중~상
UX 영향 있음 없음 없음
고아 객체 방지 O (보상 로직 필요) O (스케줄러 필요) O (경로 기준 + 스케줄러 필요)
AWS S3 요청 비용 낮음 낮음 높음 (Copy + Delete)
추천 상황 단순/소규모 서비스 중대형 서비스 클라이언트 직접 업로드 활용 시

 

3. 구현 예제 환경과 Entity 구성

위에서 소개드린 `고아 객체 처리 전략 중 2-2번의 트랜잭션 분리 매핑 처리`를 통해 예제를 다루었습니다.

AWS S3 다중 이미지 업로드/삭제 구현은 이전 글에서 자세히 다루었으니, 이미지 업로드 자체에 대한 구현이 궁금하신 분은 해당 글을 참고바랍니다.

 

[SpringBoot] AWS S3 다중 이미지 파일 업로드 및 삭제 구현하기 (feat. MultipartFile)

이미지와 같은 정적 파일을 효율적으로 관리하는 것은 웹 애플리케이션 개발에서 자주 마주하게 됩니다. 이때, 클라우드 기반 저장소인 `AWS S3 (Simple Storage Service)`는 높은 확정

tao-tech.tistory.com

이전 글에서는 `여러 이미지를 한 번에 업로드할 수 있도록 List<MultipartFile>`을 받아서 서버에 일괄 전송하는 방식으로 구현했습니다.

하지만 이번 글에서는 `트랜잭션 분리 전략에 초점을 맞추어, 이미지 업로드 요청을 하나씩 처리`하는 방향으로 설계하였습니다.

대용량 이미지나 업로드 중단 등 예외 상황에서, 여러 파일을 한 번에 처리하는 구조가 트랜잭션 안정성이나 사용자 경험`UX` 측면에서 불리할 수 있습니다.

반면, 개별 업로드 방식은 `실패 시 부분 복구나 재시도가 용이하며, 클라이언트-서버 간 작업 흐름을 더욱 유연`하게 만들 수 있습니다.

특히, AWS S3와 같이 외부 스토리지를 사용하는 경우, 이미지 업로드와 상품 등록을 명확히 분리하는 구조가 장기적으로 안전한 설계가 된다고 판단하였습니다.

 

구현 예제 조건

  • `AWS S3 기본 URL은 프론트에서 관리`한다. ex) 버킷네임.지역.amazonaws.com
    • 이후 이미지는 백엔드에서 응답 된 이미지 path, name을 통해 조합하여 표출 ex) url + path + name
  • 고아 객체 제거 스케줄러 동작 시 `이미지 createdAt을 기준으로 ~일이 지난 것만 제거`한다.

 

구현 예제 플로우

  1. `상품 등록 중`
    • 사용자가 상품 정보 입력과 함께 이미지 업로드
    • 이미지 업로드 즉시 이미지 업로드 요청 → AWS S3 업로드 및 DB 저장 (최초 productId = null `매핑X`)
    • 업로드 된 이미지 응답 → 해당 이미지 id를 프론트에서 list로 저장
  2. `상품 등록 중 이미지 삭제`
    • 사용자가 이미지 업로드 한 것 중 X버튼 클릭 (가정)
    • 프론트 측에서 list로 저장하던 id를 제거 → 프론트단에서 컨트롤
  3. `사용자 상품 등록 완료`
    • 프론트에서 저장한 이미지 id 값들과 함께 상품 정보 요청
    • 해당 상품 정보 DB 저장 및 요청받은 이미지에 해당 상품 일괄 매핑
  4. `상품 등록 완료 후 이미지 삭제`
    • 상품 수정 흐름으로 동작
    • 사용자가 이미지 X버튼 선택 → 프론트 측 요청 이미지 id 리스트에서 제거
    • 상품 수정 반영 → 해당 상품에 매핑되어있던 이미지 일괄 해제 후 요청받은 이미지 id만 일괄 매핑처리
  5. `사용자 상품등록 중 페이지 이탈 및 기타의 이유로 생성된 고아 객체 처리`
    • 스케줄러를 통해 product_id = null 인 이미지들에 한해 AWS S3, DB에서 제거

 

3-1. 예제 환경 및 의존성

이번 예제는 아래 환경과 의존성을 기반으로 구현하였습니다.

  • `SpringBoot`: 3.4.5
  • `Java`: OpenJDK 21
  • `DataBase`: MySQL 8.0 (Docker Compose로 환경 구성)
  • `ORM`: SpringDataJPA + QueryDSL
  • `Storage`: AWS SDK for S3
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // DataBase : MySQL
    runtimeOnly 'com.mysql:mysql-connector-j'

    // ORM : SpringDataJPA + QueryDSL
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    
    // Storage : AWS SDK for S3
    implementation 'software.amazon.awssdk:s3:2.29.50'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

3-2. Entity 구성

ERD 이미지

BaseEntity

@Getter
@MappedSuperclass
public class BaseEntity {

    @Comment("기본 키")
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Comment("생성일자")
    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Comment("수정일자")
    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;
}

 

Product

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {

    @Comment("상품 명")
    @Column(name = "name", nullable = false)
    private String name;

    @Comment("상품 가격")
    @Column(name = "price", nullable = false)
    private int price;

    @Comment("상품 설명")
    @Column(name = "description", nullable = false)
    private String description;

    public Product(String name, int price, String description) {
        this.name = name;
        this.price = price;
        this.description = description;
    }

    // 상품 생성
    public static Product create(String name, int price, String description) {
        return new Product(name, price, description);
    }

    // 상품 업데이트
    public void update(String name, int price, String description) {
        this.name = name;
        this.price = price;
        this.description = description;
    }
}

 

Image

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Image extends BaseEntity {

    @Comment("이미지 경로")
    @Column(name = "path", nullable = false)
    private String path;

    @Comment("이미지 명")
    @Column(name = "name", nullable = false)
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = true)
    private Product product;

    public Image(String path, String imageName, Product product) {
        this.path = path;
        this.name = imageName;
        this.product = product;
    }

    // 상품과 연결되지 않은 임시 이미지 생성 (product = null)
    public static Image create(String path, String imageName) {
        return new Image(path, imageName, null);
    }

    // 이미지 <-> 상품 매핑
    public void assignProduct(Product product) {
        this.product = product;
    }
}

 

  • 이미지와 상품 간의 관계는 `Image → Product 방향의 단방향 N:1 매핑`으로 설계하였습니다.
  • 이미지는 `최초 생성 시 product = null 상태로 저장되며, 이는 상품과 연결되지 않은 임시(고아) 이미지 상태`를 의미합니다.
  • 이후 실제 `상품 최초 등록 시 assignProduct() 메서드를 통해 상품과의 연관관계를 동적으로 설정`합니다.
  • `create, update, assignProduct` 메서드를 통해 Entity화 하여 실제 값이 변경되는 구조입니다.

 

4. 예제 : 이미지 업로드

 

4-1. DTO : 이미지 업로드 DTO 구성

Controller DTO : 응답

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ImageResponse {

    private Long id;
    private Long productId;
    private String path;
    private String name;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updatedAt;

    public ImageResponse(Long id,
                         Long productId,
                         String path,
                         String name,
                         LocalDateTime createdAt,
                         LocalDateTime updatedAt
    ) {
        this.id = id;
        this.productId = productId;
        this.path = path;
        this.name = name;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    public static ImageResponse of(Image image) {
        return new ImageResponse(
                image.getId(),
                image.getProduct() != null ? image.getProduct().getId() : null,
                image.getPath(),
                image.getName(),
                image.getCreatedAt(),
                image.getUpdatedAt()
        );
    }
}

ImageResponse는 클라이언트에 이미지 정보를 응답할 때 사용되는 Response DTO 입니다.

이미지 식별자`id`, 연관된 상품 ID`productId`, 경로`path`, 파일명`name` 등의 주요 속성과 함께, 생성 및 수정 일자를 지정된 포맷`yyyy-MM-dd HH:mm:ss`으로 직렬화되어 전달됩니다.

정적 팩토리 메서드 `of(Image image)를 통해 도메인 객체인 Image로부터 DTO로의 변환`을 수행합니다.

// 응답 예시
{
    "id": 이미지ID,
    "productId": null 또는 매핑된 상품ID,
    "path": "경로",
    "name": "이미지 name",
    "createdAt": "2025-05-06 18:20:00",
    "updatedAt": "2025-05-06 18:20:00"
}

 

4-2. Controller : 이미지 업로드

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/image")
public class ImageController {

    private final ImageService imageService;

    @PostMapping("/upload")
    public ImageResponse uploadImage(@RequestPart MultipartFile imageFile,
                                     @RequestParam("type") ImageType imageType
    ) {
        Image image = imageService.upload(imageFile, imageType);
        return ImageResponse.of(image);
    }
}

 

이미지 업로드 시 `@RequestPart를 통한 MultipartFile과 @RequestParam을 통한 이미지 타입을 요청`받습니다.

// 요청 예시
localhost:8080/api/image/upload?type=PRODUCT + form-data 이미지 파일

 

이미지 타입은 Enum을 통한 상수로 지정하였습니다.

@Getter
@RequiredArgsConstructor
public enum ImageType {
    PRODUCT("product/");

    private final String path;
}

 

4-3. Service : 비즈니스 로직 작성

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ImageService {

    private final S3Client s3Client;
    private final ImageRepository imageRepository;

    @Value("${aws.s3.bucket-name}")
    private String bucketName;

    /**
     * [public 메서드]
     * - 외부에서 사용, DB에 저장된 imageName을 반환
     */
    @Transactional
    public Image upload(MultipartFile image, ImageType imageType) {
        // [Step 1] 유효성 검사
        validateImage(image);

        // [Step 2] 유효성 검증 완료 후 S3 업로드
        String imageName = uploadImageToS3(image, imageType);

        // [Step 3] S3에 업로드 된 파일 DB 저장, imageEntity 반환
        return createImage(imageType, imageName);
    }

    /**
     * [private 메서드]
     * - 파일 유효성 검증
     */
    private void validateImage(MultipartFile image) {
        // [Step 1-1] 파일 존재 유무 검증
        if (image == null || image.isEmpty()) {
            throw new CustomApplicationException(ErrorCode.NOT_EXIST_FILE);
        }

        // [Step 1-2] 확장자 존재 유무 검증
        String imageName = image.getOriginalFilename();
        if (imageName == null || !imageName.contains(".")) {
            throw new CustomApplicationException(ErrorCode.NOT_EXIST_FILE_EXTENSION);
        }

        // [Step 1-3] 허용되지 않는 확장자 검증
        String extension = imageName.substring(imageName.lastIndexOf(".") + 1).toLowerCase();
        List<String> allowedExtentionList = Arrays.asList("jpg", "jpeg", "png", "gif");
        if (!allowedExtentionList.contains(extension)) {
            throw new CustomApplicationException(ErrorCode.INVALID_FILE_EXTENSION);
        }
    }

    /**
     * [private 메서드]
     * - S3 업로드
     */
    private String uploadImageToS3(MultipartFile image, ImageType imageType) {
        String extension = Objects.requireNonNull(image.getOriginalFilename())
                .substring(image.getOriginalFilename().lastIndexOf(".") + 1); // 확장자 명
        String imageName = UUID.randomUUID() + "." + extension;
        // [Step 2-1] 이미지 파일 -> InputStream 변환
        try (InputStream inputStream = image.getInputStream()) {
            // PutObjectRequest 객체 생성
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(bucketName) // 버킷 이름
                    .key(imageType.getPath() + imageName) // 저장할 파일 이름
                    .acl(ObjectCannedACL.PUBLIC_READ) // 퍼블릭 읽기 권한
                    .contentType(image.getContentType()) // 이미지 MIME 타입
                    .build();

            // [Step 2-2] S3에 이미지 업로드
            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, image.getSize()));

        } catch (Exception exception) {
            log.error(exception.getMessage(), exception);
            throw new CustomApplicationException(ErrorCode.IO_EXCEPTION_UPLOAD_FILE);
        }

        // [Step 2-3] s3에 저장된 imageName 반환
        return imageName;
    }

    /**
     * [private 메서드]
     * DB에 업로드된 이미지 저장
     */
    private Image createImage(ImageType imageType, String imageName) {
        // [Step 3-1] 이미지 저장, imageEntity 반환
        return imageRepository.save(Image.create(
                imageType.getPath(),
                imageName
        ));
    }
}

최초 `Controller에서 upload() 메서드를 호출`하여 내부 로직이 수행됩니다.

`유효성 검사 → S3 이미지 업로드 → 업로드 된 이미지 DB 저장` 순서의 흐름으로 진행되며 각 스텝별로 주석을 통해 설명을 남겨놓았습니다.

 

4-4. Test : 이미지 업로드 요청

Postman 요청 이미지
AWS S3 업로드 이미지
DataBase 저장 이미지

업로드 요청 시 정상적으로 `AWS S3 업로드 및 최초 productId가 null인 상태로 DB에 저장`되는 모습을 확인할 수 있습니다.

 

5. 예제 : 상품 등록

 

5-1. DTO : 상품 등록 DTO 구성

Controller DTO : 요청

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductRequest {

    private String name;
    private int price;
    private String description;
    private List<Long> imageIds;

    public ProductRequest(String name, int price, String description, List<Long> imageIds) {
        this.name = name;
        this.price = price;
        this.description = description;
        this.imageIds = imageIds;
    }

    // 상품 생성 정보 DTO로 데이터 전달
    public ProductCreateInfo toCreate() {
        return new ProductCreateInfo(name, price, description, imageIds);
    }
}

 

Service DTO : 생성 정보

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductCreateInfo {

    private String name;
    private int price;
    private String description;
    private List<Long> imageIds;

    public ProductCreateInfo(String name, int price, String description, List<Long> imageIds) {
        this.name = name;
        this.price = price;
        this.description = description;
        this.imageIds = imageIds;
    }
}

 

Controller DTO : 응답

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductResponse<T> {

    private Long id;
    private String name;
    private int price;
    private String description;
    private List<T> images;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updatedAt;

    public ProductResponse(Long id,
                           String name,
                           int price,
                           String description,
                           List<T> images,
                           LocalDateTime createdAt,
                           LocalDateTime updatedAt
    ) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.description = description;
        this.images = images;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    public static <T> ProductResponse<T> of(Product product, List<T> images) {
        return new ProductResponse<>(
                product.getId(),
                product.getName(),
                product.getPrice(),
                product.getDescription(),
                images,
                product.getCreatedAt(),
                product.getUpdatedAt()
        );
    }
}

 

  • Controller와 Service에서 사용하는 DTO를 분리하여 설계하였습니다.
  • Request DTO에서 `toCreate() 메서드를 통해 Service단의 상품 생성 정보 DTO로 데이터를 전달`하여 처리됩니다.
  • 응답의 경우 이미지 리스트를 `제네릭으로 처리하여 ProductResponse안에 ImageResponse 값을 적용`할 수 있도록 구성하였습니다.

 

5-2. Controller : 상품 등록

@RestController
@RequestMapping("/api/product")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @PostMapping
    public ProductResponse<ImageResponse> createProduct(@RequestBody ProductRequest productRequest) {
        return productService.createProduct(productRequest.toCreate());
    }
}

RequestBody로 먼저 구성한 DTO인 ProductRequest로 요청을 받습니다.

Service 계층으로의 데이터 전달을 위해 앞서 생성한 `toCreate() 메서드를 통해 Service 단의 DTO로 값을 전달`합니다.

 

5-3. Service : 비즈니스 로직 작성

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {

    private final ProductRepository productRepository;
    private final ImageService imageService;

    /**
     * [public 메서드]
     * - 상품 생성
     * - 이미지 목록이 존재하면 해당 이미지를 조회 후 productId가 할당되지 않은 이미지를 생성된 상품에 매핑
     * - 이미지의 경우 상품과 매핑된 이미지만 필터링하여 응답
     */
    @Transactional
    public ProductResponse<ImageResponse> createProduct(ProductCreateInfo productCreateInfo) {
        // [Step 1] 상품 저장
        Product product = productRepository.save(Product.create(
                productCreateInfo.getName(),
                productCreateInfo.getPrice(),
                productCreateInfo.getDescription()
        ));

        List<Image> images = Collections.emptyList();

        // [Step 2] 이미지 매핑 (이미지가 존재할 경우에만 처리)
        if (productCreateInfo.getImageIds() != null && !productCreateInfo.getImageIds().isEmpty()) { // 이미지 목록이 비어있지 않으면 처리
            images = imageService.findAllByImageId(productCreateInfo.getImageIds()); // 이미지 목록 조회
            images.stream()
                    .filter(image -> image.getProduct() == null) // 상품과 연결되지 않은 이미지만 필터링
                    .forEach(image -> image.assignProduct(product)); // 해당 이미지를 상품에 할당
        }

        // [Step 3] 응답 생성 (이미지는 생성되는 상품에 연결된 이미지만 포함)
        return ProductResponse.of(
                product,
                images.stream().map(ImageResponse::of).toList()
        );
    }
}
// ImageService

    /**
     * [public 메서드]
     * - 이미지 ID를 기준으로 이미지 목록을 조회
     * - 조회된 이미지가 없다면 예외 발생, List응답은 빈값으로 처리되어 Optional처리가 되지 않아 Empty로 직접 체크
     */
    public List<Image> findAllByImageId(List<Long> imageIds) {
        List<Image> images = imageRepository.findAllById(imageIds);
        if (images.isEmpty()) throw new CustomApplicationException(ErrorCode.IMAGE_ID_MISSING);
        return images;
    }

`상품 저장 → 이미지 매핑 → 응답` 순으로 처리되며 주석을 통해 자세한 설명을 달아놓았습니다.

 

5-4. Test : 상품 생성 요청

Postman 요청 이미지
DB product_id 매핑 이미지

응답받은 이미지ID를 추가하여 요청 시 상품과 해당 이미지에 대한 정보가 응답되고, DB에 `product_id가 등록한 상품과 매핑`되는 모습을 확인할 수 있습니다.

 

이렇게 상품 등록 시 해당 이미지에 매핑되는 형식으로 진행되며, 매핑되지 않은 `product_id = null`인 값들은 이후 스케줄러를 통해 삭제처리하게 됩니다.

 

6. 예제 : 스케줄러를 통한 고아객체 처리

 

6-1. Query : QueryDSL을 통한 조회 쿼리

@Repository
@RequiredArgsConstructor
public class ImageQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    /**
     * [고아 이미지 조회]
     * - productId가 null 인 이미지 중 createdAt이 주어진 기준(threshold)보다 오래된 것들만 조회
     */
    public List<Image> findOldUnlinkedImages(LocalDateTime threshold) {
        return jpaQueryFactory.selectFrom(image)
                .where(
                        image.product.isNull(),
                        image.createdAt.before(threshold)
                )
                .fetch();
    }
}

LocalDateTime을 매개변수로 `createdAt이 주어진 기준보다 오래된 product_id = null인 이미지를 조회`합니다.

 

6-2. Service : 비즈니스 로직 작성

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ImageService {

    private final S3Client s3Client;
    private final ImageRepository imageRepository;
    private final ImageQueryRepository imageQueryRepository;

    @Value("${aws.s3.bucket-name}")
    private String bucketName;
    
    /**
     * [public 메서드]
     * S3, DB 이미지 제거
     */
    @Transactional
    public void deleteImage(List<Image> images) {
        // [Step 1] 각 이미지 객체의 path와 name을 결합해 S3에서 삭제할 키 목록 생성
        List<String> keys = getFullKeys(images);

        try {
            // [Step 2] 생성한 키 목록을 기반으로 S3에서 파일을 삭제하기 위한 요청 객체 생성
            DeleteObjectsRequest deleteObjectsRequest = DeleteObjectsRequest.builder()
                    .bucket(bucketName)
                    .delete(delete -> delete.objects(
                            keys.stream()
                                    .map(key -> ObjectIdentifier.builder().key(key).build())
                                    .toList()
                    ))
                    .build();

            // [Step 3] S3 및 DB 이미지 제거
            s3Client.deleteObjects(deleteObjectsRequest);
            imageRepository.deleteAll(images);

        } catch (Exception exception) {
            log.error(exception.getMessage(), exception);
            throw new CustomApplicationException(ErrorCode.IO_EXCEPTION_DELETE_FILE);
        }
    }

    /**
     * [private 메서드]
     * 이미지 객체의 path와 name을 결합하여 S3에서 삭제할 키 목록 생성
     */
    private List<String> getFullKeys(List<Image> images) {
        return images.stream()
                .map(image -> image.getPath() + image.getName())
                .toList();
    }

    /**
     * [public 메서드]
     * - 고아 이미지 조회
     * - productId가 null 인 이미지 중 createdAt이 주어진 기준(threshold)보다 오래된 것들만 조회
     */
    public List<Image> findOldUnlinkedImages(LocalDateTime threshold) {
        return imageQueryRepository.findOldUnlinkedImages(threshold);
    }
}

`이미지의 path + name 을 통해 AWS S3에 삭제 요청을 위한 key 생성 → 요청 객체 생성 → AWS S3 및 DB 이미지 제거` 순으로 처리되며 주석을 통해 상세하게 설명을 남겨놓았습니다.

 

`findOldUnlinkedImages(LocalDateTime threshold)` 메서드는 앞서 작성한 쿼리를 호출하는 메서드입니다.

 

6-3. Scheduler : 주기적으로 동작하는 스케줄러 작성

@Slf4j
@Component
@RequiredArgsConstructor
public class ImageScheduler {

    private final ImageService imageService;

    @Transactional
    @Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 00시 동작
    public void deleteOrphanImages() {
        LocalDateTime threshold = LocalDateTime.now().minusWeeks(1); // 일주일 이상 지난 이미지

        List<Image> oldUnlinkedImages = imageService.findOldUnlinkedImages(threshold);

        if (!oldUnlinkedImages.isEmpty()) {
            imageService.deleteImage(oldUnlinkedImages);
            log.info("삭제된 고아 이미지 수 : {}개", oldUnlinkedImages.size());
        } else {
            log.info("고아 이미지가 없습니다.");
        }
    }
}

예시로 매주 월요일 00시 동작하는 스케줄러를 생성하였습니다.

`이미지 생성일자 기준 일주일 이상 지난 이미지를 조회하여 해당 조회값이 존재할 시 삭제` 로직을 수행시킵니다.

 

스케줄러 설정에 대해선 이전 포스팅을 참고 바랍니다.

 

[SpringBoot] @Scheduled를 이용한 스케줄러 구현

Spring 프레임워크에서 제공하는 @Scheduled어노테이션은 주기적으로 실행해야 하는 작업 데이터 정리, 알림 전송, 백업 등의 작업을 자동화하는 도구입니다.이번 포스팅에서는 @Scheduled어노테이션

tao-tech.tistory.com

 

 

6-4. Test : 고아 객체 제거

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/image")
public class ImageController {

    private final ImageScheduler imageScheduler;

    @DeleteMapping("/delete")
    public String deleteImage() {
        try {
            imageScheduler.deleteOrphanImages(); // 스케줄러 메서드를 수동 호출
            return "고아 이미지 삭제 작업이 완료되었습니다.";
        } catch (Exception e) {
            return "고아 이미지 삭제 작업에 실패했습니다: " + e.getMessage();
        }
    }
}

Service 로직 수행 테스트를 위해 스케줄러를 직접 호출하여 사용하도록 `임시 스케줄러 호출용 Controller`를 생성하였습니다.

 

AWS S3 이미지
테스트용 DB 이미지

테스트 편의상 `product_id = null인 이미지를 3개 추가하였고 created_at을 한달 전으로 수정` 해놓았습니다.

생성된지 1개월이 지난 이미지ID: `1, 3, 4`

제거될 이미지ID: `3, 4`

 

고아 객체 제거

console log 이미지
DB 이미지

고아 객체 제거 로직 수행 시 삭제된 고아객체 수 로그를 확인할 수 있고, AWS S3 및 DB에도 앞서 말씀드린 제거 대상이 제거된 모습을 확인할 수 있습니다.

 

마무리

고아 객체란, 참조되는 부모 객체 없이 남겨진 자식 객체를 의미합니다.

이번 포스팅에서는 이러한 고아 객체를 효과적으로 처리하기 위해 트랜잭션 분리 전략을 적용한 방법을 구현 예제와 함께 다루었습니다. 고아 객체가 발생하더라도 자동화된 방식으로 쉽게 관리할 수 있게 구성해 보았습니다.

이 글에서는 이미지 업로드, 상품 등록, 고아 객체 삭제까지의 흐름을 중심으로 설명드렸으며, 상품 수정 시 이미지 제거에 대한 처리 방식까지 궁금하신 분들은 아래에 첨부한 소스코드를 참고해보시길 바랍니다.

 

소스코드

https://github.com/o-tao/s3-image-service

 

GitHub - o-tao/s3-image-service: AWS S3 이미지 업로드, 고아객체 처리

AWS S3 이미지 업로드, 고아객체 처리. Contribute to o-tao/s3-image-service development by creating an account on GitHub.

github.com