안녕하세요, 이번에 앱을 하나 만든 뒤 시작부터 배포까지 회고할 겸 글을 작성해보려 합니다.
개발자로 직업을 선택하고 개인의 서비스를 갖고 싶다는 생각을 가슴 한편에 늘 지니고 살아가고 있었습니다.
해보자는 마음을 먹어도 어떤 서비스를 해보지? 라는 고민에 시작이 쉽지가 않았네요.
1인 개발자 or 사이드 프로젝트를 하시는 개발자 분들의 글을 읽으며 어떤 서비스를 개발하기로 마음을 먹었는지 알아보던 와중에 이런 글귀가 눈에 들어왔습니다.
내가 필요한 서비스를 만들자!!
누군가 필요할 것 같은 뜬구름 잡는? 서비스를 만들어 보는 것보단 내가 생활하며 필요한 부분을 서비스로 만들어보면 동기부여도 되고 더 흥미가 생기지 않을까 생각했습니다.
일주일 간의 생활 패턴을 생각해 봤을 때 평일은 출근, 일, 퇴근 반복된 루틴.. 주말은 근교 놀러 가기 뭔가 특별하진 않더라고요. 그러다 평일과 주말에 늘 이용하는 지하철이 스쳐 지나갔습니다.
지하철을 이용할 때 불편한 게 뭐가 있을까?
큰 주제는 정해졌고 다음 의식의 흐름은 어떤 게 불편할까? 였고 다시 루틴을 생각해 봤습니다.
정해진 시간에 눈을 뜨고 출근 준비를 하며 약간이라도 늦장 부리면 늘 같은 시간대에 타던 전철 시간이 촉박해지고 그러다 보면 무작정 달렸습니다. 열차를 놓칠까 열심히 뛰어 평소 시간에 간신히 도착하면 이미 열차가 지나갔거나 아직 한참 뒤에 있는 경우도 종종 있었죠.
열차의 실시간 위치를 확인할 방법이 없을까?로 생각이 문득 들었습니다. 실시간으로 열차의 위치를 알 수 있으면 내가 언제쯤 도착하면 열차를 탈 수 있을지 가늠 할 수 있을 것 같았습니다.
이러한 비슷한 기능을 제공하는 App이 있는지 스토어를 먼저 찾아봤을 때 역시나 이런 고민을 다른 분들도 하고 계셨고 부지런한 개발자 분들은 이미 만들어 출시한 앱들도 존재하더군요..ㅠㅠ 예를 들면
- 내 위치 기반으로 가까운 역을 찾아 현재 진입 중인 열차의 정보를 알려주는 앱.
- 설정한 전철역에 가장 가까이 도착 예정인 열차의 도착 시간을 알려주는 앱.
정말 유용하고 사용자에게 도움을 주는 App들이 많았습니다.
다시 또 처음으로 돌아가 주제를 정해야 하는가 생각을 해보다가 접기엔 뭔가 아쉬운 느낌이 들어 조금 더 고민을 하게 되었고 그 결과 위에 훌륭한 App들은 현재 내가 지정한 역들과 가장 근접한 열차의 정보를 제공하는 게 핵심인 느낌이 들었습니다.
그럼 나는 특정 역이 아닌 특정 노선으로 해보자는 생각을 했고, 사용자가 원하는 노선의 전체 운행 중인 열차의 실시간 위치를 제공해 보자는 생각을 했습니다.
그렇게 "수도권 지하철 노선의 운행 중인 열차의 실시간 위치 시각화" 하는 사이드 프로젝트를 시작하게 되었습니다.

어떤 걸로 만들어 볼까?
지하철 열차의 실시간 위치라는 아이템도 정해졌고 어떤 언어를 사용해서 서비스를 마늘지 고민할 시간이 왔습니다. 7~8년간 백엔드 개발자로 API만 만들다 보니 UI에 자신이 없었습니다. (옷장에 옷도 90%가 검은색 이더라구요 ㅎㅎ;;)
이런 약점을 보완해줄 수 있어야 했고 IOS, Android 앱 개발 경험이 전무하기에 이왕 하는거 하나의 언어로 2가지를 만들어 줄 수 있어야 했습니다.
검색을 통해 Flutter라는 것을 알게 되었고 pub.dev에 수 많은 위젯 라이브러리들이 존재 했고 잘 활용하면 내가 부족한 것을 보완 해주겠다 싶어 선택하게 되었습니다.
[ Flutter 장점 GPT ]
1. 크로스 플랫폼 개발: Flutter는 하나의 코드베이스로 iOS 및 Android용 애플리케이션을 개발할 수 있습니다. 이는 개발 시간과 비용을 절약하며, 애플리케이션의 일관된 사용자 경험을 제공합니다.
2. 고성능 UI: Flutter는 네이티브 수준의 성능을 제공합니다. 플랫폼별 UI 요소가 아니라 모든 것이 위젯으로 이루어져 있으므로 빠른 UI 렌더링과 부드러운 애니메이션을 제공합니다.
Flutter를 언어로 선택하게 되었고 이제 서비스 구조를 생각할 시간이 왔습니다.
실시간 열차의 위치 데이터는 공공 데이터를 활용하면 되기에 문제가 없었습니다. 다른 문제는 호선의 정보, 역의 정보 데이터를 어디에 저장해두냐 이것이 문제 였습니다.
처음 생각으로는 더럽긴 하지만 하드코딩으로 호선과 전철역의 정보를 코드상 정의해서 개발을 시작했는데요, 수도권 지하철 역만 750여개가 나오더라구요..;;
그리고 미래에 있을 새로운 노선, 전철역 신설되거나 기존에 있던 이름이 바뀌는 경우 하드코딩 된 코드를 수정해서 App 심사를 통해 재배포를 해야되는 유지보수의 문제가 있었습니다.
어쩔 수 없이 Cloud Server를 구성해야 편하겠다는 생각이 들었고 가장 먼저 떠오른건 역시나 만만한 AWS에 MySQL로 구성하자 였습니다.
한가지 걸리는 점은 월급쟁이 직장인이라 숨만 쉬어도 나가는 "서버 비용" 이 아깝더라구요. 최대한 금전적 부담없이 하고싶은 마음에 다른 대안이 있을지 찾아 보게 되었고 Supabase를 알게 되었습니다.
[ Supabase 장점 GPT ]
1. 실시간 데이터베이스: Supabase는 실시간 데이터베이스를 제공하여 실시간으로 데이터를 동기화하고 애플리케이션의 상태를 업데이트할 수 있습니다.
2. 인증 및 권한 부여: Supabase는 사용자 인증 및 권한 부여를 손쉽게 구현할 수 있는 기능을 제공합니다. 이를 통해 개발자는 복잡한 인증 시스템을 구축할 필요 없이 안전하게 사용자 데이터를 관리할 수 있습니다.
3. 실시간 HTTP API: Supabase는 데이터베이스에 대한 실시간 HTTP API를 제공하여 클라이언트 애플리케이션에서 데이터를 쉽게 검색하고 조작할 수 있습니다.
4. 서버리스 함수: Supabase는 서버리스 함수를 지원하여 백엔드 로직을 실행할 수 있습니다. 이를 통해 클라이언트 애플리케이션과 서버 간의 통신을 최소화하고 더 효율적인 애플리케이션을 개발할 수 있습니다.
Flutter에서 Supabase를 연동하는 부분도 굉장히 간단합니다. (pub.dev flutter_supabase 참고)
1. supabase_flutter 의존성 추가
# supabase 라이브러리 적용
flutter pub add supabase_flutter
2. main.dart에 초기화 선언
await Supabase.initialize(
url: TrainConsts.SUPABASE_URL,
anonKey: TrainConsts.SUPABASE_ANON,
storageOptions: StorageClientOptions(retryAttempts: 10),
realtimeClientOptions: const RealtimeClientOptions(
logLevel: RealtimeLogLevel.info
),
);
3. select 코드
Future<SettingEntity?> getSettingVersion() async {
final client = Supabase.instance.client;
try {
final data = await client
.from('settings')
.select()
.order('id', ascending: false)
.limit(1)
.single();
if (data.isNotEmpty) {
var response = SettingEntity.fromJson(data);
return response;
}
return null;
} on PostgrestException catch (error) {
print('error code ${error.code}');
return null;
}
}
Flutter를 통해 앱 개발을 시작하게 되었고 몇 줄 안되는 코드로 Supabase를 연동하여 호선과 전철역에 대한 원천 데이터를 두는 구조로 작업을 시작하게 되었습니다. :)
Supabase를 어떻게 활용해야 효율적으로 썼다고 소문이 나려나..
Supabase에 호선과 역에 대한 정보를 먼저 저장해 두었고, 데이터를 어떻게 활용해야 될지 고민을 하게 되었습니다.
무료로 기생(?)하며 사는 서버에 너무 빈번하게 요청하는게 미안하기도 했고 언제 다른 DB로 변경하게 될지 모르기 때문에 모든 질의를 Supabas에 하는 구조는 지양하는게 좋겠다고 생각이 들어 앱 초기 설정을 위한 데이터만 제공하고 휴대폰 디바이스의 SQFlite를 활용해 저장하는 구조로 결정했습니다.

그러기 위해선 Sqflite와 Supabase에 각각 매칭되는 3가지의 Table이 필요했습니다.
- line (호선 정보 테이블)
- station (전철 역 정보 테이블)
- setting (설정 정보 테이블)
3번의 Table은 왜 필요한지 의문이 드실 수 있습니다. setting 테이블은 version 정보를 기재한 1개의 Row만 갖고 있습니다.

기본으로 설정된 id, created_at 컬럼은 제외하고 version만 정의해서 사용하게 되는데요, 앱을 로딩하게되면 Supabase의 setting 데이터를 우선적으로 1회 조회를 통해 version 정보를 획득해 오고 사용자의 디바이스에 저장된 setting 정보를 조회하게 됩니다.
그 후 Sqflite의 setting version과 Supabase의 setting version을 비교하여 다르면 line, station Table을 Supabase를 통해 읽어와 Sqfilte의 line, station Table의 데이터를 갱신하게 됩니다.
- 앱 로딩 시 Supabase의 setting Table의 version 정보를 조회 한다.
- Sqflite의 setting Table의 version 정보를 조회 한다.
- 2개의 version 정보가 다른지 비교 한다.
- version이 다를 경우 -> line, station 데이터 갱신
void _syncSetting() async {
var setting = await SqlSettingCrudRepository.getList();
var version = await SupabaseRepository().getSettingVersion();
print('station list sync.... remote version [${version!.version}]');
if (setting.isEmpty) {
if (version.version != '') {
SqlSettingCrudRepository.upsert(version.version);
SqlSyncRepository.sync();
}
} else {
print('local version ${setting.first.version}');
if (version.version != '' && (version.version != setting.first.version)) {
SqlSettingCrudRepository.upsert(version.version);
SqlSyncRepository.sync();
}
}
}
static void sync() async {
// 1. supabase DB line search
var lines = await SupabaseRepository().getLine();
print('1. supabase DB line search ${lines.length}');
// 2. local db line search
var deletedList = await SqlLineCrudRepository.getList();
print('local db line search ${deletedList.length}');
deletedList.forEach((lineEntity) async {
// 3. local db line delete
await SqlLineCrudRepository.deleteEntity(lineEntity);
});
// 4. supabase search entity => local db line create
for (var line in lines) {
SqlLineCrudRepository.create(LineEntity(line: line.line, subLine: line.subLine, seq: line.seq));
}
// 5. local DB Line search
var dbLines = await SqlLineCrudRepository.getList();
print('5. local DB Line search ${dbLines.length}');
dbLines.forEach((line) async {
// 6. local DB Station search
var deletedList = await SqlStationCrudRepository.getListByLine(line.line);
print('6. local DB Station search ${deletedList.length}');
deletedList.forEach((deleted) async {
// 7. local DB Station delete
await SqlStationCrudRepository.deleteEntity(deleted);
});
// 8. supabase DB Station search
var list = await SupabaseRepository().getStations(line.line, line.subLine);
print('8. supabase DB Station search ${list.length}');
for (var station in list) {
// 9. supabase search entity => local DB Station create
await SqlStationCrudRepository.create(StationEntity(
code: station.code,
line: station.line,
subLine: station.subLine,
seq: station.seq,
stationCode: station.stationCode,
stationName: station.stationName,
displayName: station.displayName));
}
});
}
static void upsert(String version) async {
var list = await getList();
var db = await SqlDatabase().database;
if (list.isNotEmpty) { // update
var entity = list.first;
print('version update >> ${version} ${entity.version}');
var updated = entity.clone(id: entity.id, version: version);
await db.update(SettingEntity.tableName, updated.toJson(),
where: '${SettingEntityFields.id} = ?',
whereArgs: [entity.id]);
} else { // create
SettingEntity entity = SettingEntity(version: version);
print('version insert >> ${entity.version}');
var id = await db.insert(SettingEntity.tableName, entity.toJson());
}
}
그 이유가 위에 언급한 "새로운 노선, 전철역 신설되거나 기존에 있던 이름이 바뀌는 경우" 에 대한 대응을 위해 위와 같은 구조로 진행하게 되었습니다.
제가 24년 3월 30일 App Store, Play Store에 심사가 통과되어 릴리즈가 되었는데요, 몇일 뒤 GTX-A 노선이 신설 되는 일이 발생하게 되었습니다.
저는 당황하지 않고 Supabase에 line과 station에 GTX-A의 노선에 대한 정보와 전철역 정보를 Insert 하고 setting Table의 version을 올려 앱 배포 없이 노선을 추가할 수 있었습니다. :)

다만... 그 동기화 하는 과정이 순탄치는 않았습니다. 앱이 실행 된 뒤 호선과 역정보 데이터를 디바이스에 저장하는 1초의 시간이 필요했습니다. Flutter에서 외부 API 호출할때 비동기로 조회를 하도록 구현했는데요, 그 과정에서 데이터가 생기기 전 화면을 먼저 그리는 경우가 발생하게 되더라구요.
1초.. 짧다면 짧고 길다면 긴 시간..
거부감 없이 확보할 수 있는 방안이 어떤게 있을까 고민끝에 Splash를 선택하게 되었습니다.
Splash를 2초간 띄운 뒤 사라지게 하고 그 2초간 역 정보를 셋팅하는 널널한 시간을 확보 할 수 있었습니다. Splash 역시 pub.dev에서 제공을 하는데요. 저는 위의 라이브러리를 사용하지 않았습니다. 해당 라이브러리가 손쉽게 기능을 적용할 순 있지만 제가 Flutter(Dart) 언어가 처음이라 해당 기능에 커스터마이징이 쉽지가 않았습니다.
그리고 적용 후 라이브러리를 제거하는 과정도 쉽지가 않더라구요 (저는 그냥 프로젝트 삭제하고 다시 만들었네요.)
스택오버 플로우에도 라이브러리를 지워도 왜 남아있냐는 질문 글도 많았습니다.

그래서 저는 위의 라이브러리를 사용하지 않고 Visibility Widget을 활용했습니다.
# main.dart
bool _showSplash = true;
...
@override
void initState() {
# 2초 뒤 false로 변경
Future.delayed(const Duration(seconds: 2), () {
setState(() {
_showSplash = false;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
return MaterialApp(
!_showSplash
? Scaffold(
appBar: ...
bottomNavigationBar: ...
body: ...
: Visibility(
visible: _showSplash,
child: Container(
color: Colors.white,
child: Center(
child: Image.asset('assets/app_icons.jpeg'),
),
),
),
)
: Visibility(
visible: _showSplash,
child: Container(
color: Colors.white,
child: Center(
child: Image.asset('assets/splash.jpeg'),
),
),
),
]));
}
실시간 열차 위치 페이지 UI
이제 핵심 기능에 대해 고민할 시간이 찾아 왔네요.
의식의 흐름대로 지하철이니 역시 레일을 깔아야지! 해서 깔았고 백그라운드는 역시 블랙이지! 그리고 결과는 처참했습니다. 앞서 말씀드렸듯이 UI를 어려워하는 백엔드 개발자이므로... 나름 잘 나왔다고 생각했지만 팀원이 그러더군요...
오 갤로그(고전게임) 인가요?

마음의 상처를 입고 열차 위치 페이지를 갈아 엎기 시작했습니다. 그렇게 작업을 하던 중 생각지도 못한 2가지의 사실이 있었습니다.
1. "혹시 열차의 종류가 어떤게 있는지 아시나요?"
ㄴ 저는 일반(완행), 급행 2가지 의 열차 종류만 있는줄 알았는데 특급 이라는 열차가 존재하더군요.
2. "한 역에 같은 방향으로 여러대 열차가 지나갈수 있다."
ㄴ 문득 생각해보니 "뒤에 있는 급행을 보내기 위해 장시 정차..." 이런 안내 멘트가 생각이 나네요.
ㄴ 실제로 데이터를 조회해 보니 같은 역에 여러대의 다른 열차가 존재 하더군요.



그렇게 열차위치 가시화 컨셉에 맞게 열차의 종류도 색깔로 가시화 할 수 있게 작업을 진행하게 되었고, 팀원들의 피드백을 통해 완성한 열차 실시간 열차 위치 페이지 입니다. :)
노량진 역이 빨간색으로 표시된 건 "역" 즐겨찾기를 통해 진입하면 스크롤 위치가 해당 역으로 맞춰지고 빨간색 역 이름으로 시각적으로 표현 했습니다.


개인적으로는 제 손에서 디자인이 이보다 더 잘 나오긴 힘들 것 같다는 판단하에 ㅎㅎ.. 부가적인 편의 기능 작업으로 넘어갔습니다.
위의 페이지(실시간 열차 위치)에서 전척역을 클릭하게 되면 아래와 같이 해당 역에 접근 중인 열차들의 예상 도착 시간을 제공해 드리는 기능을 추가했고, 해당 역의 전체 시간표를 제공하는 기능을 붙여 편의 기능을 추가해 두었습니다.




다음은 메인 페이지인 호선 페이지 입니다. 호선은 역 검색이 가능하도록 기능을 붙여두었습니다.
역 검색 후 바로 즐겨찾기가 가능하도록 버튼도 추가해 두었구요.!
문의 기능 개선
제가 만든 앱은 회원가입이 없습니다. 회원가입까지 넣으면 빠르게 배포하기엔 무리가 있어 회원가입 기능은 넣지 않았습니다.
하지만 단방향이 아닌 양방향으로 사용하시는 분들과의 상호작용은 하고싶은 욕심이 있었고, 그 중 하나가 문의 내역에 대한 답변을 제공하는 부분이였습니다.
앱 배포 첫 버전에서는 사용자가 문의 후에 그 어느곳 에서도 본인이 문의한 내역을 확인할 방법이 없었습니다. 문의 내역을 보여줄 필요가 없었거든요..
제가 문의 내역에 대해 데이터 처리라던지 기능개선을 해도 회원가입 절차가 없다보니 특정 회원을 구분해서 개인화 답변을 전달드릴 방벙이 없었습니다.
앱 배포 후에 주변 팀원이 피드백을 줄때 이런말을 하던구요.
기도(문의)하면 들어주는 구조네요?
앗....?? 그렇게 사용자와의 상호작용에 대해 고민이 생겼습니다.
문의를 하면 답변을 해야 했고 답변을 전달할 방법이 필요 했습니다. 그래서 공지사항 기능을 넣어 전체 사용자에게 보내는걸 시작으로 작업을 했는데요. 그래도 개인화된 문의인 경우 전체 공지로 드리기에는 부적합한 내용이 있을 수 있기에 공지사항과 분리해서 답변을 전달하는 구조를 고민하게 되었습니다.
회원가입이 없어 특정한 사용자를 타겟팅할 방법이 없어 고민 하던 중 Supabase에 UUID를 생성해서 관리해주는 컬럼을 정의할 수 있는걸 확인하고 UUID를 활용하기로 했습니다.
문의 시 Supabase에 데이터를 저장하고 저장할때 자동으로 UUID가 채번이 되고 문의 결과를 사용자가 전달을 받을때 UUID 값을 Sqflite를 활용해 디바이스에 저장했습니다.
제가 답변을 하면 사용자가 앱 실행 시 답변이 없는 문의 내역에 대해서 Supabase에 답변이 달렸는지 UUID를 통해 조회를 하도록 구현했고 그렇게 개인화 답변을 타겟팅 할 수 있게 되었습니다.
void _setInquiry() async {
Future.delayed(const Duration(seconds: 2)); // delay를 주지 않으면 widget이 생성 되기 전에 setState 메소드를 사용함으로 오류 발생할 수 있어 방어로직 차원.
await SqlInquiryCrudRepository.getList().then((localEntities) {
localEntities.forEach((localEntity) async {
var uuid = localEntity.uuid ?? '';
var answer = localEntity.answer ?? '';
if (uuid != '' && answer == '') {
await SupabaseRepository().getInquiryByUUID(uuid).then((inquiry) => {
inquiry.forEach((supabaseEntity) {
var answer = supabaseEntity.answer ?? '';
if (answer != '') {
var newInquiry = InquiryEntity(
id: localEntity.id,
type: supabaseEntity.type,
content: supabaseEntity.content,
answer: supabaseEntity.answer,
uuid: supabaseEntity.uuid,
badge: true,
createdAt: supabaseEntity.createdAt);
SqlInquiryCrudRepository.update(newInquiry);
_setInquiryEntity(newInquiry, true);
} else {
_setInquiryEntity(localEntity, false);
}
}),
});
} else {
if (localEntity.badge ?? false) {
_setInquiryEntity(localEntity, true);
} else {
_setInquiryEntity(localEntity, false);
}
}
});
});
}
이제 사용자에게 문의한 내용을 답변을 드릴 수 있는 구조가 되었고 문의내역을 조회하는 페이지도 추가할 수 있었습니다.


앱 배포 후..
이번 앱이 4번째 앱이라 배포 심사에 대해서는 크게 어려움은 없었습니다.
그렇게 App Store, Play Store에 배포가 된 뒤에 버그들을 수정하며 문의에 대한 피드백을 드리고 데이터 보정하고 기능 개선 및 추가하며 어느덧 20일이 지나는 현재 앱 배포만 1~2일 간격으로 진행을 했었네요.. 퇴근 후 수정하고 새벽에 배포 심사 올리고 아침에 확인하고 이 패턴을 3주가 했던거 같습니다.
어느정도 큰 이슈들은 해결했고 잔잔한 버그들이 있는데 이 부분은 신규 기능을 추가할때 수정하면서 정기적으로 배포할 예정입니다. :)

회고하는 도중 App Brain 이라는 곳에서 1통의 메일이 왔습니다.
저는 처음 들어보는 곳이라 무슨 스팸메일인 줄 알고 삭제하려고 했는데요. 자세히 읽어 보니 랭크 관련한 메일이더라구요. 어디선가 3위를?? 했다는 내용이 더라구요. 그리고 Rank 페이지에 접속해보니 3위가 아닌 1위가 되어있더군요 ㅎㅎ 처음 듣는 사이트 였지만 기분이 너무 좋았습니다.

[ AppBrain Rank - Google Play Rank - 한국 - 네비게이션 - 무료 신작 카테고리 1위 !!!!! ]

추가로 메일의 링크중에 이런 기능을 제공해 주더라구요.
Play Store에 대한 앱 정보를 스크립트로 설정할 수 있도록 코드를 별도로 제공을 해줘서 적용해 봤습니다. ㅎㅎ
마무리..
퇴근 후 새벽까지 작업하는 일상이 계속 되어 몸은 힘들었지만 정신은 너무 재미있었던 시간들이 였네요, 어느정도 사용자 분들이 활용하는 앱으로 성장할 경우 추가하고 싶은 기능들이 너무 많습니다. ㅎㅎ 그럴 수 있기를 바라며 이만 글을 마무리 하겠습니다.
생각정리 겸 글을 작성했는데 정리가 안되네요..ㅋㅋ 정신없는글 끝까지 읽어 주셔서 감사합니다.
혹시 이 앱을 사용해보고 싶으신 분들이 계시다면 아래 다운로드 이미지 링크를 남겨두겠습니다. 한번 사용해 보시고 괜찮다 싶으시면 앱 리뷰도 남겨 주시면 성장에 도움이 많이 될 것 같네요 :)
감사합니다.

↓↓↓ 다운로드 링크 ↓↓↓
![]() |
![]() |
#1호선#2호선#3호선#4호선#5호선#6호선#7호선#8호선#9호선#신분당선#수인분당선#경의중앙선#경강선#경춘선#공항철도#서해선#우이신설선#GTX#GTX-A#지하철#실시간#도착시간#수도권지하철#지하철앱#
'App' 카테고리의 다른 글
| Flutter로 ios, android 앱 한번 만들어보자. [Locker - 나만의 링크, 사진, 메모 보관함] (0) | 2023.10.04 |
|---|

