프로젝트의 시작, 마스크 대란
2020년 1월 20일 국내 첫 확진자를 기점으로, 2020년 2월 17일에 대구에 위치한 한 종교 시설에서 대규모 집단 감염이 발생하였다. 이의 여파로, 마스크 수요가 폭증하였고 국민 모두가 마스크를 구입하기 위해 여러 약국을 찾아다니는 사태가 일어났다. 이후 2020년 3월 9일부터 마스크 5부제를 시행하면서 수요를 완화시켰지만, 그럼에도 불구하고 재고가 부족한 약국이 빈번해 헛걸음을 하는 시민들이 많았다.
이를 해결하고자, 고려대 학생들과 멋쟁이 사자처럼과 같은 몇몇의 개발자들이 마스크 재고를 실시간으로 확인할 수 있는 지도 앱을 제작하여 시민들의 불편을 해소해주었다.
그리고 이러한 선한 영향력을 이어받아, 우리도 직접 마스크 재고 알림 앱을 만들어 보기로 하였다.
약국 마스크 재고 API활용
앱을 개발하기 위해 SwaggerHub라는 사이트의 '공적 마스크 판매 현황 조회 API'를 활용하였다.
API에서 제공하는 데이터는 아래와 같다
- 약국, 우체국, 농협 등의 마스크 판매처 정보
- 마스크 재고 상태 등의 판매 정보
- 중심 좌표(위/경도)를 기준으로 반경(미터단위) 안에 존재하는 판매처 및 재고 상태 등의 판매 정보
- 주소를 기준으로 해당 구 또는 동내에 존재하는 판매처 및 재고 상태 등의 판매 정보
앱 개발
개발은 안드로이드와 IOS 팀으로 나누어 빠르게 진행하였다. 안드로이드는 개발언어로 Java를 이용하였고, IOS는 Swift를 이용하였다. 협동툴로는 Slack과 Github를 사용하여 개발을 진행하였다. 프로젝트에서 담당했던 부분은 프론트엔드와 지도 API 활성화 및 기능 적용이었다.
개인적으로 구글맵과 카카오맵 API를 활용해 다양한 기능을 구현해 본 경험이 있어서, 지도 API를 연동하는 과정은 순탄하였다. 하지만 다른 데이터 API를 적용하고 필요에 맞게 지도 앱을 변경해 본 경험은 없었기에 조금은 헤메었던 것 같다.
특히, 앱 아이콘 설정부터 스플래시 뷰, 인트로 페이지를 포함한 모든 프론트엔드 개발이 처음이었기에 수시로 도서관을 방문하거나 블로그의 도움을 받았어야 했다. (중간에 지도와 관련된 백엔드 부분도 일부 참여했지만, 다른 팀원들도 알 수 없는 오류로 난항을 겪고 있던 것은 비슷했던 것 같다;;)
그렇게 많은 시행착오와 여러 회의를 거쳐 결과적으로 기획했던 주요 기능들을 모두 구현할 수 있었다.
앱의 주요 기능
아래는 구현했던 기능들과 코드의 일부이다. 코드는 IOS는 담당하지 않았기에 안드로이드 버전만 첨부했다. 지도는 애플 내장 지도와 가장 흡사한 구글 지도를 사용했고, 마커는 마스크 재고에 따라 다른 색으로 표기했다. (녹색 100개 이상, 노란색은 30~99개, 빨간색은 2~99개, 회색은 0개)
//핀 찍는 기능
void placeMarkerOnMap(List<Store> storesByGeo) {
if (storesByGeo != null) {
Log.e(TAG,"is: "+storesByGeo.get(0).getAddr());
for (final Store store : storesByGeo) {
final LatLng pinLocation = new LatLng(store.getLat(), store.getLng());
final String remain = store.getRemain_stat();
if(remain == null) continue;
this.runOnUiThread(new Runnable() {
public final void run() {
final MarkerOptions markerOptions;
//녹색(100개 이상)/노랑색(30~99개)/빨강색(2~29개)/회색(0~1)개
switch (remain) {
// MarkerOptions를 remain에 맞춰서 설정
... ...
}
map.addMarker(markerOptions).setTag(store);
}
});
}
}
}
* placeMarkerOnMap : 지도에 핀 나타내는 메소드
//GPS 버튼 클릭시 현재 사용자의 위치에서 약국 검색, 마커 표시
public void onClick_gps(View v){
map.clear();
fusedLocationClient.getLastLocation().addOnSuccessListener(this, new OnSuccessListener<Location>() {
@Override
public void onSuccess(Location location) {
curLocation = location;
LatLng latLng = new LatLng(location.getLatitude(), location.getLongitude());
CameraUpdate cameraUpdate = CameraUpdateFactory.newLatLngZoom(latLng, 16);
map.animateCamera(cameraUpdate);
// JSON 파싱, 마커생성
StoreFetchTask storeFetchTask = new StoreFetchTask();
List<Store> temp = null;
try {
temp = storeFetchTask.execute(location).get();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
placeMarkerOnMap(temp);
}
});
}
* onClick_gps : fusedLocationClient가 마지막 위치를 가져옴에 성공하면 gps버튼으로 현 위치로 이동하고 현 위치의 약국들의 마커를 보여준다
public void onClick_reset(View v){
//이전 마커 지우기
map.clear();
// JSON 파싱, 마커생성
StoreFetchTask storeFetchTask = new StoreFetchTask();
List<Store> temp = null;
try {
temp = storeFetchTask.execute(currentLocation).get();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
placeMarkerOnMap(temp);
}
* onClick_reset : 그 전의 마커들을 지우고 현 위치 위도, 경도 객체를 받아 다시 마커를 찍는다
private static List<Store> storesByGeo = null;
@Override
protected List<Store> doInBackground(Location... lastlocation) {
Location location = lastlocation[0];
String url = "https://8oi9s0nnth.apigw.ntruss.com/corona19-masks/v1";
String query = "/storesByGeo/json?lat=" + location.getLatitude() + "&lng=" + location.getLongitude() + "&m=1000";
OkHttpClient client = new OkHttpClient();
Request.Builder builder = new Request.Builder().url(url + query).get();
Request request = builder.build();
Response response = null;
try {
response = client.newCall(request).execute();
} catch (IOException e) { ... }
assert response != null;
if (response.isSuccessful()) {
String body = null;
try { ... }
Gson gson = (new GsonBuilder()).create();
JsonParser parser = new JsonParser();
assert body != null;
JsonElement rootObj = parser.parse(body)
.getAsJsonObject().get("stores");
storesByGeo = gson.fromJson(rootObj, new TypeToken<List<Store>>() {
}.getType());
...
return storesByGeo;
}
* doInBackground : edittext를 받아서 geocoding으로 넘겨서 응답이 완료된 return 값을 서비스 객체에서 받아옴
//window를 길게 누르면 실행: 해당 마커의 약국이 즐겨찾기 목록에 있는지 확인
map.setOnInfoWindowLongClickListener(new GoogleMap.OnInfoWindowLongClickListener() {
@Override
public void onInfoWindowLongClick(final Marker marker) {
//mStore가 null이 아니고, 이미 즐겨찾기 목록에 있을 경우 isthere = true
boolean isthere = false;
mStore = getmStoreFromSP(mContext);
if(mStore != null) {
for (FStore fstore : mStore) {
. . .
}
}
//즐겨찾기 목록에 없을 경우: 즐겨찾기에 추가하시겠습니까?
if(!isthere) {
AlertDialog.Builder oDialog = new AlertDialog.Builder(MapsActivity.this,
android.R.style.Theme_DeviceDefault_Light_Dialog);
oDialog.setMessage("해당 약국을 즐겨찾기에 추가하시겠습니까?")
.setTitle("즐겨찾기 추가")
.setPositiveButton("아니오", new DialogInterface.OnClickListener() {
. . .
})
.setNeutralButton("예", new DialogInterface.OnClickListener() {
. . .
})
.setCancelable(false) // 백버튼으로 팝업창이 닫히지 않도록 한다.
.show();
}
* mapView : annotationView 안의 즐겨찾기 추가 버튼이 눌러졌을 때 실행되는 함수
//즐겨찾기 목록에 있을 경우: 즐겨찾기를 해제하시겠습니까?
else {
AlertDialog.Builder oDialog = new AlertDialog.Builder(MapsActivity.this,
android.R.style.Theme_DeviceDefault_Light_Dialog);
oDialog.setMessage("이미 즐겨찾기에 추가된 약국입니다. 즐겨찾기를 해제하시겠습니까?")
.setTitle("즐겨찾기 해제")
.setPositiveButton("아니오", new DialogInterface.OnClickListener() {
. . .
})
.setNeutralButton("예", new DialogInterface.OnClickListener() {
. . .
})
.setCancelable(false) // 백버튼으로 팝업창이 닫히지 않도록 한다.
.show();
}
}
});
}
* 마커의 InfoWindow를 longClick 하면 즐겨찾기 추가할 수 있으며, 즐겨찾기 아이콘에서 목록 확인이 가능하다. 또한, 목록에서 longClick시 즐겨찾기 해제 또한 가능하다.
//도움말 버튼
imgNotice = findViewById(R.id.imgNotice);
//anim 폴더의 애니메이션을 가져와서 준비
fade_in = AnimationUtils.loadAnimation(this, R.anim.fade_in);
fade_out = AnimationUtils.loadAnimation(this, R.anim.fade_out);
//페이지 슬라이딩 이벤트가 발생했을때 애니메이션이 시작 됐는지 종료 됐는지 감지할 수 있다.
ImageAnimationListener imgListener = new ImageAnimationListener();
fade_in.setAnimationListener(imgListener);
fade_out.setAnimationListener(imgListener);
btnHelp = findViewById(R.id.btnHelp);
btnHelp.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (isImgOpen) {
imgNotice.startAnimation(fade_out);
} else {
imgNotice.startAnimation(fade_in);
imgNotice.setVisibility(View.VISIBLE);
}
}
});
* MapActivity - OnCreate() : isImgOpen이 false인지 true인지에 따라 애니메이션 부여
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/accelerate_interpolator"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:duration="@android:integer/config_longAnimTime" />
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/accelerate_interpolator"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:duration="@android:integer/config_longAnimTime" />
* fade_in, fade_out 애니메이션 xml 생성
private class ImageAnimationListener implements Animation.AnimationListener{
@Override public void onAnimationStart(Animation animation) {
}
public void onAnimationEnd(Animation animation){
if(isImgOpen){
imgNotice.setVisibility(View.INVISIBLE);
btnHelp.setText("");
isImgOpen = false;
}
else{
btnHelp.setText("");
isImgOpen = true;
}
}
}
* 애니메이션 시작과 끝에 따라 isImgOpen에 false/true 반환
프로젝트의 끝
프로젝트를 진행하면서 느꼈던 점은, 생각보다 앱 개발은 쉽지 않다는 것이다. '이정도 앱은 손쉽게 구현할 수 있겠지?'라고 생각했지만 크나큰 오산이었다. 무엇보다 제대로 된 지식없이 시작하면 더욱 헤매일 수 밖에 없는 게 앱 개발인거 것 같다는 느낌을 많이 받았다. 그나마 지도 API에 대한 지식이 있었기에 수월했다고 본다.
그럼에도 해당 프로젝트는 매우 즐거웠던 여정이라고 생각한다. 문제를 해결하기 위해 끊임없이 소통하고 회의하면서 팀원들과 친밀해질 수 있었고, 처음으로 깃허브를 이용해 협동 개발이라는 것을 체험해볼 수 있었던 것 같다. 그리고 학교에 모여 밤을 새가며 프로젝트의 완성에 박차를 가했던 경험은 아마 평생 잊지 못할거라 생각한다. (앞으로 샐날이 더 많겠지만..ㅎ)
개발한 앱은 안드로이드와 IOS 앱 모두 앱스토어에 등록을 요청했다. 하지만, 안타깝게도 일부 보안 정책 문제로 최종 등록에는 성공하지 못했다. 그래도 첫 앱 개발인 것을 감안하면, 나쁘지 않은 성과였다고 본다.
언젠가는 스토어에 직접 개발한 앱을 등록하겠다는 각오를 다지며 글을 마친다.
https://github.com/Co-Alarm/Co-Alarm-Java
GitHub - Co-Alarm/Co-Alarm-Java: Co-ma Java Language Ver (코로나 마스크 알림이 app)
Co-ma Java Language Ver (코로나 마스크 알림이 app). Contribute to Co-Alarm/Co-Alarm-Java development by creating an account on GitHub.
github.com
'프로젝트' 카테고리의 다른 글
공항 온실가스 감축 프로젝트 (0) | 2022.02.03 |
---|---|
스마트 버스정류장 입지선정 - LH COMPAS 공모전 (1) | 2022.01.13 |
우리동네 키움센터 입지선정 - 서울시 빅데이터캠퍼스 공모전 (0) | 2022.01.06 |
딥러닝을 활용한 애호박 선별기 (0) | 2022.01.04 |
소셜메트릭스 - 진로 소주 분석 (0) | 2021.12.27 |