
이 프로젝트는 강의를 들으면서 진행한 프로젝트인데, 영화 제목을 검색해주는 애플리케이션이다. 자바스크립트로 구현 후 타입스크립트로 리팩토링하였다.
이 프로젝트를 통해 리액트를 사용한 컴포넌트 단위 SPA이 아니라, 자바스크립트의 Class를 활용한 컴로넌트 단위 SPA 구현에 대해 배울 수 있었다. 그리고 타입스크립트에 대해서도 좀 더 알게 되었다.
그래서 리액트에서의 타입스크립트 공부를 위해 리액트로 리팩토링를 하게 되었다.
타입스크립트: https://movie-search-eight-rho.vercel.app/#/
리액트: https://movie-search-react-ochre.vercel.app/
interface ComponentPayload {
tagName?: string
props?: { [key: string]: unknown }
state?: { [key: string]: unknown }
}
export class Component {
public el
public props
public state
constructor(payload:ComponentPayload = {}) {
// 컴포넌트 생성시 최상위 요소 태그, props와 state 생성
const { tagName = 'div', props = {}, state = {} } = payload;
this.el = document.createElement(tagName); // 컴포넌트의 최상위 요소
this.props = props; // 부모 컴포넌트에서 받는 데이터
this.state = state; // 컴포넌트 안에서 사용할 데이터
this.render();
}
render() {} // 컴포넌트를 렌더링하는 함수
}
export class Store<S> {
public state = {} as S // 초기화에서 {}이지만 할당 시 S타입으로
private observers = {} as StoreObservers
// 초기화에서 {}이지만 할당 시 StoreObservers타입으로
constructor(state: S) {
for(const key in state) {
// 각 상태에 대한 변경 감시(Setter) 설정
Object.defineProperty(this.state, key, {
get: () => state[key],
set: val => {
state[key] = val // 상태 변경
if(Array.isArray(this.observers[key])) { // 호출할 콜백이 있는 경우!
this.observers[key].forEach(observer => observer(val))
}
}
})
}
}
//상태 변경 구독
subscribe(key: string, cb: subscribeCallback) {
Array.isArray(this.observers[key])
? this.observers[key].push(cb)
: this.observers[key] = [cb]
// 예시)
// observers = {
// 구독할상태이름: [실행할콜백1, 실행할콜백2]
// movies: [cb, cb, cb],
// message: [cb]
// }
}
}
export default class MovieList extends Component {
constructor() {
super()
// state가 변경되었다면 cb실행(재렌더링)
movieStore.subscribe('movies', () => this.render())
movieStore.subscribe('message', () => this.render())
movieStore.subscribe('loading', () => this.render())
}
render() {
this.el.classList.add('movie-list')
this.el.innerHTML = /* html */ `
${movieStore.state.message
? `<div class="message">${movieStore.state.message}</div>`
: `<div class="movies"></div>` }
<div class="the-loader hide"></div>
`
const moviesEl = this.el.querySelector('.movies')
moviesEl?.append(
...movieStore.state.movies.map(movie => new MovieItem({ movie }).el)
)
const loaderEl = this.el.querySelector('.the-loader')
movieStore.state.loading
? loaderEl?.classList.remove('hide')
: loaderEl?.classList.add('hide')
}
}
interface MovieListProps {
movies: moviesState;
}
export const MovieList: React.FC<MovieListProps> = ({ movies }) => {
return (
<MovieListContainer>
{movies.message ? (
<div className="message">{movies.message}</div>
) : (
<div className="movies">
{movies.movies.map((movie) => (
<MovieItem key={movie.imdbID} movie={movie} />
))}
</div>
)}
{movies.loading && <div className="the-loader"></div>}
</MovieListContainer>
);
};
interface Route {
path: string
component: typeof Component
}
type Routes = Route[]
function routeRender(routes: Routes) {
// 해시 없을 경우 현재 url를 /#/으로 대체한다
if (!location.hash) {
history.replaceState(null, '', '/#/'); // (상태, 제목, 주소)
}
const routerView = document.querySelector('router-view');
const [hash, querystring = ''] = location.hash.split('?');
// 물음표를 기준으로 해시 정보와 쿼리스트링을 구분(쿼리스트링 없을 때를 고려해 기본값 주기)
// 1) 쿼리스트링을 각각 key와 value 나누어 객체로 히스토리의 상태에 저장!
interface Query {
[key: string]: string
}
const query = querystring
.split('&')
.reduce((acc, cur) => {
const [key, value] = cur.split('=');
acc[key] = value;
return acc;
}, {} as Query); // 매개변수에 직접 타입 지정 시 첫 값 {}으로 지정 안됨
history.replaceState(query, '') //(상태, 제목) 주소입력 안하면 현재 url유지
// 2) 현재 라우트 정보를 찾아서 렌더링!
const currnetRoute = routes.find(route => new RegExp(`${route.path}/?$`).test(hash))
if(routerView) {
routerView.innerHTML = ''
currnetRoute && routerView.append(new currnetRoute.component().el)
}
// 3) 화면 출력 후 스크롤 위치 복구!
window.scrollTo(0,0)
}
export function createRouter(routes: Routes) {
// 원하는(필요한) 곳에서 호출할 수 있도록 함수 데이터를 반환!
return function () {
window.addEventListener('popstate', () => { //히스토리가 변할 때마다 렌더링
routeRender(routes)
})
routeRender(routes) // 최초 호출(popstate 처음은 인식x, 변경만)
}
}
js에서는 a태그 href속성에 #id값을 주면 해당 페이지의 id를 가진 요소로 이동하게되는 점을 활용해 route함수를 구현한다.
리액트에서는 react-router-domd을 사용했다.
vercal에서 이미 파일올리고 나서 환경변수를 추가할려고 하는데 어디서 추가해야는지 한참 찾았다.
setting에서 Environment Variables 들어가면 있다.

fetch를 사용할 때 api주소 바로 사용하면 api주소가 노출되기 때문에 보안상 안 좋을 수 있다. vercel에서 지원해주는 Serverless Function을 사용하면 요청이 vercel을 걸쳐 가서 실제 api주소를 감출 수 있다.

api폴더를 만들어서 api에 사용할 이름으로 파일을 만든다. 나는 movie.ts로 만들어서 요청 URL 끝을 api/movie로 해주었다.
vercel/node를 설치해야하고 node에서 fetch 사용 못하므로 node-fetch도 설치해 fetch가 적용될 수 있게한다.
import fetch from "node-fetch";
import {VercelRequest, VercelResponse} from "@vercel/node";
const { APIKEY } = process.env
export default async function handler(request: VercelRequest, response: VercelResponse) {
const {title, page, id} = JSON.parse(request.body)
const url = id
? `https://www.omdbapi.com/?apikey=${APIKEY}&i=${id}&plot=full`
: `https://www.omdbapi.com/?apikey=${APIKEY}&s=${title}&page=${page}`
const res = await fetch(url)
const json = await res.json()
response.status(200).json(json)
요청 시에 /api/파일명으로 요청
const res = await fetch('/api/movie', {
method: 'POST',
body: JSON.stringify({title: movies.searchText, page}),
})
리액트에 타입스크립트 적용 시 가장 낯설었던것 props사용 시 타입을 같이 작성해주어야하는 점이 였다.
화살표 함수로 작성 시 앞에 prpss의 타입의 정의해 주고 React.FC<타입> 형식으로 작성해 주면된다.
interface SearchProps {
movies: moviesState;
setMovies: React.Dispatch<React.SetStateAction<moviesState>>
searchMovie: (page:number) => Promise<void>
}
export const Search: React.FC<SearchProps> = ({ movies, setMovies, searchMovie }) => {
const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setMovies({ ...movies, searchText: e.target.value });
};
return (
<SearchContainer>
<input
value={movies.searchText}
placeholder="Enter the movie title to search!"
onChange={onChangeSearch}
onKeyDown={(e) => (e.key === 'Enter' && movies.searchText.trim()) ? searchMovie(1) : null}
/>
<button className="btn btn-primary" onClick={() => movies.searchText.trim() ? searchMovie(1) : null}>Search!</button>
</SearchContainer>
);
};
무슨 타입인지 잘 모르겠을 때는 알고 싶은 변수에 마우스 갖다 대면 나온다.

그리고 스타일컴포넌트 props 사용 시에도 타입 추가해줘야하는데 <{props명: 타입>형식으로 작성해 주면된다.
export const MovieItem: React.FC<MovieProps> = ({movie}) => {
return (
<MovieWarpper poster={movie.Poster}>
<div className="info">
<div className="year">{movie.Year}</div>
<div className="title">{movie.Title}</div>
</div>
</MovieWarpper>
)
}
const MovieWarpper = styled.div<{poster: string}>`
--width: 200px;
width: var(--width);
height: calc(var(--width) * 3 / 2);
border-radius: 4px;
background-image: url();
background-color: var(--color-black);
background-size: cover;
overflow: hidden;
position: relative;
리액트의 이벤트의 타입은 단순히 Event가 아니라 이벤트 종류와 html 종류에 따라 다르다.
아래 코드처럼 input에서 발생한 change 이벤트일 경우 이벤트의 타입은 React.ChangeEvent<HTMLInputElement>이다.
interface SearchProps {
movies: moviesState;
setMovies: React.Dispatch<React.SetStateAction<moviesState>>
searchMovie: (page:number) => Promise<void>
}
export const Search: React.FC<SearchProps> = ({ movies, setMovies, searchMovie }) => {
const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setMovies({ ...movies, searchText: e.target.value });
};
return (
<SearchContainer>
<input
value={movies.searchText}
placeholder="Enter the movie title to search!"
onChange={onChangeSearch}
onKeyDown={(e) => (e.key === 'Enter' && movies.searchText.trim()) ? searchMovie(1) : null}
/>
<button className="btn btn-primary" onClick={() => movies.searchText.trim() ? searchMovie(1) : null}>Search!</button>
</SearchContainer>
);
};

단위를 잊지 말자..!
api요청 완료 후 페이지 갱신해야하는데 배치때문에 적용 안 되었다.
스프레드 문법으로 {...원래 state, 바뀐 state}로 변경했었는데 배치로 인해 변경이 안된 state가 계속해서 덧씌워져서 page의 값이 변하지 않았던 것 같다.
그래서 이전 state를 매개변수로 받아 상태를 변경해주어서 변경이 순차적으로 적용이 될 수 있게 하였다.
const searchMovie = async (page: number) => {
setMovies((prevMovies) => ({
...prevMovies,
loading: true,
message: '',
page,
}));
if (page === 1) {
setMovies((prevMovies) => ({ ...prevMovies, message: '', movies: [] }));
}
try {
const res = await fetch('/api/movie', {
method: 'POST',
body: JSON.stringify({title: movies.searchText, page}),
})
const { Response, Search, totalResults, Error } = await res.json();
if (Response === 'True') {
const newPageMax = Math.ceil(Number(totalResults / 10));
setMovies((prevMovies) => ({
...prevMovies,
movies: [...prevMovies.movies, ...Search],
pageMax: newPageMax,
page: movies.page + 1,
}));
console.log(movies);
} else {
setMovies((prevMovies) => ({
...prevMovies,
message: Error,
pageMax: 1,
}));
}
} catch (error) {
console.log('searchMovies error:', error);
} finally {
setMovies((prevMovies) => ({ ...prevMovies, loading: false }));
}
};
styled.Link가 아니라 styled(Link)로 작성하면 된다.
import 및 export 사용 시 package.json에 "type": "module" 추가하기

배포 중에 에러나서 확인해 보니 typescript5.3.2과 react-scripts5.0.1이 충돌이 나서 버전을 변경하라고 하라고 나와서 "typescript": "^4"으로 변경해 주었다.
