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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ dependencies {
// OAuth
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// AWS SDK v2
implementation platform('software.amazon.awssdk:bom:2.41.14')
implementation 'software.amazon.awssdk:s3'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package gg.agit.konect.domain.upload.controller;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

import gg.agit.konect.domain.upload.dto.ImageUploadResponse;
import gg.agit.konect.global.auth.annotation.UserId;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "(Normal) Upload: 업로드", description = "업로드 API")
@RequestMapping("/upload")
public interface UploadApi {

@Operation(summary = "이미지 파일을 업로드한다.", description = """
서버가 multipart 파일을 받아 S3에 업로드합니다.

- 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다.

## 에러
- INVALID_SESSION (401): 로그인 정보가 올바르지 않습니다.
- INVALID_REQUEST_BODY (400): 파일이 비어있거나 요청 형식이 올바르지 않은 경우
- INVALID_FILE_CONTENT_TYPE (400): 지원하지 않는 Content-Type 인 경우
- INVALID_FILE_SIZE (400): 파일 크기가 제한을 초과한 경우
- FAILED_UPLOAD_FILE (500): S3 업로드에 실패한 경우
""")
@PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<ImageUploadResponse> uploadImage(
@UserId Integer userId,
@RequestPart("file") MultipartFile file
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package gg.agit.konect.domain.upload.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import gg.agit.konect.domain.upload.dto.ImageUploadResponse;
import gg.agit.konect.domain.upload.service.UploadService;
import gg.agit.konect.global.auth.annotation.UserId;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class UploadController implements UploadApi {

private final UploadService uploadService;

@Override
public ResponseEntity<ImageUploadResponse> uploadImage(@UserId Integer userId, MultipartFile file) {
return ResponseEntity.ok(uploadService.uploadImage(file));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package gg.agit.konect.domain.upload.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;

public record ImageUploadResponse(
@Schema(description = "S3 object key", example = "konect/2026-01-26-550e8400-e29b-41d4-a716-446655440000.png",
requiredMode = REQUIRED)
String key,

@Schema(description = "CloudFront를 통한 접근 URL", requiredMode = REQUIRED)
String fileUrl
) {

}
172 changes: 172 additions & 0 deletions src/main/java/gg/agit/konect/domain/upload/service/UploadService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package gg.agit.konect.domain.upload.service;

import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;

import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import gg.agit.konect.domain.upload.dto.ImageUploadResponse;
import gg.agit.konect.global.code.ApiResponseCode;
import gg.agit.konect.global.config.S3StorageProperties;
import gg.agit.konect.global.config.StorageCdnProperties;
import gg.agit.konect.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;

@Service
@Slf4j
@RequiredArgsConstructor
public class UploadService {

private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/png",
"image/jpeg",
"image/webp"
);

private final S3Client s3Client;
private final S3StorageProperties s3StorageProperties;
private final StorageCdnProperties storageCdnProperties;

public ImageUploadResponse uploadImage(MultipartFile file) {
validateS3Configuration();
String contentType = validateFile(file);
String extension = extensionFromContentType(contentType);
String key = buildKey(extension);

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(s3StorageProperties.bucket())
.key(key)
.contentType(contentType)
.build();

try (InputStream inputStream = file.getInputStream()) {
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize()));
} catch (S3Exception e) {
String awsErrorCode = e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : null;
String awsErrorMessage = e.awsErrorDetails() != null ? e.awsErrorDetails().errorMessage() : e.getMessage();

log.error(
"S3 업로드 실패. bucket: {}, key: {}, statusCode: {}, errorCode: {}, requestId: {}, message: {}",
s3StorageProperties.bucket(),
key,
e.statusCode(),
awsErrorCode,
e.requestId(),
awsErrorMessage,
e
);
throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE);
} catch (SdkClientException e) {
log.error(
"S3 업로드 클라이언트 오류(네트워크/자격증명/설정). bucket: {}, key: {}, message: {}",
s3StorageProperties.bucket(),
key,
e.getMessage(),
e
);
throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE);
} catch (IOException e) {
log.error(
"파일 업로드 중 문제가 발생했습니다. fileName: {}, fileSize: {}, contentType: {}, message: {}",
file.getOriginalFilename(),
file.getSize(),
file.getContentType(),
e.getMessage(),
e
);
throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE);
}

String fileUrl = trimTrailingSlash(storageCdnProperties.baseUrl()) + "/" + key;
return new ImageUploadResponse(key, fileUrl);
Comment on lines +90 to +91
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trimTrailingSlashnormalizePrefix 메서드가 매 요청마다 동일한 문자열 변환을 수행합니다. 이 값들은 설정으로부터 가져온 불변 값이므로, @PostConstruct 메서드에서 한 번만 처리하여 필드 변수에 저장해 두는 것이 효율적입니다. 이렇게 하면 매 업로드 요청마다 불필요한 문자열 처리를 피할 수 있습니다.

Copilot uses AI. Check for mistakes.
}

private String validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY);
}

String contentType = file.getContentType();
if (contentType == null || contentType.isBlank() || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE);
}

Long maxUploadBytes = s3StorageProperties.maxUploadBytes();
if (maxUploadBytes != null && file.getSize() > maxUploadBytes) {
throw CustomException.of(ApiResponseCode.INVALID_FILE_SIZE);
}

return contentType;
}

private String extensionFromContentType(String contentType) {
return switch (contentType) {
case "image/png" -> "png";
case "image/jpeg" -> "jpg";
case "image/webp" -> "webp";
default -> throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE);
};
}

private String buildKey(String extension) {
String prefix = normalizePrefix(s3StorageProperties.keyPrefix());
LocalDate today = LocalDate.now();
String datePath = String.format(
"%04d-%02d-%02d",
today.getYear(),
today.getMonthValue(),
today.getDayOfMonth()
);
String uuid = UUID.randomUUID().toString();
return prefix + datePath + "-" + uuid + "." + extension;
}

private String normalizePrefix(String keyPrefix) {
if (keyPrefix == null || keyPrefix.isBlank()) {
return "";
}

String normalized = keyPrefix.trim();
if (!normalized.endsWith("/")) {
normalized += "/";
}
if (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
return normalized;
}

private String trimTrailingSlash(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.cdn.base-url 설정이 필요합니다.");
}
Comment on lines +149 to +152
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trimTrailingSlash 메서드에서 baseUrl 검증이 매 요청마다 실행됩니다. 이 검증은 validateS3Configuration과 함께 @PostConstruct 메서드로 이동하여 애플리케이션 시작 시 한 번만 실행되도록 하는 것이 좋습니다. 이렇게 하면 잘못된 설정을 더 빨리 발견하고 런타임 오버헤드를 줄일 수 있습니다.

Copilot uses AI. Check for mistakes.

String trimmed = baseUrl.trim();

if (trimmed.endsWith("/")) {
return trimmed.substring(0, trimmed.length() - 1);
}

return trimmed;
}

private void validateS3Configuration() {
if (s3StorageProperties.bucket() == null || s3StorageProperties.bucket().isBlank()) {
throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.s3.bucket 설정이 필요합니다.");
}

if (s3StorageProperties.region() == null || s3StorageProperties.region().isBlank()) {
throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.s3.region 설정이 필요합니다.");
}
}
Comment on lines +163 to +171
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

설정 검증(validateS3Configuration)이 매 요청마다 실행됩니다. 이 설정 값들은 애플리케이션 시작 시 한 번만 검증하면 되는 불변 값입니다. 설정 검증을 @PostConstruct 메서드로 이동하여 서비스 빈이 생성될 때 한 번만 실행되도록 하면 성능이 개선되고, 잘못된 설정으로 인한 문제를 더 빨리 발견할 수 있습니다. 또한, 애플리케이션 시작 시 설정 오류를 즉시 감지할 수 있어 운영상 안전합니다.

Copilot uses AI. Check for mistakes.
}
3 changes: 3 additions & 0 deletions src/main/java/gg/agit/konect/global/code/ApiResponseCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public enum ApiResponseCode {
INVALID_RECRUITMENT_DATE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "상시 모집인 경우 모집 시작일과 마감일을 지정할 수 없습니다."),
INVALID_RECRUITMENT_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "상시 모집이 아닐 경우 모집 시작일과 마감일이 필수입니다."),
INVALID_RECRUITMENT_PERIOD(HttpStatus.BAD_REQUEST, "모집 시작일은 모집 마감일보다 이전이어야 합니다."),
INVALID_FILE_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 타입입니다."),
INVALID_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일 크기가 제한을 초과했습니다."),

// 401 Unauthorized
INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."),
Expand Down Expand Up @@ -88,6 +90,7 @@ public enum ApiResponseCode {

// 500 Internal Server Error (서버 오류)
CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."),
FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."),
UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다.");

private final HttpStatus httpStatus;
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/gg/agit/konect/global/config/S3ClientConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package gg.agit.konect.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class S3ClientConfig {

@Bean
public S3Client s3Client(S3StorageProperties s3StorageProperties) {
return S3Client.builder()
.region(Region.of(s3StorageProperties.region()))
.credentialsProvider(DefaultCredentialsProvider.builder().build())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package gg.agit.konect.global.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "storage.s3")
public record S3StorageProperties(
String bucket,
String region,
String keyPrefix,
Long maxUploadBytes
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package gg.agit.konect.global.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "storage.cdn")
public record StorageCdnProperties(
String baseUrl
) {

}