이번 사이드 프로젝트를 진행하면서 파일 관련해서 업로드하는 기능을 다루어보았다. 저번에 올렸듯이 처음에는 S3에 저장하는 방식으로 했지만, 프론트분과 이야기를 하면서 DB에 저장하고, 파일 조회시 base64값을 반환해달라는 요청을 받았다.
그래서 FIle관련해서 RestAPI 형식으로 한 내용을 스스로 정리하고 싶어서 글을 쓰고자 한다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class File {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_id")
private Long id;
@Column(nullable = false)
private String originalFilename; //이미지 파일 본래 이름
@Column(nullable = false)
private String storeFilename; //이미지 파일 이름 (UUID 등으로 저장)
@Column(nullable = false)
private Long fileSize;
@Column(nullable = false)
private String fileType;
//Base64 데이터 저장
@Column(columnDefinition = "TEXT") //큰 데이터 저장 위해 TEXT 타입 사용
private String base64Data; //Base64로 인코딩 된 이미지 데이터
//필요한 경우에만 연결
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "member_id", nullable = true)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = true)
private Post post;
//필요한 경우에만 연결
@Setter
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "marker_id", nullable = true)
private Marker marker;
public void assignMarker(Marker marker) {
this.marker = marker;
if (marker != null && !marker.getFiles().contains(this)) {
marker.addFile(this); // 마커에 파일 추가
}
}
// 파일 이름과 원본 파일 이름을 매개변수로 받는 생성자
public File(String originalFilename, String storeFilename, Long fileSize, String fileType, String base64Data) {
this.originalFilename = originalFilename;
this.storeFilename = storeFilename;
this.fileSize = fileSize;
this.fileType = fileType;
this.base64Data = base64Data; // Base64 데이터 포함
}
}
File 같은 경우 주요 필드를 살펴보자면,
1. originalFilename -> 사용자가 업로드한 파일 원본 이름
2. storeFilename -> 서버에 저장할 때 사용하는 파일 이름 (UUID 등으로 생성)
3. fileSize -> 파일의 크기 (바이트 단위, 파일 용량 제한 시 사용)
4. fileType -> 파일 형식 처리
5. base64Data -> 파일의 Base64로 인코딩된 데이터 (이미지 파일과 같은 큰 파일 데이터를 직접 데이터베이스에 저장할 수 있게 하여 데이터 전송을 용이하게 함)
6. member, marker, post 관련 매핑 관계
이다.
특히 여기서 nullable = true를 설정한 이유는, Member, Post, Marker 관계에서 독립성을 유지하고, 필요할 때만 연관관계를 설정하기 위해서이다. 어떤 파일은 특정 Member, Marker, Post와 연결될 필요가 없기 때문이다.
물론 단방향으로 설계를 할 수 있지만, nullable = true로 설정한 이유는,
일단 단방향 매핑으로 설정할 경우, 파일과 관련된 정보를 처리할 때 해당 관계를 관리하기가 더 어려워질 수 있다. 예를 들어, 특정 파일에 연관된 Marker나 Member 정보를 조회하고자 할 때, 단방향 매핑만으로는 데이터의 일관성을 유지하기 어렵고, 추가적인 쿼리가 필요할 수 있다.
파일을 독립적으로 쓰면서 유연하게 쓰고 싶었고, 디벨롭되었을 때 코드 변경을 최소화 하기 위함이다.
파일이 특정 객체에(ex.Member)와 연결되어 있지 않은 경우에도 그 파일에 대한 정보를 조회하고 삭제할 수 있어야 한다.
LAZY 로딩을 통해 성능을 최적화할 수 있고, 불필요한 데이터를 미리 로드하지 않고 필요한 경우에만 데이터를 로드하게 하여 성능을 높이고자 하였다.
이를 토대로 서비스 코드를 작성해보았다.
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
@Getter
public class FileService {
private final FileRepository fileRepository;
/**
* 파일 생성
*/
public Long createFile(String base64Image, String originalFileName) {
validateImage(base64Image);
String fileName = generateFileName(); //파일 이름 생성
Long fileSize = calculateFileSize(base64Image);
String fileType = getFileType(base64Image);
File file = new File(originalFileName, fileName, fileSize, fileType, base64Image); // Base64 데이터 포함
File savedFile = fileRepository.save(file);
return savedFile.getId();
}
/**
* 파일 생성 (+ 마커)
*/
public File createFileWithMarker(String base64Image, String originalFileName, Marker marker, Member member) {
validateImage(base64Image);
String fileName = generateFileName(); //파일 이름 생성
Long fileSize = calculateFileSize(base64Image);
String fileType = getFileType(base64Image);
File file = new File(originalFileName, fileName, fileSize, fileType, base64Image); // Base64 데이터 포함
file.setMarker(marker); // 마커와 연결
file.setMember(member);
return fileRepository.save(file);
}
/**
* 파일 아이디로 조회 (DTO로 변환)
*/
@Transactional(readOnly = true)
public FileDTO findFileOne(Long fileId) {
return convertToDTO(getFileById(fileId));
}
/**
* 파일 데이터 조회
*/
@Transactional(readOnly = true)
public File getFileById(Long fileId) {
return fileRepository.findById(fileId)
.orElseThrow(() -> new FileMissingException("파일이 존재하지 않습니다. 파일 아이디 : " + fileId));
}
/**
* 파일 수정 - 메모리 문제로 파일을 직접 받을 때는 MultipartFile로 처리
*/
public File updateFile(Long id, String newFileName, MultipartFile newFile) {
File findFile = getFileById(id);
//새로운 파일의 Base64 데이터로 변환
String newBase64Image = convertToBase64(newFile);
//Base64 이미지 검증
validateImage(newBase64Image);
//파일 정보 업데이트
File resultFile = updateFileInfo(newFileName, findFile, newBase64Image);
return fileRepository.save(resultFile); // 변경된 파일 저장
}
/**
* 파일 삭제
*/
public void deleteFile(Long FileId) {
File findFile = getFileById(FileId); // 파일 조회
// 데이터베이스에서 파일 삭제
fileRepository.delete(findFile);
}
/**
* MultiFile -> Base64Image
*/
public String convertToBase64(MultipartFile file) {
//MultiFile -> Base64
try {
byte[] bytes = file.getBytes();
return "data:" + file.getContentType() + ";base64," + Base64.getEncoder().encodeToString(bytes);
} catch (IOException e) {
throw new CustomImageException(ErrorCode.IO_EXCEPTION_ON_IMAGE_UPLOAD, e.getMessage());
}
}
/**
* 파일 검증
*/
public void validateImage(String base64Image) {
//파일 유효성 검사
if (base64Image == null || base64Image.isEmpty()) {
throw new CustomImageException(ErrorCode.EMPTY_FILE_EXCEPTION, "업로드된 파일이 없습니다.");
}
//Base64 형식 체크
String[] parts = base64Image.split(",");
if (parts.length != 2) {
throw new CustomImageException(ErrorCode.INVALID_BASE64_FORMAT, "잘못된 Base64 형식입니다. 데이터 부분이 누락되었거나 잘못된 형식입니다.");
}
if (!parts[0].startsWith("data:image/")) {
throw new CustomImageException(ErrorCode.INVALID_BASE64_FORMAT, "잘못된 Base64 형식입니다.");
}
//파일 형식 체크
String mineType = parts[0].split(";")[0].substring("data:".length());
List<String> allowTypes = List.of("image/jpeg", "image/png", "image/jpg");
if (!allowTypes.contains(mineType)) {
throw new CustomImageException(ErrorCode.UNSUPPORTED_IMAGE_TYPE, "허용되지 않는 파일 형식입니다.");
}
//Base64 데이터 부분 추출
String base64Data = parts[1];
//Base64 데이터 크기 체크
byte[] decodeBytes = Base64.getDecoder().decode(base64Data);
if (decodeBytes.length > 5 * 1024 * 1024) { //5MB 이상 X
throw new CustomImageException(ErrorCode.FILE_SIZE_EXCEPTION, "파일 크기는 5MB를 초과할 수 없습니다.");
}
}
/**
* 파일 이름 생성 (무작위)
*/
public String generateFileName() {
return UUID.randomUUID().toString();
}
/**
* 파일 원본 크기 반환
*/
private Long calculateFileSize(String base64Image) {
String[] parts = base64Image.split(",");
String base64Data = parts[1];
byte[] decodedBytes = Base64.getDecoder().decode(base64Data);
return (long) decodedBytes.length;
}
/**
* 파일 형식 반환
*/
private String getFileType(String base64Image) {
String[] parts = base64Image.split(",");
return parts[0].split(";")[0].substring("data:".length()); //파일 타입 반환
}
private FileDTO convertToDTO(File file) {
return new FileDTO(
file.getOriginalFilename(),
file.getStoreFilename(),
file.getFileType(),
file.getBase64Data()
);
}
/**
* 파일 업데이트 처리 메서드
*/
private File updateFileInfo(String newFileName, File findFile, String newBase64Image) {
return fileSet(newFileName, findFile, newBase64Image);
}
private File fileSet(String newFileName, File findFile, String newBase64Image) {
findFile.setStoreFilename(newFileName);
findFile.setBase64Data(newBase64Image);
findFile.setFileSize(calculateFileSize(newBase64Image));
findFile.setFileType(getFileType(newBase64Image));
return findFile;
}
1. createFile - 파일 생성
1) 파일 검증 (validateImage):
이 메서드는 업로드된 파일이 적절한지 검사한다. 비어 있는지, Base64 형식이 유효한지, 그리고 파일 형식이 image/jpeg, image/png, image/jpg와 같은 허용된 형식인지 확인한다. 이렇게 파일을 검증하여, 파일이 유효하지 않은 경우 예외를 던진다.
2) 파일 속성 설정:
3) 파일 저장:
fileRepository.save(file)를 호출하여 파일 객체를 데이터베이스에 저장한다.
저장된 파일의 ID를 반환하여 이후 필요 시 해당 파일을 참조할 수 있도록 한다.
2. createFileWithMarker - 파일 생성 (+ 마커)
기본적인 동작 방식은 createFile 메서드와 비슷하지만, 마커 관련 파일을 생성할 때 이 메서드를 사용한다. 추가로 회원 객체도 함께 설정하였다.
- findFileOne, getFileById
FileRepository에서 주어진 fileId를 사용해 파일을 찾는다. 파일이 없으면 FileMissingException을 던진다.
getFileById 메서드에서 반환된 파일 객체를 FileDTO로 변환하여 클라이언트에게 전달한다.
DTO는 실제 파일 "객체"를 외부에 노출하지 않고 필요한 데이터만 안전하게 전달하기 위해 사용되는데, 이렇게 객체를 직접 전달하는 대신 DTO를 사용함으로써 보안이 더 우수하고, 필요한 데이터를 보내서 더 효율적이다.
- updateFile - 파일 수정
File findFile = getFileById(id);
주어진 id를 사용하여 기존 파일을 getFileById 메서드로 조회한다.
//새로운 파일의 Base64 데이터로 변환
String newBase64Image = convertToBase64(newFile);
//Base64 이미지 검증
validateImage(newBase64Image);
convertToBase64 메서드를 사용하여 MultipartFile을 Base64 문자열로 변환한다. (파일을 클라이언트에서 받은 후 Base64 형식으로 저장하는 것이 일반적인 패턴이다).
이 후 validateImage를 통해 이미지를 검증한다.
//파일 정보 업데이트
File resultFile = updateFileInfo(newFileName, findFile, newBase64Image);
/**
* 파일 업데이트 처리 메서드
*/
private File updateFileInfo(String newFileName, File findFile, String newBase64Image) {
return fileSet(newFileName, findFile, newBase64Image);
}
private File fileSet(String newFileName, File findFile, String newBase64Image) {
findFile.setStoreFilename(newFileName);
findFile.setBase64Data(newBase64Image);
findFile.setFileSize(calculateFileSize(newBase64Image));
findFile.setFileType(getFileType(newBase64Image));
return findFile;
}
updateFileInfo와 fileSet 메서드를 통해 findFile 객체의 속성을 새로운 파일 데이터로 덮어씌운다.
storeFilename, base64Data, fileSize, fileType 등 파일의 모든 속
성을 새 값으로 변경한다.
fileRepository.save(findFile)를 호출하여 변경된 파일 정보를 데이터베이스에 저장 후, 수정된 파일을 반환한다.
deleteFile - 파일 삭제
deleteFile 메서드는 주어진 FileId를 사용하여 파일을 데이터베이스에서 삭제한다.
그 다음으로 Controller 코드이다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/files")
@Slf4j
public class FileController {
private final FileService fileService;
// 파일 업로드
@PostMapping("/upload")
public ResponseEntity<FileDTO> uploadFile(@RequestParam("file") MultipartFile file) {
log.info("/upload 호출");
String originalFileName = file.getOriginalFilename();
String base64Image = fileService.convertToBase64(file);
fileService.validateImage(base64Image);
Long fileId = fileService.createFile(base64Image, originalFileName);
FileDTO fileDTO = fileService.findFileOne(fileId);
return new ResponseEntity<>(fileDTO, HttpStatus.CREATED);
}
// 파일 수정
@PutMapping("/update/{fileId}")
public ResponseEntity<File> updateFile(@PathVariable("fileId") Long fileId, @RequestParam("file") MultipartFile file) {
// 파일 이름을 가져옵니다.
String newFileName = file.getOriginalFilename();
// 파일을 업데이트하고 결과를 가져옵니다.
File updatedFile = fileService.updateFile(fileId, newFileName, file);
// 응답 반환
return ResponseEntity.ok(updatedFile);
}
@GetMapping("/{fileId}")
public ResponseEntity<File> getFile(@PathVariable("fileId") Long id) {
File findFile = fileService.getFileById(id);
return ResponseEntity.ok(findFile);
}
// 파일 삭제
@DeleteMapping("/delete/{fileId}")
public ResponseEntity<Void> deleteFile(@PathVariable("fileId") Long fileId) {
fileService.deleteFile(fileId);
return ResponseEntity.noContent().build();
}
1. uploadFile
1) file.getOriginalFilename()을 통해 파일의 원본 이름을 가져온다.
2) 파일을 Base64 형식으로 변환하고, 이를 validateImage로 검증한다.
3) createFile 메서드로 파일을 저장하고, 저장된 파일의 ID를 반환받는다.
4)findFileOne 메서드로 해당 파일의 정보를 DTO 형식으로 변환하여 반환한다.
5) 파일이 성공적으로 업로드되면 201 CREATED 응답을 반환한다.
나머지 부분들 updateFile, getFile, deleteFile은 코드를 통해 충분히 이해할 수 있다고 생각하여 생략한다.
이제 API를 검증하기 위해 POSTMAN으로 검증해준다. 핵심적인 upload, update 테스트를 한 번 검증해보자
일단 업로드 할 경우 파일이 잘 업로드 되고(201 Created), 해당 파일의 Base64 값을 적절하게 반환해준다는 점을 볼 수 있다.
해당 id(1)값을 가진 사진에 저장 이름과 base64Data이 바뀌었다는 점을 알 수 있다.
이러면 성공이다.
파일 관련해서 많이 어렵다고 생각했지만, 차근 차근 해보니 프론트분이 원하는데로 반환할 수 있었다! 더 많은 프로젝트를 하면서 단순 db에가 아닌 여러 방편으로 파일을 저장하는 법을 배우고 싶다.