Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.codeit.team5.mopl.binarycontent.dto;

public record UploadedBinaryContent(
String key,
String url
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.codeit.team5.mopl.binarycontent.event;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;

// 업로드 후 후속 작업 실패 시, 업로드된 스토리지 객체를 비동기로 삭제(롤백)하기 위한 이벤트
public record BinaryContentDeleteEvent(
UploadedBinaryContent uploaded
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.codeit.team5.mopl.binarycontent.eventlistener;

import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class BinaryContentDeleteEventListener {

private final BinaryContentService binaryContentService;

@Async("binaryContentTaskExecutor")
@EventListener
public void handle(BinaryContentDeleteEvent event) {
binaryContentService.deleteQuietly(event.uploaded());
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
package com.codeit.team5.mopl.binarycontent.service;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
public class BinaryContentService {

private final BinaryContentStorage binaryContentStorage;
private final StorageKeyFactory storageKeyFactory;
private final BinaryContentRepository binaryContentRepository;

@Transactional
public BinaryContent upload(StorageDirectory directory, UUID ownerId, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(directory, ownerId, image.filename());
public UploadedBinaryContent uploadToStorage(StorageDirectory directory, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(directory, image.filename());
binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType());
return binaryContentRepository.save(
BinaryContent.completed(binaryContentStorage.toUrl(generated.key())));
return new UploadedBinaryContent(generated.key(), binaryContentStorage.toUrl(generated.key()));
}

@Transactional
public BinaryContent saveCompleted(UploadedBinaryContent uploaded) {
return binaryContentRepository.save(BinaryContent.completed(uploaded.url()));
}

public void deleteQuietly(UploadedBinaryContent uploaded) {
if (uploaded == null) {
return;
}
try {
binaryContentStorage.delete(uploaded.key());
} catch (RuntimeException e) {
log.warn("업로드 보상 삭제 실패: key={}", uploaded.key(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.codeit.team5.mopl.binarycontent.service;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;
import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

/**
* 스토리지 업로드(트랜잭션 밖)를 수행한 뒤 후속 작업을 실행하고,
* 후속 작업이 실패하면 업로드한 객체를 비동기로 삭제해 롤백한다.
*/
@Component
@RequiredArgsConstructor
public class UploadWithRollback {

private final BinaryContentService binaryContentService;
private final ApplicationEventPublisher eventPublisher;

public <T> T execute(
StorageDirectory directory,
FileRequest image,
Function<UploadedBinaryContent, T> persist
) {
UploadedBinaryContent uploaded = null;
try {
if (image != null) {
uploaded = binaryContentService.uploadToStorage(directory, image);
}
return persist.apply(uploaded);
} catch (RuntimeException e) {
if (uploaded != null) {
eventPublisher.publishEvent(new BinaryContentDeleteEvent(uploaded));
}
throw e;
}
}
}
Comment thread
dstle marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ public interface BinaryContentStorage {
String toUrl(String key);

void store(String key, byte[] bytes, String contentType);

void delete(String key);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@

/**
* 저장소 키 생성 책임을 담당한다.
* 확장자 검증 → 폴더 prefix/소유자/난수 조합 → Content-Type 결정을 한 곳에서 처리한다.
* 확장자 검증 → 폴더 prefix/난수 조합 → Content-Type 결정을 한 곳에서 처리한다.
*/
@Component
public class StorageKeyFactory {

public GeneratedKey generate(StorageDirectory directory, UUID ownerId, String originalFilename) {
public GeneratedKey generate(StorageDirectory directory, String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
ImageExtension imageExtension = ImageExtension.from(extension)
.orElseThrow(() -> new InvalidImageExtensionException(extension));
String key = directory.value() + "/" + ownerId + "/" + UUID.randomUUID()
String key = directory.value() + "/" + UUID.randomUUID()
+ "." + imageExtension.name().toLowerCase(Locale.ROOT);
return new GeneratedKey(key, imageExtension.contentType());
}
Comment thread
dstle marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ public void store(String key, byte[] bytes, String contentType) {
log.debug("파일 저장 완료: {}", destination);
}

@Override
public void delete(String key) {
Path destination = uploadDir.resolve(key);
boolean deleted;
try {
deleted = Files.deleteIfExists(destination);
} catch (IOException e) {
throw new FileStorageException(key, e);
}
if (deleted) {
log.debug("파일 삭제 완료: {}", destination);
} else {
log.debug("삭제 대상 파일 없음: {}", destination);
}
}

private void init() {
try {
Files.createDirectories(uploadDir);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

@Slf4j
Expand Down Expand Up @@ -45,4 +46,18 @@ public void store(String key, byte[] bytes, String contentType) {
HttpStatus.INTERNAL_SERVER_ERROR, "S3 업로드 실패: key=" + key, e);
}
}

@Override
public void delete(String key) {
try {
s3Client.deleteObject(DeleteObjectRequest.builder()
.bucket(properties.bucketName())
.key(key)
.build());
log.debug("S3 객체 삭제 완료: key={}", key);
} catch (SdkException e) {
throw new BinaryContentStorageException(
HttpStatus.INTERNAL_SERVER_ERROR, "S3 객체 삭제 실패: key=" + key, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
package com.codeit.team5.mopl.binarycontent.storage.s3;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "mopl.storage.s3")
public record S3StorageProperties(
String accessKey,
String secretKey,
String bucketName,
String region
String region,
Duration apiCallTimeout,
Duration apiCallAttemptTimeout
) {

public S3StorageProperties {
if (apiCallTimeout == null) {
apiCallTimeout = Duration.ofSeconds(10);
}
if (apiCallAttemptTimeout == null) {
apiCallAttemptTimeout = Duration.ofSeconds(5);
}
if (apiCallAttemptTimeout.compareTo(apiCallTimeout) > 0) {
throw new IllegalArgumentException(
"apiCallAttemptTimeout(" + apiCallAttemptTimeout
+ ")은 apiCallTimeout(" + apiCallTimeout + ")보다 클 수 없습니다.");
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
13 changes: 13 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,17 @@ public ThreadPoolTaskExecutor batchJobTaskExecutor() {
executor.initialize();
return executor;
}

@Bean
public ThreadPoolTaskExecutor binaryContentTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("bin-delete-");
executor.setTaskDecorator(new MdcTaskDecorator());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
Comment thread
dstle marked this conversation as resolved.
}
5 changes: 5 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/StorageConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

Expand Down Expand Up @@ -40,6 +41,10 @@ public S3Client s3Client(S3StorageProperties props) {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(credentialsProvider)
.overrideConfiguration(ClientOverrideConfiguration.builder()
.apiCallTimeout(props.apiCallTimeout())
.apiCallAttemptTimeout(props.apiCallAttemptTimeout())
.build())
.build();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.entity.ContentSortByType;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.content.facade.ContentFacade;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import jakarta.validation.Valid;
Expand Down Expand Up @@ -38,6 +39,7 @@
public class ContentController implements ContentApi {

private final ContentService contentService;
private final ContentFacade contentFacade;

@InitBinder
public void initBinder(WebDataBinder binder) {
Expand All @@ -63,7 +65,7 @@ public ResponseEntity<ContentResponse> postContent(
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Create request: POST /api/contents");
ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail));
ContentResponse response = contentFacade.create(request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

Expand All @@ -76,7 +78,7 @@ public ResponseEntity<ContentResponse> patchContent(
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Update request: PATCH /api/contents/{}", contentId);
ContentResponse response = contentService.update(contentId, request, MultipartFiles.toImageResource(thumbnail));
ContentResponse response = contentFacade.update(contentId, request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.codeit.team5.mopl.content.facade;

import com.codeit.team5.mopl.binarycontent.service.UploadWithRollback;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ContentFacade {

private final ContentService contentService;
private final UploadWithRollback uploadWithRollback;

public ContentResponse create(ContentCreateRequest request, FileRequest image) {
return uploadWithRollback.execute(StorageDirectory.THUMBNAIL, image,
uploaded -> contentService.create(request, uploaded));
}

public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) {
return uploadWithRollback.execute(StorageDirectory.THUMBNAIL, image,
uploaded -> contentService.update(contentId, request, uploaded));
}
}
Loading
Loading