본 포스팅은 학생창업팀에서 1인개발로 iOS서비스를 출시한 이후에는 어떤 고민을 했고 어떤 문제가 발생했는지를 기록하고 어떻게 생각하고 해결했는지를 기록하는 글입니다, 꾸준히 운영하면서 편수를 늘려갈 예정입니다
안녕하세요, 킴스캐슬입니다~
저는 현재 genti라는 학생창업팀에서 젠티라는 AI사진 제작 어플리케이션을 제작하고 운영중인 iOS개발자입니다.
(앱스토어 검색해서 한번 사용해보세요ㅎㅎ) 프로덕트에 대한 간단한 소개는 사진한장이면 충분할것같고 앱소개가 이번글의 포인트가 아니기때문에 이정도로하고 본격적인 개발이야기를 해보겠습니다
실제 출시를 한지는 약 3일정도 된거같고 많은 분들이 재밋게 사용해주신 덕분에 앱스토어기준 사진 및 동영상
무료앱 차트에 올라가기도하고(거의 꼴지였지만말이죠) 다운로드수도 3자리로 진입했을 만큼 제가 그렇게 경험해보고싶었던 실제유저를 통한 운영이라는 경험을 하고 있습니다
아직 3~4일정도밖에 안됐지만 그 와중에도 꽤나 의미있는 경험, 반성을 많이하게되어서 몇 가지 내용을 담아보려합니다
앱을 출시하고 나서 본격적으로 마케팅을 진행하기전에 지인 위주의 입소문 마케팅을 진행했습니다. 지인의 경우엔 문제가 생겼을 때 문제된 상황을 빠르게 들을 수 있기도 하고 뭐가 불편한지 들을수있다는 장점이있습니다
아무래도 우연히 들어온 유저분들의 경우엔 앱에 진입하자마자 문제가 생기거나 불편한점이 있으면 이탈하시는경우가 많으니까 앱을 운영하는 개발자입장에선 어떤 문제가 발생했는지 혹은 왜 이탈하시는지를 알 길이 없습니다. 그만큼 피드백을 줄수있는 유저는 소중하다고 생각합니다
출시첫날, 팀원의 지인분이 앱사용중 오류 메세지가 발생했다는 제보를 해주셨습니다.
(초기에는 빠른대응을 위해서 오류메세지를 알림의 body에 넣어주기로 했습니다, 물론 유저가 어떤오류가 발생했는지를 당연히 알필요가 없어서 log를 따로 남기고 잠시후 다시시도해주세요같은 알림으로 통일하는 작업을 진행하고있습니다)
아무튼말이죠. 처음에 이 오류메세지가 디스코드에 올라왔을때, 띠용했습니다. 정말 단 한번도 본적없는 오류였거든요. 앱출시전까지 꽤나 많은 주변사람들한테 테스트플라이트 유저를 부탁해서 qa를 진행 했는데 단 한번도 뜬적 없던 오류였습니다. 근데 오류만 봐도 사진세장을 aws의 s3서버에 올리는 부분
에서 문제가 생겼다는건 알 수 있었습니다
이 오류상황을 보고 저는 이렇게 생각했습니다
- aws에 사진 3장을 올리는데 실패했나보네?
- 근데 이건 우리서버도 아니고 aws서버에서 실패했다고 오류를 보낸거네?
- 지금까지 사진이 업로드 실패한적은 없었는데?
- 이건 그냥 어쩌다 aws서버에 문제가 발생했나보네?
실제로 이 오류 메세지를 보고 저나 서버개발자분들이 단순 사고쯤으로 생각했습니다. 지금 aws에 일시적인 오류가있다보다하고 제보해주신분께 앱을 종료후 다시시도해봐달라고 요청드렸습니다. 그랬더니 10분후쯤이었을까요 똑같은 문제가 그대로 발생한다고 연락을 받았습니다
이상하죠... aws에서 일시적인 문제가 발생한게 아니란걸 직감했습니다. 설상가상으로 제 지인분들중에서도 똑같은 오류를 마주하신분들이 생겼습니다. 더이상 aws문제가 아니라는 생각이 스멀스멀 다가오기시작했습니다. 곧바로 해당오류를 throw하는 코드를 찾아봤습니다
func getS3Key(from phAssets: [PHAsset]) async throws -> [String] {
do {
let fileNames = phAssets.map { $0.value(forKey: "filename") as! String }
let responses: [GetUploadImageUrlDTO] = try await self.requsetService.fetchResponse(for: GeneratorRouter.getPresignedUrls(fileNames: fileNames))
return try await withThrowingTaskGroup(of: String?.self) { group in
for (dto, phAsset) in Array(zip(responses, phAssets)) {
group.addTask {
let imageData = try await self.imageDataTransferService.requestImageData(for: phAsset)
return try await self.uploadService.upload(s3Key: dto.s3Key, imageData: imageData, presignedURLString: dto.url)
}
}
return try await group.reduce(into: [String?](), {$0.append($1)}).compactMap{$0}
}
} catch(let error) {
throw GentiError.uploadFail(code: "AWS", message: "AWS에 여러 장 업로드 실패 \(error.localizedDescription)")
}
}
코드를 보자마자 몇가지 의문이 들었는데 가장 첫번째로 든 의문은
대체 내가 왜 구현부에서 error를 catch한거지...?
였습니다. 단순히 봐도 error를 throw할수있는곳은 requestService.fetchResponse
와 imageDataTransferService.requestImageData
그리고 uploadService.upload
인데 내부에 로직에 따라 더 많은 메서드에서 각각 특정상황에 오류가 발생할수있었습니다. 저는 이 오류를 싸그리 잡아서 AWS에 여러 장 업로드 실패
라는 오류로 묶어버린 말도안되는 코드를 짜버린겁니다. 1인개발이라 남이짜준코드도 아니고 100%제가 짠 코드였습니다
이 오류가 어디서 온건지를 알아야 해결방법을 찾을 텐데 지금은 requestService.fetchResponse
, imageDataTransferService.requestImageData
, uploadService.upload
중에서 오류가 왔다~ 정도만 알수있는 상황이었습니다
정말 aws에서 문제가 있었을수도있지만(희박하지만) 아닐수도있다는걸 알게되었습니다. 당연히 여기서 do-catch를 할필요가 없으니 do-catch문을 제거하고 최종 호출부에서 do-catch를 하는 코드로 수정을 하고 테스트플라이트에 임시로 빌드를 했는데 지금까지 내부테스터였던 분들은 오류가 뜨질 않았습니다...
그래서 어쩔수없이 임시로 버전업데이트를 하고 문제가 발생했던 분들께 똑같은 상황을 재현해달라고 부탁하기로 결정하고 다시 심사를 올리고 앱스토어에 새버전을 릴리즈를 시켰습니다
그랬더니
이런 오류가 발생했습니다. 즉 원래는 이 쪽이 문제였는데 do-catch문을 잘못된 곳에서 써서 정확히 어떤 부분에서 문제가 생긴지를 단번에 파악할수없었습니다. 실제 문제가 발생한 지점을 찾는데만 문제 파악 - 유저 재시도 요청 - 코드 문제점 파악 - 테스트플라이트 릴리즈 - 실제 버전 심사요청 - 심사 - 앱스토어 릴리즈
의 순서로 약 6~7시간이라는 시간이 걸렸습니다
아직 문제점을 제대로 파악도 해결도 하지 못했는데 반나절이 날아가버렸습니다
해당 에러를 던지는 코드를 들어가니까 당시에는 PHImageManager에서 image를 받아서 이 이미지를 image형태의 data로 바꿀때 nil일때 발생하는 에러라는걸 알게되었습니다. 결과적으로는 제가 빨간색으로 표시된 코드를 빼먹었었는데 이게 icloud에 저장된 사진을 동기화해서 가져온다는걸 전혀 몰랐습니다
그래서 문제가 발생했던 분들께 혹시 문제가 있었던 사진모두 icloud사진이었는지를 여쭤봤고 모두다 그렇다고 대답해주셨습니다...운이 좋지 못했던건지 내부테스터 모두 icloud를 사용하지 않아 문제가 발생할지 자체를 예상하지 못했습니다
코드를 수정하고 다시 수정버전을 앱스토어에 올렸고 다시 심사를 요청하고 심사가 완료되 앱스토어에 릴리즈가 되어 3~4시간이 더 소모되었지만 다행히도 문제가 해결되었습니다
단순히 코드 한줄을 추가하면 해결될 문제였지만 문제를 파악하는데 너무 오랜시간이 걸렸고 처음에는 잘못된 오류처리때문에 문제자체를 잘못 파악했습니다. icloud를 쓰지 않기때문에 문제점을 못찾은건 어쩔수없었지만 애초에 오류처리를 제대로 했다면 어디서 문제가 발생한건지를 바로 알수있었을테니까 해결까지 이렇게 오래걸리지 않았을것같습니다
약 12시간에 거친 첫 버그해결을 경험하면서 역시 앱개발은 출시후가 본격적인 시작이라는걸 다시금 깨달았습니다. 그리고 릴리즈전에(지금처럼 아얘 발생할지도 몰랐던 icloud같은 건 제 상황에서 정말 어쩔수없었다고 생각하지만) 최대한 오류에 대한 대응과 처리 방안을 고민해야한다는걸 깨달았습니다.
오류가 발생하는것 자체도 큰 문제지만 오류 하나를 처리하기 위해서는 오류의 원인을 찾고 수정하고 다시 심사에 넣고 릴리즈해야하는 과정이 짧지 않다고 느꼈기때문입니다. 지금이야 100명정도가 사용하는 앱이지만 몇만명 몇십만명이 사용하는 앱에 문제가 발생해 해결하고 다시 업데이트를 하게되면 그 유저분들의 몇시간을 낭비하는 크리티컬한 이슈가 될수있다고 생각했습니다
당연히 전혀 예상치못한 어쩔수없는 오류의 경우엔 발생하면 한대 맞아야하지만 그게아니라면 예상가능한 문제는 최대한 시뮬레이션을 해보고 테스트를 해보고(이래서 테스트코드가 중요한걸까요) 릴리즈를 해야한다는걸 알게되었습니다
지금까지 출시까지 가지도 못한 앱이라던가 단순히 출시하고 프로젝트 종료를 외쳤던 프로젝트를 들어가보면 error를 처리는 커녕 print(error)를 해놓고 끝낸걸보면서, 역시 경험하지 않으면 뭐가 중요한지 알수가없구나라는걸 알게되어 다행이들었습니다 ㅎㅎ...
처음에 팀에 합류했을때 앱의 와이어프레임정도만 잡혀있었고 아무런 기획, 디자인이 없었습니다. 당시에 다행이 디자이너 한분이 팀에 합류해주셨고 앱의 디자인과 기능을 차근차근 정리하기 시작했습니다
그중에서도 결국 내사진을 통해서 AI사진을 만들어주는 앱 특성상 내사진을 업로드해야하는 기능이 필수적으로 필요했고 내 앨범에 저장된 사진들중에서 얼굴사진 3장을 선택하는 기능과 UI가 필요했습니다
당시에 개발자 기획자 디자이너가 결정한 최종 UI였습니다. 이제와서 말하지만 저는 정말정말 아무 문제가 없을거라고 생각했습니다. 왜냐면 저는 sns를 안하고 애초에 sns가 아닌이상 내 앨범에있는 사진을 선택할일도 별로 없거든요. 그리고 그렇다고 해도 그냥 전체사진에서 내사진을 찾아서 고르기만 했었습니다
막상 앱출시를 하고 나니 제 지인분들 + 팀원분들의 지인분들이 공통적으로 하나의 불편함을 호소하셨습니다
앨범별로 이미지를 볼 수 없는게 너무 불편해요
보통 잘나온 내사진은 좋아요를 눌러놓는다던지 따로 앨범을 만들어 빼놓는 경우가 많은데 전체사진기준으로 사진을 보니 오히려 이런분들입장에서는 내 사진을 찾기가 너무 어려웠던 겁니다. sns를 안하고 사진관련앱을 써보지 않은 제 입장에서는 전혀 이해가 되지 않았지만 불편함을 호소하시는 분들이 꽤 많았았고 바로 기획단과 디자이너분께 앨범별로 모아보기 기능에 대한 기획과 디자인을 요청드려 사진선택쪽 뷰를 다시 구현했습니다
뷰를 재구현해서 최초에는 모든 사진을 보게되겠지만 앨범을 선택해서 사진을 선택할수있도록 기능을 재구현해서 새로운 버전을 릴리즈했고 많은분들이 편하게 사용하실수있게 되었습니다
앱을 만들면서 느끼는게 개발자로 앱을 매일매일 몇십번에서 몇백번씩 보고 매번 사용하다보니 UI나 UX에 익숙해져서 출시후에는 대다수일 앱을 처음사용해보는 유저입장
을 고려하는게 정말 어렵다는걸 느끼게 되었습니다. 실제로 큰 업데이트가 예정되어있다면(리브랜딩 혹은 새로운 flow의 기능) 내부테스터가 아닌 앱을 처음써보며 피드백을 들을수있는 외부 테스터의 의견을 들어보고 수정하고 배포하는 과정이 조금 시간은 더 걸리더라도 필요하겠다는 느낌이 들었습니다
앱을 만들더라도 유저가 사용해주지 않는다면 과연 의미가 있을까라는 생각을 요즘 많이하고 있습니다. 결국 유저가 불편하다고 이야기하면 팀내에서는 맞다
라고 판단한 부분마저도 틀렸다
라고 인정해야하는 순간이 온다고 생각합니다. 개발자로서도 실제로는 코드상으로 버그가아니고 의도한 부분인데 유저가 버그라고 느끼면 그건 버그라고 인식하고 수정해야할 의무가 있다는걸 깨달았습니다
앱은 만들고 앱스토어 올린다고 끝이 아니라 유저의 목소리로 부터 우리가 틀린부분을 찾아서 바꿔가는 과정이라는걸 깨닫고있습니다. 얼마나 빠르게 유저의 목소리를 듣고 얼마나 빠르게 그부분을 해소시켜줄수있느냐가 진짜 실무에서 필요한 능력이 아닐까라는 생각이 어렴풋이 드는 요즘인것같습니다
지금까지 대부분의 프로젝트(솔직히 제대로된 프로젝트기준으로는 1인개발이 genti가 처음이기에 모든 프로젝트라고 해도 큰 무리는 없겠네요)는 같이 개발을 해주는 iOS동료가 있었습니다. 그러다 보니 내가 짠 코드지만 누군가의 판단이 들어있을때가 많았습니다. 한달이 지나고 코드를 봤을때 뭔가가 이상하다면 내가 왜 이렇게 코드를 썼지?
보다는 우리가 왜 이렇게 코드를 썼지?
라고 생각할 때가 많습니다. 부끄럽지만 속으로 모든 책임에 대한 회피를 하고싶은 방어기제가 있었던 모양입니다
하지만 지금은 모든 판단도 제가하고 모든 코드도 제가짭니다. 그냥 못짠건 순전히 제탓인거죠. 이번에 코드를 정리하면서 찜찜한 코드를 하나 보게되었습니다. 사진을 앨범별로 모아보기 구현하기전에 모든 이미지를 grid형태로 한번에 scrollview에 넣는 코드가 있었습니다
1000개의 phasset을 가져오면 1000개의 이미지를 scrollview로 가져와서 이미지로 바꿔줬습니다. 근데 제가 이때 당시에 해당 내용을 pagination으로 구현을 했었는데 한달이 지난시점에서 굳이..?라는 생각이 들었습니다
아마도 swiftUI로 개발을 했어서 더 그런생각이들었을지 모르겠네요. 그렇게 생각한 이유를 정리해보면
첫번째로 phaseet은 meta data(그냥 string변수들이있는 구조체정도의 느낌이다)이기 때문에 그정도의 데이터가 foreach문으로 grid를 만드는건 과부하가 걸리지 않을것같았습니다
두번째로 lazy grid이기때문에 각 grid가 onappear될때 비동기적으로 이미지로 변환하기때문에 scrollview가 버벅거리거나 main thread에 부담이 되지 않습니다. 또한 lazy한 특성때문에 UI에 보이기전에는 메모리에 올라가지 않으므로 meta data전부가 한번에 load되지도 않습니다
세번째로 지금 pagination을 구현하기 위해서 scroll될때마다 offset을 viewmodel에게 넘기고 매 frame마다 offset과 scrollview가 바닥에 닿았는지를 계산하고 그때마다 meta data를 indexSet별로 가져오는 일자체가 더 무거운 작업일것이라고 생각했습니다
오히려 pagination을 구현하겠다고 더 많은 로직과 복잡한 코드를 구성했다는걸 사진을 앨범별로 모아보기 구현하면서 느꼈습니다. 그래서 새로운 기능을 구현할때는 pagination이 아닌 lazy의 장점을 활용해 meta data를 한번에 가져오는 방식으로 구현했습니다. 이렇게 하니까 코드도 훨씬 직관적이고 성능면에서도 pagination과 비교했을때 큰 차이가 없었습니다
이게 정말 정답인지는 모르겠으나 당시에 굳이 pagination을 선택했던 이유를 스스로 설명하지 못했던것같습니다. 그래서 다시 왜에대한 내용을 생각해보니 굳이라는 결론이 나왔고 없이 구현했을때 충분히 문제가없을거라는 이유를 말할수있게되었고 새로운 방식으로 구현하게되었습니다. 시간이 지나면 이 방법이 틀렸다고 생각할수도있겠지만 우선 지금은 스스로 이유를 선택할수있는 방식을 선택했습니다
에러처리를 잘못해서 문제가 발생한 코드도 그렇고 pagination코드도 그렇고 막상 그때는 문제가 없다고 느꼈던(혹은 문제라고 인식자체를 못했던 코드도) 시간이 지나면 문제점이 보이고 왜 이렇게했을까에대한 의문이 들었습니다
이 또한 앱을 출시하고 끝이 아니라 필요에따라 해당 기능에 새로운 기능이 추가될수도있고 또 다른 문제가 발생할수있는 가능성이 열려있다고 생각하니까 좀 더 수정하기 쉽고 문제가 생겼을때 대처하기 쉬운 코드를 지향하게되면서 보이게되는 결점들이 아닐까 싶네요
다음 글은 본격적으로 지인마케팅이아닌 실제 마케팅을 진행하면서 겪은 일들, 느낀점들을 가지고 오지 않을까 싶습니다. 일주일정도 더 많은 유저분들을 모시기 위해서 마케팅을 진행하다보면 더 많은 목소리를 들어볼수있지 않을까요?
혹시 저 처럼 실제 앱을 출시해보니 해보기전에 몰랐던 이런부분이 있었다하는 부분이 있다면 댓글로 알려주시면 감사하겠습니다:)
긴글 읽어주셔서 감사합니다
그럼 20000!
iCloud 이미지라서 에러나는건 테스트 케이스로 어떻게 작성하죠...
슬랙에 올려주셔서 재밌게 써봤습니다 🔥