⌨️ Vue.js 로이 전환
타임리프에서 Vue.js로 CSR 방식으로 전환하며 프런트에 대해 거의 깊이가 없어 많이 힘들었던 것 같다.
1. 회원가입/로그인
타임리프에서 Vue.js로 바꾸는 것만 해도 많은 공수가 들었어서 꽤 힘들었다.
Vue.js에 대한 자세한 공부는 차차 포스팅 하도록 하겠다.
1.1. 회원가입
ISSUE
- 주소 찾기를 통해 건네준 데이터가 null로 들어가는 현상
-> 문제 상황
실제로는 null로 들어가는 것이 아니라 애초에 전달이 되지 않았다.
해당 문제는 Vue.js 에서 DOM을 직접 조작할 경우 생기는 문제로 데이터 바인딩이 되지 않는 것이 원인이었다.
AS-IS
function searchAddr() {
new daum.Postcode({
oncomplete: function (data) {
// 팝업에서 검색결과 항목을 클릭했을때 실행할 코드를 작성하는 부분.
// 각 주소의 노출 규칙에 따라 주소를 조합한다.
// 내려오는 변수가 값이 없는 경우엔 공백('')값을 가지므로, 이를 참고하여 분기 한다.
var addr = ''; // 주소 변수
//사용자가 선택한 주소 타입에 따라 해당 주소 값을 가져온다.
if (data.userSelectedType === 'R') { // 사용자가 도로명 주소를 선택했을 경우
addr.value = data.roadAddress;
} else { // 사용자가 지번 주소를 선택했을 경우(J)
addr.value = data.jibunAddress;
}
// 주소 정보를 해당 필드에 넣는다.
document.getElementById("address").value = addr;
}
}).open();
}
이게 원래의 문제 코드이다.
TO-BE
<script setup>
const addr = ref("");
function searchAddr() {
new daum.Postcode({
oncomplete: function (data) {
// 팝업에서 검색결과 항목을 클릭했을때 실행할 코드를 작성하는 부분.
// 각 주소의 노출 규칙에 따라 주소를 조합한다.
// 내려오는 변수가 값이 없는 경우엔 공백('')값을 가지므로, 이를 참고하여 분기 한다.
//사용자가 선택한 주소 타입에 따라 해당 주소 값을 가져온다.
if (data.userSelectedType === 'R') { // 사용자가 도로명 주소를 선택했을 경우
addr.value = data.roadAddress;
} else { // 사용자가 지번 주소를 선택했을 경우(J)
addr.value = data.jibunAddress;
}
// 주소 정보를 해당 필드에 넣는다.
document.getElementById("address").value = addr;
}
}).open();
}
</script>
위와 같이 코드를 변경하였다.
이는 타임리프에서는 DOM 변경하였어도 form-data에서 액션을 통해 값을 넘겨주므로 해당 <input>의 밸류를 읽을 수 있었지만, Vue.js에서는 바인딩한 객체를 만들어서 Axios를 통해 넘겨주므로 생긴 문제였다.
-> 해결 방법
Vue.js 의 객체를 바인딩하여 데이터를 넘겨주는 것으로 해결
1.2. 로그인
로그인은 특별한 이슈는 없었으나, JWT 토큰 방식으로 변경함에 따라 로그인 시 로컬 스토리지에 토큰을 저장해 주는 로직을 추가하였다 (간단히 추가만)
<script>
function login() {
const LoginDto={};
LoginDto.email = this.email;
LoginDto.password = this.password;
axios.post('http://localhost:8089/login',{
email: LoginDto.email,
password : LoginDto.password
})
.then((response)=> {
localStorage.setItem("token",JSON.stringify(response.headers));
//TODO 네비게이트 설정
router.push("/");
})
.catch((error) => {
console.error("에러",error)
window.alert("로그인 정보가 일치하지 않습니다! 계정정보를 확인해주세요!!")
});
};
<script>
ISSUE
- 토큰을 Storage에 저장할 것인가 cookie 에 저장할 것인가?
- Storage에 저장한다면 session 이냐 local 이냐?
위 두 가지 고민사항을 가지고 구현을 하였다.
1. Storage와 cookie 를 선택
a) Storage 의 장점
- CSRF 공격에는 안전 : 자동으로 request에 담기는 쿠키와 다르게 직접 담아주어야 하기 때문
b) Storage 의 단점
- XSS에는 취약 : 공격자가 Storage에 접근하는 Script 코드를 주입하면 위험도 증가
c) cookie의 장점
- XSS 공격에서 Storage에 비해 안전 : 쿠키의 httpOnly로 http 메서드 통신이 아닌 스크립트는 불가하게 설정
d) cookie의 단점
- CSRF 공격에 취약 : request url 만 알면 관련 link를 클릭하도록 유도하여 요청 변조가 가능
위와 같은 이유들을 종합하고, 추후 보안적인 Guard를 더 설정했을 때 우리 프로젝트 그리고 향후 프로젝트에서는 Storage 방식이 더욱 적합할 것이라 생각하여 Storage로 설정했다.
-> Refresh Token은 cookie에 담을 예정
2. local과 session의 선택
사용자가 해당 세션을 떠나더라도, 자동로그인 혹은 지속적인 사용 경험 유지를 시켜주고 싶었기에 local에 저장하는 것으로 선택하였으나. 해당 부분은 기술적으로 고민을 더 하여 session으로 옮기는 것을 고려 중이다.
2. 리뷰
2.1. 리뷰 확인
특별한 이슈 없이 구현한 부분이다.
<template>
<section class="review" v-if="!reviewList.data || reviewList.data.length === 0">
<h3>리뷰</h3>
<div class="review_container">
<i class="rv-chav left fa-solid fa-circle-chevron-left"></i>
<i class="rv-chav right fa-solid fa-circle-chevron-right"></i>
<div class="review_none">
<p>아직 리뷰가 작성되지 않았습니다.</p>
<p>첫 리뷰를 작성해 주세요!</p>
<button @click="clickParam" class="btns btn_write_big">
리뷰작성
</button>
</div>
</div>
</section>
<section class="review" v-else>
<div id="reviews" class="review_content" v-for="review in reviewList.data" :key="review.id">
<section v-if="review.photosList && review.photosList.length > 0">
<aside class="images">
<figure class="viewer">
<img :src="'http://localhost:8089/ex_images/' + review.photosList[0].imgName" alt="">
</figure>
<div class="img_list">
<figcaption class="thumb" v-for="photo in review.photosList" :key="photo.id">
<img :src="'http://localhost:8089/ex_images/' + photo.imgName" alt="">
</figcaption>
</div>
</aside>
</section>
<section v-else>
<aside class="images">
<figure class="viewer">
<img :src="'/images/noimage.png'" alt="">
</figure>
</aside>
</section>
<article class="review_main">
<!-- 나머지 리뷰 내용 표시 -->
<div class="review_info">
<div class="detail_member">
<a href="" class="profile_image"></a>
<span class="name">{{ review.createdBy }}</span>
<span class="date">{{ review.createdAt }}</span>
</div>
<th:block th:if="${session.auth!=null && session.auth.getName()== review.createdBy}">
<div class="up_del">
<span class="info_modi" @click="modify">수정</span>
<span class="info_del" @click="deleteReviews(review.id)">삭제</span>
</div>
</th:block>
</div>
<hr>
<div class="review_text">
<p>
{{ review.content }}
</p>
</div>
</article>
</div>
</section>
</template>
<script setup>
import axios from 'axios';
import { ref, defineProps } from 'vue';
import router from '@/router/index.js'
const props = defineProps(['id', 'walkingPathdId']);
const clickParam = () => {
router.push({
path: "/" + props.walkingPathdId + "/reviews",
})
}
const reviewList = ref([]);
const getReviewList = async () => {
await axios.get(`http://localhost:8089/${props.walkingPathdId}/reviews`)
.then((response) => {
return response.data
})
.then((data) => {
reviewList.value = data;
console.log(reviewList.data);
})
}
getReviewList();
// const setList = async() => {
// getList.value = await fetchList();
// }
// setList().then(()=>{
// console.log(getList.value.data);
// })
function deleteReviews(e){
axios.delete(`http://localhost:8089/reviews/`+e,{
headers:{
Authorization: JSON.parse(localStorage.getItem("token")).authorization,
}
})
.then(response =>{
if(response.status == 205){
alert("리뷰가 정상적으로 삭제되었습니다.")
window.location.reload(true);
}
})
}
</script>
<style scoped>
@import "@/assets/walking_path_detail.css";
</style>
-> 토큰과 서버 정보 등 중복이 심한 것들은 전역적으로 관리하는 방법을 공부해야겠다.
2.1. 리뷰 작성
<template>
<Header />
<hr class="header_hr">
<div class="all">
<div class="title">
<h1>리뷰 등록</h1><br>
</div>
<div class="wrapper">
<div class="left">
<section v-if=walkingPath>
<div class="walking-path">
<img v-if="walkingPath.photosList == 0" src="/images/noimage.png" alt="">
<img v-if="walkingPath.photosList > 0"
:src="'http://localhost:8089/ex_images/' + walkingPath.photosList[0].imgName" alt="" />
<span>{{ walkingPath.title }}</span>
<section v-if="walkingPath.mapList > 0">
<span>{{ walkingPath.mapList[0].distance }}</span>
</section>
<span>{{ walkingPath.addr }}</span>
</div>
</section>
</div>
<div class="review_form">
<div class="form">
<form @submit.prevent="postReview">
<section class="write">
<span>별점</span> <br>
<span value="5">★★★★★</span><br>
<span>본문</span> <br>
<textarea name="content" v-model="content" rows="10" cols="60" wrap="soft"
style="resize: none; padding: 10px" required></textarea><br>
<span>사진(5개 제한)</span><br>
<section class="img-area">
<div id="review_image_container" class="image_container">
</div>
<div class="filebox">
<label for="file">+</label>
<input id="file" type="file" @change="readInputFile" multiple />
</div>
</section>
</section>
<section class="submit">
<input type="submit" value="저장" class="btn-review">
<button class="btn-ref"><router-link
:to="`/walking-path/${props.id}`">뒤로가기</router-link></button>
</section>
</form>
</div>
</div>
</div>
</div>
<Footer />
</template>
<script setup>
import { ref } from 'vue';
import Header from '@/components/Header.vue';
import Footer from '@/components/Footer.vue';
import axios from 'axios';
import router from '@/router/index.js'
const walkingPath = ref([]);
const props = defineProps(['id']);
const content = ref("");
const files = ref([]);
function getWalkingPath() {
axios.get(`http://localhost:8089/walking-path/${props.id}`)
.then((response) => {
return response.data;
})
.then((data) => {
walkingPath.value = data;
console.log(walkingPath.value)
})
}
getWalkingPath();
const readInputFile = (e) => {// 미리보기 기능구현
const review = document.getElementById('review_image_container')
review.innerHTML = '';
var file = e.target.files;
//e.target.files;
var fileArr = Array.from(file);
files.value = fileArr;
console.log(fileArr)
fileArr.forEach(function (f) {
if (!f.type.match("image/.*")) {
alert("이미지 확장자만 업로드 가능합니다.");
return;
};
var reader = new FileReader();
reader.onload = function (e) {
const img = document.createElement('img');
img.style.width = "100px";
img.src = e.target.result;
review.appendChild(img);
};
reader.readAsDataURL(f);
})
}
function postReview() {
const formData = new FormData();
const reviewsRequestDTO = {
content: content.value
}
const json = JSON.stringify(reviewsRequestDTO);
const blob = new Blob([json], { type: 'application/json' });
formData.append('reviewsRequestDTO', blob);
if (files.value && files.value.length > 0) {
files.value.forEach((file) => {
console.log("여기" + file);
formData.append('files', file);
});
} else {
console.log("없")
formData.append('files', new Blob(), '');
}
// formData의 내용을 로그로 출력
for (let pair of formData.entries()) {
console.log(pair[0] + ', ' + pair[1]);
}
axios.post(`http://localhost:8089/${props.id}/reviews`, formData, {
headers: {
// Authorization : JSON.parse(localStorage.getItem("token")).authorization,
Authorization: JSON.parse(localStorage.getItem("token")).authorization,
'Content-Type': 'multipart/form-data', // 수정된 부분
},
}).then(response => {
if (response.status == 201) {
router.push('/walking-path/' + props.id)
}
})
// .then(() => close(undefined))
.catch(error => console.log(error))
}
</script>
<style scoped>
@import "../assets/review_write_form.css";
</style>
이번 Vue.js의 전환으로 가장 힘들었던 부분이 아닌가 싶다. 따라서 이 부분만 따로 다루도록 하겠다
3. 피드백
- Vue.js로의 전환이 생각보다 힘들고 오래 걸렸다.
- 프론트 지식 또한 필요성을 느꼈고, 한쪽만 고집하면은 협업에서도 많이 힘들 것 같다는 생각이 든다.