본 과제는 프로그래머스 2021년 Dev-Matching: 웹 프론트엔드 개발자 (상반기) 과제입니다.
과제 링크
과제 공식해설 링크
↑ 위 페이지에서 과제를 풀어보고 공식 해설로 공부하였습니다.
한번 풀어보기만 하는 것으로는 배우는 것이 적다고 생각하여 글로 정리하여 깊게 남겨보고자 합니다.
github 코드
코드를 완성한 후 과정에 맞게 수정하는 것이라 오류가 있을 수 있습니다.
댓글로 오류를 알려주시거나 새로운 지식을 공유해주신다면 감사하겠습니다.
(간략하게 작성하였으니 문제의 상세조건은 위 과제링크를 참고해주세요)
[
{
"id": string // 문자열로 된 Node의 고유값입니다.
"name": string // 디렉토리 혹은 파일의 이름입니다. 화면에 표시할 때 사용합니다.
"type": string // 파일인지 디렉토리인지 여부입니다. 파일인 경우 FILE, 디렉토리인 경우 DIRECTORY 입니다.
"filePath": string // 파일인 경우에 존재하는 값입니다. 해당 파일 이미지를 불러오기 위한 경로가 들어있습니다.
"parent": object | null {
"id": string // 해당 Node가 어디에 속하는지 나타내는 값입니다. parent가 null이면 root에 존재하는 파일 / 디렉토리입니다.
},
}
렌더링
api로 부터 사용자 경로에 대한 nodes data를 받는다.
사용자 경로를 Breadcrumb를 통해 표기한다.
nodes 내부를 돌면서 각 node의 type이 파일인지, 디렉토리인지에 따라 이미지 렌더링을 다르게 한다.
root 경로가 아니면 상위 폴더로 돌아가는 버튼을 렌더링한다.
이벤트
node 를 클릭했을 때 해당 node의 데이터를 가져온다.
해당 node가 디렉토리인 경우
사용자 경로를 업데이트 한다.
위 렌더링을 거친다.
해당 node가 파일인 경우
api 주소와 filePath를 통해 이미지를 렌더링한다. (esc와 클릭을 통해 이미지 닫기)
문제에는 각 화면의 UI 요소는 가급적 컴포넌트 형태로 추상화하여 동작해야함
이라는 조건이 있다.
이는 명령형 보다는 선언형 프로그래밍 방식으로 접근하라는 말과 같다. 선언형 프로그래밍은 코드를 순서대로 나타내는 명령형과 달리 코드를 추상화한다. 코드를 분리하여 재사용을 용이하게, 이름을 붙여 가독성을 높게 만드는 것이다.
명령형 프로그래밍으로 코드를 작성한다면, DOM
에 직접 접근하여 업데이트하는 코드가 많아질 것이고, 그 코드들의 타이밍을 제어해서 묶는게 아니라면 DOM
에 직접 접근하는 순간이 많아질 것이다. 그렇게되면 코드가 길어질 수록 DOM
의 업데이트를 추적하기가 힘들어진다.
이 문제점을 해결하기 위해 사람들은 어떠한 상태를 기준으로 렌더링하자
라고 생각했다.
이 아이디어는 많은 자바스크립트 라이브러리에서 사용되고 있으며 대표적으로는 React.js
가 있다.
과제의 조건에서 라이브러리의 사용은 금지하고 있으므로 직접 구현함으로써 자바스크립트 라이브러리들의 작동원리를 배울 수 있을 것이다.
//Nodes.js
export default function Nodes({ $app, initialState}) {
this.state = initialState;
this.$target = document.createElement("div");
this.$target.className = "Nodes";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
const nodeTemplate = this.state.nodes
.map((node) => {
return `
<div class="Node" data-node-id = "${node.id}">
<img src="./assets/${node.type.toLowerCase()}.png"/>
<div>${node.name}</div>
</div>`;
})
.join("");
this.$target.innerHTML = this.state.isRoot
? nodeTemplate
: `<div class="Node">
<img src = "./assets/prev.png"/>
</div>
${nodeTemplate}`;
};
this.render();
}
Nodes 의 형태를 생성자 함수로 구현한 코드이다.
$app : nodes를 렌더링할 html element
initialState : 초기상태로 저장할 값
this.setState : nextState 를 this.state로 교체한 후 렌더링한다.
this.render : nodes 데이터를 통해 DOM에 직접 접근하여 렌더링한다.
Nodes 생성자 함수에서는 this.render();
코드를 통해 인스턴스 생성시 자동으로 렌더링을 한다.
그 후 setState를 통한 상태변경이 일어나면, 변경된 상태를 통해 render 함수가 DOM에 직접 접근하여 렌더링을 한다. 이를 통해 DOM에 직접 접근하는 코드를 제어할 수 있다.
//Breadcrumb.js
export default function Breadcrumb({ $app, initialState}) {
this.state = initialState;
this.$target = document.createElement("nav");
this.$target.className = "Breadcrumb";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.$target.innerHTML = `<div class="nav-item">root</div>${this.state
.map(
(node, index) =>
`<div class = "nav-item" data-index = "${index}">${node.name}</div>`
)
.join("")}`;
};
this.render();
}
Breadcrumb의 형태도 Nodes와 같은 방식으로 구현하였다.
구현해야할 이벤트를 생각해보자.
Node를 클릭했는데 type이 directory라면 Nodes 뿐 아니라 Breadcrumb의 상태가 update 되어야한다.
잘못하면 Nodes 함수 내부에서 Breadcrumb 의 상태를 update하는 코드를 작성하게 될 수 있다.
구현 유의사항에는 각 컴포넌트가 서로 의존성을 가지지 않고 App 또는 그에 준하는 컴포넌트가 조율하는 형태로 동작해야한다
라고 되어있다. 의존성을 가지면 코드를 재사용하기가 어려워진다. Breadcrumb 없이 nodes 만 사용해야 하는 경우가 생길 수 있기 때문에 의존성을 가지지 않도록 코드를 작성해야한다.
Nodes 와 Breadcrumb를 조율하는 상위 컴포넌트 App을 만들고, Node 를 클릭했을 때 동작할 함수를 App에서 콜백함수로 작성하여 전달한다면 컴포넌트간의 의존성을 줄일 수 있다.
//Nodes.js
export default function Nodes({ $app, initialState, onClick}) {
this.state = initialState;
this.onClick = onClick;
this.$target = document.createElement("div");
this.$target.className = "Nodes";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
const nodeTemplate = this.state.nodes
.map((node) => {
return `
<div class="Node" data-node-id = "${node.id}">
<img src="./assets/${node.type.toLowerCase()}.png"/>
<div>${node.name}</div>
</div>`;
})
.join("");
this.$target.innerHTML = this.state.isRoot
? nodeTemplate
: `<div class="Node">
<img src = "./assets/prev.png"/>
</div>
${nodeTemplate}`;
};
this.$target.addEventListener("click", (e) => {
const { nodeId } = e.target.closest(".Node").dataset;
const selectedNode = this.state.nodes.find((node) => node.id === nodeId);
if (selectedNode) this.onClick(selectedNode);
});
this.render();
}
//App.js
export default function App($app) {
this.state = {
isRoot: false,
nodes: [],
depth: [],
};
const breadcrumb = new Breadcrumb({$app, initialState: this.state.depth});
const nodes = new Nodes({
$app,
initialState: { isRoot: this.state.isRoot, nodes: this.state.nodes },
onClick: async (node) => {
if (node.type === "DIRECTORY") {
// DIRECTORY 인 경우 처리
// 여기서 Breadcrumb 관련 처리를 하면 Nodes는 Bread에 대해 몰라도 됨
} else if (node.type === "FILE") {
// FILE 인 경우
}
}
});
this.setState = (nextState) => {
this.state = nextState;
breadcrumb.setState(this.state.depth);
nodes.setState({ isRoot: this.state.isRoot, nodes: this.state.nodes });
};
}
//index.js
new App(document.querySelector(".App"));
fetch 함수와 api 주소를 통해 데이터를 받아올 수 있다.
문제 요구사항에는 api 받아오는 코드를 따로 분리하여 작성하라고 되어있다.
데이터를 어떤 방식으로 받아올지는 각 컴포넌트의 관심사가 아니기 때문이다.
//api.js
const API_END_POINT = "...";
export const request = async (nodeId) => {
try {
const res = await fetch(`${API_END_POINT}/${nodeId ? nodeId : ""}`);
if (!res.ok) throw new Error("서버의 상태가 이상합니다!");
return await res.json();
} catch (e) {
throw new Error(`무엇인가 잘못 되었습니다. ${e.message}`);
}
};
export default function App($app) {
/* 코드 생략 */
this.init = async () => {
try {
const rootNodes = await request();
this.setState({
...this.state,
isRoot: true,
nodes: rootNodes,
});
} catch (e) {
throw new Error(e);
}
};
this.init();
}
이로써 App 인스턴스가 생성될 때 init 함수가 실행되어 데이터를 받아오고 각 컴포넌트들의 상태를 업데이트한다.
export default function App($app) {
/* ... */
const nodes = new Nodes({
$app,
initialState: { isRoot: this.state.isRoot, nodes: this.state.nodes },
onClick: async (node) => {
try {
if (node.type === "DIRECTORY") {
const nextNodes = await request(node.id);
this.setState({
...this.state,
isRoot: false,
depth: [...this.state.depth, node],
nodes: nextNodes,
});
} else if (node.type === "FILE") {
// file 인 경우
}
} catch (e) {
throw new Error(e.message);
}
},
});
/* ... */
}
onClick 에서 node 데이터를 받아 node.id 로 nextNodes 정보를 받아온다.
depth에는 현재 노드를 추가하고 nodes는 nextNodes로 업데이트한다.
파일을 클릭했을 때 이미지가 나오는 이벤트를 처리해야한다.
//ImageView.js
const IMAGE_PATH_PREFIX = "...";
export default function ImageView({ $app, initialState, onClick }) {
this.state = initialState; // filePath
this.onClick = onClick;
this.$target = document.createElement("div");
this.$target.className = "Modal ImageView";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.$target.innerHTML = `<div class="content">${
this.state ? `<img src = "${IMAGE_PATH_PREFIX}${this.state}" />` : ""
}</div>`;
this.$target.style.display = this.state ? "block" : "none";
this.$target.addEventListener("click", onClick);
};
this.render();
}
ImageView의 state 는 filePath 이다.
filePath 가 없는 경우 null 값을 받아 $target의 style.display 속성을 조절하도록 했다.
export default function App($app) {
this.state = {
isRoot: false,
nodes: [],
depth: [],
selectedFilePath: null,
};
const imageView = new ImageView({
$app,
initialState: this.state.selectedFilePath,
onClick: (e) => {
if (e.target.nodeName !== "IMG") // ImageView를 클릭했지만 Image는 아닌경우
this.setState({ ...this.state, selectedFilePath: null }); // 이미지 닫기
},
});
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);
};
this.init = async () => {
try {
const rootNodes = await request();
window.addEventListener("keydown", (e) => { // esc 를 누르는 경우
if (e.key === "Escape")
this.setState({ ...this.state, selectedFilePath: null });
});
this.setState({
...this.state,
isRoot: true,
nodes: rootNodes,
});
} catch (e) {
throw new Error(e);
}
};
this.init();
}
이미지를 닫는 방법은 두가지로 규정되어있다.
이미지가 아닌 부분을 클릭하는 경우, 그리고 esc를 누르는 경우다.
문제에서 ImageView의 style은 전체 화면을 차지한다. 따라서 ImageView를 클릭했지만 이벤트 타겟이 img 가 아닌 경우 이미지를 닫도록 했다.
또한 App 의 init 함수가 초기화 하는 과정에서 window에 keydown 이벤트를 걸었다. esc 를 누르는 경우 이미지를 닫도록 했다.
Nodes 는 사용자 경로가 root 가 아닌 경우 뒤로가기 버튼을 렌더링하기에 뒤로가기 이벤트를 구현해야한다.
App 코드는 Breadcrumb 의 상태로 depth라는 배열을 사용하는데 이 안에 지금까지 거쳐온 경로들의 nodes data가 담겨있다.
따라서 뒤로가기 버튼을 누르면
1. depth 배열 끝에 있는 node data를 pop 하고
2. 남은 배열 마지막 node data의 node.id를 통해 nextNodes를 받아와 렌더링하면된다.
// Node.js
export default function Nodes({ $app, initialState, onClick, onBackClick }) {
this.state = initialState;
this.onClick = onClick;
this.onBackClick = onBackClick;
this.$target = document.createElement("div");
this.$target.className = "Nodes";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
const nodeTemplate = this.state.nodes
.map((node) => {
return `
<div class="Node" data-node-id = "${node.id}">
<img src="./assets/${node.type.toLowerCase()}.png"/>
<div>${node.name}</div>
</div>`;
})
.join("");
this.$target.innerHTML = this.state.isRoot
? nodeTemplate
: `<div class="Node">
<img src = "./assets/prev.png"/>
</div>
${nodeTemplate}`;
};
this.$target.addEventListener("click", (e) => {
const { nodeId } = e.target.closest(".Node").dataset;
if (!nodeId) this.onBackClick();
const selectedNode = this.state.nodes.find((node) => node.id === nodeId);
if (selectedNode) this.onClick(selectedNode);
});
this.render();
}
// App.js
export default function App($app) {
this.state = {
isRoot: false,
nodes: [],
depth: [],
selectedFilePath: null,
};
const loading = new Loading({...});
const imageView = new ImageView({...});
const breadcrumb = new Breadcrumb({$app, initialState: this.state.depth});
const nodes = new Nodes({
$app,
initialState: { isRoot: this.state.isRoot, nodes: this.state.nodes },
onClick: async (node) => {...},
onBackClick: async () => {
try {
const nextState = { ...this.state };
nextState.depth.pop();
const prevNodeId = nextState.depth.length
? nextState.depth[nextState.depth.length - 1].id
: null;
this.setState({
...nextState,
isRoot: !prevNodeId,
nodes: await request(prevNodeId),
});
} catch (e) {
throw new Error(e.message);
}
},
});
this.init = async () => {...};
this.init();
}
이로써 뒤로가기 기능까지 구현하였다.
필수적인 요구사항은 구현했기에 1편을 여기서 마무리한다. 2편에서는
를 구현할 것이다.
빵 부스러기로 표시한 길. 헨젤과 그레텔에서 따온 용어로
사용자 인터페이스에서 사용자가 어떤 위치에 있는지 시각적으로 나타내는 기법 중 하나이다.
명령형 프로그래밍과 대비되는 개념으로 프로그램이 어떤 방법으로 해야하는지를 나타내기 보다는 무엇과 같은지를 설명하는 경우를 말한다. 명령형으로 나타내어진 코드를 추상화하여 어떤 행위와 같은지 나타내는 것이다. 읽으며 추론하기 쉽다는 장점이 있다.