하단의 링크에 Markdown 복사해서 붙여넣어서 확인해보면 된다.
https://markdownlivepreview.com/
# FileService JUnit 테스트 가이드
## 테스트 코드 구조
### 전체 구조
```java
@ExtendWith(MockitoExtension.class) // Mockito 사용 선언
@DisplayName("FileService 테스트") // 테스트 클래스 설명
class FileServiceTest {
@Mock
private S3Service s3Service; // Mock 객체 선언
@Mock
private FileRepository fileRepository; // Mock 객체 선언
@InjectMocks
private FileService fileService; // Mock을 주입받을 실제 테스트 대상
@BeforeEach
void setUp() {
// 각 테스트 실행 전에 공통으로 실행되는 코드
// @Value 필드에 테스트용 값 주입
}
@Test
@DisplayName("멀티파트 업로드 초기화 - 이미지 파일")
void testInitiateMultipartUpload_Image() {
// 테스트 코드
}
}
| 어노테이션 | 의미 |
|---|---|
@ExtendWith(MockitoExtension.class) |
Mockito 프레임워크 사용 |
@Mock |
Mock 객체 생성 (가짜 객체) |
@InjectMocks |
Mock을 주입받을 실제 객체 |
@BeforeEach |
각 테스트 실행 전 실행 |
@Test |
테스트 메서드임을 표시 |
@DisplayName |
테스트 설명 (한글 가능) |
testInitiateMultipartUpload_Image() - 이미지 파일 업로드 초기화멀티파트 업로드 초기화가 올바르게 동작하는지 검증
// ===== Given (준비 단계) =====
String originalFileName = "test-image.jpg";
String mimeType = "image/jpeg";
Long fileSize = 1024 * 1024L; // 1MB
// 테스트용 요청 객체 생성
FileDto.MultipartUploadInitiateRequest request = ...;
// Mock이 반환할 값 설정
when(s3Service.initiateUpload(...)).thenReturn(s3InitiateResponse);
when(s3Service.calculatePartCount(fileSize)).thenReturn(1);
when(s3Service.generatePartPresignedUrls(...)).thenReturn(partPresignedUrls);
의미: "S3Service의 메서드들이 호출되면 이런 값을 반환해줘"라고 Mock에게 지시
// ===== When (실행 단계) =====
FileDto.MultipartUploadInitiateResponse response =
fileService.initiateMultipartUpload(request);
의미: 실제 FileService의 메서드를 호출
// ===== Then (검증 단계) =====
assertThat(response.getKey()).isEqualTo(expectedKey);
assertThat(response.getUploadId()).isEqualTo(expectedUploadId);
assertThat(response.getPartCount()).isEqualTo(1);
의미: 결과가 예상한 대로 나왔는지 확인
// ===== Verify (호출 확인) =====
verify(s3Service, times(1)).initiateUpload(...);
verify(s3Service, times(1)).calculatePartCount(fileSize);
의미: S3Service의 메서드가 정확히 1번 호출되었는지 확인
참고: completeMultipartUpload() 테스트에서는 ArgumentCaptor를 사용하여 DB에 저장되는 File 엔티티도 검증합니다.
testInitiateMultipartUpload_LargeVideo() - 대용량 동영상 파일대용량 파일이 여러 Part로 분할되는지 검증
1. 파일 크기: 500MB
2. Part 크기: 64MB (권장 크기)
3. Part 개수: 500MB ÷ 64MB = 약 8개
4. 각 Part마다 Presigned URL 생성
testCompleteMultipartUpload_ImageContent() - 업로드 완료 (CONTENT)멀티파트 업로드 완료 후 DB에 올바르게 저장되는지 검증
completeUpload()가 올바른 ETag와 함께 호출되는지isThumbnail이 false로 설정되는지 (CONTENT이므로)1. 프론트엔드가 각 Part를 업로드하고 ETag를 받음
2. 모든 Part 업로드 완료 후, ETag 목록을 서버로 전송
3. 서버가 S3에 "이 ETag들로 업로드를 완료하라"고 요청
4. S3가 파일을 합치고 최종 URL 반환
5. 서버가 File 엔티티를 생성하여 DB에 저장
// ETag가 올바르게 전달되는지
parts.add(FileDto.Part.builder()
.partNumber(1)
.etag("\"etag-123\"") // 프론트엔드가 받은 ETag
.build());
// S3Service에 올바른 ETag가 전달되는지 확인
verify(s3Service, times(1)).completeUpload(
argThat(request ->
request.getParts().get(0).getEtag().equals("\"etag-123\"")
)
);
// isThumbnail이 false인지 확인 (CONTENT이므로)
assertThat(result.getIsThumbnail()).isFalse();
// ArgumentCaptor로 DB에 저장되는 File 엔티티 검증
ArgumentCaptor<File> fileCaptor = ArgumentCaptor.forClass(File.class);
verify(fileRepository, times(1)).save(fileCaptor.capture());
File savedEntity = fileCaptor.getValue();
// 저장되는 File 엔티티의 모든 필드 검증
assertThat(savedEntity.getS3ObjectKey()).isEqualTo(key);
assertThat(savedEntity.getFilename()).isEqualTo(originalFileName);
assertThat(savedEntity.getFileSize()).isEqualTo(fileSize);
assertThat(savedEntity.getMediaType()).isEqualTo(MediaType.IMAGE);
assertThat(savedEntity.getIsThumbnail()).isFalse();
assertThat(savedEntity.getFilePath()).contains(TEST_CDN_BASE_URL);
assertThat(savedEntity.getFilePath()).contains(key); // CDN URL에 key가 포함되는지 확인
testCompleteMultipartUpload_ImagePreview() - 업로드 완료 (PREVIEW)PREVIEW 파일이 isThumbnail=true로 저장되는지 검증
MediaRole.PREVIEW일 때 isThumbnail이 true로 설정되는지1. 사용자가 "대표 이미지"로 지정하여 업로드
2. 프론트엔드가 mediaRole="PREVIEW"로 전송
3. 서버가 File 엔티티 생성 시 isThumbnail=true로 설정
4. DB에 저장
MediaRole mediaRole = MediaRole.PREVIEW; // 대표 이미지
// FileService 내부 로직
boolean isThumbnail = (mediaRole == MediaRole.PREVIEW); // true
// 테스트 검증 - 반환된 결과
assertThat(result.getIsThumbnail()).isTrue(); // PREVIEW이므로 true
// 테스트 검증 - DB에 저장되는 File 엔티티
ArgumentCaptor<File> fileCaptor = ArgumentCaptor.forClass(File.class);
verify(fileRepository, times(1)).save(fileCaptor.capture());
File savedEntity = fileCaptor.getValue();
assertThat(savedEntity.getIsThumbnail()).isTrue(); // PREVIEW이므로 true
assertThat(savedEntity.getS3ObjectKey()).isEqualTo(key);
assertThat(savedEntity.getFilename()).isEqualTo(originalFileName);
assertThat(savedEntity.getFilePath()).contains(TEST_CDN_BASE_URL);
testCompleteMultipartUpload_MultiplePartsWithETags() - 여러 Part와 ETag여러 Part의 ETag가 올바르게 처리되는지 검증
Part 1 업로드 → ETag: "etag-part-1"
Part 2 업로드 → ETag: "etag-part-2"
Part 3 업로드 → ETag: "etag-part-3"
서버로 전송:
[
{partNumber: 1, etag: "etag-part-1"},
{partNumber: 2, etag: "etag-part-2"},
{partNumber: 3, etag: "etag-part-3"}
]
S3에 전달하여 파일 합치기
// 3개의 Part와 ETag 생성
parts.add(FileDto.Part.builder().partNumber(1).etag("\"etag-part-1\"").build());
parts.add(FileDto.Part.builder().partNumber(2).etag("\"etag-part-2\"").build());
parts.add(FileDto.Part.builder().partNumber(3).etag("\"etag-part-3\"").build());
// S3Service에 올바른 순서로 전달되었는지 확인
verify(s3Service, times(1)).completeUpload(argThat(request -> {
return request.getParts().size() == 3 &&
request.getParts().get(0).getPartNumber() == 1 &&
request.getParts().get(0).getEtag().equals("\"etag-part-1\"");
// ... 나머지 검증
}));
// DB에 저장되는 File 엔티티 검증
ArgumentCaptor<File> fileCaptor = ArgumentCaptor.forClass(File.class);
verify(fileRepository, times(1)).save(fileCaptor.capture());
File savedEntity = fileCaptor.getValue();
assertThat(savedEntity.getS3ObjectKey()).isEqualTo(key);
assertThat(savedEntity.getMediaType()).isEqualTo(MediaType.VIDEO);
assertThat(savedEntity.getFilePath()).contains(TEST_CDN_BASE_URL);
assertThat(savedEntity.getFilePath()).contains(key); // 여러 Part 업로드 후에도 올바른 key로 저장되는지 확인
testAbortMultipartUpload() - 업로드 취소업로드 취소가 올바르게 동작하는지 검증
abortUpload()가 올바른 파라미터로 호출되는지1. 사용자가 업로드를 중단하고 싶어함
2. 프론트엔드가 key와 uploadId를 서버로 전송
3. 서버가 S3에 "이 업로드를 취소하라"고 요청
4. S3가 업로드 중인 파일들을 삭제
// S3Service의 abortUpload가 올바른 key와 uploadId로 호출되는지 확인
verify(s3Service, times(1)).abortUpload(argThat(request -> {
return request.getKey().equals(key) &&
request.getUploadId().equals(uploadId);
}));
testGetThumbnailUrl_WithCDN() - 썸네일 URL 생성 (CDN 사용)CDN이 설정되어 있을 때 썸네일 URL이 올바르게 생성되는지 검증
원본 파일:
- S3 Key: files/uuid-123__test-image.jpg
썸네일 생성:
- Thumbnail Key: files/thumbnails/thumbnail/uuid-123__test-image.jpg
- CDN URL: <https://cdn.example.com/files/thumbnails/thumbnail/uuid-123__test-image.jpg>
File file = File.builder()
.s3ObjectKey("files/uuid-123__test-image.jpg")
.mediaType(MediaType.IMAGE)
.build();
String thumbnailUrl = fileService.getThumbnailUrl(file, "thumbnail");
// 썸네일 키가 올바르게 생성되었는지 확인
assertThat(thumbnailUrl).contains("files/thumbnails/thumbnail/uuid-123__test-image.jpg");
// CDN URL이 올바르게 구성되었는지 확인
assertThat(thumbnailUrl).startsWith("<https://cdn.example.com>");
testGetThumbnailUrl_WithoutCDN_ThrowsException() - 썸네일 URL 생성 (CDN 없음)CDN이 없을 때 예외가 발생하는지 검증 (보안: 버킷명 노출 방지)
IllegalStateException이 발생하는지CDN 설정 없음:
- CDN Base URL: null
- 결과: IllegalStateException 발생
- 예외 메시지: "CDN base URL이 설정되지 않았습니다. 보안을 위해 CDN 설정이 필수입니다."
// CDN 비활성화
ReflectionTestUtils.setField(fileService, "cdnBaseUrl", null);
// CDN이 없으면 예외가 발생해야 함 (보안: 버킷명 노출 방지)
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> {
fileService.getThumbnailUrl(file, "medium");
});
testGetThumbnailUrl_NonImage() - 이미지가 아닌 경우이미지가 아닌 파일은 썸네일 URL이 null인지 검증
동영상 파일:
- MediaType: VIDEO
- 썸네일 생성 불가
- 결과: null
File videoFile = File.builder()
.mediaType(MediaType.VIDEO) // 이미지가 아님
.build();
String thumbnailUrl = fileService.getThumbnailUrl(videoFile, "thumbnail");
// null이 반환되는지 확인
assertThat(thumbnailUrl).isNull();
MIME 타입이나 파일 확장자로 MediaType이 올바르게 결정되는지 검증
image/jpeg → MediaType.IMAGEvideo/mp4 → MediaType.VIDEOaudio/mpeg → MediaType.MUSIC// FileService 내부 로직
private MediaType determineMediaType(String mimeType, String fileName) {
if (mimeType.startsWith("video/") || fileName.matches(".*\\.mp4$")) {
return MediaType.VIDEO;
} else if (mimeType.startsWith("image/") || fileName.matches(".*\\.jpg$")) {
return MediaType.IMAGE;
}
// ...
}
// 이미지 파일
String mimeType = "image/jpeg";
String fileName = "test-image.jpg";
// MediaType이 IMAGE로 결정되는지 확인
assertThat(result.getMediaType()).isEqualTo(MediaType.IMAGE);
// DB에 저장되는 File 엔티티의 MediaType도 검증
ArgumentCaptor<File> fileCaptor = ArgumentCaptor.forClass(File.class);
verify(fileRepository, times(1)).save(fileCaptor.capture());
File savedEntity = fileCaptor.getValue();
assertThat(savedEntity.getMediaType()).isEqualTo(MediaType.IMAGE);
assertThat(savedEntity.getS3ObjectKey()).isEqualTo(key);
assertThat(savedEntity.getFilename()).isEqualTo(originalFileName);
S3 URL이 CDN URL로 올바르게 변환되는지 검증 (CDN 필수)
CDN 있음:
- S3 URL: <https://bucket.s3.region.amazonaws.com/files/key>
- CDN URL: <https://cdn.example.com/files/key>
- 결과: CDN URL로 변환되어 저장
CDN 없음:
- CDN Base URL: null
- 결과: IllegalStateException 발생
- 예외 메시지: "CDN base URL이 설정되지 않았습니다. 보안을 위해 CDN 설정이 필수입니다."
// CDN이 있을 때
// S3Response의 location은 실제 key를 포함한 S3 URL이어야 함
String s3Url = "<https://test-bucket.s3.ap-northeast-2.amazonaws.com/>" + key;
CompleteMultipartUploadResponse s3Response = CompleteMultipartUploadResponse.builder()
.location(s3Url) // 실제 key를 포함한 URL
.eTag("\"final-etag\"")
.build();
// CDN URL로 변환되어 저장되는지 확인
ArgumentCaptor<File> fileCaptor = ArgumentCaptor.forClass(File.class);
verify(fileRepository, times(1)).save(fileCaptor.capture());
File savedEntity = fileCaptor.getValue();
assertThat(savedEntity.getFilePath()).contains("cdn.example.com");
assertThat(savedEntity.getFilePath()).contains(key); // CDN URL에 실제 key가 포함되는지 확인
// CDN이 없을 때
ReflectionTestUtils.setField(fileService, "cdnBaseUrl", null);
// 예외가 발생하는지 확인 (보안: 버킷명 노출 방지)
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> {
fileService.completeMultipartUpload(...);
});
테스트에서 S3Response의 location은 실제 key를 포함한 S3 URL이어야 합니다:
location("<https://bucket.s3>.../files/test-key") (고정값)location("<https://bucket.s3>.../" + key) (실제 key 사용)이유: extractKeyFromS3Url()이 S3 URL에서 key를 추출하여 CDN URL을 생성하므로, 실제 key가 포함된 URL이어야 올바른 CDN URL이 생성됩니다.
./gradlew test
src/test/java 폴더에서 *Test.java 파일 찾기FileServiceTest.java 발견@BeforeEach
void setUp() {
// 각 테스트 실행 전 실행
ReflectionTestUtils.setField(fileService, "bucketName", "test-bucket");
ReflectionTestUtils.setField(fileService, "cdnBaseUrl", "<https://cdn.example.com>");
}
1. testInitiateMultipartUpload_Image() 실행
- Given: Mock 설정
- When: fileService.initiateMultipartUpload() 호출
- Then: 결과 검증
- ✅ 통과
2. testCompleteMultipartUpload_ImageContent() 실행
- Given: Mock 설정
- When: fileService.completeMultipartUpload() 호출
- Then: 결과 검증
- ✅ 통과
... (나머지 테스트들)
✅ testInitiateMultipartUpload_Image() - 통과
✅ testInitiateMultipartUpload_LargeVideo() - 통과
✅ testCompleteMultipartUpload_ImageContent() - 통과
✅ testCompleteMultipartUpload_ImagePreview() - 통과
... (모든 테스트 통과)
1. 프론트엔드가 파일 업로드 요청
↓
2. {Post, Gallery, Diary, ...}Controller가 요청 받음
↓
3. FileService.initiateMultipartUpload() 호출
↓
4. S3Service.initiateUpload() 호출 → 실제 AWS S3에 요청
↓
5. S3가 uploadId와 key 반환
↓
6. FileService가 Presigned URL 생성
↓
7. 프론트엔드에 Presigned URL 전달
↓
8. 프론트엔드가 각 Part를 S3에 직접 업로드
↓
9. 프론트엔드가 ETag 수집
↓
10. FileService.completeMultipartUpload() 호출
↓
11. S3Service.completeUpload() 호출 → 실제 AWS S3에 요청
↓
12. S3가 파일 합치고 최종 URL 반환
↓
13. FileRepository.save() 호출 → 실제 DB에 저장
↓
14. 완료
1. 테스트 코드가 Mock 설정
↓
2. FileService.initiateMultipartUpload() 호출
↓
3. S3Service.initiateUpload() 호출 → Mock이 가짜 응답 반환
↓
4. FileService가 Presigned URL 생성
↓
5. 테스트가 결과 검증
↓
6. 완료 (실제 AWS S3나 DB에 접근하지 않음)
isThumbnail이 MediaRole에 따라 올바르게 설정되는가?fileRepository.save()에 전달되는 File 엔티티의 모든 필드가 올바른가? (ArgumentCaptor 사용)filePath)이 올바르게 생성되어 저장되는가?# 전체 테스트 실행
./gradlew test
# 특정 테스트 클래스만 실행
./gradlew test --tests FileServiceTest
# 특정 테스트 메서드만 실행
./gradlew test --tests FileServiceTest.testCompleteMultipartUpload_ImagePreview
BUILD SUCCESSFUL in 5s
11 actionable tasks: 11 executed
> Task :test
FileServiceTest > testInitiateMultipartUpload_Image() PASSED
FileServiceTest > testInitiateMultipartUpload_LargeVideo() PASSED
FileServiceTest > testCompleteMultipartUpload_ImageContent() PASSED
FileServiceTest > testCompleteMultipartUpload_ImagePreview() PASSED
FileServiceTest > testCompleteMultipartUpload_MultiplePartsWithETags() PASSED
FileServiceTest > testAbortMultipartUpload() PASSED
FileServiceTest > testGetThumbnailUrl_WithCDN() PASSED
FileServiceTest > testGetThumbnailUrl_WithoutCDN_ThrowsException() PASSED
FileServiceTest > testGetThumbnailUrl_NonImage() PASSED
FileServiceTest > testDetermineMediaType_Image() PASSED
FileServiceTest > testDetermineMediaType_Video() PASSED
FileServiceTest > testDetermineMediaType_Audio() PASSED
FileServiceTest > testGenerateCdnUrl_WithCDN() PASSED
FileServiceTest > testGenerateCdnUrl_WithoutCDN_ThrowsException() PASSED
이전에는 CDN이 설정되지 않았을 때 S3 URL을 그대로 반환하여 버킷명이 노출되었습니다:
// 이전 코드 (보안 취약)
if (cdnBaseUrl == null || cdnBaseUrl.isBlank()) {
return s3Url; // 버킷명 노출 위험!
}
CDN이 없으면 예외를 발생시켜 버킷명 노출을 방지합니다:
// 개선된 코드 (보안 강화)
if (cdnBaseUrl == null || cdnBaseUrl.isBlank()) {
throw new IllegalStateException(
"CDN base URL이 설정되지 않았습니다. " +
"보안을 위해 CDN 설정이 필수입니다. "
);
}
generateCdnUrl(): S3 URL을 CDN URL로 변환getThumbnailUrl(): 썸네일 URL 생성testGetThumbnailUrl_WithoutCDN() → testGetThumbnailUrl_WithoutCDN_ThrowsException()testGenerateCdnUrl_WithoutCDN() → testGenerateCdnUrl_WithoutCDN_ThrowsException()application.yml에 CDN base URL을 반드시 설정해야 합니다:
cdn:
base-url: <https://cdn.example.com> # 필수 설정
CDN이 설정되지 않으면 파일 업로드나 썸네일 URL 생성 시 예외가 발생합니다.
FileService가 Post 도메인 없이도 독립적으로 올바르게 동작하는지 검증
isThumbnail 설정 (PREVIEW vs CONTENT)