rkdms357
2022.07.05
@rkdms357님이
[15일차] 호스팅과 정리
포스트를 좋아합니다.
익명
2022.04.28
익명님이
[15일차] 호스팅과 정리
포스트에 댓글을 남겼습니다.
BearBear
2022.03.28
@BearBear님이
[15일차] 호스팅과 정리
포스트를 좋아합니다.
BearBear
2022.03.28
@BearBear님이
[15일차] 호스팅과 정리
포스트를 좋아합니다.
BearBear
2022.03.28
@BearBear님이
[15일차] 호스팅과 정리
포스트를 좋아합니다.
BearBear
2022.03.28
@BearBear님이
[15일차] 호스팅과 정리
포스트를 좋아합니다.
BearBear
2022.02.06
@BearBear님이 새 포스트를 작성했습니다.
[15일차] 호스팅과 정리
호스팅 마지막은 간단하게 호스팅으로 마무리 해 보겠다. 나는 기본적으로 가지고있던 ec2와, 그 ec2 내부에 설치되어있는 nginx가 존재한다. 따라서 호스팅 자체는 nginx을 사용해 진행할 예정이고, 도메인만 구매해서 연결해보자! 호스팅을 위한 웹 도메인은 가비아에서 구매했다. https://my.gabia.com/dashboard#/ 가비아에서 도메인을 구매한 뒤, 서비스 관리 -> DNS 툴에 들어간다. 그 뒤, A 레코드를 추가해 호스트에 각각 www와 @를 입력하고, 값 및 위치에는 내가 가지고있는 ec2의 인스턴스 public ip 주소를 입력해 준다. 이제 nginx에서 도메인을 설정해 주자! /etc/nginx/sites-available 경로에 들어가 아래 명령어를 입력해주자. $ sudo vi lolduo.conf 왜 lolduo냐면.. 내가 구매한 도메인이 lolduo.kr이기 때문이다. ㅎㅎ..; 텍스트 편집기가 열렸다면 아래 설정을 입력해 주자. server { listen 80; listen [::]:80         server_name lolduo.kr;         location / {             proxy_pass http://localhost:3000;         }         location /graphql {             proxy_pass http://localhost:3333;         } } server_name에는 본인의 도메인 이름을 적어주면 된다. listen 80은 http 요청을 받는 부분이고, 서버 이름으로 요청이 들어왔을 땐 localhost:3000에 실행되고있는 웹으로 라우팅된다. 백엔드 요청은 localhost:3333에 실행되고 있는 API로 이어진다! sites-available에 파일을 추가했기 때문에, 이 파일을 sites-enabled와 연결해줘야 한다. $ sudo ln -s /etc/nginx/sites-available/lolduo.conf /etc/nginx/sites-enabled/ 이제 nginf.conf 파일을 수정해 준다. $ cd /etc/nginx $ sudo vi nginx.conf http { ... server_names_hash_bucket_size 64; #주석해제 ... } $ sudo nginx -t $ sudo systemctl restart nginx nginx -t는 nginx를 실행 테스트하는 과정이다. 테스트에 문제가 없다면, nginx를 재시작 해 준다! https 연결 마지막으로, https를 적용해 주자. https는 lets encrypt를 이용해 발급하려고 한다. lets encrypt는 발급 절차가 간단하고, 무료라는 장점이 있기 때문에 나같은 개인 프로젝트에서 사용하기에 좋다. 😉 인증서는 웹 서버를 통해 발급받자. 웹서버를 통한 SSL 인증서 발급 방법의 좋은 점은 standalone 방식과 비슷한 방식이면서도 다르게 발급 받을 시 사이트 서비스를 중단하지 않아도 된다는 점이고, 웹서버가 알아서 적절한 SSL 옵션을 제안해 적용해 준다는 점이다. Certot 설치 서버에서 SSL 인증서를 설치할 웹서버용 인증서 설치 툴인 Certbot을 설치한다. sudo apt install certbot python3-certbot-nginx 방화벽에서 HTTPS를 허용 아마 기본적으로 설정되어 있기는 하겠지만 80포트와 443포트를 허용해 주고 있는지 확인한다. 우분투 20.04라면 기본 방화벽으로 ufw를 사용하고 있고, 여기에서라면 ‘Nginx Full’ 옵션을 사용한다. sudo ufw allow ssh sudo ufw allow 'Nginx Full' SSL 인증서 발급 sudo certbot --nginx -d lolduo.kr 위 명령어를 사용하면 /etc/etsencrypt 폴더에 자동으로 SSL 적용 옵션을 제안해 준다. 사용자는 이 옵션을 그대로 사용할 수도 있고, 독자적인 옵션을 적용할 수도 있다. 그러나 별도 옵션을 적용하면 nginx가 제안하는 사항들이 제대로 업데이트가 안되기 때문에 매번 수작업으로 업데이트를 해주어야 하지만, 독자적인 옵션을 사용하지 않고 그냥 웹서버가 제안하는 옵션 그대로 사용한다면 나쁘지 않다고 한다. 서버 설정 확인 위 과정을 전부 마치면, /etc/nginx/sites-available/lolduo.kr 파일이 아래와 같이 변경된다. server {         server_name lolduo.kr;         location / {             proxy_pass http://localhost:3000;         }         location /graphql {             proxy_pass http://localhost:3333;         }     listen [::]:443 ssl ipv6only=on; # managed by Certbot     listen 443 ssl; # managed by Certbot     ssl_certificate /etc/letsencrypt/live/lolduo.kr/fullchain.pem; # managed by Certbot     ssl_certificate_key /etc/letsencrypt/live/lolduo.kr/privkey.pem; # managed by Certbot     include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot     ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot } server {     if ($host = lolduo.kr) {         return 301 https://$host$request_uri;     } # managed by Certbot         listen 80;         listen [::]:80;         server_name lolduo.kr;     return 404; # managed by Certbot } 원래라면 위 설정을 알아서 해 줘야 하지만.. CertBot에서 알아서 해준다는 점이 너무나도 편리한 것 같다.😉 HTTPS가 적용된걸 볼 수 있다!
BearBear
2022.02.06
@BearBear님이 새 포스트를 작성했습니다.
[14일차] 프론트 구현중 발생한 오류들과 정리
Comment query 첫 번째는 comment query에서 발생한 오류였다. 본론부터 말하자면, comment 쿼리에 넘겨지는 이름 변수가 공백이여서 발생한 문제이다. 이 오류를 찾기 힘들었던 이유는 아래와 같이 변수가 undefined인지 검사하고 있는데도 위 오류가 발생했기 때문이였다..   useEffect(() => {     if (name !== undefined) {       comments();     }   }, [comments, name]); 근데, 이름 변수는 초기값이 undefined가 아닌 ''이었기 때문에, const initialState: SummonerState = {   id: '',   name: '',   puuid: '', }; 아직 이름 데이터를 받아오지 않았음에도 쿼리문이 실행되어 오류가 발생한 것이다. 이미지 최적화 문제 다음은 이미지 컴포넌트를 사용하면서 발생한 문제였다. nextJS에서 기본적으로 제공하는 nextjs/image 컴포넌트는 자동으로 이미지를 최적화해, 빌드타임을 최소화하는 기능을 제공한다. 뷰포트에 잡히지 않는 이미지는 로드하지 않고, 이후에 lazy load 함으로서 초기 웹 로딩시간을 단축시킬 수 있다! public 폴더 내부의 이미지는 자동으로 최적화가 되지만, 외부에서 받아오는 이미지는 이러한 최적화가 자동으로 진행되지 않기 때문에 아래와 같은 오류가 발생한다. External domains must be configured in next.config.js using the domains property. 이는 어뷰징을 막기 위한 방법이라고 하며, next.config.js 파일에 image domain property를 설정해줘야 한다! 기존 export하던 모듈 설정에 아래 세팅을 추가해줬다.     images: {       domains: ['ddragon.leagueoflegends.com'],     }, 프론트엔드 구현을 마치며.. 이번엔 누가 기획해준 내용에 따라 개발을 한 것이 아닌, 내가 기획부터 디자인, DB설계, API구현, 프론트 구현까지 전부 내가 직접 작업해 봤다. 디자인과 DB 설계까지는 그나마 매끄럽게 진행된다고 생각했지만, 아니나 다를까 API 구현에서부터 삐걱거리기 시작하더니, 점점 더 코드가 꼬여가는게 눈에 보였다. 최대한 덮어놓고 해결부터 하는 방식보다는 천천히 매듭을 하나씩 풀어나가고 싶었지만, API 구현 막바지에 이르니 DB 설계부터 잘못되었다는 것이 뼈저리게 느껴졌다. 실제로 소환사의 최근 전적 데이터를 받아오는 recentMatches 뮤테이션은 실행되는데에 3초나 걸린다. API 호출시 받아오는 데이터의 양이 많은 탓도 있지만, 한 게임에 참여한 사람의 챔피언 아이콘이나 아이템 이미지 경로, 스펠 경로 등을 받아오기 위해선 한 번의 DB 접근과, 한 번의 리스트 탐색을 필요로 한다. 즉, 최근 10 경기의 데이터를 받아오기 위해선 한 사람의 아이템 7개, 스펠 2개, 참여자 10명의 챔피언 사진 경로까지 약 200번의 DB 접근과 200번의 리스트 탐색이 이뤄진다. ...이게 무슨짓인가 싶은 생각이 들더라. 2주라는 타임라인이 그다지 길다고 생각되지는 않았지만 처음부터 모든걸 손대기엔 너무나도 짧은 시간이였기 때문에 일단은 넘어갔지만, 추후에 다시 웹사이트를 손대게 된다면 이 사진 경로를 찾는 알고리즘부터 뜯어고치지 싶다. 15일차엔 웹사이트를 호스팅하고 마무리해 보겠다.
BearBear
2022.02.06
@BearBear님이 새 포스트를 작성했습니다.
[13일차] GraphQL 연결
GraphQL 연결 오늘 해볼 것은 GraphQL 연결이다. 뭔가 중간에 있어야 할 내용들이 많이 스킵된 것 같지만.. 디자인 구현 자체는 그다지 어렵지 않은 내용이니, 바로 GraphQL로 들어가자! 우선 가장 먼저 해 줄 것은 config 설정이다. environment에 내 백엔드의 경로를 넣어줘야 한다! 👉 .env.development API_URL="http://localhost:3333" SITE_URL="http://localhost:3000" 👉 next.config.js const path = require('path'); module.exports = (phase) => {     env: {       API_URL: process.env.API_URL,       SITE_URL: process.env.SITE_URL,     },     sassOptions: {       includePaths: [path.join(__dirname, './src/styles')],       prependData: `       @import "${(path.resolve(__dirname), './src/styles/_variables.scss')}";       @import "${(path.resolve(__dirname), './src/styles/_typography.scss')}";       @import "${(path.resolve(__dirname), './src/styles/_mixins.scss')}";       `,     },   }; }; 👉 config/env.ts export const API_URL = process.env.API_URL || ''; export const SITE_URL = process.env.SITE_URL || ''; if (process.env.NODE_ENV === 'development') {   console.log('\n---------ENV---------\n');   console.log(`NODE_ENV`, NODE_ENV);   console.log(`API_URL`, API_URL);   console.log(`SITE_URL`, SITE_URL); } 위처럼 설정해주면, 내 코드 내에서 config/env.ts 파일의 API_URL과, SITE_URL 변수를 불러와서 사용할 수 있다. 이제 GraphQL을 연결해보자! NextJS에서 GraphQL은 아래 두 가지 라이브러리를 사용하게 된다. @apollo/client graphql Apollo Client의 세팅을 담당하는 apolloClient.tsx 파일을 아래와 같이 생성했다. 이 링크를 참고했다! import {   ApolloClient,   ApolloLink,   ApolloProvider,   from,   HttpLink,   InMemoryCache,   NormalizedCacheObject, } from '@apollo/client'; import { onError } from '@apollo/client/link/error'; import { API_URL } from 'config/env'; import { useMemo } from 'react'; let apolloClient: ApolloClient<NormalizedCacheObject> | undefined; export function createApolloClient() {   const errorLink = onError(     ({ graphQLErrors, networkError, response, operation, forward }) => {       if (graphQLErrors) {         graphQLErrors.map((error) => {           graphQLErrors.forEach((error) => {             if (process.env.NODE_ENV === 'development') {               console.error(error);               console.error(                 `[GraphQL error]\n<Message> ${error.message} ${error.stack}`,               );               console.error(error.extensions);             }           });         });       }       if (networkError) {         if (process.env.NODE_ENV === 'development') {           console.log(`[Network Error]: ${networkError}`);         }         networkError.message = '서버 오류가 발생했습니다.';       }     },   );   const headerLink = new ApolloLink((operation, forward) => {     operation.setContext(({ headers = {} }) => ({       headers,     }));     return forward(operation);   });   const cache = new InMemoryCache();   const httpLink = new HttpLink({     uri: API_URL,     credentials: 'include',   });   return new ApolloClient({     link: from([errorLink, headerLink, httpLink]),     cache,     ssrMode: typeof window === 'undefined',     defaultOptions: {       watchQuery: {         fetchPolicy: 'cache-and-network',         nextFetchPolicy: 'cache-first',       },     },   }); } export function initializeApollo(initialState: any = null) {   const _apolloClient = apolloClient ?? createApolloClient();   // If your page has Next.js data fetching methods that use Apollo Client,   // the initial state gets hydrated here   if (initialState) {     // Get existing cache, loaded during client side data fetching     const existingCache = _apolloClient.extract();     // Restore the cache using the data passed from     // getStaticProps/getServerSideProps combined with the existing cached data     _apolloClient.cache.restore({ ...existingCache, ...initialState });   }   // For SSG and SSR always create a new Apollo Client   if (typeof window === 'undefined') return _apolloClient;   // Create the Apollo Client once in the client   if (!apolloClient) apolloClient = _apolloClient;   return _apolloClient; } export function useApollo(initialState: any) {   const store = useMemo(() => initializeApollo(initialState), [initialState]);   return store; } export const withApollo = (PageComponent: any) => {   const WithApollo = ({ apollClient, apolloState, ...pageProps }: any) => {     const client = initializeApollo(apolloState);     return (       <ApolloProvider client={client}>         <PageComponent {...pageProps} />       </ApolloProvider>     );   };   return WithApollo; }; 링크의 본문에선 _app 파일에 ApolloProvider를 연결했지만, 내가 follow-up하던 회사의 프로젝트에선 withApollo라는 HOC를 사용했었다. 구현을 전부 마친 지금 다시 생각해보면, Authorize때문에 각 페이지에 withApollo를 사용해 cache를 넘겨주는 방식이 아니였을까.. 라고 생각이 든다. ..내 프로젝트에선 로그인이고 뭐고 없다보니, 굳이 위처럼 작성하지 않고, 그냥 _app에서 Provider를 연결해주면 되는게 아니였을까. ㅎㅎ..;
BearBear
2022.02.06
@BearBear님이 새 포스트를 작성했습니다.
[12일차] 컴포넌트 분리
컴포넌트 분리 자! 오늘 할 것은 컴포넌트 분리와, Global Sass 적용이다. 컴포넌트 분리는 정말 정답이라고 할만한게 존재하지 않지만, 개인적으로는 두 곳 이상에서 동일하게 사용되는 컴포넌트를 분리하고, 하나의 tsx 파일이 너무 길어져 가독성이 떨어진다고 생각될 때, 해당 파일의 일부를 컴포넌트로 분리하는 편이다. 모든 컴포넌트를 설계하고 들어가는 것이 물론 좋겠지만.. 주니어 개발자 특기, 설계사항 뒤엎기를 백엔드 구현하면서 정말 많이 사용했기 때문에.. 개략적인 컴포넌트만 설계하고 세부적인 내용은 컴포넌트를 구현하면서 나눠보자. 컴포넌트를 나누는 예시는 아래와 같다. 👉 멀티 서치 페이지의 승/패 👉 전적 결과 페이지의 전적 총합 그래프 위 두 그래프를 보면, 흰색 글씨를 제외하고서는 파란색 그래프의 길이와 빨간색 그래프의 길이를 사용해, 승/패 그래프를 리턴한다는 점은 동일하다. 그럼 이 두가지 디자인 요소를 구현하는 그래프를 만들면 되는걸까? 아래 디자인 요소를 보자. 👉 전적 결과 페이지의 팀 분석 그래프 여기서는 각 소환사의 인게임 데이터를 사용해 팀 별로 빨간색, 파란색 그래프를 그려주고 있다. 개인마다 컴포넌트를 나누는 법은 다르겠지만 나는 색상과 길이에 맞는 그래프를 그려주는 Graph 컴포넌트를 구현하고, 위의 두 컴포넌트는 WinRateGraph라는 새로운 컴포넌트로 작성할 것 같다. Global Sass 위에서 나눈 컴포넌트를 구현하기에 앞서, 색상이나 폰트같은 global한 css 변수값들을 Sass 파일에서 사용하기 위한 설정을 해 줘야 한다. 👉 _variables.scss $black: #000000; $white: #ffffff; $blue: #00a2ff; $red: #f85959; $red-op: #f8595970; $blue-op: #00a2ff70; $red-op2: #f8595930; $blue-op2: #00a2ff30; $spacing-2: 0.125rem; $spacing-4: 0.25rem; $spacing-6: 0.375rem; $spacing-8: 0.5rem; $spacing-12: 0.75rem; $spacing-16: 1rem; 물론 그냥 각 scss 파일에서 위의 변수값들을 직접 입력해줘도 되지만, 만약 디자인의 색상이 변경된다고 가정하면..? 혹은 폰트의 크기가 변경된다면.. ? 일일히 변경해주는 것 보단 이렇게 변수로 관리하는게 100배는 낫지 않을까 싶다. 😉 이 Global Sass는 이 링크를 참고해서 적용했다! 👉 next.config.js const path = require('path'); module.exports = (phase) => {   return {     sassOptions: {       includePaths: [path.join(__dirname, './src/styles')],       prependData: `       @import "${(path.resolve(__dirname), './src/styles/_variables.scss')}";       @import "${(path.resolve(__dirname), './src/styles/_typography.scss')}";       @import "${(path.resolve(__dirname), './src/styles/_mixins.scss')}";       `,     },   }; };
BearBear
2022.02.06
@BearBear님이 새 포스트를 작성했습니다.
[11일차] NextJS 기본 설정
스타일 초기화 컴포넌트를 나누기 전에, 우선 Base CSS 먼저 세팅해보자. 현재 전적 검색 결과 메인 페이지는 아래와 같이 코드가 작성되어있다. import { GetServerSidePropsContext } from "next" const SummonerPage = ({name} : {name: string}) => { return <div>mainpage, {name}</div> } export async function getServerSideProps(ctx: GetServerSidePropsContext) { const params = ctx.params if (!params) { return } return { props : { name: params.name } } } export default SummonerPage 해당 페이지를 웹에서 접속해보면, 아래와 같은 사이트를 볼 수 있다. 위에서 html → body → div 태그 아래에 우리가 구현한 페이지 컴포넌트를 볼 수 있다. 즉, 최상단 HTML 태그에만 CSS를 적용해 주면 그 CSS값은 모든 컴포넌트에 적용된다는 사실! 물론 커스텀 document를 사용해 document에 CSS를 적용해도 되지만,( 링크 ) 지금 당장은 document 파일을 만들 필요는 없어 보이니 추후에 구현하게 되면 생각해보자. 기본적으로 제공되는 _app 컴포넌트를 보면, 이미 global.css를 적용 받고 있는 걸 볼 수 있다. import '../styles/globals.css' import type { AppProps } from 'next/app' function MyApp({ Component, pageProps }: AppProps) { return <Component {...pageProps} /> } export default MyApp 경로를 따라 global 파일로 가보면, 이미 html태그와 body태그에 스타일을 적용하고 있다. html, body { padding: 0; margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; } a { color: inherit; text-decoration: none; } * { box-sizing: border-box; } 여기에 Base Style을 작성해주면 된다. 😉 스타일 초기화는 일반적으로, 아래 오픈소스를 사용한다. https://necolas.github.io/normalize.css/ 외부 스타일 링크를 사용해도 되고, 직접 적용 시켜도 되지만 난 scss를 사용할 예정이기 때문에 위 오픈소스를 scss로 넣어주고, 해당 scss파일을 Base CSS에서 import한다. 👉 src/styles/normalize.css ... 오픈소스 ... 👉 src/styles/globals.scss @import './normalize.scss'; ... 폰트 다음에 적용할 스타일은 폰트이다. 물론 내가 아는 폰트라고는 이전 프로젝트에서 사용해본 Spoqa Han Sans 뿐이다. 😥 폰트는 아래 링크에서 받아 styles/fonts 아래에 넣어주었다. https://openbase.com/js/spoqa-han-sans/documentation 👉 src/styles/typography.scss @font-face { font-family: 'Spoqa Han Sans'; font-weight: 700; src: local('Spoqa Han Sans Bold'), url('./fonts/SpoqaHanSansNeo-Bold.eot') format('embedded-opentype'), url('./fonts/SpoqaHanSansNeo-Bold.woff2') format('woff2'), url('./fonts/SpoqaHanSansNeo-Bold.woff') format('woff'), url('./fonts/SpoqaHanSansNeo-Bold.ttf') format('truetype'); } ... $font-spoqa: 'Spoqa Han Sans'; $font-regular: 400; $font-medium: 500; $font-bold: 700; 👉 src/styles/global.scss @import './normalize.scss'; @import './typography.scss'; html, body { padding: 0; margin: 0; font-family: -apple-system, BlinkMacSystemFont, $font-spoqa, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; font-size: 16px; } a { color: inherit; text-decoration: none; } * { box-sizing: border-box; }
BearBear
2022.02.06
@BearBear님이 새 포스트를 작성했습니다.
[10일차] 프론트엔드 구현 시작
프론트엔드 작업 시작! 자! 드디어 프론트엔드 작업을 시작하게 되었다. 프론트는 NextJS를 사용할 예정인데, NestJS는 React의 SSR을 쉽게 구현할 수 있게 도와주는 프레임워크이다. 물론 React 18버전에 나오면서 React에서도 SSR을 쉽게 사용할 수 있다는데.. 이건 다음 사이드 프로젝트를 하면서 적용해보자. 프론트엔드에선 크게 설계 → 목업 데이터로 디자인 구현 → 기능 추가 및 백엔드 연결 순서로 진행하려고 한다. 폴더 구조 가장 먼저 선택할 것은 폴더 구조였다. NextJS는 기본적으로 root 폴더 아래에 pages 폴더가 바로 존재한다. 하지만 이전에 진행한 프로젝트는 참고하던 프로젝트의 폴더 구조를 그대로 사용했었다. public/ ... src/ pages/ ... components/ ... lib/ ... ... styles/ ... ...setting files... 물론 src 구조는 여러 앱에서 흔히 사용되는 구조이므로, NextJS에서도 이를 기본적으로 지원한다. 근데 왜 이런 구조를 쓰는지 모르고, 그냥 따라할 수는 없다. 리서치를 해보자! 여러 문헌을 뒤져보던 와중, 아래와 같은 글귀를 발견했다. 링크 The ability to quickly add code without thinking much about where it should be (common). The ability to organize at your own pace when you feel like that thing has grown too big and all those pieces of code should be brought together (converting from common to modules). The ability to quickly find your code in your existing modules and to have an overview of how big a module is. 그냥 누가 언제 특정 페이지의 컴포넌트 위치를 물어보더라도 무조건 반사로 컴포넌트의 위치를 알 정도로 명확한 폴더 구조면 된다는 의미라고 생각한다. 또한 src directory에 대해서도 찾아봤지만, 따로 자료를 찾지는 못했다. 정리하자면 나한테 편한 구조, 또 누가 보더라도 명백한 구조가 최고이다 😉 public/ ... src/ components/ common/ common_component1/ common_component2/ ... page1/ page1_component1/ page1_component2/ page2/ page2_component1/ pages/ index summoner/[name]/ index statistics comment multi duo styles/ ... 기본적으로 컴포넌트에서는 코멘트 등록과 같은 뮤테이션만 실행하고, 디자인을 위한 데이터는 모두 페이지에서 받아온다. 또한 둘 이상의 페이지에서 사용되는 컴포넌트는 common 폴더 내부에 구현되며, 한 페이지에서만 사용되는 컴포넌트는 사용되는 페이지 이름으로 폴더를 생성해 그곳에 모아둔다. 페이지에서 전적 검색 결과 페이지는 동적 라우팅으로 구성하며, 메인페이지와 통계( 변경 예정 ), 댓글 페이지로 구성되어 있다. 나머지 module이나 lib 등은 추후 구현하면서 추가할 예정이다.
BearBear
2022.01.31
@BearBear님이 새 포스트를 작성했습니다.
[9일차] 백엔드 1차 구현 완료!
Match Base Resolver 기존에 recentMatch, matchDetail이 선언되어있던 match-basic과 match-detail은 matchType이라는 ResolveField가 필요하다는 공통점이 존재한다. 다른점이 더 많기 때문에 굳이 상속구조를 만들 필요는 없지만, 기존에 resolver를 구현할 때, base resolver를 구현해본 적은 없기 때문에 match-basic, match-detail의 부모인 match-base resolver를 구현해 봤다. base-resolver의 경우, 역시 nestjs의 공식문서에 가이드라인이 존재한다. https://docs.nestjs.com/graphql/resolvers#class-inheritance 위 링크를 따라가면서 차근차근 진행해, 아래와 같은 base resolver를 생성했다. import { Type } from '@nestjs/common'; import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { MatchDocument } from './schema/match.schema'; export function MatchBaseResolver<T extends Type<unknown>>(model: T): any {   @Resolver((of) => model, { isAbstract: true })   abstract class MatchBase {     @ResolveField((returns) => String)     matchType(@Parent() match: MatchDocument) {       return match.queueId === 420         ? '솔로 랭크'         : match.queueId === 430         ? '일반 게임'         : match.queueId === 440         ? '자유 5:5 랭크'         : match.queueId === 450         ? '무작위 총력전'         : match.queueId === 1400         ? '궁극기 주문서'         : '기타';     }   }   return MatchBase; } 기존 Args 지금, mutation의 대부분은 inputType이 아닌 일반 파라미터로 설정되어있다. async posts( @Args('createdAt') createdAt: number, @Args('limit') limit: number, ) 각 parameter에는 validation이 필요한데, 위와 같은 형태로 작성하다 보니 validation의 적용이 어려워졌다. 이러한 parameter들을 필요한 경우 inputType, 이외엔 전부 ArgsType으로 변경하려고 한다. 그리고 class-validator 라이브러리를 사용해 validation을 추가해주자! import { ArgsType, Field } from '@nestjs/graphql'; import { IsString } from 'class-validator'; @ArgsType() export class NameArgs { @Field((type) => String) @IsString() name: string; } Api 호출 및 파싱함수 기존 글을 읽어봤다면 알겠지만, 나는 각 폴더의 서비스에 API 호출 함수와, 파싱 함수를 작성해 두었었다. 👉 Timeline Service async getTimeline(matchId: string) { return await this.api.getApiResult('timelineBymatchId', matchId); } parseTimeline(data: JSON): TimelineDto { const events = data['info']['frames'] .map(({ events }) => events.reduce((acc, event) => { if (TimelineEventType.includes(event.type)) { acc.push({ ...event, timestamp: Math.floor( event.timestamp / data['info']['frameInterval'], ), }); } return acc; }, []), ) .flat(); return { matchId: data['metadata']['matchId'], events: events }; } 이 때는 각 서비스는 그 서비스의 데이터 구조를 다룬다는 생각으로 이처럼 작성했었지만, API를 호출하고 나면 데이터는 JSON타입이므로 파싱 함수에 들어가기 전까지 내부 구조를 알 수 없다. 즉, 구조도 모르는 모호한 데이터가 resolver에서 돌아다니는 것을 계속 보니.. 너무나 마음에 안 들었다. 😑 그래서 API 호출 코드와 파싱 함수를 private로 작성하고, API 호출 및 파싱 후 리턴 하는 함수를 만드려고 했지만.. DB에서 데이터를 꺼내오는 함수와 결과가 같아 네이밍도 헷갈리고, 내가 만약 이 코드를 처음 보면 findByName 함수와 getSummoner 함수의 차이를 절대 모를 것 같다는 생각이 들었다. 👉 findByName : 이름을 파라미터로 받아, DB에서 데이터를 꺼내는 함수 👉 getSummoner : 이름을 파라미터로 받아, API 호출 결과를 리턴하는 함수 따라서 API 호출 및 파싱 함수를 전부 API 서비스에 몰아넣었고, 결과적으로 각 폴더의 서비스들을 좀 더 간결하게 유지할 수 있었다. 😉 백엔드 1차 구현을 마치고.. 일단 백엔드 1차 구현을 마쳤다! Mutation은 총 13개로, 아래와 같다. basicSummonerInfo createComment deleteComment matchBuild ranking recentMatches updateChampionData updateIconData updateItemData updateRuneData updateSummonerData Query는 한개이다. mastery matchDetail posts comments 뮤테이션 이름만 보면 당연히 쿼리일 것 같은 ranking, matchBuild 뮤테이션도 DB에서 조회만 하는 게 아니라, API를 호출하고 DB를 업데이트하는 작업까지 하다 보니 쿼리가 많이 줄어들었다. 이러한 점이 조금 마음에 걸려 API 호출 및 DB 저장 뮤테이션과 DB 조회 쿼리로 분리해서 클라이언트측에게 각각 요청하도록 만들까 고민도 했었지만, 이건 서버 요청을 한번 더 할뿐만 아니라, DB 저장 후 바로 리턴하던 데이터를 DB 저장 -> 쿼리 요청 -> DB조회 -> 데이터 리턴 순서의, 중간 단계를 추가하기 때문에 그다지 좋은 방식은 아니라고 생각한다. 물론 지금 방식도 좋은 방식은 아니라고 생각이 드는지라, 어떻게 이 난관을 헤쳐나갈지.. 생각해봐야겠다. 지금까지 구성한 코드에서 부족한 부분을 찾자면.. API 호출을 실패했을 때 ( 주로 Limit 이슈 ) 대기 후 재 호출 할 것인지, 클라이언트에 호출이 실패했음을 알릴 것인지? 의미가 모호한 네이밍 ( match-basic, match-detail 등.. ) MatchBasicModel에 summonerInGameData를 추가하기 위해 recentMatch 뮤테이션 리턴 값에 Object.assign을 사용해 소환사의 puuid값을 강제로 넣어주어, summonerInGameData ResolveField의 parent 타입이 꼬인 것. 스파게티 코드로 가는 지름길이라고 생각한다. DataDragon의 DB 구조 ResolveField로 이루어진 아이템, 스펠 등의 이미지 경로를 받아오기 위해 매번 DB에 접근하는 방식 이외에도 함수 관심사의 분리에 실패하는 등 여러가지 문제가 존재하지만, 이제 프론트엔드 작업을 시작하지 않으면 기간 내에 끝내지 못하겠다는 생각이 든다. 우선 1차 구현을 이 정도로 마치고, 프론트엔드 작업을 끝낸 뒤 다시 손을 대 보자!
BearBear
2022.01.31
@BearBear님이 새 포스트를 작성했습니다.
[8일차] 백엔드 구현중의 고민들
ranking 변경 기존에 league-entry에 구현해 두었던 ranking 뮤테이션을 summoner-basic으로 옮기게 되었다. 이전 포스트에서도 언급했지만, ranking 뮤테이션은 소환사 레벨과 같은 기본정보들이 존재하지 않았다.😢 또한, 기존에 soleRank, freeRank의 경우 각 ResolveField마다 한 번의 API 호출을 하기때문에 랭킹 정보를 전부 받아오기 위해선 총 21번의 API 호출이 필요하므로, 이를 줄일 필요가 있었다. 이 경우엔 DB를 사용하기로 했다. basicSummonerInfo, ranking 쿼리가 들어올 때 소환사 이름으로 소환사정보를 받아온 뒤 소환사의 id값으로 Entries, 즉 리그 정보를 받아와 DB에 업데이트 한다. 그 뒤, ResolveField에서는 API를 호출하는 것이 아니라 DB에서 데이터를 받아오는 구조이다! 이러한 방식을 선택해 21번의 API 호출을 11번으로 줄일 수 있었다. 물론.. 이것도 적은 API 호출은 아니지만, 기존 방식의 ranking 뮤테이션은 API 호출 횟수가 너무 많아 뮤테이션이 실행 불가능한 현상이 발생했었다. 정말 다행이다.. 😢 이외의 뮤테이션은 따로 글로 적지 않고, 위와 동일한 방식을 사용해 구현한다. 다만 Mutation을 구현하면서, 혹은 구현하고 난 뒤 했던 고민들과 문제점, 알아두면 좋을만한 점들을 정리하려고 한다. Mastery 관련 구현하던 중.. 소환사의 챔피언 숙련도를 구현하던 중, 갑자기 이런 생각이 들었다. 현재 구상으로는 클라이언트에서 챔피언 숙련도를 보여줄 때, 해당 챔피언을 언제 플레이했는지 데이터가 필요하다. DB에는 마지막으로 플레이한 시간이 Unix millisecond로 저장되어 있기 때문에, 이 데이터를 몇시간 전, 혹은 몇일 전으로 변환해서 보여줘야 하는데, 이걸 백엔드에서 할지, 프론트엔드에서 할지 고민이 되었다. 현재 생각으로는 데이터 자체를 조작하는것이 아닌 데이터를 보여주는 방식을 변환하는 행위이므로 프론트엔드에서 조작하는게 낫다고 생각한다. 물론 이것도 리서치를 하면서 변경될 여지는 존재하지만.. 우선은 이 방식을 택했다! 매번 헷갈리는 InputType과 ArgsType 백엔드 코드를 짜다보면, InputType과 ArgsType이 진짜 매번 헷갈린다. 이 역시 내 배움이 아직 부족하다는 의미겠지만.. 다음엔 꼭 기억하자는 의미에서 기록을 남긴다! InputType과 ArgsType 모두 Query, 혹은 Mutation에서 Arguments들을 받고자 할 때 사용한다. 두 Type은 코드 작성 시 , 또 GraphQL 요청시 차이점을 보인다. 코드를 작성할 때 우선, 둘 다 Args() 데코레이터를 사용한다. 다만 InputType은 Args 데코레이터의 인자로 arguments의 이름을 넣어줘야 하고, ArgsType은 인자로 이름을 넣어주지 않아도 된다. GQL을 사용할 때 InputType은 Args에 넘겨준 arguments의 이름으로 하나의 객체를 보낸다. createAuthor(example: { firstName: "Brendan", lastName: "Eich" }) 하지만 ArgsType은 각각의 필드를 따로따로 전송한다는 차이점이 존재한다! createAuthor(firstName: "Brendan", lastName: "Eich") 이렇게 정리하면 대체 왜 맨날 헷갈리는지 모르겠는 명백한 차이점이지만.. ..더 열심히 기억하도록 하자! 비밀번호 해쉬 위의 의문점까지 해결하고 나니 전적 검색 - 전적 총합 페이지의 뮤테이션들은 전부 구현했다. 이제 소환사에게 한마디 페이지를 구현해야 하는데, 이 페이지에선 작성자의 닉네임과 비밀번호를 필요로 한다. 근데, 아무리 개인정보가 존재하지 않는다고 해도 DB에 비밀번호를 그대로 저장하는 건 보안 측면에서 좋지 않고 생각된다. 그래서 이 비밀번호를 암호화 해야 하는데, 이 때 사용하는 것이 바로 해싱, 혹은 암호화이다. 암호화는 양방향, 즉 암호화 키를 알면 복호화도 가능하지만 해싱은 단방향, 즉 복호화가 불가능하다는 차이점이 존재하다! nestjs에선 친절하게도 Hashing에 대한 기본적인 가이드를 제공하고 있다. Hashing 가이드 이 가이드를 참고해 CryptService를 생성했다. import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { ApiObject, ApiType } from './type/api.type'; import * as bcrypt from 'bcrypt'; @Injectable() export class CryptService { async hashPassword(password: string) { const salt = await bcrypt.genSalt(); return await bcrypt.hash(password, salt); } async comparePassword(password: string, hash: string) { return await bcrypt.compare(password, hash); } } 원래는 이러한 유틸리티 함수들을 모은 util service를 만들까 했지만, 지금 당장은 crypt만 필요하다보니 위처럼 cryptService만 만들었다.
BearBear
2022.01.22
@BearBear님이 새 포스트를 작성했습니다.
[7일차] DataDragon, API 구현
원래는 지난 시간에 이어, 데이터 모델을 설계하는 작업을 진행해야 한다. 하지만 아직 백엔드의 실력이 미천한지라.. 설계된대로 진행된다는 보장이 없더라. 😢 모든걸 완벽하게 설계하고 넘어가고 싶지만, 그러면 기간을 맞추지 못할 것 같다는 생각이 든다. 따라서 설계를 하긴 하되, 모든걸 검증하지는 않고 넘어가려고 한다. 물론 모델 설계는 따로 진행하고, 글로 남기지는 않으려고 한다. 코드가 너무 많아서 정리하기가 힘들다... DataDragon 채우기 오늘 할 작업은 바로 DataDragon을 채우는 것이다. DataDragon은 라이엇에서 제공하는 데이터 파일이라고 생각하면 편한데, 우리는 이 데이터파일을 정제하여 아이템이나 스펠마다 정해져있는 이미지 파일의 경로를 DB에 저장할 예정이다. 이렇게 저장된 이미지 경로는 클라이언트에서 이미지가 필요할 때 해당 품목의 타입 ( 아이템, 스펠 등 )과 그 품목의 unique key값을 통해 이미지의 경로를 리턴할 수 있게 만들 예정이다. 그럼, 가장 먼저 API 로직을 구성해 보자. API는 여러 리졸버에서 사용될 예정이므로 common이라는 폴더를 생성하고, api service를 추가해 그 안에 로직을 구현한다. //common/api/api.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; @Injectable() export class SummonerService { constructor(private readonly config: ConfigService) {} async getDataDragon(version: string, type: string) { return await axios.get( `http://ddragon.leagueoflegends.com/cdn/${version}/data/ko_KR/${type}.json`, ); } private getUri(url: string, parameter: string = null) { return ( this.config.get('api.base') + url + encodeURI(parameter) + `?api_key=${this.config.get('api.key')}` ); } } 지금 당장은 데이터드래곤만 받아오면 되지만, 추후에 사용할 API 호출을 위해 API url과 추가 파라미터를 받아 API uri를 리턴하는 getUri 함수도 생성해 두었다. ( 이 getUri 함수는 이후에 네번정도 변경되었다... ) 또한 이러한 공통 service에 대한 module도 구현하고, 이는 글로벌 모듈로 선언해주자. import { Global, Module } from '@nestjs/common'; import { ApiService } from './api/api.service'; @Global() @Module({ providers: [ApiService], exports: [ApiService], }) export class CommonModule {} 이제 dataDragon의 서비스와 리졸버를 구현해야 하는데, 여기가 조금 까다롭다. 모든 데이터의 형태가 다 다르기 때문에.. 전부 각각 파싱해줘야 한다.. 우선 아이템 정보부터 파싱해 보자. // datadragon service import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { DataDragon, DataDragonDocument } from './schema/data-dragon.schema'; @Injectable() export class DataDragonService { parseItem(data: JSON) { return Object.keys(data).map((id) => ({ id, path: data[id].image.full })); } } // datadragon resolver import { Mutation, Resolver } from '@nestjs/graphql'; import { ApiService } from 'src/common/api/api.service'; import { DataDragonService } from './data-dragon.service'; @Resolver((of) => Boolean) export class DataDragonResolver { version: string; constructor( private readonly api: ApiService, private readonly ddService: DataDragonService, ) { this.version = '12.1.1'; } @Mutation((returns) => Boolean) async updateItemData() { const data = await this.api.getDataDragon(this.version, 'item'); const pathList = this.ddService.parseItem(data.data.data); console.log(pathList); return true; } } 위처럼 API 호출을 통해 데이터를 받아오고, 파싱 함수에 넣어 id, path를 키로 갖는 객체 리스트를 생성했다. 이제 이를 기존에 선언해둔 스키마에 mapping하고, DB에 저장해주면 된다. 다음으로 해줘야 할 작업은 스키마를 등록해주는 작업이다. 아래와 같이 MongooseModule의 forFeature 메소드를 통해 스키마를 등록할 수 있다. import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { DataDragonResolver } from './data-dragon.resolver'; import { DataDragonService } from './data-dragon.service'; import { DataDragon, DataDragonSchema } from './schema/data-dragon.schema'; @Module({ imports: [ MongooseModule.forFeature([ { name: DataDragon.name, schema: DataDragonSchema }, ]), ], providers: [DataDragonResolver, DataDragonService], exports: [DataDragonService], }) export class DataDragonModule {} 스키마를 모듈에 등록했다면 @InjectModel() 데코레이터를 사용하여 DataDragon 모델을 service에 삽입할 수 있다. import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { ApiService } from 'src/common/api/api.service'; import { DataDragon, DataDragonDocument } from './schema/data-dragon.schema'; @Injectable() export class DataDragonService { constructor( @InjectModel(DataDragon.name) private readonly ddModel: Model<DataDragonDocument>, ) {} parseItem(data: JSON) { return Object.keys(data).map((id) => ({ id, path: data[id].image.full })); } } 다음은 DataDragon DTO를 정의한다. // datadragon.dto.ts export class DataDragonDto { type: string; base: string; pathes: { id: string; path: string; }[]; } 이는 service에서 함수의 parameter의 타입을 정의할 때 사용하며, 입력 받은 DTO를 그대로 저장만 해주면 된다. // datadragon service import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { ApiService } from 'src/common/api/api.service'; import { DataDragonDto } from './dto/data-dragon.dto'; import { DataDragon, DataDragonDocument } from './schema/data-dragon.schema'; @Injectable() export class DataDragonService { constructor( @InjectModel(DataDragon.name) private readonly ddModel: Model<DataDragonDocument>, ) {} parseItem(data: JSON) { return Object.keys(data).map((id) => ({ id, path: data[id].image.full })); } async create(dataDragonDto: DataDragonDto) { return await this.ddModel.create(dataDragonDto); } } // datadragon resolver import { Mutation, Resolver } from '@nestjs/graphql'; import { create } from 'domain'; import { ApiService } from 'src/common/api/api.service'; import { DataDragonService } from './data-dragon.service'; @Resolver((of) => Boolean) export class DataDragonResolver { version: string; constructor( private readonly api: ApiService, private readonly ddService: DataDragonService, ) { this.version = '12.1.1'; } @Mutation((returns) => Boolean) async updateItemData() { const data = await this.api.getDataDragon(this.version, 'item'); const pathList = this.ddService.parseItem(data.data.data); const result = await this.ddService.create({ type: 'item', base: '<http://ddragon.leagueoflegends.com/cdn/12.2.1/img/item/>', pathes: pathList, }); if (result) { return true; } else { return false; } } } 👉 결과 이렇게 아이템 하나에 대한 dataDragon 등록이 끝났다. 다른 데이터 (소환사아이콘과 티어, 챔피언 이미지, 스펠, 룬, 스텟 )에 대해 동일한 작업을 수행하자. 원래는 하나의 mutation에 파라미터를 받아서 API를 호출하겠지만, 이 경우엔 데이터 파싱 함수가 다 다르다보니, 적용이 조금 어렵다고 생각했다. 따라서 각 데이터당 하나의 뮤테이션으로 구현했다. import { Mutation, Resolver } from '@nestjs/graphql'; import { create } from 'domain'; import { ApiService } from 'src/common/api/api.service'; import { DataDragonService } from './data-dragon.service'; @Resolver((of) => Boolean) export class DataDragonResolver { version: string; constructor( private readonly api: ApiService, private readonly ddService: DataDragonService, ) { this.version = '12.1.1'; } @Mutation((returns) => Boolean) async updateItemData() { const data = await this.api.getDataDragon(this.version, 'item'); const pathList = this.ddService.parseItemAndIcon(data.data.data); await this.ddService.delete('item'); const result = await this.ddService.create({ type: 'item', base: '<http://ddragon.leagueoflegends.com/cdn/12.2.1/img/item/>', pathes: pathList, }); if (result) { return true; } else { return false; } } @Mutation((returns) => Boolean) async updateIconData() { const data = await this.api.getDataDragon(this.version, 'profileicon'); const pathList = this.ddService.parseItemAndIcon(data.data.data); await this.ddService.delete('profileicon'); const result = await this.ddService.create({ type: 'profileicon', base: '<http://ddragon.leagueoflegends.com/cdn/12.2.1/img/profileicon/>', pathes: pathList, }); if (result) { return true; } else { return false; } } @Mutation((returns) => Boolean) async updateSummonerData() { const data = await this.api.getDataDragon(this.version, 'summoner'); const pathList = this.ddService.parseSpellAndChampion(data.data.data); await this.ddService.delete('summoner'); const result = await this.ddService.create({ type: 'summoner', base: '<http://ddragon.leagueoflegends.com/cdn/12.2.1/img/spell/>', pathes: pathList, }); if (result) { return true; } else { return false; } } @Mutation((returns) => Boolean) async updateChampionData() { const data = await this.api.getDataDragon(this.version, 'champion'); const pathList = this.ddService.parseSpellAndChampion(data.data.data); await this.ddService.delete('champion'); const result = await this.ddService.create({ type: 'champion', base: '<http://ddragon.leagueoflegends.com/cdn/12.2.1/img/champion/>', pathes: pathList, }); if (result) { return true; } else { return false; } } @Mutation((returns) => Boolean) async updateRuneData() { const data = await this.api.getDataDragon(this.version, 'runesReforged'); const pathList = this.ddService.parseRune(data.data); await this.ddService.delete('runes'); const result = await this.ddService.create({ type: 'runes', base: '<http://ddragon.leagueoflegends.com/cdn/img/>', pathes: pathList, }); if (result) { return true; } else { return false; } } } import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { ApiService } from 'src/common/api/api.service'; import { DataDragonDto } from './dto/data-dragon.dto'; import { DataDragon, DataDragonDocument } from './schema/data-dragon.schema'; @Injectable() export class DataDragonService { constructor( @InjectModel(DataDragon.name) private readonly ddModel: Model<DataDragonDocument>, ) {} parseItemAndIcon(data: JSON) { return Object.keys(data).map((id) => ({ id, path: data[id].image.full })); } parseSpellAndChampion(data: JSON) { return Object.keys(data).map((id) => ({ id: data[id].key, path: data[id].image.full, })); } parseRune(data: Array<any>) { return data .map(({ id, icon, slots }) => [{ id, path: icon }].concat( slots .map(({ runes }) => runes.map(({ id, icon }) => ({ id, path: icon })).flat(), ) .flat(), ), ) .flat(); } async create(dataDragonDto: DataDragonDto) { return await this.ddModel.create(dataDragonDto); } async delete(type: string) { return await this.ddModel.deleteOne({ type }); } } 참고로, GraphQL 모듈도 App모듈에 등록해줘야한다. 😊 API 구현 데이터드래곤 작업도 얼추 마무리 되었으니, 이제 실제 API를 호출하고, 이를 DB에 저장하는 작업을 해보자. 이후에 실제 코드를 구현하게 되면 인터셉터를 사용하는 방식도 생각하고 있지만 지금 당장은 아무것도 없기 때문에.. 아직은 적용이 조금 어려울 것 같다. 우선은 각 서비스에 API호출 코드와 리턴받은 데이터 파싱, 그리고 DB 저장 코드까지 구현해 주자. ( 물론, DB에 저장하지 않는 경우, 혹은 파싱이 필요 없는 경우는 제외한다. ) 맨 처음 작업은 API 서비스에 API 호출 코드 구현이다. 지금은 데이터드래곤을 위한 getDataDragon 함수만 구현되어있지만, 이제 API 호출을 위한 getApiResult함수도 추가해 준다. 이 때, 함수는 간결하게 api 호출 타입과 추가 파라미터만 받을 수 있도록 하기위해 api 타입을 먼저 선언해 준다. export const ApiObject = { summonerByName: { path: '<https://kr.api.riotgames.com/lol/summoner/v4/summoners/by-name/>', behind: '', }, entriesById: { path: '<https://kr.api.riotgames.com/lol/league/v4/entries/by-summoner/lol/league/v4/entries/by-summoner/>', behind: '', }, challengersByQueue: { path: '<https://kr.api.riotgames.com/lol/league/v4/challengerleagues/by-queue/>', behind: '', }, masteryById: { path: '<https://kr.api.riotgames.com/lol/champion-mastery/v4/scores/by-summoner/lol/champion-mastery/v4/champion-masteries/by-summoner/>', behind: '', }, matchBymatchId: { path: '<https://asia.api.riotgames.com/lol/match/v5/matches/lol/match/v5/matches/>', behind: '', }, matchesByPuuid: { path: '<https://asia.api.riotgames.com/lol/match/v5/matches/by-puuid/>', behind: '/ids', }, timelineBymatchId: { path: '<https://asia.api.riotgames.com/lol/match/v5/matches/>', behind: '/timeline', }, } as const; export type ApiType = keyof typeof ApiObject; 타입 선언 이후엔 바로 Api 호출 코드를 생성한다. import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { ApiObject, ApiType } from './api.type'; @Injectable() export class ApiService { constructor(private readonly config: ConfigService) {} async getDataDragon(version: string, type: string) { return await axios.get( `http://ddragon.leagueoflegends.com/cdn/${version}/data/ko_KR/${type}.json`, ); } async getApiResult(type: ApiType, variable: string, params: any = {}) { return await axios.get(this.getUri(type, variable), { params: { ...params, api_key: this.config.get('api.key'), }, }); } private getUri(type: ApiType, variable: string) { return ApiObject[type].path + encodeURI(variable) + ApiObject[type].behind; } } api 타입을 좀 더 간결하게 만들고 싶었지만, api 호출을 위한 베이스 주소나 Api의 종단점이 파라미터가 아닌 경우가 존재해 위와같이 코드를 구현할 수 밖에 없었다.. 조금 더 나은 방법이 있는지 고민해 보자. summoner의 API 호출 및 DB저장은 아래와 같이 구현되었다. summoner DTO 선언 import { CommentDto } from './comment.dto'; import { MasteryDto } from './mastery.dto'; export class SummonerDto { accountId: string; profileIconId: number; id: string; name: string; puuid: string; summonerLevel: number; comment?: CommentDto[]; masteries: MasteryDto[]; } // mastery.dto.ts export class MasteryDto { championId: string; championLevel: number; championPoints: number; lastPlayTime: number; } // comment.dto.ts export class CommentDto { createdAt: number; nickname: string; password: string; text: string; } 서비스에 API 호출 함수와 DB저장 함수 선언 import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { ApiService } from 'src/common/api/api.service'; import { SummonerDto } from './dto/summoner.dto'; import { Summoner, SummonerDocument } from './schema/summoner.schema'; @Injectable() export class SummonerService { constructor( @InjectModel(Summoner.name) private readonly SummonerModel: Model<SummonerDocument>, private readonly api: ApiService, ) {} async getSummoner(name: string) { return await this.api.getApiResult('summonerByName', name); } async create(data: SummonerDto) { await this.SummonerModel.create(data); } async findByName(name: string) { return await this.SummonerModel.findOne({ name }, 'name puuid'); } } 테스트용 Mutation, query 코드 생성 import { ConfigService } from '@nestjs/config'; import { Args, Mutation, Resolver, Query } from '@nestjs/graphql'; import axios from 'axios'; import { SummonerService } from './summoner.service'; @Resolver() export class SummonerResolver { constructor(private readonly summonerService: SummonerService) {} /** * test용 코드 * */ @Mutation((returns) => String) async testUpdateSummoner(@Args('name') name: string) { const apiResult = await this.summonerService.getSummoner(name); await this.summonerService.create(apiResult.data); return 'test'; } @Query((returns) => String) async getSummoner(@Args('name') name: string) { const result = await this.summonerService.findByName(name); return result.name; } } 위와 같은 방식으로, API 호출을 모두 구현해 준다. ❗ 변경사항 구현하던 중, 굳이 매치 정보에 timeline 데이터를 추가할 필요가 없다고 생각되었다. timeline 데이터는 전적 결과 - 전적 종합 페이지에서 빌드에서만 필요한데, 사용처가 한정적인 데이터를 위해 매치 정보 하나 저장할 때 마다 30만줄이 넘는 데이터를 받아오고, 파싱할 필요는 없다고 생각된다. 따라서 match 스키마에서 timeline 스키마 삭제했다! 또한, 이 경우 timeline이 match에 종속된 데이터가 아니기 때문에, timeline 폴더를 새로 생성하는게 맞다고 생각했다. ❗ 구현을 완료하고.. API를 전부 구현한 뒤, 약간의 의문점이 생겼다. API를 호출하는 코드가 전부 개별 Service에 나뉘어져 있는데, 이게 과연 유지보수의 용이성을 증진시킨다고 할 수 있을까? 이 부분에서 개발 커뮤니티에 리서치를 해 봤는데, 얻은 답변은 별도의 service를 만들고, controller를 가져오는 것이 좋습니다.이였다. 이해한 바로는 API를 위한 service, 즉 지금 단순히 API 호출을 위한 함수만 구현되어있는 서비스에 종류 별 API 호출 코드를 작성하라는 말인 것 같은데, 이게 맞는지, 혹은 지금 작성한 방식이 맞는지 조금 더 고민해봐야 할 것 같다. Mutation 구현 이제, Mutation을 하나씩 구현해보자. 첫 번째로 구현할 mutaiton은 소환사의 기본 정보를 받아오는 basicSummonerInfo 이다. model 정의 → 필요한 비즈니스 로직을 Service에 정의 → Mutation 구현 순서로 진행한다. model 정의 👉 League-Entry Model import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() export class LeagueEntryModel { @Field((type) => String) summonerId: string; @Field((type) => String) summonerName: string; @Field((type) => String) queueType: string; @Field((type) => String) tier: string; @Field((type) => String) rank: string; @Field((type) => Number) leaguePoints: number; @Field((type) => Number) wins: number; @Field((type) => Number) losses: number; } 👉 Summoner-Entry Model import { ObjectType, OmitType, PickType } from '@nestjs/graphql'; import { LeagueEntryModel } from 'src/league-entry/model/league-entry.model'; @ObjectType() export class SummonerEntryModel extends PickType(LeagueEntryModel, [ 'tier', 'rank', 'leaguePoints', 'wins', 'losses', ] as const) {} 👉 Summoner-Basic Model import { Field, ObjectType } from '@nestjs/graphql'; import { SummonerEntryModel } from './summoner-entry.model'; @ObjectType() export class SummonerBasicModel { @Field((type) => String) iconPath: string; @Field((type) => String) name: string; @Field((type) => Number) summonerLevel: number; @Field((type) => Number) profileIconId: number; @Field((type) => String) id: string; @Field((type) => SummonerEntryModel, { nullable: true }) soleRank: SummonerEntryModel; @Field((type) => SummonerEntryModel, { nullable: true }) freeRank: SummonerEntryModel; } Service에 함수 구현 기본정보를 받아오는데에 필요한건 소환사 정보를 받아오는 함수와, 소환사 정보를 통해 리그 정보를 받아오는 함수와, 소환사 아이콘의 id값으로 이미지 경로를 받아오는 함수 세 가지가 필요하다. 소환사의 정보를 받아오는 함수는 이미 API로 구현되어 있으니 패스하고, 리그 정보는 LeagueEntry Service에, 이미지 경로는 DataDragon Service에서 담당하기로 했으니 각 서비스에 구현하자. 👉 LeagueEntry Service, getEntryByType async getEntryByType( summonerId: string, queueType: 'RANKED_SOLO_5x5' | 'RANKED_FLEX_SR', ) { const entries = (await this.api.getApiResult('entriesById', summonerId)) .data; return entries.find( ({ queueType: type }: LeagueEntryDto) => type == queueType, ); } 깔끔하게 메모리를 사용하지 않고 구현하고 싶었지만.. 우리의 API는 정보 하나만 보내주지 않기 때문에, 받아온 정보에서 원하는 정보만 필터링해 리턴해준다. 👉 DataDragon Service, getImagePath async getImagePath(type: string, key: number | string) { const dataDragon = await this.ddModel.findOne({ type, }); return ( dataDragon.base + dataDragon.pathes.find(({ id }) => id === key.toString()).path ); } 이 함수를 구현하면서, 내가 DB구조를 조금 잘못짠건가..라는 생각이 들었다. 지금 DB 구조에선 pathes라는 nested array에 각각의 데이터가 저장되어 있다 보니, type을 사용해 데이터를 받아오면, item 타입의 모든 데이터가 받아와진다. 받아온 데이터에서 filtering을 거치는 걸 보면 type과 id를 key값으로 저장할걸 그랬나..라는 생각이 들었다. 이건 추후에 구현하고 난 뒤 생각하도록 하겠다. 시간이 많이 없다.. Mutation 구현 import { ConfigService } from '@nestjs/config'; import { Args, Mutation, Resolver, Query, ResolveField, Parent, } from '@nestjs/graphql'; import axios from 'axios'; import { DataDragonService } from 'src/data-dragon/data-dragon.service'; import { LeagueEntryService } from 'src/league-entry/league-entry.service'; import { SummonerBasicModel } from './model/summoner-basic.model'; import { SummonerEntryModel } from './model/summoner-entry.model'; import { SummonerService } from './summoner.service'; @Resolver((of) => SummonerBasicModel) export class SummonerBasicResolver { constructor( private readonly summonerService: SummonerService, private readonly leagueEntryService: LeagueEntryService, private readonly dataDragonService: DataDragonService, ) {} @ResolveField((returns) => String) iconPath(@Parent() summoner: SummonerBasicModel) { return this.dataDragonService.getImagePath( 'profileicon', summoner.profileIconId, ); } @ResolveField((returns) => SummonerEntryModel) async soleRank(@Parent() summoner: SummonerBasicModel) { return await this.leagueEntryService.getEntryByType( summoner.id, 'RANKED_SOLO_5x5', ); } @ResolveField((returns) => SummonerEntryModel) async freeRank(@Parent() summoner: SummonerBasicModel) { return await this.leagueEntryService.getEntryByType( summoner.id, 'RANKED_FLEX_SR', ); } @Mutation((returns) => SummonerBasicModel) async basicSummonerInfo(@Args('name') name: string) { const apiResult = await this.summonerService.getSummoner(name); await this.summonerService.updateSummoner( apiResult.data['accountId'], apiResult.data, ); return apiResult.data; } } 마지막으로, Mutation 구현이다. basicSummonerInfo라는 뮤테이션 내부에서 리그 정보나 이미지 경로를 받아온 뒤 각각의 데이터들을 추가해 리턴해줄까 생각했지만, 그냥 ResolveField를 사용해 데이터를 넣어주기로 했다. 중간의 updateSummoner는 소환사를 닉네임으로 검색하기 때문에 닉네임을 변경했을 경우 comment 데이터가 초기화되는 현상이 발생할 수 있다. 따라서 소환사 정보에서 변하지 않는 값인 accountId를 사용해 데이터를 검색하고, 소환사 정보를 최신화 하는 코드이다. 잘 나온다..ㅎㅎ 리그 정보는 플레이하지 않으면 null값이 나오게 된다! 다음 구현은 ranking이다. ranking은 생각보다 간단하게 구현하였다. 소환사의 리그 정보를 담당하는 league-entry에서 Challenger 리그의 소환사 정보를 받아오고, 해당 데이터를 파싱해서 리턴해주면 되었다. 받아온 뒤 간단한 파싱 과정만 거치면 우리가 원하는 정보가 그대로 나오다보니 따로 ResolveField를 추가해주지 않아도 되었다! 👉 LeagueEntryModel import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() export class LeagueEntryModel { @Field((type) => String) summonerId: string; @Field((type) => String) summonerName: string; @Field((type) => String) queueType: string; @Field((type) => String) tier: string; @Field((type) => String) rank: string; @Field((type) => Number) leaguePoints: number; @Field((type) => Number) wins: number; @Field((type) => Number) losses: number; } 👉 LeagueEntryservice async getChallengerEntries() { return await this.api.getApiResult('challengersByQueue', 'RANKED_SOLO_5x5'); } getRanking(entries: LeagueEntryDto[]) { return entries .sort(({ leaguePoints: a }, { leaguePoints: b }) => { if (a > b) { return -1; } else if (a < b) { return 1; } else { return 0; } }) .slice(0, 10); } 👉 LeagueEntryResolver @Query((returns) => [LeagueEntryModel]) async Ranking() { const apiResult = await this.leagueEntryService.getChallengerEntries(); const parsed = this.leagueEntryService.parseChallengerEntries( apiResult.data, ); return this.leagueEntryService.getRanking(parsed); } 근데, 구현을 하고 리턴된 데이터를 보면서 곰곰히 생각해 보았다. 뭔가..없는 것 같은데..? 라는 생각이 불현듯 들기 시작했다. ..와이어프레임에서 간단한 데이터로 처리하다보니, 이미지로 생각을 안했던 소환사 아이콘이 없다는 사실을 이제야 깨달았다. 문제는 Challenger 리그 정보를 받아오는 API에선 소환사 아이콘에 대한 정보를 제공해주지 않는다는 점이다. 따라서 위에서 필터링한 상위 10명에 대해 각각 소환사 정보를 받아오는 API를 실행하고, 그 API의 데이터에서 소환사 아이콘 정보를 받아온 뒤, 아이콘 정보에 맞는 아이콘 경로를 DB에서 받아오는 작업이 추가로 필요하게 되었다. ..말만 들어도 작업량이나 사용될 리소스의 량이 상당한데, 다른 접근 방법이 없을지 고안해봐야겠다.
BearBear
2022.01.22
@BearBear님이 새 포스트를 작성했습니다.
[6일차] 백엔드 프로젝트 생성과 폴더구조, 스키마생성
백엔드 프로젝트 생성 우선, 5일차에서 정립한 DB구조를 구현하기 위해 백엔드 프로젝트를 생성했다. 백엔드는 nestjs로 구현하고 프론트에서 요청은 GraphQL로, 데이터베이스는 MongoDB를 사용할 예정이다. 우선 nestjs 프로젝트를 생성하고, @nestjs/cli new duo_gg_server 아래 패키지들을 설치한다. npm install --save @nestjs/mongoose mongoose npm i --save @nestjs/config MongoDB를 사용하기 위한 모듈, Configuration을 위한 모듈이다. ✅ Configuration 우선 Configuration을 AppModule에 추가해 보자. Configuration은 우리가 프로젝트를 다른 환경에서 실행해야 하는 경우, 이 환경 변수들의 값을 다르게 실행할 수 있도록 해 준다. 또한 보안을 유지해야 하는 Token key나 api 호출 키 등을 따로 분리할 수 있기 때문에, 가장 먼저 세팅해줘야 한다. @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.development', }), ], controllers: [AppController], providers: [AppService, ConfigService], }) export class AppModule {} isGlobal은 ConfigModule을 다른 모듈에서 전역적으로 사용할 수 있게 해주고, envFilePath는 이름 그대로 환경 변수 파일의 이름을 의미한다. 세팅을 마쳤으면 환경 변수 파일을 추가하고, 분리한 환경 변수 값으로 DB Connection을 진행한다. // .env.development NODE_ENV='development' PORT=3333 # DB DB_HOST="" DB_USERNAME="" DB_PASSWORD="" DB_NAME="" // src/config/configuration.ts export default () => ({ port: process.env.PORT || 3333, db: `mongodb+srv://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}/${process.env.DB_NAME}`, }); import configuration from './config/configuration'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.development', load: [configuration], }), ], controllers: [AppController], providers: [AppService, ConfigService], }) export class AppModule {} ✅ Mongoose 다음은 MongoDB를 연결해 보자. Mongoose 모듈을 사용하며 useFactory를 사용해 위에서 정의한 configuration을 동적으로 불러오고, db를 연결한다. ( useFactory 대신, useClass를 사용할 수도 있다. ) import configuration from './config/configuration'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.development', load: [configuration], }), MongooseModule.forRootAsync({ useFactory: async (config: ConfigService) => { return { uri: config.get('db'), }; }, inject: [ConfigService], }), ], controllers: [AppController], providers: [AppService, ConfigService], }) Mongoose는 스키마를 기반으로 동작하고, 스키마는 MongoDB의 컬렉션에 매핑되어 각 컬렉션의 형태를 정의한다. 폴더 구조 schema를 정의하기에 앞서, 폴더 구조를 먼저 정해야만 한다. 기본적으로 nestjs에선 cli를 사용해 컨트롤러나 모듈, 서비스를 생성할 수 있다. 이 때, src 아래에 각각의 네이밍에 맞는 폴더를 생성하고, 그 안에 컨트롤러, 모듈, 서비스 등을 모아둔다. 이는 nestjs의 graphql 공식 예제에서도 동일하게 사용하고 있으므로, 이 폴더 구조를 따라가려 한다. 다만, summoner와 comment처럼 일방적인 종속성을 지니는 schema를 분리할지, 혹은 한 폴더로 묶어 관리할 지 조금 고민이 되는데.. 우선은 묶어서 관리하고, 조금 더 리서치 후 변경할지 고민해 보자. schema 생성 다음으로, 이제 데이터 구조를 schema 파일로 구현한다. nestjs는 @Schema라는 데코레이터를 통해 이 스키마를 구현할 수 있다. 아래는 comment schema의 예시이고, 이와 유사한 형태로 모든 스키마를 구현했다. 👉 예시, comment import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import * as mongoose from 'mongoose'; const Types = mongoose.Schema.Types; @Schema({ timestamps: { currentTime: () => new Date().getTime() }, }) export class Comment { @Prop({ type: Types.Number }) createdAt: number; @Prop({ type: Types.String }) nickname: string; @Prop({ type: Types.String }) password: string; @Prop({ type: Types.String }) text: string; } export type CommentDocument = Comment & mongoose.Document; export const CommentSchema = SchemaFactory.createForClass(Comment); 혹시 다른 코드가 궁금하신분들은 아래 github에서 develop-v0.1.0을 참고해보세요. https://github.com/jub3907/duo_gg_server
BearBear
2022.01.22
@BearBear님이 새 포스트를 작성했습니다.
[5일차] 데이터 구조 설계
다음은 데이터 구조 설계이다. 데이터 구조 설계 다음 과정은 데이터 구조 설계였다. 데이터 구조 설계 시, 중점으로 생각한 것은 아래와 같았다. ✅ 각 게임의 정보와, 소환사의 정보를 분리한다. 소환사의 정보와, 각 게임의 정보를 분리하지 않으면, 전적검색 사이트의 특성상 한 게임의 모든 정보가 소환사에게 종속될 수 밖에 없다고 생각한다. 예를 들어, 두 사람이 같은 게임을 했다고 가정해 보자. 이 때 게임의 정보가 소환사에게 종속되어 있다면, 같은 게임의 데이터가 두 사람에게 각각 부여되어 있어야 한다. 이렇게 불필요한 중복 데이터가 존재할 필요가 없으므로, 게임의 데이터는 게임의 데이터대로 저장하고, 소환사는 해당 게임의 unique key 값만 저장하는 방식을 선택했다. 또한 api를 사용했을 때 리턴되는 데이터의 이름을 최대한 따라가는 방향으로 진행하되, 추후 사용하기 편한 형태로 파싱해 저장하려고 한다. ( 근데.. riot에서 리턴해주는 데이터들이 네이밍이 다 다르다.. 어디는 summoner라고 되어있고, 어디는 spell이라고 되어있고.... ㅠㅠ ) summoner accountId: 해당 계정의 고유 id profileIconId: 소환사가 설정해둔 프로필 아이콘의 unique key name: 소환사명 id: 소환사의 id. LeagueEntry를 업데이트 하는데에 사용된다. puuid: 소환사의 puuid summonerLevel: 소환사의 레벨 comments: 해당 소환사에게 남겨진 commnet 리스트 masteries: 챔피언에 대한 mastery 리스트 comment createdAt: 생성시간 nickname: 댓글 작성 닉네임 password: 암호화된 댓글 작성 비밀번호 text: 댓글 mastery champId: 챔피언의 unique id championLevel: 챔피언에 대한 숙련도 레벨 championPoints: 챔피언에 대한 숙련도 점수 leagueEntry queueType: 랭크의 종류. 파싱후 솔로랭크, 자유랭크만 저장 summonerId: 소환사의 id summonerName: 소환사명 tier: 소환사의 티어. ex) Challenger rank: 소환사의 랭크. ex) I leaguePoints: 소환사의 점수. ex) 1000 wins: 승리 수 losses: 패배 수 createdAt: 해당 데이터 생성 시간 updatedAt: 해당 데이터가 업데이트된 시간 matches matchId: 해당 매치에 대한 unique key gameCreation: 게임 생성 시간, Unix Milliseconds gameDuration: 게임 진행 시간, sec winner : 승리한 팀의 값. 100 / 200 queueId: 게임 타입 participants: 해당 매치에 대한 각 participant의 인게임 정보 리스트 timelines: 시간대 별 이벤트. 아이템구매/ 아이템철회/ 스킬정보 리스트 participant puuid: 해당 플레이어의 unique key participantsId: 해당 매치에서 참여자의 식별번호. 1~10 teamId: 블루, 레드팀 식별값. 100 / 200 win : 승리 여부 individualPosition: 포지션 champLevel: 인게임 종료 시 레벨 champId: 선택한 챔피언의 unique id championName: 선택한 챔피언 이름 dragonKills: 드래곤 킬수 baronKills: 바론 킬수 turretKills: 타워 부신수 goldEarned: 골드 획득량 kills: 킬 수 deaths: 데스 수 assists: 어시스트 수 totalMinionsKilled: 미니언 처치 횟수 wardsKilled: 와드 부순 횟수 wardsPlaces: 와드 설치 횟수 visionWardsBoughtInGame: 제어와드 구매 횟수 totalDamageDealtToChampions: 가한 피해량 totalDamageTaken: 받은 피해량 items: 아이템 정보를 저장하는 ??? 리스트 summoners: 스펠 정보를 저장하는 ??? 리스트 perks: 스탯과 룬에 대한 정보가 저장된 객체 ??? - 아이템, 스펠의 위치와 unique key 저장 -임시로 image라는 네이밍 사용.. index: 품목의 위치. id: 품목의 unique key perks flex: 선택한 스탯의 unique key defense: 선택한 스탯의 unique key offense: 선택한 스탯의 unique key primaryStyle: 메인 룬의 종류에 대한 unique key primarySelections: 선택한 세부 룬의 unique key 리스트 subStyle: 서브 룬의 종류에 대한 unique key subSelections: 선택한 세부 룬의 unique key 리스트 perks는 한 데이터에서 불러오지만, 해당 데이터를 사용하는 위치가 달라 편의성을 위해 따로 처리. timeline type: 이벤트 정보 timestamp: 이벤트가 발생한 시간, 60000이 1분 participantId: 이벤트 발생자의 인게임 식별번호 itemId: 아이템의 unique id, nullable skillSlot: 스킬의 id. DataDragon - 각 아이템이나 스텟 등의 unique key와 그 경로를 저장 type: items 데이터의 타입. base: 이미지 경로의 base path pathes: 각 품목에 대한 이미지 정보 리스트 path id: 품목에 대한 unique key path: 품목에 대한 이미지 경로 posts queueType: 랭크 타입 role: 포지션 name: 소환사명 tier: 티어 text: 게시글 본문 createdAt: 작성시간
BearBear
2022.01.22
@BearBear님이 새 포스트를 작성했습니다.
[4일차] 프로젝트 생성과 API 호출
Github 생성 실제 프로젝트에 들어가기 앞서, 우선 프로젝트를 생성해 주자! 이 프로젝트는 그냥 public으로 오픈해 둘 예정이다. 보고싶은 분이 계실지는 잘 모르겠지만.. 👉 https://github.com/jub3907/duo_gg_client 👉 https://github.com/jub3907/duo_gg_server 프로젝트도 생성했으니, 이제 결정해야 하는게 한 가지 존재한다. 바로 API를 어디서 호출할 지이다. 전적 검색 사이트를 구현하기 위해선 Riot에서 제공하는 API를 사용해야 한다. 즉, 내 DB에서 데이터를 받아오는 것이 아닌 외부 API를 사용해야 하는데, 이 작업은 백엔드에서도 가능하고, 프론트엔드에서도 가능하다. 하지만, API를 호출하는 과정에서 key가 필요하고, 이는 외부에 절대 노출되어서는 안되는 값이며, 일반적으로 프론트엔드에선 이 값을 몰라야 하기 때문에 백엔드에서 api를 호출한다. 또한 gql의 InMemoryCache를 사용해, api 호출 회수를 줄일 수도 있다고 생각된다!
BearBear
2022.01.22
@BearBear님이 새 포스트를 작성했습니다.
[3일차] 와이어프레임 작성
와이어 프레임 3일차는 와이어프레임을 작성한다. IA 문서나 기획문서를 토대로, 각 페이지에서 어떤 작업을 진행하는지 파악했다면 와이어프레임을 통해 웹 페이지의 골격을 생성하고, 웹 페이지가 실제로 어떤 구조로 형성될 것인지를 보여준다. 물론 나는 개발자이다보니.. 처음 다뤄보는 figma에서 이쁜 와이어 프레임을 그리기란 여간 어려운게 아니였다.. 여기저기 padding과 컴포넌트 사이즈가 제각각이라는 점에서, 와이어프레임이라고 불러도 되는건지 조금 의아하다.😢 ( 이거 그리는데에만 이틀이 소요되었다... ) 기존에는 로그인, 로그아웃 기능까지 추가되었었지만, 시간이 모자를 것 같다 생각해 우선 해당 기능은 제거되었다. 기존에 로그인 후 남기는 댓글 기능은 DC인사이드의 댓글처럼, 해당 댓글에 닉네임과 비밀번호를 개별저장 하는 방식으로 수정했다! https://www.figma.com/file/8pL1wrTzHhqTGAC8EU50HZ/duo.gg-wireframe?node-id=0%3A1