본문 바로가기
빌드노트 검색

빌드노트

URLSearchParams.append로 검색어를 바꿨더니 q가 두 번 남는다

검색어를 바꾸려던 append는 기존 q를 남기고 새 값을 추가한다. 쿼리 원문과 get/getAll, loopback의 실제 요청 대상을 비교해 set으로 교체할 키와 여러 값을 보존할 키를 구분한다.

URLSearchParams로 검색어 q를 바꿨는데 요청 주소에 q=old&q=new가 함께 남는다면, 검색 URL을 만드는 코드의 append('q', 새값)부터 확인한다. append는 기존 값을 보존하고 같은 키의 값을 하나 더 추가한다. 검색어를 하나로 교체하려는 코드에는 set('q', 새값)이 맞다.

수정 전에는 요청을 보내기 직전 URL의 쿼리 원문과 get('q'), getAll('q')를 함께 본다. 새 값이 들어 있다는 사실과 옛 값이 사라졌다는 사실은 다르다. 특히 get() 하나로는 중복을 확인할 수 없다.

get()에는 첫 번째 값만 보인다

다음 코드는 같은 URL에서 추가 전, append 후, set 후의 값을 비교한다. Node.js가 설치돼 있다면 새 파일 query-check.cjs에 저장하고 그 폴더에서 node query-check.cjs로 실행한다. 추가 패키지는 필요 없다. 주소는 합성 입력이며 이 코드는 HTTP 요청을 보내지 않는다. 아래 출력은 Windows의 Node.js 22.23.2에서 직접 확인했다.

const url = new URL('http://127.0.0.1/search?q=old&tag=js&tag=web');
const show = stage => console.log(
  stage, url.search, url.searchParams.get('q'),
  JSON.stringify(url.searchParams.getAll('q'))
);

show('before');
url.searchParams.append('q', 'new');
show('append');
url.searchParams.set('q', 'new');
show('set');
console.log('tags', JSON.stringify(url.searchParams.getAll('tag')));

각 행은 단계, 쿼리 원문, get('q'), getAll('q') 순서다. 마지막 행은 남아 있는 태그 목록이다.

before ?q=old&tag=js&tag=web old ["old"]
append ?q=old&tag=js&tag=web&q=new old ["old","new"]
set ?q=new&tag=js&tag=web new ["new"]
tags ["js","web"]

append 후 새 검색어는 맨 뒤에 추가됐다. 그런데 get('q')는 여전히 old다. 첫 번째 값을 읽는 메서드이기 때문이다. getAll('q')로 읽어야 두 값이 함께 남은 것이 보인다. 이 동작은 WHATWG URL 표준의 get·getAll 정의 (새 탭에서 열림)와 일치한다. 순서를 바꿔 새 값이 먼저 나오게 해도 중복이 없어지는 것은 아니다.

자기 코드에서 확인할 때도 최종 쿼리 문자열을 먼저 보존하고, 같은 URL 객체에서 두 조회를 비교한다. 로그의 get('q')가 원하는 값이라는 이유만으로 수정이 끝났다고 판단하지 않는다.

교체할 키만 하나로 만든다

기존 코드가 url.searchParams.append('q', keyword)라면, 검색어 하나를 유지한다는 요청 규칙에 맞춰 url.searchParams.set('q', keyword)로 바꾼다. 앞의 출력처럼 q만 교체하고 tag=js&tag=web는 유지할 수 있다. MDN의 set 설명 (새 탭에서 열림)은 같은 이름의 값이 여러 개면 나머지를 제거하고, 키가 없으면 새로 만든다고 명시한다.

이미 중복된 URL에도 같은 수정이 적용되는지 확인하려면 앞의 파일 끝에 다음 코드를 붙이고 다시 실행한다. 이 블록은 q가 두 개였던 입력에서 교체 후 하나만 남는지 확인한다.

const repeated = new URL('http://127.0.0.1/search?q=old&q=older&tag=js&tag=web');
repeated.searchParams.set('q', 'new');
console.log(repeated.search);
console.log(JSON.stringify(repeated.searchParams.getAll('q')));

앞의 네 행 뒤에 다음 두 행이 출력된다.

?q=new&tag=js&tag=web
["new"]

별도 비교에서는 이 중복 입력에 append('q', 'new')를 적용하자 getAll('q')가 ["old","older","new"]가 됐다. 반면 set을 두 번 호출해도 ["new"] 하나만 남았다. q가 처음부터 없는 입력에서는 append와 set 모두 새 q를 추가했다. 빈 URL에서만 시험하면 두 메서드의 차이를 놓칠 수 있다.

같은 키가 여러 번 나온다는 사실 자체가 오류는 아니다. 여러 태그를 받는 요청이라면 tag=js&tag=web가 필요한 표현일 수 있다. 같은 실험에서 append('tag', 'css')는 태그 세 개를 보존했고, set('tag', 'css')는 css 하나만 남겼다. 따라서 모든 append를 일괄 교체할 것이 아니라 검색어처럼 하나여야 하는 키와 여러 값을 받는 키를 구분한다.

실제 요청에도 두 값이 남는지 본다

문자열 출력과 전송 결과도 대조했다. 별도 실험에서 127.0.0.1에만 연 Node HTTP 서버로 같은 입력의 URL을 보내고 서버의 req.url을 읽었다. 서버가 받은 요청 대상은 다음과 같았다. 행 앞의 이름은 비교한 메서드를 표시한 것이다.

append: /search?q=old&tag=js&tag=web&q=new
set: /search?q=new&tag=js&tag=web

이 실험에서는 중복 키가 전송 중에 자동으로 합쳐지거나 마지막 값 하나로 바뀌지 않았다. 수신 쿼리를 URLSearchParams로 읽었을 때도 append 요청의 get('q')는 old, getAll('q')는 ["old","new"]였다. 이는 수신 확인용 파서의 결과다. 실제 서비스가 여러 값을 어떤 검색 조건으로 처리하는지는 해당 서버의 바인딩·조회 규칙을 따로 확인해야 한다.

브라우저에서 진단한다면 개발자 도구 Network에 기록된 해당 요청의 Request URL을 펼쳐 q가 몇 번 있는지 확인한다. 한 값만 보여 주는 요약 로그와 비교하는 대신, 요청을 만들기 직전의 url.href와 실제 요청 주소를 대조한다.

set 후에도 옛 쿼리가 전송된다면, 수정한 객체가 요청에 쓰이는 객체인지 확인한다. new URLSearchParams(url.search)로 만든 값은 별도 객체다. 그 객체에서 set을 해도 원래 url은 바뀌지 않는다. 복사본을 쓰는 코드라면 수정 결과를 url.search = params.toString()으로 반영한 뒤 그 URL을 요청에 사용한다. 또는 앞의 예제처럼 url.searchParams를 직접 수정한다. Node.js 22.23.2의 URLSearchParams 예제 (새 탭에서 열림)도 복사본 수정과 원본 URL 반영을 구분한다.

직접 실행한 복사본 비교에서도 params에는 q=new 하나만 있었지만 원래 URL의 q=old&q=older는 그대로였다. 복사본을 url.search에 반영한 뒤에는 loopback 요청에서도 q=new 하나만 확인됐다.

이번 검증은 Node.js 22.23.2의 URL 처리와 loopback HTTP 요청 범위다. 브라우저의 실제 검색 화면, 라우터의 상태 갱신, 서버 프레임워크의 중복 값 바인딩은 실행하지 않았다. 수정 후 쿼리에 q가 하나인데도 검색 결과가 옛 조건이라면, 이 글에서 재현한 append 중복과 구분해 실제 전송 주소와 서버가 읽은 값을 이어서 확인한다.

← 전체 글 보기