Skip to content

Commit 70473fa

Browse files
infra: 이벤트 이미지 업로드를 위한 S3 연동
* feat: s3 정책 부착 * feat: s3 bucket t * feat: s3 기본 설정 * feat: s3 설정 변경 * feat: s3 기본 세팅 * feat: ã�event 잠시 스테이징 * feat: s3 관련 로직 추가 완료 * feat: event test s3 mokitoBean 처리 * refactor: 코딩 컨벤션 * feat: s3 mockitoBean 추가 * feat: s3 mockitoBean 추가
1 parent e43b6de commit 70473fa

16 files changed

Lines changed: 412 additions & 57 deletions

File tree

backend/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ dependencies {
7979
// logstash logback encoder
8080
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
8181

82+
//s3
83+
implementation("software.amazon.awssdk:s3:2.40.13")
84+
8285
// ShedLock (스케줄러 중복 실행 방지)
8386
implementation("net.javacrumbs.shedlock:shedlock-spring:5.10.0")
8487
implementation("net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.10.0")

backend/src/main/java/com/back/api/event/dto/response/EventResponse.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,19 @@ public record EventResponse(
5656
example = "PRE_OPEN")
5757
EventStatus status
5858
) {
59+
5960
public static EventResponse from(Event event) {
61+
return from(event, null);
62+
}
63+
64+
public static EventResponse from(Event event, String imageUrl) {
6065
return new EventResponse(
6166
event.getId(),
6267
event.getTitle(),
6368
event.getCategory(),
6469
event.getDescription(),
6570
event.getPlace(),
66-
event.getImageUrl(),
71+
imageUrl,
6772
event.getMinPrice(),
6873
event.getMaxPrice(),
6974
event.getPreOpenAt(),

backend/src/main/java/com/back/api/event/service/EventService.java

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import com.back.api.event.dto.request.EventUpdateRequest;
1414
import com.back.api.event.dto.response.EventListResponse;
1515
import com.back.api.event.dto.response.EventResponse;
16+
import com.back.api.s3.service.S3MoveService;
17+
import com.back.api.s3.service.S3PresignedService;
1618
import com.back.domain.event.entity.Event;
1719
import com.back.domain.event.entity.EventCategory;
1820
import com.back.domain.event.entity.EventStatus;
@@ -28,33 +30,80 @@
2830
public class EventService {
2931

3032
private final EventRepository eventRepository;
33+
private final S3MoveService s3MoveService;
34+
private final S3PresignedService s3PresignedService;
35+
3136

3237
@Transactional
3338
public EventResponse createEvent(EventCreateRequest request) {
34-
validateEventDates(request.preOpenAt(), request.preCloseAt(),
35-
request.ticketOpenAt(), request.ticketCloseAt());
36-
validateDuplicateEvent(request.title(), request.place(), request.ticketOpenAt());
39+
40+
validateEventDates(
41+
request.preOpenAt(),
42+
request.preCloseAt(),
43+
request.ticketOpenAt(),
44+
request.ticketCloseAt()
45+
);
46+
47+
validateDuplicateEvent(
48+
request.title(),
49+
request.place(),
50+
request.ticketOpenAt()
51+
);
3752

3853
Event event = request.toEntity();
3954
Event savedEvent = eventRepository.save(event);
4055

56+
// 이미지가 있으면 temp → events/{eventId}/main.{ext}
57+
if (savedEvent.getImageUrl() != null && !savedEvent.getImageUrl().isBlank()) {
58+
59+
// imageUrl 컬럼에는 실제로 S3 objectKey가 저장됨 (URL 아님)
60+
String tempKey = savedEvent.getImageUrl();
61+
String finalKey = s3MoveService.moveImage(savedEvent.getId(), tempKey);
62+
63+
// imageUrl 필드에 최종 key 저장
64+
savedEvent.changeBasicInfo(
65+
savedEvent.getTitle(),
66+
savedEvent.getCategory(),
67+
savedEvent.getDescription(),
68+
savedEvent.getPlace(),
69+
finalKey
70+
);
71+
}
72+
4173
return EventResponse.from(savedEvent);
4274
}
4375

4476
@Transactional
4577
public EventResponse updateEvent(Long eventId, EventUpdateRequest request) {
4678
Event event = findEventById(eventId);
4779

48-
validateEventDates(request.preOpenAt(), request.preCloseAt(),
49-
request.ticketOpenAt(), request.ticketCloseAt());
50-
validateDuplicateEventForUpdate(eventId, request.title(), request.place(), request.ticketOpenAt());
80+
validateEventDates(
81+
request.preOpenAt(),
82+
request.preCloseAt(),
83+
request.ticketOpenAt(),
84+
request.ticketCloseAt()
85+
);
86+
87+
validateDuplicateEventForUpdate(
88+
eventId,
89+
request.title(),
90+
request.place(),
91+
request.ticketOpenAt()
92+
);
93+
94+
String imageUrl = event.getImageUrl();
95+
96+
// 이미지가 변경된 경우
97+
if (request.imageUrl() != null && request.imageUrl().startsWith("events/temp/")) {
98+
imageUrl = s3MoveService.moveImage(event.getId(), request.imageUrl());
99+
}
51100

52101
event.changeBasicInfo(
53102
request.title(),
54103
request.category(),
55104
request.description(),
56105
request.place(),
57-
request.imageUrl()
106+
imageUrl
58107
);
59108
event.changePriceInfo(
60109
request.minPrice(),
@@ -81,7 +130,14 @@ public void deleteEvent(Long eventId) {
81130

82131
public EventResponse getEvent(Long eventId) {
83132
Event event = findEventById(eventId);
84-
return EventResponse.from(event);
133+
134+
String imageUrl = null;
135+
136+
if (event.getImageUrl() != null && !event.getImageUrl().isBlank()) {
137+
imageUrl = s3PresignedService.issueDownloadUrl(event.getImageUrl());
138+
}
139+
140+
return EventResponse.from(event, imageUrl);
85141
}
86142

87143
public Page<EventListResponse> getEvents(EventStatus status, EventCategory category, Pageable pageable) {
@@ -100,6 +156,9 @@ private Event findEventById(Long eventId) {
100156

101157
private void validateEventDates(LocalDateTime preOpenAt, LocalDateTime preCloseAt,
102158
LocalDateTime ticketOpenAt, LocalDateTime ticketCloseAt) {
159+
if (preOpenAt.isBefore(LocalDateTime.now())) {
160+
throw new ErrorException(EventErrorCode.INVALID_EVENT_DATE);
161+
}
103162
if (preOpenAt.isAfter(preCloseAt)) {
104163
throw new ErrorException(EventErrorCode.INVALID_EVENT_DATE);
105164
}
@@ -131,6 +190,7 @@ public List<Event> findEventsByStatus(EventStatus status) {
131190
return eventRepository.findByStatus(status);
132191
}
133192

193+
134194
public List<Event> findEventsByTicketOpenAtBetweenAndStatus(
135195
LocalDateTime start,
136196
LocalDateTime end,
@@ -139,4 +199,5 @@ public List<Event> findEventsByTicketOpenAtBetweenAndStatus(
139199
return eventRepository.findByTicketOpenAtBetweenAndStatus(start, end, status);
140200
}
141201

202+
142203
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.back.api.s3.controller;
2+
3+
import org.springframework.web.bind.annotation.RequestParam;
4+
5+
import com.back.api.s3.dto.response.PresignedUrlResponse;
6+
import com.back.global.response.ApiResponse;
7+
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
11+
12+
@Tag(name = "S3 Image API", description = "S3 이미지 업로드 API")
13+
public interface S3ImageApi {
14+
15+
@Operation(
16+
summary = "이미지 업로드용 Presigned URL 발급",
17+
description = """
18+
이벤트 이미지를 S3에 업로드하기 위한 Presigned PUT URL을 발급합니다.
19+
20+
- 클라이언트는 이 API로 발급받은 URL을 사용해 S3에 직접 PUT 업로드합니다.
21+
- 업로드 성공 후 반환되는 objectKey를 이벤트 생성 API에 전달해야 합니다.
22+
- 업로드 URL은 일정 시간 후 만료됩니다.
23+
"""
24+
)
25+
ApiResponse<PresignedUrlResponse> issueEventImageUploadUrl(
26+
@Parameter(
27+
description = "업로드할 이미지 파일명 (확장자 포함)",
28+
example = "img.jpg"
29+
)
30+
@RequestParam String fileName
31+
);
32+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.back.api.s3.controller;
2+
3+
import org.springframework.web.bind.annotation.PostMapping;
4+
import org.springframework.web.bind.annotation.RequestMapping;
5+
import org.springframework.web.bind.annotation.RequestParam;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
import com.back.api.s3.dto.response.PresignedUrlResponse;
9+
import com.back.api.s3.service.S3PresignedService;
10+
import com.back.global.response.ApiResponse;
11+
12+
import lombok.RequiredArgsConstructor;
13+
14+
@RestController
15+
@RequestMapping("/api/v1/images")
16+
@RequiredArgsConstructor
17+
public class S3ImageController implements S3ImageApi {
18+
19+
private final S3PresignedService s3PresignedService;
20+
21+
// 이벤트 이미지 업로드용 Presigned URL 발급
22+
@Override
23+
@PostMapping("/events/upload-url")
24+
public ApiResponse<PresignedUrlResponse> issueEventImageUploadUrl(
25+
@RequestParam String fileName
26+
) {
27+
return ApiResponse.ok(
28+
"이벤트 이미지 업로드 URL 발급",
29+
s3PresignedService.issueUploadUrl(fileName)
30+
31+
);
32+
}
33+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.api.s3.dto.response;
2+
3+
public record PresignedUrlResponse(
4+
String uploadUrl,
5+
String objectKey
6+
) {
7+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.back.api.s3.service;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.stereotype.Service;
5+
6+
import lombok.RequiredArgsConstructor;
7+
import software.amazon.awssdk.services.s3.S3Client;
8+
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
9+
10+
@Service
11+
@RequiredArgsConstructor
12+
public class S3MoveService {
13+
14+
private final S3Client s3Client;
15+
16+
@Value("${cloud.aws.s3.bucket}")
17+
private String bucket;
18+
19+
20+
//temp 이미지 -> 영구 이미지로 이동
21+
public String moveImage(Long eventId, String tempKey) {
22+
23+
String extension = tempKey.substring(tempKey.lastIndexOf("."));
24+
String targetKey = "events/" + eventId + "/main" + extension;
25+
26+
s3Client.copyObject(CopyObjectRequest.builder()
27+
.sourceBucket(bucket)
28+
.sourceKey(tempKey)
29+
.destinationBucket(bucket)
30+
.destinationKey(targetKey)
31+
.build());
32+
33+
s3Client.deleteObject(builder -> builder
34+
.bucket(bucket)
35+
.key(tempKey)
36+
.build());
37+
38+
return targetKey;
39+
}
40+
41+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.back.api.s3.service;
2+
3+
import java.time.Duration;
4+
import java.util.UUID;
5+
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.stereotype.Service;
8+
9+
import com.back.api.s3.dto.response.PresignedUrlResponse;
10+
11+
import lombok.RequiredArgsConstructor;
12+
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
13+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
14+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
15+
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
16+
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
17+
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
18+
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
public class S3PresignedService {
23+
24+
private final S3Presigner s3Presigner;
25+
26+
@Value("${cloud.aws.s3.bucket}")
27+
private String bucket;
28+
29+
@Value("${cloud.aws.presigned.put-expire-minutes}")
30+
private long putExpire;
31+
32+
@Value("${cloud.aws.presigned.get-expire-minutes}")
33+
private long getExpire;
34+
35+
// 이미지 업로드용 Presigned PUT
36+
public PresignedUrlResponse issueUploadUrl(String originalFileName) {
37+
38+
// 확장자 추출
39+
String extension = originalFileName.substring(originalFileName.lastIndexOf("."));
40+
41+
String objectKey = "events/temp/" + UUID.randomUUID() + extension;
42+
43+
PutObjectRequest request = PutObjectRequest.builder()
44+
.bucket(bucket)
45+
.key(objectKey)
46+
.build();
47+
48+
PresignedPutObjectRequest presigned =
49+
s3Presigner.presignPutObject(
50+
PutObjectPresignRequest.builder()
51+
.signatureDuration(Duration.ofMinutes(putExpire))
52+
.putObjectRequest(request)
53+
.build()
54+
);
55+
56+
return new PresignedUrlResponse(presigned.url().toString(), objectKey);
57+
}
58+
59+
// 이미지 조회용 Presigned GET
60+
public String issueDownloadUrl(String objectKey) {
61+
GetObjectRequest request = GetObjectRequest.builder()
62+
.bucket(bucket)
63+
.key(objectKey)
64+
.build();
65+
66+
PresignedGetObjectRequest presigned =
67+
s3Presigner.presignGetObject(
68+
GetObjectPresignRequest.builder()
69+
.signatureDuration(Duration.ofMinutes(getExpire))
70+
.getObjectRequest(request)
71+
.build()
72+
);
73+
74+
return presigned.url().toString();
75+
}
76+
77+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.back.global.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
import software.amazon.awssdk.regions.Region;
8+
import software.amazon.awssdk.services.s3.S3Client;
9+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
10+
11+
@Configuration
12+
public class S3Config {
13+
14+
@Value("${cloud.aws.region}")
15+
private String region;
16+
17+
@Bean
18+
public S3Client s3Client() {
19+
return S3Client.builder()
20+
.region(Region.of(region))
21+
.build();
22+
}
23+
24+
@Bean
25+
public S3Presigner s3Presigner() {
26+
return S3Presigner.builder()
27+
.region(Region.of(region))
28+
.build();
29+
}
30+
}

0 commit comments

Comments
 (0)