Skip to content

Commit d9001ae

Browse files
authored
Merge pull request #103 from wafflestudio/96-사진-업로드-기능-리팩토링
96 사진 업로드 기능 리팩토링
2 parents dc572a0 + 3be1a16 commit d9001ae

File tree

7 files changed

+305
-113
lines changed

7 files changed

+305
-113
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.wafflestudio.spring2025.common.image.controller
2+
3+
import com.wafflestudio.spring2025.common.image.dto.ImageUploadResponse
4+
import com.wafflestudio.spring2025.common.image.service.ImageService
5+
import com.wafflestudio.spring2025.domain.auth.AuthRequired
6+
import com.wafflestudio.spring2025.domain.auth.LoggedInUser
7+
import com.wafflestudio.spring2025.domain.user.model.User
8+
import io.swagger.v3.oas.annotations.Operation
9+
import io.swagger.v3.oas.annotations.Parameter
10+
import io.swagger.v3.oas.annotations.responses.ApiResponse
11+
import io.swagger.v3.oas.annotations.responses.ApiResponses
12+
import org.springframework.http.MediaType
13+
import org.springframework.http.ResponseEntity
14+
import org.springframework.web.bind.annotation.PostMapping
15+
import org.springframework.web.bind.annotation.RequestMapping
16+
import org.springframework.web.bind.annotation.RequestParam
17+
import org.springframework.web.bind.annotation.RequestPart
18+
import org.springframework.web.bind.annotation.RestController
19+
import org.springframework.web.multipart.MultipartFile
20+
21+
@AuthRequired
22+
@RestController
23+
@RequestMapping("/api/images")
24+
class ImageController(
25+
private val imageService: ImageService,
26+
) {
27+
@Operation(summary = "이미지 업로드", description = "서버 저장소에 이미지를 업로드합니다.")
28+
@ApiResponses(
29+
value = [
30+
ApiResponse(responseCode = "200", description = "이미지 업로드 성공"),
31+
ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"),
32+
ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"),
33+
],
34+
)
35+
@PostMapping(
36+
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE],
37+
)
38+
fun uploadImage(
39+
@Parameter(hidden = true) @LoggedInUser user: User,
40+
@RequestPart("image") image: MultipartFile,
41+
@Parameter(description = "이미지를 저장할 상위 경로", required = false)
42+
@RequestParam(name = "prefix", required = false)
43+
prefix: String?,
44+
): ResponseEntity<ImageUploadResponse> {
45+
val userId = requireNotNull(user.id) { "로그인 사용자 ID가 없습니다." }
46+
val response = imageService.uploadImage(ownerId = userId, image = image, prefix = prefix)
47+
return ResponseEntity.ok(response)
48+
}
49+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.wafflestudio.spring2025.common.image.dto
2+
3+
data class ImageUploadResponse(
4+
val key: String,
5+
val url: String,
6+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.wafflestudio.spring2025.common.image.exception
2+
3+
import com.wafflestudio.spring2025.common.exception.DomainErrorCode
4+
import org.springframework.http.HttpStatus
5+
6+
enum class ImageErrorCode(
7+
override val httpStatusCode: HttpStatus,
8+
override val title: String,
9+
override val message: String,
10+
) : DomainErrorCode {
11+
IMAGE_FILE_EMPTY(
12+
httpStatusCode = HttpStatus.BAD_REQUEST,
13+
title = "이미지 파일이 비어있습니다.",
14+
message = "업로드한 이미지가 비어있습니다. 파일을 다시 확인해 주세요.",
15+
),
16+
IMAGE_FILE_TYPE_INVALID(
17+
httpStatusCode = HttpStatus.BAD_REQUEST,
18+
title = "지원하지 않는 이미지 형식입니다.",
19+
message = "이미지 파일만 업로드할 수 있습니다.",
20+
),
21+
IMAGE_FILE_TOO_LARGE(
22+
httpStatusCode = HttpStatus.BAD_REQUEST,
23+
title = "이미지 파일 크기가 너무 큽니다.",
24+
message = "이미지 파일 크기는 5MB 이하로 제한되어 있습니다.",
25+
),
26+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.wafflestudio.spring2025.common.image.exception
2+
3+
import com.wafflestudio.spring2025.common.exception.DomainException
4+
5+
open class ImageException(
6+
error: ImageErrorCode,
7+
cause: Throwable? = null,
8+
) : DomainException(
9+
httpErrorCode = error.httpStatusCode,
10+
code = error,
11+
title = error.title,
12+
msg = error.message,
13+
cause = cause,
14+
)
15+
16+
class ImageValidationException(
17+
error: ImageErrorCode,
18+
) : ImageException(error = error)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.wafflestudio.spring2025.common.image.service
2+
3+
import com.wafflestudio.spring2025.common.image.dto.ImageUploadResponse
4+
import com.wafflestudio.spring2025.common.image.exception.ImageErrorCode
5+
import com.wafflestudio.spring2025.common.image.exception.ImageValidationException
6+
import com.wafflestudio.spring2025.config.AwsS3Properties
7+
import org.springframework.stereotype.Service
8+
import org.springframework.web.multipart.MultipartFile
9+
import software.amazon.awssdk.core.sync.RequestBody
10+
import software.amazon.awssdk.services.s3.S3Client
11+
import software.amazon.awssdk.services.s3.model.GetObjectRequest
12+
import software.amazon.awssdk.services.s3.model.PutObjectRequest
13+
import software.amazon.awssdk.services.s3.presigner.S3Presigner
14+
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
15+
import java.time.Duration
16+
import java.util.UUID
17+
18+
@Service
19+
class ImageService(
20+
private val s3Client: S3Client,
21+
private val presigner: S3Presigner,
22+
private val s3Props: AwsS3Properties,
23+
) {
24+
fun uploadImage(
25+
ownerId: Long,
26+
image: MultipartFile,
27+
prefix: String?,
28+
): ImageUploadResponse {
29+
validateImage(image)
30+
31+
val directory = sanitizePrefix(prefix)
32+
val ext = extractExtension(image.originalFilename)
33+
val key = listOf(directory, ownerId.toString(), "${UUID.randomUUID()}$ext").joinToString(separator = "/")
34+
35+
val putRequest =
36+
PutObjectRequest
37+
.builder()
38+
.bucket(s3Props.bucket)
39+
.key(key)
40+
.contentType(image.contentType ?: "application/octet-stream")
41+
.build()
42+
43+
image.inputStream.use { inputStream ->
44+
s3Client.putObject(
45+
putRequest,
46+
RequestBody.fromInputStream(inputStream, image.size),
47+
)
48+
}
49+
50+
val url = presignedGetUrl(key)
51+
return ImageUploadResponse(key = key, url = url)
52+
}
53+
54+
private fun validateImage(image: MultipartFile) {
55+
if (image.isEmpty) {
56+
throw ImageValidationException(ImageErrorCode.IMAGE_FILE_EMPTY)
57+
}
58+
val contentType = image.contentType ?: ""
59+
if (!contentType.startsWith("image/")) {
60+
throw ImageValidationException(ImageErrorCode.IMAGE_FILE_TYPE_INVALID)
61+
}
62+
if (image.size > MAX_IMAGE_BYTES) {
63+
throw ImageValidationException(ImageErrorCode.IMAGE_FILE_TOO_LARGE)
64+
}
65+
}
66+
67+
private fun sanitizePrefix(prefix: String?): String {
68+
val trimmed = prefix?.trim()?.takeIf { it.isNotEmpty() } ?: DEFAULT_PREFIX
69+
return trimmed.trim('/').ifEmpty { DEFAULT_PREFIX }
70+
}
71+
72+
private fun extractExtension(originalFilename: String?): String {
73+
if (originalFilename.isNullOrBlank()) return DEFAULT_EXTENSION
74+
val lastDot = originalFilename.lastIndexOf('.')
75+
if (lastDot == -1) return DEFAULT_EXTENSION
76+
val ext = originalFilename.substring(lastDot).lowercase()
77+
return if (ext in ALLOWED_EXTENSIONS) ext else DEFAULT_EXTENSION
78+
}
79+
80+
private fun presignedGetUrl(key: String): String {
81+
val getRequest =
82+
GetObjectRequest
83+
.builder()
84+
.bucket(s3Props.bucket)
85+
.key(key)
86+
.build()
87+
88+
val presignRequest =
89+
GetObjectPresignRequest
90+
.builder()
91+
.signatureDuration(Duration.ofSeconds(s3Props.presignExpireSeconds))
92+
.getObjectRequest(getRequest)
93+
.build()
94+
95+
return presigner.presignGetObject(presignRequest).url().toString()
96+
}
97+
98+
companion object {
99+
private const val DEFAULT_PREFIX = "images"
100+
private const val DEFAULT_EXTENSION = ".jpg"
101+
private const val MAX_IMAGE_BYTES = 5L * 1024 * 1024
102+
private val ALLOWED_EXTENSIONS = setOf(".jpg", ".jpeg", ".png", ".webp")
103+
}
104+
}

src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,10 @@ import io.swagger.v3.oas.annotations.Parameter
1010
import io.swagger.v3.oas.annotations.responses.ApiResponse
1111
import io.swagger.v3.oas.annotations.responses.ApiResponses
1212
import io.swagger.v3.oas.annotations.tags.Tag
13-
import org.springframework.http.MediaType
1413
import org.springframework.http.ResponseEntity
15-
import org.springframework.web.bind.annotation.DeleteMapping
1614
import org.springframework.web.bind.annotation.GetMapping
17-
import org.springframework.web.bind.annotation.PutMapping
1815
import org.springframework.web.bind.annotation.RequestMapping
19-
import org.springframework.web.bind.annotation.RequestPart
2016
import org.springframework.web.bind.annotation.RestController
21-
import org.springframework.web.multipart.MultipartFile
2217

2318
@RestController
2419
@RequestMapping("/api/users")
@@ -39,40 +34,40 @@ class UserController(
3934
@Parameter(hidden = true) @LoggedInUser user: User?,
4035
): ResponseEntity<GetMeResponse> = ResponseEntity.ok(userService.me(user))
4136

42-
@Operation(summary = "프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드(교체)합니다")
43-
@ApiResponses(
44-
value = [
45-
ApiResponse(responseCode = "204", description = "프로필 이미지 업로드 성공"),
46-
ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"),
47-
ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"),
48-
],
49-
)
50-
@AuthRequired
51-
@PutMapping(
52-
"/me/profile-image",
53-
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE],
54-
)
55-
fun uploadProfileImage(
56-
@Parameter(hidden = true) @LoggedInUser user: User?,
57-
@RequestPart("image") image: MultipartFile,
58-
): ResponseEntity<Void> {
59-
userService.updateProfileImage(user, image)
60-
return ResponseEntity.noContent().build()
61-
}
62-
63-
@Operation(summary = "프로필 이미지 삭제", description = "로그인한 사용자의 프로필 이미지를 삭제(기본 이미지로 복귀)합니다")
64-
@ApiResponses(
65-
value = [
66-
ApiResponse(responseCode = "204", description = "프로필 이미지 삭제 성공"),
67-
ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"),
68-
],
69-
)
70-
@AuthRequired
71-
@DeleteMapping("/me/profile-image")
72-
fun deleteProfileImage(
73-
@Parameter(hidden = true) @LoggedInUser user: User?,
74-
): ResponseEntity<Void> {
75-
userService.deleteProfileImage(user)
76-
return ResponseEntity.noContent().build()
77-
}
37+
// @Operation(summary = "프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드(교체)합니다")
38+
// @ApiResponses(
39+
// value = [
40+
// ApiResponse(responseCode = "204", description = "프로필 이미지 업로드 성공"),
41+
// ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"),
42+
// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"),
43+
// ],
44+
// )
45+
// @AuthRequired
46+
// @PutMapping(
47+
// "/me/profile-image",
48+
// consumes = [MediaType.MULTIPART_FORM_DATA_VALUE],
49+
// )
50+
// fun uploadProfileImage(
51+
// @Parameter(hidden = true) @LoggedInUser user: User?,
52+
// @RequestPart("image") image: MultipartFile,
53+
// ): ResponseEntity<Void> {
54+
// userService.updateProfileImage(user, image)
55+
// return ResponseEntity.noContent().build()
56+
// }
57+
//
58+
// @Operation(summary = "프로필 이미지 삭제", description = "로그인한 사용자의 프로필 이미지를 삭제(기본 이미지로 복귀)합니다")
59+
// @ApiResponses(
60+
// value = [
61+
// ApiResponse(responseCode = "204", description = "프로필 이미지 삭제 성공"),
62+
// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"),
63+
// ],
64+
// )
65+
// @AuthRequired
66+
// @DeleteMapping("/me/profile-image")
67+
// fun deleteProfileImage(
68+
// @Parameter(hidden = true) @LoggedInUser user: User?,
69+
// ): ResponseEntity<Void> {
70+
// userService.deleteProfileImage(user)
71+
// return ResponseEntity.noContent().build()
72+
// }
7873
}

0 commit comments

Comments
 (0)