아니 그냥 이미지 url로 따서 그거 db에 넣으면 되는거 아님? 🧐
팀 프로젝트를 진행하며, 사용자 프로필 이미지 저장, 처리를 해야된다고 들었을 때의 나의 생각이었다.
직접 해보기 전까진 말이다..
사실 이 아이디어가 완전히 틀리다 -> 이건 아니긴 했다. 정확히는 반은 맞고, 반은 틀리다 에 가까웠다.
지금부터 내가 공부하고 겪은 시행착오에 대해 복기해보고자 이 글을 적는다!
DB에 이미지 파일을 저장하는 방식은 두 가지라고 한다.
- 이미지 데이터 자체를 저장하는 방법.
➡️ 이 방법은 개인적으로 이번 프로젝트에서 사용하자는 생각은 들지 않았다. 애초에 DB에 부담가는게 싫기도 했고(RDS가 본인 사비로 나가고 있기도 하고), 잘 안 쓴다고도 해서..무엇보다 내가 생각했던 것과 비슷한 2번째 방법이 훨씬 낫다는 생각이 들기도 했다.
(물론 이렇게 처리하는 방법도 배워야겠다는 생각도 들었다.)
- 이미지는 저장소 다른 어딘가에 저장하고, 이미지가 저장된 경로를 데이터베이스에 저장한다.
➡️ 이 방법이 내가 위에서 말했던 반은 맞고, 반은 틀리다고 했던 방법이다. 단순히 경로만 따서 가져오는 것이 아니라, 사용자가 업로드한 이미지를 내 서버에서 접근할 수 있는 경로로 저장하고, 그에 대한 경로를 DB에 저장해야 했기 때문이다. (서버에 이미지를 저장해두는 것까진 아예 생각하지 못했었음😮)
우선 내가 생각했던 방식에 가까운 2번 방식을 채택해 기능을 구현하기로 결정했다.
이 방식을 알아보고 내가 생각했던 방식은 간단했다.
사용자가 업로드한 이미지 파일은
➡️ EC2 서버 내에 디렉토리를 하나 만들어 저장하고,
이 EC2에 생성된 디렉토리에 대한 경로를 접근할 수 있게 하여 이미지를 보여줄 수 있게 API를 생성하고,
사용자 프로필 이미지 정보에는
➡️이미지에 대한 URL를 저장할 수 있게 하는 것이다.
스프링 부트에서는 MultipartFile이라는 클래스가 제공되는데,
이 녀석은 form 형식으로 파일이 날아오면, 날아온 파일을 이 타입으로 사용한다.
그 후에 transferTo() 메서드를 거쳐 JAVA의 File 객체로 변환이 가능해진다.
Hayden 님의 블로그에 있는 예제와 이미지를 참고하였습니다!
사실 서비스-이미지 핸들러 부분만 제외하면 나머지는 스프링 백엔드 구조와 큰 차이는 없다.
1.사용자는 이미지 파일을 POST 요청으로 서버에 전송한다. 이때 데이터 타입은 multipart/form-data 형식으로 전송한다.
컨트롤러는 해당 요청을 받는다. 이때 저장할 이미지를 MultipartFile 형태로 받아와 서비스 계층에 넘겨준다.
서비스는 먼저 전달받은 이미지를 서버 PC에 저장한다. 이때 파일 저장 과정은 ImageHandler 클래스에게 위임한다.
ImageHandler는 이미지를 저장한 후 이미지가 저장된 경로를 리턴한다. 서비스는 이미지 저장 후 리턴 받은 이미지 저장 경로를 레퍼지토리 계층에 넘겨준다. 이때 이미지 경로는 Entity 형태로 전달된다.
리포지토리는 전달 받은 경로를 DB에 저장한다.
그래서 위 예제 부분을 참고해서 필요한 부분만 발췌해서 코드를 작성했다.
그런데 웬걸, 400 에러가 계속해서 뜬다.
당시 내가 이미지 핸들링을 위해 컨트롤러 메서드로 받은 값들의 형태는 이랫다.
@ResponseBody
@PostMapping("엔드포인트")
public ResponseEntity<String> signUp(
@RequestParam String name,
@RequestParam String nickname,
@RequestParam boolean sex ,
@RequestParam Age age,
@RequestParam String intro,
@RequestParam MultipartFile profileImg,
@RequestHeader String sub
) {
// 기능 구현..
}
으와..보기만해도 너무 조잡했다. 사실 위 예제에서 formData를 파라미터로 받았기에,
fromData 처리는 파라미터로 진행하면 되는구나!
라는 안일한 생각으로 같이 작업하고 있던 프론트엔드 팀원에게 같이 테스트해보자고 요청했다.
위에서 언급했듯이..400 에러가 났다.😢
프론트엔드 쪽에서는 제대로 Multipart/form-data 형식으로 Content-Type을 지정해주어 요청을 보냈다는데 뭐가 문제일까 싶어 고민하다가,
원래 사용하던 DTO 객체를 전달받는 방식에
이 객체를 @RequestBody 어노테이션으로 받아와야겠다는 생각이 들어 이렇게 처리했다.
그렇게 바꾼 두 번째 코드
@ResponseBody
@PostMapping("엔드포인트")
public ResponseEntity<String> signUp(
@RequestHeader String sub,
@RequestBody AddUserRequest request) {
// 기능 구현..
}
딱히 기대는 안하고 다시 프론트 팀원과 테스트.
오잉? 이번엔 415 에러가 났다.
415 에러는 발생하는 데 다양한 이유가 있지만, 주된 이유는 'Content-Type 불일치' 라는 것을 확인했다.
사실 이때 적잖아 당황했다.🙁
그동안 처리해왔던 데이터 타입이 대부분 JSON 방식을 이용했었기에, formData에 대한 무지식이 발목을 붙잡았다.
무엇보다 이때까지 요청의 데이터 타입을 컨트롤러에 지정해주는 방식을 인지하지 못하고 있었다.
그렇게 컨트롤러에 formdata 처리 방식을 추가하여 다시 시도해보기로 했다.
그렇게 완성한..세 번째 코드다.
@ResponseBody
@PostMapping(value = "엔드포인트",
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<String> signUp(@RequestHeader String sub,
@RequestBody AddUserRequest request) {
// 기능 구현..
}
이젠 됬겠지?? 🙏
'오!'
테스트를 진행해본 프론트 팀원의 반응에 매우 기대했다.
아쉽게도 성공은 아니었지만, 415 에러는 아니었다는 점이다!
문제는 발생한 에러가 400 에러였다는 것이다.
당연히 프론트와 백 둘 중 하나의 문제는 맞았는데,
프론트 팀원은 자신은 확실하게 네가 원하는 요청 양식을 다 맞춰서 보내주었다고 말했다.
사실 나도 아예 문외한은 아니었기에 요청 양식을 확인하기 위해 프론트엔드 코드를 같이 복기해보기로 했다.
내가 보기에도 역시 별 문제는 없었기에,
아무래도 '또 난가?' 싶었다.
다시 찬찬히 되짚어보다, 차라리 formdata 요청 방식을 좀 더 정확하게 알아야겠다 싶었다.
그렇게 되짚다보니
'우리가 생각한 요청구조' 는 동일했지만, 내가 작성한 코드가 우리의 요청방식을 받지 못한다는 사실을 알게 되었다..
그 사실을 알고 다시 고친 4번째 코드다.
@ResponseBody
@PostMapping(value = "엔드포인트",
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<String> signUp(
@RequestPart("profileImg") MultipartFile profileImg,
@RequestPart("addUserRequest") AddUserRequest request
) {
//기능 구현..
}
됬다!!
결국 Spring의 formData 처리 방식에 대한 무지로 인한 내 잘못이 컸다.
이 과정을 통해 알게된 것들을 간단하게 정리하자면
@RequestBody는 formdata 방식을 처리하는데 적합하지 못하다.
formdata 요청 처리는 @RequestPart 어노테이션에 키 값을 지정해주어 처리하는 게 좋다.
요청 타입 설정도 컨트롤러에서 가능하다. (매핑 어노테이션에 cousumes = {MediaType.요청 타입 이름})
여기까진 마냥 좋았다. 내 집도 아니고 친구집에서 새벽까지 잠도 안자고 팀원과 같이 작업을 하다가
다음날 시원하게 늦잠까지 때려버리니 기분이 매우 상쾌했다.😀
다만 이 생각이 조금 마음에 걸렸다.
그래서 이미지 어떻게 브라우저에 띄울건데?
처음에는 뭐 별거 아니지 않을까? 라는 생각이었다.
'서버에 있는 이미지가 저장된 경로를 환경변수로 지정해서, 이미지를 받아올 수 있게 해야지'
라는 생각하며
return 타입을 Resource 타입을 준다고 생각하고 코드를 작성했다.
그 전에 서버 배포 시에 이미지 받아올 용으로 쓸 경로를 config에 설정해주었다.
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations(img_save_path);
}
이렇게 하면 서버경로+/uploads/ 밑으로 오는 요청은 img_save_path에 저장된 경로 값을 기반으로 처리한다.
img_save_path 변수는 스프링 부트에서 제공하는 @Value 어노테이션을 이용하여 환경변수의 값을 받아와주었다.
참고로 @Value 어노테이션은 전역 변수에만 사용할 수 있다.
아무튼 그래서 작성한 컨트롤러 코드는 이랫다.
@ResponseBody
@GetMapping("엔드포인트")
public ResponseEntity<Resource> showProfileImg(@PathVariable String profileImg)
throws MalformedURLException {
Path imgPath = Paths.get(img_save_path, profileImg).toAbsolutePath();
if (!Files.exists(imgPath)) {
throw new MalformedURLException("File not found: " + imgPath);
}
return ResponseEntity.ok()
.body(new UrlResource(imgPath.toUri()));
}
이런 식으로 반환을 했던 것 같다.
물론 포스트맨 테스트 중에는 200 코드로 작동은 정상적으로 하는 것을 확인했지만,
결과값에는 알 수 없는 바이너리 값들만 브라우저에도, 포스트맨에도 뜰 뿐이었다.
그래서 프론트 팀 쪽에서 이 값을 파싱해줄 수 있나? 라는 생각을 했다가
너무 말도 안된다고 생각이 들었다.
보통 이미지 url로 들어가면 이미지 자체가 나오지 바이너리 값이 나오는 건 아니니까..
그래서 파싱 과정을 거쳐주었다.
파싱까지 거친 코드 ⬇️
@ResponseBody
@GetMapping("엔드포인트")
public ResponseEntity<Resource> showProfileImg(@PathVariable String profileImg)
throws MalformedURLException {
Path imgPath = Paths.get(img_save_path, profileImg).toAbsolutePath();
if (!Files.exists(imgPath)) {
throw new MalformedURLException("File not found: " + imgPath);
}
Resource resource = new UrlResource(imgPath.toUri());
String contentType;
try{
contentType = Files.probeContentType(imgPath);
} catch (Exception e) {
//자동 변환이 불가능할 경우의 기본 타입 지정
contentType = "application/octet-stream";
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, contentType)
.body(resource);
}
이렇게 API를 최종적으로 구성해주니, 브라우저 창에서도 이미지가 잘 나오는 것을 볼 수 있었다.
추가로 이미지 핸들링을 위해 사용한 핸들러 코드는 이렇다.
public String createImgURL(MultipartFile image) throws IOException {
String filename = image.getOriginalFilename();
String newUrl = "절대 경로" + filename;
image.transferTo(new File(newUrl));
return newUrl;
}
}
프로필 이미지 따위? 이미지 님이 니 친구냐?
이미지 처리를 너무 쉽게 보고 아예 제대로 된 사전 조사나 준비조차 안하고 들어가니 약간의 시행착오가 있었지만,
나름 문제를 해결하고 이렇게 복기해보니 이 기능을 개발하며 배운 부분이 꽤 많다는 사실을 깨달았다.
결론은 개발하기 전에 사전으로 이론에 대해 정확히 아는 것도 매우 중요하다는 것을 가장 크게 느꼈던 것 같다.
개발하면서 찾아보는 것도 좋지만, 너무 조급하게 개발을 시작하려 했던 내 잘못이 있는 것 같기도 했다.
다른 선배님한테 조언을 구해보니 아예 내가 이미지를 저장하려는 방식과 다르게 처리하신 선배님도 계셨다.(코드까지 공유해주신..👍)
파일 저장 용량 제한 등의 구현은 제대로 못한채 하드코딩한 느낌이 확 들었지만..
아무튼 나름 즐거운 경험이었다.😀
혹시나 이 기나 길고 영양가 없을지 모르는 글을 끝까지 읽어주신 분들은
정말 감사할 따름입니다.
보신 분들 모두 좋은 하루 보내셨으면 하네요!