프레임워크 없이 SPA 만들기(+항해 플러스 1주차 회고)
항해 플러스 프론트엔드 4기에 참여하며,
많은 고민 끝에 항해 플러스 프론트엔드 4기에 참여하게 되었다. 이 프로그램은 약 10주간 진행되며, 주니어 개발자에게 실질적인 성장을 도와주는 교육 과정이다.
사실 몇 달 동안 신청 여부를 두고 많은 망설임이 있었다. 이제 신입을 벗어난 연차로서 다시 교육을 받는 것이 맞을까? 오히려 더 부족해 보이지 않을까 하는 생각들이 나를 주저하게 만들었다.
신청을 결심하게 된 이유
결국 항해를 신청한 이유는 나 자신에게 “정말 연차에 걸맞는 실력을 가지고 있는가?”라는 의문이 더 컸기 때문이다. 지금까지 빠르게 기능을 구현하거나, 늘 해오던 방식으로 작업을 완수하는 데는 자신이 있었지만, 내 개발 실력이 성장하고 있는지, 아니면 단순히 작업 요령이 늘었을 뿐인지 확신할 수 없었다.
특히 비전공자로 개발에 입문해 학원과 회사 업무가 나의 개발 경험의 전부였기에, 이 고민은 더 깊었다. ‘물경력 탈출’이라는 문구는 나에게 굉장히 매력적으로 다가왔다.
이러한 고민 끝에 결국 항해99 플러스에 합류하게 되었다.
1주차 회고 - 프레임 워크 없이 SPA 만들기
드디어 시작된 항해 플러스 1주차 과제는 바닐라 JS로 SPA를 만드는 것이었다. 사실 처음 과제를 접했을 때는 “그거 history API로 pushState 쓰면 되는 거 아닌가?”라고 생각했던 내가 너무나도 단순했다. 과제의 본질은 단순히 “페이지 이동”이 아니라, 진정한 의미의 “SPA 구현”이었다.
1주차 과제를 진행하며 많은 시행착오와 고민을 겪었지만, 이 과정에서 배운 점과 느낀 점이 많았다. 특히, 리액트와 같은 프레임워크가 왜 많은 사랑을 받는지 절실히 이해할 수 있었다. 이제부터는 1주차 과제를 하며 깊이 고민했던 부분들을 중심으로 회고를 남기고자 한다.
고민거리
SPA를 만들면서 가장 어려웠던 점은 관심사의 분리였다. 화면에 UI 컴포넌트를 출력하는 단순한 작업처럼 보였지만, 실제로는 더 복잡한 설계와 많은 고민이 필요했다. 특히 다음과 같은 주제들이 주요 고민거리였다.
- 컴포넌트 함수의 형태와 Router와의 연결
- 이벤트 리스너 등록하기
- Router의 의존성 분리 이다.
1. 컴포넌트 함수의 형태와 Router와의 연결
초기 구현과 한계
처음에는 Component 클래스를 상속받아 각 페이지를 구성하는 구조로 작성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//ProfilePage.js
export class ProfilePage extends Component {
constructor(props) { ... }
addEvent() {
new Navigation({ pathname: "/profile", router: this.props.router }).mount();
document.getElementById("profile-form").addEventListener("submit", (e) => { ... });
}
render() {
const root = document.getElementById("root");
root.innerHTML = this.template();
}
template() {
return `<div id="root">... </div>`;
}
}
이 방식은 모든 페이지가 동일한 구조를 갖으며 메서드명으로 기능을 명시적으로 파악할 수 있으나 몇 가지 치명적인 문제가 있었다.
우선, 응집도가 지나치게 강했다. 각 페이지가 UI 렌더링, 이벤트 처리 방식을 모두 책임지다 보니 역할 분리가 어려웠고, 로직 변경 시 모든 페이지 컴포넌트를 수정해야 했다. 또한, 비표준적인 구조였다. 컴포넌트 인스턴스를 호출하는 것만으로 화면이 출력되는 방식은 일반적인 개발 패턴에서 벗어난 방식이었다. 이로 인해 코드의 동작을 예측하기 어려웠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
export function ProfilePage({ container }) {
const user = UserStore.geUser();
const updateProfile = (e) => {
if (e.target.id !== "profile-form") return;
//...
};
EventManager.addEvent(container, "submit", updateProfile);
return ` <div class="bg-gray-100 min-h-screen flex justify-center"> ... </div>`;
}
함수형 컴포넌트로의 전환
위 문제를 해결하기 위해 함수형 컴포넌트로 전환했다. 각 컴포넌트를 UI를 반환하는 데 집중하도록 설계하고, 이벤트 처리와 비즈니스 로직은 외부에서 관리하거나 위임하는 방향으로 변경했다.
함수형 컴포넌트로 변경한 이유는 다음과 같다. UI 중심 역할에 충실하게 하고자 했다 각 컴포넌트가 UI를 반환하는 데만 집중하게 하며 이를 통해 렌더링 결과를 예측 가능하게 만들었고, 비즈니스 로직이나 중복 기능을 최소화했다. 또한, 유연성과 가독성을 높였다. 클래스 기반 컴포넌트에서 혼재되어 있던 DOM 조작, 이벤트 처리 로직 등을 함수로 분리하면서 가독성과 재사용성을 개선했다.
2. 이벤트 리스너 등록하기
기존의 class 기반 컴포넌트에서 함수형 컴포넌트로 전환하면서 이벤트 처리 방식도 크게 변경하였다. 기존에는 각 페이지 컴포넌트에서 특정 요소를 직접 찾아 이벤트를 부착하는 방식이었으나 이 방식은 UI 렌더링 이후 각 컴포넌트에서 이벤트를 순차적으로 부착해야 하는 복잡한 생성 순서를 관리해야 했고, 유지보수가 어려웠다. 이를 해결하기 위해, root 요소에 이벤트 핸들러를 등록한 뒤, 이벤트 위임 방식을 적용했다. 이제 생성 순서를 관리하지 않아도 예측 가능한 이벤트 실행이 가능해졌다. (이 접근 방식은 React에서 사용하는 이벤트 위임 모델과 유사한데, DOM 트리의 상위 요소 하나에서 이벤트를 통합적으로 관리하는 방식이다.)
또한, 최상위 root요소에 이벤트가 중복되어 계속 쌓이는 문제가 있어, 이벤트 리스너 관리를 도와주는 EventManager
를 생성하여 각 페이지 렌더 시 이벤트 등록, 새로운 페이지 등록 시 기존 이벤트를 제거하도록 구현하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
//변경 전, 각 class 내 addEvent에서 profile-form요소를 찾아 이벤트를 등록한다.
addEvent() {
new Navigation({ pathname: "/profile", router: this.props.router }).mount();
document.getElementById("profile-form").addEventListener("submit", (e) => { ... });
}
//변경 후, EventManager를 이용해 상위 root요소에 이벤트를 등록한다.
const updateProfile = (e) => {
if (e.target.id !== "profile-form") return;
//...
};
EventManager.addEvent(container, "submit", updateProfile);
3. router의 의존성 분리
SPA를 만들면서 가장 고민했던 부분 중 하나는 예외 처리와 이벤트 처리를 어디서 어떻게 관리해야 해야하는지 였다. 특히, 라우터 가드를 작성할 때 많은 고민이 있었다. 특정 페이지로의 진입을 막고, 조건에 따라 다른 페이지로 리다이렉트해야 할 때, 이 책임을 누가 담당해야 할지 명확하지 않았기 때문이다.
초기에는 과제 제출 기한에 맞추기 위해 router에 모든 로직을 넣어 빠르게 구현했다. 인증이 필요한 페이지의 경우 router 내부에서 직접 인증 상태를 확인한 후, 특정 페이지로 이동하도록 강제하는 방식이었다. 그 결과, 이 router는 내 프로젝트에서만 쓸 수 있는 router가 생성되었다ㅎㅎ..
과제 제출 후 돌아보니, router에 “특정 상황에서 어디로 이동하라”는 명시적인 코드를 작성하는 것은 유지보수와 확장 측면에서 취약하다는 것을 깨달았다. 이런 고민을 하던 중 솔루션 코드에서 고차 함수(HoF)를 활용한 구조를 접하게 되었고, 문제를 더 유연하게 해결할 수 있는 아이디어를 얻었다.
내 프로젝트에 솔루션처럼 에러 처리로 구현하기에는 어려움이 있었지만, 방향을 바꿔 컴포넌트를 전달하는 방식으로 개선해 보았다. 기존에는 라우터가 인증 여부를 직접 판단하고 이동시켰다면, 이제는 인증 로직을 캡슐화한 고차 함수인 AuthGuard를 사용하여, 라우터와 인증 로직을 분리하고 재사용성을 높였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//변경 전, 각 router 메서드 내에서 직접 유효성 검사 및, 이동할 페이지를 명시하였다.
//main.js
router.createRoutes({
"/profile": { component: ProfilePage, requiresAuth: true },
...
});
//router.js
linkRoute(pathname) {
const route = this.routes[pathname] || this.routes.notFound;
if (!!route?.requiresAuth && !UserStore.checkLogin()) {
this.navigation("/login");
return;
}
if (pathname === "/login" && UserStore.checkLogin()) {
this.navigation("/");
return;
}
//...
}
//변경 후, route 컴포넌트를 지정할 때 고차함수를 사용하여 페이지를 명시하였고, router에서 관련 의존성을 제거하였다.
//main.js
router.createRoutes({
"/profile": {
component: AuthGuard((authState) => !!authState, ProfilePage, LoginPage),
},
...
});
//router.js
linkRoute(pathname) {
const route = this.routes[pathname] || this.routes.notFound;
EventManager.clearEvent();
this.container.innerHTML = route.component({
router: this,
container: this.container,
});
}
남은 고민..
이렇게 생각보다 많은 시간을 통해 과제를 완성하였고 무사히 PASS를 받을 수 있었다. 다만, 아직 개선할 점들이 크게 남아있다.
1.이벤트 위임 최적화
모든 이벤트를 root로 위임하다 보니, 한 곳으로 이벤트가 몰릴 경우 성능 이슈 가능성이 남아 있다. 우선 이벤트 핸들러에서 불필요한 작업을 방지하기 위해 early return 사용하였으나 조금 더 최적화할 방법을 고민해보아야 한다.
2. 전역 상태 관리
현재 UI는 상태 변경에 따라 반응적으로 렌더링되지 않는 구조이기 때문에 큰 문제가 없다. 그러나 과제 항목 중 “상태 업데이트 시 관련 컴포넌트 리렌더링”이 있으며, 이번 프로젝트의 목적을 선언적인 프로그래밍 방식으로 설계하는 것으로 설정하였기 때문에, 전역 상태가 변경되면 UI가 자동으로 갱신되는 구조가 더 적합하다고 판단한다. 앞으로 개선할 방향은 옵저버 패턴을 활용해 상태와 UI 간의 반응성을 추가하는 것이다. 상태 변경 시 관련 컴포넌트가 자동으로 리렌더링되도록 옵저버 패턴을 적용하여 상태가 업데이트되면 구독 중인 컴포넌트에 알림을 보내 렌더링을 트리거할 계획이다.
결론?
1주차 과제를 통해, SPA 설계와 구현에서 발생할 수 있는 다양한 문제를 고민할 기회가 되었다. 특히, 생각만 하던 싱글턴 패턴, 옵저버 패턴 등 다양한 디자인 패턴을 직접 적용해볼 수 있었다. 또한, 이번 과제를 통해 관심사 분리에 대한 시각을 넓힐 수 있었다. 멘토링 과정에서 “재사용성이 없는 분리는 과연 의미가 있는가?”라는 피드백을 받으며, 내가 분리 자체에 지나치게 몰두했던 것은 아닌지 되돌아보게 되었다. 실제로, 재사용성이 필요해질 때 분리해도 늦지 않다는 조언은 앞으로 코드 작성과 설계에서 중요한 기준이 될 것 같다. 무엇보다 이번 과제를 진행하며 기술적인 배움뿐만 아니라, 초기 설계의 중요성을 다시 한번 깨달았다. 클래스 기반 컴포넌트를 설계할 때 책임과 역할을 명확히 정의하지 않은 탓에 많은 시행착오를 겪어야 했고, 이는 설계 단계에서 더 깊이 고민하는 것이 얼마나 중요한지를 깨닫는 계기가 되었다.
앞으로는 설계 단계에서부터 명확한 목표와 기준을 세우고, 이를 바탕으로 구현에 임하며 더 나은 코드를 작성하기 위해 노력할 것이다. 1주차 과제는 기술적 성장뿐만 아니라, 개발자로서의 사고방식을 다듬는 값진 경험이 되었다.