출석 알리미
수업 시작 몇 분 전에 폰이 먼저 울리고, 알림을 누르면 출석 인정 시간 카운트다운과 함께 대학 전자출결 앱이 열리는 서버 없는 앱 - 기능보다 OS 제약 세 개를 어떻게 처리했는지가 전부인 하루짜리 MVP.
- Expo / React Native
- TypeScript
- iOS / Android
- Local Notifications
- QA
Setup
- Problem
전자출결은 수업 시작 전후 짧은 시간 안에 학교 앱에서 출석확인을 눌러야 한다. 그 창은 이동 중이거나 강의실에 막 도착한 순간에 열렸다 닫히기 때문에, 놓치는 이유는 게을러서가 아니라 "지금이 그 순간"이라는 신호가 없어서다. 필요한 건 기능이 아니라 한 단계짜리 경로였다. 폰이 먼저 울리고, 그 알림을 누르면 곧장 전자출결 앱까지 열리는 것. 서버도 계정도 없이 폰 안에서만 돌아야 한다는 제약은 스스로 걸었다. 수집할 데이터가 0이면 개인정보 처리와 스토어 심사 부담이 처음부터 사라진다. 막상 만들어보니 어려운 건 이 경로 자체가 아니었다. iOS와 Android가 "먼 미래의 알림을 쌓아두는 것"과 "다른 앱을 대신 열어주는 것"을 각각 다른 방식으로 막아둔다는 사실이 문제였다. 이 케이스는 그 제약 세 개를 설계로 처리한 기록이다.
- Context
하루 안에 커밋 9개로 끝낸 개인용 MVP다. 스토어에 올리지 않았고, 실기기 검증 기록도 repo에 남아 있지 않다. 규모로 내세울 게 없다는 걸 먼저 적어둔다. 대신 기능 목록보다 "무엇이 나를 막을까"를 먼저 적고 시작했다. 로컬 알림만 쓰기로 한 순간 iOS의 앱당 대기 알림 64개 상한이 학기 단위 스케줄과 정면으로 부딪히고, 다른 앱을 열겠다고 정한 순간 Android 11+ 패키지 가시성과 "상대 앱이 공개 URL 스킴을 내놓지 않는다"는 사실이 걸린다. 이 셋이 화면 설계보다 먼저 정해진 설계 입력이었다. 앱 이름과 아이콘에는 학교 이름과 로고를 넣지 않기로 했다. 상표 심사 리스크라고 README에 이유를 적어뒀다.
- Users
저녁·주말 수업을 듣는 대학원 과정 재학생 본인. 스토어에 출시하지 않았으니 나 말고 쓴 사람은 없다. README에 같은 과정 원우 소수를 대상으로 한 비공개 테스트를 적어뒀지만 계획으로만 남아 있고 실행한 흔적은 없다.
- Hypothesis
출석을 놓치는 이유가 기억력이 아니라 "지금이 그 순간"이라는 신호와 앱까지 가는 한 단계 경로의 부재라면, 서버 없이 폰 안의 로컬 알림만으로 해결된다.
Build
- What I did
- 수업 시작 N분 전(기본 3분) 로컬 알림 - 각 회차를 개별 DATE 트리거로 예약하고, 앱이 포그라운드로 돌아올 때마다 전부 취소한 뒤 가까운 60개를 다시 예약해 큐를 굴린다
- 알림을 탭하면 출석 인정 시간 카운트다운 화면이 뜨고, 기본 설정이면 전자출결 앱이 이어서 열린다
- 학기(Term) 기반 교시 프리셋 10개를 체크박스로 적용 - 평일·주말 전반·주말 후반 날짜 범위를 프리셋 종류에서 파생 계산하고, 수업별 휴강 날짜를 캘린더로 지정하면 그 회차만 예약에서 빠진다
- 출결 자가 기록 - 날짜별 O(출석)/△(지각)/없음 3단계, 셀을 눌러 수동으로 순환 변경
- 전자출결 앱 실행 경로 - Android는 패키지명 인텐트(직접 만든 Expo config plugin으로 AndroidManifest에 <queries> 주입), iOS는 스킴 → 단축어 → 사용자가 직접 눌렀을 때만 스토어
- 시간 의존 로직을 src/
schedule.ts순수 함수로 분리하고 now를 인자로 주입 - 기기 없이 판정 경계를 테스트할 수 있게, node:test 기반 단위 테스트 17개 - 10초 뒤 테스트 알림 - 권한 → 발송 → 탭 → 앱 실행이라는 이 앱의 진짜 경로를 기기에서 한 번에 밟아보라고 넣은 자가 점검 기능
- Product decisions
- 서버·계정·로그인을 아예 만들지 않음 - 수집 데이터가 0이면 개인정보 처리와 스토어 심사 부담이 처음부터 사라진다. 대신 사용자 수·알림 도달률 같은 지표를 구조적으로 수집할 수 없다는 대가를 받아들였다
- 알림 예약 상한을 60개로 - iOS 앱당 대기 알림 64개 제한에 4개를 남겨뒀다. 상한을 꽉 채우면 다른 경로로 들어오는 알림에 밀려 학기 후반 회차가 조용히 잘려나간다
- 앱을 못 열었을 때 자동으로 스토어를 띄우지 않음 - 실행 실패의 대부분은 "설치가 안 됐다"가 아니라 "스킴·단축어 설정이 틀렸다"인데, 자동 스토어 이동은 사용자를 엉뚱한 곳으로 보내고 원인을 가린다. 코드 주석에 never automatically라고 못 박았다
- 학기 날짜를 하드코딩하지 않고 Term 타입으로 분리 - 프리셋의 전반·후반 범위가 Term에서 파생되므로, 학기가 바뀌면 날짜만 고쳐도 프리셋 10개가 따라온다
- 앱 이름과 아이콘에 학교 이름·로고를 넣지 않음 - 상표 심사 리스크. 개인 편의 도구 하나 때문에 심사에서 막힐 이유가 없다
- 출결 기록에 "학교 공식 출결과는 별개의 자가 기록"이라고 README에서 선을 그음 - 이 앱의 O/△를 학교 시스템의 출결과 같다고 믿는 순간, 도구가 도움이 아니라 위험이 된다
- QA considerations
- iOS 앱당 대기 알림 64개 상한에 걸려 학기 후반 알림이 조용히 사라지지 않는가 - 예약을 60개로 끊고, 앱이 포그라운드로 돌아올 때마다(AppState active) 전부 취소한 뒤 가까운 순으로 다시 예약해 큐를 굴린다. 그래도 "학기 중 가끔 앱을 열어줘야 한다"는 한계는 남고, 그 문장을 README에 그대로 적었다
- Android 11+ 패키지 가시성 때문에 패키지명 실행이 조용히 실패하지 않는가 - 직접 만든 Expo config plugin(plugins/
withAndroidQueries.js)으로 AndroidManifest에 <queries><package>를 주입하고, 중복 추가 방지 체크까지 넣었다 - 상대 앱이 공개 URL 스킴을 내놓지 않는데 실행 경로가 하나뿐이면 전부 실패한다 - iOS는 canOpenURL로 스킴 확인 → 실패 시 단축어 실행 → 그래도 실패면 사용자가 "스토어 열기"를 직접 눌렀을 때만 스토어로. 실패 문구도 단축어 이름을 넣은 경우와 안 넣은 경우로 갈라, 다음에 뭘 해야 하는지 알려준다
- 시간 판정이 기기 시계에 묶여 테스트가 불가능해지지 않는가 - 판정 로직을 순수 함수로 빼고 now를 인자로 주입해, 18:57 before / 19:00·19:15 open / 19:16·19:30 late / 19:31 closed 경계를 양쪽 다 고정했다. lateUntilMin을 지정하지 않으면 late 구간 자체가 사라지는 기본 동작도 별도 케이스로 검증
- 예약되는 회차 수 자체가 맞는가 - 화요일 16회 중 휴강 1회를 뺀 15회, 토요일 전반 8회와 후반 8회, 학기 중간에 개강하는 과목의 첫 회차와 마지막 회차를 테스트로 박아뒀다
- 자동 기록이 사용자의 수동 수정을 되돌리지 않는가 - 출석 기록을 idempotent로 두어 이미 기록된 회차는 덮어쓰지 않고, 알림 응답은 처리한 notification identifier를 기억해 콜드스타트·백그라운드 복귀 시 같은 알림이 두 번 처리되는 것을 막는다
- 구버전 저장 데이터와 손상된 JSON에서 앱이 죽지 않는가 - lateUntilMin 키가 없으면 구버전 설정으로 판단해 타이밍·iOS 관련 키만 새 기본값으로 되돌리고 나머지는 유지, 손상된 AsyncStorage JSON은 try/catch로 기본값 복구, status가 없는 옛 출결 기록은 present로 채운다
Outcome
- Metrics
- 단위 테스트 17개 -
schedule.test.ts10개 +presets.test.ts7개 (describe 7블록, assert 호출 43회) - TypeScript 소스 1,847줄 (
App.tsx288줄, config plugin 18줄, 테스트 파일 포함) - 화면 6개(홈·교시 선택·수업 편집·출결 현황·알림·설정), 교시 프리셋 10개
- 알림 예약 상한 60개 - iOS 앱당 대기 알림 64개 제한 대비 4개 여유
- 런타임 의존성 12개, 서버 엔드포인트 0개, 외부 API 호출 0개, 수집 데이터 0
- 커밋 9개, 전부 2026-08-30 하루 안에 (18:30 ~ 22:05 KST)
- 스토어 미출시 - Play 스토어 404, App Store 조회 결과 0건. 사용자 수·알림 도달률·출석 성공률은 전부 측정 전이고, 서버가 없어 수집할 방법도 없다
- 단위 테스트 17개 -
- Result / Learning
하루 만에 개인용 MVP 한 벌이 나왔다. 알림 예약, 알림 탭 처리, 출석 인정 카운트다운, 전자출결 앱 실행까지 코드 경로는 이어져 있다. 여기까지가 만든 것이고 스토어 출시도 다른 사용자도 없다. 실기기에서 알림이 실제로 울리고 앱이 열렸는지는 repo에 스크린샷·로그·리포트가 하나도 없어 기록으로 증명할 수 없다. 그래서 "서버 없이 로컬 알림만으로 출석을 안 놓친다"는 가설도 검증 전이다. 가설을 확인하려면 나 말고 다른 사람이 한 학기를 써봐야 하는데 거기까지 가지 않았다. 배운 건 기능 목록의 크기와 설계 난이도가 별개라는 것이다. 화면 6개짜리 앱인데 실제로 시간을 쓴 자리는 전부 "OS가 못 하게 막아둔 것"이었다. 알림을 무한정 쌓아둘 수 없고, 다른 앱이 깔려 있는지 마음대로 볼 수 없고, 상대 앱이 문을 열어주지 않는다. 기능은 반나절이면 붙지만 이 셋은 각각 다른 우회 경로를 요구했고, 그 우회의 대가(가끔 앱을 열어야 한다, 단축어를 직접 만들어야 한다)를 사용자에게 정직하게 적어두는 것까지가 설계였다. 가장 값이 나간 결정은 시간 로직을 순수 함수로 빼고 now를 주입한 것이다. 그 한 번의 분리 덕분에 19:15와 19:16, 19:30과 19:31을 기기 없이 고정할 수 있었다. 하루짜리 프로젝트에 테스트 17개가 붙은 이유는 시간이 남아서가 아니라, 테스트할 수 있는 모양으로 먼저 잘라뒀기 때문이다.
- Retrospective
- iOS 기본값에 특정 URL 스킴 문자열을 박아뒀는데, 정작 README 본문은 "공개된 스킴이 없어서 단축어를 만든다"고 반대로 설명한다. 코드의 기본값과 문서가 어긋나 있고 그 스킴이 실제로 동작하는지 확인한 기록도 없다. 근거가 없으면 기본값으로 넣지 말았어야 했다.
- Android 실행 1단계에서 IntentLauncher.openApplication을 await 없이 동기 try/catch로만 감쌌다. 이 API가 Promise를 반환한다면 거부가 catch에 걸리지 않고 곧장 성공으로 보고될 수 있다. 실패가 성공으로 보고되는 건 가장 나쁜 종류의 버그라, 실기기 확인을 다음 작업 목록 맨 위에 올려뒀다.
- 하루에 몰아 끝낸 대가로 실기기 검증 기록이 한 장도 없다. 순수 함수는 테스트로 덮었지만 권한 → 발송 → 탭 → 앱 실행이라는 이 앱의 진짜 경로는 단위 테스트가 닿지 않는 자리다. 그걸 확인하라고 10초 테스트 알림까지 만들어놓고 결과를 남기지 않은 게 앞뒤가 안 맞는다.
eas.json과 프로젝트 설정은 넣어뒀지만 빌드 산출물이나 로그도 남아 있지 않아 빌드가 돌았는지조차 기록으로는 말할 수 없다. LICENSE가 템플릿 그대로이고package.json name이 slug와 어긋나 있는 것도 같은 흔적이다.
- Tech stack
- Expo SDK 57
- React Native 0.86.3
- React 19.2.3
- TypeScript 6.0
- expo-notifications (로컬 DATE 트리거)
- expo-intent-launcher
- expo-linking
- AsyncStorage
- node:test + tsx
- Expo config plugin (AndroidManifest)
- EAS Build