본선 (8/19 ~ 8/24)
그 어느때보다 빡센 개발 기간
마켓컬리 해커톤은 본선 진출 후 19일부터 24일까지 개발 기간이 약 5일 정도로만 주어졌다. 그래서 5일 안에 우리가 생각한 아이디어를 빠르게 구현해야만 했다.
협업툴(Git)
우선 함께 사용할 github repository를 만든 후, git 컨벤션을 같이 의논했다. 커밋 메시지의 경우 쉽게 커밋 내용을 알아볼 수 있도록 gitmoji를 사용하기로 했다.
React(Redux, Redux-toolkit, CSS-Module사용)
이번 프로젝트에서 나는 Redux를 처음으로 익혔다. Vuex와 유사점이 많아서, 쉽게 익힐 수 있었다. 특히 dispatch를 통해 Actions를 작동시키는 부분이나, Vuex의 mutations처럼 직접 state를 바꾸는 Reducer가 따로 있다는 것이 비슷했다.
노마드코더, 공식문서를 통해 Redux toolkit을 하루 정도 익힌 후, 로그인, 회원가입, 로그아웃, 검색 기능을 구현하였다. (당시 참조한 블로그: https://blog.logrocket.com/handling-user-authentication-redux-toolkit/)
로그인, 회원가입은 react hook form 라이브러리를 활용해 제출 form을 구성했고, 제출 시 userAction.js상의 로그인과 회원가입에 해당하는 액션을 dispatch 할 수 있도록 구성했다. 로그아웃도 로그아웃 버튼을 누르면 action을 dispatch해서 localStorage 상에 저장된 jwt 토큰을 없애고 refresh 되도록 했다.
// 로그인
// features/user/userActions.js
export const userLogin = createAsyncThunk(
'/api/v1/signin',
async ({userId, password}, {rejectWithValue}) => {
try {
// configure header's Content-Type as JSON
const config = {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': true,
},
};
const res = await axios.post(
'/api/v1/signin',
{userId, password},
config,
);
const words = res.headers.authorization.split(' ');
let userToken = words[1];
localStorage.setItem('userToken', userToken);
return {...res.data, decode};
} catch (error) {
if (error.response && error.response.data.message) {
return rejectWithValue(error.response.data.message);
} else {
return rejectWithValue(error.message);
}
}
},
);
// 로그아웃 구현
// features/user/userSlice.js
const initialState = {
loading: false,
userInfo: null,
userToken,
userName,
error: null,
success: false,
}
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
logout: (state) => {
localStorage.removeItem('userId') // delete token from storage
localStorage.removeItem('userToken') // delete token from storage
state.loading = false
state.userInfo = null
state.userToken = null
state.userName = null;
state.error = null
},
},
...
검색 기능은 searchSlice를 만들어서 검색창에 검색어가 입력된 후, form이 submit 되면 slice 상에 만든 Actions를 호출하여 서버로 '검색어'를 담은 요청을 보내, 검색 결과를 불러오도록 짰다.
// features/search/searchSlice.js
const initialState = {
searchKeyword: '',
searchResults : []
}
export const searchSlice = createSlice({
name: 'search',
initialState,
reducers : {
changeSearchKeyword : (state, action) => {
// console.log('action:', action)
state.searchKeyword = action.payload
},
getSearchResults : (state, action) => {
// console.log('action:', action)
state.searchResults = action.payload.data
}
}
});
export const getSearchResultAsync = keyword => async dispatch => {
try {
const response = await axios.get('/api/v1/product/search/'+ keyword);
console.log('response', response)
dispatch(getSearchResults(response.data))
} catch (err) {
console.log(err)
}
};
export const {changeSearchKeyword, getSearchResults} = searchSlice.actions;
export const searchKeywordSelector = state => state.search.searchKeyword;
export const searchResultsSelector = state => state.search.searchResults;
export default searchSlice.reducer;
React Multi Carousel
검색 결과로 나오는 캐러셀을 구현하려 할 때, 처음에는 이런 CSS 기반의 캐러셀을 사용하려 했다.
Netflix-style Image Carousel With Pure CSS | CSS Script
A pure CSS implementation of the Netflix-style carousel slider with infinite scroll support - there is no JavaScript and super fast loading.
www.cssscript.com
하지만 이건 사용할 수 없었다. 왜냐하면 CSS 코드 상에서 :has() 구문을 사용하게 되는데, 이게 많은 브라우저에서 적용되지 않기 때문이었다.
:has() CSS relational pseudo-class | Can I use... Support tables for HTML5, CSS3, etc
Only select elements containing specified content. For example, a:has(>img) selects all <a> elements that contain an <img> child.
caniuse.com
그래서 다른 방법으로 캐러셀을 구현하기 위해 찾던 중, React 상에서 캐러셀을 만들 수 있는 라이브러리가 따로 있음을 알게 되어 그것을 설치하여 사용하였다.
react-multi-carousel
Production-ready, lightweight fully customizable React carousel component that rocks supports multiple items and SSR(Server-side rendering) with typescript.. Latest version: 2.8.2, last published: 3 months ago. Start using react-multi-carousel in your proj
www.npmjs.com
CSS-Grid
카테고리별로 제품을 모아볼 수 있는 페이지 상에서 제품을 나열하는 방식에 grid를 적용해보았다.
늘 css-flex 방식만 써왔기 때문에 새로운 방식을 시도하고자 했다.(참고 사이트: https://studiomeal.com/archives/533)
이번에야말로 CSS Grid를 익혀보자
이 포스트에는 실제 코드가 적용된 부분들이 있으므로, 해당 기능을 잘 지원하는 최신 웹 브라우저로 보시는게 좋습니다. (대충 인터넷 익스플로러로만 안보면 된다는 이야기) 이 튜토리얼은 “
studiomeal.com
flex가 왼쪽/오른쪽/가운데 정렬에 힘쓰는 느낌에 가깝다면, Grid는 행과 열을 만드는 느낌에 가까운 것 같았다.
개인적으로 flex 보다는 조금 더 완성됐을 때 형태가 예상이 되서 편한 느낌이었다.
대신 flex의 item들은 flex box의 크기에 따라 자동으로 배치가 되는 반면, grid의 item들은 grid container 크기가 변해도 코드 상에 지정한 행과 열이 변하지 않아서 반응형을 만들기 위해 따로 또 작업을 해야 하는 불편함이 있었다.
Github Actions, S3, CloudFront
함께 프론트엔드를 담당하신 팀원 분의 제안으로 처음으로 github Actions를 이용해 CD를 구축하고, S3와 CloudFront 서비스를 활용해 프론트엔드를 배포하는 방법을 시도하게 되었다.
우선 Github Actions는 블로그에 공부한 자료를 올려두었다. 이제껏 협업 시에는 gitlab만 사용해와서 github을 통해 이렇게 편리하게 CD를 구현할 수 있는 방법이 있다는 것은 처음 알게 되었다.
.yml 파일의 표기법이 복잡해서 좀 헤메긴 했다. yaml Lint 검사기를 이용해 돌려보며 오류를 체크하고, 표기법을 확인하며 무사히 지속적 통합.배포를 위한 workflow를 등록할 수 있었다.
또, 이제껏 EC2로 프론트엔드와 백엔드를 한번에 배포하는 방법밖에 몰랐는데, S3+ CloudFront로 배포했을 때 프론트엔드만도 호스팅이 가능한데다, 물리적으로 가까운 거리에서 전송받는 CDN을 활용해 훨씬 빠르게 전송받을 수 있고, 비용 면에서도 훨씬 효율적일 수 있다는 것을 알게 되었다.
정적인 페이지를 AWS S3 + Cloudfront로 배포하기
들어가기 전에.. ✔️ 정적인 웹 페이지(Static Web Page)가 뭘까? 언제 접속해도 같은 응답을 보내주는 페이지라고 생각하면 쉽다. 말 그대로 정적! 움직이지 않는! 클라이언트가 요청을 보내면, 웹
mingule.tistory.com
CORS 이슈 해결
CORS 에러의 경우에는 다음과 같이 해결했다.
백엔드 단에서는 .allowedOrigins("*"), .allowedHeaders("*") 코드를 이용해 요청에 응답이 가능하도록 했고, 프론트엔드 상에서는 proxy middleware 라이브러리를 설치하여 서버에 요청을 보내 응답을 받아올 수 있게 했다.
//setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app){
app.use(
'/api',
createProxyMiddleware(
{
target: 'http://3.39.153.128:8080',
pathRewrite: {
'^/api':'' // /api로 시작되는 url을 자동으로 인식하여 프록시 처리한다.
},
changeOrigin: true,
})
)
};
참고
리액트(React) CORS처리
잠깐 개인적으로 리액트를 만지게 되었는데 모르던 귀찮은 에러들이 많았다. 대표적으로 교차 출처 리소스 공유(CORS)와 관련된 에러인데, 이것은 api서버쪽에서 헤더에 Access-Control-Allow-Origin을 열
woochan-dev.tistory.com
프로젝트에 적용은 못했지만, 새롭게 익힌 것.
jwt 저장 위치
처음 공부할 때는 localStorage에 jwt를 저장하는 방식을 배웠는데, 'localStorage' 상에 jwt를 저장하는 것이 보안적으로 과연 괜찮은지에 대한 고민이 생겼다. 그래서 추가적으로 알아보니 이런 자료가 있었다.
JWT는 어디에 저장해야할까? - localStorage vs cookie
이번에 지하철 미션을 만들면서 JWT를 클래스 property에 저장했었는데 리뷰어 분께 해당 부분을 피드백 받으면서 어디에 JWT를 저장하는 것이 좋을까 에 대해 고민해보게 되었다. 0. 기본 지식 JWT Js
velog.io
jwt 토큰을 localStorage 상에 저장하면 XSS 공격에 취약해진다. 그래서 http-only 속성이 부여된 쿠키에 저장하는 것을 권장하고 있다. 해당 속성이 부여된 쿠키는 자바스크립트 환경에서 접근할 수 없기 때문에, XSS나 CSRF가 발생하더라도 토큰이 누출되지 않는다. 또한, Access Token 외에 Refresh Token을 따로 두고 AccessToken의 유효기간을 짧게 설정해 Access Token 만료 시 Refresh Token의 존재를 확인해 다시 Access Token을 발급하는 방법도 보안적으로 좋다고 한다. (다음 프로젝트에서 꼭 시도해봐야 할 듯 하다)
검색어와 관련성 높은 순으로 제품 카테고리 정렬
원래 우리는 '사과'라는 검색어를 검색했을 때, '과일' 제품이 가장 많이 나올 것으로 예상하고 해당하는 제품이 많은 카테고리 순으로 정렬을 시도했다. 하지만, 실제로는 '사과'를 검색했을 때 과일이 아니라 음료가 가장 결과물이 많이 나왔었다.
즉, 사과를 검색했을 때 과일이 먼저 나오는 요인에는 단지 제품 수가 많아서가 아니라, 정확도라던지 사용자들의 검색 후 구매 비율이 높았다던지 하는 추가적인 요소가 있었던 것이었다.
이 부분을 해결하고자 다양한 방법들을 조사했다. 그 중, 이 아마존 AWS Comprehend를 이용해 과일 카테고리의 제품들의 제목을 모두 넣어 실제로 과일이 나올 수 있을 만한 공통 키워드를 뽑아낼 수 있을지 확인해봤다. '1kg', '김천' 처럼 지명이나 무게와 관련된 키워드가 주로 나왔고, 이는 가공식품에도 적용될 수 있는 요소이므로 과일만의 요소로 보기에는 어려웠다. 하지만 GAP, 고당도 처럼 과일에만 해당할 수 있는 키워드도 뽑아낼 수 있어 유의미한 결과였다.
자연어 처리 - Amazon Comprehend - Amazon Web Services
문서 내 텍스트, 고객 지원 티켓, 제품 리뷰, 이메일, 소셜 미디어 피드 등에서 소중한 인사이트를 찾을 수 있습니다.
aws.amazon.com
[GAP, 고당도, 김천, ...과일명(포도 , 사과, 샤인머스켓, 복숭아...)] 등 단어를 미리 DB 상에 저장해두고 해당 검색어가 검색되면, 과일류를 가장 먼저 정렬하는 방식이었다.
하지만 이 방법은 아무래도 수작업에 가깝고, 임의성이 강해서 결국 사용할 수 없게 되었다. 결론적으로, 완벽한 해답은 찾지 못했지만 '검색어에 가장 정확한 결과 순'으로 정렬해서 보여주는 것이 얼마나 많은 것을 고려해야 하는 일인지 깨달을 수 있는 좋은 기회였다고 생각한다.
그렇게 PT 자료까지 같이 준비해서, 마침내 프로젝트를 제출하게 되었다!
깃허브 주소: https://github.com/Kurvey
Kurvey
U Life Kurly : 컬리 Hack Festa 2022. Kurvey has 2 repositories available. Follow their code on GitHub.
github.com
'개발 공부 > 2022 마켓컬리 해커톤' 카테고리의 다른 글
[마켓컬리 해커톤 회고] 5주차: 결선 진출, 그리고 최종 우승! (0) | 2022.09.06 |
---|---|
[마켓컬리 해커톤 회고] 1-2주차: Kurvey팀이 라이프스타일 기반 추천 시스템을 기획하기까지의 고군분투 (1) | 2022.09.05 |