Skip to content
Merged
53 changes: 30 additions & 23 deletions src/main/java/eatda/client/file/FileClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,34 @@
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

@Slf4j
@Component
public class FileClient {

private static final int THREAD_POOL_SIZE = 5; // TODO 비동기 병렬처리 개선
private static final String PATH_DELIMITER = "/";
private final S3Client s3Client;
private final String bucket;
private final S3Presigner s3Presigner;
private final ExecutorService executorService;

public FileClient(S3Client s3Client,
@Value("${spring.cloud.aws.s3.bucket}") String bucket,
S3Presigner s3Presigner) {
this.s3Client = s3Client;
this.bucket = bucket;
this.s3Presigner = s3Presigner;
this.executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
}

public String generateUploadPresignedUrl(String fileKey, Duration signatureDuration) {
Expand All @@ -52,27 +51,35 @@ public String generateUploadPresignedUrl(String fileKey, Duration signatureDurat
}

public List<String> moveTempFilesToPermanent(String domainName, long domainId, List<String> tempImageKeys) {
List<CompletableFuture<String>> futures = tempImageKeys.stream()
.map(tempImageKey -> CompletableFuture.supplyAsync(() -> {
String fileName = extractFileName(tempImageKey);
String newPermanentKey = domainName + "/" + domainId + "/" + fileName;
try {
copyObject(tempImageKey, newPermanentKey);
deleteObject(tempImageKey);
return newPermanentKey;
} catch (Exception e) { //TODO 근본 예외 추가 필요
throw new BusinessException(BusinessErrorCode.FAIL_TEMP_IMAGE_PROCESS);
}
}, executorService))
.toList();
List<String> successKeys = new ArrayList<>();

return futures.stream()
.map(CompletableFuture::join) // TODO 일부 파일 에러에도 처리하도록 개선
.toList();
try {
for (String tempKey : tempImageKeys) {
String fileName = extractFileName(tempKey);
String newPermanentKey = domainName + PATH_DELIMITER + domainId + PATH_DELIMITER + fileName;

copyObject(tempKey, newPermanentKey);
deleteObject(tempKey);

successKeys.add(newPermanentKey);
}
return successKeys;
} catch (SdkException sdkException) {
log.error("S3 파일 이동 중 실패. 롤백 수행. successKeys={}", successKeys, sdkException);
deleteFiles(successKeys);
throw new BusinessException(BusinessErrorCode.FAIL_TEMP_IMAGE_PROCESS);
}
}

public void deleteFiles(List<String> keys) {
if (keys.isEmpty()) {
return;
}
keys.forEach(this::deleteObject);
}

private String extractFileName(String fullKey) {
int index = fullKey.lastIndexOf('/');
int index = fullKey.lastIndexOf(PATH_DELIMITER);
return index == -1 ? fullKey : fullKey.substring(index + 1);
}

Expand Down
4 changes: 3 additions & 1 deletion src/main/java/eatda/controller/cheer/CheerController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import eatda.domain.cheer.CheerTagName;
import eatda.domain.store.StoreCategory;
import eatda.domain.store.StoreSearchResult;
import eatda.facade.CheerRegisterFacade;
import eatda.service.cheer.CheerService;
import eatda.service.store.StoreSearchService;
import jakarta.validation.constraints.Max;
Expand All @@ -29,13 +30,14 @@ public class CheerController {

private final CheerService cheerService;
private final StoreSearchService storeSearchService;
private final CheerRegisterFacade cheerRegisterFacade;

@PostMapping("/api/cheer")
public ResponseEntity<CheerResponse> registerCheer(@RequestBody CheerRegisterRequest request,
LoginMember member) {
StoreSearchResult searchResult = storeSearchService.searchStoreByKakaoId(
request.storeName(), request.storeKakaoId());
CheerResponse response = cheerService.registerCheer(request, searchResult, member.id(), ImageDomain.CHEER);
CheerResponse response = cheerRegisterFacade.registerCheer(request, searchResult, member.id(), ImageDomain.CHEER);
return ResponseEntity.status(HttpStatus.CREATED)
.body(response);
}
Expand Down
7 changes: 3 additions & 4 deletions src/main/java/eatda/controller/cheer/CheerResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import eatda.domain.cheer.Cheer;
import eatda.domain.cheer.CheerTagName;
import eatda.domain.store.Store;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
Expand All @@ -15,12 +14,12 @@ public record CheerResponse(
List<CheerTagName> tags
) {

public CheerResponse(Cheer cheer, Store store, String cdnBaseUrl) {
public CheerResponse(Cheer cheer, String cdnBaseUrl) {
this(
store.getId(),
cheer.getStore().getId(),
cheer.getId(),
cheer.getImages().stream()
.map(img -> new CheerImageResponse(img, cdnBaseUrl)) // ✅ CDN 붙여줌
.map(img -> new CheerImageResponse(img, cdnBaseUrl))
.sorted(Comparator.comparingLong(CheerImageResponse::orderIndex))
.collect(Collectors.toList()),
cheer.getDescription(),
Expand Down
1 change: 1 addition & 0 deletions src/main/java/eatda/exception/BusinessErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public enum BusinessErrorCode {
INVALID_CHEER_IMAGE_KEY("CHE002", "응원 이미지 키가 비어 있습니다.", HttpStatus.BAD_REQUEST),
FULL_CHEER_SIZE_PER_MEMBER("CHE003", "회원당 응원 한도가 넘었습니다."),
ALREADY_CHEERED("CHE004", "이미 응원한 가게입니다."),
CHEER_NOT_FOUND("CHE005", "응원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),

// CheerTag
CHEER_TAGS_DUPLICATED("CHE_TAG001", "응원 태그는 중복될 수 없습니다."),
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/eatda/facade/CheerCreationResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package eatda.facade;

import eatda.domain.cheer.Cheer;
import eatda.domain.store.Store;

public record CheerCreationResult(Cheer cheer, Store store) {
}
76 changes: 76 additions & 0 deletions src/main/java/eatda/facade/CheerRegisterFacade.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package eatda.facade;

import eatda.client.file.FileClient;
import eatda.controller.cheer.CheerRegisterRequest;
import eatda.controller.cheer.CheerResponse;
import eatda.domain.ImageDomain;
import eatda.domain.cheer.Cheer;
import eatda.domain.store.StoreSearchResult;
import eatda.service.cheer.CheerService;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class CheerRegisterFacade {

private final CheerService cheerService;
private final FileClient fileClient;

public CheerResponse registerCheer(CheerRegisterRequest request,
StoreSearchResult result,
long memberId,
ImageDomain domain
) {
CheerCreationResult creationResult = cheerService.createCheer(request, result, memberId);
Cheer cheer = creationResult.cheer();

if (request.images() == null || request.images().isEmpty()) {
return cheerService.getCheerResponse(cheer.getId());
}

List<String> permanentKeys = Collections.emptyList();
try {
List<CheerRegisterRequest.UploadedImageDetail> sortedImages = sortImages(request.images());
permanentKeys = moveImages(domain, cheer.getId(), sortedImages);
cheerService.saveCheerImages(cheer.getId(), sortedImages, permanentKeys);

} catch (Exception e) {
log.error("응원 등록 프로세스 실패. 롤백 수행. cheerId={}", cheer.getId(), e);

cheerService.deleteCheer(cheer.getId());

if (!permanentKeys.isEmpty()) {
fileClient.deleteFiles(permanentKeys);
}
throw e;
}

return cheerService.getCheerResponse(cheer.getId());
}

private List<CheerRegisterRequest.UploadedImageDetail> sortImages(
List<CheerRegisterRequest.UploadedImageDetail> images) {
return images.stream()
.sorted(Comparator.comparingLong(CheerRegisterRequest.UploadedImageDetail::orderIndex))
.toList();
}

private List<String> moveImages(ImageDomain domain,
long cheerId,
List<CheerRegisterRequest.UploadedImageDetail> sortedImages) {
if (sortedImages.isEmpty()) {
return List.of();
}

List<String> tempKeys = sortedImages.stream()
.map(CheerRegisterRequest.UploadedImageDetail::imageKey)
.toList();
return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys);
}
}
78 changes: 32 additions & 46 deletions src/main/java/eatda/service/cheer/CheerService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package eatda.service.cheer;

import eatda.client.file.FileClient;
import eatda.controller.cheer.CheerImageResponse;
import eatda.controller.cheer.CheerInStoreResponse;
import eatda.controller.cheer.CheerPreviewResponse;
Expand All @@ -9,14 +8,14 @@
import eatda.controller.cheer.CheerSearchParameters;
import eatda.controller.cheer.CheersInStoreResponse;
import eatda.controller.cheer.CheersResponse;
import eatda.domain.ImageDomain;
import eatda.domain.cheer.Cheer;
import eatda.domain.cheer.CheerImage;
import eatda.domain.member.Member;
import eatda.domain.store.Store;
import eatda.domain.store.StoreSearchResult;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import eatda.facade.CheerCreationResult;
import eatda.repository.cheer.CheerRepository;
import eatda.repository.member.MemberRepository;
import eatda.repository.store.StoreRepository;
Expand All @@ -37,20 +36,18 @@
public class CheerService {

private static final int MAX_CHEER_SIZE = 10_000;

private static final String SORTED_PROPERTIES = "createdAt";
private final MemberRepository memberRepository;
private final StoreRepository storeRepository;
private final CheerRepository cheerRepository;
private final FileClient fileClient;

@Value("${cdn.base-url}")
private String cdnBaseUrl;

@Transactional
public CheerResponse registerCheer(CheerRegisterRequest request,
StoreSearchResult result,
long memberId,
ImageDomain domain
public CheerCreationResult createCheer(CheerRegisterRequest request,
StoreSearchResult result,
long memberId
) {
Member member = memberRepository.getById(memberId);
validateRegisterCheer(member, request.storeKakaoId());
Expand All @@ -59,15 +56,7 @@ public CheerResponse registerCheer(CheerRegisterRequest request,
.orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결
Cheer cheer = new Cheer(member, store, request.description());
cheer.setCheerTags(request.tags());
Cheer savedCheer = cheerRepository.save(cheer);

// TODO 트랜잭션 범위 축소
List<CheerRegisterRequest.UploadedImageDetail> sortedImages = sortImages(request.images());
List<String> permanentKeys = moveImages(domain, cheer.getId(), sortedImages);

saveCheerImages(cheer, sortedImages, permanentKeys);

return new CheerResponse(savedCheer, store, cdnBaseUrl);
return new CheerCreationResult(cheerRepository.save(cheer), store);
}

private void validateRegisterCheer(Member member, String storeKakaoId) {
Expand All @@ -79,25 +68,14 @@ private void validateRegisterCheer(Member member, String storeKakaoId) {
}
}

private List<CheerRegisterRequest.UploadedImageDetail> sortImages(
List<CheerRegisterRequest.UploadedImageDetail> images) {
return images.stream()
.sorted(Comparator.comparingLong(CheerRegisterRequest.UploadedImageDetail::orderIndex))
.toList();
}
@Transactional
public void saveCheerImages(Long cheerId,
List<CheerRegisterRequest.UploadedImageDetail> sortedImages,
List<String> permanentKeys) {

private List<String> moveImages(ImageDomain domain,
long cheerId,
List<CheerRegisterRequest.UploadedImageDetail> sortedImages) {
List<String> tempKeys = sortedImages.stream()
.map(CheerRegisterRequest.UploadedImageDetail::imageKey)
.toList();
return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys);
}
Cheer cheer = cheerRepository.findById(cheerId)
.orElseThrow(() -> new BusinessException(BusinessErrorCode.CHEER_NOT_FOUND));

private void saveCheerImages(Cheer cheer,
List<CheerRegisterRequest.UploadedImageDetail> sortedImages,
List<String> permanentKeys) {
IntStream.range(0, sortedImages.size())
.forEach(i -> {
var detail = sortedImages.get(i);
Expand All @@ -108,10 +86,8 @@ private void saveCheerImages(Cheer cheer,
detail.contentType(),
detail.fileSize()
);
cheer.addImage(cheerImage); // 여기서 양방향 동기화
cheer.addImage(cheerImage);
});

cheerRepository.save(cheer);
}

@Transactional(readOnly = true)
Expand All @@ -121,7 +97,7 @@ public CheersResponse getCheers(CheerSearchParameters parameters) {
parameters.getCheerTagNames(),
parameters.getDistricts(),
PageRequest.of(parameters.getPage(), parameters.getSize(),
Sort.by(Direction.DESC, "createdAt"))
Sort.by(Direction.DESC, SORTED_PROPERTIES))
);

List<Cheer> cheers = cheerPage.getContent();
Expand All @@ -130,14 +106,11 @@ public CheersResponse getCheers(CheerSearchParameters parameters) {

private CheersResponse toCheersResponse(List<Cheer> cheers) {
return new CheersResponse(cheers.stream()
.map(cheer -> {
Store store = cheer.getStore();
return new CheerPreviewResponse(cheer,
cheer.getImages().stream()
.map(img -> new CheerImageResponse(img, cdnBaseUrl))
.sorted(Comparator.comparingLong(CheerImageResponse::orderIndex))
.toList());
})
.map(cheer -> new CheerPreviewResponse(cheer,
cheer.getImages().stream()
.map(img -> new CheerImageResponse(img, cdnBaseUrl))
.sorted(Comparator.comparingLong(CheerImageResponse::orderIndex))
.toList()))
.toList());
}

Expand All @@ -152,4 +125,17 @@ public CheersInStoreResponse getCheersByStoreId(Long storeId, int page, int size

return new CheersInStoreResponse(cheersResponse);
}

@Transactional
public void deleteCheer(Long cheerId) {
cheerRepository.deleteById(cheerId);
}

@Transactional(readOnly = true)
public CheerResponse getCheerResponse(Long cheerId) {
Cheer cheer = cheerRepository.findById(cheerId)
.orElseThrow(() -> new BusinessException(BusinessErrorCode.CHEER_NOT_FOUND));

return new CheerResponse(cheer, cdnBaseUrl);
}
}
Loading