AppOrbit
← blog

앱의 완성도를 결정짓는 마지막 10% — i18n, 테마, 그리고 프로덕션 하드닝 (5/5)

Flutter, i18n, ThemeSystem, DarkMode, OLED, NeonBoard

1. 들어가며: 기능은 완성됐지만, 앱은 완성되지 않았다

시리즈 1~4편에 걸쳐 아키텍처, 렌더링 엔진, 상태 관리, 수익화까지 NeonBoard 2.0의 핵심 기능을 모두 다루었습니다. 코드를 빌드하면 돌아가고, 기획서의 모든 기능이 구현된 상태였습니다.

하지만 "돌아가는 앱"과 "출시할 수 있는 앱" 사이에는 꽤 큰 간극이 있습니다. 다국어 지원은 되는데 전환할 때 어색하지는 않은지, 다크 테마가 일관된 깊이감을 주는지, 전광판을 오래 켜두면 화면이 손상되지는 않는지, 아이폰의 Dynamic Island에서 콘텐츠가 가려지지는 않는지.

이런 것들은 기능 목록에는 한 줄로 적히지만, 실제로는 상당한 시간과 고민이 필요한 작업들입니다. 이 마지막 편에서는 NeonBoard 2.0의 완성도를 끌어올린 "마지막 10%"의 작업들을 정리합니다.


2. 커스텀 i18n 시스템: flutter_localizations를 쓰지 않은 이유

NeonBoard 2.0은 영어와 한국어 두 개 언어를 지원합니다. Flutter의 공식 다국어 솔루션인 flutter_localizations + ARB 파일 시스템을 사용하는 것이 정석이지만, 의도적으로 사용하지 않았습니다.

이유는 세 가지였습니다.

첫째, NeonBoard의 문자열이 120여 개 수준으로 비교적 적습니다. ARB 파일 시스템은 수백, 수천 개의 문자열을 관리하는 대규모 앱에 적합하지만, 이 정도 규모의 문자열에 flutter_localizations, intl, ARB 파일, 코드 생성 파이프라인을 도입하는 것은 오버 엔지니어링이라고 판단했습니다.

둘째, 앱 내 언어 전환이 필요했습니다. NeonBoard는 시스템 언어를 따르지 않고, 사용자가 앱 안에서 EN/KO를 직접 토글합니다. flutter_localizations는 시스템 로케일 기반으로 동작하도록 설계되어 있어, 앱 내 전환과의 통합이 자연스럽지 않습니다.

셋째, Riverpod과의 자연스러운 통합이었습니다. Provider 기반으로 언어를 관리하면, 로케일 변경 시 UI가 자동으로 리빌드되는 Riverpod의 반응형 시스템을 그대로 활용할 수 있습니다.

구현 구조

// 의사코드: 커스텀 i18n 시스템

// 추상 클래스로 문자열 인터페이스 정의
abstract class AppStrings:
    get appTitle: String
    get myBoards: String
    get start: String
    get settings: String
    get fontPreviewText: String
    // ... 120여 개의 문자열 getter

// 영어 구현
class EnStrings extends AppStrings:
    appTitle = "NeonBoard"
    myBoards = "My Boards"
    start = "Start"
    fontPreviewText = "AaBb 123"

// 한국어 구현
class KoStrings extends AppStrings:
    appTitle = "네온보드"
    myBoards = "내 보드"
    start = "시작"
    fontPreviewText = "가나다 ABC"

// Riverpod Provider
stringsProvider = Provider((ref):
    locale = ref.watch(appSettingsProvider).locale
    return locale == 'ko' ? KoStrings() : EnStrings()
)

사용하는 쪽에서는 ref.watch(stringsProvider)로 문자열 객체를 가져와서 s.myBoards, s.start 같은 형태로 사용합니다. Dart의 타입 시스템이 "이 키가 존재하는가?"를 컴파일 타임에 검증해 주므로, ARB 파일에서 키 오타로 런타임 에러가 나는 상황을 원천 차단합니다.

앱 내 언어 토글

홈 화면 AppBar에 EN/KO 두 개의 칩(Chip)이 나란히 배치되어 있습니다. 현재 선택된 언어의 칩에는 네온 블루 하이라이트가 적용됩니다. 탭하면 appSettingsProvider의 로케일이 변경되고, Hive에 즉시 영속화됩니다. stringsProviderappSettingsProviderwatch하고 있으므로, 로케일이 바뀌는 순간 앱 전체의 문자열이 자동으로 전환됩니다.

한 가지 세심한 디테일은 폰트 프리뷰 텍스트가 언어에 따라 다르다는 것입니다. 영어 사용자에게는 "AaBb 123"이, 한국어 사용자에게는 "가나다 ABC"가 표시됩니다. 이는 해당 폰트가 한글을 지원하는지 직관적으로 판단할 수 있게 해줍니다.


3. 4-Depth 다크 테마 시스템

NeonBoard는 네온사인 앱입니다. 밝은 테마는 아예 고려하지 않았습니다. 네온 효과가 빛나려면 배경이 어두워야 하기 때문입니다.

하지만 "그냥 검은 배경"만으로는 부족합니다. 카드, 입력 필드, 다이얼로그, 바텀 시트 등 다양한 UI 요소가 각각 다른 "깊이(depth)"를 가져야 시각적 계층 구조가 만들어집니다.

4단계 Surface 시스템

// 의사코드: Surface 깊이 정의

depth0 = Color(0xFF0A0A1A)   // 가장 깊음: 스캐폴드 배경 (거의 검정에 가까운 남색)
depth1 = Color(0xFF111128)   // 카드, 리스트 아이템
depth2 = Color(0xFF1A1A35)   // 입력 필드, 컨트롤
depth3 = Color(0xFF222248)   // 팝업, 바텀 시트 (가장 밝음)

깊이가 증가할수록 밝아지는 구조입니다. 이는 Material Design의 "elevation" 개념과 동일하지만, 그림자 대신 색상의 밝기로 깊이를 표현합니다. OLED 화면에서 다크 테마의 그림자는 거의 보이지 않기 때문에, 색상 차이로 계층을 나타내는 것이 더 효과적입니다.

모든 색상은 순수한 회색이 아니라 남색(navy) 계열입니다. 이는 네온 블루(#00D4FF), 네온 핑크(#FF00FF), 네온 그린(#39FF14)의 액센트 컬러와 자연스럽게 어울리기 위함입니다.

3가지 네온 액센트 컬러

// 의사코드: 액센트 컬러 역할 분배
neonBlue  = Color(0xFF00D4FF)  // Primary: 인터랙티브 요소, 슬라이더, 탭바
neonPink  = Color(0xFFFF00FF)  // Secondary: 닫기, 삭제, 경고
neonGreen = Color(0xFF39FF14)  // Tertiary: 성공, 편집, 긍정적 액션

색상에 의미를 부여함으로써, 사용자가 UI를 학습한 뒤에는 색상만으로 버튼의 역할을 예측할 수 있습니다. 디스플레이 오버레이의 세 개 원형 버튼이 좋은 예시입니다. 파란 버튼은 일시정지/재생(기본 동작), 초록 버튼은 편집(에디터로 이동), 분홍 버튼은 닫기(디스플레이 종료)입니다.

디자인 토큰화

간격(spacing)과 모서리 반경(radius)도 토큰으로 관리합니다.

// 의사코드: 간격과 반경 토큰
spacingXS  = 4      spacingSM  = 8
spacingMD  = 12     spacingLG  = 16
spacingXL  = 24     spacingXXL = 32

radiusSM   = 8      radiusMD   = 12
radiusLG   = 16     radiusFull = 999

토큰을 사용하면 "이 카드의 패딩을 좀 더 넓히고 싶다"라는 요구에 spacingMDspacingLG로 변경하는 것으로 대응할 수 있고, 앱 전체에서 일관된 간격을 유지할 수 있습니다.


4. OLED 번인 방지

NeonBoard의 디스플레이 모드는 장시간 사용을 전제로 합니다. 매장 안내 문구를 하루 종일 띄워놓거나, 콘서트에서 몇 시간 동안 응원 문구를 표시하는 경우가 많습니다.

OLED 화면에서 같은 이미지를 오래 표시하면 번인(burn-in) 현상이 발생합니다. 특정 픽셀이 고정된 색상을 오래 표시하면서 영구적으로 변색되는 것입니다. 특히 테두리 영역이나 밝은 텍스트 가장자리에서 번인이 잘 발생합니다.

미세 위치 이동 기법

번인을 방지하는 가장 효과적인 방법은 콘텐츠의 위치를 주기적으로 아주 조금씩 이동시키는 것입니다. NeonBoard에서는 30분마다 1픽셀씩 이동합니다.

// 의사코드: OLED 번인 방지
directions = [오른쪽, 아래, 왼쪽, 위]  // 4방향 순환
currentDirection = 0

burnInTimer = Timer.periodic(30분):
    direction = directions[currentDirection % 4]
    currentDirection += 1

    // 현재 방향으로 1px 이동
    switch direction:
        오른쪽: offsetX += 1
        아래:   offsetY += 1
        왼쪽:   offsetX -= 1
        위:     offsetY -= 1

// 위치 변경은 AnimatedContainer로 2초에 걸쳐 부드럽게 전환
AnimatedContainer(
    duration: 2초,
    transform: Matrix4.translation(offsetX, offsetY, 0),
    child: 전광판 콘텐츠
)

30분마다 1픽셀이므로, 사용자가 이동을 인지하는 것은 사실상 불가능합니다. 하지만 OLED 패널 입장에서는 같은 픽셀이 2시간 이상 동일한 색상을 표시하지 않게 됩니다. 4방향 순환이므로 2시간(30분 x 4) 후에는 원래 위치로 돌아옵니다.

AnimatedContainer의 2초 전환 시간은 갑작스러운 점프를 방지합니다. 사용자가 화면을 응시하고 있더라도 이동을 눈치채기 어렵습니다.


5. Dynamic Island과 노치 대응

아이폰 14 Pro 이후 모델의 Dynamic Island, 그리고 이전 모델의 노치(notch)는 전광판 모드에서 심각한 문제를 일으켰습니다. 디스플레이 모드는 SystemUiMode.immersiveSticky로 상태바와 내비게이션바를 숨기는데, Dynamic Island은 물리적인 하드웨어이므로 숨길 수 없습니다.

텍스트가 Dynamic Island 뒤에 가려지거나, 노치 영역과 겹치는 문제가 실제 기기 테스트에서 발견됐습니다.

// 의사코드: SafeArea 적용 (렌더링 파이프라인 내 위치)
// 텍스트 → 애니메이션 → 스크롤 → [SafeArea + Padding] → 테두리 → 플립

content = buildTextAndScroll(board)  // 텍스트 + 애니메이션 + 스크롤

content = SafeArea(
    top: true,      // Dynamic Island / 노치 영역 회피
    bottom: true,   // 홈 인디케이터 영역 회피
    left: false,    // 가로 모드에서 좌우는 풀 활용
    right: false,
    child: Padding(padding: 16, child: content)
)

content = NeonEdgeRenderer(child: content)  // 테두리는 SafeArea 바깥

핵심은 SafeArea테두리(NeonEdgeRenderer) 안쪽에 위치한다는 것입니다. 이렇게 하면 텍스트 콘텐츠는 노치나 Dynamic Island 영역을 피하면서도, 네온 테두리는 화면 가장자리까지 확장되어 풀스크린의 몰입감을 유지합니다.

leftrightfalse로 둔 이유는, 가로 모드에서 좌우 영역까지 SafeArea로 처리하면 전광판의 폭이 불필요하게 좁아지기 때문입니다. Dynamic Island은 세로 모드에서 상단에만 존재하므로, 가로 모드에서는 좌우 SafeArea가 필요하지 않습니다.


6. 디스플레이 모드의 생명주기 관리

전광판 모드의 진입과 종료에는 여러 시스템 설정을 조작해야 합니다. 진입 시 활성화한 설정을 종료 시 반드시 복원해야, 앱을 나갔을 때 기기가 정상 상태로 돌아옵니다.

// 의사코드: 디스플레이 모드 생명주기

function onInit():
    // 진입 시 활성화
    SystemChrome.setSystemUIMode(immersiveSticky)  // 풀스크린
    WakelockPlus.enable()                           // 화면 꺼짐 방지
    startBurnInTimer()                              // OLED 번인 방지 시작
    setOrientation(board.orientation)               // 방향 잠금

function onDispose():
    // 종료 시 복원
    SystemChrome.setSystemUIMode(edgeToEdge)        // 시스템 UI 복원
    SystemChrome.setOrientation(portrait)            // 세로 모드 복원
    WakelockPlus.disable()                          // 화면 꺼짐 허용
    cancelBurnInTimer()                              // 타이머 정리

WakelockPlus는 특히 중요합니다. 전광판을 켜두는 동안 화면이 자동으로 꺼지면 사용 목적이 무너지기 때문입니다. 하지만 disable()을 빠뜨리면, 앱을 나간 후에도 기기의 화면이 꺼지지 않는 심각한 문제가 발생합니다. dispose에서 반드시 정리해야 하는 이유입니다.


7. 디스플레이 오버레이: 사라지는 컨트롤

전광판 모드에서 화면을 탭하면 반투명 오버레이가 나타납니다. 스크롤 속도 슬라이더와 일시정지/편집/닫기 버튼이 포함되어 있습니다.

// 의사코드: 자동 숨김 오버레이
isOverlayVisible = false
hideTimer: Timer?

function onScreenTapped():
    isOverlayVisible = !isOverlayVisible
    resetHideTimer()

function resetHideTimer():
    hideTimer?.cancel()
    if isOverlayVisible:
        hideTimer = Timer(5초, () => isOverlayVisible = false)

function onSliderDragged():
    resetHideTimer()  // 상호작용 중에는 타이머 리셋

오버레이는 5초 후 자동으로 사라집니다. 슬라이더를 드래그하거나 버튼을 탭하는 등 상호작용이 발생하면 타이머가 리셋되어 5초가 다시 시작됩니다. 이렇게 하면 사용자가 조작 중에 오버레이가 갑자기 사라지는 일이 없습니다.

오버레이의 배경에는 BackdropFilter로 블러를 적용하여 프로스트 글래스(frosted glass) 효과를 만들었습니다. 전광판 콘텐츠가 완전히 가려지지 않으면서도, 오버레이 위의 버튼이 명확하게 보이는 균형을 맞췄습니다.


8. NeonSlider: 정밀한 값 입력 UX

에디터의 슬라이더는 폰트 크기, 스크롤 속도, 글로우 강도 등 다양한 설정에 사용됩니다. 기본 슬라이더만으로는 손가락으로 정확한 값을 맞추기 어렵다는 문제가 있었습니다.

// 의사코드: NeonSlider 구조
Row(
    children: [
        // 좌측: -1 스텝 버튼 (선택적)
        IconButton(icon: minus, onTap: value -= step),

        // 중앙: 슬라이더
        Expanded(child: Slider(value, min, max, onChanged)),

        // 우측: +1 스텝 버튼 (선택적)
        IconButton(icon: plus, onTap: value += step),
    ]
)
// 상단: 라벨 + 현재 값 (탭하면 직접 입력 다이얼로그)
Row(
    children: [
        Text(label),
        Spacer(),
        GestureDetector(
            onTap: showNumberInputDialog(),
            child: Text(currentValue)
        )
    ]
)

세 가지 입력 방식을 동시에 제공합니다. 슬라이더 드래그로 대략적인 값을 맞추고, 양쪽 스텝 버튼으로 1단위씩 미세 조정하고, 현재 값을 탭하면 숫자를 직접 입력하는 다이얼로그가 열립니다. 이 세 가지가 결합되면 "대략적인 조작 → 미세 조정 → 정확한 값 입력"의 자연스러운 흐름이 만들어집니다.


9. 빌드와 배포: 마지막 허들들

Gradle/AGP/Kotlin 버전 호환성

Android 빌드에서 가장 많은 시간을 소모한 문제는 Gradle, Android Gradle Plugin(AGP), Kotlin 버전 간의 호환성이었습니다. Flutter의 플러그인들이 요구하는 최소 버전이 저마다 다르고, 세 가지 중 하나를 올리면 다른 것도 함께 올려야 하는 연쇄 반응이 발생합니다.

최종적으로 targetSdk 35(Android 15), Java 17, 최신 AGP로 맞추면서 빌드가 안정됐습니다. 이 과정에서 배운 교훈은 Flutter 프로젝트를 생성한 후 가능한 빨리 최신 버전으로 맞춰두는 것이 장기적으로 유리하다는 것입니다. 나중에 한 번에 올리면 호환성 문제가 기하급수적으로 복잡해집니다.

QA: 36개의 버그

2.0의 핵심 기능을 모두 구현한 후, 전수 테스트를 진행했습니다. flutter analyze에서 이슈 0개를 확인한 뒤, 실제 기기에서 시나리오별 테스트를 수행했습니다. 그 결과 36개의 버그를 발견하고 수정했습니다.

기억에 남는 버그 몇 가지를 소개합니다.

기본 언어 문제: 초기에는 한국어가 기본 언어였는데, 해외 사용자에게 한국어 UI가 표시되는 문제가 있었습니다. 시스템 로케일을 따르지 않고 앱 내 설정만 사용하는 구조였기 때문에, 기본값을 영어로 변경했습니다.

에디터에서 홈으로 돌아올 때 보드 카드가 업데이트되지 않는 문제: boardListProvidercurrentBoardProvider의 변경을 자동으로 반영하지 않아서, 에디터에서 수정한 내용이 홈 그리드에 즉시 반영되지 않았습니다. 에디터를 나갈 때 명시적으로 boardListProvider를 갱신하는 것으로 해결했습니다.


10. 마치며: NeonBoard 2.0 시리즈를 마무리하며

5편에 걸쳐 NeonBoard 2.0의 전체 개발 과정을 다루었습니다. 아키텍처 설계부터 렌더링 엔진, 상태 관리, 수익화, 그리고 이번 글의 마무리 작업까지.

돌이켜보면, 가장 큰 수확은 **"처음부터 다시 만드는 결단"**이었습니다. 1.0의 코드를 살리면서 기능을 추가하는 것은 단기적으로 빠르지만, 장기적으로는 기술 부채가 누적됩니다. 2.0은 깨끗한 아키텍처 위에서 시작했기 때문에, 앞으로 새로운 기능을 추가하거나 버그를 수정할 때도 훨씬 수월할 것입니다.

아직 해야 할 일도 많습니다. 타이핑, 웨이브, 레인보우 같은 프리미엄 애니메이션은 UI에서 잠금 처리만 되어 있고 실제 구현은 남아있습니다. 보드 내보내기/공유 기능, 더 많은 폰트 추가, 위젯 지원 등도 계획하고 있습니다.

NeonBoard는 App StoreGoogle Play에서 무료로 다운로드할 수 있습니다. 콘서트 응원, 매장 안내, 혹은 그냥 재미로 네온 전광판을 만들어 보고 싶으시다면 한 번 사용해 보세요. 피드백은 언제나 환영합니다.

이 시리즈가 Flutter로 앱을 만들거나, 기존 앱을 리뉴얼하려는 분들에게 조금이라도 도움이 됐으면 좋겠습니다. 읽어주셔서 감사합니다.