프로그래머스 과제 테스트 2020 Dev-Matching: 웹 프론트엔드 개발자(상반기)' 고양이 사진 검색 사이트 과제를 직접 풀어보았습니다.
본 과제는 라이브러리나 프레임워크 없이 Vanilla JS(ES6)로만 구현하였으며, 기능 요구 사항의 풀이 과정을 기록하였습니다.
저장소: https://github.com/petitesize/search-cat
본 포스팅에서는 과제 문제를 풀이하기 위해 추가적인 공부가 필요한 큰 개념 위주로 정리하였습니다. 세부 문제 풀이는 복기를 목적으로 다음 포스팅에 정리하기로 하고,
시작!
1. 시맨틱한 태그 이용하기
출처:[HTML] <section>과 <article>의 차이
먼저 시맨틱한 태그를 이용하기 위해, header 태그에 다크모드와 검색창을 넣어주었다.
그 외로는, section, article 태그를 결과창에 넣어주었다.
class App {
constructor($target) {
this.$target = $target;
// 헤더 안에 다크모드와 검색창을 넣어줄 것
this.$header = document.createElement("header");
this.$target.appendChild(this.$header);
this.darkMode = new DarkMode({ $target: this.$header });
this.searchInput = new SearchInput({
$target: this.$header,
onSearch: (keyword) => {
api.fetchCats(keyword).then(({ data }) => this.setState(data));
},
});
...
2. ES6 module 형태로 코드를 변경하기
ES6 모듈이란?
- 파일 간의 코드를 독립적으로 관리할 수 있도록 분리하는 방식
- import & export 문법을 사용하여 모듈을 가져오고 내보낼 수 있음
- 모든 코드가 전역 스코프에서 실행되지 않고, 필요한 파일에서만 사용 가능
- 번들러(Webpack, Parcel) 없이도 브라우저에서 type="module"을 사용하면 실행 가능
1. 기존 index.html 코드를 변경해주기
<!-- 기존 방식: 모든 스크립트를 하나의 파일에서 실행 -->
<script src="api.js"></script>
<script src="main.js"></script>
...
<!-- ES6 모듈 적용 -->
<script type="module" src="src/main.js"></script>
2. 각 파일에서 export default로 내보내고, import를 사용하여 필요한 파일에서 가져오기
...
export default api; // 모듈 내보내기
--------------------------------------------------
import api from "./api.js"; // API 모듈 가져오기
...
3. 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row당 column 개수를 적절히 변경하기
.SearchResult {
display: grid;
grid-template-columns: repeat(4, minmax(250px, 1fr));
}
위의 코드를 변경해야 하는데, 처음엔 미디어 쿼리를 이용해서 했지만, auto-fit 을 사용하면 훨씬 간결하게 표현할 수 있다!
미디어 쿼리 코드를 주석으로 표시해보니, 코드가 얼마나 간결해졌는지 알 수 있다.
.SearchResult {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
/* @media screen and (max-width: 992px) {
grid-template-columns: repeat(3, minmax(250px, 1fr));
}
@media screen and (max-width: 768px) {
grid-template-columns: repeat(2, minmax(250px, 1fr));
}
@media screen and (max-width: 576px) {
grid-template-columns: repeat(1, minmax(250px, 1fr));
} */
}
4. 다크모드 구현하기
기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다. |
먼저, 다크 모드 ON/OFF를 구현하기 위해 .dark-mode 클래스를 토글하는 방식을 사용하였다.
하지만, 이 방식은 OS의 다크 모드가 활성화된 상태에서는 다크 모드를 OFF할 수 없는 문제가 발생했다.
사용자의 OS 방식을 우선하되, 사용자가 변경 가능한 방법을 찾다가 data-theme 속성을 사용하면 쉽게 해결 가능한 것을 발견하였다.
✅ 그래서 data-theme란?
data-theme는 HTML 요소에 다크 모드같은 테마를 지정할 때 사용하는 커스텀 속성이다.
브라우저가 기본적으로 제공하는 속성이 아니라, 개발자가 자유롭게 정의하고 활용할 수 있는 데이터 속성(data-*)이라고 한다.
1. 먼저 css에 data-theme의 각 속성에 따른 css를 지정한다. 나는 dark에 다크모드 light에 라이트모드를 지정해줬다.
@media (prefers-color-scheme: dark) {
:root {
--bg-color: black;
--text-color: white;
}
}
[data-theme="dark"] {
--bg-color: black;
--text-color: white;
}
[data-theme="light"] {
--bg-color: white;
--text-color: black;
}
body,
body * {
background-color: var(--bg-color);
color: var(--text-color);
}
2. 그리고, OS에 설정된 다크모드가 있는지 확인하고, 있다면 그 값을 data-theme에 지정해준다.
또한, 체크박스 이벤트리스너에 체크 활성화 여부에 따라 data-theme를 바꿔준다.
✅ OS 다크모드는 어떻게 체크하는데?
window.matchMedia("(prefers-color-scheme: dark)")는 사용자의 OS에서 다크 모드를 활성화했는지 여부를 확인하는 코드다.
여기서 .matches 속성을 사용하면 dark 모드에 따라 true 또는 false를 반환하므로 이를 이용해서 OS 다크모드를 확인했다.
setTheme() {
const userTheme = localStorage.getItem("theme");
// 2. OS 다크모드 확인
// theme: userTheme가 있다면 그 값(dark or light), 없다면 os값을 data-set theme에 지정해줌
const theme =
userTheme ||
(window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light");
document.documentElement.setAttribute("data-theme", theme);
// 만약 다크모드면 체크처리
this.$darkModeCheckBox.checked = theme === "dark";
}
addEvent() {
// 3. 체크박스 이벤트리스너 추가
this.$darkModeCheckBox.addEventListener("change", () => {
const theme = this.$darkModeCheckBox.checked ? "dark" : "light";
// 다크모드 체크되면, 다크모드 활성화
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
});
}
💡 다크 모드 사용자 경험(UX) 개선 아이디어
1. 테마 전환 시 부드러운 애니메이션 적용 (ease 효과 추가)
- 구현하면서 단순 흑백 컬러를 반복 전환하다 보니 눈에 피로감이 상당했다. OS 다크모드가 변경되는 것 처럼 전환 시 ease 효과를 주면 눈에 피로감이 덜 할 것 같다.
2. 사용자의 테마 설정을 localStorage에 저장하여 새로고침 후에도 유지
3. 자동 다크 모드 기능 제공 (스케줄 설정)
- 네비게이션처럼 6시, 18시를 기준으로 자동 다크모드 ON/OFF 가 전환되는 아이디어
- 기존 다크모드 사용자들은 낮에도 다크모드를 유지하고 싶은 사람도 많을 것으로 예상됨
4. 테마 변경 시 아이콘 추가 (직관적인 UX)
- ☀️,🌙 같이 아이콘으로 직관적으로 다크모드 유무를 보여줘도 좋을 것 같다.
5. lazy load(레이지 로드)
✅Lazy Load란?
💡 Lazy Load(=지연 로딩)은 필요할 때만 데이터를 불러오는 기법이다.
💡 특히 이미지 같은 무거운 리소스를 한꺼번에 로딩하지 않고, 사용자가 화면을 스크롤할 때 필요한 것만 로딩하도록 최적화하는 방식이다.
👉 현재, 고양이 사진을 한 번에 매우 많이 불러오고 있기 때문에 lazy load를 적용하라는 것 같다.
📌 페이지 로딩 속도를 빠르게 하기 위해 사용
📌 불필요한 네트워크 요청을 줄여 성능을 개선
📌 사용자가 보지 않는 이미지는 불러오지 않기 때문에 렌더링 속도가 향상됨
✅ Lazy Load를 적용하는 방법
1️⃣ HTML에서 loading="lazy" 속성을 사용하기
2️⃣ Intersection Observer API를 활용하여 직접 구현하기
💡getBoundingClientRect() 사용하기?
- getBoundingClientRect() 또한 lazy load의 방법 중 하나이다.
- 구형 브라우저도 지원하나, 성능면에서 좋지 않다.
- lazy load는 성능 개선의 목적 또한 가지고 있기에, 성능이 저하되는 방법인 것 같아 자세히 알아보지 않았다.
- 하지만 Intersection Observer API를 아직 외워서 하기에는 무리가 있기 때문에 사용하는 법만 알아보았다.
- src 속성을 data-src 속성으로 바꾸는건 동일하고, 아래 스크롤 이벤트만 추가하면 된다.
window.addEventListener("scroll", function () {
document.querySelectorAll(".lazy-img").forEach((img) => {
if (img.dataset.src && img.getBoundingClientRect().top < window.innerHeight) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
});
});
1️⃣ loading="lazy" 속성 사용하기
📌 img 태그에 loading="lazy" 속성을 추가하면 브라우저가 자동으로 레이지 로드를 적용한다.
<img src="https://example.com/cat.jpg" alt="고양이" loading="lazy" />
✔ 사파리 구버전에서 지원되지 않을 수 있지만, 현재(2024년)를 기준으로는 웬만한 최신 브라우저에서는 모두 적용이 가능하다.
2022년 이후로는 최신 사파리에서도 적용 가능한 것을 확인할 수 있다.
참고: https://caniuse.com/?search=loading%20lazy
- iOS Safari 15.0+ (2021년 이후 버전)
- macOS Safari 16.0+ (2022년 이후 버전)
✔ 사파리 구버전에서 지원되지 않을 수 있지만, 현재(2024년)를 기준으로는 웬만한 최신 브라우저에서는 모두 적용이 가능하다.
✔ 이를 사용하기 위해서는 html에 width, height를 지정해주자. 여기서부터 좋은 방법인지 의심이 든다.
✔ 하지만 후에 스크롤 페이징을 구현해야 하기 때문에 2번 방법을 써보기로 한다!
2️⃣ Intersection Observer API를 활용하여 Lazy Load 직접 구현하기
https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API
Intersection Observer API - Web API | MDN
Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.
developer.mozilla.org
📌 Intersection Observer API는 특정 요소가 화면에 나타나는지를 감지하는 기능을 제공함.
✔ 이미지가 화면에 보일 때만 src 속성을 설정하여 이미지를 로드하는 방식으로 최적화 가능.
✔ 구형 브라우저에서도 사용할 수 있음
1. 기본적으로 img 태그의 src 속성을 설정하지 않고 data-src 속성으로 이미지 경로를 저장
2. 화면에 보이지 않는 이미지들은 로드되지 않음
3. Intersection Observer API가 동작하여 사용자가 스크롤하면 이미지가 보이는 시점에 src 속성을 설정하여 로드됨
4. 한 번 로드된 이미지는 더 이상 감지할 필요가 없으므로 observer.unobserve(img);를 사용하여 감지 해제
class SearchResult {
constructor({ $target, initialData }) {
...
this.observer = new IntersectionObserver(this.handleLazyLoad.bind(this), {
root: null, //viewport
rootMargin: "0px",
threshold: 0.1, // 10% 이상 보일 때 로드
});
this.render();
}
handleLazyLoad = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 실제 이미지 로드
observer.unobserve(img); // 로딩 후 감지 중지
}
});
};
...
render() {
this.$searchResult.innerHTML = this.data
.map(
(cat) => `
<div class="item">
<img class="lazy-img" data-src="${cat.url}" alt="${cat.name}" />
</div>
`
)
.join("");
// Lazy Load 적용
document.querySelectorAll(".lazy-img").forEach((img) => {
this.observer.observe(img);
});
}
}
6. Event Delegation(이벤트 위임) 기법
이벤트 위임(Event Delegation)은 부모 요소에 이벤트 리스너를 등록하여 자식 요소의 이벤트를 처리하는 기법이다.
- 여러 개의 자식 요소 각각에 이벤트 리스너를 붙이는 대신, 공통된 부모 요소에 이벤트 리스너를 하나만 붙이는 방식
- 부모 요소에 이벤트 리스너를 등록하고, 이벤트가 버블링을 통해 전달되도록 하는 방식
- 성능 최적화와 유지보수성이 뛰어난 방법으로, 특히 동적 요소 추가가 많은 경우에 필수적인 기법
❌ 이벤트를 각각 등록하는 방식 (비효율적인 방식)
const buttons = document.querySelectorAll(".button");
buttons.forEach((button) => {
button.addEventListener("click", (event) => {
console.log(`${event.target.textContent} 버튼 클릭됨!`);
});
});
/** 이 프로젝트에서 **/
addEvent() {
this.$target.querySelectorAll(".item").forEach(($item, index) => {
$item.addEventListener("click", () => {
this.onClick(this.data[index]);
});
});
}
- .button 요소가 많아질수록 addEventListener가 여러 번 실행되면서 메모리 낭비가 발생함.
- 새로운 .button 요소가 동적으로 추가되면, 기존에 등록된 이벤트 리스너가 적용되지 않음.
✅ 이벤트 위임(Event Delegation) 사용 (효율적인 방식)
document.querySelector(".button-container").addEventListener("click", (event) => {
if (event.target.classList.contains("button")) {
console.log(`${event.target.textContent} 버튼 클릭됨!`);
}
});
/** 이 프로젝트에서 **/
addEvent() {
this.$target.addEventListener("click", (event) => {
const $clickedItem = event.target.closest(".item"); // 클릭한 요소의 가장 가까운 .item 찾기
if (!$clickedItem) return; // .item이 아닌 곳을 클릭하면 무시
// 클릭한 .item 요소의 인덱스 찾기
const index = Array.from(this.$target.querySelectorAll(".item")).indexOf($clickedItem);
if (index !== -1) {
this.onClick(this.data[index]); // 해당 인덱스를 사용해 데이터 전달
}
});
}
1️⃣ 이벤트 리스너를 부모 요소에 하나만 추가 → 성능 최적화
2️⃣ 동적으로 추가된 요소도 자동으로 이벤트 적용 가능
3️⃣ DOM 요소가 많아질수록 효율적인 방식
😺완성!😺
테스트 후기
상세 기능을 한 번씩 직접 구현해본 뒤, 4시간짜리 테스트를 보았다.
필수 기능들은 쉽게 구현할 수 있었고, 다크모드 등 새롭게 공부한 개념들은 바로 구현하기 쉽지 않아서 시간이 부족했다.
또, lazy load는 loading="lazy"로 해결하였고, 스크롤 페이징까진 구현하지 못했다..
랜덤 배너섹션은 시간이 부족해서 구현하지 못했는데, 트랜지션을 제외하고는 시간 분배를 좀 더 했다면 구현 자체는 하기 쉬웠을 것 같다!
요약하자면, 스크롤 페이징과 랜덤 배너섹션 외에는 구현은 했지만 잘 써보지 않은 기본 내장 함수들을 생각하는 데에 시간을 예상보다 더 소모했기 때문에 기초를 다지고 구현하지 못했던 부분은 로직을 한 번 더 공부해보면 좋을 것 같다!
'[Study] 개발 공부' 카테고리의 다른 글
[알고리즘] 공부(reduce, parseInt 대체) (0) | 2025.03.20 |
---|---|
[Vanilla-JS] 고양이 사진 검색 사이트😺: 상세 기능 구현 (0) | 2025.03.13 |
자주 사용하는 HTTP 상태 코드 별 에러 메시지 처리하기! (0) | 2025.03.04 |
Vercel로 프론트엔드 프로젝트 배포해보기 (0) | 2024.06.28 |
mousedown와 click의 차이: 버블링 (0) | 2024.06.24 |