H1-1. Notice

  1. 실제 Production에서의 테스트가 아닌 JUnit의 Mock을 사용한 테스트를 진행
  2. Thumbnail Resizing은 AWS S3 Lambda로 처리하고, 동적으로 썸네일 CDN URL을 제공하는 로직으로 구성한다.

H1-2. Test Scenario Markdown

하단의 링크에 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 테스트 설명 (한글 가능)

각 테스트 메서드 상세 설명

1. testInitiateMultipartUpload_Image() - 이미지 파일 업로드 초기화

목적

멀티파트 업로드 초기화가 올바르게 동작하는지 검증

검증 내용

  1. S3Service가 올바른 파라미터로 호출되는지
  2. Part 개수가 올바르게 계산되는지
  3. Presigned URL이 올바르게 생성되는지
  4. 응답 DTO가 올바르게 구성되는지

단계별 설명

// ===== 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 엔티티도 검증합니다.


2. testInitiateMultipartUpload_LargeVideo() - 대용량 동영상 파일

목적

대용량 파일이 여러 Part로 분할되는지 검증

검증 내용

실제 동작 과정

1. 파일 크기: 500MB
2. Part 크기: 64MB (권장 크기)
3. Part 개수: 500MB ÷ 64MB = 약 8개
4. 각 Part마다 Presigned URL 생성

테스트가 검증하는 것


3. testCompleteMultipartUpload_ImageContent() - 업로드 완료 (CONTENT)

목적

멀티파트 업로드 완료 후 DB에 올바르게 저장되는지 검증

검증 내용

  1. S3Service의 completeUpload()가 올바른 ETag와 함께 호출되는지
  2. File 엔티티가 올바른 값으로 생성되는지
  3. isThumbnailfalse로 설정되는지 (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가 포함되는지 확인

4. testCompleteMultipartUpload_ImagePreview() - 업로드 완료 (PREVIEW)

목적

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);

5. 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로 저장되는지 확인

6. testAbortMultipartUpload() - 업로드 취소

목적

업로드 취소가 올바르게 동작하는지 검증

검증 내용

실제 동작 과정

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);
}));

7. 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>");

8. testGetThumbnailUrl_WithoutCDN_ThrowsException() - 썸네일 URL 생성 (CDN 없음)

목적

CDN이 없을 때 예외가 발생하는지 검증 (보안: 버킷명 노출 방지)

검증 내용

실제 동작 과정

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");
});

보안 개선 사항


9. 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();

10. MediaType 결정 테스트들

목적

MIME 타입이나 파일 확장자로 MediaType이 올바르게 결정되는지 검증

검증 내용

실제 동작 과정

// 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);

11. CDN URL 생성 테스트들

목적

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 설정

테스트에서 S3Response의 location실제 key를 포함한 S3 URL이어야 합니다:

이유: extractKeyFromS3Url()이 S3 URL에서 key를 추출하여 CDN URL을 생성하므로, 실제 key가 포함된 URL이어야 올바른 CDN URL이 생성됩니다.

보안 개선 사항


테스트 실행 과정

1. 테스트 시작

./gradlew test

2. JUnit이 테스트 클래스 찾기

3. 테스트 클래스 초기화

@BeforeEach
void setUp() {
    // 각 테스트 실행 전 실행
    ReflectionTestUtils.setField(fileService, "bucketName", "test-bucket");
    ReflectionTestUtils.setField(fileService, "cdnBaseUrl", "<https://cdn.example.com>");
}

4. 각 테스트 메서드 실행

1. testInitiateMultipartUpload_Image() 실행
   - Given: Mock 설정
   - When: fileService.initiateMultipartUpload() 호출
   - Then: 결과 검증
   - ✅ 통과

2. testCompleteMultipartUpload_ImageContent() 실행
   - Given: Mock 설정
   - When: fileService.completeMultipartUpload() 호출
   - Then: 결과 검증
   - ✅ 통과

... (나머지 테스트들)

5. 테스트 결과

✅ 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에 접근하지 않음)

테스트가 검증하는 핵심 사항

1. 파일 업로드 플로우

2. ETag 처리

3. DB 저장

4. 썸네일 처리

5. MediaType 결정

6. CDN URL 변환


테스트 실행 및 결과 확인

테스트 실행

# 전체 테스트 실행
./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 필수 설정 (버킷명 노출 방지)

문제점

이전에는 CDN이 설정되지 않았을 때 S3 URL을 그대로 반환하여 버킷명이 노출되었습니다:

// 이전 코드 (보안 취약)
if (cdnBaseUrl == null || cdnBaseUrl.isBlank()) {
    return s3Url;  // 버킷명 노출 위험!
}

개선 사항

CDN이 없으면 예외를 발생시켜 버킷명 노출을 방지합니다:

// 개선된 코드 (보안 강화)
if (cdnBaseUrl == null || cdnBaseUrl.isBlank()) {
    throw new IllegalStateException(
        "CDN base URL이 설정되지 않았습니다. " +
        "보안을 위해 CDN 설정이 필수입니다. " 
    );
}

영향받는 메서드

  1. generateCdnUrl(): S3 URL을 CDN URL로 변환
  2. getThumbnailUrl(): 썸네일 URL 생성

테스트 변경 사항

설정 요구사항

application.yml에 CDN base URL을 반드시 설정해야 합니다:

cdn:
  base-url: <https://cdn.example.com>  # 필수 설정

CDN이 설정되지 않으면 파일 업로드나 썸네일 URL 생성 시 예외가 발생합니다.


요약

테스트의 목적

FileService가 Post 도메인 없이도 독립적으로 올바르게 동작하는지 검증

검증하는 주요 기능

  1. ✅ 멀티파트 업로드 초기화
  2. ✅ 멀티파트 업로드 완료 및 DB 저장
  3. ✅ ETag 처리
  4. ✅ 썸네일 URL 생성
  5. ✅ MediaType 결정
  6. ✅ CDN URL 변환
  7. isThumbnail 설정 (PREVIEW vs CONTENT)