App Screenshot

iOS 앱 Tiny Tip Calculator를 만들었다.

계기

매번 식사를 밖에서 할 때마다 팁을 계산하는 모습을 보고 간편한 팁 계산기가 있으면 좋겠다고 생각했다. 그래서 앱스토어에서 받으려고 검색했는데 수많은 팁 계산기가 다음 부류였다.

어떤 앱은 세 가지 모두에 해당했다. 그래서 간단한 앱을 하나 만들기로 했다.

도구 선정

집에 있는 모든 사람이 아이폰을 사용하고 있어서 iOS 앱을 만들기로 했고 무엇으로 개발할지 고민했다.

React도 계속 공부하고 써보려고 하고 있지만, 여전히 Angular가 익숙한 데다 생각보다 Ionic의 성능이 좋아서 Ionic으로 선택했다. React에 좀 더 익숙하다면 React Native를 고민 없이 선택했을 것이다. 결과물을 빠르게 보겠다는 생각 탓에 편향적인 결정을 내렸다.

개발 목표

개발하기 전에 다음 목표를 정했다.

개발 계획

프로젝트를 생성할 때 정도만 문서를 봤고 Angular는 이미 익숙해서 평소 작업하듯 만들었다. 로직은 별문제 없이 만들었지만, 스타일에 수고가 많이 들었다.

전체적으로 페이지 수는 얼마 되지 않았다.

Angular는 각 컴포넌트나 디렉티브, 모듈의 선언적 관리가 전체적인 코드 구성에 정말 편리했다. TypeScript와 함께 궁합도 너무 좋았다. 프레임워크 답게 전체적인 만듦새나 구조는 Angular가 확실히 더 이해하기 좋게 느껴진다.

앱에서 필요한 부분은 모두 ionic에서 제공하는 플러그인으로 충분했다. Storage 등도 이미 다 고수준으로 제공하고 있는 데다 angular에서 손쉽게 의존성 주입으로 활용할 수 있었다.

Good & Bad

웹과 다른 흐름의 도구를 제대로 만들어서 결과를 낸 것은 처음이었다. 이 과정에서 좋았던 점은 다음 같았다.

아쉬운 점도 있었다.

결과

이미 많은 앱이 존재해서 검색 목록 위로 올라가는 일조차 쉽지 않지만 어떤 방식으로든 결과물을 빠르게 냈다는 점에 즐거웠다. 그리고 작은 앱이더라도 옆에서 부지런히 쓰는 사용자가 있어서 재미있었다. 다음 또 앱을 만든다면 어떤 점을 미리 고려해야 하는지도 배웠다.


트러블 슈팅

iOS에서 overflow로 생성한 스크롤 부드럽게(?) 하기

스타일에서 overflow 프로퍼티를 적용하면 웹 페이지 내에 스크롤을 넣을 수 있다. 그런데 iOS에서는 그 스크롤을 써보면 일반적으로 경험할 수 있는 스크롤처럼 부드럽지 않고 뻑뻑하게 움직인다. 이 동작을 iOS의 기본 동작처럼 바꾸려면 다음 스타일을 추가로 넣어야 한다.

--webkit-overflow-scrolling: touch;

최근 모델의 notch 해결하기

이 스타일은 Safari만 지원한다.

먼저 웹뷰의 페이지에서는 기본적으로 상단의 스테이터스바를 안전 영역으로 처리한다. 그래서 고정 영역을 예로 들어 top: 0로 설정해도 스테이터스바 바로 아래에 자리를 잡는다. 화면 전체를 웹 영역에 넣고 처리하고 싶은 경우에는 viewport-fit을 viewport에 추가해야 한다.

viewport-fit은 다음 3가지 값을 지원한다. csswg 문서에 그림을 잘 그려놨다.

<meta name="viewport" content="viewport-fit=cover, ..." />

이제 화면 전체 영역을 사용할 수 있게 되었다. 이제 스테이터스바 영역만큼 밀어야 할 필요가 생긴다.

header.global {
    padding-top: 20px;
}

하지만 이렇게 고정값을 넣으면 노치가 없는 기기에서 높이가 이상해진다. 이런 상황에서는 safe-area-inset-* 상수를 사용하면 된다.

heaader.global {
    padding-top: constant(safe-area-inset-top); /* iOS 11.0 */
    padding-top: env(safe-area-inset-top); /* iOS 11+ */
}

constraint()는 없어질 예정이며 이후로는 env()를 사용하면 되겠다.

CSS Grid

고정된 화면을 기준으로 각 컴포넌트를 배치하는데 css grid가 정말 편했다. vw, vhcalc()를 함께 쓰면 어떤 컴포넌트든 원하는 위치에 놓을 수 있었다.

position: sticky

stickyposition 프로퍼티에 사용하면 필요한 요소를 고정으로 띄울 수 있다. 예전엔 위치 계산해서 fixed를 직접 설정해줬어야 했는데 손쉽게 구현할 수 있다.

다만 iOS에서 sticky에 배경을 지정해도 위에 1px 정도 아래 엘리먼트가 보이는 문제가 있었다. transform: translateY(-1px)를 추가해서 약간 비틀어서 해결되었고 GPU 가속이 동작해선지 약간 버벅이던 동작도 없어졌다.

:host-context()

하위 컴포넌트가 상위 컴포넌트의 스타일에 따라 스타일을 제어해야 할 때가 있는데 여기에 :host-context()를 사용할 수 있다. [theme]과 같은 디렉티브를 사용해서 부모 엘리먼트에 클래스를 추가하도록 했다면 :host-context()로 해당 클래스를 추적하는 것이 가능하다.

// hello.theme.scss
:host-context(.theme--hello) {
    * {
        font-family: 'Arial Rounded MT Bold', sans-serif;
    }

    header {
        color: #ff00c3;
    }
    // ...
}

현재는 이렇게 만든 각 테마 scss를 한 곳에서 모두 불러오는 구조로 되어 있다. 이 구현을 다시 한다면 중간에 테마를 제어하는 컴포넌트를 ViewEncapsulation.None로 놓고 테마 스타일을 동적으로 불러오도록 처리하고 싶다.

오버 스크롤 끄기

하이브리드앱을 가장 하이브리드앱처럼 보이게 하는 동작 중 하나가 오버 스크롤이다. 이 동작으로 전체 레이아웃이 고정된 부분 없이 움직이면 앱처럼 느껴지지 않는다. Cordova에서는 다음 속성을 config.xml에 추가하면 이 문제를 해결할 수 있다.

<!--config.xml in the project-->
<preference name="DisallowOverscroll" value="true" />

Version API 만들기

최신 버전을 확인하고 새 버전이 있다면 업데이트를 하도록 작은 버튼을 띄워주고 싶었다. 예전엔 블로그용 서버가 있어서 코드를 작성해서 올리면 되었겠지만 이제는 정적 블로그를 사용하고 있어서 아무래도 제한되는 부분이 있었다. 요구 사항은 이랬다.

그래서 Azure Functions를 선택하게 되었다.

module.exports = async function (context) {
    context.res = {
        headers: {
            'Content-Type': 'application/json',
        },
        body: {
            version: '1.1.4',
            tagline: 'New Theme: Mono',
            link: 'https://itunes.apple.com/app/tiny-tip-calculator/id1448227957?mt=8'
        }
    };
};

아쉽게도 현재 netlify에 물려 있는 도메인을 사용해서는 API 주소를 https로 사용할 수 없었다. Azure의 앱서비스 내로 도메인을 가져와야만 A 레코드로 사용할 수 있었고 CNAME은 https의 인증서에 문제가 있다고 접근이 되질 않았다. 큰 기능의 API도 아닌 탓에 그냥 기본으로 제공하는 azurewebsites.net의 서브 도메인을 사용했다.

Azure Functions의 비용은 앱 서비스 플랜과 종량제 플랜 중 하나를 고를 수 있는데 앱 서비스 플랜의 경우는 앱 서비스를 사용해서 function app을 실행하는 방식으로 앱 서비스만큼 비용을 내야 하고 종량제는 쓰는 만큼 비용을 지불하는 방식이다. 내 경우에는 크게 사용량이 많지 않을 것이라는 판단에서 종량제 플랜을 선택했다.

하지만 종량제 플랜에서는 Cold start가 너무 느렸다. 앱서비스 플랜은 인스턴스를 계속 띄우고 있기 때문에 항상 빠른 응답을 받을 수 있다. 하지만 종량제 플랜에서는 Function app 호출이 특정 시간 동안 없을 땐 해당 코드가 구동되는 인스턴스를 없엤다가 호출이 있을 때 인스턴스를 다시 생성해서 코드를 올려 구동한다. 그래서 인스턴스가 올라와 있는 상황에서 호출하면 warm start로 응답이 100ms 미만으로 매우 빠르지만, cold start는 인스턴스를 올리는 것부터 시작하기 때문에 더 오랜 시간이 걸렸다. 내 경우에는 코드가 의존성도 없고 매우 단순한데도 8초에서 22초까지 걸렸다. 비용은 코드를 실행해서 결과를 반환하기까지 시간에 대해서만 청구되기 때문에 비용적으로 문제는 없었다.

현재 사용량이 상당히 적은 상태인데 이 문제 때문에 앱 서비스 플랜을 사용하고 싶지는 않았다. 그래서 해결 방법을 찾아보니 그냥 계속 살아 있도록 function을 호출하는 방법이 있었다. 그래서 5분 간격으로 function app을 실행하도록 Timer 트리거를 추가했다.

{
  "bindings": [
    {
      "name": "myTimer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 */5 * * * *"
    }
  ]
}
module.exports = async function () {
    console.log('health checked');
};

만약 사용량이 많아지면 Azure Functions는 알아서 스케일링을 수행한다. 스케일링을 수행하면 새 인스턴스가 생성되면서 cold start가 다시 발생할 수 있지만, 현재는 두 API 모두 큰 자원을 소모하지 않고 있고 사용량도 적기 때문에 계속 warm start를 유지할 수 있었다. 만약 다른 인스턴스가 올라가서 다시 cold start가 감지된다면 그때는 앱 서비스 플랜으로 변경할 생각이다.

현재까지 비용 예측은 월간 $0.25 정도고 이조차도 Functions 호출 비용보다 코드가 저장된 공간인 스토리지의 비용이 대부분을 차지하고 있다.

빌드와 배포

빌드와 배포 과정에서 문제가 되었던 부분이 몇 있었는데 검색으로 쉽게 해소했다.


웹사이트 설정

웹페이지 색상을 선택하세요

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.