사내 그룹웨어를 모바일 대응하고 나면 마지막 한 걸음이 남는다. 폰 홈 화면에 아이콘으로 올려서 앱처럼 쓰게 하는 것이다.
앱스토어에 올릴 것도 아니고, 네이티브 앱을 만들 것도 아니다. 사파리에서 공유 → 홈 화면에 추가만 하면 끝난다. 아이콘이 생기고, 탭하면 주소창 없이 전체화면으로 뜬다. 여기까지는 아무것도 안 해도 된다.
문제는 그다음이다.
링크를 한 번 눌렀더니 브라우저가 됐다
첫 화면은 분명 앱처럼 떴다. 그런데 메뉴에서 결재함으로 이동하는 순간 위쪽에 주소창이 생기고, 아래에 사파리 도구막대가 붙는다. 앱이었던 것이 갑자기 브라우저가 된다.
로그인 세션은 그대로다. 화면도 정상이다. 그냥 앱처럼 보이지 않을 뿐이다. 그런데 이게 생각보다 거슬린다. 홈 화면에 아이콘까지 만들어놓고 실제로는 브라우저를 쓰는 셈이 되니까.
원인은 manifest가 없었던 것
iOS는 홈 화면 웹앱에 범위(scope)라는 개념을 둔다. 그 범위 안의 주소는 웹앱 안에서 열고, 범위 밖의 주소는 주소창이 달린 인앱 브라우저로 연다. 밖으로 나가는 링크를 눌렀을 때 "여기서부터는 웹앱이 아니라 웹입니다"라고 알려주는 장치다.
그 범위를 알려주는 것이 manifest다. 그리고 manifest가 없으면 iOS는 홈 화면에 추가한 그 주소 하나만 범위로 잡는다.
여기까지 오면 증상이 정확히 설명된다. 홈 화면에 추가한 주소가 /였으니 /만 웹앱이고, 결재함 링크는 범위 밖이라 인앱 브라우저로 열린 것이다. 버그가 아니라 명세대로 동작한 것이었다.
manifest 한 장
{
"name": "A사 그룹웨어",
"short_name": "그룹웨어",
"lang": "ko",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f1f2f7",
"theme_color": "#f1f2f7",
"icons": [
{
"src": "/cms/images/icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
}
]
}
핵심은 "scope": "/" 한 줄이다. 사이트 전체를 웹앱 범위로 선언하면, 어느 메뉴로 이동하든 주소창이 나타나지 않는다.
나머지 항목도 각자 역할이 있다.
start_url— 아이콘을 탭했을 때 처음 열 주소.scope와 다르게 줄 수 있다(예: 대시보드로 바로 열기).display: "standalone"— 브라우저 UI 없이 띄운다.fullscreen은 상태바까지 먹어서 시계와 배터리가 사라지므로, 업무용 화면에는standalone이 맞다.background_color— 앱이 뜨는 순간 화면이 채워지기 전까지 보이는 색. 여기가 흰색이면 다크 테마에서 켤 때 한 번 번쩍인다.icons— iOS는apple-touch-icon을 먼저 보므로 사실상 안드로이드·데스크톱용이다. 180×180 하나로 충분하다.
메타 태그 — 특히 이름 붙이기
<link rel="apple-touch-icon" href="/cms/images/icon.png">
<link rel="manifest" href="/cms/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="{{SiteName}}">
<meta name="theme-color" content="#f1f2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#17181e" media="(prefers-color-scheme: dark)">
여기서 실무적으로 중요한 건 apple-mobile-web-app-title이다.
홈 화면 아이콘 아래에 붙는 이름인데, iOS에서는 이 값이 manifest의 name보다 우선한다. 한 코드베이스로 여러 사이트를 굴리는 경우 — 그룹웨어와 헬프데스크가 같은 레이아웃을 쓰는 식이라면 — manifest는 한 장을 공유하면서 아이콘 이름만 사이트별로 다르게 붙일 수 있다. 템플릿 변수 하나면 된다.
theme-color를 두 벌 주는 것도 작지만 티가 난다. 상태바 주변 색이 화면 배경과 맞아떨어져야 앱처럼 보인다. 라이트에서만 맞춰두면 다크 테마에서 위쪽에 밝은 띠가 남는다.
구형 iOS를 위한 폴백
manifest를 홈 화면 웹앱에 반영하기 시작한 건 iOS 16.4부터다. 그 아래 버전은 manifest를 아예 보지 않고 예전처럼 동작한다. 사내 시스템은 기기 교체 주기가 길어서 구형이 남아 있기 마련이다.
그래서 자바스크립트로 같은 일을 한 번 더 한다. standalone으로 떠 있을 때, 같은 사이트로 가는 평범한 링크만 가로채서 직접 이동시킨다.
(function () {
// navigator.standalone 은 iOS 사파리에만 있는 값이다.
// 홈 화면 아이콘으로 띄운 창일 때만 true.
if (!window.navigator.standalone) return;
document.addEventListener('click', function (e) {
var link = e.target.closest ? e.target.closest('a') : null;
if (!link || !link.href) return;
// 새 창으로 여는 것이 의도인 링크들은 그대로 둔다
if (link.target && link.target !== '_self') return;
if (link.hasAttribute('download')) return;
if (link.classList.contains('popup')) return;
// 다른 스킴(tel:, mailto:)과 외부 도메인은 밖으로 나가는 것이 맞다
if (link.protocol !== 'http:' && link.protocol !== 'https:') return;
if (link.host !== window.location.host) return;
// 같은 페이지 앵커는 브라우저에 맡긴다
var raw = link.getAttribute('href') || '';
if (raw === '' || raw.charAt(0) === '#') return;
e.preventDefault();
window.location.href = link.href;
}, false);
})();
코드에서 실제로 중요한 건 location.href 한 줄이 아니라 그 앞의 return 여섯 개다. 이런 가로채기는 무엇을 가로채는가보다 무엇을 건드리지 않는가로 품질이 갈린다.
| 건드리지 않는 것 | 이유 |
|---|---|
target이 지정된 링크 | 새 창이 의도다 |
download 속성 | 이동이 아니라 저장이다 |
| 팝업 관례 클래스 | 프로젝트가 팝업으로 쓰는 링크다 |
tel: · mailto: | 전화·메일 앱으로 넘어가야 한다 |
| 외부 도메인 | 밖으로 나가는 게 맞다 |
# 앵커 | 드롭다운·탭 토글이 대부분이다 |
특히 마지막 항목을 빠뜨리면 드롭다운 메뉴가 전부 페이지 이동으로 바뀐다. 부트스트랩 계열 UI는 토글을 <a href="#">로 만드는 경우가 많아서, 이 한 줄이 없으면 화면 곳곳이 깨진다.
폼 전송은 손대지 않았다. POST 제출은 원래 standalone 안에서 유지되기 때문이다. 필요 없는 곳까지 가로채면 버그만 늘어난다.
알아두면 하루를 아끼는 함정
iOS는 홈 화면에 추가하던 시점의 설정을 저장한다.
manifest를 고치고 배포해도 이미 홈 화면에 있는 아이콘에는 반영되지 않는다. 아이콘을 지우고 다시 추가해야 한다. 이걸 모르면 "분명 고쳤는데 그대로다"를 반복하며 코드를 계속 의심하게 된다. 캐시 문제로 착각하기 딱 좋다.
테스트할 때는 아예 습관을 만들어두는 편이 낫다. 고쳤으면 아이콘부터 지운다.
브라우저를 없애면, 브라우저가 하던 일이 남는다
여기까지 하면 웹앱은 완성된 것처럼 보인다. 그런데 실제로 써보니 예상 못 한 숙제가 하나 나왔다.
이 시스템은 상세·수정 화면 상당수를 팝업으로 띄운다. 데스크톱에서는 아무 문제가 없다. 볼 일이 끝나면 창의 X를 누르면 되니까. 그래서 팝업 화면 48개 중 화면 안에 닫기 버튼이 있는 것은 4개뿐이었다. 나머지 44개는 닫는 방법이 브라우저에만 있었다.
홈 화면 웹앱에서는 그 X가 없다.
팝업은 사파리로 열리는데, 거기엔 닫을 창도 없고 돌아갈 뒤로도 없다. 사용자는 그 화면에 갇힌다. 앱 전환으로 빠져나오는 수밖에 없다.
결국 44개 화면에 닫기 버튼을 넣었다. 기계적인 작업 같지만 실제로는 화면마다 사정이 달랐다.
@if/@else로 [등록]과 [수정] 버튼이 갈리는 폼이 많았다. 마지막 버튼 뒤에 넣으면 한쪽 화면에만 나온다.@endif뒤에 넣어야 양쪽 다 나온다.- "하단 버튼 영역의 첫 자식이 버튼 그룹"이라는 가정이 통하지 않는 화면이 있었다. 안내 문구가 먼저 오는 경우다.
- 어떤 화면은 하단 막대 전체가 권한 조건문 안에 들어 있어서, 권한이 없으면 막대째 사라졌다. 막대는 항상 그리고 조건부 버튼만 조건 안에 두도록 구조를 바꿔야 했다.
- 복원 폼 안에 버튼 영역이 들어가 있어 폼 밖으로 꺼내야 하는 화면도 있었다.
템플릿 46개를 고쳤으니 눈으로 훑는 것만으로는 부족했다. 컴파일 결과를 php -l로 검사하고 태그 여닫이 균형을 파일마다 확인했다.
정리 — 홈 화면 웹앱 체크리스트
manifest.json에scope를 준다. 이게 링크 이동의 핵심이다.apple-touch-icon과apple-mobile-web-app-title을 넣는다. 멀티사이트라면 title은 사이트별로.theme-color는 라이트·다크 두 벌.- 구형 iOS를 지원해야 한다면
navigator.standalone폴백을 둔다. 가로채지 말아야 할 것들을 먼저 정하고 시작한다. - 고쳤으면 홈 화면 아이콘을 지우고 다시 추가한다.
- 브라우저 UI에 의존하던 동작을 찾아낸다. 닫기, 뒤로 가기, 새로고침 — 데스크톱에서는 공짜였던 것들이다.
웹앱으로 만든다는 건 화면을 전체화면으로 띄우는 일이 아니다. 브라우저가 대신 해주던 일을 화면이 직접 떠맡는 일에 가깝다. 주소창을 없앤 대가로 44개 화면에 닫기 버튼을 넣게 될 줄은 시작할 때 몰랐다.