[Exception] 프로필 이미지 수정 기능에서 이미지가 아닌 경우에 대한 예외 처리 - Hype

2023. 4. 19. 21:08프로젝트/학습


💡Hype이란 프로젝트를 구현하면서 프로필 이미지 수정 기능을 구현했습니다. 이 때, png, jpg와 같이 특정 파일 형식이 아닌 경우에 대해서 예외처리를 구현했는데 해당 기능에 대한 테스트도 없었고 프론트 측에서 이 기능을 나중에 만들면서 해당 예외 처리가 제대로 동작하지 않는 것을 확인했습니다. 이 부분에 대한 테스트 추가와 기능 수정을 해보려고 합니다.

기존 기능 코드

현재 구현된 부분은 프로필 이미지 수정 요청이 들어오면 요청과 함께 multipart/form-data 형식으로 이미지 데이터가 들어오고 이를 AWS S3에 저장한 후 해당 이미지의 url 정보를 유저 정보에 update 하는 형식으로 구현되어 있습니다. 그래서 문제가 발생한 부분은 이미지를 저장하는 부분에서의 예외처리가 제대로 동작하지 않는 것이기에 이 부분에 대해서만 코드를 살펴보겠습니다.

  • 해당 기능 구현을 위해 생성한 파일 구조. (📁 : 디렉토리, 🔗 : 인터페이스, 🏛️ : 클래스, 🔤 : enum 클래스)
📁 common
    > 🔗 ResourceStorage
    > 🏛️ AmazonS3ResourceStorage
📁 util
    > 🔤 FileFormat
    > 🏛️ MultipartUtils -> FileUtils (코드 변경과 함께 네이밍 변경)

 

  • ResourceStorage: 해당 인터페이스를 이용해서 추상화를 참조하게 구현하여 이미지를 저장할 저장소가 바뀌어도 쉽게 갈아끼울 수 있도록 구성했습니다. 정말 간단하게 이미지를 저장하는 기능만을 구현하도록 구성했습니다.
public interface ResourceStorage {
	String save(String path, Long entityId, MultipartFile image);
}
  • AmazonS3ResourceStorage: 위 인터페이스의 구현체 클래스로 S3에 저장하는 기능을 수행합니다.
@Component
@RequiredArgsConstructor
public class AmazonS3ResourceStorage implements ResourceStorage {

	private final AmazonS3 amazonS3;

	@Value("${cloud.aws.s3.bucket.name}")
	private String bucketName;

	@Override
	public String save(String path, Long entityId, MultipartFile multipartFile) {
		String savedFilePath = createSavedFilePath(path, entityId, multipartFile);

		try {
			ObjectMetadata objectMetadata = new ObjectMetadata();
			objectMetadata.setContentType(MultipartUtils.getFormat(savedFilePath));
			objectMetadata.setContentLength(multipartFile.getSize());
			amazonS3.putObject(
				new PutObjectRequest(
					bucketName,
					savedFilePath,
					multipartFile.getInputStream(),
					objectMetadata
				)
			);
		} catch (IOException exception) {
			throw new IllegalStateException(ExceptionMessage.FAIL_UPLOAD_FILE_S3.getMessage());
		}

		return amazonS3.getUrl(bucketName, savedFilePath).toString();
	}

	private String createSavedFilePath(String path, Long entityId, MultipartFile multipartFile) {
		if (Objects.isNull(multipartFile.getOriginalFilename())) {
			throw new IllegalArgumentException(ExceptionMessage.NOT_EXIST_FILE_NAME.getMessage());
		}

		return String.format("%s%s/%s.%s", path, entityId, createUniqueFilename(),
			getFormat(multipartFile.getOriginalFilename()));
	}
}
  • S3에서 유저 id를 이용하여 유저별 디렉토리를 구성해 이미지를 저장하기 위해서 특정 path와 entityId를 이용하고 MultipartUtil 클래스를 이용하여 UUID를 통한 파일명과 파일 형식을 분리해서 이것들을 다시 조합해 유니크한 파일의 path를 새롭게 구성하는 로직을 거칩니다.
  • ObjectMetadata를 이용하여 이미지 데이터 크기와 데이터 형식을 지정해주고 AWS S3 SDK를 이용해서 특정 버킷으로 만든 path와 이미지 데이터와 함께 ObjectMetadata를 보내줍니다.
  • 이 때 만들어지는 url을 반환해줍니다.

 

  • MultipartUtils & FileFormat
public final class MultipartUtils {

	public static String getContentType(String originalName) {
		String format = getFormat(originalName).toLowerCase();
		FileFormat fileFormat = FileFormat.of(format);
		return String.format("%s/%s", fileFormat.name().toLowerCase(), format);
	}

	public static String getFormat(String originalName) {
		return originalName.substring(originalName.lastIndexOf('.') + 1);
	}

	public static String createUniqueFilename() {
		return UUID.randomUUID().toString();
	}
}

////////////////////////////////////////////////////////////////////////////////

@Getter
public enum FileFormat {
	IMAGE(List.of("gif", "jpeg", "jpg", "png", "pjpeg"));

	final List<String> formats;

	FileFormat(List<String> formats) {
		this.formats = formats;
	}

	public static FileFormat of(String format) {
		return Arrays.stream(FileFormat.values())
			.filter(v -> v.formats.contains(format))
			.findAny()
			.orElseThrow(() -> new IllegalArgumentException(ExceptionMessage.NOT_ALLOWED_FILE_FORMAT.getMessage()));
	}
}
  • 파일 형식을 분리하거나 파일의 이름을 만들어주는 등의 multipart 형식을 위한 로직들을 모아놓기 위해서 해당 클래스를 만들었고 FileFormat enum 클래스를 이용해서 허용할 이미지 파일 형식을 명했습니다.
  • FileFormat을 저렇게 List를 이용해서 구성한 이유는 나중에 Image 말고 다른 형식이 추가되어도 관리하기 쉽게 만들기 위해서 저렇게 구성했고 저 문자 자체가 필요한 것이 아니라 포맷이 내가 허용하는 포맷에 존재하는지만 확인하면 된다고 생각해서 이렇게 구현해도 괜찮을 것 같다는 생각이 들었습니다.

 

그래서 위의 구현에서 파일 형식을 확인하고 예외가 발생하는 위치는 FileFormat의 of 메서드를 이용하는 시점이라고 생각하고 코드를 찾아보았습니다. 하지만 해당 메서드를 사용하는 부분이 존재하지 않았습니다.

기존에 아래와 같은 메서드가 MultipartUtils 클래스 내부에 존재했는데 이를 이용할 때는 of 메서드를 거쳤는데 이를 AmazonS3ResourceStorage 클래스내부로 옮겼습니다. 파일의 path를 생성해주는 로직이기에 해당 클래스에서 만들어줘야 한다는 생각에 이렇게 구성했습니다.

public static String getContentType(String originalName) {
	String format = getFormat(originalName).toLowerCase();
	FileFormat fileFormat = FileFormat.of(format);
	return String.format("%s/%s", fileFormat.name().toLowerCase(), format);
}

하지만 지금 드는 생각은 만약 저장소의 구현체가 바뀐다면 똑같은 코드를 반복해서 짜야하고 이는 DRY 법칙을 어긴다는 생각이 들었습니다. 그래서 이를 유틸리티 클래스로 빼고 구현체에서 이용하는 방법으로 구성을 바꿨습니다.

수정한 기능 코드

  •  AmazonS3ResourceStorage
@Component
@RequiredArgsConstructor
public class AmazonS3ResourceStorage implements ResourceStorage {

	private final AmazonS3 amazonS3;

	@Value("${cloud.aws.s3.bucket.name}")
	private String bucketName;

	@Override
	public String save(String path, Long entityId, MultipartFile multipartFile) {
		String savedFilePath = getSavedFilePath(path, entityId, multipartFile);

		try {
			ObjectMetadata objectMetadata = new ObjectMetadata();
			objectMetadata.setContentType(FileUtils.getFormat(savedFilePath));
			objectMetadata.setContentLength(multipartFile.getSize());
			amazonS3.putObject(
				new PutObjectRequest(
					bucketName,
					savedFilePath,
					multipartFile.getInputStream(),
					objectMetadata
				)
			);
		} catch (IOException exception) {
			throw new IllegalStateException(ExceptionMessage.FAIL_UPLOAD_FILE_S3.getMessage());
		}

		return amazonS3.getUrl(bucketName, savedFilePath).toString();
	}
}
  • MultipartUtils -> FileUtils
public final class FileUtils {

	public static String getSavedFilePath(String path, Long entityId, MultipartFile multipartFile) {
		if (Objects.isNull(multipartFile.getOriginalFilename())) {
			throw new IllegalArgumentException(ExceptionMessage.NOT_EXIST_FILE_NAME.getMessage());
		}

		String format = getFormat(multipartFile.getOriginalFilename()).toLowerCase();
		return String.format("%s%s/%s.%s", path, entityId, createUniqueFilename(), format);
	}

	public static String getFormat(String originalName) {
		String format = originalName.substring(originalName.lastIndexOf('.') + 1);
		FileFormat.of(format);
		return format;
	}

	public static String createUniqueFilename() {
		return UUID.randomUUID().toString();
	}
}

 

테스트 추가하기

@Test
void 실패_허용하는_파일_형식이_아니라면_프로필_이미지를_수정할_수_없다() throws MalformedURLException {
	// given
	Long entityId = 1L;
	String fileName = "test";
	String format = "pdf";
	MockMultipartFile file = createFile(String.format("%s.%s", fileName, format));

	// when, then
	assertThatThrownBy(() -> resourceStorage.save("members/profile/", entityId, file))
		.isInstanceOf(IllegalArgumentException.class);
}​

이와 같이 허용되지 않는 파일 형식에 대해서 예외가 발생하는지 여부를 확인하기 위한 테스트를 추가했습니다.


마무리

파일 형식을 찾는 enum 클래스에 대한 테스트 코드를 짜진 않았습니다. 그 이유는 로직이 되게 단순해서 굳이 필요하지 않다고 느꼈기 때문입니다.

또 파일 형식을 가져오는 경우도 content type을 이용하면 image/png 이런 식으로 값이 들어와서 해결할 수 있는데 만약 형식을 multipart/form-data로 명시한다면 이것이 불가능해지기 때문에 그런 경우까지 생각했을 때 모든 경우를 아우를 수 있는 방법을 생각했습니다. lastIndexOf 메서드를 이용해서 '.' 위치를 찾는 방법을 이용해서 파일 형식을 가져오게 구현했습니다.

이런 방법들로 처리할 수 없는 예외가 또 존재하는데 그건 파일이 손상된 경우에 대한 처리라고 생각합니다. 손상된 이미지 파일로 이미지 수정 요청을 보내는 경우에 대한 처리를 다음 글에서 해보도록 하겠습니다.