Perplz Logo
Productivity

자동으로 www. 를 붙이던 한 줄짜리 코드 때문에 인앱 웹뷰가 새하얗게 되어 버린 건에 대하여

훌러터훌러터

인앱 웹뷰로 외부 링크를 띄우는 화면이 하얗게 멈춘다는 보고가 들어왔다. 콘솔 에러는 아래와 같았다.

WebView 리소스 에러 — errorCode: -1003,
description: 특정 호스트 이름을 가진 서버를 찾을 수 없습니다.
url: https://www.<some-blog>.<naver-like>.com/<post-path>



iOS 의 -1003 은 `NSURLErrorCannotFindHost`. DNS 가 그 주소를 모른다는 뜻이다. 처음엔 흔한 원인을 의심했다. 웹뷰의 User-Agent 에 wv 마커가 붙어 있으면 본문을 안 내려 주는 사이트가 있다. 모바일 크롬 / 사파리로 위장하고, 3rd-party 쿠키를 켜고, 외부 스킴 처리를 손봤다. 같은 에러가 계속 찍혔다.

주소 자체는 살아 있었다. DNS 조회를 직접 돌려 보니 차이가 분명했다.

www.blog.<provider>.com → NXDOMAIN
blog.<provider>.com     → HTTP 200



원본은 `blog.<provider>.com` 이었고, 웹뷰에 넘긴 호스트는 `www.blog.<provider>.com` 이었다. 클라이언트 안에서 `www.` 가 한 번 더 붙었다.

코드를 보니 `UrlValidator.getValidUrl` 라는 헬퍼가 외부 URL 마다 아래 동작을 하고 있었다.

// 스키마가 없으면 https:// 추가
if (!url.startsWith('http://') && !url.startsWith('https://')) {
  url = 'https://$url';
}

// www. 가 없으면 추가 (localhost / IP 가 아닌 경우만)
if (!url.contains('www.') && !url.contains('localhost') && !isIp(host)) {
  url = url.replaceFirst('https://', 'https://www.');
}



옛날엔 거의 모든 사이트가 www 서브도메인으로 시작했다. 지금은 다르다. 한국 서비스 진입 경로의 상당수는 `blog.*`, `m.*`, `cafe.*` 같은 서브도메인이 기본이다. 짧은 URL 서비스 (`youtu.be`, `bit.ly`) 도 마찬가지였다. 이런 호스트 앞에 `www.` 를 또 붙이면 대부분 NXDOMAIN.

이 헬퍼는 외부 URL 처리의 공통 진입점이었다. 웹뷰뿐 아니라 채팅의 링크 감지기, 대기방 메시지의 링크 렌더도 같은 함수를 호출하고 있었다. 같은 깨짐이 여러 화면에 깔려 있는 상태였다. 한국어 환경에서만 동작하던 `body.contains` 분기가 다국어 도입에서 한꺼번에 무너졌던 직전 사례 (참고: [[notification-i18n-snapshot]]) 와 같은 구조였다.

첫 번째 수정은 호스트의 점 개수로 분기하는 것이었다. 점이 한 개면 (`provider.com`, `flutter.dev`) 기존 동작 그대로 `www.` 를 붙이고, 점이 두 개 이상이면 (`blog.provider.com`, `m.provider.com`) 손대지 않았다.

final labelCount = '.'.allMatches(host).length + 1;
if (labelCount == 2 && !isLocalhost(host) && !isIp(host)) {
  url = url.replaceFirst('https://', 'https://www.');
}



수정 후 다시 테스트했더니 같은 에러가 또 찍혔다. 헬퍼가 `www.` 를 더 이상 붙이지 않는데, 입력 자체가 이미 `www.blog.<provider>.com` 모양이었다. 다른 앱에서 공유받은 텍스트나 직접 적어 넣은 URL 에 그렇게 박혀 있었다. 결과는 NXDOMAIN.

두 번째 수정은 이미 잘못 박힌 `www.` 를 떼어 내는 것이었다. 호스트가 `www.` 로 시작하고 점으로 구분한 label 이 네 개 이상이면 그 `www.` 는 실수 입력으로 봤다. `www.X.Y.Z` 같은 호스트는 실제로 등록된 경우가 거의 없다.

if (host.startsWith('www.') && labelCount >= 4) {
  url = url.replaceFirst('://www.', '://');
}



코드는 점점 길어졌다. 한 줄짜리 보정이 두 분기와 한 개 정리 코드로 늘어났다. 이 시점에 헬퍼의 호출처를 다시 봤다.

외부 URL 을 다루는 곳은 세 군데였다.

웹뷰 진입은 받는 URL 이 항상 외부에서 완성된 형태로 들어왔다. `https://` 추가, `www.` 추가 모두 의미가 없었다.

채팅 링크 감지기는 정규식이 메시지 텍스트에서 URL 형태를 매치해 넘기고 있었다. 정규식이 `(?:https?:\/\/)?(?:www\.)?` 를 옵션으로 잡아서 결과가 `provider.com` 같은 짧은 도메인일 수도 있었다. 다만 같은 정규식이 `blog.provider.com` 도 매치하기 때문에 결과의 모양이 보장되지 않았다.

대기방 메시지 버블은 메시지 텍스트 전체를 도메인 후보로 보고 있었다. 메시지가 정확히 도메인 한 줄일 때만 의도대로 동작했다.

세 호출처 모두 헬퍼가 보정해 주려는 형태 (짧은 도메인 입력) 와 입력 모양이 다르거나 일치를 보장하지 못했다. 함수 이름이 일반적이라 (`getValidUrl`) "URL 처리는 이거 쓰면 되겠지" 식의 호출만 들어 있었다.

세 번째 수정은 헬퍼 폐기였다. 도메인 자동 변형을 전부 빼고 스키마 보정 한 줄만 남겼다.

static String ensureHttpScheme(String url) {
  final t = url.trim();
  if (t.isEmpty) return '';
  if (t.startsWith('http://') || t.startsWith('https://')) return t;
  if (t == 'localhost' || t.startsWith('localhost:')) return 'http://$t';
  return 'https://$t';
}



함수 이름이 `getValidUrl` 에서 `ensureHttpScheme` 으로 바뀌면서 동작이 이름으로 드러났다. 도메인은 손대지 않게 됐다. 호출처에서 이름만 봐도 "스키마만 보정, 나머지는 입력 그대로 통과" 가 보였다.

호출처도 같이 정리했다. 웹뷰는 `ensureHttpScheme(widget.url)` 만 호출하게 했다. 채팅 링크 감지기와 대기방 메시지 버블도 같은 식으로 바꿨다. 호출 흔적이 없던 `isValidUrl`, `extractDomain`, `extractUrls` 메서드는 삭제했다. 검증은 `isValidDomain` 만 남겼다.

정리.

이번 임시 처리는 다른 작업이 들어오면서 발견됐다. 외부 URL 의 도메인 다양성이 그 작업이었고, 직전엔 `body.contains('초대')` 로 한국어 알림을 라우팅하던 분기가 다국어 도입에서 같은 식으로 깨진 적이 있다. 두 경우 다 새 작업과 함께 수정하느라 일정이 길어졌다. 새 작업이 잡히기 전에 미리 손봤더라면 그만큼 짧았을 것 같다.

함수 이름이 호출처가 처음 보는 정보라는 점도 다시 느꼈다. `getValidUrl` 이라는 일반적인 이름이 호출처가 헬퍼의 동작 범위를 모르고 가져다 쓰게 만들었다. 이름이 `normalizeUserTypedDomain` 정도였다면 웹뷰 호출처에서 가져다 쓰기 전에 한 번 멈춰서 입력 형태를 확인했을 가능성이 컸을 것 같다.

외부 입력 헬퍼는 입력을 그대로 받고 모양만 확인하는 쪽이 이번 케이스에선 잘 맞았다. 자동 보정 자체를 폐기하고 스키마 보정 한 줄만 남기는 결정.

도메인 정규화가 다시 필요해지면 Public Suffix List 같은 표준 라이브러리를 먼저 살펴보는 게 좋을 것 같다. 같은 식의 임시 처리는 다음에 안 만드는 쪽으로 가고 싶다. 함수 이름이 좁고 명확하면 비슷한 짐작 호출이 다시 들어오는 일도 줄어들지 않을까 싶다.