과제 테스트를 접하며,
2023 데브 매칭 상반기에 참여해 처음으로 과제 테스트를 풀어보았다. 예상치 못한 구조로 나오기도 했지만 자바스크립트에 대한 복습이 필요하다는 생각이 들어 과제 테스트 풀이 방향을 찾아보고 정리하는 시간을 가지게 되었다. 자바스크립트 과제 풀이계에서 정석과 같은 고양이 사진첩을 바탕으로 정리하였는데, 익숙한 내용도 있었지만 캐싱이나 뒤로 가기 등의 선택적으로 구현해야 하는 기능은 새로 배울 수 있어 유용했다. 커넥투 과정에서 페어 프로그래밍을 통해 구현했던 내용들을 복습하고 바닐라 자바스크립트로 구현하는 힘을 길러야겠다는 생각이 들었다.
이때까지는 구현 시에 주로 axios를 사용했는데 fetch도 사용해보면서 차이점을 알고 사용할 수 있게 되었다. 이전에는 리액트와 유사하게 구현하기 위해 각 컴포넌트가 DOMString을 반환하고 최상위 컴포넌트인 App에서 이를 일괄적으로 렌더링하고, 이벤트를 App에 위임하거나 컴포넌트에서 넘겨준 이벤트에 대한 정보로 App에서 이벤트 핸들러를 바인딩하는 방식으로 문제를 해결했다. 그런데 과제 테스트 풀이에서는 각 컴포넌트가 부모 요소를 매개변수로 받아 컴포넌트의 최상위 요소만 createElement를 통해 생성하고, 거기에 이벤트를 부착한 다음 컴포넌트 내에서 render하는 방식임을 배웠다. 리액트와 꼭 같은 방향이 아니더라도 자바스크립트에서 이벤트 처리가 더 간단하고, 컴포넌트로서 더 응집도가 있을 것이라는 생각을 했다.
API 화면구조
- 아래와 같이 API 데이터는 배열 안에 객체가 있는 형태이다.
[
{
"id": "5",
"name": "2021/04",
"type": "DIRECTORY",
"filePath": null,
"parent": {
"id": "1"
}
},
{
"id": "19",
"name": "물 마시는 사진",
"type": "FILE",
"filePath": "/images/a2i.jpg",
"parent": {
"id": "1"
}
}
]
- 이런 데이터 구조가 주어지면 화면 요구사항에 맞게 렌더링하는 것이 핵심이다.
데이터 렌더링하기
- 먼저 root 경로(해당 데이터를 그릴 최상위 요소)에 데이터 fetch하기
- fetch한 데이터 렌더링
- 이때 데이터가 필요한 부분의 root DOM를 찾아 그 아래에 innerHTML으로 렌더링하기(선언적)
- const, 화살표 함수, 구조분해할당 이용하기
- 가급적 컴포넌트 형태로 추상화 : DOM에 접근하는 부분을 최소화하고, 명령형 프로그래밍보다 선언적 프로그래밍 방식으로 접근하기
각각에 대해 자세히 알아보자.
📌 선언형 프로그래밍과 명령형 프로그래밍
이번 devmatching 때도 html 내 script에 명령형 프로그래밍 방식으로 table을 그려져있는 문제가 주어졌다. 이를 js 파일에서 선언형 프로그래밍 방식으로 리렌더링할 수 있다.
명령형 프로그래밍은 DOM에 접근하고 업데이트하는 시점에 명확한 기준점이 없어 코드가 거대해지고, UI 업데이트가 많아질 경우 어느 지점에서 어느 시점에 DOM을 업데이트했는지 추적하기가 어려워진다는 문제점이 있다.
이에 비해 선언형 프로그래밍은 어떠한 상태를 기준으로 렌더링한다. 이 경우, 상태가 변경될 때 렌더링, 즉 UI가 업데이트된다는 것을 알 수 있다. 이렇게 하면 DOM을 직접 제어하는 부분을 컴포넌트가 인스턴스화 되는 시점, 그리고 render 함수가 실행되는 시점으로 제한할 수 있다.
// 명령형 프로그래밍 방식 : DOM에 직접 접근
function renderNodes(nodes) {
const $container = document.querySelector('.container')
nodes.forEach(node => {
const $node = document.createElement('div')
....
....
$container.appendChild($node)
})
}
// 선언형 프로그래밍 방식: 상태를 기준으로 렌더링
function Nodes({ $container, initialState }) {
this.state = initialState
this.$element = document.createElement('ul')
$container.appendChild(this.$element)
// 새로운 상태를 받아서 현재 컴포넌트의 상태를 변경하고 리렌더링하는 함수
this.setState = (newState) => {
this.state = { ...this.state, ...newState } // 상태 업데이트
this.render() // 리렌더링
}
// 컴포넌트를 render하는 함수
this.render = () => {
this.$element.innerHTML = this.state.nodes.map(node =>
`<li>${node.name}</li>`
)
}
this.render() // new로 인스턴스화 한 후 바로 렌더링되도록 render 함수 실행
}
📌 함수형과 클래스형 컴포넌트
// 함수형 컴포넌트
function Nodes({ $container, initialState }) {
this.state = initialState
this.$element = document.createElement('ul')
$container.appendChild(this.$element)
// 새로운 상태를 받아서 현재 컴포넌트의 상태를 변경하고 리렌더링하는 함수
this.setState = (newState) => {
this.state = { ...this.state, ...newState } // 상태 업데이트
this.render() // 리렌더링
}
// 컴포넌트를 render하는 함수
this.render = () => {
this.$element.innerHTML = this.state.nodes.map(node =>
`<li>${node.name}</li>`
)
}
this.render() // new로 인스턴스화 한 후 바로 렌더링되도록 render 함수 실행
}
// 클래스형 컴포넌트
class Nodes {
constructor({ $app, initialState )) {
this.state = initialState
this.$target = document.createElement('ul')
$app.appendChild(this.$target)
this.render()
}
setState(nextState) {
this.state = nextState
this.render()
}
render() {
this.$target.innerHTML = this.state.nodes.map(node =>
`<li>${node.name}</li>`
)
}
}
- 컴포넌트는 함수형(생성자 함수) 또는 클래스형으로 만들 수 있다. 이 구조에서 주목해야 할 부분은 크게 세 가지이다.
- constructor(생성자)
- new로 컴포넌트가 생성되는 시점에 실행한다. 해당 컴포넌트가 표현될 element를 생성하고, 파라미터로 받은 DOM 루트에 렌더링한다.
- render
- 컴포넌트의 현재 상태를 기준으로 element에 렌더링한다.
- setState
- 해당 컴포넌트의 state를 업데이트하는 함수이다.
- 컴포넌트를 만드는 순서
- 파라미터로는 해당 컴포넌트의 부모 요소(루트)와 필요한 초기 상태를 받는다.
- constructor에 컴포넌트가 처음 생성될 때 필요한 초기 변수(state, $element) 등을 작성 - state: 초기 상태 할당, $element는 해당 컴포넌트의 루트
- 생성한 루트 $element를 부모 요소에 appendChild해서 연결한다.
- render 함수에서 해당 요소의 DOMString을 선언적으로 $element 요소의 자식으로 넣는다. 이렇게 하면 파라미터로 받은 부모 요소에 해당 컴포넌트가 부착된다.
- 클래스형과 함수형의 차이
- 클래스 필드 생성 및 초기화 : 클래스형은 constructor 안에서, 함수형은 함수 몸체 내부에서 this에 프로퍼티를 추가한다. 클래스에서는 이를 클래스 필드(=데이터 멤버=멤버 변수)라고 하고, 함수형에서는 프로퍼티라고 한다.
- 클래스 몸체에는 methodName()처럼 메서드만 선언할 수 있다. (최신 브라우저에서는 몸체에 선언 가능). 함수형에서는 this.methodName = () ⇒ {} 와 같이 객체의 프로퍼티로 정의한다.
- 클래스는 항상 new와 함께 호출해야 하지만, 함수형은 new 없이도 호출이 가능하기 때문에 유의해야 한다.
컴포넌트 간 의존도 줄이기
- 한 컴포넌트에서 발생하는 이벤트가 다른 컴포넌트에 영향을 미칠 때는 의존성에 대해 생각해봐야 한다. 한 컴포넌트에서 직접 다른 컴포넌트를 다루거나 업데이트하도록 하면 독립성이 떨어지고 의존성이 생기기 때문에 해당 컴포넌트만 필요한 화면에서 사용할 수 없게 된다.
- 이 경우, 일반적으로 두 컴포넌트를 조율하는 상위 컴포넌트를 만들고, 콜백 함수를 통해 느슨하게 결합한다.
// App.js (상위 컴포넌트)
function App() {
// 생략...
this.render = () => {
new Table({
initialState: {
data: this.state.data, // render 안에서 fetch 아니면 state에 저장?
page: this.state.page,
pageSize: this.state.pageSize
}
})
new Dropdown({
// pageSize를 받아 상태 업데이트
onChange: (pageSize) => this.setState({ pageSize })
})
}
}
// Table.js
function Table({ initialState }) {
this.state = initialState
this.render = () => {
const { data, page, pageSize } = this.state
// 전달받은 상태를 기준으로 그려질 데이터만 필터
const filteredData = data.slice(pageSize * (page - 1), per * page)
this.$element.innerHTML = `...`
}
}
// Dropdown.js
function Dropdown({ initialState, onChange }) {
this.render = () => {
//...
}
this.$element.addEventListener('change', (e) => {
// select 요소에서 변경이 발생하면 값 넘겨주고, 세부 내용은 App에서 처리
onChange(+e.target.value)
})
}
- Dev-Matching 문제 가운데 Dropdown(한 컴포넌트)에서 지정한 pageSize에 따라 한 페이지에 보여지는 테이블(다른 컴포넌트) 데이터 크기가 변경되어야 하는 문제가 있었다.
- 이 경우, Dropdown 컴포넌트에서 직접 Table 컴포넌트에 데이터를 전달하거나 변경하면 의존도가 높아지게 되므로, 상위 컴포넌트인 App에서 data를 가지고 있고, Dropdown에서 pageSize를 변경하면 해당 data를 필터할 수 있도록 Table 컴포넌트로 해당 pageSize를 넘겨주는 방식으로 구성할 수 있다.
- 이렇게 하면 App이 두 컴포넌트(Dropdown과 Table)를 조율하는 형태가 되고, 두 컴포넌트는 독립적으로 동작해 다른 곳에 재활용할 수 있는 구조가 된다.
fetch 함수로 데이터 불러오기
- fetch 함수는 url을 파라미터로 받고 Promise를 반환한다.
- fetch()로 반환되는 Promise 객체는 HTTP error 상태를 reject하지 않는다는 점에 유의해야 한다. 따라서 http 요청 중 에러가 발생해도 catch로 떨어지지 않고, 요청이 성공했는지 확인하려면 response의 ok를 확인해야 한다.
- api를 호출하는 부분은 별도로 분리한다! 각 컴포넌트가 데이터를 어떤 방식으로 불러올지는 해당 컴포넌트의 관심사가 아니기 때문이다.
- async, await를 활용해 함수 체이닝을 없애고, 비동기를 동기식 문법으로 작성한다.
// api.js
// api end point를 변경 쉽도록 상수 처리
const API_END_POINT = '...'
const request = (id) => {
try {
// 외부에서 받은 id를 기준으로 fetch 요청
const res = fetch(`${API_END_POINT}/${id? id: ''}`);
if (!res.ok) {
throw new Error("something went wrong!");
}
return res.json();
} catch (error) {
throw new Error(`something went wrong! ${error.message}`);
}
};
그 외
- import, export 사용하기
- 데이터 로딩 중 UI 사용하기
- 로딩 컴포넌트 만들어 사용 → 마찬가지로 부모 요소와 초기 상태를 받아 렌더링
- isLoading을 App의 상태로 가지고 있고, 로딩 컴포넌트로 이 상태를 내려준다.
- 중요한 것은 api.js에서 로딩 중 화면을 표시하고 숨기고 하는 처리를 하지 않는다는 것이다. 이는 api.js의 관심사가 아니기 때문이다. 상태를 변경하고, 상태에 따라 컴포넌트가 렌더링되도록 한다.
- 최상위 컴포넌트 App에 있는 상태들을 사용하는 하위 컴포넌트로 내려준다. 어떤 컴포넌트에서 상태를 변경하면 App에 끌어올려진 상태가 변경되고, 해당 상태를 사용하는 하위 컴포넌트의 상태도 갱신한다.
const loading = new Loading(this.state.isLoading)
// 각 하위 컴포넌트에 필요한 상태 갱신
this.setState = (nextState) => {
this.state = nextState
breadcrumb.setState(this.state.depth)
nodes.setState({
isRoot: this.state.isRoot,
nodes: this.state.nodes
})
imageView.setState(this.state.selectedFilePath)
loading.setState(this.state.isLoading)
}
캐싱 기능 구현하기
- 한 번 불러온 데이터는 다시 요청 시 캐시된 데이터를 불러오도록 하는 기능
- App 컴포넌트에서 모든 데이터를 받아오는 경우, 캐시를 위한 cache 객체를 만들어 관리할 수 있다. 데이터를 불러올 때마다 데이터를 쌓아두고, 해당 데이터가 필요할 때 cache에 있는지 확인한다. 있다면 해당 데이터를 사용하고, 아닌 경우 데이터를 요청해 cache에 저장한다.
- 이 경우, 시작점에도 cache에 저장하고, 이전으로 돌아가는 경우에도 cache에서 데이터를 가져와 쓸 수 있다.
// App.js
// nodeId: nodes 형태로 데이터를 불러올 때마다 캐시에 저장
const cache = {};
export default function App($app) {
const nodes = new Nodes({
$app,
initialState: [],
onClick: async (node) => {
try {
// 캐시에 nodeId가 저장되어 있는 경우 -> 캐시에 있는 데이터 사용
if (cache[node.id]) {
const nextNodes = cache[node.id]
this.setState({
...this.state,
depth: [...this.state.depth, node],
nodes: nextNodes,
});
// 캐시에 없는 경우 -> api 요청하고 캐시에 저장
} else {
const nextNodes = await request(node.id);
this.setState({
...this.state,
depth: [...this.state.depth, node],
nodes: nextNodes,
});
// 캐시 업데이트
cache[node.id] = nextNodes;
}
} catch (e) {
// 에러 처리하기
}
},
// 뒤로 가기 기능
onBackClick: async () => {
try {
const nextState = { ...this.state };
nextState.depth.pop();
// 이전 노드
const prevNodeId =
nextState.depth.length === 0
? null
: nextState.depth[nextState.depth.length - 1].id;
// 이전 노드가 없으면, 즉 루트 노드라면
if (prevNodeId === null) {
const rootNodes = await request();
this.setState({
...nextState,
isRoot: true,
nodes: cache.rootNodes, // cache에 저장된 루트 노드
});
} else {
this.setState({
...nextNodes,
isRoot: false,
nodes: cache[prevNodeId], // cache에 저장된 이전 노드
});
}
} catch (e) {
// 에러처리
}
},
});
const init = async () => {
this.setState({
...this.state,
isLoading: true,
});
try {
const rootNodes = await request();
this.setState({
...this.state,
isLoading: false,
isRoot: true,
nodes: rootNodes,
});
// 캐시에 추가
cache.root = rootNodes;
} catch (e) {
// 에러처리
}
};
}
이벤트 최적화
- 이벤트 캡처링과 버블링을 이용해 이벤트 위임을 사용하면 요소마다 핸들러를 할당하지 않고, 요소의 공통 조상에 이벤트 핸들러를 하나 달고 이를 이용해 여러 요소에서 발생하는 이벤트를 처리할 수 있다.
- 공통 조상에 할당한 핸들러에서 event.target을 이용하면 실제로 이벤트가 발생한 요소를 알 수 있다. closest를 이용해 현재 이벤트가 발생한 요소와 가장 인접한 상위 요소를 찾아 사용할 수 있다.
- 이벤트 핸들러를 $target으로 생성한 각 컴포넌트의 최상위 요소에 달면 된다. (이벤트 바인딩)
- Breadcrumb를 클릭하여 이전 path로 돌아가기
- Breadcrumb를 클릭하면 해당 경로로 돌아가는 기능
- Breadcrumb에서 클릭 이벤트가 발생하면 해당 path의 Index를 app으로 전달
- app에서 index에 따라 상태 갱신
- index=== null일 때 : cache.root로 경로 변경
- index === this.state.depth.length-1 일 때(현재 위치인 경우) : return 처리
- 그 이외(즉, 경로가 변경된 경우) : depth를 0~index까지 slice. nodes는 cache에서 현재 depth의 마지막 요소 반환
const loading = new Loading(this.state.isLoading)
// 각 하위 컴포넌트에 필요한 상태 갱신
this.setState = (nextState) => {
this.state = nextState
breadcrumb.setState(this.state.depth)
nodes.setState({
isRoot: this.state.isRoot,
nodes: this.state.nodes
})
imageView.setState(this.state.selectedFilePath)
loading.setState(this.state.isLoading)
}
🖇️ REF
'2021 Dev-Matching: 웹 프론트엔드 개발자(상반기)' 기출 문제 해설
'FrontEnd > JavaScript' 카테고리의 다른 글
[JavaScript] sort 함수를 이용한 데이터 정렬 (0) | 2023.06.22 |
---|---|
[프로그래머스 FE 2023 Dev-Matching] 인사 정보 SPA 리뉴얼(History API, 자바스크립트 import, localStorage 사용) (0) | 2023.06.15 |