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

빌드노트

팝업은 열려 있는데 window.open 참조의 closed는 true다

팝업이 살아 있는데 반환 참조의 closed가 true가 되는 COOP 조건을 두 loopback 출처에서 비교했다. 실제 종료와 참조 분리를 구분하고, 양쪽 문서의 응답 헤더·출처·창 통신 요구를 확인하는 방법을 설명한다.

window.open()으로 연 팝업이 그대로 있는데, 원래 창에서 읽은 popup.closed는 true일 수 있다. 응답의 Cross-Origin-Opener-Policy(COOP)가 두 문서를 다른 브라우징 컨텍스트 그룹으로 분리하면 이런 결과가 나온다. 반환 참조의 closed=true만으로 사용자가 팝업을 닫았다고 판단하면 안 된다. 팝업 자체의 상태와 양쪽 문서의 응답 정책을 함께 확인해야 한다. MDN의 window.open 반환값 설명 (새 탭에서 열림)

브라우징 컨텍스트 그룹은 창과 문서의 참조 관계를 관리하는 브라우저 내부 묶음이다. COOP로 그룹이 갈라지면, 열린 문서와 원래 창 사이의 참조가 끊긴다. 창의 화면을 닫는 동작과는 다르다. 여기서는 링크의 rel 대신 탐색한 문서가 받은 응답 헤더 때문에 관계가 달라지는 조건을 확인한다. MDN의 COOP 설명 (새 탭에서 열림)

원래 창의 반환값과 팝업 내부 값을 따로 읽는다

자신이 관리하는 테스트 화면에서 기존 클릭 처리 함수를 살펴본다. window.open()의 반환값을 popup이라는 변수에 담고 있다면, 호출 바로 뒤에 다음 기록을 추가해 참조가 반환됐는지 남긴다. 기존 열 주소와 세 번째 인자 옵션은 유지한다. 이 코드는 이미 선언한 popup 변수를 사용한다.

window.coopPopupProbe = popup;
console.log({
  returnedNull: popup === null,
  returnedClosed: popup?.closed ?? null
});

팝업이 목적 문서로 이동한 뒤 원래 창의 개발자 도구 콘솔에서 window.coopPopupProbe?.closed를 다시 읽는다. 호출 직후의 값만으로 끝내지 않는 이유가 있다. 새 창은 처음에 about:blank를 담고, 요청한 URL은 비동기로 로드된다. 응답을 받은 뒤의 참조 상태를 따로 봐야 한다. MDN의 새 창 로딩 설명 (새 탭에서 열림)

이어 열린 팝업의 개발자 도구 콘솔에서 다음 식으로 그 문서 자신의 상태와 opener 관계를 확인한다. 여기서 opener는 iframe의 parent가 아니라 이 팝업을 연 원래 창이다.

({
  selfClosed: window.closed,
  openerIsNull: window.opener === null
})

원래 창에서는 returnedNull=false, 나중에 읽은 returnedClosed=true인데, 열린 팝업 안에서는 selfClosed=false, openerIsNull=true라면 창은 살아 있고 참조 관계가 없는 상태다. 이 조합에서 양쪽 응답의 COOP를 조사한다. openerIsNull=true 하나만으로 COOP를 확정하지는 않는다. window.open()의 noopener 옵션도 opener를 없애며 이 경우 반환값은 null이다. 팝업이 전혀 열리지 않았다면 사용자 클릭 안에서 호출했는지와 차단 여부도 별도로 확인한다. MDN의 noopener 옵션과 반환값 (새 탭에서 열림)

두 포트에서 헤더 조합만 바꿨다

차이를 확인하려고 직접 소유한 loopback 서버 두 개를 열었다. 원래 창 A는 http://127.0.0.1:31329, 팝업 B는 http://127.0.0.1:31330에서 제공했다. 포트가 달라 서로 다른 출처다. 출처는 scheme·호스트·포트로 비교한다. HTML 표준의 출처 비교 (새 탭에서 열림)

버튼을 클릭하면 window.open(url, "_blank", "popup,width=420,height=300")을 한 번 호출했다. noopener와 noreferrer는 지정하지 않았다. 팝업에는 무해한 문구와 자기 창을 닫는 버튼만 넣었다. 자동화로 목적 문서의 로딩 완료를 기다린 뒤 두 창에서 각각 값을 읽고, 팝업의 브라우저 대상도 남아 있는지 확인했다. 아래의 ‘없음’은 COOP 응답 헤더를 보내지 않았다는 뜻이며, 기본 정책은 unsafe-none이다. COOP의 기본값과 창 열기 규칙 (새 탭에서 열림)

  • A 없음 → B 없음: 반환 참조의 closed=false, B의 openerIsNull=false였다.
  • A 없음 → B same-origin: 반환 참조의 closed=true, B의 openerIsNull=true였다.
  • A same-origin → B 없음: 반환 참조의 closed=true, B의 openerIsNull=true였다.
  • A same-origin → B same-origin: 반환 참조의 closed=true, B의 openerIsNull=true였다.

네 조건 모두 반환값은 null이 아니었고, B는 로딩을 마친 채 window.closed=false였다. 호출 직후에는 네 조건 모두 반환 참조의 closed=false가 기록됐다. 뒤의 세 조건은 목적 문서가 로드된 뒤 읽자 true로 바뀌었다. 클릭 이벤트의 isTrusted와 호출 직전 사용자 활성 상태도 모두 true였다. 이는 합성 페이지를 비교한 관찰이며 실제 서비스의 장애 기록은 아니다.

그다음 자동화로 B의 창을 직접 닫았다. 이번에는 B의 브라우저 대상이 사라졌고 원래 창의 참조는 네 조건 모두 closed=true였다. 정책으로 이미 끊겼던 세 참조에서는 종료 전후의 값이 같았다. 따라서 그 참조를 계속 폴링해도 이 실험의 ‘열려 있지만 분리됨’과 ‘실제 종료됨’을 구분할 수 없었다.

같은 same-origin 값이어도 출처까지 같아야 한다

양쪽에 same-origin을 적었으니 관계가 유지될 것이라고 기대하기 쉽다. 그러나 서로 다른 출처가 같은 헤더 값을 보낸 것만으로는 충분하지 않다. 표준의 정책 일치 판정은 양쪽 값이 같은지와 문서가 같은 출처인지도 확인한다. HTML 표준의 COOP 일치 판정 (새 탭에서 열림)

이를 확인하려고 팝업도 A와 같은 31329 포트에서 열었다. 양쪽 모두 same-origin인 대조군에서는 반환 참조의 closed=false, 팝업의 openerIsNull=false가 나왔다. 반면 같은 포트라도 A는 헤더 없음, 팝업만 same-origin이면 closed=true, openerIsNull=true였다. 두 대조군의 팝업 역시 관찰 중에는 열려 있었고, 자동화로 닫은 뒤에는 실제로 사라졌다.

자기 화면을 조사할 때는 양쪽 개발자 도구의 Network에서 최상위 HTML 문서 요청을 선택하고 다음 세 가지를 맞춰 본다.

  1. 원래 창 문서와 팝업 문서의 실제 URL이 같은 출처인가. 포트나 scheme의 차이도 포함한다.
  2. 두 문서의 Response Headers에 Cross-Origin-Opener-Policy가 각각 어떤 값으로 도착했는가. 자바스크립트 파일이나 API 응답 대신 해당 문서의 응답을 본다. 리다이렉트가 있다면 최종 문서 URL과 응답까지 확인한다.
  3. 강제 적용 헤더인지 Cross-Origin-Opener-Policy-Report-Only인지 구분했는가. 보고 전용 정책에 따른 경고를 실제 참조 분리의 증거로 삼지 않는다. 표준도 실제 그룹 전환과 보고 전용일 때의 전환 예측을 따로 관리한다. HTML 표준의 헤더 처리와 그룹 전환 (새 탭에서 열림)

이 비교는 보안 컨텍스트에서 수행해야 한다. 표준은 비보안 컨텍스트에서 COOP 정책을 읽는 절차를 건너뛴다. 이번 HTTP loopback 문서에서는 양쪽의 isSecureContext=true를 직접 확인했다. 일반 HTTP 원격 주소에서 헤더만 똑같이 붙였다고 같은 실험 조건이 되지는 않는다. HTML 표준의 정책 취득 절차 (새 탭에서 열림), MDN의 loopback 보안 컨텍스트 설명 (새 탭에서 열림)

closed를 없애기 전에 창 통신이 필요한지 정한다

COOP는 다른 문서가 창 참조를 통해 접근하는 범위를 제한하기 위한 정책이다. 외부 문서를 보여 주기만 하는 팝업이라면, 분리된 참조를 오류로 간주해 헤더를 삭제할 이유가 없다. 반대로 원래 창으로 완료 메시지를 보내야 한다면, 그 연동이 opener 관계를 전제로 하는지부터 확인한다. 그룹 분리로 참조가 끊기는 동작은 두 문서의 정책 조합에 달려 있다. MDN의 COOP 목적과 정책 조합 (새 탭에서 열림)

same-origin-allow-popups에는 원래 창이 window.open()으로 unsafe-none 문서를 열 때 같은 그룹에 둘 수 있는 예외가 있다. 모든 팝업과 관계를 유지하는 설정은 아니다. 상대가 same-origin을 보내는 경우까지 해결한다고 가정해서는 안 된다. 신뢰하는 팝업과 필요한 통신을 정한 뒤 기존 격리 요구와 함께 검토할 선택지다. 이 값은 이번 여섯 조건에 넣어 실행하지 않았다. MDN의 same-origin-allow-popups 조건 (새 탭에서 열림)

postMessage도 메시지를 보낼 대상 창이 필요하다. window.opener가 null인 상태에서 targetOrigin을 "*"로 바꿔도 opener가 생기지 않는다. 관계를 유지하는 설계라면 정확한 대상 origin을 지정하고, 수신 측에서 발신 origin과 데이터 형식을 검사한다. HTML 표준의 메시지 전송과 검증 (새 탭에서 열림) 분리를 유지해야 한다면 같은 탭의 완료 경로나 서버의 완료 상태 조회처럼 opener에 의존하지 않는 연동을 검토할 수 있다. 그런 대체 연동의 구현은 이번 실험 범위에 포함하지 않았다.

관찰 환경은 Windows 11, 설치된 Chrome 156.0.8078.47의 headless 모드, agent-browser 0.27.0, Python 3.12.10이었다. 새 독립 브라우저 세션과 소유한 두 loopback 출처만 사용했고 여섯 조건을 확인한 뒤 브라우저와 두 서버를 종료했다. 다른 브라우저·일반 창 모드·물리 마우스 입력, report-only 동작, 리다이렉트 중 정책 전환, 외부 로그인·OAuth·결제는 실행하지 않았다. 여기서 확인한 것은 목적 문서의 COOP 조합에 따른 참조 상태와 실제 창 종료를 구분하는 관찰이다.

← 전체 글 보기