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
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
Loading