외부 패키지를 걷어내고 직접 만든 Flutter 네온 렌더링 엔진 — NeonBoard 2.0 (2/5)
1. 들어가며: 패키지에 의존하던 1.0의 한계
NeonBoard 1.0은 텍스트 스크롤을 위해 marquee라는 외부 패키지를 사용하고 있었습니다. 설치 한 줄이면 좌우로 흐르는 텍스트를 만들 수 있었으니, 빠르게 MVP를 만들기엔 합리적인 선택이었습니다.
하지만 2.0을 기획하면서 이 패키지의 근본적인 한계에 부딪혔습니다. 첫째, 좌우 스크롤만 지원했습니다. 사용자들이 요청한 상하 스크롤은 불가능했습니다. 둘째, 실시간 속도 변경이 어려웠습니다. 디스플레이 모드에서 오버레이 슬라이더로 속도를 조절하는 UX를 구현하려면, 애니메이션을 중단하지 않고 속도를 바꿀 수 있어야 합니다. 셋째, 네온 글로우나 페이드 애니메이션 같은 복합 이펙트와의 조합이 불안정했습니다.
결론은 단순했습니다. 직접 만들자. 이번 글에서는 NeonBoard 2.0의 핵심인 커스텀 렌더링 엔진을 구성하는 네 가지 컴포넌트를 하나씩 살펴보겠습니다.
2. 렌더링 파이프라인 전체 구조
디스플레이 화면에서 최종적으로 사용자에게 보이는 전광판은 다음과 같은 파이프라인을 거쳐 조립됩니다.
텍스트 렌더링 (NeonTextRenderer)
↓
애니메이션 적용 (Blink / Fade)
↓
스크롤 래핑 (NeonScrollWidget)
↓
SafeArea + Padding (노치/Dynamic Island 영역 회피)
↓
테두리 장식 (NeonEdgeRenderer)
↓
좌우 반전 (Matrix4 flip)
↓
OLED 번인 방지 (미세 위치 이동)
각 단계는 독립적인 위젯으로 구현되어 있어, 사용자가 특정 옵션을 끄면 해당 위젯이 파이프라인에서 빠지는 구조입니다. 예를 들어 스크롤을 끄면 NeonScrollWidget이 아예 생략되고, 텍스트 렌더러의 출력이 바로 SafeArea 래핑으로 전달됩니다. 불필요한 위젯 레이어가 쌓이지 않으므로 성능 면에서도 유리합니다.
한 가지 의도적인 설계 결정은 SafeArea를 테두리(NeonEdgeRenderer) 안쪽에 배치했다는 것입니다. 이렇게 하면 텍스트 콘텐츠는 노치나 Dynamic Island 영역을 피하면서도, 네온 테두리는 화면 가장자리까지 확장되어 풀스크린 느낌을 유지할 수 있습니다.
3. NeonTextRenderer: 네온사인처럼 빛나는 글자
가장 먼저, 텍스트를 네온사인처럼 보이게 만드는 렌더러입니다. 핵심은 **다중 레이어 그림자(Shadow)**와 스트로크 오버레이 두 가지 기법의 조합입니다.
다중 레이어 글로우
실제 네온사인을 관찰하면, 빛이 중심부에서 가장 밝고 바깥으로 갈수록 부드럽게 퍼져나가는 것을 볼 수 있습니다. 이 효과를 디지털로 재현하기 위해, 하나의 텍스트에 여러 겹의 그림자를 동시에 적용하는 방식을 택했습니다.
// 의사코드: 네온 글로우 그림자 생성
function generateNeonShadows(color, intensity):
layers = clamp(intensity, 1, 5)
shadows = []
for i in 0..layers:
blurRadius = (i + 1) * 10.0 // 안쪽: 10, 바깥쪽: 50
opacity = (1.0 - i * 0.2) // 안쪽: 1.0, 바깥쪽: 0.2
opacity = clamp(opacity, 0.3, 1.0)
shadows.add(Shadow(color.withOpacity(opacity), blurRadius))
return shadows
intensity가 1이면 그림자 1겹(은은한 발광), 5이면 5겹(강렬한 네온)이 됩니다. 각 레이어는 blurRadius가 10씩 증가하면서 점점 넓게 퍼지고, 동시에 opacity가 낮아져 자연스러운 감쇠를 만들어냅니다. 이 조합이 실제 네온 조명의 광학적 특성과 꽤 유사한 결과를 만들어 줍니다.
텍스트 스트로크: 두 겹 쌓기 기법
네온 글자의 윤곽선(stroke)을 구현하는 것은 생각보다 까다로운 문제였습니다. Flutter의 Text 위젯에 Paint의 stroke 스타일을 적용하면, 글자 내부가 비어 보이는 현상이 발생합니다. 이는 stroke가 글자의 경로(path)를 따라 외곽선만 그리기 때문입니다.
해결 방법은 같은 텍스트를 두 개 쌓는 것이었습니다.
// 의사코드: 스트로크 + 채우기 조합
Stack(
children: [
// 아래 레이어: 스트로크 (외곽선)
Text(text, style: TextStyle(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = strokeColor
)),
// 위 레이어: 채우기 (본문)
Text(text, style: TextStyle(color: fillColor))
]
)
아래에 스트로크 버전을, 위에 채우기 버전을 정확히 같은 위치에 겹쳐 놓으면, 스트로크가 글자 바깥쪽으로만 보이는 자연스러운 외곽선이 완성됩니다.
자동 크기 조절: FittedBox의 활용
1.0에서는 텍스트 크기를 사용자가 직접 슬라이더로 조절해야 했습니다. 하지만 대부분의 사용 시나리오에서 "화면에 꽉 차게"가 정답이었습니다. 2.0에서는 자동 크기 조절 모드를 기본으로 제공합니다.
구현은 놀라울 만큼 간단합니다. FittedBox에 fit: BoxFit.contain을 지정하면, 내부 텍스트가 부모 영역에 맞게 자동으로 스케일됩니다. 기준 폰트 사이즈를 100으로 설정해 두고, FittedBox가 이를 컨테이너에 맞춰 축소하거나 확대합니다.
다만, 이 방식에는 주의할 점이 있었습니다. FittedBox는 자식 위젯의 고유 크기(intrinsic size)를 기준으로 스케일링하는데, 스크롤 위젯 내부에서 사용할 경우 LayoutBuilder로 명시적인 제약 조건을 전달해야 측정(measurement)과 렌더링 단계에서 동일한 크기를 사용하게 됩니다. 이 부분을 놓쳐서 초기에는 프리뷰와 실제 디스플레이에서 글자 크기가 다르게 표시되는 버그가 있었습니다.
4. NeonScrollWidget: Ticker 기반 4방향 마키
렌더링 엔진의 가장 복잡한 컴포넌트입니다. 외부 마키 패키지를 대체하면서, 4방향 스크롤과 실시간 속도 조절까지 지원해야 했습니다.
AnimationController 대신 Ticker를 선택한 이유
일반적으로 Flutter 애니메이션에는 AnimationController를 사용합니다. 그러나 마키 스크롤은 "시작에서 끝까지"라는 고정된 구간이 있는 애니메이션이 아닙니다. 텍스트가 화면 밖으로 완전히 사라진 뒤 다시 반대편에서 나타나는 무한 루프이고, 속도가 실시간으로 변할 수 있습니다.
AnimationController의 duration을 기반으로 속도를 제어하면, 속도 변경 시마다 duration을 재계산하고 컨트롤러를 리셋해야 합니다. 이 과정에서 스크롤 위치가 점프하는 현상이 불가피합니다.
반면 Ticker는 매 프레임마다 델타 시간(이전 프레임과의 시간 차)만 알려줍니다. 이 델타 시간에 현재 속도를 곱해서 이동량을 계산하면, 속도를 바꿔도 위치가 자연스럽게 이어집니다.
// 의사코드: 프레임 델타 기반 스크롤
function onTick(elapsed):
delta = elapsed - lastElapsed
delta = clamp(delta, 0, 0.1초) // 백그라운드 복귀 시 점프 방지
offset += delta * speed * 30.0 // speed 1 = 30px/s
if offset > childWidth + gap:
offset = 0 // 무한 루프
lastElapsed = elapsed
delta를 0.1초로 클램핑하는 부분은 실제 운영에서 발견한 문제의 해결책입니다. 앱이 백그라운드에 갔다가 돌아오면, 마지막 프레임과 현재 프레임 사이의 시간 차가 수십 초에 달할 수 있습니다. 클램핑하지 않으면 텍스트가 순간적으로 수천 픽셀 점프하는 현상이 발생합니다.
2-Copy 기법으로 끊김 없는 무한 스크롤
마키 스크롤에서 텍스트가 화면 밖으로 빠져나갈 때, 동시에 반대편에서 동일한 텍스트가 등장해야 자연스러운 루프가 됩니다. 이를 위해 같은 텍스트를 두 벌 렌더링하고, 고정된 간격(gap)을 두고 나란히 배치합니다.
// 의사코드: 2-Copy 무한 스크롤
[텍스트 A] ---gap--- [텍스트 B]
offset이 증가하면:
텍스트 A의 위치 = -offset
텍스트 B의 위치 = -offset + childWidth + gap
텍스트 A가 완전히 화면 밖으로 나가면:
offset을 0으로 리셋
→ 텍스트 B가 있던 자리에서 텍스트 A가 다시 시작
→ 무한 루프 완성
핵심은 위치 업데이트에 ValueNotifier를 사용한다는 것입니다. setState로 매 프레임 리빌드를 트리거하면 성능이 크게 저하됩니다. ValueNotifier + ValueListenableBuilder 조합을 사용하면, Transform.translate의 오프셋 값만 변경되고 위젯 트리 전체가 리빌드되는 것을 피할 수 있습니다. 60fps에서 동작해야 하는 애니메이션이므로 이 최적화는 필수적이었습니다.
자식 위젯 크기 측정 문제
무한 스크롤이 올바르게 동작하려면, 스크롤할 텍스트의 실제 렌더링 너비(또는 높이)를 정확히 알아야 합니다. 그래야 "텍스트가 화면 밖으로 완전히 빠져나간 시점"을 판단하고 오프셋을 리셋할 수 있습니다.
문제는 Flutter에서 위젯의 크기를 렌더링 전에 알 수 없다는 것입니다. GlobalKey를 부여하고, addPostFrameCallback 안에서 RenderBox의 size를 읽어오는 방식을 사용했습니다.
// 의사코드: 렌더링 후 크기 측정
onAfterFirstFrame():
renderBox = globalKey.currentContext.findRenderObject()
if renderBox.hasSize:
childWidth = renderBox.size.width
startTicker() // 크기를 알아야 스크롤 시작 가능
hasSize 체크는 안전장치입니다. 위젯 트리가 아직 레이아웃을 완료하지 않은 상태에서 size에 접근하면 에러가 발생하기 때문입니다.
5. NeonEdgeRenderer: 테두리도 빛나게
전광판의 테두리에 네온 효과를 주는 컴포넌트입니다. 원리는 텍스트의 네온 글로우와 비슷하지만, BoxShadow를 활용합니다.
// 의사코드: 2-Layer 테두리 글로우
Container(
decoration: BoxDecoration(
border: Border(color: edgeColor, width: thickness),
borderRadius: radius,
boxShadow: [
// 안쪽 레이어: 선명한 발광
BoxShadow(color: edgeColor.withAlpha(0.6), blurRadius: 8),
// 바깥 레이어: 은은한 확산
BoxShadow(color: edgeColor.withAlpha(0.3), blurRadius: 20),
]
)
)
2겹의 BoxShadow를 사용하면 안쪽은 선명하게, 바깥쪽은 부드럽게 번지는 발광 효과가 만들어집니다. 한 가지 주의할 점은 ClipRRect로 내부 콘텐츠가 테두리 바깥으로 넘어가지 않도록 클리핑하는 것이었습니다. 특히 스크롤 중인 텍스트가 테두리 모서리를 뚫고 나오는 현상을 방지하기 위해 반드시 필요한 처리였습니다.
6. 애니메이션 엔진: Blink과 Fade
텍스트 전체에 적용되는 애니메이션은 현재 두 가지를 지원합니다. 블링크(깜빡임)와 페이드(숨 쉬는 네온)입니다.
Blink: 하드한 ON/OFF
블링크는 AnimationController.repeat(reverse: true)로 구현됩니다. Opacity 위젯의 값을 1.0에서 0.0으로 반복합니다. 속도 공식은 다음과 같습니다.
// 의사코드: 블링크 속도 계산
duration(ms) = 2000 - (speed - 1) * 200
// speed 1 → 2초 주기 (느린 깜빡임)
// speed 10 → 200ms 주기 (빠른 깜빡임)
Fade: 숨 쉬는 네온
페이드는 블링크와 기본 구조는 같지만, 두 가지가 다릅니다. 첫째, 완전히 사라지지 않고 opacity 0.3까지만 낮아집니다. 실제 네온사인은 완전히 꺼지지 않고 은은하게 남아있기 때문입니다. 둘째, Curves.easeInOut을 적용해서 부드러운 곡선을 만들었습니다. 이 두 가지 차이만으로 "숨을 쉬는 듯한" 네온 효과가 완성됩니다.
속도 변경 시 끊김 방지
두 애니메이션 모두 사용자가 슬라이더로 속도를 실시간 변경할 수 있습니다. 이때 AnimationController의 duration을 바꿔야 하는데, 단순히 새 duration을 설정하면 애니메이션이 처음부터 다시 시작됩니다.
이를 방지하기 위해, duration 변경 직전에 현재 controller.value를 저장하고, 변경 후 그 위치부터 다시 시작하는 방식을 사용했습니다.
// 의사코드: 끊김 없는 속도 변경
function onSpeedChanged(newSpeed):
currentProgress = controller.value // 현재 위치 저장
controller.duration = calculateDuration(newSpeed)
controller.forward(from: currentProgress) // 이어서 재생
7. 개발 과정의 챌린지
프리뷰와 디스플레이의 크기 불일치
가장 오래 고민한 버그였습니다. 에디터의 프리뷰 영역과 실제 디스플레이 화면에서 텍스트 크기가 다르게 보이는 현상이었습니다.
원인은 FittedBox가 부모 컨테이너의 제약 조건(constraints)에 따라 스케일링하는데, 프리뷰와 디스플레이의 컨테이너 크기가 당연히 다르다는 것이었습니다. FittedBox 자체는 정상 동작하고 있었지만, 스크롤 위젯 내부에서 FittedBox를 사용할 때 LayoutBuilder가 빠져 있어서 측정 단계와 렌더링 단계의 제약 조건이 달라지는 문제가 있었습니다.
LayoutBuilder를 스크롤 위젯과 FittedBox 사이에 삽입하여 명시적인 크기 제약을 전달함으로써 해결했습니다.
백그라운드 복귀 시 스크롤 점프
앱이 백그라운드에 갔다가 돌아올 때, Ticker가 "정지된 동안의 시간"을 한 번에 처리하려고 시도하면서 텍스트가 순간적으로 수천 픽셀 이동하는 문제가 있었습니다.
프레임 델타를 0.1초로 클램핑하는 것으로 해결했지만, 이 문제를 발견하기까지 시간이 걸렸습니다. 개발 중에는 앱을 백그라운드로 보내는 상황을 자주 테스트하지 않았기 때문입니다. 이후로는 "앱 생명주기 전환"을 테스트 시나리오에 반드시 포함하게 됐습니다.
8. 마치며
렌더링 엔진을 직접 만드는 것은 분명 더 많은 시간과 노력이 필요한 선택이었습니다. 하지만 그 대가로 얻은 것은 명확합니다. 4방향 스크롤, 실시간 속도 조절, 네온 글로우의 세밀한 제어, 그리고 애니메이션과의 자유로운 조합. 이 중 어느 것도 기존 패키지로는 구현하기 어려운 것들이었습니다.
Flutter의 Ticker와 RenderBox API는 이런 저수준 제어가 필요한 상황에서 충분히 강력한 도구였습니다. "패키지가 없으면 직접 만들면 된다"는 Flutter 개발의 자유도를 체감한 경험이었습니다.
다음 글에서는 에디터의 Undo/Redo 시스템과 자동 저장 메커니즘을 다루겠습니다. 40개 필드를 가진 Board 객체에서 "어떤 변경은 Undo 스택에 넣고, 어떤 변경은 넣지 않는가"라는 고민이 궁금하시다면 기대해 주세요.