지난 2개월간의 인턴십은 단순한 개발 경험 그 이상이었습니다.
프론트엔드 개발자를 꿈꾸던 제가, React Native를 넘어 iOS, Android 네이티브 영역까지 뛰어들게 된,
짧지만 강렬했던 3주간의 여정을 공유하려 합니다.
이번 진행했던 인턴십의 방식은 독특했습니다.
정해진 스펙을 구현하는 것이 아니라, 아이디어 발굴부터 기획, 디자인, 개발, 테스트, 그리고 배포까지의 전 과정을 온전히 제 스스로 책임지는 구조였습니다.
"지금 우리 앱에 필요한 것은 무엇인가?"
스스로 문제를 정의하고 해결책을 찾아 나가는 과정에서, 코드를 짜는 '개발자'를 넘어 제품의 가치를 만들어내는 'Product Engineer'의 태도를 배울 수 있었습니다.
이 자유로운 환경 덕분에 저는 기술의 경계에 갇히지 않고 과감한 도전을 시작할 수 있었습니다.
아이디에이션 단계에서 다양한 아이디어를 생각하면서, 여러 후보가 있었지만, 해당 앱
의 핵심 가치인 몰입감을 극대화하기엔 홈 화면 위젯만 한 것이 없다고 확신했습니다.
유저 커뮤니티를 살펴보니, 이미 많은 유저가 캐릭터 이미지를 캡처해 서드파티 위젯 앱으로 배경화면에 꾸며두고 있더라고요. "이걸 우리가 공식 기능으로 제공한다면 유저 경험이 훨씬 매끄러워지지 않을까?" 라는 가설을 세웠고, 평소 위젯을 많이 사용하는 저의 사심(?)까지 더해져 망설임 없이 주제로 선정했습니다.
이런식으로 위젯을 적용하게 되면 어떤 기대 효과가 있을지 쭉 적어보았습니다.
많은 유저들이 자체 위젯이 없음에도 불구하고, 서드파티 앱을 써서 캐릭터 사진을 배경화면에 띄워두고 있더라고요.이를 보니 더더욱 위젯의 기대 효과와 방향성은 명확해졌습니다.
디자인을 전공하지 않은 개발자로서 맨땅에 그냥 헤딩하기보다, 미리 알고 진행하는 것이 좋을 것 같다라는 생각이 들어 애플 개발자 공식 문서 위젯 디자인 파트를 정독했습니다.
상당히 흥미로웠던 점은, 애플만이 가지고 있는 철학이였습니다. 여기 문서에서 읽을 수 있었던 내용은 다음과 같았습니다.
애플 공식 문서에 있는 내용을 제가 직접 요약한 내용입니다.
가이드라인을 읽고 저는 간단하면서도 메리트있는 위젯을 만드는 것에 초점을 맞췄습니다.
초기 아이디어에는 실시간으로 무언가 상호작용 하는 등의 기획도 생각해보았는데, 우선 단순하면서도 메리트있는 위젯을 먼저 만들어보고자 했습니다.
또한, 안드로이드 위젯 디자인 문서도 함께 읽으면서 플랫폼별 차이를 고민했습니다.
iOS가 '일관된 미학'을 강조한다면, 안드로이드는 다양한 크기와 자유로운 레이아웃을 제공하는 점이 흥미로웠습니다.
기존 앱이 React Native 기반이라 위젯도 당연히 JS로 뚝딱 만들 수 있을 줄 알았습니다.
하지만 리서치 결과는 제 예상과 달랐습니다.
"홈 화면 위젯은 각 OS의 네이티브 UI 시스템(SwiftUI, XML/Compose)으로 직접 구현해야 한다."

프론트엔드 개발자인 저에게 상당히 당황스러운 상황이였지만, 찾아보니 원리는 생각보다 흥미로웠습니다.
이때 핵심은 앱과 위젯이 데이터를 공유할 수 있는 공용 저장소를 만드는 것이었습니다.
결론적으로 저는 React Native라는 안전지대를 벗어나, iOS와 Android라는 다소 도전적인 영역으로 데이터를 들고 가야만 했습니다.
안그래도 네이티브에 대한 지식이 매우 전무한 상태라, 두려움을 가지고 시도했던 도전이였던 것 같습니다. 과정 중에서 많은 트러블을 겪기도 했구요.
코드를 한 줄도 적지 않았는데, 첫 번째 난관이 찾아왔습니다. 바로 '빌드'였습니다. npm run start 한 번이면 실행되던 리액트 네이티브와 달리, Xcode는 버전 호환성부터 하나하나 따져가며 저를 시험에 들게 했습니다.

objectVersion 불일치로 인한 빌드 오류... 원인을 찾기 위해 구글링과 삽질을 반복하며 2시간을 보냈습니다. 결국 프로젝트 설정을 직접 수정하여(objectVersion 60으로 하향) pod install에 성공했을 때의 그 안도감이란...
"아, 네이티브 세계는 입장료가 꽤 비싸구나"라는 것을 뼈저리게 느낀 순간이었습니다.
위젯 개발에서 가장 중요한 것은 '리소스를 아끼는 것' 이었습니다.
위젯은 백그라운드에서 동작하므로, 무의미한 데이터 변경으로 인해 불필요한 갱신이 일어나면 배터리 소모가 커지고, 심하면 OS 정책에 의해 업데이트가 제한될 수도 있습니다.
그래서 저는 "정말 필요한 데이터가 바뀌었을 때만" 위젯에게 신호를 보내기로 했습니다. 이를 위해 이전 데이터와 현재 데이터를 비교하는 커스텀 Hook을 구현했습니다.
export const hasWidgetDataChanged = <T>(
newData: T,
currentData: T | null,
pickFields?: (keyof T)[],
): boolean => {
if (!currentData) {
return true;
}
// 특정 필드만 비교하는 경우 (updatedAt 등 제외)
if (pickFields) {
const pickData = (data: T) =>
pickFields.reduce(
(acc, key) => ({...acc, [key]: data[key]}),
{} as Partial<T>,
);
return JSON.stringify(pickData(newData)) !== JSON.stringify(pickData(currentData));
}
return JSON.stringify(newData) !== JSON.stringify(currentData);
};
// 기존 저장된 데이터와 비교
const currentData = await getDdayWidgetData();
if (!hasWidgetDataChanged(newData, currentData, compareFields)) {
lastSyncDataRef.current = newDataHash;
return;
}
이 로직 덕분에 불필요한 네이티브 브릿지 통신을 획기적으로 줄일 수 있었고, 앱 성능에도 영향을 주지 않도록 방어할 수 있었습니다.
이제 브릿지를 준비했으니 이를 출력할 UI를 만들 차례였습니다.
사실 겁을 많이 먹었습니다. 예전에 학교에서 안드로이드 수업을 들었을 때만 해도, 제 머릿속의 네이티브 개발은 복잡한 XML 파일을 만지거나, 명령형으로 UI를 하나하나 조작하는 방식이었거든요.
하지만 놀랍게도, 최신 네이티브 생태계는 프론트엔드의 트렌드인 '선언형 UI' 로 수렴하고 있었습니다.
생소한 것들이 많았지만, 저는 이렇게 생각하기로 했습니다.
<div> 대신 VStack, HStack언어만 JavaScript에서 Swift로 바뀌었을 뿐, 상태에 따라 UI가 변한다는 사고방식은 완전히 동일했습니다.
덕분에 러닝 커브를 대폭 줄이고 빠르게 UI를 구성할 수 있었습니다.
사실 SwiftUI는 이전에 혼자 iOS를 공부하다가 다뤄본 적이 있었습니다. 제가 원래 애플 생태계를 좋아하기도 하고, 폰도 아이폰을 써서 관심이 많았는데, 문제는 안드로이드였습니다.
안드로이드 폰에 대해서는 거의 지식이 없고, RemoteViews방식이 생각보다 까다로울거다라는 말이 많았거든요. 그때 저는 한 친구의 조언으로 Jetpack Compose 문법으로 만들 수 있는 라이브러리를 발견했습니다. 그것이 바로 Jetpack Glance였어요.
하지만 이제 막 개발된 라이브러리였어서, 여러 제약들도 존재했고, 스타일링도 조금씩 달랐습니다.
사실 심지어 취약성 관련 문제로도 한 번 리포트를 받기도 했죠...
결과적으로 프론트엔드 개발자로서 쌓아온 '컴포넌트 기반 사고'가 네이티브 위젯 개발에도 큰 무기가 되었습니다. SwiftUI와 Glance 모두 결국은 "데이터를 UI로 치환하는 함수"를 만드는 과정이었으니까요.
제가 느낀 두 플랫폼은 이러했습니다.
안드로이드는 무한한 가능성을 열어두지만, iOS는 철저한 일관성을 요구했습니다.
이제 구현한 위젯을 유저에게 직접 공유해볼 시간이였습니다. 총 3가지 위젯을 개발했는데, D-Day 위젯, 최근 대화 위젯, 캘린더 위젯. 이렇게 개발했습니다.
베타 테스트를 위해 먼저 iOS만 빠르게 개발하고, iOS 유저 대상 베타 테스트 지원을 받기 시작했습니다.
많은 유저분들께서 지원을 해주셨고, 그중 일부만 선발하게 되었습니다.
지원을 하는 과정에서, 유저들의 실제 위젯 니즈가 어떤지 궁금하여, 여러 문항을 함께 넣어 통계를 내보았습니다.
지원 과정에서 진행한 설문조사는 저의 예상을 완전히 빗나갔습니다.
저는 감성적인 'D-Day 위젯'이 1위일 것이라 예상했지만, 유저들은 '채팅방 바로 가기(Deep Link)' 기능이 있는 '최근 대화 위젯'을 가장 원했습니다. "아, 유저들은 감성도 중요하지만 '편의성'을 더 갈구하는구나." 이 베타 지원서는 제가 처음 유저의 목소리를 확인한 귀중한 데이터였습니다.
사진에 있는 요약, 제안은 말그대로 써주신 내용들을 참고하여 제가 요약해본 내용들입니다. 읽으면서도 아, 진짜 이랬으면 좋았긴 하겠다... 하며 고민한 점들도 있었죠.
지금은 우선 유저의 니즈도 파악해야 하고, 우선 내본 다음에 더 많은 유저들이 원한다면 추후 업데이트를 지속하지 않을까 싶습니다 ㅎㅎ
iOS 대상으로 베타테스트를 진행했고, 베타테스트에 함께 해주신 분들이 이런식으로 SNS에 공유해주셨습니다.
사실.. 버그가 눈에 보여서 조금 부끄럽네요
치명적인 실수였습니다. 디데이 위젯 배경으로 '캐릭터의 프로필'을 불러와야 하는데, 로직 상 우선순위 문제로 '유저의 멀티 프로필' 이미지가 뜨는 경우가 발생했습니다. 제 계정에는 멀티 프로필 설정이 되어있지 않아 테스트 과정에서 놓쳤던 것이죠. 'Works on my machine(내 컴퓨터에선 되는데)'이라는 개발자의 안일함을 반성하며, 모든 프로필 케이스를 대응하도록 로직을 수정했습니다.
iOS 18부터 홈 화면 아이콘과 위젯을 '다크 모드'나 '틴트(Tinted)' 모드로 바꿀 수 있다는 사실을 간과했습니다. 유저가 아이콘 스타일을 변경하자, 위젯의 배경색이 사라지거나 이미지가 의도치 않게 변하는 문제가 발생했습니다.

원래 위젯 배경으로 캐릭터의 사진을 도입해두었었는데, 투명 모드가 되자마자 배경이 없어지게 되어 유저 입장에선 버그처럼 보였던 것이였습니다.
containerBackgroundRemovable를 추가하여 배경이 없어지지 않도록 방지했습니다.
가장 기술적으로 까다로웠던 건 메모리 이슈였습니다. 위젯은 앱보다 메모리 제약이 훨씬 엄격한데, 고화질 캐릭터 이미지를 그대로 띄우려다 보니 프로세스가 강제 종료되는 현상이 발생했습니다.
Widget archival failed due to image being too large [1] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [2] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [3] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
[S:3] Error received: Connection invalidated.
Widget archival failed due to image being too large [12] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [13] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [14] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [16] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [18] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [19] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [20] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
Widget archival failed due to image being too large [21] - (1001, 1252), totalArea: 1253252 > max[988574.400000].
[S:4] Error received: Connection invalidated.
사실 위젯에서 가장 중요했던 건 이미지였는데, 이미지가 너무 큰 바람에 프로세스가 그냥 종료가 되고, 위젯 데이터를 불러오지 못하는 버그가 생긴 것이였죠.
그래서 일반적인 UIImage 리사이즈 대신 CGImageSource 다운샘플링을 사용했습니다.
기존의 UIImage 방식은 전체 이미지를 메모리에 디코딩한 후 리사이즈하는 방식이라,
메모리를 많이 사용할 뿐 아니라 보간 과정에서 화질 저하가 발생했습니다.
반면 CGImageSource의 다운샘플링은 디코딩 단계에서 바로 작은 크기로 생성하기 때문에, 메모리 사용량도 적고 원본 품질도 유지할 수 있었습니다.
static func resizeImage(data: Data, maxSize: CGFloat) -> Data? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions) else {
return data
}
// 원본 크기 확인 - 이미 작으면 리사이즈 안 함
guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else {
return data
}
let ratio = min(maxSize / width, maxSize / height)
if ratio >= 1.0 {
return data // 이미 충분히 작음
}
// 다운샘플링
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxSize
] as CFDictionary
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
return data
}
let uiImage = UIImage(cgImage: downsampledImage)
return uiImage.jpegData(compressionQuality: 0.92)
}
이러한 버그 제보 덕분에 2차 베타할 땐 문제 없이 모두 작동했습니다.
모두가 만족스럽다고 답변해주신 내용을 보니 매우 뿌듯했습니다 ㅎㅎ
iOS를 끝내고 안드로이드도 거의 바로 진행했는데, 기존에 해본 내용들을 그대로 android로만 구현하면 되었어서, 큰 오류 없이 진행할 수 있었습니다.
베타 테스터들 덕분에 버그를 줄이고, 무사히 릴리즈에 성공했습니다.
기획부터 디자인, 개발, 테스트, A to Z를 다 경험하니 굉장히 뿌듯했습니다.
3주 전의 저는 "React 말고는 할 줄 아는 게 없는데 어떡하지?"라고 걱정하던, 스스로를 '프론트엔드'라는 틀 안에 가두고 있던 개발자였습니다.
하지만 iOS의 SwiftUI, Android의 Glance(Compose)를 직접 부딪쳐보며 깨달았습니다. 결국 플랫폼과 언어는 사용자가 원하는 기능을 구현하기 위한 '도구'에 불과하다는 것을요.
특히 React의 선언형 UI 패러다임이 네이티브 진영(SwiftUI, Compose)에서도 표준이 되어가고 있음을 목격했습니다.
이는 곧 프론트엔드 개발자와 네이티브 개발자 사이의 기술적 장벽이 점점 허물어지고 있음을 의미한다고 생각합니다. 문법은 달라도, 문제를 해결하는 사고방식은 하나로 통했으니까요.
현재 AI 기술의 발전으로 개발자에게 요구되는 역량은 '코드 작성'을 넘어 점점 '주도적인 문제 해결력'으로 변화하고 있다고 생각합니다. 저의 이러한 유연한 사고와 거침없는 실행력을 바탕으로, 앞으로는 '경계 없는 성장'을 만들어가고 싶습니다.
앞으로도 "나는 프론트엔드 개발자니까 여기까지만 해야지"라고 선을 긋기보다, 문제를 해결하기 위해서라면 기꺼이 그 선을 넘나드는 개발자, 새로운 기술에도 두려워하지 않고 도전할 수 있는 개발자로 나아가고자 합니다.
읽어주셔서 감사합니다!
기획부터 운영까지 꽉찬 경험이셨겠어요!
문제해결 사고방식 재미있게 읽고가요~!