만들면서 배운 것

충돌 0건이라던 병합 제안이, 이틀 만에 위험한 조언이 됐습니다 🧨

답답해하는 초록이 아빠

돈독(제가 혼자 만드는 자산관리 앱, https://don-doc.com )을 매일 점검하는 AI 에이전트 중 하나가 이런 제안을 올렸습니다. “브랜치 하나만 병합하면 됩니다. 충돌 0건 확인했고, 이 병합 하나로 문제 두 개가 한 번에 끝납니다.” 병합 버튼을 누르기 전에 다음 라운드 점검이 먼저 돌았습니다. 거기서 나온 결론은 정반대였습니다. 지금 그 브랜치를 병합하면 안 됩니다. 오히려 보안 구멍을 다시 여는 결과가 됩니다.

로그아웃 버튼 하나에 넉 달째 뚫려 있던 구멍

문제는 로그아웃 라우트였습니다. /api/auth/logout?redirect=아무주소처럼 쿼리파라미터로 이동할 주소를 받는데, 그 값을 검증 없이 그대로 리다이렉트에 써버리고 있었습니다. 로그아웃 버튼을 눌렀는데 낯선 사이트로 튕기는 게 가능하다는 뜻이었습니다(오픈 리다이렉트). 5월에 처음 발견됐지만, 다른 작업을 하다 “이건 지금 범위 밖”으로 두 번 흘려보내지며 자기 이슈로 등록된 적이 한 번도 없었습니다.

9월 7일, 이 문제를 직접 고친 커밋이 하나 만들어졌습니다.

- return NextResponse.redirect(new URL(redirectTo, req.url))
+ const safeRedirectTo = redirectTo.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/sign-in'
+ return NextResponse.redirect(new URL(safeRedirectTo, req.url))

슬래시로 시작하고 이중 슬래시(//)로는 시작하지 않는 값만 내부 경로로 인정한다 — 얼핏 맞아 보이는 수정입니다. 그런데 이틀 뒤(9월 9일), 이 수정 자체가 다시 뚫린다는 게 확인됐습니다.

첫 번째 수정이 왜 뚫렸는가

브라우저가 URL을 해석할 때 쓰는 규칙(WHATWG URL 파서)에는 특이한 구석이 있습니다. 특정 스킴에서는 백슬래시(\)도 슬래시(/)와 똑같이 경로 구분자로 취급합니다. 그러면 /\evil.com이라는 문자열은 어떻게 될까요. 슬래시로 시작하고 //로는 시작하지 않으니 첫 번째 검사는 통과합니다. 하지만 브라우저가 실제로 이 문자열을 해석하면 https://evil.com으로 풀립니다. 검사는 통과했는데, 결과는 정확히 막으려던 그 외부 주소입니다.

삽질하다 멘붕한 초록이 아빠

문자열 앞부분만 보는 검사(접두사 검사)는 이래서 위험합니다. “이렇게 생긴 값은 안전해 보인다”는 사람의 직관과, 파서가 실제로 그 값을 어떻게 해석하는지는 다를 수 있습니다. 두 번째 수정은 접근 자체를 바꿨습니다. 문자열을 눈으로 판단하지 않고, 파서에게 먼저 해석을 시킨 다음 그 결과의 도메인(origin)이 우리 사이트가 맞는지만 확인하는 함수(safeRedirect)를 새로 만들었습니다. 인코딩 트릭은 파서가 이미 정규화한 뒤라 통하지 않습니다. 테스트도 8가지 우회 케이스(백슬래시, 계정정보 흉내, 서브도메인 흉내, javascript: 스킴 등)로 붙였습니다.

여기까지는 흔한 “1차 수정 → 우회 발견 → 2차 수정” 이야기입니다. 진짜 흥미로운 지점은 그다음입니다.


어제까지 맞던 제안이, 오늘은 위험한 조언이 됐다

점검 담당 에이전트는 병합 대기 중인 브랜치 하나를 발견했습니다. 그 브랜치에는 딱 커밋 하나가 있었고, 내용은 바로 그 “1차 수정”(접두사 검사 방식)이었습니다. git merge-tree로 병합을 미리 시뮬레이션해봤더니 실제 텍스트 충돌은 0건. 최우선 순위로 “이 병합 하나면 끝난다”고 제안이 올라갔습니다.

문제는 타이밍이었습니다. 그 제안이 올라올 무렵, main 브랜치는 이미 더 나은 버전(파서 기반 판정)으로 넘어가 있었습니다. 브랜치 쪽 내용은 옛날 버전, main 쪽 내용은 새 버전 — 그런데 두 파일이 건드리는 줄이 미묘하게 갈려서, git이 보기엔 “충돌 없이 자동으로 합쳐지는” 상태였습니다. 병합하면 safeRedirect 함수를 불러오는 부분이 사라지고, 그 자리에 뚫린 접두사 검사가 다시 들어섭니다. 화면엔 아무 경고도 안 뜹니다. 테스트만 돌려보면 통과할 수도 있습니다(8개 우회 케이스 테스트 자체가 새 파일에 있었으니, 그 파일이 통째로 사라지면 테스트도 같이 사라집니다).

다음 라운드 점검에서 이 사실이 뒤늦게 드러났습니다. “충돌 0건”은 사실이었습니다. 그런데 그 사실이 “병합해도 안전하다”를 보장해주지는 않았습니다.

충돌이 없다는 건 텍스트가 안 겹친다는 뜻이지, 병합해도 안전하다는 뜻이 아니었습니다.

저는 이 대목에서 뜨끔했습니다. 저도 “ahead 1, behind 40” 같은 숫자만 보고 “그럼 가져올 게 별로 없네” 하고 브랜치를 정리한 적이 여러 번 있습니다. 그 반대(ahead가 딱 하나뿐이라 “이 정도면 금방 합칠 수 있겠다”)도 마찬가지로 방심할 지점이라는 걸 이번에 알았습니다. 숫자가 작을수록 오히려 “그 하나가 지금도 여전히 최신인가”를 확인 안 하고 넘어가기 쉽습니다.

어떻게 끝났나

결국 이 브랜치는 병합하지 않고 삭제하는 쪽으로 정리됐습니다. 같은 내용을 이미 main이 더 나은 방식으로 가지고 있으니, 병합은 발전이 아니라 퇴보였던 셈입니다. 대신 앞으로 이런 낡은 로컬 브랜치를 다시 만들지 않기 위해, “병합 절대 금지 — main이 이미 더 나은 구현을 가지고 있음”이라는 표시를 남기기로 했습니다. 단순히 지우고 끝내면, 누군가(사람이든 AI든) 같은 브랜치명 패턴을 또 만들었을 때 똑같은 실수가 반복될 수 있으니까요.


따라 하기

혼자 여러 브랜치를 굴리다 보면 “이 브랜치, 병합해도 되나?”를 자주 판단해야 합니다. ahead/behind 숫자나 “충돌 0건” 표시만으로는 부족하다는 걸 이번에 배웠습니다. 아래 두 명령으로 한 단계 더 확인합니다.

1. 브랜치가 가진 커밋이 지금도 유효한지 확인하기

git log --oneline main..<브랜치명>
  • 이 브랜치가 main에 없는 고유 커밋이 몇 개인지, 어떤 내용인지 먼저 봅니다. 숫자가 적을수록(“딱 1개뿐이네”) 안심하기 쉬운데, 바로 그 1개가 이미 낡았을 수 있다는 걸 이번 일로 알았습니다.

2. 브랜치가 건드리는 파일을, main의 지금 버전과 직접 대조하기

git diff main <브랜치명> -- <건드리는 파일 경로>
  • 여기서 보는 건 “충돌 여부”가 아니라 “내용 차이”입니다. main 쪽 파일에 브랜치가 모르는 새 로직(예: 새로 만든 함수 호출)이 이미 들어와 있다면, 그 브랜치는 병합 대상이 아니라 폐기 대상입니다.

✅ 이렇게 되면 성공 — 브랜치의 고유 커밋 내용과 main의 최신 내용을 나란히 비교했을 때, 브랜치 쪽이 최신이거나 최소한 동등하다는 걸 확인했습니다. 그럴 때만 병합합니다.

⚠️ 막히면 — 파일이 여러 개라 하나씩 diff 하기 번거로우면, 먼저 git log --oneline main..<브랜치명> --stat으로 브랜치 커밋이 건드린 파일 목록부터 뽑고, 그중 보안·인증·삭제처럼 되돌리기 어려운 파일만 골라 집중적으로 대조하는 것도 방법입니다.

배운 것

화이팅하는 초록이 아빠

“병합 승인만 남았다”는 말을 들으면 마음이 편해집니다. 남은 건 버튼 하나뿐이라는 뜻이니까요. 그런데 이번 일로, 그 편한 마음이 정확히 위험한 지점이라는 걸 알았습니다. 제안이 틀렸던 게 아닙니다. 제안이 나온 그 순간까지는 맞았는데, 코드가 그보다 빨리 움직였을 뿐입니다.

그래서 이제 저는 “충돌 0건”이라는 문구를 봐도 바로 병합 버튼을 누르지 않습니다. 그 브랜치가 건드리는 파일이 지금 main에서 어떻게 생겼는지, 한 번은 직접 열어봅니다. 어제 맞던 판단이 오늘도 맞다는 보장은, 코드가 멈춰 있을 때만 성립하니까요.

오늘도 한 걸음. 🐴

이 글은 산업 구조와 만드는 과정을 정리한 개인 기록입니다.
특정 종목의 매수·매도 추천이 아니며, 투자 판단과 그 결과는 독자 본인에게 있습니다.

← 글 목록으로