김남준(팀장) |
석지명 |
|
🙋 프론트엔드 리드 - CSS 마크업 구현 - 리뷰, 페이지 마크업 구현 - 페이지별 API 통신 로직 개발 - 개발자 경험 개선을 위한 코드 리팩토링 |
🙋 프론트엔드 팀원 - 리뷰 수정, 삭제 기능 개발 - 네비게이션 가드를 이용한 로그인 기능 개발 |
🟢 웹 아키텍처를 이해하고 이를 활용하여 웹 프로젝트를 설계하고 구현
🟢 Vue3버전을 활용한 SPA 클라이언트 구현
🟢 Axios 라이브러리를 사용하여 서버와 통신한다.
- 로그인 성공시 서버로부터 JWT를 localStorage에 저장한다.
//Jwt를 활용한 로그인 구현
const REST_USER_API = `http://localhost:8080/api/user`
export const useUserStore = defineStore('user', () => {
const loginUserId = ref('')
const userLogin = (userId, password) => {
axios
.post(`${REST_USER_API}/login`, { userId, password })
.then((response) => {
localStorage.setItem('access-token', response.data)
const token = response.data.split('.')
loginUserId.value = (JSON.parse(atob(token[1])))['userId']
})
.then(() => {
router.push('/list')
})
.catch((e) => {
console.log(e)
})
}
return { userLogin, loginUserId }
})- 전역 navigation guard를 이용하여 페이지 라우팅마다 로그인 상태를 확인함
// Jwt 토큰을 활용한 로그인 전역가드 설정
router.beforeEach(async (to, from, next) => {
//페이지가 바뀔때마다 서버로 토큰을 보내 유효성을 검사한다.
if (to.name === 'login') {
return next()
}
const token = window.localStorage.getItem('access-token')
if (!token) {
alert('로그인이 필요합니다.')
return next('/login')
}
try {
const response = await axios.get('http://localhost:8080/api/user/validation?token=' + token);
const isAuth = response.data;
if (isAuth) {
useUserStore().loginUserId = ((JSON.parse(atob((token.split("."))[1])))['userId'])
next();
} else {
alert('로그인이 필요합니다.')
next('/login')
}
} catch (error) {
console.error('Error sending data to server:', error)
alert('로그인이 필요합니다.')
next('/login')
}
})- API통신 및 컴포넌트 리렌더링을 통한 암장, 리뷰 목록 조회/작성/수정/삭제 가능
//암장목록 조회 로직
const fetchGymData = async () => {
try {
const gymResponse = await axios.get(`${URL.GYM_API}${id.value}`);
gymData.value = gymResponse.data;
title.value = gymData.value.gymName;
} catch (e) {
console.error("암장 데이터 로딩에 실패했습니다");
}
}
//리뷰작성 로직(파일도 업로드 가능하도록)
const review = ref({})
const textInput = ref(null);
const selectedImage = ref(null);
const visitDate = ref(null);
const submitForm = () => {
let formData = new FormData()
review.value.content = textInput.value;
review.value.visitDate = visitDate.value;
//formDate에 담아서 전송
formData.append('review', new Blob([JSON.stringify(review.value)], { type: 'application/json' }))
formData.append('image', selectedImage.value)
axios
.post(`${URL.REVIEW_API}write`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(() => {
router.push('/detail/'+gymId.value);
})
.catch((e) => {
console.error('Error sending data to server:', e)
})
}
//리뷰 삭제 로직
const deleteReview = async (reviewNo) => {
try{
await axios.delete(`${URL.REVIEW_API}delete/${reviewNo}`);
reviewData.value = reviewData.value.filter((review) => review.reviewNo !== reviewNo);
} catch(e){
console.log("리뷰 삭제 에러");
}
}- 컴포넌트 리렌더링과 서버 통신을 이용한 찜/찜해제 기능 추가
//찜 기능
const doFavorite = async () => {
try {
await axios.post(`h${URL.GYM_API}favorite/${id.value}`, keyword);
isFavorite.value = true;
} catch(e){
console.log('좋아요 에러');
}
}
//찜 해제 기능
const cancelFavorite = async () => {
try {
await axios.post(`${URL.GYM_API}favorite/delete`, keyword);
isFavorite.value = false;
} catch(e){
console.log("찜하기 해제 에러");
}
}🟢 제한된 폰트 / 컬러 / 그레이스케일의 사용으로 프로덕트 내의 디자인 일관성 유지
🟢 데이터 로딩을 나타내는 이미지와 문구로 유저의 사용성 증대
🟢 뱃지 UI와 다양한 아이콘 사용으로 다양한 정보를 시각적으로 표현
🟢 카카오맵 API를 활용하여 주변 암장 목록 불러오기, 암장 검색 기능 구현
🟢 카카오 API에서 제공하는 ES5의 보일러 플레이트 코드를 ES6 문법을 활용하여 리팩토링 진행
const nearPlaces = () => {
navigator.geolocation.getCurrentPosition((position) => {
const latitude = position.coords.latitude
const longitude = position.coords.longitude
const ps = new kakao.maps.services.Places()
const currentCoordinate = new kakao.maps.LatLng(latitude, longitude)
const options = {
location: currentCoordinate,
radius: 3000,
size: 6,
sort: kakao.maps.services.SortBy.DISTANCE
}
ps.keywordSearch('클라이밍', placesSearchCB, options)
})
}
const searchPlaces = function () {
navigator.geolocation.getCurrentPosition((position) => {
const latitude = position.coords.latitude
const longitude = position.coords.longitude
const ps = new kakao.maps.services.Places()
const keyword = location.value
if (!keyword.replace(/^\s+|\s+$/g, '')) {
alert('키워드를 입력해주세요!')
return false
}
const currentCoordinate = new kakao.maps.LatLng(latitude, longitude)
const options = {
location: currentCoordinate,
size: 8
}
ps.keywordSearch(keyword + ' 클라이밍', placesSearchCB, options)
})
}
// 장소검색이 완료됐을 때 호출되는 콜백함수
const placesSearchCB = (data, status) => {
switch (status) {
case kakao.maps.services.Status.OK:
isLoading.value = false
gymList.value = data
listCnt.value = data.length
return
case kakao.maps.services.Status.ZERO_RESULT:
alert('검색 결과가 존재하지 않습니다.')
return
case kakao.maps.services.Status.ERROR:
alert('검색 결과 중 오류가 발생했습니다.')
return
}
}🟢 Vue에서 제공하는 computed variable을 이용하여, 리뷰 목록을 4개씩 불러오는 Pagination 기능 구현
const displayedReview = computed(() => reviewData.value.slice(0, displayCount.value));
const hasMoreItems = computed(() => displayCount.value < reviewData.value.length);
const showMoreReviews = () => {
if (hasMoreItems.value) {
if (displayCount.value + 4 > reviewData.value.length) {
displayCount.value = reviewData.value.length;
} else {
displayCount.value += 4;
}
}
}
// template
<div class="show_more_button_container">
<div v-if="hasMoreItems" @click="showMoreReviews" class="show_more_button">
<img src="@/assets/underArrow.svg" alt="더보기 버튼">
<p>리뷰 더보기</p>
</div>
</div>🟢 여러 컴포넌트에서 사용하는 유틸함수는 @/utils 내의 유틸함수로 분리하여 사용
//route.js
const API_BASE_URL = `http://localhost:8080/api/`
export const GYM_API = `${API_BASE_URL}gym/`
export const REVIEW_API = `${API_BASE_URL}review/`
export const USER_API = `${API_BASE_URL}user/`
const URL = {
GYM_API,
REVIEW_API,
USER_API
}
export { URL }
//format.js
const convertVisitDate = (date) => {
return `${date.substring(2, 4)}.${date.substring(5, 7)}.${date.substring(8, 10)} 방문`
}
const convertRegDate = (date) => {
return `${date.substring(2, 4)}.${date.substring(5, 7)}.${date.substring(8, 10)} ${date.substring(
11,
13
)}:${date.substring(14, 16)} 작성`
}
const getDistanceFormat = (dist) => {
if (dist < 1000) return `${dist} M`
return `${Math.round(dist / 100) / 10} KM`
}
export { convertRegDate, convertVisitDate, getDistanceFormat }
//api.js
export const routeForAddReview = (router, id) => {
router.push(`/review/${id}`)
}
export const routeForUpdateReview = (router, gymId, reviewNo) => {
router.push(`/review/${gymId}/${reviewNo}`)
}
export const routeToDetail = (router, id) => {
router.push(`/detail/${id}`)
}
🟢 이미지 최적화를 위해 대부분의 이미지 확장자는 .svg 포맷으로 사용함


