PWA 21분 읽기

한 줄의 보안 패치가 PWA를 깨운 이야기 — noopener와 링크 캡처

보안 스캐너 경고를 끄려고 넣은 noopener 한 줄. 그게 멀쩡하던 새 탭을 설치형 PWA 창으로 깨워버렸습니다. 게다가 그 경고는 오검출이었어요.

보안 스캐너 경고를 끄려고 넣은 noopener 한 줄. 그게 멀쩡하던 새 탭을 설치형 PWA 창으로 깨워버렸습니다. 게다가 그 경고는 실재하지 않는 위험을 오해한 오검출이었어요.


🐛 같은 코드인데 한 제품만 이상해요

어느 날 QA 팀에서 버그 제보가 들어왔습니다.

“마지막 배포 이후부터, 클라우드 제품에서 내 정보를 누르면 계정 페이지가 새 탭이 아니라 별도 창(앱)으로 열려요.”

증상은 단순했어요. 원래는 클라우드 제품 화면에서 “내 정보”를 클릭하면 계정·인증 서비스(우리 서비스 중 별도 도메인으로 떠 있는 영역)가 새 탭으로 열려야 했습니다. 그런데 어느 순간부터 주소창도 없는 독립 앱 창 — 즉 PWA 형태로 열리기 시작한 거죠.

이상한 점이 하나 있었어요. 그 한 제품에서만 이 현상이 발생했습니다. 우리 회사는 치과용 3D 스캔/CAD 솔루션을 여러 제품으로 내는데, 다른 솔루션 제품들에서 똑같이 “내 정보”를 눌러도 거기서는 멀쩡하게 새 탭으로 잘 열렸어요. 코드상으로는 같은 동작인데 왜 한 제품만 다를까요?

이 “왜 이 제품만?”이 사실상 사건 전체의 실마리였습니다. 결론부터 말하면, 범인은 보안 스캔을 통과시키려고 추가한 단 하나의 옵션noopener 였어요. 그리고 더 얄궂게도, 그 보안 위험은 실재하지 않는데 스캐너가 오해해서 띄운 경고였습니다.

이 글은 그 한 줄이 어떻게 PWA를 깨웠는지 인과를 따라가며, 제가 이해한 것과 아직 이해 못 한 것, 그리고 이 사건이 남긴 회고를 정리한 기록이에요.

🧩 배경: PWA와 manifest의 display: standalone

PWA(Progressive Web App)는 웹사이트를 설치형 앱처럼 쓸 수 있게 해주는 기술입니다. 브라우저가 “이 사이트를 앱으로 설치하시겠어요?”를 띄우고, 설치하면 독립 창과 아이콘을 가진 네이티브 앱처럼 동작해요.

PWA의 정체성은 manifest.json이 결정합니다. 이 파일은 브라우저에게 앱 이름, 아이콘, 시작 URL, 그리고 표시 모드(display mode) 를 알려줍니다.

우리 계정 서비스는 초기에 CRA(Create React App)로 만들어졌는데, CRA 기본 스캐폴딩이 manifest에 이렇게 박아두었어요.

{ "display": "standalone" }

standalone은 “브라우저 탭이 아니라 별도의 독립 창으로 열어라”는 표시 모드입니다. 주소창 없는 앱 창이 이 설정의 결과예요.

중요한 포인트 하나. 이 설정 자체는 문제가 아니었습니다. 계정 서비스를 PWA로 설치하지 않은 사람에게는 아무 영향이 없어요. 문제는 이걸 PWA로 설치해 둔 사람에게서 터졌습니다. QA 팀 PC가 바로 그런 환경이었어요. 예전에 누군가 계정 서비스를 PWA로 설치해 둔 거죠.

🚪 PWA는 언제 “내가 나설 차례”라고 판단할까

PWA가 설치되어 있으면, 브라우저는 특정 조건에서 “이 navigation은 설치된 앱이 처리해야 한다”고 판단하고 새 탭 대신 앱 창을 띄웁니다. 이걸 링크 캡처(link capturing) 라고 해요. Chrome 문서의 표현을 빌리면, navigation이 캡처 가능(capturable)하려면 대략 이런 조건입니다.

그 navigation이 보조 브라우징 컨텍스트(auxiliary browsing context)가 아닌 새 컨텍스트로 열리고, 앱의 scope 안으로 들어가는 경우.

여기서 핵심 단어가 “보조 브라우징 컨텍스트가 아닌” 이에요. 즉, opener와 연결된 종속 창이 아니라 독립된 최상위(top-level) 컨텍스트로 열릴 때 캡처 대상이 됩니다.

자, 그럼 무엇이 “보조 컨텍스트”를 “독립 컨텍스트”로 바꿔버렸을까요?

🔍 범인: 리팩토링 중 “이동한 코드”에 붙은 noopener

마지막 배포 직전, 시각 회귀(visual regression) 검증을 위해 컴포넌트들을 더 작고 테스트 가능한 단위로 쪼개는 작업이 있었어요. 이른바 seam 추출 — 헤더/프로필 같은 덩어리를 별도 파일과 핸들러로 분리하는 리팩토링입니다.

그 과정에서 “내 정보”를 새 탭으로 여는 코드가 새 파일로 이동했어요. 그리고 코드가 이동하자, AI 기반 diff 보안 스캐너가 이 코드를 “새로 추가된 코드”로 보고 경고를 띄웠습니다.

// Before (이동 전·후 내용은 같음)
window.open(MYINFO_URL, '_blank')

// After — 스캐너 경고를 잠재우려고 추가
window.open(MYINFO_URL, '_blank', 'noopener,noreferrer')

경고 내용은 Reverse Tabnabbing 위험이었어요.

Reverse Tabnabbing이 뭔가요

window.open(url, '_blank')로 연 새 탭은 기본적으로 원본 탭을 가리키는 window.opener 참조를 가집니다. 만약 새 탭에서 로드된 페이지가 악의적이라면 이렇게 할 수 있어요.

window.opener.location = 'https://피싱사이트.example'

즉, 새로 열린 탭이 원본 탭을 피싱 페이지로 바꿔치기할 수 있습니다. 사용자가 원본 탭으로 돌아왔을 때 감쪽같이 다른 사이트로 바뀌어 있는 공격이에요. 표준 방어가 noopener인데, openernull로 만들어 새 탭이 원본을 건드릴 통로 자체를 없앱니다.

그런데 — 그 경고는 “오검출”이었어요

여기서 가장 중요한 디테일이 나옵니다. 커밋 메시지에 단서가 그대로 남아 있었어요. 이 코드는 이번에 새로 생긴 게 아니라, 처음부터 noopener 없이 동작하던 기존 패턴이었습니다. seam 추출로 코드가 새 파일로 자리만 옮기자, diff 기반 스캐너가 그걸 “신규 추가 라인”으로 집계해 예전부터 멀쩡히 있던 패턴을 갑자기 CRITICAL로 띄운 것이죠.

게다가 따져보면 이 경우 reverse tabnabbing은 애초에 성립하지 않았어요. 이유는 뒤에서 정리하겠지만, 계정 서비스는 origin이 달라 opener가 이미 끊겨 있었고, “내 정보”를 자식 컨텍스트로 여는 경로도 없었습니다. 즉 실재하는 위협이 없는데, 코드 이동이 만든 착시 경고에 반응해 “안전을 위한” 한 줄을 넣은 것이에요.

그리고 그 한 줄이, 멀쩡하던 동작을 바꿔버렸습니다.

⚡ noopener가 브라우징 컨텍스트를 바꾼다

핵심은 여기예요. noopener는 단순히 openernull로 만드는 데서 끝나지 않습니다. 새 탭이 열리는 컨텍스트의 성격 자체를 바꿔요.

  • window.open(url, '_blank') (noopener 없음) → 새 탭은 원본과 연결된 보조 브라우징 컨텍스트(auxiliary context) 로 열립니다.
  • window.open(url, '_blank', 'noopener') → 새 탭은 원본과 단절된 독립적인 최상위 브라우징 컨텍스트로 열립니다.

그리고 앞서 봤듯, PWA 링크 캡처 조건은 “보조 컨텍스트가 아닌 경우” 죠.

퍼즐이 맞춰집니다.

noopener 추가 → 새 탭이 보조 컨텍스트가 아닌 독립 컨텍스트로 계정 origin에 진입 → PWA 링크 캡처 조건 충족 → 설치된 계정 PWA가 깨어나 앱 창으로 열림

보안 스캐너의 오검출을 잠재우려던 한 줄이, PWA에게 “지금이 네가 나설 차례야”라고 신호를 보낸 셈이에요.

🤔 그럼 왜 그 제품만? — cross-origin과 COOP

여기서 헷갈리는 지점이 있었어요. “noopener가 opener를 끊어서 문제라면, noopener 없이 열었을 때는 opener가 살아있어야 하는 거 아닌가?” 그런데 확인해보니 noopener 없이 열어도 새 탭의 opener는 이미 null 이었습니다.

이유는 cross-origin과 COOP(Cross-Origin-Opener-Policy) 였어요.

  • 메인 제품들은 서로 같은 origin 계열입니다.
  • 계정 서비스는 별도 도메인 — origin이 다릅니다.

Cross-Origin-Opener-Policy: same-origin 헤더가 응답에 실려 있으면, 브라우저는 opener 연결을 끊습니다(opener = null). 그래서 계정 서비스는 noopener를 주든 안 주든 opener가 어차피 null이었어요. 이게 “이 경우엔 reverse tabnabbing 방어가 애초에 필요 없었다”의 근거입니다 — COOP가 이미 그 일을 하고 있었던 거죠.

미검증으로 남긴 부분: “cross-origin이라서 opener가 null”이라고 흔히 말하지만, 엄밀히는 COOP 헤더가 있어야 null이 됩니다. cross-origin 자체만으로 opener 참조가 사라지는 건 아니고, 다른 origin에 대한 접근이 제한될 뿐이에요. 제가 관측한 opener=null은 계정 서버가 보내는 COOP 헤더 때문일 가능성이 높지만, 실제 응답 헤더를 떠서 확인하기 전까진 단정하지 않으려 합니다.

그렇다면 noopener가 추가로 바꾼 건 무엇일까요? opener 값(null이냐 아니냐)이 아니라, 컨텍스트의 종류(보조냐 독립이냐) 입니다. COOP는 opener 참조를 끊지만, 그것만으로 새 탭이 “독립 top-level 컨텍스트”로 분류되어 PWA에 캡처되진 않았어요. noopener 플래그가 명시적으로 들어오면서 비로소 컨텍스트 자체가 독립으로 생성됐고, 그게 캡처의 트리거가 됐습니다.

정리: opener=null은 COOP가 만든 증상, PWA 캡처를 유발한 진짜 변경은 noopener가 만든 “독립 컨텍스트” 분류. 이 둘을 분리해서 보는 게 사건 이해의 핵심이었어요.

이 COOP opener 정책은 다른 곳에도 흔적을 남겼습니다. 우리가 모듈들을 iframe으로 띄울 때 COOP/COEP 설정을 풀어준 적이 있는데, 이제 그 이유도 이해가 됐어요. 다른 origin을 opener로 열면 COOP가 opener를 null로 만들고, 그러면 부모 창과 postMessage로 통신하는 길이 막힙니다. 부모-자식 통신이 필요한 경우엔 COOP를 풀어줘야 했던 거죠.

🩹 manifest를 고쳐도 “이미 설치된 PWA”는 즉시 안 풀려요

“그럼 manifest의 displaystandalone에서 browser로 바꾸면 되는 거 아냐?”

방향은 맞습니다. 실제로 우리도 manifest를 standalonebrowser로 바꿨어요(CRA 기본값 제거). 하지만 이것만으로는 이번 사고를 즉시 풀지 못합니다.

PWA를 설치하면 그 manifest 정보가 브라우저(OS) 레지스트리에 등록됩니다. 한 번 설치된 뒤에는 서버의 manifest를 바꿔도, 이미 설치된 환경에는 기존에 등록된 설정이 남아있어요. 즉 manifest 수정은 “앞으로 새로 설치할 사람”에겐 예방책이 되지만, 이미 PWA를 설치해 둔 QA PC를 그 자리에서 구제하지는 못합니다(재설치해야 반영).

그래서 수정은 두 갈래로 병행됐어요.

  1. manifest standalonebrowser — 미래의 신규 설치 예방.
  2. 불필요하게 추가됐던 noopener 제거 — 이미 설치된 PWA를 포함해 즉시 원래 동작(보조 컨텍스트로 새 탭 열기)으로 복귀.

이번 회귀의 실제 원인은 manifest가 아니라 noopener가 만든 컨텍스트 변화였으니, 즉효약은 2번이었습니다. (블로그를 쓰며 처음엔 “manifest는 해법이 아니다”라고 단정하려 했는데, 코드를 확인해보니 manifest 수정도 분명히 함께 들어가 있더라고요. “해법이 아니다”가 아니라 “이미 설치된 환경엔 즉효가 아니다”가 정확합니다.)

보너스: 1st-party와 3rd-party 링크를 가른 결정

이 사건의 부산물로, 팀은 새 탭 링크의 정책을 출처에 따라 분리했어요.

  • 내 정보(우리 계정 서비스, 1st-party)noopener 제거. 위협이 없고, 붙이면 PWA 캡처만 유발.
  • User manual 등 외부 문서 링크(3rd-party)noopener 유지. 통제 못 하는 외부 페이지라 reverse tabnabbing 방어가 실제로 필요.

“이 경우엔 noopener가 불필요했다”는 판단을, 모든 링크에서 빼버리는 게 아니라 위험이 실재하는 외부 링크에서는 남기는 형태로 코드에 못 박은 거예요. 이게 더 정직한 결론이라고 생각합니다.

🛠️ 디버깅 여정: 크롬 작업 관리자와 PID의 함정

가설을 검증할 때 저는 크롬 작업 관리자(Task Manager) 를 켰어요. 제 기억엔 같은 site의 리소스를 공유하는지 / 프로세스(PID)가 같은지를 볼 수 있었기 때문입니다. “보조 컨텍스트면 원본과 프로세스를 공유하고, 독립이면 분리되겠지”라는 기대였죠.

결과는 기대와 달랐어요. PWA로 열든 새 탭으로 열든, 둘 다 원본(opener) 쪽과 다른 PID였습니다. 컨텍스트 종류로 PID 차이를 구분하려던 시도는 실패했어요.

이유는 아마 Site Isolation 때문인 것 같습니다. 크롬은 기본적으로 site가 다르면 프로세스를 격리해요. 계정 서비스는 origin/site가 다르니, 컨텍스트가 보조든 독립이든 어차피 별도 프로세스로 떨어진 거죠. 즉 PID는 이 사건에서 좋은 판별 지표가 아니었습니다. (이 부분은 아직 완전히 이해하진 못했어요 — 아래 오픈 퀘스천에 남깁니다.)

❓ 아직 모르겠는 것들 (Open Questions)

퍼즐의 큰 그림은 맞춰졌지만, 디테일에서 확신이 안 서는 것들을 묻어두지 않고 적어둡니다.

Q1. opener의 정확한 정의 — 제가 헷갈렸던 부분

작업 중 저는 “opener란 자식 프레임에서 부모 창으로 연결되는 것 아닐까? iframe으로 열면 opener가 부모가 되나?”라고 추측했었어요. 정리하며 보니 보정이 필요했습니다.

  • window.openerwindow.open() 또는 <a target="_blank"> 로 새로 열린 최상위 창자기를 연 창을 가리키는 참조입니다.
  • iframe의 부모는 window.opener가 아니라 window.parent 예요. 둘은 별개의 개념입니다.

즉 “iframe으로 열면 opener가 부모”라는 제 추측은 부정확했고, iframe ↔ parent 관계와 open된 창 ↔ opener 관계를 분리해서 봐야 합니다. 다만 “자식/종속 컨텍스트일 때 원본과의 연결이 살아있다”는 큰 직관은 맞았어요. (확인 출처: MDN Window.opener / Window.parent)

Q2. PID가 둘 다 다르게 나온 이유 (Site Isolation)

“site가 다르면 프로세스를 격리한다”까지는 알겠는데, 그럼 보조 컨텍스트 vs 독립 컨텍스트의 차이를 프로세스 모델 수준에서 관찰할 방법은 무엇일까요? 작업 관리자 PID로는 안 됐어요. → Site Isolation과 browsing context group의 관계, 두 컨텍스트를 구분해 볼 실제 관측 지표(예: chrome://process-internals)를 더 파봐야겠습니다.

Q3. COOP가 opener를 끊는 것과 “독립 컨텍스트로 분류되는 것”의 경계

앞에서 “COOP는 opener를 null로 만들지만, 그것만으로 독립 top-level 컨텍스트로 분류되어 PWA에 캡처되진 않았고, noopener 플래그가 명시될 때 비로소 캡처됐다”고 정리했는데, 이 경계가 스펙상 정확히 어디서 갈리는지는 아직 실증으로 못 박았어요. → COOP 값(same-origin / same-origin-allow-popups)별 동작과 noopener 조합이 PWA 캡처 여부에 미치는 영향을 매트릭스로 재현해볼 생각입니다.

✅ 회고: 이 사건이 남긴 두 가지

(1) 리팩토링에는 “동작 불변”을 보증하는 장치가 먼저예요

이번 버그의 진짜 교훈은 noopener가 아닙니다. 시각 회귀 검증을 하려는 리팩토링이, 오히려 다른 동작을 깨뜨렸고 아무도 그걸 잡아내지 못했다는 점이에요. 그리고 그 방아쇠가 코드 이동을 “신규 코드”로 오해한 diff 스캐너였다는 것까지요.

요즘 AI로 작업하며 우리는 정말 많은 것을 빠르게 갈아엎습니다. 그런데 “기존 동작이 변하지 않았다”를 자동으로 보증해줄 장치 — 회귀 테스트, 골든 동작 스냅샷, 동작 계약(contract) — 이 충분하지 않으면 회귀는 계속 새어 나와요. (다행히 이번 수정엔 “noopener 없이 _blank로만 열려야 한다”는 회귀 가드 테스트가 함께 들어갔습니다. 늦었지만 바른 방향이에요.)

리팩토링의 전제 조건은 리팩토링 자체가 아닙니다. “바꾸기 전에, 바뀌지 않아야 할 것을 고정할 수 있는 테스트/환경을 먼저 만든다.” 이게 없으면 그건 리팩토링이 아니라 도박이에요. 아이러니하게도 이번 사고를 낸 작업 자체가 “시각 회귀를 검증하려는” 리팩토링이었다는 점이 더 뼈아픕니다.

또 하나. 자동 보안 스캐너의 경고를 “사실”이 아니라 “가설”로 대해야 합니다. diff 기반 스캐너는 코드가 이동만 해도 “신규 위험”으로 오인할 수 있어요. 경고를 끄려고 반사적으로 패치를 넣기 전에, “이 위험이 이 컨텍스트에서 실제로 성립하는가?” 를 먼저 물었어야 했습니다.

(2) 문제 해결에도 순서가 필요해요 — 나만의 체크리스트

남이 만든 문제를 제가 해결하면서, 매번 같은 사고 과정을 반복한다는 걸 느꼈어요. 그런데 이걸 명시적으로 정의해두지 않으면 급할 때 이 접근이 바로 떠오르지 않더라고요. 그래서 정리합니다. 문제를 만나면 고치기 전에 이 순서로 묻습니다.

  1. 이 문제가 왜 발생했는가? (증상이 아니라 인과의 사슬을 끝까지)
  2. 기존 구현의 의도는 무엇이었나? (이 코드는 원래 무엇을 지키려 했는가)
  3. 문제를 만든 변경이 그 의도를 해쳐서 문제가 생긴 게 맞는가? (변경과 증상의 인과 확정)
  4. 내가 고치려는 방향은 기존 구현의 의도를 지키는가? (또 다른 회귀를 만들지 않는가)
  5. 이 모든 것 이전에 — 이 기능/이 코드/이 방어가 정말 필요한가? (이번엔 noopener가 “필요 없는 방어”였다)
  6. 자주 발생하는 문제라면, 왜 자주 발생하는가? (피로도를 높이는 구조적 원인)
  7. 그 피로도 자체를 없앨 방법은 무엇인가? (개별 수정이 아니라 재발 방지 시스템)

이번 사건에 대입하면 깔끔히 맞아떨어져요.

  • 1번: noopener가 컨텍스트를 독립으로 바꿔 PWA를 깨웠다.
  • 2번: 원래 의도는 “보조 컨텍스트로 새 탭 열기”.
  • 3번: noopener 추가가 그 의도를 깼다. 맞다.
  • 4번: noopener를 걷어내면 원 의도로 복귀. ✔
  • 5번: 애초에 cross-origin+COOP라 noopener가 불필요했고, 스캐너 경고도 오검출이었다 → 제거가 정답.
  • 6/7번: 근본 원인은 “리팩토링 회귀를 잡을 장치의 부재” + “스캐너 경고를 무비판 수용” → 회귀 테스트/동작 계약 + 경고 검증 습관.

기존에도 이렇게 접근하고 있었다고 생각했지만, 스스로 언어로 정의해두지 않으면 그때그때 떠오르지 않더라고요. 그래서 이렇게 박아둡니다.

🧵 마치며

한 줄의 보안 패치가 PWA를 깨운 이 사건은 표면적으로는 “noopener 버그”지만 실제로는 세 겹의 이야기였어요.

  • 기술의 겹: noopener는 opener만 끊는 게 아니라 브라우징 컨텍스트의 성격을 바꾸고, 그게 PWA 링크 캡처를 트리거한다. cross-origin COOP, Site Isolation이 그 위에 겹쳐 “왜 이 제품만?”이라는 미스터리를 만들었다.
  • 프로세스의 겹: 동작 불변을 보증하는 장치 없이 빠르게 리팩토링하면, 그리고 diff 스캐너의 오검출을 무비판 수용하면, 회귀는 반드시 새어 나온다.
  • 사고방식의 겹: 남의 문제를 고칠 때일수록 “왜 → 원래 의도 → 인과 확정 → 의도 보존 → 정말 필요한가 → 재발 구조”의 순서를 명시적으로 밟아야 한다.

아직 모르는 것(Q1~Q3)들은 숙제로 남겨둡니다. 다 이해하지 못한 채로도 문제는 고쳤지만, 그 미해결의 디테일을 적어두는 것까지가 이 사건을 제대로 닫는 방법이라고 생각해요.


참고

  • Chrome for Developers — Navigation management into installed PWAs / Declarative Link Capturing
  • MDN — Window.opener, Window.parent, Window.open()
  • MDN / 표준 — Cross-Origin-Opener-Policy: same-origin