useNavigation훅을 통해서 상태를 저장할 수 있습니다. 해당 상태는 uri에 직접적으로 반영이 되지 않는 상태입니다. 따라서 새로고침 시 막연하게 초기화 될 거라고 생각을 했었습니다. 하지만 실제 테스트 해 본 결과 상태가 초기화가 되지 않았습니다. 심지어 ChagGPT 조차 상태가 초기화 된다고 하더군요..
message에 저장됨이라는 상태를 여전히 가지고 있었습니다. useNavigate훅은 결국 History API 기반의 라이브러리 이기에 혹시 HTML 파일에서도 확인해보았고 여전히 초기화되지 않음을 확인하였습니다.
따라서 History API의 state의 스펙에 대해서 알아보기로 생각을 하였습니다.
결론은 브라우저에서는 Session History에 상태를 기록을 하는데, 해당 상태는 복원이 가능해야 합니다. 따라서 메모리에 기록이 되어야 하기 때문에, 새로고침을 하여도 다른 탭을 열거나 기존 탭을 닫는 방식이 아니면 상태가 유지가 됩니다.
History에 대해서
시작점은 History였습니다. 당연히 MDN에는 상태의 생명주기에 대한 언급이 있지 않을까라는 막연한 기대감에 접근하게 되었습니다.
History API는 history 전역 객체를 통해 브라우저 세션 히스토리(웹 익스텐션 히스토리와 혼동해서는 안 됩니다.)에 대한 접근을 제공합니다. 사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택의 내용을 조작할 수 있는 유용한 메서드와 속성을 노출합니다.
해당 문서에서 History State에 대해 찾아보니 "History.state속성은 현 history에 해당하는 state값을 나타냅니다." 라는 내용이 끝이더군요.
따라서 더 깊게 파고들기 위해서는 HTML의 History 스펙을 확인할 필요가 있었습니다.
history.state : Returns the classic history API state of the active session history entry, deserialized into a JavaScript value.
이제 Session history에 대해서 뭔저 알 필요가 생겼네요.
세션 히스토리 엔트리는 여러 가지 데이터를 포함하는 구조체(struct)로, 다음과 같은 요소를 가집니다.
step: 방문 순서를 나타내는 0 이상의 정수 또는 "pending"(초기값 "pending").
URL: 해당 히스토리 엔트리에 저장된 페이지의 URL.
document state: 해당 페이지의 문서 상태.
classic history API state: window.history.pushState()나 window.history.replaceState()를 통해 저장된 직렬화된 상태 데이터. 기본적으로 StructuredSerializeForStorage(null)로 초기화됨.
navigation API state: 네비게이션 API 관련 직렬화된 상태 데이터. 초기값은 StructuredSerializeForStorage(undefined).
navigation API key: 네비게이션 API에서 사용하는 UUID(고유 식별자).
navigation API ID: 개별 네비게이션 요청을 구분하는 또 다른 UUID(고유 식별자).
scroll restoration mode: 스크롤 복원 방식 ("auto"가 기본값).
scroll position data: 문서에서 스크롤이 복원될 위치 데이터.
persisted user state: 사용자가 입력한 데이터 등 브라우저가 유지할 상태 (기본값 null).
브라우저의 세션 히스토리는 각 탭에서 발생한 탐색을 추적하여 뒤로 가기/앞으로 가기 탐색과 세션 복원을 지원한다. 이는 chrome://history 같은 기록(history)과는 다른데, 기록은 프로필의 수명 동안 사용자가 방문한 주요 프레임의 URL을 모든 탭에 걸쳐 저장한다.
탭의 공동 세션 히스토리는 유지되므로, Chromium을 다시 시작하거나, 탭을 닫은 후, 또는 다른 기기에서 탭을 복원할 수 있다. 이를 위해 각 NavigationEntry와 그 내부 FrameNavigationEntries 트리의 상태를 PageState 객체 및 기타 메타데이터를 사용하여 직렬화해야 한다. 새로운 값을 안전하게 저장하고 복원하는 방법에 대해서는 Modifying Session History Serialization을 참고하라.
마지막으로 Modifying Session History Serialization 에서 직렬화 방법을 변경시 디스크에 남아있는 데이터에 의한 호완 문제가 생길 수 있다는 점을 경고하고 있는데, 이를 통해서 크롬에서 disk에 Session History가 저장되어있음을 알 수 있습니다. 따라서 새로고침시에도 History.state가 유지가 됩니다.
Note that changing the serialization format ishigh riskand should be approached carefully. Mistakes or missed steps can cause backwards compatibility problems, because the effects can continue to live on disk between different versions of Chromium
느낀점
사실 처음에는 History.state의 생명주기 라고 검색하면 금방 해결할 수 있는 지식이라 생각했습니다. 하지만 관련 키워드로는 검색했을 때 나오지 않더군요. 공식 스펙을 보는 것도 제로초님이 영상에서 보시는 걸 본적은 있지만 직접 본 적은 없어서 많이 어렵게 느껴졌네요. 해당 스펙이 존재한다는 것이 명시되었을 뿐 어떻게 구현할 지는 브라우저마다 다를 수 있다는 사실도 어려웠습니다. 영어를 번역하면서 열심히 읽었는데, 혹시 오역이 있었을까에 대한 막연한 걱정도 조금 남아있네요. 혹시 잘못된 부분이 있으면 지적부탁드립니다 ㅠ
그래도 덕분에 history가 트리구조가 아니라 리스트 구조가 되었다는 등 많은 정보도 확인할 수 있었네요.. 또한 ChatGpt 를 사용할 떄 좀 더 유의할 필요성을 느꼈습니다. 사실 새로고침 시 history.state가 초기화 되는지 여부 정보는 고차원적인 지식은 아니라 생각했는데 ChatGpt가 잘못 알려주더군요. 그리고 확실하게 새로고침에서 history.state가 사라지진 않는다는 지식을 얻어가네요.
과거에 next.js의 튜토리얼을 진행하면서 얕게 streaming에 대해 학습한 적이 있습니다. 그 당시에 깊게 이해하지 못한 부분에 대해서 다시 한번 정리하고자 포스팅을 작성하게 되었습니다.
전통적인 SSR의 단점
SSR은 서버에서 완성된 HTML을 클라이언트에 전송해줍니다. SSR이 진행되는 과정은 아래와 같습니다.
1. 먼저, 특정 페이지에 필요한 모든 데이터가 서버에서 가져와집니다. 2. 그런 다음, 서버에서 해당 페이지의 HTML을 렌더링합니다. 3. 이후, 페이지의 HTML, CSS, 그리고 JavaScript가 클라이언트로 전송됩니다. 4. 전송된 HTML과 CSS를 사용하여 비상호작용 UI가 화면에 표시됩니다. 5. 마지막으로, React가 UI를 하이드레이션(hydration) 하여 상호작용할 수 있도록 만듭니다.
그렇기에 한 페이지 내에서도 모든 구역이 동시에 작업이 완료되진 않습니다. 하지만 우린 완성된 HTML을 받기 때문에,하나라도 오래 걸리는 작업이 생기면 HTML을 받지 못하는 문제점을 겪게 됩니다.
Streaming은 데이터를 전송하는 기술로, 하나의 라우트를 더 작은 "청크(chunks)"로 분할하여 서버에서 클라이언트로 준비되는 대로 점진적으로 스트리밍할 수 있도록 합니다. Streaming is a data transfer technique that allows you to break down a route into smaller "chunks" and progressively stream them from the server to the client as they become ready.
React, Vue , Angular 는 보통 프론트엔드 UI 프레임워크로 묶여서 자주 언급됩니다. 사실 처음 개발을 시작할 때, 유저 수가 가장 많은 React를 선택했고 React기반의 RN 이나 Next.js를 사용해봤지만 아직 다른 UI프레임워크를 경험한 적이 없습니다. 그래서 React 다음으로 유저수가 많은 Vue의 공식문서를 통해 학습하고 간단한 TODOLIST를 만들며 느낀점을 공유하고자 합니다.
VUE 에 대해 전해 들은 장점
VUE가 쉽다는 얘기를 평소에 많이 들었습니다. 면접을 갔을 때도 생산성에 대한 질문도 많이 받았고, VUE를 쓰는 기업이라면 VUE의 생산성에 대해서 긍정적인 얘기를 합니다. 또한 React와 다른 양방향 바인딩에 대한 얘기도 많이합니다.
SFC는 Single-File Components 의 약자로 html, css, js를 하나의 컴퍼넌트에서 관리하는 Vue의 파일형식입니다.
template에서 html , script 에서 js , style에서 css를 다룹니다. 물론 별도의 module을 import하는 방식으로 css와 js를 관심사 분리할 수도 있습니다. 하지만 Vue측의 입장은 "관심사항의 분리가 파일 유형의 분리와 동일한 것이 아니다는 관점으로 바라보는 것이 중요하다"입니다. 하나의 컴퍼넌트가 명확하게 관심사가 분리가 되어있다면 , 굳이 css나 js를 따로 분리하기보다 응집되는 것 또한 매력적일 수 있기 때문입니다.
이때 style의 경우에는 scoped 태그를 적용함으로서 module.css 역할을 할 수 있게 할 수 있습니다.
3. 상태 관리
vue의 경우에는 v-model을 통한 양방향 바인딩을 지원합니다.
아래는 vue로 만든 간단한 todolist 입니다.
<script lang="ts">
import { defineComponent, ref } from'vue';
// Todo 타입 정의interface Todo {
id: number;
text: string;
completed: boolean;
}
exportdefault defineComponent({
name: 'TodoApp',
setup() {
// 상태 정의const newTodo = ref<string>(''); // 입력 필드 상태const todos = ref<Todo[]>([]); // 투두리스트 배열// 할 일 추가const addTodo = () => {
if (newTodo.value.trim() === '') return;
todos.value.push({
id: Date.now(), // 고유 ID 생성text: newTodo.value.trim(),
completed: false,
});
newTodo.value = '';
};
// 할 일 삭제const removeTodo = (index: number) => {
todos.value.splice(index, 1);
};
// 할 일 완료/미완료 토글const toggleComplete = (index: number) => {
todos.value[index].completed = !todos.value[index].completed;
};
return {
newTodo,
todos,
addTodo,
removeTodo,
toggleComplete,
};
},
});
</script>
<template><div><divclass="input-container"><inputtype="text"v-model="newTodo"placeholder="할 일을 입력하세요"
@keyup.enter="addTodo"
/><button @click="addTodo">추가</button></div><ul><liv-for="(todo, index) in todos":key="todo.id":class="{ completed: todo.completed }"
><span @click="toggleComplete(index)">{{ todo.text }}</span><button @click="removeTodo(index)">삭제</button></li></ul></div></template>
vue의 경우에는 ref나 reactive등을 활용하여 상태관리를 할 수 있습니다.
이때 v-model을 활용한 양방향 바인딩을 통해서 손쉽게 이벤트 핸들러 및 상태관리를 할 수 있습니다.
Vue를 사용하며 느낀점
vue를 사용하며 느낀점은 만약 바닐라 자바스크립트까지만 사용한 시점에서 새로운 프레임워크를 배운다면 vue가 react보다 사용이 쉽다고 느껴졌습니다. 컴퍼넌트의 경우에 매번 리랜더링되는 react와 다르게 Vue의 SFC파일은 변경된 상태에 대해서만 감지하여 최적화 측면에서도 수월한 점이 존재하다고 느꼈습니다.
하지만 사용 유저가 많다는 커뮤니티적인 장점도 존재하고, 익숙함이 존재하는 시점에서 제게는 React가 더 쉽게 느껴졌습니다. Vue만의 독특한 문법들도 많이 존재하였습니다. 새로 배우는 입장에서 외울게 많다는 게 비용으로 느껴졌고, 좀 더 javascript 답게 코드를 작성할 수 있는 것은 React라는 느낌을 받았습니다.
또한 오픈소스의 양적 측면에서도 React가 유리하다고 느꼈습니다.
다만 React와 Vue의 공식문서를 읽으면서 css의 중첩을 막기 위해 module이나 css-in-js를 사용하여 지역단위로 css를 적용하는 방식이나 사이드 이펙트를 감지하기 위해서 watcher 혹은 useEffect를 사용하는 방식이 결국 프레임워크는 도구나 사용법의 차이지 도달하는 방향은 유사하다는 느낌을 받았습니다.
부모님이 컴퓨터에 익숙하지 않다보니, 대신 예약을 해드리는 일이 종종 있습니다. 골프 예약 같은 경우에 경쟁이 치열하다 보니 종종 실패하더군요... 그래서 관련해서 도움을 받을 수 있는 예약 도우미 확장자를 만들었습니다.
그 과정에 보편적으로 쓸만한 기능들 그리고 트러블 슈팅 내역을 공유하고자 하여 작성하게 되었습니다.
제가 생각하는 기능은
1. 단축키를 통해서 원하는 시간에 예약하기
2. 그리고 해당 페이지에서 예약 과정 처리하기입니다.
이 과정에서 필요하다 느낀 기능은 알람, 페이지 접근, 스크립트 사용, 저장소 사용입니다.
(확장 프로그램의 경우에 결국 브라우저가 아니기 떄문에 웹api에서 제공하는 alert대신 를 chrome api에서 notification을 사용해야 하더군요...) 돔 요소를 제어하는 거는 기존 프론트엔드에서 하던 js를 사용하면 되지만 다른 기능들을 구현하기 위해선 추가적인 확장 프로그램에서 어떤 기능을 제공하는지를 알 필요가 있었습니다.
우선 공식 문서를 통해서 학습하려고 했습니다. 개인적으로 구글측 공식문서의 경우에 하나의 완성된 앱이 아닌, 부분적인 코드 위주로 보여줘서 처음부터 보면 어렵게 느껴지더군요..
permission :확장 프로그램이 특정 권한을 사용할 때 유저에게 안내를 하기 위해 사용합니다. 권한이 등록되지 않은 기능은 작동하지 않으므로 내가 어떤 기능을 구현하고 그러기 위해 필요한 권한에 대해서 확인할 필요가 있습니다.
제가 사용한 권한은 아래 3가지입니다.
storage: 브라우저의 저장소(local storage)에 데이터를 저장하거나 읽는 권한.
notifications: 알림(notification)을 생성하는 권한.
scripting: 실행 중인 탭에 스크립트를 주입하는 권한.
host_permissions : 확장 프로그램이 작동할 도메인을 지정합니다. 모든 url에 대해서라면 <all_urls> 을 사용하면 됩니다.
action : 확장 프로그램을 클릭했을 떄 열리는 페이지에 대해서 정의합니다.
commands : 키보드 단축키를 등록하기 위해서 사용합니다. 사용할 수 키등이 존재하니 공식문서를 확인하길 바랍니다.
이때 각각의 키에 대해서 입력을 하면 추후 background(service_worker)에서 사용할 수 있습니다. 유의할 점은 _execute_action의 경우에는 팝업창을 여는데 사용됩니다.
그리고 각각의 운영체제에 따른 입력 값을 지정할 수도 있습니다.
backgrond : 다른 페이지를 이용중일떄도 작동하게 할 내용(백그라운드에서)을 담을 수 있습니다. 저는 commands를 통해 등록한 키가 감지되면 background.js에서 특정 함수가 작동하도록 구현하였습니다.
content : 컨텐츠 스크립트는 지정된 URL에서 실행됩니다., matches:를 통해서 특정 페이지에서만 스크립트가 실행하도록 하였습니다.
popup.html
팝업창의 경우 일반적인 html과 똑같습니다. 해당 페이지에서는 브라우저 api를 사용할 수 있습니다.(ex : alert)
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8" /><linkrel="stylesheet"href="popup.css" /></head><body><div><labelfor="startInput">시작 시간 (예 : 11:00)</label><inputtype="text"id="startInput"placeholder="11:00"maxlength="5" /></div><div><labelfor="endInput">종료 시간 (예: 13:00):</label><inputtype="text"id="endInput"placeholder="15:00"maxlength="5" /></div><buttonid="submit">시간 설정</button><scriptsrc="popup.js"></script></body></html>
이후 popup.js에서 버튼이 클릭될 경우 storage에 해당 시간을 저장하여 추후 다른 script에서도 사용할 수 있도록 하였습니다.
document.addEventListener('DOMContentLoaded', () => {
const submitButton = document.getElementById('submit');
const startInput = document.getElementById('startInput');
const endInput = document.getElementById('endInput');
submitButton.addEventListener('click', () => {
const startTime = startInput.value.trim();
const endTime = endInput.value.trim();
if (!startTime || !endTime) {
alert('시작 시간과 종료 시간을 모두 입력해주세요.');
return;
}
if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime)) {
alert('시간 형식이 잘못되었습니다. 예: 11:00');
return;
}
// chrome.storage api
chrome.storage.local.set({ startTime, endTime }, () => {
alert(`희망예약시간이 ${startInput} ~ ${endInput}으로 설정되었습니다`);
});
});
});
background.js
background.js에서 탭에 접근해서 돔 요소를 조종하려고 합니다. 하지만 일반적으로 tab에 접근할 경우에는 권한 문제가 발생합니다. activate_tabs를 통해서 간단하게 해결할 수도 있지만, 그럴 경우 사용자에게 불안감을 야기시킵니다.
따라서 크롬에서는 권한을 최소화 하는 방식을 권장합니다
이에 대한 대안이 content.js를 사용하는 방식입니다.
콘텐츠 스크립트는 웹 페이지의 DOM과 상호작용할 수 있는 중간다리 역할을 합니다.
백그라운드 스크립트에서 데이터를 전달받아 DOM을 수정하거나 이벤트를 실행합니다.
우선 코드를 먼저보겠습니다.
// 커맨드 감지 리스너
chrome.commands.onCommand.addListener((command) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs.length === 0) {
notify('활성화된 탭이 없습니다.');
return;
}
const tab = tabs[0];
const url = tab.url || '';
// 허용된 URL에서만 명령 실행if (!url.startsWith('url주소')) {
notify('이 페이지에서는 확장 프로그램을 사용할 수 없습니다.');
return;
}
// 명령 처리if (command === 'start_reservation') {
chrome.storage.local.get(['startTime', 'endTime'], (data) => {
const { startTime, endTime } = data;
if (!startTime || !endTime) {
notify('저장된 시간이 없습니다. 시간을 먼저 설정해주세요.');
return;
}
const startNumeric = parseInt(startTime.replace(':', ''), 10);
const endNumeric = parseInt(endTime.replace(':', ''), 10);
// 콘텐츠 스크립트로 시간 범위를 전달
chrome.scripting.executeScript(
{
target: { tabId: tab.id },
files: ['content.js'],
},
() => {
chrome.tabs.sendMessage(tab.id, {
action: 'start_reservation',
startNumeric,
endNumeric,
});
}
);
});
}
if (command === 'fill_cert_no') {
// 콘텐츠 스크립트에 fill_cert_no 액션 전달
chrome.scripting.executeScript(
{
target: { tabId: tab.id },
files: ['content.js'],
},
() => {
chrome.tabs.sendMessage(tab.id, { action: 'fill_cert_no' });
}
);
}
});
});
functionnotify(message) {
chrome.notifications.create({
type: 'basic',
iconUrl: '/images/icons-32.png',
title: '알림',
message: message,
});
}
chrome.scripting.executeScript : 백그라운드 스크립트에서 특정 탭에 스크립트를 삽입할 때 사용됩니다. 이를 통해 웹 페이지의 DOM에 접근하거나, 콘텐츠 스크립트를 실행할 수 있습니다.
chrome.runtime.sendMessage : 크롬 확장 프로그램 내에서 서로 다른 스크립트 간에 메시지를 주고받는 데 사용
// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'start_reservation') {
const { startNumeric, endNumeric } = message;
// 예약 실행 로직
executeReservation(startNumeric, endNumeric);
}
if (message.action === 'fill_cert_no') {
// 인증 번호 입력 로직
fillCertNo();
}
});
이후 content.js에서도 리스너를 통해서 해당 함수를 사용합니다.
느낀점
환경자체가 다르고 사용하는 기능이 다르기 때문에 낯설게 느껴졌습니다. 모든 기능을 사용하거나 파악하기는 초기에 힘들기에 필요한 기능 위주로 검색하고 사용하는 것이 효율적이라고 느껴졌습니다.
모든 코드를 공개하지 않은 이유는.. 모든 프로젝트가 같은 로직을 사용하진 않을것이기 때문에, 그리고 저는 특정 사이트에만 적용되도록 구현했어서 공개할 필요는 없다고 생각했습니다. 다만 기본적인 원리 위주로 공개를 할 경우 타 프로젝트에 적용시에 더 유용하다 판단하였습니다.
복잡한 P2P 연결에 대해서 알기 위해선 우선 네트워크에 관한 지식이 필요합니다. 비디오 스트리밍의 경우 데이터를 서버를 통해 직접적으로 전달하면 많은 부하가 생깁니다. 따라서 P2P 연결을 하는데, 이를 위해선 상대방의 IP 주소가 필요합니다. 이때 사용할 기술들은 보통 NAT, STUN 서버 ,ICE 입니다.
NAT (Network Address Translation)와 STUN
NAT는 사설 네트워크(private network) 내에서 사용하는 private IP 주소를 공인 IP(public IP 주소)로 변경하는 기술입니다. 이는 보안상 이유로 외부와의 직접적인 연결을 차단하고, 여러 장치가 하나의 공인 IP를 공유할 수 있게 해줍니다.
STUN (Session Traversal Utilities for NAT) 서버는 NAT 방화벽을 통과할 수 있도록 도와주는 역할을 합니다. 이 서버는 클라이언트에게 자신의 공인 IP 주소와 포트 번호를 알려주어, 외부에서 접근할 수 있도록 합니다. 하지만 STUN만으로는 NAT 뒤에 있는 장치 간의 연결을 완전히 해결할 수 없을 때도 있습니다.
ICE (Interactive Connectivity Establishment)
ICE는 WebRTC에서 두 피어 간의 연결을 설정할 때 최적의 경로를 찾는 프레임워크입니다. ICE는 STUN과 TURN 서버를 활용하여, NAT 뒤에 있는 두 장치가 서로 연결할 수 있도록 도와줍니다.
STUN은 NAT 방화벽을 통과할 수 있도록 공인 IP와 포트를 알려주는 데 사용됩니다.
TURN은 직접적인 연결이 불가능한 경우 중계 서버를 통해 연결을 지원합니다.
따라서 우리는 장치정보를 바탕으로 peer를 생성할 것 입니다. 이때 구글의 무료 스턴 서버를 사용하였고, 연결이 되면 peer의 stream을 통해서 p2p 연결 후 데이터를 주고 받습니다.
이때 ICE 정보의 경우 SDP(Session Description Protocol)에 담겨서 전송되고, 이는 Socket의 webritcSignal을 통해서 유저간 실시간으로 주고 받습니다. 그리고 해당 SDP 정보를 기반으로 p2p 연결을 하면 데이터가 stream 됩니다.
redux는 어떻게 상태 관리를 하는가에 대해서 알아보기 위해서 createStore에 대해서 알아보려고 합니다.
createStore는 deprecated인데 왜 해당 함수에 대해서 알아보냐에 대한 답변은 아래와 같습니다.
The standard method for creating a Redux store. It uses the low-level Redux core createStore method internally, but wraps that to provide good defaults to the store setup for a better development experience.
공식 문서에 의하면 configureStore 는 middleware등 추가적인 기능을 createStore 함수를 기반으로 같이 쓸 수 있게 만든 함수이기 때문입니다.
따라서 핵심 상태 관리 원리는 여전히 createStore에 남아있고 다만 유저는 configureStore를 사용하는 것이 권장되기에 derpecated 된 것 입니다.
exportfunctioncreateStore<
S,
AextendsAction,
Extextends{} = {},
StateExt extends {} = {}
>(
reducer: Reducer<S, A>,
enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext>
exportfunctioncreateStore<
S,
AextendsAction,
Extextends{} = {},
StateExt extends {} = {},
PreloadedState = S
>(
reducer: Reducer<S, A, PreloadedState>,
preloadedState?: PreloadedState | undefined,
enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext>
exportfunctioncreateStore<
S,
AextendsAction,
Extextends{} = {},
StateExt extends {} = {},
PreloadedState = S
>(
reducer: Reducer<S, A, PreloadedState>,
preloadedState?: PreloadedState | StoreEnhancer<Ext, StateExt> | undefined,
enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext> {
if (typeof reducer !== 'function') {
thrownewError(
`Expected the root reducer to be a function. Instead, received: '${kindOf(
reducer
)}'`
)
}
if (
(typeof preloadedState === 'function' && typeof enhancer === 'function') ||
(typeof enhancer === 'function' && typeofarguments[3] === 'function')
) {
thrownewError(
'It looks like you are passing several store enhancers to ' +
'createStore(). This is not supported. Instead, compose them ' +
'together to a single function. See https://redux.js.org/tutorials/fundamentals/part-4-store#creating-a-store-with-enhancers for an example.'
)
}
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState as StoreEnhancer<Ext, StateExt>
preloadedState = undefined
}
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
thrownewError(
`Expected the enhancer to be a function. Instead, received: '${kindOf(
enhancer
)}'`
)
}
return enhancer(createStore)(
reducer,
preloadedState as PreloadedState | undefined
)
}
let currentReducer = reducer
let currentState: S | PreloadedState | undefined = preloadedState as
| PreloadedState
| undefinedlet currentListeners: Map<number, ListenerCallback> | null = newMap()
let nextListeners = currentListeners
let listenerIdCounter = 0let isDispatching = falsefunctionensureCanMutateNextListeners() {
if (nextListeners === currentListeners) {
nextListeners = newMap()
currentListeners.forEach((listener, key) => {
nextListeners.set(key, listener)
})
}
}
functiongetState(): S{
if (isDispatching) {
thrownewError(
'You may not call store.getState() while the reducer is executing. ' +
'The reducer has already received the state as an argument. ' +
'Pass it down from the top reducer instead of reading it from the store.'
)
}
return currentState as S
}
functionsubscribe(listener: () => void) {
if (typeof listener !== 'function') {
thrownewError(
`Expected the listener to be a function. Instead, received: '${kindOf(
listener
)}'`
)
}
if (isDispatching) {
thrownewError(
'You may not call store.subscribe() while the reducer is executing. ' +
'If you would like to be notified after the store has been updated, subscribe from a ' +
'component and invoke store.getState() in the callback to access the latest state. ' +
'See https://redux.js.org/api/store#subscribelistener for more details.'
)
}
let isSubscribed = true
ensureCanMutateNextListeners()
const listenerId = listenerIdCounter++
nextListeners.set(listenerId, listener)
returnfunctionunsubscribe() {
if (!isSubscribed) {
return
}
if (isDispatching) {
thrownewError(
'You may not unsubscribe from a store listener while the reducer is executing. ' +
'See https://redux.js.org/api/store#subscribelistener for more details.'
)
}
isSubscribed = false
ensureCanMutateNextListeners()
nextListeners.delete(listenerId)
currentListeners = null
}
}
functiondispatch(action: A) {
if (!isPlainObject(action)) {
thrownewError(
`Actions must be plain objects. Instead, the actual type was: '${kindOf(
action
)}'. You may need to add middleware to your store setup to handle dispatching other values, such as 'redux-thunk' to handle dispatching functions. See https://redux.js.org/tutorials/fundamentals/part-4-store#middleware and https://redux.js.org/tutorials/fundamentals/part-6-async-logic#using-the-redux-thunk-middleware for examples.`
)
}
if (typeof action.type === 'undefined') {
thrownewError(
'Actions may not have an undefined "type" property. You may have misspelled an action type string constant.'
)
}
if (typeof action.type !== 'string') {
thrownewError(
`Action "type" property must be a string. Instead, the actual type was: '${kindOf(
action.type
)}'. Value was: '${action.type}' (stringified)`
)
}
if (isDispatching) {
thrownewError('Reducers may not dispatch actions.')
}
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
const listeners = (currentListeners = nextListeners)
listeners.forEach(listener => {
listener()
})
return action
}
functionreplaceReducer(nextReducer: Reducer<S, A>): void{
if (typeof nextReducer !== 'function') {
thrownewError(
`Expected the nextReducer to be a function. Instead, received: '${kindOf(
nextReducer
)}`
)
}
currentReducer = nextReducer as unknown as Reducer<S, A, PreloadedState>
dispatch({ type: ActionTypes.REPLACE } as A)
}
functionobservable() {
const outerSubscribe = subscribe
return {
subscribe(observer: unknown) {
if (typeof observer !== 'object' || observer === null) {
thrownewTypeError(
`Expected the observer to be an object. Instead, received: '${kindOf(
observer
)}'`
)
}
functionobserveState() {
const observerAsObserver = observer as Observer<S>
if (observerAsObserver.next) {
observerAsObserver.next(getState())
}
}
observeState()
const unsubscribe = outerSubscribe(observeState)
return { unsubscribe }
},
[$$observable]() {
returnthis
}
}
}
dispatch({ type: ActionTypes.INIT } as A)
const store = {
dispatch: dispatch as Dispatch<A>,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
} as unknown as Store<S, A, StateExt> & Ext
return store
}
결국 요약만 하면 currentState를 통해 상태를 기억하고 외부에서 받아온 reducer를 통해서 상태를 업데이트 합니다. Map으로 선언한 listener를 통해서 구독자를 관리합니다. 이후 반환값으로 상태를 얻는 getState, 구독하는 subscribe, 상태를 업데이트하기 위한 dispatch함수들을 반환합니다.
(상태 관리에 좀 더 집중하기 위해 여타 에러처리와 replaceReducer , observable에 대해서는 넘어가겠습니다.)
최근 react와 router을 활용한 검색기능에 대한 질문을 받은 적이 있습니다. 그런데 막상 질문을 받았을 떄, 검색을 하지 않고서는 잘 기억이 나지 않더군요. 실제로 직접 url을 활용한 검색 기능을 구현한 적도 없었고,,, 따라서 간단한 검색 예제를 직접 만들면서 학습했습니다.
구현할 검색 서비스
검색 서비스의 경우에는 실제로도 URI 기반으로 작동을 많이 하는데, 이는 타인에게 검색 결과를 공유하기가 수월해 지기 때문입니다. 단순하게 경로를 복사하여 전송해주는 것만으로 사람들은 제가 보고 있는 것과 동일한 페이지를 볼 수 있습니다.
예전 프로젝트에서 tanstack query의 in useInfiniteQuery를 활용하여 무한스크롤을 구현한 적이 있습니다.
그 당시에 굉장히 수월하게 구현할 수 있었는데, 원티드 프리온보딩에서 라이브러리 없이 Intersection Oberserver API을 활용하여 무한스크롤을 구현하라는 사전과제를 받은 김에 정리하고자 글을 작성하게 되었습니다.
Intersection Observer API란?
MDN에 따르면 Intersection Observer API의 필요성은 아래와 같습니다.
역사적으로, 요소의 가시성 또는 관련된 두 요소 사이의 상대적 가시성을 감지하는 것은 해결책을 신뢰할 수 없고 브라우저와 사용자가 접근하는 사이트를 느리게 만드는 어려운 작업이었습니다. Web이 성숙해짐에 따라, 이러한 종류의 정보의 요구가 늘어났습니다. 교차 정보는 다음과 같은 많은 이유로 필요합니다.
즉, 특정 요소가 얼만큼 노출되었는지(교차 정보)를 확인할 필요성이 점점 늘어났고 이를 위해 생긴 API입니다.
이때 상호작용 요소를 메인스레드가 아닌 콜백함수에서 관리함으로써 , 브라우저는 적합하다고 판단되는 대로 교차 관리를 자유롭게 최적화할 수 있게 됩니다.
문법
new IntersectionObserver(callback)
new IntersectionObserver(callback, options)
콜백함수 콜백 함수는 대상 요소가 지정한 가시성 임계값을 넘을 때 호출됩니다. 콜백 함수는 두 개의 매개변수를 입력받는데, - entries는 콜백함수가 생길 때 발생한 정보를 담고 있습니다. - observer는 관측자에 대한 정보를 담고 있습니다.
options option은 콜백함수가 발생하는 조건에 대해 커스텀 할 때 쓸 수 있습니다. 선택적인 요소라서 필요할 때 적용하면 됩니다. - root : 상호작용의 경우에 뷰포트 기준으로 할 때가 많습니다. 하지만 특정 모달 내부의 스크롤 등 관측 대상자의 상위 요소를 지정해줘야 할 경우에 사용합니다. -rootMargin : 이미지 등 미리 보여야할 경우에는 뷰포트에 도달하기 전에 사용해야할 수 있습니다. 이때 해당 관측대상 기준으로 미리 감지할 수 있는 여백의 크기를 결정합니다. -threshold : 관측 대성이 얼만큼의 가시성을 확보해야 상호 작용할 지 설정할 떄 사용합니다.
이를 바탕으로 무한 스크롤에 사용한 옵션의 예시는 아래와 같습니다.
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fn();
}
},
{ threshold: 1.0 }
);
관측 대상이 완벽하게 보일 때 fn이라는 함수를 실행하겠다 입니다.
Intersection Observer를 활용하여 React에서 무한스크롤 구현하기
무한스크롤을 구현하는 방법은 여러가지가 있습니다. 스크롤 높이 기준으로 구현할 수도 있고, 특정 물체가 감지될 때마다 다음페이지를 요청하는 옵저버 방식도 있습니다.
Intersection Observer API를 통해 구현할 옵저버 방식으로 감지대상이 감지되면 다음 페이지에 대한 정보를 요청할 것 입니다.
우선 완성된 화면은 아래와 같습니다. 상품 리스트를 담고, 상품의 가격을 전부다 합산한 내용이 우측 상단에 표시했습니다. 그리고 하단으로 내리다 불러온 자료 끝부분에 도달하면, 새로운 요청을 합니다.
코드 구조는 다음과 같습니다. App에서 무한스크롤 영역과 가격 총합을 보여주는 Header부분이 있습니다. 이때 가격을 계산하는 부분은 커스텀훅으로 따로 구현하였습니다.
Proxy객체를 사용하면 원래Object대신 사용할 수 있는 객체를 만들지만, 이 객체의 속성 가져오기, 설정 및 정의와 같은 기본 객체 작업을 재정의할 수 있습니다. 프록시 객체는 일반적으로 속성 액세스를 기록하고, 입력의 유효성을 검사하고, 형식을 지정하거나, 삭제하는 데 사용됩니다.
let proxy = newProxy(target, handler)
target– 감싸게 될 객체로, 함수를 포함한 모든 객체가 가능함
handler– 동작을 가로채는 메서드인 '트랩(trap)'이 담긴 객체로, 여기서 프락시를 설정함
Counter 1 2는 각각 상태 count 12 를 사용하고 있다. zustand나 redux처럼 별도의 selector를 사용하지 않기에, 리렌더링 최적화가 안될거 같지만 앞서 언급한 상태 사용 추적기법에 의해서 리렌더링이 최적화가 되어있다.
1을 눌렀을 때 2가 리렌더링 되지 않음
useSnapshot 훅에서 나온 스냅샷을 컴퍼넌트가 사용하게 되면 해당 컴퍼넌트가 특정 상태를 사용하는 것을 기억한다. 이후 상태가 변경하였으면 proxy에서 특정 상태의 변경을 알리고 이를 통해 useSnapshot훅이 컴퍼넌트가 사용하기 있는 객체가 변경 되었으면 리렌더링을 하는 것이다.
Valtio내부 코드 확인
state (proxy함수 리턴값) 와 snap을 각각 콘솔로 찍어보면 Proxy 객체임을 확인할 수 있습니다. 그리고 각각의 Handler를 보면 state에는 deleteProperty와 set이 존재하고 snap에는 get, getOwnPropertyDescription ,has , ownKeys 프로퍼티가 존재하는 것을 확인할 수 있습니다.
이제 각각의 코드를 확인하려고 합니다. 분량이 많아서 전체코드는 다루지 않겠습니다.
각각의 코드를 확인하기전에 proxy, useSnapshot 모두 Proxy를 다룰 때, proxy-compare 라이브러리에 의존하고 있고, 해당 부분은 관심사의 밖이기에 간단하게 어떤 역할을 하는지만 언급하려고 합니다. 자세한 내용은 상단 링크의 레포에서 확인할 수 있습니다.
addPropListener 특정 속성에 리스너를 추가하는 함수입니다.속성의 변경을 감지하고, 구독자에게 알림을 보내기 위해 사용
removePropListener 속성에 연결된 리스너를 제거하는 함수
notifyUpdate 속성 값이 변경되었을 때 변경 사항을 알리는 함수로 변경된 속성의 이름과 값, 이전 값 등을 구독자에게 전달함
또한 앞서 console.log에서 확인했듯이 creatHandler는 set 와 deleteProperty 두가지 핸들러 메서드를 반환하는 것을 확인할 수 있습니다.
이제 각각의 핸들러 메서드에서 어떤 작업이 진행되는지 알아봅시다.
deleteProperty 1. 삭제하려는 속성의 기존 값을 Reflect.get으로 가져옴 2. 그 다음, 해당 속성에 연결된 리스너를 제거함 3. Reflect.deleteProperty를 통해 속성을 삭제함 4. 속성이 성공적으로 삭제되면, notifyUpdate를 호출하여 삭제 작업을 listener에게 알림 5.삭제가 여부를 반환합니다.
set 1. 속성의 이전 값이 존재하는지 확인하고 초기화 중이 아니라면, Reflect.has로 해당 속성이 이미 있는지 확인하고, 이전 값을 가져옵니다. 2.만약 이전 값과 새 값이 동일하거나, 새 값이 이미 캐시된 프록시 값과 동일하다면 true를 반환하고 설정 작업을 종료 3. 새로운 값이 설정될 속성에 대해 기존 리스너를 제거함 4. 새 값이 객체일 경우, getUntracked를 호출해 프록시되지 않은 원본 객체를 가져오거나, 새 값이 프록시 가능한 경 우 proxy()를 사용해 새로운 프록시로 변환. 5. addPropListener를 통해 새 값에 대한 리스너를 추가 6. Reflect.set을 호출하여 속성에 새로운 값을 설정 7. notifyUpdate를 호출해 속성 값이 변경되었음을 알림
결국 proxy는 캐시를 활용한 최적화에 대한 작업과 쓰기 등록 시 구독자에게 알려준다는 사실을 알았습니다. 하지만
컴퍼넌트가 해당 객체의 어떤 속성을 사용하는지 어떻게 알고 리렌더링 시키는지에 대해서 알기 위해선 useSnapshot훅을 분석할 필요성이 있습니다.
subscribe: 하나의 callback 인수를 받아 store에 구독하는 함수입니다. store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링하도록 해야 합니다. subscribe 함수는 구독을 정리하는 함수를 반환해야 합니다.
getSnapshot: 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수입니다. store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 합니다. 저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링합니다.
optional getServerSnapshot: store에 있는 데이터의 초기 스냅샷을 반환하는 함수입니다. 서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용됩니다. 서버 스냅샷은 클라이언트와 서버 간에 동일해야 하며 일반적으로 직렬화되어 서버에서 클라이언트로 전달됩니다. 이 함수가 제공되지 않으면 서버에서 컴포넌트를 렌더링할 때 오류가 발생합니다.
이제 다시 useSnapshot 훅을 보면 1. useCallback으로 감싸진 subscribe부분, 2. 화살표 함수로된 getSnapshot, 3. ()=>snapshot(proxyObject)인 getServerSnapshot 을 확인할 수 있습니다.
const currSnapshot = useSyncExternalStore(
useCallback(
(callback) => {
const unsub = subscribe(proxyObject, callback, notifyInSync)
callback() // Note: do we really need this?return unsub
},
[proxyObject, notifyInSync],
),
() => {
const nextSnapshot = snapshot(proxyObject)
try {
if (
!inRender &&
lastSnapshot.current &&
!isChanged(
lastSnapshot.current,
nextSnapshot,
affected,
newWeakMap(),
)
) {
// not changedreturn lastSnapshot.current
}
} catch {
// ignore if a promise or something is thrown
}
return nextSnapshot
},
() => snapshot(proxyObject),
)
1. subscribe 함수에서 전달된 callback에서는 proxyObject의상태 변화를 감지하고 상태가 변경될 때마다 이 callback이 호출되어 새로운 스냅샷을 가져오고 컴포넌트를 리렌더링합니다. 2. getSnapshot 함수에 전달된 화살표 함수 내부에 있는 isChanged 함수는 상태가 변경되었는지 확인하는 역할을 합니다. 만약 상태가 변하지 않았으면 리렌더링을 방지합니다.
이제 proxy 객체가 변화가 되었을 때, 왜 컴퍼넌트가 리렌더링하는지는 알게 되었습니다. 하지만 아직 어떻게 특정 컴퍼넌트가 객체를 사용하고 있는지에 대해서는 언급이 없었습니다.
해당 내용은 createProxyToCompare 이란 별칭으로 사용된 proxy-compare 라이브러리의 createProxy 함수에 있습니다.
공식 문서에서 createProxy 함수에 대한 설명은 아래와 같습니다.
createProxy 함수는 최상위에서 프록시를 생성하고, 접근할 때 중첩된 객체들도 프록시로 만들어, get/has 프록시 핸들러를 통해 어떤 프로퍼티가 접근되었는지를 추적합니다.
해당 함수에 관한 내용은 방대하기에 소스코드 링크만 남겨두고, 어떻게 proxy 핸들러에서 접근을 감지하는지에 대한 부분만 간단하게 추츨하는지 알아보기 위해 proxy의 핸들러의 내용만 추출하면 아래와 같습니다.
이떄 get이 proxy를 읽을 때 핸들링하는 내용입니다. key에 접근할 때, recordUsage를 호출해 KEYS_PROPERTY와 함께 사용 기록을 남기고, 다시 createProxy를 호출하여 그 값을 프록시로 감쌉니다. 따라서 우리가 컴퍼넌트에서 useSnapshot훅의 반환 proxy를 사용하게 되면 get 핸들러 메서드에 의해 기록되어서 추후 상태가 변경되었을 때 관련 상태를 사용한 컴퍼넌트만 리렌더링이 되게 됩니다.
요약
리액트 훅을 활용한 마이크로 상태관리훅을 통해서 proxy와 Valtio의 상태 사용 추적이 어떻게 일어나는지에 대해서 알아봤습니다.
proxy를 통해서 원본 객체에 핸들링 메서드를 적용할 수 있는데, Valtio에서는 이를 통해서 proxy의 상태 변화를 감지하고 이를 기반으로 불변의 snapshot을 찍습니다. 이때 상태관리하는 프록시에서는 proxy의 핸들러 메서드 중 set과 deleteProperty를 통해 변경을 listener들에게 알립니다.
그리고 리엑트 컴퍼넌트에서는 useSnapshot 훅 내부의 useSyncExternalStore와 createProxy를 통해서 2차 가공된 proxy 객체를 얻습니다. 해당 객체는 원본 proxy가 상태 변화를 할 때마다 새로운 proxy가 형성되는 불변 객체이며 컴퍼넌트에서 가공된 proxy객체(추후 스냅샷이라 부름)를 사용할 경우 createProxy훅 내부에 있는 get 메서드에 의해서 사용이 감지되고 등록이 됩니다. 이후 proxy객체가 변경이되면 useSyncExternalStore훅에 의해서 이전 상태와 차이점이 있는지를 비교하고 있을 경우 리렌더링이 됩니다.