📢 들어가며
이번 포스팅은 지난 포스팅에서 이어진다.
지난 포스팅에선 CRUD API를 적용하고,
지도에 리뷰를 출력하고 오버레이를 추가해 보았다.
이번 포스팅에선 사진 파일 업로드/다운로드(화면에 출력) 기능을 구현해볼 것이다.
참고로, 디테일한 부분은 포스팅에서 다소 빠졌을 수도 있다...
자세한 건 깃헙코드를 참고!
모든 소스코드는 깃헙에서 확인할 수 있다.
🍜 파일 저장 위치 선정
이미지 파일을 저장하는데엔 크게 두가지 방법이 있다.
- 파일 시스템 스토리지(storage) 에 업로드 하는 방법. DB엔 해당 파일 관련 정보(크기, 이름, 업로드 시간 등)를 저장.
- DB에 바이너리 형식으로 파일 자체를 저장하는 것.
어떤 방법을 선택해야할까 고민하다가 좋은 레퍼런스를 찾았다.
파일 저장 방식은 그 파일을 어떤 용도로 사용하느냐에 따라 다르게 저장하면 된다고 한다.
웹 페이지용 이미지를 저장하는 경우 파일 시스템에 저장하는 것이 가장 좋다.
웹 서버에서 이미지 파일을 빠르게 찾아 방문자에게 파일을 보낼 수 있기 때문이다.
이미지 파일을 DB에 저장하는 경우, 이 이미지가 도달하는 단계가 크게 증가하므로 이미지 다운로드 속도가 느려진다.
또한, 더 많은 서버 리소스를 사용한다.
DB에 파일을 저장하는 경우는, 외부에서 사용되지 않을 직원 도는 고객의 얼굴 사진과 같은 민감한 이미지일 때라고 한다.
우리 팀에서도 파일 업로드/다운로드 기능을 구현할 때 blob으로 DB에 바로 저장되도록 구현했었는데,
그 이유가 이미지파일이 아니고, 보안이 필요한 파일이라 그랬던 것 같다.
결론적으로 나는, 파일 스토리지에 이미지를 저장하고, 이미지 정보만 DB에 저장시키기로 했다!
🍜 드래그 앤 드롭을 통한 파일 업로드 기능 구현
기존에 구현했던 UI에 맞춰 파일 업로드 기능을 구현해보자.
파일 업로드 부분을 이렇게 크게 만든 이유는,
드래그 엔 드롭 (drag and drop) 으로도 파일 업로드가 가능하게 하고 싶어서였다.
드래그 엔 드롭을 구현하기 위해 HTML DOM 이벤트인 drop 을 사용할 것이다.
마우스로 끌고 클릭을 뗄 때 발생하는 이벤트이다.
이 이벤트는 draggable 하다는 전제 하에 동작하기 때문에, dragover 이벤트와 함께 사용해야한다.
사진 업로드 부분을 아래와 같이 수정해주자!
<div class="image-area" v-if="!isDisabledInput">
<div class="file-input-wrapper"
@dragover="onDragOver"
@drop="onDrop"
>
사진을 업로드 해주세요
</div>
</div>
// ...data
fileList: [],
// ... methods
onDrop(e) {
e.preventDefault();
this.fileList.push(...e.target.files);
console.log(this.fileList);
},
onDragOver(e) {
e.preventDefault();
},
frontend/src/components/SideBar.vue
dragOver
은 해당 영역에서 드래그를 하고 있을 때 발생하는 이벤트인데,
구현할 기능이 없으니 prevent(기존 기능 막기) 해준다.
drop
도 마찬가지로 prevent 해준다.
사실 처음엔 prevent 해주지 않았었는데, 그러자 change
이벤트가 같이 발생되어 기능이 두번 수행되는 버그가 생겼다.
별개의 이벤트로 동작해야하기 때문에 drop 이벤트가 change와 함께 동작하지 않도록 prevent 해 줄 것이다.
drop
에선 fileList
라는 상태데이터에 이벤트로부터 받은 파일리스트를 push 해주었다.
콘솔을 찍어 확인해보자 드래그 앤 드롭으로 파일 정보를 가져오는 것을 확인할 수 있었다! 🎉
🍜 클릭을 통한 파일 업로드 기능 구현
드래그 앤 드롭으로 파일을 충분히 업로드 할 수 있을 듯 하니,
이젠 클릭으로도 파일 업로드가 가능하게 해보자.
영역을 클릭했을 때 File Selector 화면이 출력되게 해야한다.
이를 위해선 <input type="file" />
을 써야한다.
따라서 영역 내에 input
을 꽉 채우고 투명하게 만들어서 div
만 존재하는 것 처럼 보이게 할 것이다.
<div class="image-area" v-if="!isDisabledInput">
<div class="file-input-wrapper"
@dragover="onDragOver"
@drop="onDrop"
>
<input
class="file-input"
type="file"
accept="image/*"
@change="onChangeFiles"
multiple
/>
사진을 업로드 해주세요
</div>
</div>
// ... methods
onChangeFiles(e) {
this.fileList.push(...e.target.files);
console.log(this.fileList);
},
// ...style
> .image-area {
// ...
> .file-input-wrapper {
position: relative;
// ...
> .file-input {
cursor: pointer;
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
opacity: 0;
}
}
}
frontend/src/components/SideBar.vue
부모 요소에 position: relative
를 주고,
자식 요소를 position: absolute
로 설정하고 모든 범위를 0으로 주면
부모 요소에 딱 맞게 자식 요소를 가득 채울 수 있다.
input
을 click 했을 때가 아니라, File Selector 창에서 '확인'을 눌렀을 때 선택한 파일 정보를 가져오기 위해@chage
이벤트를 활용해 파일 정보 fileList
에 push
해줬다.
콘솔 확인해보니 파일 정보를 정상적으로 가져오는 걸 확인할 수 있었다! 🎉
🍜 파일 업로드 시 UI 변경
fileList
를 UI에서 확인하고, 선택한 파일을 삭제할 수 있도록 해보자.
먼저 삭제용 엑스 아이콘을 추가해줄것이다.
나는 추가용 플러스 아이콘도 추가해주었다.
import Vue from 'vue';
// 0. 편의를 위해 아이콘은 알파벳 순서대로 추가하자.
// 1. 설치했던 fontawesome-svg-core 와 vue-fontawesome
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
// 2. 설치했던 아이콘 파일에서 원하는 아이콘 불러오기
import {
faAngleLeft,
faAngleRight,
faLocationDot,
faTimes,
faPlus
} from "@fortawesome/free-solid-svg-icons";
// 3. 불러온 아이콘을 라이브러리에 담기
library.add(faAngleLeft);
library.add(faAngleRight);
library.add(faLocationDot);
library.add(faTimes);
library.add(faPlus);
// 4. fontawesome 아이콘을 Vue 템플릿에서 사용할 수 있도록 등록
Vue.component("FontAwesomeIcon", FontAwesomeIcon);
src/common/Icons.js
faTimes
와 faPlus
아이콘을 추가해줬다.
이제 아래에서 이 아이콘을 활용해보자.
<div class="image-area" v-if="!isDisabledInput">
<div class="file-input-wrapper"
@dragover="onDragOver"
@drop="onDrop"
v-if="!fileList.length"
>
<input
class="file-input"
type="file"
accept="image/*"
@change="onChangeFiles"
multiple
/>
사진을 업로드 해주세요
</div>
<div class="file-list" v-else>
<ul v-if="fileList.length > 0">
<li
v-for="(file, idx) in fileList"
:key="idx"
>
{{file.name}}
<FontAwesomeIcon
class="delete-file-icon"
icon="times"
@click="deleteFile(idx)"
/>
</li>
</ul>
<ul>
<li class="file-btn-area">
<BButton
size="sm"
@click="deleteAllFile"
class="file-delete-btn"
>
전체 삭제
<FontAwesomeIcon icon="times" />
</BButton>
<BButton
size="sm"
class="file-add-btn"
>
추가
<FontAwesomeIcon icon="plus" />
<input
class="file-input"
type="file"
accept="image/*"
@change="onChangeFiles"
multiple
/>
</BButton>
</li>
</ul>
</div>
</div>
// ... methods
deleteFile(idx) {
this.fileList.splice(idx, 1);
},
deleteAllFile() {
this.fileList = [];
},
onChangeFiles(e) {
this.fileList.push(...e.target.files);
},
onDrop(e) {
this.fileList.push(...e.dataTransfer.files);
},
// ... style
> .image-area {
padding: 0 10px;
> .file-input-wrapper, .file-list {
position: relative;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.3rem;
border: 5px dashed rgb(255, 255, 255, 0.5);
border-radius: 10px;
height: 250px;
background-color: rgb(255, 255, 255, 0.5);
overflow-y: auto;
flex-direction: column;
> ul {
padding: 0 10px;
> li {
list-style: none;
font-size: 1rem;
word-break: break-all;
> .delete-file-icon {
cursor: pointer;
padding: 10px 0 0 5px;
}
}
> .file-btn-area {
display: flex;
padding-top: 10px;
> .file-delete-btn {
display: flex;
align-items: center;
font-size: 0.7rem;
margin-right: 5px;
}
> .file-add-btn {
display: flex;
align-items: center;
font-size: 0.7rem;
position: relative;
> .file-input {
cursor: pointer;
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
opacity: 0;
}
}
}
}
> .file-input {
cursor: pointer;
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
opacity: 0;
}
}
}
fileList
가 채워진 상태라면,
'사진을 업로드 해주세요' 문구가 아닌,
파일 제목 리스트를 보여주게했고, 파일 제목 옆엔 times 아이콘을 배치했다.
times 아이콘을 클릭하면 splice
로 fileList
의 해당 인덱스 파일이 삭제되도록 구현해보았다.
파일을 여러개 업로드했을 때 하나하나 지우면 굉장히 귀찮을 것 같아서,
전체 삭제 버튼도 구현해보았다.
추가한 plus 버튼은 fileList 에 또다른 파일을 추가하고 싶을 경우에 쓸 '추가' 버튼 옆에 배치해줬다.
파일리스트가 출력되고 정상적으로 추가/삭제되는 걸 확인할 수 있었다! 🎉
CSS는 딱히 크게 설명할 부분이 없는 듯 하지만,
신경 쓴 부분만 짚고 넘어가자면...
일단 파일 제목이 긴 경우를 대비해 li
에 word-break: break-all;
를 추가해
파일이름이 영역 밖으로 넘어가는 상황을 방지했다.
그리고 첨부된 파일이 많을 때를 대비해 file-input-wrapper
클래스에 overflow-y: auto;
를 추가했다.
파일 수가 많아 영역 밖으로 글자가 넘치는 경우 스크롤이 생길 것이다.
스크롤은 지난 포스팅에서 CSS로 스타일을 보정해준 적이 있는데,
어플리케이션 전체가 아닌 textarea
부분에만 적용되도록 구현되어 있었다.
그래서 스크롤 스타일 CSS를 App.vue 로 옮겨 주었다.
#app {
// ...
/* width */
::-webkit-scrollbar {
width: 10px;
}
/* Track */
::-webkit-scrollbar-track {
background: #f1f1f1;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
}
frontend/src/App.vue
🍜 파일 저장 API 구현하기
🍥 Spring Boot Server
리뷰가 저장될 때, 첨부된 파일도 같이 저장되도록 구현해보자.
구현 전에 로직(이라기 보단 주의사항...)을 간단히 정리해보았다.
- '/api/review/saveReview' API 가 동작할 때 파일도 함께 Param 데이터에 붙어야한다.
- 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 param은 List 형식이어야한다.
- 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 시스템에 이름이 "리뷰 ID"인 디렉토리 하위에 첨부된 파일이 저장되어야한다.
- 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 정보를 저장하는 DB를 따로 CREATE 해야한다.
- 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 정보를 저장하는 DB는 "리뷰 ID"를 외래키로 가지고 있어야한다.
- 각 파일의 삭제를 위해 파일 DB엔 PRIMARY 키도 존재해야한다.
그리고 스프링 부트 가이드 에 파일 업로드 기능을 구현하는 튜토리얼이 있길래, 참고 했다.
먼저, 주의사항 1번을 고려하여 ReviewDTO
에 files
를 추가했다.
public class ReviewDTO {
// ...
List<MultipartFile> files;
// ...
public List<MultipartFile> getFiles() {
return files;
}
public void setFiles(List<MultipartFile> files) {
this.files = files;
}
}
src/main/java/com/map/restaurant/good/dto/ReviewDTO.java
일단 가이드를 따라 파일 타입을 MultipartFile
이라고 선언했는데...
MultipartFile 이란 무엇일까?
🚀 MultipartFile 이란?
스프링 공식문서 에 따르면,
MultipartFile 이란 인터페이스로,
Multipart 요청에서 수신된 업로드된 파일의 표현이다.
파일 내용을 메모리에 저장되거나 일시적으로 디스크에 저장된다.
이 임시 저장소는 요청 처리가 끝나면 지워진다.
..라고 설명하고 있다.
대충 메모리 등에 임시 저장되는 Multipart request(요청) 로 보내진 파일 타입이라고 이해하면 될 것 같다.
여기서 Multipart 란 무엇일까?
Multipart란,
직역하면 Multi(여러) part(부분)이라는 뜻으로,
하나 이상의 서로 다른 데이터 집합이 하나의 body 에 결합되어 있는 것을 말한다.
여기서 말한 body 는 HTTP 의 body 부분을 의미한다.
풀어 설명하자면...
UI 부분에서 구현했듯이 파일 첨부를 위해서 우린 <input type="file" >
HTML 태그를 사용했다.
HTML input
요소는 보통 form
요소와 함께 사용된다.form
요소는 input
데이터 집합을 서버에 submit
(전송) 할때 주로 사용된다.
여기서 input
의 타입은 문자열이 될 수도 있고, 파일이 될 수도 있고, 숫자가 될 수도 있다.
즉, 여러 타입의 데이터가 form
에 의해 결합되어 전송된다는 것인데,
이를 Multipart 라고 부른다.
HTTP 로 전송할 때 Body 에 Multipart 타입의 데이터들을 넣고
HTTP Header Content-type 필드에 multipart/form-data
라고 명시해 요청/응답을 주고받을 수 있다.(자세히)
결론적으로, MultipartFile 은 Multipart HTTP Request 로 보내진, 파일용 Spring 참조 타입이다.
이제 MultipartFile
에 대해 이해했으니,
본격적으로 컨트롤러를 구현해보자.
@RestController
@RequestMapping("/api/review")
public class ReviewCtrl {
@Autowired
private ReviewDAO reviewDAO;
@Autowired
private ReviewService reviewService;
@Autowired
private FileService fileService;
@PostMapping("/saveReview")
public void saveReview(ReviewDTO reviewDTO) {
reviewService.saveReview(reviewDTO);
fileService.deleteFiles(reviewDTO);
fileService.saveFiles(reviewDTO);
}
@GetMapping("/getReviews")
public List<ReviewDTO> getReviews() {
return reviewService.getReviews();
}
@DeleteMapping("/deleteReview")
public void deleteReview(@RequestBody ReviewDTO reviewDTO) {
reviewService.deleteReview(reviewDTO);
}
}
src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java
극단적으로 간단해진 코드를 볼 수 있다ㅋㅋ
컨트롤러에선 요청/응답을 처리하기만하고 주요 기능은 서비스에 구현하는 아키텍처가 좋다고 하길래
파일 첨부 로직을 추가하면서 리뷰와 파일 로직 각각을 Service 로 분리해 구현했다.
여기서 주목해야할 점은, saveReview API의 @RequestBody 가 사라졌다는 점이다.
클라이언트에서 API를 호출하는 부분에서 설명할 테지만,
파일 데이터를 전송하기 위해 formData 로 파라미터를 감싸 전송할 예정이다.
@RequestBody 는 HTTP 요청의 BODY 에 담긴 JSON 파라미터를 JAVA 객체로 파싱할 때 사용 가능하기 때문에,
formData 형식은 인지할 수 없다.
이 부분에서 삽질을 엄청나게 많이 했는데, 그냥 @RequestBody 를 제거하면 된다.
처음엔 엥? 어노테이션 제거하고도 파라미터가 전달이되나? 했는데,
정확히는 @ModelAttribute 를 사용하는 것이다. 그리고 이 @ModelAttribute 가 생략이 가능하기 때문에
어노테이션 없이 사용이 가능한 것이다.
@ModelAttribute 는 formData 같은 객체 형태로 넘어온 파라미터를 JAVA 객체로 매핑 시켜주는 역할을 한다.
때문에 DTO에 반드시 setter가 있어야한다.
참고로 @RequestBody는 reflection(반사, 객체를 통해 클래스의 정보를 분석해 내는 프로그램 기법)으로 setter 없이 JSON을 JAVA 객체로 파싱시켜줄 수 있다.
🚀 Service 와 ServiceImpl
서비스 기능 구현에 앞서, 궁금한 점이 생겼다.
공식 가이드에선 서비스 "인터페이스"가 구현되어 있었다.
공식 가이드 외에도 여러 Spring Boot 예제를 보면 서비스와 서비스 인터페이스 구조로 구현된 코드를 자주 발견할 수 있다.
여기서 든 의문점이 Service, ServiceImpl 구조를 꼭 써야할까? 였다.
지금 근무 중인 팀에는 Service 를 구현하지 않고, 바로 컨트롤러에 기능을 구현하고 있다.
내용이 길고 복잡한 경우엔 Service를 따로 두고 구현하지만,
보통은 mapper interface 로 값을 바로 가져오는 CRUD 기능이기 때문에 Service 를 많이 찾을 수 없다.
그리고 Service 의 인터페이스는 더더욱 찾을 수 없다. 거의 전무하다.
뭐가 정답인지 알 수 없어 구글링해보았다.
일단 Service 를 쓰는 이유에 대해서 stackoverflow 에 나와있었다.
요약하자면,
단순 CRUD 에서 항상 Service 를 사용할 필요는 없지만,
단일 책임 원칙을 준수하기 위해선,
컨트롤러는 단순히 요청만 처리해야하고,
서비스 계층은 컨트롤러가 수신한 테이터에 대한 모든 기능을 수행해야한다고한다.
ServiceImpl(서비스 인테페이스 구조)을 꼭 써야하는지에 대한 좋은 글도 많이 찾을 수 있었다.
요약하자면,
비즈니스 로직은 요청사항에 따라 얼마든지 변경될 수 있고,
이를 대응하기 위해 인터페이스를 만들어두면 다양한 구현체를 둘 수 있어 유연한 개발을 할 수 있다.
하지만 개발을 하다보면 절차지향적인 코드가 개발되기도 하고,
보통 구현체를 여러개 두지 않고 일대일로 구현되는 경우가 많기 때문에
인터페이스가 반드시 필요한 것은 아니다.
💡절차 지향적 코드(프로그래밍)
순차적인 처리가 중요시 되며 프로그램 전체가 유기적으로 연결되도록 만드는 프로그래밍 기법.
컴퓨터의 처리구조와 유사해 실행속도가 빠름.
유지보수가 어려움.
실행 순서가 정해져 있으므로 코드의 순서가 바뀌면 동일한 결과를 보장하기 어려움.
그래서 나는 서비스는 두되, 인터페이스는 구현하지 않는 방향으로 진행하기로 했다!
service
패키지를 파고 Review.java
파일을 파준 뒤,
아래와 같이 구현했다.
@Service
public class ReviewService {
@Autowired
private ReviewDAO reviewDAO;
@Transactional
public void saveReview(ReviewDTO reviewDTO) {
String id = reviewDTO.getId();
if (id == null || id.isEmpty()) {
String uuidStr = UUID.randomUUID().toString();
reviewDTO.setId(uuidStr);
}
reviewDAO.saveReview(reviewDTO);
}
public List<ReviewDTO> getReviews() {
return reviewDAO.getReviews();
}
@Transactional
public void deleteReview(ReviewDTO reviewDTO) {
String id = reviewDTO.getId();
reviewDAO.deleteReview(id);
}
}
src/main/java/com/map/restaurant/good/service/ReviewService.java
기존 Controller 에 구현된 내용을 Service 로 옮겼을 뿐이다.
그 중 신경 쓴 부분은 @Transactional
어노테이션이다.
🚀 트랙잭션
- DB에 접근하는 작업의 최소 단위
🚀 트랜잭션의 특징
- 원자성
- 트랙잭션이 DB에 모두 반영되던가, 아니면 전혀 반영되지 않아야한다.
- 일관성
- 트랜잭션의 작업 처리 결과가 항상 일관성 있어야한다.
- 독립성
- 어떤 트랜잭션도, 다른 트랜잭션의 연산에 끼어들 수 없다.
- 지속성
- 트랜잭션이 성공적으로 완료되었을 경우, 결과는 영구적으로 반영되어야한다.
saveReview
API에 리뷰를 저장하고 파일 정보를 DB에 저장하는 로직이 함께 수행된다.
각 기능 중 하나에 문제가 발생했다고 해보자.
리뷰를 DB에 저장하는 데에는 성공 했으나,
파일 정보를 DB에 저장하려고 하니 DB 컬럼이 존재하지 않는 에러가 발생했다면?
리뷰는 DB에 잘 저장되었겠지만 해당 리뷰의 이미지 파일은 오류로 인해 저장되지 않는다.
파일 또한 리뷰의 한 구성이기 때문에 오류가 생긴다면 리뷰 역시 저장되지 않는 것이 맞다.
이럴 때 트랜잭션을 사용할 수 있다.
트랜잭션을 사용하면 원자성 덕분에
두 로직 중 하나라도 실패한다면 롤백되게 할 수 있다.
@Transactional
어노테이션은 클래스, 메소드 상단에 붙여줄 수 있다.
직접 객체를 만들 필요 없이 선언만으로 관리를 용이하게 해주기 때문에 선언적 트랜잭션이라고도 한다.
추가로,@Transactional
어노테이션 외에 트랜잭션을 선언할 수 있는 방법이 있다.TransactionTemplate
을 활용하는 것이다.
내가 근무 하고 있는 팀에선 @Transactional
이 아닌 TransactionTemplate
을 사용하고 있다.
이유는 아마 Service 를 사용하고 있지 않기 때문인 듯 하다ㅋㅋ
서비스가 아닌 컨트롤러에서 @Transactional 을 쓰는 건 권장하지 않는 구조라 그런게 아닐까 싶다.(자세히)
TransactionTemplate
은 아래와 같이 사용해줄 수 있다.
@Component
class Sample {
private final TransactionTemplate transactionTemplate;
public Sample(TransactionTemplate transactionTemplate) {
this.transactionTemplate = transactionTemplate;
}
void execute() {
String result = transactionTemplate.execute(new TransactionCallback<String>() {
@Override
public String doInTransaction(TransactionStatus status) {
return "";
}
});
}
}
반환값이 없는 메소드라면, TransactionCallback
이 아닌 TransactionCallbackWithoutResult
을 사용해주면된다.
TransactionTemplate
은@Transactional
의 메서드 내에 지정이 불가하다는 단점과
self invocation에서 트랙션이 불가하다는 단점을 해결해줄 수 있지만,
오래된 추상클래스이기 때문에 람다 표현식을 사용할 수 없다는 단점을 가지고 있다.
💡 self invocation
A 메소드에서 같은 객체의 B 메소드를 호출하는 것
ReviewService.java
와 마찬가지로 FileService.java
도 service
패키지에 생성하고 아래와 같이 구현했다.
@Service
public class FileService {
private Path imgDirPath;
@Value("${file.storage.imgDir}")
private String imgDir;
@PostConstruct
public void init() {
this.imgDirPath = Paths.get(imgDir);
}
@Autowired
private FileDAO fileDAO;
@Transactional
public void saveFiles(ReviewDTO reviewDTO) {
List<MultipartFile> files = reviewDTO.getFiles();
String reviewId = reviewDTO.getId();
if (files == null || files.isEmpty()) {
return;
}
try {
if (! Files.exists(imgDirPath)) {
Files.createDirectories(imgDirPath);
}
Path reviewImgDirPath = imgDirPath.resolve(
Paths.get(reviewId)).normalize().toAbsolutePath();
if (! Files.exists(reviewImgDirPath)) {
Files.createDirectories(reviewImgDirPath);
}
} catch (IOException e) {
throw new ApiException("Failed to make index dir", e);
}
for (MultipartFile file : files) {
saveFile(file, reviewId);
}
}
@Transactional
public void saveFile(MultipartFile file, String reviewId) {
try {
String originFilename = file.getOriginalFilename();
long fileSize = file.getSize();
String contentType = file.getContentType();
if (file.isEmpty() || originFilename == null) {
throw new ApiException("Failed to store empty file.");
}
Path destinationFile = imgDirPath
.resolve(Paths.get(reviewId))
.resolve(Paths.get(originFilename))
.normalize()
.toAbsolutePath();
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
}
String fileId = UUID.randomUUID().toString();
fileDAO.saveFile(fileId, reviewId, originFilename, fileSize, contentType);
} catch (IOException e) {
throw new ApiException("Failed to store file", e);
}
}
}
src/main/java/com/map/restaurant/good/service/FileService.java
먼저 Path 타입인 imgDirPath
를 선언해준다.
이것은 init()
부분에서 application.propertiese
에서 선언된 file.store.imgDir
값으로 채워질 것이다.
file.storage.imgDir=src/main/resources/static/upload-dir
src/main/resources/application.properties
file.store.imgDir
의 값은 이미지 파일이 저장될 디렉토리 경로이다.
나는 upload-dir
라고 정의해줬다. (스프링 공식 튜토리얼에서 이 이름으로 정의해 줬길래 따라했다.)
참고로 서버의 이미지 자원을 사용하기 위해선 resources/static 에 이미지가 저장되어 있어야한다.
(다른 디렉토리에도 넣어봤는데 404만 뜨더라...)
그런데 나중에 배포를 하게 되면 클라이언트 코드가 빌드되면서 resources 폴더가 clear 될 것이기 때문에
서버의 resources/static
폴더에 유동적인 이미지를 저장하는 것은 옳지 않다.
그렇다고 로컬 디렉토리에 저장하려니 브라우저 보안상 제약되는 부분이 많았다.
때문에 나는 개발 환경에서만 서버에 저장하고 배포 시 경로를 변경해주기로 했다.
🚨 참고
FileSizeLimitExceededException 에러가 발생하면
application.properties 에 아래 내용을 선언하자!
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
설정된 파일 크기를 초과하면 발생하는 에러로, 스프링 기본 설정이 1MB이다.
이 크기를 늘려주는 내용이다.
Paths.get
메소드를 활용해서 imgDirPath
값을 구한다.Paths.get
에 인자로 경로 문자열을 넘기면되는데,
나는 달랑 디렉토리 이름을 넘겼기 때문에 상대 경로값이 채워지게 된다. (절대 경로로 구할 수도 있다.)
여기서 주의해야할 부분은 @PostConstruct
어노테이션이다.
사실 스프링 부트 공식 튜토리얼에선 application.properties
에 사용된 값을 쓰기 위해 @ConfigurationProperties
를 사용했다.
나는 굳이 @ConfigurationProperties
를 사용할 필요가 있을까? 싶어서 @Value
를 쓰고 싶었다.
왜냐면 @ConfigurationProperties
사용 시, 따로 getter, setter를 구현해줘야하고,
prefix 로 어떤 application.properties
의 속성을 여러개 가져와 사용 가능한 기능이기때문에,
단순히 file.store.imgDir
만 필요했던 지금 상황에선 키로만 속성값을 가져오는 @Value
가 적절하다고 생각했다.
🚀 @Value vs @ConfigurationProperties
@Service
public class FileService {
private final Path imgDirPath;
@Value("${file.storage.imgDir}")
private String imgDir;
public FileService() {
this.imgDirPath = Paths.get(imgDir);
}
// ...
처음엔 @Value
를 쓰기 위해 위와 같이 구현해 줬었는데,
NullPointerException이 발생했다.
에러 로그를 보니 초기화를 해주지 않았다고 하는데,
@Value
를 붙여 줬으니 값이 있어야하는 상태여야하지 않나? 라고 생각했다.
원인은 타이밍 때문이다.
stackoverflow 답변에 따르면,@Value
는 객체가 생성되고 난 후에 주입된다고 한다.
즉, FileService
가 생성되고 난 뒤에! imgDir
값이 채워진다.
이 때문에 생성자 부분에서 Paths.get(imgDir)
를 하면 null
값으로 Paths.get
을 하게 되는 것이다.
따라서 아래와 같이 수정해줘야한다.
바로 생성자! 에서 @Value
값을 주입하는 것이다.
@Service
public class FileService {
private final Path imgDirPath;
public FileService(@Value("${file.storage.imgDir}") String imgDir) {
this.imgDirPath = Paths.get(imgDir);
}
// ...
사실 이 방법을 알아내기까지 꽤 삽질을 많이 했는데,
그 과정에서 친구가 @PostConstruct
를 사용하는 방법도 있다고 알려줬다.
@Service
public class FileService {
private Path imgDirPath;
@Value("${file.storage.imgDir}")
private String imgDir;
@PostConstruct
public void init() {
this.imgDirPath = Paths.get(imgDir);
}
// ...
@PostConsturct
는 말 그대로 '생성 이후' 라는 뜻이다.
즉, 이 어노테이션이 붙은 메소드는 생성자 이후에 냅다 실행이 된다는 것.
따라서 생성이 된 이후에 실행될테니 @Value
값이 주입된 상태이고,
NullPointerException 없이 imgDirPath
가 채워질 것이다!
saveFiles
코드를 하나하나 뜯어보자.
@Transactional
public void saveFiles(ReviewDTO reviewDTO) {
List<MultipartFile> files = reviewDTO.getFiles();
String reviewId = reviewDTO.getId();
if (files == null || files.isEmpty()) {
return;
}
try {
if (! Files.exists(imgDirPath)) {
Files.createDirectories(imgDirPath);
}
Path reviewImgDirPath = imgDirPath.resolve(
Paths.get(reviewId)).normalize().toAbsolutePath();
if (! Files.exists(reviewImgDirPath)) {
Files.createDirectories(reviewImgDirPath);
}
} catch (IOException e) {
throw new ApiException("Failed to make index dir", e);
}
for (MultipartFile file : files) {
saveFile(file, reviewId);
}
}
먼저, reviewId
를 이름으로 갖는 디렉토리 생성을 위해, reviewDTO.getId()
해온다.
첨부된 파일이 없는 경우엔 그래도 메소드를 빠져나오게 했다.
try 부분에서,imgDirPath
즉, 이미지 파일 전체를 보관하는 upload-dir
폴더가 없다면 Files.createDirectories
로 생성해준다.
resolve
메소드는 경로를 합해주는 메소드이다. imgDirPath
와 reviewId
를 합하여 현재 리뷰의 파일들이 저장될 경로를 설정한다.
여기서 normalize()
는 경로를 표준화해주는 메소드이다. 잘못된 문자가 있는지 확인하고 공백 등을 제거해준다.toAbsolutePath()
는 말 그대로 절대 경로로 변환해주는 메소드 이다.
reviewId
를 포함한 경로 역시 존재하지 않는다면 Files.createDirectories
로 생성해준다.
여기서 예외처리 로직도 추가해줬다.
담고 싶은 메시지를 exception 에 추가할 수 있게 한다.exception
패키지를 파고 ApiException
이라는 클래스를 파서 아래와 같이 구현했다.
public class ApiException extends RuntimeException {
public ApiException(String message) {
super(message);
}
public ApiException(String message, Throwable cause) {
super(message, cause);
}
}
src/main/java/com/map/restaurant/good/exception/ApiException.java
공식 가이드에서 에러를 다루는 방법이기도 하고,
현직 우리팀에서도 이런 식으로 에러 메시지를 관리하고 있다.
폴더 생성이 완료되면, saveFile
메소드가 반복되어 실행된다.
@Transactional
public void saveFile(MultipartFile file, String reviewId) {
try {
String originFilename = file.getOriginalFilename();
long fileSize = file.getSize();
String contentType = file.getContentType();
if (file.isEmpty() || originFilename == null) {
throw new ApiException("Failed to store empty file.");
}
Path destinationFile = imgDirPath
.resolve(Paths.get(reviewId))
.resolve(Paths.get(originFilename))
.normalize()
.toAbsolutePath();
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
}
String fileId = UUID.randomUUID().toString();
fileDAO.saveFile(fileId, reviewId, originFilename, fileSize, contentType);
} catch (IOException e) {
throw new ApiException("Failed to store file", e);
}
}
file
로 부터 originFilename
, fileSize
, contentType
을 get 해온다.
이 정보들을 DB에 저장할 것이다.
reviewId
디렉토리를 포함한느 경로와 파일 이름을 합한 경로를 destinationFile
(목적파일경로) 로 선언한다.
Files.copy
로 목적경로에 파일을 생성해준다. 존재한다면 대체한다는 StandardCopyOption.REPLACE_EXISTING
옵션도 추가해줬다.
참고로 파일 컨텐트를 얻는데에 InputStream
타입을 이용하고 있다.
이를 try()
의 괄호 부분에 선언해주면
Stream 타입의 클래스들이 동작후 필요로하는 close()
메소드가 자동실행된다.
마지막으로,
파일의 키(UUID)를 생성해 saveFile
쿼리문을 실행시켰다!
CREATE TABLE tbl_file_info
(
file_id varchar(36) not null primary key,
review_id varchar(36) not null,
content_type varchar(50),
file_name varchar(100),
file_size long,
FOREIGN KEY (review_id)
REFERENCES tbl_review(id) ON UPDATE cascade ON DELETE cascade
);
파일 정보는 tbl_file_info
테이블에 저장될 것이다.
리뷰가 삭제될 때 하위 이미지 파일들도 함께 삭제 되어야하기 때문에 cascade
제약조건을 넣었다.
DAO
public interface FileDAO {
void saveFile(
@Param("fileId") String fileId,
@Param("reviewId") String reviewId,
@Param("fileName") String fileName,
@Param("fileSize") long fileSize,
@Param("contentType") String contentType
);
}
src/main/java/com/map/restaurant/good/dao/FileDAO.java
Mapper
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.map.restaurant.good.dao.FileDAO">
<insert id="saveFile">
insert into tbl_file_info (file_id, review_id, file_name, file_size, content_type)
values (#{fileId}, #{reviewId}, #{fileName}, #{fileSize}, #{contentType})
</insert>
</mapper>
src/main/resources/mappers/FileMapper.xml
🍥 Vue Client
이제 클라이언트에서 API를 호출하는 부분을 구현해보자.
파일 데이터를 Param으로 넘길 때, form
요소가 필요하다고 위에서 언급했었다.
Vue의 동적 상태 데이터를 사용하고 있기 때문에 따로 form
요소는 구현할 필요 없고,formData
를 활용할 것이다.
formData는 form
요소와 같은 기능을 하지만 key, value 로 구성된 인터페이스 형태로 되어 있을 뿐이다.
saveReview () {
process(this, async () => {
await axios.post('/api/review/saveReview', {
id: this.reviewId,
title: this.title,
address: this.address,
grade: this.grade,
review: this.review,
lon: this.$store.state.curLon,
lat: this.$store.state.curLat,
files: this.fileList
},
{
transformRequest: function (data) {
const formData = new FormData();
for (let key in data) {
const value = data[key];
if (! value)
continue;
if (key === 'files')
value.forEach(file => {
formData.append(key, file);
})
else
formData.append(key, value)
}
return formData
}
})
await ok(this, '저장 완료되었습니다.');
this.fileList = [];
await this.$store.dispatch('setReviews', this);
this.$store.commit('setInputState', true);
})
}
frontend/src/components/SideBar.vue
saveReview
API의 파라미터에 files
를 추가해줬고,transformRequest
라는 설정을 추가했다.formData
에 요청 파라미터들을 key, value 형태로 append 해준다.
💡 tansformRequest
요청 데이터를 서버로 전송하기 전에 변경할 수 있게 해주는 axios 기능.
'PUT', 'POST', 'PATCH', 'DELETE' 메소드에서만 적용 가능.
마지막 함수는 Buffer, ArrayBuffer, FormData 또는 Stream의 인스턴스 또는 문자열을 반환해야 한다.
헤더 객체를 수정할 수 있다.
정상적으로 리뷰와 함께 이미지 파일이 저장되는 걸 확인할 수 있었다! 🎉
🍜 저장된 파일 불러오기 API 구현
🍥 Spring Boot Server
저장된 파일을 불러와보자.
처음엔 getReviews
API에 파일 목록도 같이 붙여 response 할까 했지만...
파일이나 리뷰가 많으면 부하가 올 수 있을 것 같아서
- 지도에서 아이콘을 클릭했을 때, 즉, 현재 리뷰가 사이드 바에 set된 경우
- 리뷰를 저장하고 나서 갓 저장된 현재 리뷰를 사이드 바에 보여줄 때
위 두 경우에만 이미지 파일 목록을 불러오기로 했다.
즉, 한 리뷰에 대한 파일 목록만 불러오는 것이다.
Controller
@RestController
@RequestMapping("/api/file")
public class FileCtrl {
@Autowired
private FileDAO fileDAO;
@GetMapping("/getImages")
public List<FileDTO> getImages(@RequestParam String reviewId) {
return fileDAO.getImages(reviewId);
}
}
src/main/java/com/map/restaurant/good/controller/FileCtrl.java
나름 restfull 하게 file 컨트롤러도 파줬다...
Service
public List<FileDTO> getImages(String reviewId) {
return fileDAO.getImages(reviewId);
}
src/main/java/com/map/restaurant/good/service/FileService.java
DAO
List<FileDTO> getImages(@Param("reviewId") String reviewId);
src/main/java/com/map/restaurant/good/dao/FileDAO.java
Mapper
<select id="getImages" resultType="hashMap">
select *
from tbl_file_info
where review_id = #{reviewId}
</select>
src/main/resources/mappers/FileMapper.xml
🍥 Vue Client
사진 파일들은 SideBar에만 표시되긴하지만,
지도를 클릭했을 때, SideBar 에 표시가 되어야한다.
지도와 사이드바 컴포넌트 사이에 상호작용이 있어야하기 때문에,
불러온 파일 목록을 Vuex 상태에 담을 것이다.
export default new Vuex.Store({
state: {
// ...
curFileList: [],
},
mutations: {
// ...
setCurFileList: (state, images) => {
state.curFileList = images;
}
},
actions: {
// ...
async setFileList({commit, state}, that) {
await process(that, async () => {
const result = await axios.get('/api/file/getImages', {
params: { reviewId: state.curReviewId }
});
await commit('setCurFileList', result.data);
})
}
},
modules: {}
})
frontend/src/store/index.js
curFileList
라는 state
를 선언하고,mutations
부분엔 setter를, actions
부분엔 API로 부터 가져온 파일 정보를 담는 메소드를 선언한다.
사이드 바의 fileList
와 다르게 curFileList
는 오로지 DB에 저장된 파일 정보만 담고 있게 될 것이다.
setFileList
는 리뷰 아이콘을 클릭했을 때와, 리뷰가 저장되었을 때 동작해야하기 때문에
각각 아래 위치에 추가해주자.
this.olMap.on('click', async (e) => {
const existFeature = that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
this.$store.commit('setCurTitle', feature.get('title'));
this.$store.commit('setCurAddress', feature.get('address'));
this.$store.commit('setCurGrade', feature.get('grade'));
this.$store.commit('setCurReview', feature.get('review'));
this.$store.commit('setCurReviewId', feature.get('reviewId'));
this.$store.commit('setInputState', true);
this.$store.dispatch('setFileList', this); // 추가!
return true;
})
frontend/src/components/MainMap.vue
반드시 리뷰 상태데이터가 set되고 나서 실행되어야하기 때문에 가장 마지막에 추가해준다.
왜냐하면 getImages
API의 파라미터가 curReviewId
이기 때문이다.
saveReview () {
process(this, async () => {
await axios.post('/api/review/saveReview', {
// ...
})
await ok(this, '저장 완료되었습니다.');
this.fileList = [];
await this.$store.dispatch('setReviews', this);
await this.$store.dispatch('setFileList', this); // 추가!
this.$store.commit('setInputState', true);
})
}
frontend/src/components/SideBar.vue
이 역시 현재 리뷰가 set 된 다음에 파일 리스트가 채워져야하기 떄문에await this.$store.dispatch('setReviews', this);
이후에 추가한다.
🍜 불러온 이미지 파일 UI에 출력
채워진 curFileList
가 사용될 곳은 두 군데이다.
- 리뷰가 읽기 모드일 때 슬라이드 형식으로 보여지기
- 리뷰를 수정할 때 파일 목록에 리스트로 출력
🍥 Carousel 형태로 이미지 출력
Bootstrap Carousel로 이미지를 출력할 것이다.
슬라이드 형태로 보이는 이미지이다.
<div class="image-area" v-if="!isDisabledInput">
</div>
<div class="slide-image-area" v-else>
<BCarousel
controls
indicators
v-if="curFileList.length > 0"
>
<BCarouselSlide
v-for="(fileInfo, idx) in curFileList"
:key="idx"
:img-src="`${imgDirPath}/${fileInfo.review_id}/${fileInfo.file_name}`"
reloadable
/>
</BCarousel>
<div class="no-image-text" v-else>
<span>등록된 사진이 없습니다.</span>
</div>
// css
> .slide-image-area {
padding: 0 10px;
::v-deep .img-fluid {
height: 250px !important;
object-fit: cover;
}
> .no-image-text {
display: flex;
justify-content: center;
align-items: center;
font-size: 1.3rem;
border: 5px dashed rgb(255, 255, 255, 0.5);
border-radius: 10px;
height: 250px;
background-color: rgb(255, 255, 255, 0.5);
}
}
frontend/src/components/SideBar.vue
슬라이드 이미지는 isDisabledInput
상태, 즉, 읽기 모드 일때 보여져야한다.
v-if/else
로 수정 시 나타나는 이미지 목록과 이미지 슬라이드를 보이고, 사라지게 했다.curFileList
가 빈 배열일 때 역시 v-if/else
로 "등록된 사진이 없습니다" 문장이 출력되도록 했다.
BCarousel
에 controls
를 추가하면 좌우에 이동이 가능한 화살표가 생기고indicators
를 추가하면 하단에 몇번째 페이지인지 알려주는 점이 생긴다.
여기서 주목해야할 부분은 img-src
이다.imgDirPath
와 reviewId
, fileName
을 조합하여 경로를 구현했다.
처음엔 파일경로를 통째로 DB에 저장해서 가져올까 생각했는데,
만약 파일 위치가 변경된다면 곤란할 것 같아서 이렇게 구현했다.
imgDirPath
는 application.properties
부분에서도 설명했지만 추후에 변경될 예정이다.
개발 환경에서는 클라이언트와 서버의 포트가 따로 돌고 있기 때문에 서버에 저장된 파일을 가져와야한다.
즉, 클라이언트와 서버의 이미지 path를 불러오는 방법이 각각 다르고,
추후에 수정될 수 있다는 점을 고려해서common
폴더에 Config.js
라는 파일을 구현해서 서버처럼 application.properties
역할을 하게했다.
export const IMG_DIR_PATH = 'http://localhost:8083/upload-dir';
frontend/src/common/Config.js
Spring Boot 서버의 디렉토리를 바라보게 했다.
퍼온 사진이라 화질구지이지만 ^^... 서버의 resource 이미지가 UI에 출력되는 것을 확인할 수 있었다!🎉
🍥 수정용 이미지 목록 출력
<div class="image-area" v-if="!isDisabledInput">
<div class="file-input-wrapper"
@dragover="onDragOver"
@drop="onDrop"
v-if="!fileList.length && !curFileList.length"
>
<input
class="file-input"
type="file"
accept="image/*"
@change="onChangeFiles"
multiple
/>
사진을 업로드 해주세요
</div>
<div class="file-list" v-else>
<ul v-if="fileList.length > 0">
<li
v-for="(file, idx) in fileList"
:key="idx"
>
{{file.name}}
<FontAwesomeIcon
class="delete-file-icon"
icon="times"
@click="deleteFile(idx)"
/>
</li>
</ul>
<ul v-if="curFileList.length > 0">
<li
v-for="(file, idx) in curFileList"
:key="idx"
>
{{file.file_name}}
<FontAwesomeIcon
class="delete-file-icon"
icon="times"
@click="addDeletedFileId(idx)"
/>
</li>
</ul>
<ul>
<li class="file-btn-area">
<BButton
size="sm"
@click="deleteAllFile"
class="file-delete-btn"
>
전체 삭제
<FontAwesomeIcon icon="times" />
</BButton>
<BButton
size="sm"
class="file-add-btn"
>
추가
<FontAwesomeIcon icon="plus" />
<input
class="file-input"
type="file"
accept="image/*"
@change="onChangeFiles"
multiple
/>
</BButton>
</li>
</ul>
</div>
</div>
<div class="slide-image-area" v-else>
// ...
</div>
// methods
deleteFile(idx) {
this.fileList.splice(idx, 1);
},
deleteAllFile() {
this.curFileList.forEach((file, idx) => {
this.addDeletedFileId(idx);
})
this.$store.commit('setCurFileList', []);
this.fileList = [];
},
addDeletedFileId(idx) {
this.deletedFileIds.push(this.curFileList[idx].file_id);
const newCurFileList = this.curFileList.reduce((arr, item, i) => {
if (i !== idx)
arr.push(item);
return arr;
}, [])
this.$store.commit('setCurFileList', newCurFileList);
},
frontend/src/components/SideBar.vue
fileList
와 동일하게 curFileList
도 출력시킨다.fileList
와 curFileList
의 차이점은 fileList 는 File 객체를 가지고 있지만,curFileList
는 DB에서 가져온 정보만을 가지고 있기 때문에 서로의 속성/키(attr)가 다르다는 것이다.
여기서 주목해야할 점은 삭제 로직이다.fileList
는 파일을 삭제했을 때 배열의 요소를 비우면 되지만,curFileList
는 DB에 있는 파일 정보를 삭제해야한다.
deletedFileIds
라는 데이터를 하나 파고, 삭제하겠다고 클릭한 파일들의 ID 값을 채워 넣자.
수정 역시 saveReview
API에서 진행되기 때문에 saveReview
파라미터에fileIds
를 추가한다.
saveReview () {
process(this, async () => {
await axios.post('/api/review/saveReview', {
id: this.reviewId,
title: this.title,
address: this.address,
grade: this.grade,
review: this.review,
lon: this.$store.state.curLon,
lat: this.$store.state.curLat,
files: this.fileList,
fileIds: this.deletedFileIds // 추가!
})
// ...
})
}
frontend/src/components/SideBar.vue
DTO
public class ReviewDTO {
//...
List<String> fileIds;
// ...
public List<String> getFileIds() {
return fileIds;
}
public void setFileIds(List<String> fileIds) {
this.fileIds = fileIds;
}
}
src/main/java/com/map/restaurant/good/dto/ReviewDTO.java
Controller
@PostMapping("/saveReview")
public void saveReview(ReviewDTO reviewDTO) {
reviewService.saveReview(reviewDTO);
fileService.deleteFiles(reviewDTO);
fileService.saveFiles(reviewDTO);
}
src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java
Service
@Transactional
public void deleteFiles(ReviewDTO reviewDTO) {
List<String> fileIds = reviewDTO.getFileIds();
if (fileIds == null || fileIds.isEmpty()) {
return;
}
fileDAO.deleteFiles(fileIds);
}
src/main/java/com/map/restaurant/good/service/FileService.java
DAO
void deleteFiles(@Param("fileIds") List<String> fileIds);
src/main/java/com/map/restaurant/good/dao/FileDAO.java
Mapper
<delete id="deleteFiles">
delete
from tbl_file_info
<where>
<foreach collection="fileIds" item="fileId" separator="or">
file_id = #{fileId}
</foreach>
</where>
</delete>
src/main/resources/mappers/FileMapper.xml
MyBatis의 forEach
를 사용하면 리스트 데이터를 반복시킬 수 있다.
DB에 저장된 이미지가 정상적으로 삭제되는 걸 확인할 수 있었다!🎉
이번 포스팅에선 이미지 파일을 업로드하고 UI에 출력시켜보았다.
사실 파일 업로드 기능은 배포때까지 완성된 게 아니다!
배포된 환경의 location에 파일이 저장되는지, src로 불러와지는 지 확인해야한다.
배포까지 열심히 달려보자 🤓
다음 포스팅에선 사이드 바에 무한 스크롤, 또는 페이징 방식으로 리뷰 목록을 불러와 보겠다.
시간이 남는다면 Prettier 도 적용하고 전반적으로 코드를 개선해보자.
댓글, 하트, 피드백은 언제나 환영입니다!🥰
[참조]
'개인 프로젝트' 카테고리의 다른 글
맛집 지도 만들기(9) - 검색 기능 구현 (like, full text search) (0) | 2022.11.17 |
---|---|
맛집 지도 만들기(8) - 리뷰 목록 페이징 구현 (2) | 2022.10.13 |
맛집 지도 만들기(6) - 리뷰 지도에 표시하기 및 리뷰 수정, 삭제 (2) | 2022.07.22 |
맛집 지도 만들기(5) - CRUD API 구현하기 (feat. Axios, 함수형 컴포넌트) (1) | 2022.06.19 |
맛집 지도 만들기(4) - 지도 클릭 이벤트로 주소 입력 받기 (Nominatim API) (12) | 2022.05.16 |