
부부가 함께 쓰는 임신·육아 기록 앱
부부가 함께 쓰는 임신부터 돌까지의 육아 기록 앱
두 번 만들 수 없는 화면 113개
처음 받은 요구사항은 앱 하나였습니다. 임신 주차를 세는 시기부터 아기가 돌을 지날 때까지, 부부가 같은 아기 데이터를 나눠 보고, 수유·수면·배변을 스톱워치로 기록하고, 국민건강보험 영유아 건강검진과 예방접종 이력을 손으로 옮겨 적지 않고 끌어오고, 그렇게 쌓인 일기를 마지막에는 종이책으로 인쇄해 파는 것까지요.
그게 화면 113개짜리 앱이었습니다. 라우터를 세어 보면 타임라인 기록 10종, 커뮤니티, 상품 리뷰, 핫딜 레이더, 매거진, 쿠폰·포인트·주문서까지 들어 있어요. 여기에 한국어·영어·일본어 세 언어와 iOS·Android 동시 출시가 붙었습니다.
조건이 하나 더 있었습니다. 공지 문구나 이벤트 배너 같은 콘텐츠는 앱을 새로 배포하지 않고 어드민에서 직접 바꿀 수 있어야 했습니다. 그래서 먼저 정해야 했던 것은 개별 기능이 아니라 이 앱의 화면을 무엇으로 그릴 것인가였습니다.
이 글에서는 그 선택 하나와, 거기서 이어지는 일기책 인쇄만 다룹니다. GraphQL 단일 스키마와 코드 생성, Clayful과 아임포트를 붙인 커머스, 어드민과 배포 파이프라인은 각각 따로 다룰 이야기라 여기서는 접습니다.

네이티브는 껍데기, 화면은 전부 웹
화면 113개를 전부 Next.js로 만들고, 네이티브 앱은 웹뷰 셸과 브릿지만 남겼다
목록·상세·작성·신고처럼 폼과 리스트가 대부분인 화면을 두 플랫폼에 두 번 만들 이유가 없었고, 문구와 레이아웃 수정이 스토어 심사를 타지 않아야 했습니다. 배포는 ECS 서비스 갱신 한 번으로 끝납니다. 소셜 로그인, 인앱결제, 사진 저장, 공유 시트, 홈 위젯은 웹에서 못 합니다. 그래서 네이티브에 넘길 일을 BridgeActions 12개로 좁게 못 박고, iOS는 window.webkit.messageHandlers.bridge, Android는 window.bridge.sendMessage로 갈라 감쌌어요.
화면 전환을 브라우저 히스토리가 아니라 네이티브 스택(NAV_PUSH)으로 처리했다
웹뷰 안에서 pushState로 화면을 갈면 iOS의 스와이프 백 제스처와 화면 전환 애니메이션이 죽고, 목록에서 상세로 갔다 돌아올 때 스크롤 위치가 날아갑니다. NAV_PUSH 한 번에 웹뷰 하나를 쌓으면 앱이 네이티브처럼 움직여요. 화면 하나가 곧 자바스크립트 컨텍스트 하나가 됩니다. 화면 사이에 값을 넘길 방법이 통째로 사라졌어요.
일기책 PDF 조판을 API 서버가 아니라 AWS Batch 잡으로 떼어 냈다
200~400 페이지를 렌더하고 병합하는 데 수 분이 걸립니다. 이걸 ECS Fargate의 API 태스크가 붙들면 그 시간 동안 다른 GraphQL 요청이 밀려요. submitJob으로 던지고 API는 즉시 응답합니다. 잡이 도는 동안 사용자에게 보여 줄 진행률이 없어서, diary_print 테이블에 status와 currentProcessingPage를 남기고 운영 화면이 그걸 읽습니다.

웹뷰 스택과 GraphQL 한 덩어리
전체를 한 줄로 줄이면 이렇습니다. 네이티브는 웹뷰를 쌓고 쿠키를 심는 껍데기, 화면은 Next.js, 데이터는 GraphQL 하나. 앱을 켜면 네이티브 셸이 첫 웹뷰로 /를 엽니다. 로그인 시 네이티브가 SET_COOKIE로 AUTH_TOKEN과 LOGIN_METHOD를 심어 주는데, 이후 쌓이는 모든 웹뷰가 같은 origin이라 이 쿠키를 공유합니다. 그래서 화면마다 토큰을 다시 넘길 필요가 없어요. API는 NestJS 하나에 모듈 36개가 들어 있고 MySQL을 TypeORM으로 씁니다. 무거운 일 하나만 프로세스 밖으로 나가는데, 그게 일기책 조판입니다. AWS.Batch.submitJob으로 diary-print-queue에 던지면 같은 NestJS 코드베이스를 다른 엔트리(batch-diary-merger.ts)로 빌드한 컨테이너가 떠서 페이지를 만들고, 병합한 PDF를 S3에 올린 뒤 사용자에게 메일을 보냅니다.

펼침면 규칙을 지키며 일기 300장을 책으로 앉히기
일기를 날짜순으로 정렬해서 페이지 템플릿에 차례로 채우고, 만들어진 PDF들을 순서대로 병합하면 될 것 같습니다. 사진 페이지 하나, 글 페이지 하나, 다음 일기, 다음 일기. 화면에서 보면 아무 문제가 없어요.
페이지를 넣는 통로를 addPage 하나로 좁히고, 거기에 커서를 뒀습니다. getCurrentPageLeftRight()가 지금까지 쌓인 페이지 수를 length % 2로 보고 다음 장이 왼쪽인지 오른쪽인지 알려 주면, 사진 페이지도 글 페이지도 좌·우 두 벌의 템플릿 중 맞는 쪽을 골라 씁니다.
그 위에 책의 규칙을 얹었습니다. 임신 주차 간지는 항상 오른쪽에서 시작해야 해서, 커서가 오른쪽이면 빈 왼쪽 페이지를 먼저 끼워 한 칸 밀어 줍니다. 같은 날의 엄마 일기와 아빠 일기가 왼쪽에서 끝나면 오른쪽에 마감 페이지를 넣어 다음 일기가 새 펼침면에서 시작하게 하고요. 글이 한 장을 넘치면 렌더러가 overflowingTextData에 hiddenText, nextTemplateId, nextNodeId를 담아 돌려주는데, 이걸 while로 소진될 때까지 이어 붙입니다. 사진은 4장까지 한 면, 5장이면 4+1이 아니라 3+2로 갈라 양쪽 면에 겁니다.
페이지 PDF는 templateId와 데이터의 SHA-512 해시로 print_cache에 캐시합니다. 커버, 헛장, 마감 페이지, 주차 간지처럼 값이 같은 페이지는 처음 한 번만 만들면 돼요.
책은 페이지의 배열이 아니라 펼침면의 배열입니다. 화면에서는 왼쪽·오른쪽이 아무 의미가 없지만, 인쇄물에서 좌우가 한 칸 어긋나면 사진과 그 사진에 붙은 글이 등을 지고 갈라집니다. 사용자가 받아 보는 건 종이라서 되돌릴 수가 없어요.
분기를 아무리 늘려도 좌우가 어긋나지 않는 이유는, 페이지가 늘어나는 경로가 addPage 하나뿐이기 때문입니다. 사진이 몇 장이든, 글이 몇 장을 넘치든, 주차가 몇 개든 커서는 언제나 실제 쌓인 장 수에서 계산됩니다. 조건을 세는 대신 상태를 한 곳에 모은 거죠. 캐시도 같은 성질을 씁니다. 페이지 PDF가 입력 데이터의 순수 함수라서, 해시가 같으면 결과가 같다고 믿어도 됩니다.
화면마다 웹뷰가 따로인데 값은 어떻게 돌려받을까
보통은 전역 스토어에 넣거나, Context로 감싸거나, 안 되면 localStorage에 던져 두고 돌아와서 읽습니다.
이 앱에서는 셋 다 틀립니다. NAV_PUSH 한 번에 웹뷰가 하나씩 쌓이니 화면마다 자바스크립트 컨텍스트가 아예 다르거든요. 서버에 flash_data 테이블을 두고 (userId, key, json)으로 넣습니다. getFlashData는 한 건을 꺼내면서 곧바로 지웁니다. 키별 pop 콜백과 _app.tsx의 refetchQueries까지, 화면 사이를 오가는 흐름은 따로 다룰 이야기라 여기서는 접습니다.
서버에 한 번 읽으면 사라지는 값을 두면 이 문제가 통째로 없어집니다. 웹뷰가 죽었다 살아나도, 사용자가 앱을 껐다 켜도 같은 결과를 받아요.
유저가 할 수 있는 일
테스트 없음, 소스에 박힌 키
솔직하게 적어 둡니다.
테스트가 한 줄도 없습니다. jest, msw, factory.ts, next-page-tester까지 devDependencies에 다 들어가 있는데 *.spec.ts가 프런트에도 서버에도 하나도 없어요. 조판 로직처럼 분기가 촘촘하고 눈으로 확인하기 어려운 코드일수록 테스트가 필요했는데, 출시 일정에 밀려 계속 미뤘습니다. 다시 만든다면 getCurrentPageLeftRight와 페이지 조립 함수만이라도 순수 함수로 떼어 스냅샷 테스트를 붙였을 겁니다.
AWS와 Clayful 자격증명이 소스에 그대로 박혀 있습니다. print.service.ts와 health-check.service.ts의 new AWS.S3({ credentials }), clayful.service.ts의 Clayful.config가 그렇습니다. 지금이라면 태스크 역할과 파라미터 스토어로 뺍니다.
인쇄 예상 페이지 수가 실제와 다릅니다. 주문서는 estimateDiaryBookPages가 글자 733자를 한 페이지, 사진 4장을 한 페이지로 잡아 어림한 값으로 가격을 매기는데, 실제 조판은 좌우 보정 페이지와 마감 페이지를 더 넣습니다. 사용자가 본 숫자와 배송되는 책의 두께가 어긋날 수 있어요. 조판기를 미리 한 번 돌려 페이지 수를 확정한 뒤 견적을 냈어야 했습니다.















