프로젝트를 시작하던 시절 나는 이미지 저장 방식에 대해 잘 몰랐다.
프로젝트를 진행하면서 공부를 하고 바꿔온 방식에 대해 알아보자
서버가 있었기에 이미지 저장은 S3가 아닌 서버 로컬스토리지에 저장하고 쓰는 방식을 택했다.
게시글에서는 이미지의 링크를 저장하지 않고, imageCount의 변수로 이미지의 갯수를 관리했다.
이미지 id, image 번호를 이용해 1-1, 1-2 등으로 이미지를 불러오는 방식으로 구현했다.
public void saveFireImage(List<MultipartFile> images) throws IOException {
for(int i = 0 ; i<images.size();i++){
byte[] bytes = images.get(i).getBytes();
String key = "fire" + "-" + (i+1);
log.info("이미지 저장 key:{}",key);
redisTemplateForImage.opsForValue().set(key,bytes);
}
}
@GetMapping("/{id}")
public ResponseEntity<byte[]> getFireImage(@PathVariable Long id){
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.IMAGE_PNG);
return ResponseEntity.ok().headers(httpHeaders).body(redisService.getFireImage(id));
}
처음에는 레디스를 사용하는 것이 데이터베이스에 이미지를 넣고 쓰는 방식보다 성능이 좋다고 들어 아래와 같은 방식으로 구현했다.
하지만, 레디스는 인메모리 데이터베이스로 컴퓨터 스토리지가 아닌 메모리에 데이터를 쓰고 가져오는 방식으로 속도는 빠르지만 그만큼 용량이 작은 메모리 공간을 차지한다는 사실을 알고 방향을 바꾸었다.
public void saveImage(Long id, List<MultipartFile> images, String path) throws IOException {
for (int i = 1; i < images.size() + 1; i++) {
MultipartFile file = images.get(i - 1);
String originalFilename = file.getOriginalFilename();
String fileExtension = getExtension(originalFilename);
String fileName = id + "-" + i + fileExtension;
Path filePath = Paths.get(path, fileName);
Files.write(filePath, file.getBytes());
}
}
추후에 데이터가 쌓이면 메모리 많은 데이터가 올라간 방식은 컴퓨터에 부담이 커질거라 예상했다.
현재는 이미지의 확장자를 붙여 저장했지만, 전에는 api 형식으로 이미지를 불러왔기 레디스와 같은 방식으로 아래 기능으로 수정했다.
@PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ResponseDto<Long>> savePost(@Valid@RequestPart PostDto postDto, @AuthenticationPrincipal Member member, @RequestPart(required = false) List<MultipartFile> images) throws NoSuchAlgorithmException, IOException {
log.info("게시글만 저장 호출 id:{}",member.getId());
Long postId = postService.saveOnlyPost(member,postDto,images);
return ResponseEntity.status(HttpStatus.CREATED).body(ResponseDto.of(postId,"게시글 등록 성공"));
}
해당 코드를 보면 멀티파트 타입으로 이미지와 게시글을 한번에 받아 저장하는 방식이다.
하지만 이 전에는 게시글 저장 api, 이미지 저장 api를 따로 만들었다. 분리를 한 이유는 다음과 같다.
찾아본 결과는 다음과 같았다.
그 이유 때문에 해당 멀티파트 속 데이터의 타입의 명시가 불가능한 swagger에서 테스트하지 못한다는 점도 있었다.
해당 문제는 2가지의 해결책이 있었다.
나는 같은 개발자와 고민을 한 결과 swagger의 편의성을 버리더라도, 추후에 모든 octet-stream 으로 들어온 요청을 json 으로 인식하고 파싱하는 것이 어떤 문제점을 일으킬지 모르기에 2번 방식을 선택했다.
api로 이미지를 제공하는 방식으로 만들어진 지금 두 방식에 대한 고민이 생겼다.
해당 방식은 많은 이미지를 한 번에 불러 올 때 차이가 있었다.
전자의 경우는 불러올 이미지의 수 만큼 api를 호출해야하는 부담감이 있었다.
후자의 경우는 모든 이미지가 렌더링 될 때 까지 대기를 해야한다는 점이었다.
게시글을 보여준다는 서비스의 특성 상 글을 먼저 보여주고, 천천히 이미지를 로딩하며 보여주자는 의견을 선택해 전자의 방식을 선택했다.
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceLocations("file:"+path)
.setCachePeriod(3600);
}
이미지 제공 방식에 대해 고민하던 중 이미지는 스프링으로 하고, 불러오는 것은 NGINX 같은 웹서버가 하는 방식이 있다는 기억이 났다.
그런데 새로 웹서버를 띄우지 않고 스프링에서 해당 기능을 제공한다는 점을 알았다.
해당 방식으로 구현시 api로 구현하는 방식보다 엄청난 성능 이득이 있었다.
바로 이미지를 제공함으로 속도적으로도 훌륭하고, 캐싱 기능도 제공한다는 점이었다.
현재 이미 구현되어있는 시스템은 이미지의 경로를 따로 저장하지 않는다.
이미지의 갯수만 저장된 상태이기에 {게시글 id}-{이미지번호}로 이미지를 저장했다.
하지만 정적 리소스는 정확한 파일 위치에 정확한 파일의 이름이 필요한 것이 문제였다.
그래서 이 문제가 발생한 시점부터 이미지를 저장할 때 확장자까지 저장했다.
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceLocations("file:"+path)
.setCachePeriod(3600)
.resourceChain(false)
.addResolver(customResourceHttpRequestHandler);
}
@Override
public Resource resolveResource(HttpServletRequest request, String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) {
try {
// 요청된 경로를 기준으로 실제 파일 경로 계산
Path requestedPath = Paths.get(path, requestPath);
// 디렉토리 내에서 파일 검색
File baseDir = requestedPath.getParent().toFile();
File[] matchingFiles = baseDir.listFiles((dir, name) -> name.startsWith(requestedPath.getFileName().toString()+"."));
if (matchingFiles != null && matchingFiles.length > 0) {
return new UrlResource(matchingFiles[0].toURI());
}
} catch (Exception e) {
e.printStackTrace();
}
// 파일이 없으면 기본 체인으로 위임
return chain.resolveResource(request, requestPath, locations);
}
@Override
public String resolveUrlPath(String resourcePath, List<? extends Resource> locations, ResourceResolverChain chain) {
return chain.resolveUrlPath(resourcePath, locations);
}
커스텀한 ResourceResolver는 다음과 같은 기능을 수행한다.
그렇다면 파일의 확장자가 어떻든 파일명. 뒤에 오는 파일명을 가져옴으로 정확한 이름이 필요한 정적 리소스 제공 방식에 부합하게 되어 해결했다.
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceLocations("file:"+path)
.setCachePeriod(3600)
.resourceChain(false)
.addResolver(customResourceHttpRequestHandler);
}
이미지를 수정하고 요청을 해보니 다음과 같은 문제가 발생했다.
다시 정적 리소스 제공 메소드를 보면 resourceChain(false)라는 메소드가 있다.
나는 이 메소드가 customResolver를 활성시켜주는 메소드인 줄 알았지만, customResolver의 결과를 캐싱해주는 기능이었다.
모든 이미지의 이름은 고유했기에 이름을 찾는 것에 캐싱을 시켜줄 필요가 없다 생각해 해당 기능을 끄고 문제를 해결했다.
이미지를 수정했지만, 해당 이미지 조회를 해보니 바뀌지 않는 문제가 발생했다.
문제 발생 원인은 다음과 같았다.
해당 문제로 왜 사람들이 이미지의 경로를 저장하며, 이미지의 버전으로 이름을 붙여 저장하는지에 대해 알게 되었다.
하지만, 이미 만들어진 시스템이었기에 현재의 시스템에서 문제를 해결할 방법을 고민했다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
@Column(name = "create_date")
private LocalDate createDate;
@LastModifiedDate
@Column(name = "modified_date")
private LocalDateTime modifiedDate;
}
기존 시스템에서 이미지 기능을 이용하는 도메인은 전부 이 클래스를 상속받아 만들어지고 있다.
여기서 modifiedDate는 해당 엔티티가 수정될 경우 수정되는 시간이 찍히는 필드이다.
지금까지 해당 필드를 만들어 놓고 한번도 사용한 적이 없었다.
하지만, LocalDate타입을 LocalDateTime로 바꿔줌으로 해당 이미지 수정 문제를 해결했다.
정적 리소스에 대한 요청은 다음과 같은 특징을 가진다.
프론트에서 모든 이미지에 요청을 보낼때 요청 뒤에 ?{modifiedDate}를 넣는 방식으로 바꾸었다.
그렇다면, 기존 게시글들은 수정을 하지 않는 이상 같은 요청으로 판단해 캐싱된 이미지를 사용한다.
하지만, 이미지 수정으로 인해 modifiedDate가 변경된다면 해당 이미지도 변경되었다 판단해 새로운 수정된 이미지를 가져오게 되는 방식이다.
이미지에 대해 아무것도 모르고 개발을 할 때 여러 방식을 시도해보았다.
점점 좋은 방식으로 개선해 나갈수록 사람들이 흔히 사용하는 S3 방식의 원리와 가까워지는 것 같았다.
모든 사람들이 사용하는 좋은 방법으로 바로 시작하는 방식도 좋지만,
이렇게 이런저런 시도를 해보며 여러 방식을 시도해보고 경험하는 것도 좋다.
지금 구현된 이미지도 최선의 방법이 아니라고 생각한다. 아직 개선할 점이 많다고 생각한다.