주제(시나리오)
고양이를 좋아하는 당신은 고양이 사진 전용 검색 웹사이트를 운영하고 있었습니다. 지금까지는 혼자 소소하게 운영해왔는데, 생각보다 고양이 사진을 원하는 사람들이 많아지면서 해결해야 할 문제들이 하나씩 드러나기 시작했어요. 몇 개의 문제는 금세 고칠 수 있지만, 기존 코드를 자세히 봐야만 고칠 수 있는 문제들도 있어서 조금 골치아픈 상황! 심지어 최대 4시간 내에 수정한 뒤 배포를 해야만 합니다. 당신이라면 기존 서비스의 여러 버그를 제한시간 내에 고치고, 유저를 위한 추가 기능까지 구현해볼 수 있을까요? 도전해보세요!
과제 설명
- thecatapi 에서 크롤링한 데이터를 이용해 이미지를 검색하는 베이스 코드가 주어집니다.
- 베이스 코드는 모두 ES6 클래스 기반으로 작성되어 있으며, 이 코드에는 여러 개의 버그가 존재합니다. 요구사항을 잘 읽고, 버그를 하나씩 해결해주세요.
수행 기술
- JavaScript(ES6)
- 설치되어있는 모듈(node_modules) 외에 다른 외부 라이브러리는 사용하지 않도록 합니다. 예를들어 jQuery, Webpack, Lodash, Axios, Angular, React, Vue, Immutable-js, Ramda 등을 사용할 수 없습니다.
요구사항
참고 요구사항의 순서는 난이도와 상관이 없음
HTML, CSS 관련
- 현재 HTML 코드가 전체적으로 <div> 로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.
- 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row 당 column 갯수를 적절히 변경해주어야 합니다.
- 992px 이하: 3개
- 768px 이하: 2개
- 576px 이하: 1개
/* style.css */
/* 기본 */
.SearchResult {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(4, minmax(250px, 1fr));
grid-gap: 10px;
}
/* 1. HTML, CSS 관련 - (2) 반응형 */
@media (max-width:992px) {
.SearchResult {
grid-template-columns: repeat(3, minmax(250px, 1fr));
}
}
@media (max-width:768px) {
.SearchResult {
grid-template-columns: repeat(2, minmax(250px, 1fr));
}
}
@media (max-width:576px) {
.SearchResult {
grid-template-columns: repeat(1, minmax(250px, 1fr));
}
}
- 다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다.
- CSS 파일 내의 다크 모드 관련 주석을 제거한 뒤 구현합니다.
- 모든 글자 색상은 #FFFFFF , 배경 색상은 #000000 로 한정합니다.
- 기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.
/* style.css */
.dark {
background-color: #000;
color:white;
}
body.dark .ImageInfo .content-wrapper {
background-color: #000;
color: white;
}
.light {
background-color: white;
color:#000;
}
body.light .ImageInfo .content-wrapper {
background-color: white;
color:#000;
}
/* dark mode 처리 */
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
color: white;
}
.ImageInfo .content-wrapper {
background-color: #000;
color: white;
}
}
@media (prefers-color-scheme: light) {
body {
background-color: white;
color: #000;
}
.ImageInfo .content-wrapper {
background-color: white;
color: #000;
}
}
// SearchInput.js
class SearchInput {
constructor({ $target, ... {
...
// 1. HTML, CSS 관련 - (3) 다크 모드
const $themeMode = document.createElement("input");
$themeMode.type = "checkbox";
$themeMode.addEventListener("click", () => {
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
if (theme && !$themeMode.checked) {
document.body.classList.remove("light")
document.body.classList.add("dark")
} else if (theme && $themeMode.checked) {
document.body.classList.remove("dark")
document.body.classList.add("light")
}
})
...
$searchHeader.appendChild($themeMode)
}
}
이미지 상세 보기 모달 관련
- 디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.
/* style.css */
@media (max-width:768px) {
...
/* 2.이미지 상세 보기 모달 관련 - (1) */
.ImageInfo .content-wrapper{
width : 100%
}
}
- 필수 이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.
// ImageInfo.js
class ImageInfo {
...
render() {
if (this.data.visible) {
...
// 2. 이미지 상세 보기 모달 관련 - (2) 모달 영역 밖, 모달 우측의 닫기(X) 버튼
document.addEventListener("click", e => {
if (e.target == document.querySelector(".ImageInfo") || e.target === document.querySelector(".close")) {
this.$imageInfo.style.display = "none";
}
})
// 2. 이미지 상세 보기 모달 관련 - (2) ESC키 누르기
document.addEventListener("keydown", e => {
if (e.keyCode == 27) {
this.$imageInfo.style.display = "none";
}
})
}
}
}
- 모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id 를 통해 불러와야 합니다.
// api.js
...
const api = {
...
// 2. 이미지 상세 보기 모달 관련 - (3) 모달에서 정보 렌더링
fetchCatDetails: id => {
return fetch(`${API_ENDPOINT}/api/cats/${id}`).then(res => res.json());
}
}
// App.js
class App {
...
this.searchResult = new SearchResult({
...
onClick: image => {
// 2. 이미지 상세 보기 모달 관련 - (3) 모달 고양이 정보 랜더링
api.fetchCatDetails(image.id).then(({data}) =>
this.imageInfo.setState({
visible: true,
image: data
})
)
}
});
}
- 추가 모달 열고 닫기에 fade in/out을 적용해 주세요.
/* style.css */
/* 2. 이미지 상세 보기 모달 관련 - (5) fade-in fade-out */
.ImageInfo {
...
animation: fadein 0.5s;
-moz-animation: fadein 0.5s; /* Firefox */
-webkit-animation: fadein 0.5s; /* Safari and Chrome */
-o-animation: fadein 0.5s; /* Opera */
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-moz-keyframes fadein { /* Firefox */
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-webkit-keyframes fadein { /* Safari and Chrome */
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-o-keyframes fadein { /* Opera */
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.ImageInfo.fade {
animation: fadeout 1s;
-moz-animation: fadeout 1s; /* Firefox */
-webkit-animation: fadeout 1s; /* Safari and Chrome */
-o-animation: fadeout 1s; /* Opera */
animation-fill-mode: forwards;
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
display:none;
visibility: hidden;
}
}
@-moz-keyframes fadeout { /* Firefox */
from {
opacity: 1;
}
to {
opacity: 0;
display:none;
visibility: hidden;
}
}
@-webkit-keyframes fadeout { /* Safari and Chrome */
from {
opacity: 1;
}
to {
opacity: 0;
visibility: hidden;
display:none;
}
}
@-o-keyframes fadeout { /* Opera */
from {
opacity: 1;
}
to {
opacity: 0;
visibility: hidden;
display:none;
}
}
// ImageInfo.js
class ImageInfo {
...
render() {
if (this.data.visible) {
this.$imageInfo.classList.remove("fade")
...
document.addEventListener("click", e => {
if (e.target == document.querySelector(".ImageInfo") || e.target === document.querySelector(".close")) {
// 2. 이미지 상세 보기 모달 관련 - (4) fade-in fade-out
this.$imageInfo.classList.add("fade")
}
})
document.addEventListener("keydown", e => {
if (e.keyCode == 27) {
// 2. 이미지 상세 보기 모달 관련 - (4) fade-in fade-out
this.$imageInfo.classList.add("fade")
}
})
} else {
this.$imageInfo.style.display = "none";
}
}
}
검색 페이지 관련
- 페이지 진입 시 포커스가 input 에 가도록 처리하고, 키워드를 입력한 상태에서 input 을 클릭할 시에는 기존에 입력되어 있던 키워드가 삭제되도록 만들어야 합니다.
// SearchInput.js
class SearchInput {
constructor({ $target, onSearch }) {
...
// 3. 검색 페이지 관련 - (1) 페이지 진입 시 포커스가 input에 가도록
$searchInput.autofocus = "true";
// 3. 검색 페이지 관련 - (1) 키워드를 입력한 상태에서 input을 클릭할 때
$searchInput.addEventListener("click", e => {
if (e.target.value.length > 0) {
e.target.value = '';
}
})
...
}
render() {}
}
- 필수 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중임을 유저에게 알리는 UI를 추가해야 합니다.
// App.js
class App {
...
constructor($target) {
...
this.searchInput = new SearchInput({
$target,
onSearch: keyword => {
// 3. 검색 페이지 관련 - (2) 로딩 중
this.setState({
data: null,
loading: false
})
// 3. 검색 페이지 관련 - (2) 로딩 완료
api.fetchCats(keyword).then(({ data }) => this.setState({
data,
loading: true
}));
}
});
...
}
}
// SearchResult.js
class SearchResult {
...
loading = false;
...
setState(nextData) {
this.data = nextData.data;
this.loading = nextData.loading;
...
}
render() {
if (this.loading) {
...
}
// 3. 검색 페이지 관련 - (2) 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중
else if (!this.loading && this.data === null) {
this.$searchResult.innerHTML = `
<h3>Loading...</h3>
`
}
// 3. 검색 페이지 관련 - (2) 데이터를 불러오지 않고, 첫 화면일 때는 Loding이 아닌 빈 화면
else if (!this.loading && this.data === []) {
this.$searchResult.innerHTML = ``
}
}
}
- 필수 검색 결과가 없는 경우, 유저가 불편함을 느끼지 않도록 UI적인 적절한 처리가 필요합니다.
// SearchResult.js
class SearchResult {
...
render() {
if (this.loading) {
// 3. 검색 페이지 관련 - (3) 검색 결과가 없을 때
if (!this.data.length) {
this.$searchResult.innerHTML = `
<h3>검색 결과가 없습니다.</h3>
`
} else {
...
}
...
}
...
}
}
- 최근 검색한 키워드를 SearchInput 아래에 표시되도록 만들고, 해당 영역에 표시된 특정 키워드를 누르면 그 키워드로 검색이 일어나도록 만듭니다. 단, 가장 최근에 검색한 5개의 키워드만 노출되도록 합니다.
// SearchInput.js
class SearchInput {
lastKeywords = [];
constructor({ $target, onClick, onSearch }) {
...
// 3. 검색 페이지 관련 - (4) 최근 검색한 키워드
const $lastKeywords = document.createElement("div");
$lastKeywords.className = "LastKeywords";
$searchInput.addEventListener("keyup", e => {
if (e.keyCode === 13) {
const keyword = e.target.value
onSearch(keyword)
// 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 5개 표시
this.$searchInput.value = ''
this.set_lastKeywords(keyword, 1)
this.get_lastKeywords($lastKeywords)
}
})
// 3. 검색 페이지 관련 - (4) 키워드 선택했을 때 검색되는 것
$lastKeywords.addEventListener("click", e => {
if (e.target.nodeName === "BUTTON") {
onSearch(e.target.innerText)
this.$searchInput.value = ''
this.set_lastKeywords(e.target.innerText, 2)
this.get_lastKeywords($lastKeywords)
}
})
// 3. 검색 페이지 관련 - (4) 새로고침 했을 때 최근 검색한 키워드 표시
this.get_lastKeywords($lastKeywords)
$target.appendChild($lastKeywords);
...
}
// 3. 검색 페이지 관련 - (4) 세션스토리지에 최근 검색한 키워드 저장
set_lastKeywords(key, ver) {
// 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 직접 입력 검색할 때
if (ver === 1) {
if (this.lastKeywords.length == 5) {
this.lastKeywords.shift();
}
this.lastKeywords.push(key);
sessionStorage.setItem("keywords", JSON.stringify(this.lastKeywords));
}
// 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 선택해서 검색할 때
else {
this.lastKeywords = this.lastKeywords.filter(e => e != key)
this.lastKeywords.push(key);
sessionStorage.setItem("keywords", JSON.stringify(this.lastKeywords));
}
}
// 3. 검색 페이지 관련 - (4) 세션 스토리지의 키워드 가져오기
get_lastKeywords(lastkeywords) {
while (document.querySelector(".Keyword")) {
const tmp_last = document.querySelector(".Keyword")
console.log(tmp_last)
lastkeywords.removeChild(tmp_last)
}
const keywords = JSON.parse(sessionStorage.getItem("keywords"));
if (keywords) {
this.lastKeywords = keywords;
for (var i=0;i<this.lastKeywords.length; i++) {
const $keyword = document.createElement("button");
$keyword.className = "Keyword";
$keyword.innerText = this.lastKeywords[i];
lastkeywords.appendChild($keyword);
}
}
}
render() {}
}
- 페이지를 새로고침해도 마지막 검색 결과 화면이 유지되도록 처리합니다.
// App.js
class App {
...
setState(nextData) {
...
// 3. 검색 페이지 관련 - (5) sessionstorage에 저장
if (this.data.data) sessionStorage.setItem("data", JSON.stringify(this.data.data));
}
}
// SearchResult.js
class SearchResult {
...
constructor({ $target, initialData, onClick }) {
...
// 3. 검색 페이지 관련 - (5) 새로고침해도 유지(sessionData)
this.get_sessionData();
...
}
// 3. 검색 페이지 관련 - (5) 새로고침해도 유지(sessionData)
get_sessionData() {
const data = JSON.parse(sessionStorage.getItem("data"));
if (data) {
this.setState({
data,
loading:true
})
}
}
...
}
- 필수 SearchInput 옆에 버튼을 하나 배치하고, 이 버튼을 클릭할 시 /api/cats/random50 을 호출하여 화면에 뿌리는 기능을 추가합니다. 버튼의 이름은 마음대로 정합니다.
// SearchInput.js
class SearchInput {
constructor({ $target, onSearch, onClick }) {
// 3. 검색 페이지 관련 - (6) 랜덤 버튼 만들기(header로 input과 button 감싸기)
const $searchHeader = document.createElement("header");
$searchHeader.className = "SearchHeader";
...
$searchHeader.appendChild($searchInput);
// 3. 검색 페이지 관련 - (6) 랜덤 버튼 만들기
const $randomButton = document.createElement("button");
$randomButton.innerText = "랜덤";
$randomButton.className = "RandomButton";
$searchHeader.appendChild($randomButton);
$target.appendChild($searchHeader);
...
// 3. 검색 페이지 관련 - (6) 랜덤 버튼 클릭 이벤트
$randomButton.addEventListener("click", () => onClick());
}
render() {}
}
/* style.css */
/* 3. 검색 페이지 관련 - (6) 랜덤 버튼 */
.SearchInput {
display: inline-block;
width: 80%;
margin-right: 10px;
padding: 10px 15px;
}
/* 3. 검색 페이지 관련 - (6) 랜덤 버튼 */
.RandomButton {
display: inline-block;
padding: 10px 15px;
}
// api.js
const api = {
...
// 3. 검색 페이지 관련 - (6) 랜덤 고양이 50개
fetchRandomCat: () => {
return fetch(`${API_ENDPOINT}/api/cats/random50`).then(res => res.json());
}
};
// App.js
class App {
...
constructor($target) {
this.$target = $target;
this.searchInput = new SearchInput({
$target,
...
// 3. 검색 페이지 관련 - (6) 랜덤 버튼
onClick: () => {
// 로딩 중
this.setState({
data:null,
loading:false
})
/// 로딩 완료
api.fetchRandomCat().then(({data}) => this.setState({
data,
loading: true
}))
}
});
...
}
...
}
- lazy load 개념을 이용하여, 이미지가 화면에 보여야 할 시점에 load 되도록 처리해야 합니다.
- 추가 검색 결과 각 아이템에 마우스 오버시 고양이 이름을 노출합니다.
// SearchResult.js
class SearchResult {
...
render() {
console.log(this.data, this.loading)
if (this.loading) {
...
} else {
this.$searchResult.innerHTML = this.data
.map(
// 3. 검색 페이지 관련 - (8) mouse over시 고양이 이름 노출
cat => `
<div class="item" title=${cat.name}>
<img src=${cat.url} alt=${cat.name} />
</div>
`
)
.join("");
...
}
}
...
}
}
스크롤 페이징 구현
- 검색 결과 화면에서 유저가 브라우저 스크롤 바를 끝까지 이동시켰을 경우, 그 다음 페이지를 로딩하도록 만들어야 합니다.
랜덤 고양이 배너 섹션 추가
- 현재 검색 결과 목록 위에 배너 형태의 랜덤 고양이 섹션을 추가합니다.
- 앱이 구동될 때 `/api/cats/random50` api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.
- 검색 결과가 많더라도 화면에 5개만 노출하며 각 이미지는 좌, 우 슬라이드 이동 버튼을 갖습니다.
- 좌, 우 버튼을 클릭하면, 현재 노출된 이미지는 사라지고 이전 또는 다음 이미지를 보여줍니다.(트렌지션은 선택)
코드 구조 관련
- ES6 module 형태로 코드를 변경합니다.
- webpack , parcel 과 같은 번들러를 사용하지 말아주세요.
- 해당 코드 실행을 위해서는 http-server 모듈을(로컬 서버를 띄우는 다른 모듈도 사용 가능) 통해 index.html 을 띄워야 합니다.
- API fetch 코드를 async , await 문을 이용하여 수정해주세요. 해당 코드들은 에러가 났을 경우를 대비해서 적절히 처리가 되어있어야 합니다.
- 필수 API 의 status code 에 따라 에러 메시지를 분리하여 작성해야 합니다. 아래는 예시입니다.
const request = async (url: string) => {
try {
const result = await fetch(url);
return result.json();
} catch (e) {
console.warn(e);
}
}
const api = {
fetchGif: keyword => {
return request(`${API_ENDPOINT}/api/gif/search?q=${keyword}`);
},
fetchGifAll: () => {
return request(`${API_ENDPOINT}/api/gif/all`);
}
};
// 6. 코드 구조 관련 - (2, 3) async, await 문을 이용해 수정, Status Code에 따라 에러 메세지 분리
const request = async (url) => {
try {
const result = await fetch(url);
if (result.status < 300) return result.json()
else if (result.status < 400) return console.warn(`Redirection Error Code ${result.status}`)
else if (result.status < 500) return console.warn(`Client Error Code ${result.status}`)
else if (result.status < 600) return console.warn(`Server Error Code ${result.status}`)
} catch(err) {
console.warn(err)
}
}
const api = {
// 고양이 검색
fetchCats: (keyword) => {
return request(`${API_ENDPOINT}/api/cats/search?q=${keyword}`);
},
// 2. 이미지 상세 보기 모달 관련 - (3) 모달에서 정보 렌더링
fetchCatDetails: (id) => {
return request(`${API_ENDPOINT}/api/cats/${id}`);
},
// 3. 검색 페이지 관련 - (6) 랜덤 고양이 50개
fetchRandomCat: () => {
return request(`${API_ENDPOINT}/api/cats/random50`);
}
};
- SearchResult 에 각 아이템을 클릭하는 이벤트를 Event Delegation 기법을 이용해 수정해주세요.
- 컴포넌트 내부의 함수들이나 Util 함수들을 작게 잘 나누어주세요.
API
1. GET /cats/random50
Request parameter
None
Query paramter
None
Response
Success 200
Field name | Type | Description |
data | Array | 랜덤한 50개의 고양이 사진 목록입니다. |
HTTP/1.1 200 OK
{
"data": [{
id: string
url: string
name: string
}]
}
2. GET /cats/search
Request parameter
None
Query paramter
Field name | Type | Description |
q | string | 고양이의 품종(영어/한글) |
Response
Success 200
Field name | Type | Description |
data | Array | Keyword로 검색된 고양이 사진 목록입니다. |
HTTP/1.1 200 OK
{
"data": [{
id: string
url: string
name: string
}]
}
3. GET /cats/:id
Request parameter
Field name | Type | Description |
id | string | 고양이 사진의 id값 입니다. |
Query paramter
None
Response
Success 200
Field name | Type | Description |
data | Object | Id로 검색된 고양이 사진 입니다. |
HTTP/1.1 200 OK
{
"data": {
name: string
id: string
url: string
width: number
height: number
temperament: string
origin: string
}
}
시멘틱웹
"의미론적인 웹"
NON-시멘틱 구조 태그
<div id="header">헤더입니다.</div>
시멘틱 구조 태그
<header>헤더입니다</header>
- 거의 같은 결과를 나타내지만, 시멘틱 구조의 경우 개발자가 의도한 요소의 의미가 명확해 코드의 가독성 증가
References
'Algorithm > 과제' 카테고리의 다른 글
[과제] FE - 고양이 사진첩 애플리케이션 (1) | 2021.05.15 |
---|
댓글