메시지 앱은 대체로 비슷하게 생겼습니다. 둥근 사각형 카드에 왼쪽 정렬 목록, 아래에서 올라오는 새 말풍선까지. 하루에도 몇 번씩 여는 화면은 단순해야 하고 오래 써도 덜 피곤해야 하니, UI들이 직관적이고 깔끔한건 좋은 결과예요.
게임 UI는 그 제약에서 비교적 자유롭습니다. 화면 자체가 즐길 거리의 일부라서 편안함보다 개성을 우선할 수 있는데, Persona 5의 메시지 화면이 그런 경우예요. 말풍선은 네 변이 전부 비스듬하고, 인물 사진은 기울어진 프레임에 담겨 있고, 대화 순서를 따라 검은 선이 지그재그로 꺾이며 내려갑니다.
이 화면을 처음 봤을 때 든 질문은 "웹에서도 만들 수 있을까"였어요. 실제로 옮겨 보니 따로 짚어볼 만한 지점이 세 군데 있었고, 그것들을 하나씩 정리했습니다.
저작권 안내
©ATLUS ©SEGA
이 데모에 사용된 모든 아트와 캐릭터 디자인의 저작권은 Atlus Co., Ltd.에 있습니다. 모든 자료는 교육 목적으로만 사용했습니다.
먼저 살펴보기
대화창 아무 곳이나 누르면 다음 메시지가 나오고, 열두 개를 다 보면 처음부터 다시 시작합니다. 왼쪽 위 IM 로고로는 계절을 바꿀 수 있어요. 진행하면서 아래 세 가지를 눈여겨봐 주세요. 뒤에 나올 세 절이 각각 여기서 출발합니다.
- 말풍선의 폭 — 문장마다 다름, 좌우 두 변의 기울기는 동일
- 인물 사진 — 머리는 프레임 위로 넘치고, 아래쪽은 프레임 경계선에서 잘림
- 메시지 사이의 검은 선 — 새 메시지가 올 때만 아래로 늘어나고, 이미 그려진 조각은 고정



Tap to start the conversation
Conversation not started
Tap the chat to continue. Select the IM logo to change the season.
말풍선의 폭과 검은 선의 폭·방향은 누를 때마다 달라집니다. 미리 그려 둔 이미지를 붙여서는 만들 수 없고, 매번 계산해서 그려야 하는 화면이에요. 그리고 그 계산이 성립하려면 조건이 하나 필요합니다.
이 화면의 좌표는 전부 절대 픽셀값입니다. 아바타 110×90, 겹침 18px처럼 컨테이너 폭과 무관하게 고정된 숫자예요. 그래서 반응형을 요소 단위로 풀지 않고, 모든 것을 400×600 고정 영역에 그린 뒤 그 영역 전체를 transform: scale() 하나로 줄였습니다. 이 글에 나오는 픽셀 값이 화면 크기와 상관없이 항상 유효한 건 그 덕분이에요. 대신 영역 안에는 반응형이 없어서, 좁은 화면에서는 레이아웃이 재배치되지 않고 글자까지 같이 작아집니다.
폭이 변하는 비스듬한 도형 그리기
말풍선은 담긴 문장이 길어지면 같이 커져야 합니다. 가장 쉬운 방법은 완성된 말풍선을 이미지 한 장으로 잘라 두는 것인데, 이미지는 정해진 크기에서만 맞아요. 두 줄짜리 메시지가 들어오는 순간 글자가 말풍선 밖으로 넘칩니다.
이미지를 늘이는 방법도 있습니다. 반듯한 사각형이라면 늘어나도 되는 부분만 골라 늘이는 nine-patch 같은 기법을 쓸 수 있어요. 하지만 네 변이 전부 기울어진 도형에는 늘여도 안전한 방향이 없습니다. 가로로 늘이든 세로로 늘이든 변의 기울기와 꼭짓점 각도가 함께 변해요.
그래서 말풍선을 크기를 받아 좌표를 돌려주는 함수로 정의했습니다.
const getEntryOuterBox = (width: number, height: number) =>
`M 31.7 3.1 L ${width} 0 L ${width - 23} ${height} L 15.6 ${height - 8} Z`;SVG path에서 M은 시작점, L은 직선, Z는 도형 닫기입니다. 반듯한 직사각형이라면 네 꼭짓점이 (0,0)·(width,0)·(width,height)·(0,height)일 텐데, 여기서는 꼭짓점들이 그 위치에서 조금씩 비껴나 있어요. 비껴난 거리가 width·height와 무관한 고정값이라, 폭이 달라져도 좌우 변의 기울기는 변하지 않습니다.
텍스트를 일반 DOM 요소로 렌더링한 뒤 offsetWidth와 offsetHeight를 읽어 이 함수에 넘기고, 흰 테두리와 검은 면 두 겹을 그 크기로 다시 그립니다. 꼬리는 별도 요소로 붙이지 않고 같은 SVG 안에서 꼬리를 먼저, 본체를 나중에 그려요. 본체가 이음새를 덮기 때문에 도형 두 개를 맞붙일 때 생기는 틈이 없습니다.
다만 첫 렌더가 문제입니다. path를 그리려면 텍스트 크기가 필요한데, 텍스트는 화면에 렌더링돼야 잴 수 있거든요. 그래서 첫 렌더에서는 임의로 정한 200×60으로 그리고, 측정이 끝나면 실측값으로 다시 그립니다. 실측값이 바로 덮어쓰기 때문에 시작값은 무엇이어도 상관없어요.
프레임은 그대로 두고 사진만 자르기
아바타는 검정·흰색·캐릭터 색상 프레임 세 겹 위에 인물 사진을 얹은 구조입니다. 원본 그림에는 프레임에 담기는 것보다 넓은 범위가 그려져 있어서, 그대로 얹으면 인물이 프레임 위아래로 삐져나와요. 문제는 이걸 전부 잘라 내면 안 된다는 겁니다. 사진 레이어가 검정 프레임 윗변보다 14~21px 위에서 시작해 머리가 프레임 위로 솟은 모습이 이 화면의 특징이라, 위쪽은 남기고 아래쪽만 프레임 경계에서 끊어야 해요. 물론 프레임 세 겹은 어디도 잘리면 안 되고요.
첫 시도는 컨테이너 전체에 clip-path를 하나 거는 것이었는데, 사진과 함께 바깥 프레임까지 잘려 나갔습니다. clip-path는 요소가 그리는 것 전부에 적용되는 속성이라, 안에 무엇이 몇 겹 들어 있는지 구분하지 않기 때문이에요.
한 요소 안에서 프레임은 남기고 사진만 자를 수는 없으니, 요소를 나눴습니다.
{/* 프레임 세 겹 — 클리핑 바깥이라 항상 온전하다 */}
<svg className="absolute inset-0">
<path d={blackBox} fill="black" />
<path d={whiteBox} fill="white" />
<path d={coloredBox} fill={senderColor} />
</svg>
{/* 사진 레이어에만 클리핑을 건다 */}
<div style={{ clipPath: `path('${clipBox}')` }}>
<CharacterImage />
</div>프레임 SVG는 클리핑 바깥에 두고, 사진을 감싼 레이어에만 clip-path의 path() 값을 적용합니다. 이러면 "위는 남기고 아래만 자르기"가 클리핑 도형의 모양 문제로 바뀝니다.
clipBox: 'M 10.3 -5.6 L 114.7 -5.6 L 114.7 65.6 L 40 76.6 Z';이 도형에서 실제로 사진을 자르는 변은 왼쪽과 아래 둘뿐입니다. 위쪽 변(-5.6)과 오른쪽 변(114.7)은 아바타 박스(110×90) 바깥에 있어서 걸리는 픽셀이 없고, 위쪽을 자르는 변이 없으니 머리는 그대로 프레임 밖으로 남아요. 자르는 두 변은 안쪽 프레임 coloredBox의 같은 변과 기울기가 거의 같고, 꼭짓점 (40, 76.6)은 아예 공유합니다. 사진이 끊기는 선이 안쪽 프레임의 경계와 겹친다는 뜻이에요. 그래서 실제로는 인물 픽셀의 10~13%가 잘려 나가는데도, 처음부터 프레임에 맞게 그린 사진처럼 보입니다.
아쉬운 점은 이 맞물림이 코드에 드러나지 않는다는 것입니다. 프레임 두께를 바꾸면 clipBox도 같이 고쳐야 하는데, 그 사실을 알려 주는 장치가 없어요. 프레임 세 겹의 여백도 꼭짓점마다 달라서(오른쪽 아래 4.5px, 왼쪽 위 16.8px), 형태를 바꾸려면 결국 네 path의 꼭짓점 열여섯 개를 손으로 다시 맞춰야 합니다.
스크롤해도 어긋나지 않는 검은 선 그리기
검은 선은 메시지마다 하나씩 정해진 기준점을 잇습니다. 폭은 44~60px이고, 캐릭터 메시지는 아바타 중심이, 주인공 답장은 오른쪽 끝에서 60px 안쪽 지점이 기준점이에요. 새 메시지가 들어올 때마다 선이 위쪽 두 점에서 아래쪽 두 점으로 늘어나고, 목록을 스크롤해도 양 끝은 각자의 메시지에 붙어 있어야 합니다.
처음에는 이 요구를 배경에 Canvas 한 장을 깔고 선 전체를 거기 그리는 구조로 풀려고 했습니다. 그런데 대화 목록은 스크롤되는 DOM이고, 배경 Canvas의 좌표계는 그 스크롤과 무관해요. 메시지가 밀릴 때마다 모든 기준점을 다시 측정해서 Canvas에 그려야 하는데, 측정이 한 프레임만 늦어도 선 끝이 말풍선에서 떨어집니다. 선을 그리는 곳과 선이 붙어야 할 곳이 서로 다른 좌표계에 있는 한, 이 동기화 문제는 사라지지 않아요.
그래서 동기화를 잘하는 대신, 동기화가 필요 없는 구조로 뒤집었습니다. 선 전체를 한 곳에서 그리지 않고, 각 메시지가 다음 메시지까지 이어지는 조각 하나씩만 소유합니다. 조각이 메시지 안에 들어 있으니 스크롤로 메시지가 움직이면 조각도 같은 만큼 움직이고, 맞춰야 할 좌표계가 애초에 하나가 됩니다. 대신 각 항목이 메시지 내용과 함께, 조각을 그리는 데 필요한 값을 전부 들고 있어야 해요.
type EntryState = {
id: string; // 재시작 회차가 들어간 키
message: Message;
position: number; // 지그재그 방향의 근거
drawPunctuation: boolean; // 문장이 ?로 끝나는지
lineCoordinates: { leftPoint: Point; rightPoint: Point };
horizontalShift: number; // 생성 순간 한 번만 결정된다
};한 타입에 메시지 내용과 화면 좌표가 섞여 있는 게 이상해 보일 수 있지만, 둘은 수명이 같습니다. entry가 만들어질 때 같이 생기고, 목록이 비워질 때 같이 사라져요. 그래서 둘을 한 상태로 묶고, 다음 메시지로 넘어가는 동작은 이전 상태를 받아 다음 상태를 돌려주는 리듀서 하나로 처리했습니다.
이 상태를 받아 각 조각은 ResizeObserver로 자기 크기를 읽고, 위 두 점(자기 좌표)과 아래 두 점(다음 메시지의 좌표)을 잇는 닫힌 path를 만듭니다. 선이 아래로 늘어나는 동안에는 requestAnimationFrame 루프가 아래 두 점의 좌표를 매 프레임 선형 보간으로 다시 계산해요. 좌우 방향은 메시지 순서의 홀짝을 따라 번갈아 바뀌고, 밀리는 거리 16~48px와 폭 44~60px는 메시지마다 새로 뽑습니다. 다시 시작할 때마다 선 모양이 달라지는 이유예요.
방금 나온 requestAnimationFrame 루프는 사실 이 화면의 예외입니다. 등장 애니메이션 가운데 JavaScript로 움직이는 건 검은 선뿐이거든요. 프레임·사진·말풍선·글자는 한 번 재생되고 끝나는 애니메이션이라 CSS animation으로 선언했고, 매 프레임 path 문자열을 새로 계산해야 하는 검은 선만 JavaScript로 그렸습니다.
이 구조에도 비용은 있습니다. 조각마다 ResizeObserver와 SVG가 하나씩 붙어서, 메시지 열두 개면 관찰자가 열한 개 생깁니다. 그리고 목록이 entries[index + 1]을 항상 메시지라고 가정하기 때문에, 날짜 구분선처럼 메시지가 아닌 항목을 끼우려면 이 가정부터 손봐야 해요.
마치며
처음 질문으로 돌아가면, 답은 "만들 수 있다"였습니다. 만드는 동안 제일 재밌었던 순간은 대화창을 누를 때마다 검은 선이 매번 다른 모양으로 내려오는 걸 처음 확인했을 때였어요. 완성된 이미지를 재생하는 게 아니라 방금 계산한 좌표가 그대로 화면이 된다는 게, 직접 만들어 봐야 알 수 있는 재미였습니다. 앞으로도 직접 구현해 보고 싶어지는 화면을 만나면, 또 하나 만들어 볼 생각이에요.
참고 자료
- clip-path — MDN —
path()문법과 브라우저 지원 현황 - Path2D — MDN — SVG path 문자열을 Canvas에서 재사용하는 API. 데모의 계절 파티클에 사용
- cubic-bezier.com — 데모의 등장 애니메이션에 쓴 overshoot 곡선을 직접 움직여 볼 수 있는 도구
