원하는 글을 찾은 뒤 목록의 조건을 다시 만들려면 어떤 분류와 검색어를 썼는지 기억해야 한다. 나는 현재 목록의 주소 상태 코드를 읽고 선택한 조건을 다시 읽을 수 있는 작은 모듈로 정리했다. 공유할 길이 단순한 첫 목록으로 돌아가지 않도록 하는 방법이다.

같은 조건과 같은 자료를 영원히 보존하는 것은 다르다. 글이 계속 늘어나는 곳에서는 현재 공개 자료에 저장한 조건을 적용하게 된다. 여기서는 그 범위를 설명하고 한글과 기호, 조합 입력을 포함한 새 구현과 익명 예시를 남긴다.

주소에는 결과보다 고른 조건을 남긴다

현재 글 목록 소스는 분류와 검색어를 주소의 조건으로 읽고 변경 때 현재 주소를 갱신한다. 같은 조건으로 목록을 다시 열 수 있는 구조다. 하지만 이후 글이 새로 발행되거나 내려가면 결과 목록은 달라질 수 있다. 주소가 자료의 영구 스냅샷을 저장하는 것은 아니다.

나는 공유할 것이 같은 탐색 조건인지 특정 시점의 결과인지 구별한다. 일반 글 탐색에서는 최신 공개 목록에 같은 조건을 적용하는 것이 자연스러울 수 있다. 결과 자체를 보존해야 하는 용도라면 별도의 저장과 확인 시점이 필요하다. 이 모듈은 조건의 복원을 맡는다.

한글과 기호는 문자열을 직접 붙이지 않는다

검색어에 공백과 더하기, 앰퍼샌드 같은 기호가 있으면 단순히 주소 문자열 뒤에 붙이는 방식은 뜻을 바꿀 수 있다. 모듈은 URLSearchParams로 값을 넣고 다시 읽는다. 사람이 입력한 검색어와 주소에 보이는 인코딩 문자열을 같은 형태로 비교하지 않는다.

새 구현은 유니코드를 정리하고 검색어 길이를 코드 포인트 기준으로 제한한다. 모르는 분류는 전체로 처리하고 같은 키가 여러 번 들어오면 정한 파싱 규칙을 따른다. 허용한 분류와 검색어 두 값만 만드는 정책이며 다른 추적 조건까지 자동으로 보존하는 범용 주소 편집기는 아니다.

한글을 조합하는 중에는 확정 입력을 기다린다

한글을 입력하는 동안에는 글자가 조합 중인 상태가 있다. 그 중간 값을 곧바로 확정 검색 조건처럼 저장하면 화면이 흔들리거나 복원한 입력이 의도와 다를 수 있다. 새 연결 함수는 조합 시작과 끝, 입력 이벤트의 조합 상태를 보고 끝난 값을 반영하도록 작성했다.

조합이 끝난 뒤 같은 입력 이벤트가 이어져도 주소를 불필요하게 다시 쓰지 않는다. 이번 검사는 가짜 이벤트로 그 조건을 확인한 것이며 실제 운영체제의 모든 한글 입력기를 검수한 결과는 아니다. 적용할 페이지에서는 입력기의 실제 조합과 검색 결과 이동을 확인해야 한다.

새로 열기와 뒤로 이동을 같은 상태에서 읽는다

페이지를 처음 열 때 주소에서 값을 읽어 입력과 분류 선택을 맞춘다. 뒤로 이동해 다른 조건이 활성화되면 같은 읽기 함수로 복원한다. 변경을 모두 새 방문 기록으로 추가할지 현재 기록을 바꿀지도 정해야 한다. 이 모듈은 입력 변경에 현재 기록 교체를 사용한다.

주소를 바꾸었다고 그 페이지를 서버가 실제로 제공하는 것은 아니다. 새로고침과 직접 접근이 가능한 경로를 별도로 만들어야 한다. 검색 조건은 접근 권한을 주는 토큰도 아니다. 공개 주소에 개인정보나 관리 자격증명을 넣지 않고 서버의 공개 상태 검사를 유지해야 한다.

재사용할 주소 상태 구현

다음 코드는 파싱과 주소 만들기, 입력 연결을 나눈다. 사용할 분류 식별자와 실제 목록 경로, 필터를 적용할 콜백을 전달한다. 입력과 선택, 페이지 상태 이벤트를 정리하는 함수도 반환한다. 원래 사이트의 특정 업체나 글 제목을 담지 않는 새로운 예시 코드다.

이번에는 한글과 기호 왕복, 모르는 분류와 매개변수, 중복 키, 유니코드와 길이, 잘못된 경로, 초기 복원, 조합 중 입력과 중복 이벤트, 뒤로 상태 복원, 빈 조건과 정리를 다룬 9개 검사를 실행했다. 실제 브라우저 새로고침과 뒤로 이동은 아직 확인하지 않았다.

코드와 구현 살펴보기

JavaScript · 검색 조건과 주소 상태

function categories(values){if(!Array.isArray(values)||values.some(x=>typeof x!=='string'||!/^[a-z][a-z0-9-]*$/.test(x)))throw new TypeError('Explicit category keys required');return new Set(values);}
export function normalizeArchiveQuery(value){return [...String(value||'').normalize('NFKC').trim().replace(/\s+/gu,' ')].slice(0,80).join('');}
export function readArchiveState(search,keys){
 const allowed=categories(keys),params=new URLSearchParams(search);
 const category=params.get('category')||'';
 return {category:allowed.has(category)?category:'',q:normalizeArchiveQuery(params.get('q'))};
}
export function archiveAddress(state,keys,path='/library/'){
 if(typeof path!=='string'||!/^\/(?:[a-z0-9-]+\/)*$/.test(path))throw new TypeError('A local directory path is required');
 const allowed=categories(keys),params=new URLSearchParams();
 if(allowed.has(state.category))params.set('category',state.category);
 const q=normalizeArchiveQuery(state.q);if(q)params.set('q',q);
 return path+(params.size?'?'+params.toString():'');
}
export function attachArchiveState({input,select,keys,onChange,win=window,path='/library/'}){
 let composing=false,closed=false,last='';
 function notify(state){const key=JSON.stringify(state);if(key!==last){last=key;onChange({...state,address:archiveAddress(state,keys,path)});}}
 function read(){if(closed)return;const state=readArchiveState(win.location.search,keys);input.value=state.q;select.value=state.category;notify(state);}
 function commit(event){if(closed||composing||event?.isComposing)return;const state={category:categories(keys).has(select.value)?select.value:'',q:normalizeArchiveQuery(input.value)};const address=archiveAddress(state,keys,path);if(JSON.stringify(state)!==last)win.history.replaceState(win.history.state,'',address);notify(state);}
 const start=()=>{composing=true;};const end=()=>{composing=false;commit();};
 input.addEventListener('compositionstart',start);input.addEventListener('compositionend',end);input.addEventListener('input',commit);select.addEventListener('change',commit);win.addEventListener('popstate',read);read();
 return {read,destroy(){closed=true;input.removeEventListener('compositionstart',start);input.removeEventListener('compositionend',end);input.removeEventListener('input',commit);select.removeEventListener('change',commit);win.removeEventListener('popstate',read);}};
}

익명 목록으로 조건을 바꿔보는 예시

아래 예시는 새로 작성한 세 개의 합성 제목으로 분류와 검색어, 생성 주소의 관계를 보여준다. 실행 프레임에서는 실제 홈페이지 주소를 바꾸지 않고 모의 기록 어댑터로 조건을 저장하고 읽는다. 생성된 예시 경로를 실제 운영 페이지처럼 열지 않는다.

실제 페이지에 붙일 때는 일반 창의 History API와 실제 목록 경로를 사용하고 서버가 그 경로를 제공하는지 확인한다. 공개 응답의 프레임과 코드가 들어가는 것은 확인하되 기존 Studio Chrome을 보존하므로 새 실화면·입력 검수는 미검증으로 남긴다. 검색 조건의 복원과 정확한 결과, 실제 권한은 각각 연결할 작업이다.