Skip to content

Commit 3d9dbe2

Browse files
authored
Merge pull request #216 from YAPP-Github/refactor/PRODUCT-287
[Refactor] 응원 등록 이미지 트랜잭션 분리 및 보상 트랜잭션 적용
2 parents 1997898 + 06562cc commit 3d9dbe2

File tree

12 files changed

+604
-152
lines changed

12 files changed

+604
-152
lines changed

src/main/java/eatda/client/file/FileClient.java

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,34 @@
33
import eatda.exception.BusinessErrorCode;
44
import eatda.exception.BusinessException;
55
import java.time.Duration;
6+
import java.util.ArrayList;
67
import java.util.List;
7-
import java.util.concurrent.CompletableFuture;
8-
import java.util.concurrent.ExecutorService;
9-
import java.util.concurrent.Executors;
8+
import lombok.extern.slf4j.Slf4j;
109
import org.springframework.beans.factory.annotation.Value;
1110
import org.springframework.stereotype.Component;
11+
import software.amazon.awssdk.core.exception.SdkException;
1212
import software.amazon.awssdk.services.s3.S3Client;
1313
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
1414
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
1515
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
1616
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
1717
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
1818

19+
@Slf4j
1920
@Component
2021
public class FileClient {
2122

22-
private static final int THREAD_POOL_SIZE = 5; // TODO 비동기 병렬처리 개선
23+
private static final String PATH_DELIMITER = "/";
2324
private final S3Client s3Client;
2425
private final String bucket;
2526
private final S3Presigner s3Presigner;
26-
private final ExecutorService executorService;
2727

2828
public FileClient(S3Client s3Client,
2929
@Value("${spring.cloud.aws.s3.bucket}") String bucket,
3030
S3Presigner s3Presigner) {
3131
this.s3Client = s3Client;
3232
this.bucket = bucket;
3333
this.s3Presigner = s3Presigner;
34-
this.executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
3534
}
3635

3736
public String generateUploadPresignedUrl(String fileKey, Duration signatureDuration) {
@@ -52,27 +51,35 @@ public String generateUploadPresignedUrl(String fileKey, Duration signatureDurat
5251
}
5352

5453
public List<String> moveTempFilesToPermanent(String domainName, long domainId, List<String> tempImageKeys) {
55-
List<CompletableFuture<String>> futures = tempImageKeys.stream()
56-
.map(tempImageKey -> CompletableFuture.supplyAsync(() -> {
57-
String fileName = extractFileName(tempImageKey);
58-
String newPermanentKey = domainName + "/" + domainId + "/" + fileName;
59-
try {
60-
copyObject(tempImageKey, newPermanentKey);
61-
deleteObject(tempImageKey);
62-
return newPermanentKey;
63-
} catch (Exception e) { //TODO 근본 예외 추가 필요
64-
throw new BusinessException(BusinessErrorCode.FAIL_TEMP_IMAGE_PROCESS);
65-
}
66-
}, executorService))
67-
.toList();
54+
List<String> successKeys = new ArrayList<>();
6855

69-
return futures.stream()
70-
.map(CompletableFuture::join) // TODO 일부 파일 에러에도 처리하도록 개선
71-
.toList();
56+
try {
57+
for (String tempKey : tempImageKeys) {
58+
String fileName = extractFileName(tempKey);
59+
String newPermanentKey = domainName + PATH_DELIMITER + domainId + PATH_DELIMITER + fileName;
60+
61+
copyObject(tempKey, newPermanentKey);
62+
deleteObject(tempKey);
63+
64+
successKeys.add(newPermanentKey);
65+
}
66+
return successKeys;
67+
} catch (SdkException sdkException) {
68+
log.error("S3 파일 이동 중 실패. 롤백 수행. successKeys={}", successKeys, sdkException);
69+
deleteFiles(successKeys);
70+
throw new BusinessException(BusinessErrorCode.FAIL_TEMP_IMAGE_PROCESS);
71+
}
72+
}
73+
74+
public void deleteFiles(List<String> keys) {
75+
if (keys.isEmpty()) {
76+
return;
77+
}
78+
keys.forEach(this::deleteObject);
7279
}
7380

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

src/main/java/eatda/controller/cheer/CheerController.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import eatda.domain.cheer.CheerTagName;
77
import eatda.domain.store.StoreCategory;
88
import eatda.domain.store.StoreSearchResult;
9+
import eatda.facade.CheerRegisterFacade;
910
import eatda.service.cheer.CheerService;
1011
import eatda.service.store.StoreSearchService;
1112
import jakarta.validation.constraints.Max;
@@ -29,13 +30,14 @@ public class CheerController {
2930

3031
private final CheerService cheerService;
3132
private final StoreSearchService storeSearchService;
33+
private final CheerRegisterFacade cheerRegisterFacade;
3234

3335
@PostMapping("/api/cheer")
3436
public ResponseEntity<CheerResponse> registerCheer(@RequestBody CheerRegisterRequest request,
3537
LoginMember member) {
3638
StoreSearchResult searchResult = storeSearchService.searchStoreByKakaoId(
3739
request.storeName(), request.storeKakaoId());
38-
CheerResponse response = cheerService.registerCheer(request, searchResult, member.id(), ImageDomain.CHEER);
40+
CheerResponse response = cheerRegisterFacade.registerCheer(request, searchResult, member.id(), ImageDomain.CHEER);
3941
return ResponseEntity.status(HttpStatus.CREATED)
4042
.body(response);
4143
}

src/main/java/eatda/controller/cheer/CheerResponse.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import eatda.domain.cheer.Cheer;
44
import eatda.domain.cheer.CheerTagName;
5-
import eatda.domain.store.Store;
65
import java.util.Comparator;
76
import java.util.List;
87
import java.util.stream.Collectors;
@@ -15,12 +14,12 @@ public record CheerResponse(
1514
List<CheerTagName> tags
1615
) {
1716

18-
public CheerResponse(Cheer cheer, Store store, String cdnBaseUrl) {
17+
public CheerResponse(Cheer cheer, String cdnBaseUrl) {
1918
this(
20-
store.getId(),
19+
cheer.getStore().getId(),
2120
cheer.getId(),
2221
cheer.getImages().stream()
23-
.map(img -> new CheerImageResponse(img, cdnBaseUrl)) // ✅ CDN 붙여줌
22+
.map(img -> new CheerImageResponse(img, cdnBaseUrl))
2423
.sorted(Comparator.comparingLong(CheerImageResponse::orderIndex))
2524
.collect(Collectors.toList()),
2625
cheer.getDescription(),

src/main/java/eatda/exception/BusinessErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public enum BusinessErrorCode {
2828
INVALID_CHEER_IMAGE_KEY("CHE002", "응원 이미지 키가 비어 있습니다.", HttpStatus.BAD_REQUEST),
2929
FULL_CHEER_SIZE_PER_MEMBER("CHE003", "회원당 응원 한도가 넘었습니다."),
3030
ALREADY_CHEERED("CHE004", "이미 응원한 가게입니다."),
31+
CHEER_NOT_FOUND("CHE005", "응원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
3132

3233
// CheerTag
3334
CHEER_TAGS_DUPLICATED("CHE_TAG001", "응원 태그는 중복될 수 없습니다."),
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package eatda.facade;
2+
3+
import eatda.domain.cheer.Cheer;
4+
import eatda.domain.store.Store;
5+
6+
public record CheerCreationResult(Cheer cheer, Store store) {
7+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package eatda.facade;
2+
3+
import eatda.client.file.FileClient;
4+
import eatda.controller.cheer.CheerRegisterRequest;
5+
import eatda.controller.cheer.CheerResponse;
6+
import eatda.domain.ImageDomain;
7+
import eatda.domain.cheer.Cheer;
8+
import eatda.domain.store.StoreSearchResult;
9+
import eatda.service.cheer.CheerService;
10+
import java.util.Collections;
11+
import java.util.Comparator;
12+
import java.util.List;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.stereotype.Component;
16+
17+
@Slf4j
18+
@Component
19+
@RequiredArgsConstructor
20+
public class CheerRegisterFacade {
21+
22+
private final CheerService cheerService;
23+
private final FileClient fileClient;
24+
25+
public CheerResponse registerCheer(CheerRegisterRequest request,
26+
StoreSearchResult result,
27+
long memberId,
28+
ImageDomain domain
29+
) {
30+
CheerCreationResult creationResult = cheerService.createCheer(request, result, memberId);
31+
Cheer cheer = creationResult.cheer();
32+
33+
if (request.images() == null || request.images().isEmpty()) {
34+
return cheerService.getCheerResponse(cheer.getId());
35+
}
36+
37+
List<String> permanentKeys = Collections.emptyList();
38+
try {
39+
List<CheerRegisterRequest.UploadedImageDetail> sortedImages = sortImages(request.images());
40+
permanentKeys = moveImages(domain, cheer.getId(), sortedImages);
41+
cheerService.saveCheerImages(cheer.getId(), sortedImages, permanentKeys);
42+
43+
} catch (Exception e) {
44+
log.error("응원 등록 프로세스 실패. 롤백 수행. cheerId={}", cheer.getId(), e);
45+
46+
cheerService.deleteCheer(cheer.getId());
47+
48+
if (!permanentKeys.isEmpty()) {
49+
fileClient.deleteFiles(permanentKeys);
50+
}
51+
throw e;
52+
}
53+
54+
return cheerService.getCheerResponse(cheer.getId());
55+
}
56+
57+
private List<CheerRegisterRequest.UploadedImageDetail> sortImages(
58+
List<CheerRegisterRequest.UploadedImageDetail> images) {
59+
return images.stream()
60+
.sorted(Comparator.comparingLong(CheerRegisterRequest.UploadedImageDetail::orderIndex))
61+
.toList();
62+
}
63+
64+
private List<String> moveImages(ImageDomain domain,
65+
long cheerId,
66+
List<CheerRegisterRequest.UploadedImageDetail> sortedImages) {
67+
if (sortedImages.isEmpty()) {
68+
return List.of();
69+
}
70+
71+
List<String> tempKeys = sortedImages.stream()
72+
.map(CheerRegisterRequest.UploadedImageDetail::imageKey)
73+
.toList();
74+
return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys);
75+
}
76+
}
Lines changed: 32 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package eatda.service.cheer;
22

3-
import eatda.client.file.FileClient;
43
import eatda.controller.cheer.CheerImageResponse;
54
import eatda.controller.cheer.CheerInStoreResponse;
65
import eatda.controller.cheer.CheerPreviewResponse;
@@ -9,14 +8,14 @@
98
import eatda.controller.cheer.CheerSearchParameters;
109
import eatda.controller.cheer.CheersInStoreResponse;
1110
import eatda.controller.cheer.CheersResponse;
12-
import eatda.domain.ImageDomain;
1311
import eatda.domain.cheer.Cheer;
1412
import eatda.domain.cheer.CheerImage;
1513
import eatda.domain.member.Member;
1614
import eatda.domain.store.Store;
1715
import eatda.domain.store.StoreSearchResult;
1816
import eatda.exception.BusinessErrorCode;
1917
import eatda.exception.BusinessException;
18+
import eatda.facade.CheerCreationResult;
2019
import eatda.repository.cheer.CheerRepository;
2120
import eatda.repository.member.MemberRepository;
2221
import eatda.repository.store.StoreRepository;
@@ -37,20 +36,18 @@
3736
public class CheerService {
3837

3938
private static final int MAX_CHEER_SIZE = 10_000;
40-
39+
private static final String SORTED_PROPERTIES = "createdAt";
4140
private final MemberRepository memberRepository;
4241
private final StoreRepository storeRepository;
4342
private final CheerRepository cheerRepository;
44-
private final FileClient fileClient;
4543

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

4947
@Transactional
50-
public CheerResponse registerCheer(CheerRegisterRequest request,
51-
StoreSearchResult result,
52-
long memberId,
53-
ImageDomain domain
48+
public CheerCreationResult createCheer(CheerRegisterRequest request,
49+
StoreSearchResult result,
50+
long memberId
5451
) {
5552
Member member = memberRepository.getById(memberId);
5653
validateRegisterCheer(member, request.storeKakaoId());
@@ -59,15 +56,7 @@ public CheerResponse registerCheer(CheerRegisterRequest request,
5956
.orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결
6057
Cheer cheer = new Cheer(member, store, request.description());
6158
cheer.setCheerTags(request.tags());
62-
Cheer savedCheer = cheerRepository.save(cheer);
63-
64-
// TODO 트랜잭션 범위 축소
65-
List<CheerRegisterRequest.UploadedImageDetail> sortedImages = sortImages(request.images());
66-
List<String> permanentKeys = moveImages(domain, cheer.getId(), sortedImages);
67-
68-
saveCheerImages(cheer, sortedImages, permanentKeys);
69-
70-
return new CheerResponse(savedCheer, store, cdnBaseUrl);
59+
return new CheerCreationResult(cheerRepository.save(cheer), store);
7160
}
7261

7362
private void validateRegisterCheer(Member member, String storeKakaoId) {
@@ -79,25 +68,14 @@ private void validateRegisterCheer(Member member, String storeKakaoId) {
7968
}
8069
}
8170

82-
private List<CheerRegisterRequest.UploadedImageDetail> sortImages(
83-
List<CheerRegisterRequest.UploadedImageDetail> images) {
84-
return images.stream()
85-
.sorted(Comparator.comparingLong(CheerRegisterRequest.UploadedImageDetail::orderIndex))
86-
.toList();
87-
}
71+
@Transactional
72+
public void saveCheerImages(Long cheerId,
73+
List<CheerRegisterRequest.UploadedImageDetail> sortedImages,
74+
List<String> permanentKeys) {
8875

89-
private List<String> moveImages(ImageDomain domain,
90-
long cheerId,
91-
List<CheerRegisterRequest.UploadedImageDetail> sortedImages) {
92-
List<String> tempKeys = sortedImages.stream()
93-
.map(CheerRegisterRequest.UploadedImageDetail::imageKey)
94-
.toList();
95-
return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys);
96-
}
76+
Cheer cheer = cheerRepository.findById(cheerId)
77+
.orElseThrow(() -> new BusinessException(BusinessErrorCode.CHEER_NOT_FOUND));
9778

98-
private void saveCheerImages(Cheer cheer,
99-
List<CheerRegisterRequest.UploadedImageDetail> sortedImages,
100-
List<String> permanentKeys) {
10179
IntStream.range(0, sortedImages.size())
10280
.forEach(i -> {
10381
var detail = sortedImages.get(i);
@@ -108,10 +86,8 @@ private void saveCheerImages(Cheer cheer,
10886
detail.contentType(),
10987
detail.fileSize()
11088
);
111-
cheer.addImage(cheerImage); // 여기서 양방향 동기화
89+
cheer.addImage(cheerImage);
11290
});
113-
114-
cheerRepository.save(cheer);
11591
}
11692

11793
@Transactional(readOnly = true)
@@ -121,7 +97,7 @@ public CheersResponse getCheers(CheerSearchParameters parameters) {
12197
parameters.getCheerTagNames(),
12298
parameters.getDistricts(),
12399
PageRequest.of(parameters.getPage(), parameters.getSize(),
124-
Sort.by(Direction.DESC, "createdAt"))
100+
Sort.by(Direction.DESC, SORTED_PROPERTIES))
125101
);
126102

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

131107
private CheersResponse toCheersResponse(List<Cheer> cheers) {
132108
return new CheersResponse(cheers.stream()
133-
.map(cheer -> {
134-
Store store = cheer.getStore();
135-
return new CheerPreviewResponse(cheer,
136-
cheer.getImages().stream()
137-
.map(img -> new CheerImageResponse(img, cdnBaseUrl))
138-
.sorted(Comparator.comparingLong(CheerImageResponse::orderIndex))
139-
.toList());
140-
})
109+
.map(cheer -> new CheerPreviewResponse(cheer,
110+
cheer.getImages().stream()
111+
.map(img -> new CheerImageResponse(img, cdnBaseUrl))
112+
.sorted(Comparator.comparingLong(CheerImageResponse::orderIndex))
113+
.toList()))
141114
.toList());
142115
}
143116

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

153126
return new CheersInStoreResponse(cheersResponse);
154127
}
128+
129+
@Transactional
130+
public void deleteCheer(Long cheerId) {
131+
cheerRepository.deleteById(cheerId);
132+
}
133+
134+
@Transactional(readOnly = true)
135+
public CheerResponse getCheerResponse(Long cheerId) {
136+
Cheer cheer = cheerRepository.findById(cheerId)
137+
.orElseThrow(() -> new BusinessException(BusinessErrorCode.CHEER_NOT_FOUND));
138+
139+
return new CheerResponse(cheer, cdnBaseUrl);
140+
}
155141
}

0 commit comments

Comments
 (0)