diff --git a/.babelrc.js b/.babelrc.js new file mode 100644 index 00000000..19d0ba20 --- /dev/null +++ b/.babelrc.js @@ -0,0 +1,4 @@ +// https://velog.io/@sooyun9600/React-is-not-defined-에러-해결 +module.exports = { + presets: ['@babel/preset-env', ['@babel/preset-react', { runtime: 'automatic' }]], +} diff --git a/.env b/.env new file mode 100644 index 00000000..729754a4 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +# 안중요한 내용이라 깃헙에 올립니다! +OMDB_API_KEY=7035c60c +PORT=6060 \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..e6edf7c3 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,30 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: ['plugin:react/recommended', 'airbnb', 'prettier'], + overrides: [], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['react'], + rules: { + 'no-console': 'warn', // console 사용시 warning이 나타납니다. (production모드일떄 지워줘야하니까 warn합니다.) + 'linebreak-style': 'off', // crlf, lf 상관없도록 변경합니다. (윈도우, 맥 번갈아서 쓰고 있습니다.) + semi: ['error', 'never'], // semicolon을 제거합니다. + 'no-underscore-dangle': ['warn', { allowAfterThis: true }], // this._bar()는 가능하고 그 외는 warning이 나타납니다. + 'max-len': ['warn', { code: 120, tabWidth: 2 }], + 'spaced-comment': 'off', // 주석 스타일 강제 취소 (html, css 파일에서 주석을 달면 이 에러가 납니다.) + 'no-param-reassign': 'off', // DOM을 다루다보면 재할당 할 일이 생긴다고 판단했습니다. + 'import/extensions': 'warn', // js파일을 spliting하기 위해 on -> warn으로 변경 하였습니다. + 'prefer-arrow-callback': 'off', // this를 다루게 될 일이 있을 수도 있으니까 off하였습니다. + 'no-plusplus': 'off', + 'no-await-in-loop': 'warn', + 'prefer-const': 'off', + // library가 .js인 경우도 있기 때문에 .jsx, .js를 모두 컴포넌트로 사용할 수 있습니다. + 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }], + }, +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..53d55ff3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules + +build +build-ssr +.ssr-server-cache \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..25019579 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "singleQuote": true, + "semi": false, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 120, + "singleAttributePerLine": false +} diff --git a/.vscode/javascript.code-snippets b/.vscode/javascript.code-snippets new file mode 100644 index 00000000..9dbed1e6 --- /dev/null +++ b/.vscode/javascript.code-snippets @@ -0,0 +1,59 @@ +{ + // scope 속성 주의! + "Print to console log": { + "prefix": "log", + "body": ["console.log($0)"], + "description": "Log output to console", + "scope": "javascript, typescript, javascriptreact, typescriptreact" + }, + "Print to console error": { + "prefix": "elog", + "body": ["console.error($0)"], + "description": "Log output to console", + "scope": "javascript, typescript, javascriptreact, typescriptreact" + }, + "Throw new Error": { + "prefix": "tne", + "body": ["throw new Error($0)"], + "scope": "javascript, typescript, javascriptreact, typescriptreact" + }, + "ES6 import from ": { + "prefix": "imf", + "body": ["import $0 from '$1'"], + "scope": "javascript, typescript, javascriptreact, typescriptreact" + }, + "for loop": { + "prefix": "for", + "body": ["for (let i= 0, len= $0; i < len; i++) {\n \n}"] + }, + "Try Catch Scope": { + "prefix": "tc", + "body": "try {\n $0\n} catch (err) {\n throw new Error(err)\n}", + "scope": "javascript, typescript, javascriptreact, typescriptreact" + }, + "reactFunction": { + "prefix": "rfc", + "body": "import React from 'react';\n\nexport default function ${1:${TM_FILENAME_BASE}}() {\n\treturn (\n\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\n", + "description": "Creates a React Function component", + "scope": "javascriptreact, typescriptreact" + }, + "reactStatelessImplicitReturn": { + "prefix": "rsi", + "body": "import React from 'react';\n\nexport const ${1:${TM_FILENAME_BASE}} = (props) => {\n\t\t\t$0\n\t};", + "description": "Creates a React Function component", + "scope": "javascriptreact, typescriptreact" + }, + // postcss 에시입니다. 고쳐쓰세요! + "Import Module CSS": { + "prefix": "si", + "body": ["import styles from './$TM_FILENAME_BASE.module.css'"], + "description": "Import PostCSS", + "scope": "javascriptreact, typescriptreact" + }, + "ClassName": { + "prefix": "cn", + "body": ["className={styles.$0}"], + "description": "Adding className", + "scope": "javascriptreact, typescriptreact" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e5cac74b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,48 @@ +{ + // * prettier default + // .prettierrc.json || .prettierrc.json + // "editor.defaultFormatter": "esbenp.prettier-vscode", // prettier가 deafault fomatter 입니다 + // "prettier.singleQuote": true, // single quote를 사용합니다. + // "prettier.semi": false, + // "prettier.tabWidth": 2, + // "prettier.trailingComma": "all", + // "prettier.singleAttributePerLine": false, + // "prettier.printWidth": 100, + + // * prettier 개별 설정 + "[javascript]": {}, + + "[typescriptreact]": { + "prettier.singleAttributePerLine": true + }, + "[javascriptreact]": { + "prettier.singleAttributePerLine": true + }, + + "eslint.validate": [ + "javascript", // elint검사가 필요한 경우 이 곳에 언어를 추가해주세요 + "html" // Add "html" to enable linting `.html` files. + ], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true // eslint auto fix를 enableg합니다. + }, + + // * vscode 기능 설정 + "editor.linkedEditing": true, + "editor.minimap.autohide": true, + "eslint.enable": true, + "window.zoomLevel": 1, // zoom level을 기본값으로 설정 + "editor.snippetSuggestions": "top" // intellisense가 최상위에 sinnpet을 출력합니다. + + // theme + // "editor.tokenColorCustomizations": { + // "comments": "#8d818e", + // "functions": "#e27979", + // "types": "#c2f2e3", + // "strings": "#c9e3f3", + // "keywords": "#f9dbdb", + // "numbers": "#f5f3f3", + // "variables": "#7091ee", + // "textMateRules": [] + // } +} diff --git a/README.md b/README.md index 32dc87a5..07d91122 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,69 @@ -# 🎬 영화 검색 - -주어진 API를 활용해 '[완성 예시](https://stupefied-hodgkin-d9d350.netlify.app/)' 처럼 자유롭게 영화 검색 기능을 구현해보세요! -과제 수행 및 리뷰 기간은 별도 공지를 참고하세요! - -## 과제 수행 및 제출 방법 - -``` -KDT기수번호_이름 | E.g, KDT0_ParkYoungWoong -``` - -1. 현재 저장소를 로컬에 클론(Clone)합니다. -1. 자신의 본명으로 브랜치를 생성합니다.(구분 가능하도록 본명을 꼭 파스칼케이스로 표시하세요, `git branch KDT0_ParkYoungWoong`) -1. 자신의 본명 브랜치에서 과제를 수행합니다. -1. 과제 수행이 완료되면, 자신의 본명 브랜치를 원격 저장소에 푸시(Push)합니다.(`main` 브랜치에 푸시하지 않도록 꼭 주의하세요, `git push origin KDT0_ParkYoungWoong`) -1. 저장소에서 `main` 브랜치를 대상으로 Pull Request 생성하면, 과제 제출이 완료됩니다!(E.g, `main` <== `KDT0_ParkYoungWoong`) - -- `main` 혹은 다른 사람의 브랜치로 절대 병합하지 않도록 주의하세요! -- Pull Request에서 보이는 설명을 다른 사람들이 이해하기 쉽도록 꼼꼼하게 작성하세요! -- Pull Request에서 과제 제출 후 절대 병합(Merge)하지 않도록 주의하세요! -- 과제 수행 및 제출 과정에서 문제가 발생한 경우, 바로 담당 멘토나 강사에서 얘기하세요! - -## 요구사항 - -필수 요구사항은 꼭 달성해야 하는 목표로, 수정/삭제는 불가하고 추가는 가능합니다. -선택 요구사항은 단순 예시로, 자유롭게 추가/수정/삭제해서 구현해보세요. -각 요구사항은 달성 후 마크다운에서 `- [x]`로 표시하세요. - -### ❗ 필수 - -- [ ] 영화 제목으로 검색이 가능해야 합니다! -- [ ] 검색된 결과의 영화 목록이 출력돼야 합니다! -- [ ] 단일 영화의 상세정보(제목, 개봉연도, 평점, 장르, 감독, 배우, 줄거리, 포스터 등)를 볼 수 있어야 합니다! -- [ ] 실제 서비스로 배포하고 접근 가능한 링크를 추가해야 합니다. - -### ❔ 선택 - -- [ ] 한 번의 검색으로 영화 목록이 20개 이상 검색되도록 만들어보세요. -- [ ] 영화 개봉연도로 검색할 수 있도록 만들어보세요. -- [ ] 영화 목록을 검색하는 동안 로딩 애니메이션이 보이도록 만들어보세요. -- [ ] 무한 스크롤 기능을 추가해서 추가 영화 목록을 볼 수 있도록 만들어보세요. -- [ ] 영화 포스터가 없을 경우 대체 이미지를 출력하도록 만들어보세요. -- [ ] 영화 상세정보가 출력되기 전에 로딩 애니메이션이 보이도록 만들어보세요. -- [ ] 영화 상세정보 포스터를 고해상도로 출력해보세요. (실시간 이미지 리사이징) -- [ ] 차별화가 가능하도록 프로젝트를 최대한 예쁘게 만들어보세요. -- [ ] 영화와 관련된 기타 기능도 고려해보세요. - -## API 기본 사용법 - -```curl -curl https://omdbapi.com/?apikey=7035c60c - \ -X 'GET' -``` - -## 영화 목록 검색 - -영화 목록은 한 번에 최대 10개까지 검색할 수 있습니다. - -파라미터 | 설명 | 기본값 ----|----------------------|--- -`s` | 검색할 영화 제목(필수!) | - -`y` | 검색할 개봉연도, 빈 값은 전체 검색 | - -`page` | 검색할 페이지 번호 | `1` - -요청 코드 예시: - -```js -async function getMovies(title, year = '', page = 1) { - const s = `&s=${title}` - const y = `&y=${year}` - const p = `&page=${page}` - try { - const res = await fetch(`https://omdbapi.com/?apikey=7035c60c${s}${y}${p}`) - const json = await res.json() - if (json.Response === 'True') { - const { Search: movies, totalResults } = json - return { - movies, - totalResults - } - } - return json.Error - } catch (error) { - console.log(error) - } -} -``` - -응답 데이터 타입 및 예시: - -```ts -interface ResponseValue { - Search: Movie[] // 검색된 영화 목록, 최대 10개 - totalResults: string // 검색된 영화 개수 - Response: 'True' | 'False' // 요청 성공 여부 -} -interface Movie { - Title: string // 영화 제목 - Year: string // 영화 개봉연도 - imdbID: string // 영화 고유 ID - Type: string // 영화 타입 - Poster: string // 영화 포스터 이미지 URL -} -``` - -```json -{ - "Search": [ - { - "Title": "Frozen", - "Year": "2013", - "imdbID": "tt2294629", - "Type": "movie", - "Poster": "https://m.media-amazon.com/images/M/MV5BMTQ1MjQwMTE5OF5BMl5BanBnXkFtZTgwNjk3MTcyMDE@._V1_SX300.jpg" - }, - { - "Title": "Frozen II", - "Year": "2019", - "imdbID": "tt4520988", - "Type": "movie", - "Poster": "https://m.media-amazon.com/images/M/MV5BMjA0YjYyZGMtN2U0Ni00YmY4LWJkZTItYTMyMjY3NGYyMTJkXkEyXkFqcGdeQXVyNDg4NjY5OTQ@._V1_SX300.jpg" - } - ], - "totalResults": "338", - "Response": "True" -} -``` - -## 영화 상제정보 검색 - -단일 영화의 상제정보를 검색합니다. - -파라미터 | 설명 | 기본값 ----|---|--- -`i` | 검색할 영화 ID(필수!) | -`plot` | 줄거리 길이 | `short` - -요청 코드 예시: - -```js -async function getMovie(id) { - const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${id}&plot=full`) - const json = await res.json() - if (json.Response === 'True') { - return json - } - return json.Error -} -``` - -응답 데이터 타입 및 예시: - -```ts -interface ResponseValue { - Title: string // 영화 제목 - Year: string // 영화 개봉연도 - Rated: string // 영화 등급 - Released: string // 영화 개봉일 - Runtime: string // 영화 상영시간 - Genre: string // 영화 장르 - Director: string // 영화 감독 - Writer: string // 영화 작가 - Actors: string // 영화 출연진 - Plot: string // 영화 줄거리 - Language: string // 영화 언어 - Country: string // 영화 제작 국가 - Awards: string // 영화 수상 내역 - Poster: string // 영화 포스터 이미지 URL - Ratings: Rating[] // 영화 평점 정보 - Metascore: string // 영화 메타스코어 - imdbRating: string // 영화 IMDB 평점 - imdbVotes: string // 영화 IMDB 투표 수 - imdbID: string // 영화 고유 ID - Type: string // 영화 타입 - DVD: string // 영화 DVD 출시일 - BoxOffice: string // 영화 박스오피스 - Production: string // 영화 제작사 - Website: string // 영화 공식 웹사이트 - Response: string // 요청 성공 여부 -} -interface Rating { // 영화 평점 정보 - Source: string // 평점 제공 사이트 - Value: string // 평점 -} -``` - -```json -{ - "Title": "Frozen", - "Year": "2013", - "Rated": "PG", - "Released": "27 Nov 2013", - "Runtime": "102 min", - "Genre": "Animation, Adventure, Comedy", - "Director": "Chris Buck, Jennifer Lee", - "Writer": "Jennifer Lee, Hans Christian Andersen, Chris Buck", - "Actors": "Kristen Bell, Idina Menzel, Jonathan Groff", - "Plot": "When the newly crowned Queen Elsa accidentally uses her power to turn things into ice to curse her home in infinite winter, her sister Anna teams up with a mountain man, his playful reindeer, and a snowman to change the weather co...", - "Language": "English, Norwegian", - "Country": "United States", - "Awards": "Won 2 Oscars. 82 wins & 60 nominations total", - "Poster": "https://m.media-amazon.com/images/M/MV5BMTQ1MjQwMTE5OF5BMl5BanBnXkFtZTgwNjk3MTcyMDE@._V1_SX300.jpg", - "Ratings": [ - { "Source": "Internet Movie Database", "Value": "7.4/10" }, - { "Source": "Rotten Tomatoes", "Value": "90%" }, - { "Source": "Metacritic", "Value": "75/100" } - ], - "Metascore": "75", - "imdbRating": "7.4", - "imdbVotes": "620,489", - "imdbID": "tt2294629", - "Type": "movie", - "DVD": "18 Mar 2014", - "BoxOffice": "$400,953,009", - "Production": "N/A", - "Website": "N/A", - "Response": "True" -} -``` +# Move App Challenge + +[결과물 보기](http://ec2-3-84-213-170.compute-1.amazonaws.com) (https적용 안했습니다!) + +# ❗ 필수 + +- [x] 영화 제목으로 검색이 가능해야 합니다! +- [x] 검색된 결과의 영화 목록이 출력돼야 합니다! +- [x] 단일 영화의 상세정보(제목, 개봉연도, 평점, 장르, 감독, 배우, 줄거리, 포스터 등)를 볼 수 있어야 합니다! +- [x] 실제 서비스로 배포하고 접근 가능한 링크를 추가해야 합니다. + +# 선택 + +- [x] 영화 개봉연도로 검색할 수 있도록 만들어보세요. +- [x] 영화 목록을 검색하는 동안 로딩 애니메이션이 보이도록 만들어보세요. +- [x] 무한 스크롤 기능을 추가해서 추가 영화 목록을 볼 수 있도록 만들어보세요. +- [x] 영화 포스터가 없을 경우 대체 이미지를 출력하도록 만들어보세요. +- [x] 영화 상세정보 포스터를 고해상도로 출력해보세요. (실시간 이미지 리사이징) +- [x] 차별화가 가능하도록 프로젝트를 최대한 예쁘게 만들어보세요. +- [x] API 기본 사용법 + +# 완성하지 못한 부분 (문제점) + +### 1. webpack-dev-sever의 hot reload기능을 사용하지 못했습니다. + +서버에서 EMS와 CJS를 같이 사용하기 때문에 번들링을 해줘야합니다. + +그래서 프론트 서버 번들링, 리액트 앱 번들링 **총 두 번 번들링을 하게됩니다.** + +여기서 해결하지 못한 부분이 있습니다. + +1. webpack-dev-server는 번들링 결과를 캐싱하여 개발 서버를 제공해주는데 + webpack-dev-server로 서버를 번들링하여 결과물을 개발 서버를 제공해줄 수 있는 방법을 못찾았습니다. +2. 프론트 서버와 프론트 리액트 앱 총 두개의 entry가 있는데 각각의 entry는 rules가 달라야합니다. entry마다 rules를 다르게 설정하여 번들링하는 방법을 찾지 못했습니다. + +### 2. React 18의 Suspense를 사용하지 못했습니다. + +React18의 Steaming SSR은 서버 컴포넌트의 랜더링이 비동기적으로 동작합니다. + +이를 활용할 생각이였으나 해결하지 못한 부분이 있습니다. + +1. /detail 페이지로 요청이 들어올 떄, 서버에서 OMDB로 API를 보냅니다. 이떄 서버 컴포넌트는 로딩창(fallback)을 클라이언트에 제공하도록 만들고 싶은데 데이터 패칭 전략을 어떻게 해야할지 잘 모르겠습니다. + +그래서 nextjs12 버전에서 SSR하는 방식으로 대체 해놓은 상태입니다. + +# Reference + +[ReactDom.renderToString메소드를 사용한 SSR세팅](https://github.com/Octanium91/react-app-ssr) + +[New Suspense SSR Architecture in React Discusstion](https://github.com/reactwg/react-18/discussions/37) + +[react 18 Suspense알아보기 throw Promise](https://velog.io/@xiniha/React-Suspense-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0) + +[React 18 New Suspense SSR Demo](https://codesandbox.io/s/kind-sammet-j56ro?file=/src/index.js:267-278) + +[compression](https://velog.io/@onejaejae/Node-jsExpress-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4-%EC%82%AC%EC%9A%A9-body-paresr-compression) + +[babel-register 참고, 외에도 많은 바벨 관련 정보가 많습니다.](https://jbee.io/etc/Everything-about-babel/) + +[webpack-prefetch](https://velog.io/@minsu2344/Vue-router-%EC%84%A4%EC%A0%95%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%AF%B8%EB%A6%AC-cache%EC%97%90-%EB%8B%B4%EC%95%84%EB%91%90%EA%B8%B0webpackChunkName-webpackPrefetch) + +[react-router-dom v6](https://velog.io/@soryeongk/ReactRouterDomV6) +[react-route-dom ssr guides](https://reactrouter.com/en/main/guides/ssr) + +[css minimize webpack plugin](https://webpack.js.org/plugins/css-minimizer-webpack-plugin/) + +[webpack hot reload(미적용)](https://webpack.kr/guides/hot-module-replacement/) + +[heropy님의 실시간 이미지 리사이징 블로그(미적용)](https://heropy.blog/2019/07/21/resizing-images-cloudfrount-lambda/) diff --git a/index.js b/index.js new file mode 100644 index 00000000..c6c1997e --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +// 이 파일은 Front react app의 entry파일 입니다. +// ./scripts/build.js에서 사용하고 있습니다. +// ! 내용을 추가하거나 삭제하지 말아주세요. diff --git a/legacy/render.js b/legacy/render.js new file mode 100644 index 00000000..1ef5f0d2 --- /dev/null +++ b/legacy/render.js @@ -0,0 +1,173 @@ +// ! 테스트 파일입니다 + +import React from 'react' +import { renderToPipeableStream } from 'react-dom/server' +import { StaticRouter } from 'react-router-dom/server' +import dotenv from 'dotenv' + +import { QueryClient, QueryClientProvider } from 'react-query' +import App from '../src/App' +import { MovieProvider } from '../src/context/movieContext' + +import { getSearchMovies, getMovieDetailById } from '../service/api' + +/** + * * 서버에서 라우터 정보를 react-router-dom에게 보냅니다. + * * StaticRouter가 없다면 SSR시 StaticRouter가 감싸는 컴포넌트 내에서 useRoutes context를 사용할 수 없습니다. + * * StaticRouter(서버 라우터) == BrowserRouter(클라이언트 라우터) + */ +import { ABORT_DELAY } from './delays' + +dotenv.config() + +const assets = { + 'main.js': '/main.js', + 'main.css': '/main.css', +} +function createDelay() { + let done = false + let promise = null + let testData = '' + return { + read() { + if (done) { + return testData + } + if (promise) { + throw promise + } + promise = new Promise((resolve) => { + getSearchMovies().then((res) => { + testData = res + done = true + resolve() + }) + setTimeout(() => { + done = true + promise = null + testData = 'foo' + resolve() + }, 9000) + }) + throw promise + }, + } +} + +/** + * + * @param {string} url + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +export default async function render(url, req, res) { + res.socket.on('error', (error) => { + console.error('soket연결에 실패했습니다.\n', error) + }) + let didError = false + + /** + * @description react 18이전엔 아래와 같이 사용했습니다. + * @example import {renderToString} from 'react-dom/server'; + * res.send( + * '' + + * renderToString( + * + * + * , + * ) + */ + const delay = createDelay() + const data = { + delay, + data: '', + } + + if (req.path.endsWith('detail')) { + const movieDetailData = await getMovieDetailById(req.query.id) + + movieDetailData.Ratings.map((rating) => { + switch (rating.Source) { + case 'Internet Movie Database': + rating.SourceImage = '/imdb_icon.png' + break + case 'Rotten Tomatoes': + rating.SourceImage = '/rotten_icon.png' + break + case 'Metacritic': + rating.SourceImage = '/matatric_icon.png' + break + default: + rating.SourceImage = '/noImage.png' + break + } + return rating + }) + data.data = JSON.stringify(movieDetailData) + } + + const queryClient = new QueryClient() + + const stream = renderToPipeableStream( + + + + + + + + + MOVIE DATABASE + + + + +