1+ package com .ongil .backend .global .config .s3 ;
2+
3+ import java .io .IOException ;
4+ import java .util .List ;
5+ import java .util .UUID ;
6+
7+ import org .springframework .beans .factory .annotation .Value ;
8+ import org .springframework .stereotype .Service ;
9+ import org .springframework .web .multipart .MultipartFile ;
10+
11+ import com .ongil .backend .global .common .exception .AppException ;
12+ import com .ongil .backend .global .common .exception .ErrorCode ;
13+
14+ import lombok .RequiredArgsConstructor ;
15+ import lombok .extern .slf4j .Slf4j ;
16+ import software .amazon .awssdk .core .exception .SdkException ;
17+ import software .amazon .awssdk .core .sync .RequestBody ;
18+ import software .amazon .awssdk .services .s3 .S3Client ;
19+ import software .amazon .awssdk .services .s3 .model .DeleteObjectRequest ;
20+ import software .amazon .awssdk .services .s3 .model .PutObjectRequest ;
21+
22+ @ Slf4j
23+ @ Service
24+ @ RequiredArgsConstructor
25+ public class S3ImageService {
26+
27+ private final S3Client s3Client ;
28+
29+ @ Value ("${spring.cloud.aws.s3.bucket}" )
30+ private String bucket ;
31+
32+ @ Value ("${spring.cloud.aws.region.static}" )
33+ private String region ;
34+
35+ private static final List <String > ALLOWED_EXTENSIONS = List .of ("jpg" , "jpeg" , "png" );
36+ private static final String PROFILE_DIRECTORY = "profile" ;
37+
38+ /**
39+ * 이미지를 S3에 업로드하고 공개 URL을 반환한다.
40+ */
41+ public String upload (MultipartFile file ) {
42+ validateFile (file );
43+
44+ String extension = extractExtension (file .getOriginalFilename ());
45+ String key = PROFILE_DIRECTORY + "/" + UUID .randomUUID () + "." + extension ;
46+
47+ try {
48+ PutObjectRequest putRequest = PutObjectRequest .builder ()
49+ .bucket (bucket )
50+ .key (key )
51+ .contentType (file .getContentType ())
52+ .build ();
53+
54+ s3Client .putObject (putRequest ,
55+ RequestBody .fromInputStream (file .getInputStream (), file .getSize ()));
56+ } catch (IOException | SdkException e ) {
57+ log .error ("S3 업로드 실패: {}" , e .getMessage ());
58+ throw new AppException (ErrorCode .S3_UPLOAD_FAILED );
59+ }
60+
61+ return generateUrl (key );
62+ }
63+
64+ /**
65+ * S3에서 기존 이미지를 삭제한다.
66+ */
67+ public void delete (String imageUrl ) {
68+ String key = extractKey (imageUrl );
69+
70+ try {
71+ DeleteObjectRequest deleteRequest = DeleteObjectRequest .builder ()
72+ .bucket (bucket )
73+ .key (key )
74+ .build ();
75+
76+ s3Client .deleteObject (deleteRequest );
77+ log .info ("S3 이미지 삭제 완료: {}" , key );
78+ } catch (SdkException e ) {
79+ log .error ("S3 이미지 삭제 실패: {}" , e .getMessage ());
80+ throw new AppException (ErrorCode .S3_DELETE_FAILED );
81+ }
82+ }
83+
84+ private void validateFile (MultipartFile file ) {
85+ if (file == null || file .isEmpty ()) {
86+ throw new AppException (ErrorCode .FILE_IS_EMPTY );
87+ }
88+
89+ String extension = extractExtension (file .getOriginalFilename ());
90+ if (!ALLOWED_EXTENSIONS .contains (extension .toLowerCase ())) {
91+ throw new AppException (ErrorCode .INVALID_FILE_EXTENSION );
92+ }
93+ }
94+
95+ private String extractExtension (String originalFilename ) {
96+ if (originalFilename == null || !originalFilename .contains ("." )) {
97+ throw new AppException (ErrorCode .INVALID_FILE_EXTENSION );
98+ }
99+ return originalFilename .substring (originalFilename .lastIndexOf ("." ) + 1 );
100+ }
101+
102+ /**
103+ * S3 공개 URL에서 key(경로) 부분만 추출한다.
104+ * 예: "https://ongil-bucket.s3.ap-northeast-2.amazonaws.com/profile/abc.jpg"
105+ * -> "profile/abc.jpg"
106+ */
107+ private String extractKey (String imageUrl ) {
108+ String prefix = generatePrefix ();
109+ if (!imageUrl .startsWith (prefix )) {
110+ log .error ("예상 외 이미지 URL 형식: {}" , imageUrl );
111+ throw new AppException (ErrorCode .S3_DELETE_FAILED );
112+ }
113+ return imageUrl .substring (prefix .length ());
114+ }
115+
116+ private String generatePrefix () {
117+ return "https://" + bucket + ".s3." + region + ".amazonaws.com/" ;
118+ }
119+
120+ private String generateUrl (String key ) {
121+ return generatePrefix () + key ;
122+ }
123+ }
0 commit comments