항해 플러스 프론트엔드 6기 1주차, Chapter 1-1. 프레임워크 없이 SPA 만들기 (1)
👋 안녕하세요, 프론트엔드 개발자 여찬규입니다! 요즘 회사일에 치여서 인생이 재미없던 차에 항해 플러스 모집광고를 보게 되었고, 그 자리에서 바로 지원하게 되었어요 무언가에 이끌려 시작하게 되었는데, 시작한지 1주일밖에 안되었지만 엄청 재미있고 좋은 사람들도 많이 알...

이전 블로그 링크: https://velog.io/@chan9yu/hanghae-plus-wil1
(썸네일 추천 감사합니다 부팀장님 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ)
항해 플러스 시작하기
👋 안녕하세요, 프론트엔드 개발자 여찬규입니다!
요즘 회사일에 치여서 인생이 재미없던 차에 항해플러스 모집 광고를 보게 되었고, 그 자리에서 바로 지원하게 되었어요. 무언가에 이끌려 시작하게 되었는데, 시작한 지 1주일밖에 안 되었지만 엄청 재미있고 좋은 사람들도 많이 알아가는 중이라 굉장히 만족하고 있습니다. (완전 강추..)
어 그런데.. 많이 힘든 것 같아요.... 과제 마감 전까지 사람들이 잠도 안 자고 계속 과제만 하고 있더라고요 ㅠㅠ 과제 볼륨도 크고 회사 업무 때문에 시간도 부족해서 아쉬웠던 시작이었지만, 팀원들이 서로 으쌰으쌰 해주면서 서로 진도를 나가는 게 정말 힘이 되었습니다. 우리 팀 정말 최고인 것 같아요 👍
5팀 10주 동안 탈주자 없이 파이팅!
탈주자는 죽음뿐..
1주차 과제
총 3주차는 자바스크립트와 리액트에 대해 딥다이브를 하는 주간입니다!
그 중 1주차는 프레임워크의 도움 없이 순수 자바스크립트로 Single Page Application를 구현하는 과제였어요. 벌써부터 머리가 아프지만 3주 뒤에는 많은 것을 얻어갈 수 있는 섹션이 될 것 같았습니다.
어떻게 구현했을까?
초반에 고민을 많이 했습니다. 순수 자바스크립트로 어떻게 SPA를 구현해야 할까? 리액트는 어떻게 구현했는지에 대해 먼저 생각해보고 거기서 아이디어를 얻은 것 같아요.
컴포넌트
일단 기초가 되는 컴포넌트부터 구현하기로 했습니다. 컴포넌트가 필요한 기능은 무엇이 있을까요?
- life-cycle
- props
- state
- re-render
위 기능들을 토대로 기초가 되는 추상 클래스 Component
를 만들고, 컴포넌트들은 Component
클래스를 상속하여 정해진 메소드 내에서만 기능을 구현하면 되게끔 만들었습니다.
여기서 라이프사이클은 EventEmitter
를 통해 이벤트를 emit하는 방식으로 구현했고, state가 변경될 때마다 render 메소드가 다시 실행되어 DOM을 최신 상태로 갱신할 수 있도록 구현했습니다.
export class MyComponent extends Component {
constructor(props) {
super(props);
this.on(Component.EVENTS.MOUNT, () => {
// 컴포넌트 마운트 시점에 발생
});
this.on(Component.EVENTS.UPDATE, () => {
// 컴포넌트 리렌더 시점에 발생
});
this.on(Component.EVENTS.UNMOUNT, () => {
// 컴포넌트 언마운트 시점에 발생
});
}
}
라우터
다음으로 중요한 건 역시 라우터겠죠? 라우터는 무슨 기능이 있으면 좋을까요?
- 경로 기반 컴포넌트 매핑 (라우트 정의)
- URL 변경 없이 주소만 변경 (pushState)
- 동적 파라미터 처리
- 쿼리스트링 파싱 및 전달
- 프로그램적 이동 (navigate 함수)
- 히스토리 API 연동 (뒤로/앞으로 가기 지원)
위 기능들을 토대로 Router 클래스를 구성했습니다.
SPA의 라우터는 전통적인 웹서비스의 라우터와 다릅니다. 보통은 서버에 페이지 요청을 하지만 SPA는 index.html 단 하나의 페이지에서 자바스크립트를 통해 화면이 변경되는 건데요. 그러면 서버 요청을 막고 페이지를 전환하는 방법이 무엇이 있을까요?
export class Router {
// ...
navigate(path) {
if (typeof path !== "string") {
throw new Error("경로를 문자열로 입력해주세요");
}
if (this.#currentRoute !== path) {
window.history.pushState({}, "", path);
this.#handleRouteChange();
}
}
// ...
}
바로 window.history.pushState()
를 이용하는 것입니다. History:pushState()
pushState()는 페이지를 새로 고침하지 않고도 브라우저의 주소(URL)를 변경할 수 있는 메서드입니다. 이 기능을 통해 SPA에서 화면 전환을 부드럽게 처리할 수 있는데 예를 들어, 사용자가 어떤 메뉴를 클릭했을 때:
window.history.pushState({}, "", "/products/123");
이렇게 하면 브라우저의 주소창은 /products/123으로 바뀌지만, 페이지 전체가 새로 고쳐지지 않고, 클라이언트 측에서 필요한 컴포넌트만 렌더링됩니다.
라우터와 컴포넌트 연결
결국 라우터 클래스 내부에서 매칭되는 path에 페이지 컴포넌트를 렌더링하게 됩니다. 이 시점에서 페이지를 만들 때 자기 자신의 객체 즉 Router 인스턴스를 주입해주어 navigate 등 메서드를 쉽게 사용할 수 있도록 구성했습니다.
export class Router {
// ...
/**
* 컴포넌트 인스턴스 생성
*
* @description 클래스를 인자로 받아 컴포넌트 인스턴스를 생성하고 라우터 의존성을 주입한다.
*
* @private
*/
#createComponent(componentConstructor) {
try {
// 컴포넌트 인스턴스 생성 시 라우터 의존성 주입
const component = new componentConstructor({ router: this }); // 이 부분!
if (!(component instanceof Component)) {
throw new Error("컴포넌트는 추상클래스 Component를 상속해야 합니다!!");
}
return component;
} catch (error) {
if (error instanceof Error) {
console.error("컴포넌트 인스턴스 생성 실패:", error.message);
throw error;
}
}
}
// ...
}
위 메서드는 매핑되는 path에 접근 시 페이지 컴포넌트의 인스턴스를 만드는 메서드인데 this를 주입해줌으로써 컴포넌트는 props를 통해 쉽게 사용할 수 있습니다.
export class ProductListPage extends Component {
// ...
bindEvents(element) {
element.addEventListener("click", (e) => {
const targetElement = e.target.closest("[data-route]");
if (targetElement) {
const route = targetElement.dataset.route;
this.props.router.navigate(route); // 이렇게 사용할 수 있게 됩니다
return;
}
}
}
// ...
}
참고로 컴포넌트 레벨에서 이벤트는 이벤트 위임을 통해 바인딩해주고 있습니다.
this 바인딩 문제
라우터를 구현하면서 this 바인딩 관련 문제가 발생했습니다.
Uncaught TypeError: Cannot read private member #currentRoute from an object whose class did not declare it
코드 분석
Router 클래스 (Private 필드 사용)
export class Router {
#currentRoute = null;
navigate(path) {
if (this.#currentRoute !== path) {
// ← 여기서 에러 발생
window.history.pushState({}, "", path);
this.#handleRouteChange();
}
}
}
App에서 의존성 주입
class App {
init() {
// 메서드만 전달하면 this 바인딩이 끊어짐
this.router.register("/", new ProductListPage(this.router.navigate));
}
}
컴포넌트에서 사용
class ProductListPage {
constructor(navigate) {
this.navigate = navigate; // ← 이때 this가 Router가 아님!
}
handleClick(route) {
this.navigate(route); // ← 에러 발생!
}
}
문제 원인
JavaScript에서 메서드를 다른 변수에 할당하면 this
바인딩이 끊어집니다. 이때, navigate
메서드가 호출 시점에 this
가 Router 인스턴스가 아니므로 private 필드에 접근할 수 없어 에러가 발생하는 거였습니다.
해결책
제 생각에는 3가지 정도의 해결책이 있는 거 같습니다
1. bind 사용하여 this 연결
// 1. bind() 사용 - 매번 바인딩 필요
new ProductListPage(this.router.navigate.bind(this.router));
// 2. Router 생성자에서 미리 바인딩
constructor() {
this.navigate = this.navigate.bind(this);
}
2. 인스턴스 주입 (저는 이 방법이 제일 좋은 거 같습니다!)
// 인스턴스 주입
class App {
init() {
// 메서드 대신 라우터 인스턴스 자체를 전달
this.router.register("/", new ProductListPage(this.router));
this.router.register("/product/:id", new ProductPage(this.router));
}
}
class ProductListPage {
constructor(router) {
this.router = router; // Router 인스턴스 저장
}
handleClick(route) {
this.router.navigate(route); // 안전한 메서드 호출
}
}
3. 화살표 함수로 해결
class Router {
#currentRoute = null;
// Arrow function으로 정의하면 this 바인딩 자동 해결
navigate = (path) => {
if (this.#currentRoute !== path) {
window.history.pushState({}, "", path);
this.#handleRouteChange();
}
};
}
이벤트 위임
SPA 구조에서는 컴포넌트가 동적으로 생성되고 사라지기 때문에, 모든 요소에 개별적으로 이벤트를 등록하는 것은 비효율적입니다.
그래서 하나의 상위 요소에 이벤트를 등록하고, e.target
을 통해 실제 클릭된 요소를 판별하는 이벤트 위임 방식을 사용했습니다.
element.addEventListener("click", (e) => {
const route = e.target.closest("[data-route]")?.dataset.route;
if (route) this.props.router.navigate(route);
});
이 방식은 DOM이 동적으로 바뀌어도 이벤트를 재등록할 필요가 없고, 메모리 효율성과 성능 면에서도 큰 장점이 있습니다. 덕분에 코드가 훨씬 간결하고 유지보수도 쉬워졌습니다.
애플리케이션 구조
저렇게 클래스로 구현해두었다면 어떻게 사용하는지 궁금하죠?
class SPAService {
constructor() {
this.router = new Router("#root");
this.init();
}
init() {
this.router.register("/", ProductListPage);
this.router.register("/product/:productId", ProductPage);
this.router.register("*", NotFoundPage);
this.router.start();
}
}
function main() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => new SPAService());
} else {
new SPAService();
}
}
// 애플리케이션 시작
if (import.meta.env.MODE !== "test") {
enableMocking().then(main);
} else {
main();
}
SPAService
클래스는 애플리케이션의 진입점 역할을 합니다. 라우터를 초기화하고 각 경로에 맞는 페이지 컴포넌트들을 등록한 후 라우터를 시작하는 구조입니다.
라우트 등록 부분을 보면 동적 파라미터(:productId)와 와일드카드(*)를 지원하는 것을 알 수 있는데, 이는 실제 라우터 라이브러리들과 유사한 패턴처럼 보이기 위해 구현했습니다.
그래서 결과는..?
목표는 심화 과제까지 통과하는 거였지만 회사 업무상 시간 부족으로 겨우 기본 과제 통과로 마무리했습니다..
배포 링크
코드 리뷰 경험
이렇게 다수에게 코드 리뷰를 받아본 적이 처음인데 정말 좋은 것 같습니다. 여러 의견들을 듣고 보니 개선점과 문제점도 보이고 리팩토링 방향성도 보이는 도움이 많이 되는 것 같았어요.
다음 리뷰 때는 좀 더 잘 받을 수 있도록 주석과 코드를 깔끔하게 작성해봐야 될 것 같네요!
(석호님, 지훈님, 유현님 좋은 리뷰 감사합니다~ 고수분들)
(ㅋㅋㅋ 정석님 파이팅!)
1주차 KPT 회고
Keep
순수 자바스크립트로 SPA 구현 경험
프레임워크 없이 직접 구현해보면서 React가 내부적으로 어떻게 동작하는지에 대한 이해도가 높아졌습니다. 특히 컴포넌트 라이프사이클과 상태 관리, 라우팅 시스템을 직접 만들어보니 평소에 당연하게 사용했던 기능들의 원리를 깊이 있게 알 수 있었습니다.
이벤트 위임 패턴 적용
동적으로 생성되는 DOM 요소들에 효율적으로 이벤트를 바인딩하는 방법을 배웠습니다. 이 패턴 덕분에 메모리 사용량도 줄이고 성능도 개선할 수 있었습니다.
Problem
시간 관리의 아쉬움
회사 업무와 병행하면서 과제를 진행하다 보니 시간이 부족했습니다. 초반에 너무 완벽하게 만들려고 시간을 많이 쏟아서 심화 과제를 완벽하게 못한 게 아쉬웠어요.
설계 단계에서의 고민 부족
구현을 시작한 후에 구조를 자주 바꾸게 되면서 시간이 많이 소요되었습니다. 사전 설계에 더 많은 시간을 투자했다면 더 효율적이었을 것 같아요.
Try
요구사항 완료 후 리팩토링
다음 과제부터는 일단 요구사항을 만족하는 구현을 빠르게 완료한 후, 여유가 있을 때 리팩토링을 진행하는 방식으로 접근해보고 싶습니다.
마무리
1주일 동안 느낀 점은 일단 구현부터 빠르게해야 된다 느낀 것 같아요. 초반에 너무 힘주고 코드를 갈아엎고 리팩토링하고 했던 점이 시간을 더 없게 만든 것 같습니다 ㅠㅠ.
이번엔 아쉽지만 워밍업이라 생각하고, 다음부터는 느슨하게 시작해서 요구사항을 맞춘 후에 리팩토링을 진행해야 될 것 같아요.
1주차 부터 힘들었지만 열심히 해서 끝까지 완주해보도록 하겠습니다 5팀 파이팅!
(반드시 완주해야 될 듯..)