03. @emotion/styled 활용한 타이포 시스템 구현

flee | 플리·2026년 3월 16일

recode-Emotion

목록 보기
3/4
post-thumbnail

저는 디자인 시스템을 사용한 프로젝트를 5번 참여했는데요, 매번 타이포 시스템을 정의할 때마다 같은 과정을 반복했습니다.

피그마 dev 모드복사붙여넣기또 복사또 붙여넣기...

타이포그래피 컴포넌트가 20개라면, 이 과정을 20번 반복해야 했습니다.
결과물은 아래와 같은 구조였는데요.

(왼쪽) 피그마 코드를 그대로 객체로 정의 / (오른쪽) 컴포넌트 오버라이딩 한 코드

타이포 객체를 정의해두고, 실제 사용 컴포넌트에서는 color 같은 가변 요소만 styled(컴포넌트) 형식으로 오버라이딩해서 쓰는 방식이었습니다.

ctrl+c, ctrl+v.
행동 자체는 단순했지만, 유지보수가 문제였습니다.
시스템 변경이 생기면 20개의 객체를 전부 찾아서 고쳐야 했고, 코드는 점점 더 지저분해졌습니다.

' 좀 더 효율적인 방식이 없을까? '

그래서 직접 설계해봤습니다..!

이번 구현 방식은 @emotion/styled 이론 지식 기반입니다!
공식 문서를 먼저 읽어 보신 뒤, 기록을 읽어주시면 더욱 이해가 잘 될거에요!
▶ 🔗 공식 문서 이동 링크
▶ 🔗 @emotion/styled 정리 글

0. intro

먼저 '공통적으로 사용되는 요소들은 Token으로 정의하고, 이를 기반으로 베이스를 만들어서 활용하자!'로 시작했습니다.

피그마 dev 모드로 전환시 타이포그라피 요소는 위와 같은 형태로 코드가 출력됩니다.
참여한 서비스 내 타이포 시스템은 아래와 같은 특징을 가지고 있었습니다.

1. 불변 요소(빨간 부분) : font-family, font-style
2. 가변 요소 : font-size, font-weight, font-height, color

다음으로 저는 2가지 문제 상황을 해결해보고자 했습니다.

0-1. 문자열로 표현된 font-weight를 내부적으로 숫자로 풀어내자.


퍼블리싱 작업 중에 간혹 디자인 시스템에 정의되지 않은 타이포 요소를 확인할 수 있었는데요!
그럴 경우 font-weight (초록색 부분)가 숫자값이 아닌 문자열로 표현이 되어 해당 시스템 요소의 weight 값이 헷갈리는 경우가 있었습니다.
이런 경우에 '리터럴로 작성하여도 내부적으로 자동 변환이 되면 편하겠다.' 라는 생각이 들었습니다.

0-2. 불변요소들은 외부로 노출되지 않게 표현해보자.

피그마의 Variables 표현 방식처럼 ' color를 제외한 가변요소(노랑색, 하늘색)만 표현하고, 그 외의 요소는 숨겨보자!' 라는 목표로 삼고 디자인 시스템을 리팩토링했습니다.

이 두 가지 문제를 해결하기 위해 @emotion/styled 기반의 레이어 구조를 설계했습니다.
각 레이어가 다음 레이어의 재료가 되는 구조로, 아래 순서로 구현했어요.

원시값(Token) → 재사용 조각(Mixins) → 공통 기본값(Base) → 조합기(Builder) → 자동 생성기(Factory)


1. import

@emotion/styled 패키지는 내부적으로 @emotion/react를 래핑하고 있어서, 2개의 패키지를 함께 설치 후 사용해주셔야 합니다!
▶🔗 관련 정리 글

먼저 타이포 시스템 파일 상단에 import문을 작성했습니다.

1-1. @emotion/styled

styled 컴포넌트 자동 생성(7. Factory) 기능에 활용

1-2. @emotion/react

  • css() : 객체로 스타일을 정의
  • CSSObject : TypeScript 환경에서 Emotion의 자동완성 및 타입 추론

2. token

" 폰트 상수 정의 "

시스템 전체에서 사용할 원시값인 Token을 정의했습니다.

2-1. $typoFont

$typoFont 객체는 아래 2가지 목적을 가지고 있습니다.

1. family 정의 : font-family를 한 곳에서 관리합니다.
2. weight 값 리터럴 맵핑 : 피그마에서는 font-weight를 숫자가 아닌 문자열(Regular, SemiBold 등)으로 표기하는데요, 이를 보고 바로 코드에 적용 가능하기 위해 리터럴 타입으로 매핑했습니다.
( 0-1 문제 해결 !! )

2-2. $lh

$lh 함수는 행간을 배수로 입력받아 '%문자열'로 변환해주는 유틸입니다.

$lh(1.4)"140%"

2글자 타이핑이라도 줄여보자는 마음으로 작성했는데, 만족스럽습니다!

2-3. etc

$ prefix에 대해 Sass $ 변수 선언 컨벤션에서 영감을 받아 붙여봤습니다.
문법적으로는 의미가 없지만, 코드를 보면 스타일 토큰이라는 것을 바로 인식할 수 있어서 꽤 만족스럽습니다!

as const를 사용해 리터럴 타입으로 좁혀주었기 때문에, 컴파일 단계에서 잘못된 키 접근을 바로 잡아줍니다!


3. Mixins

" 재사용 스타일 조각 "

여러 곳에서 반복되는 스타일 묶음을 clampLines()함수로 추출하였습니다.

3-1. clampLines

텍스트를 n줄까지만 보여주고, 초과하면 말줄임표(...)로 자르는 함수

  • display: -webkit-box; : 박스를 webkit flex box로 설정
  • -webkit-box-orient: vertical; : 박스 방향을 세로로 설정
  • -webkit-line-clamp: ${n}; : n줄까지만 표시
  • overflow: hidden; : n줄 초과 텍스트 숨김
  • text-overflow: ellipsis; : 잘린 부분을 ...으로 표시

clampLines(2)를 호출하면 2줄 이상은 말줄임표 처리하는 CSS를 반환합니다.
특정 타이포 컴포넌트에 clamp 옵션이 있을 때 이 믹스인이 자동으로 주입됩니다!
( 5.Styled Builder 참고 )

clampLines는 현재 구현에서 사용한 예시이며,
프로젝트 필요에 따라 믹스인 레이어에 자유롭게 함수를 추가해 확장할 수 있습니다!


4. Base

" 공통 기본 스타일 "

모든 타이포 컴포넌트에 공통으로 깔리는 베이스 스타일입니다.
CSSObject 타입으로 정의하여 Object Style자동완성타입 검사가 동작하게 했습니다!
( Object Style의 장점! )

BASE_TYPO는 상수임을 명시하기 위해 UPPER_SNAKE_CASE로 작성했습니다.

  • UPPER_SNAKE_CASE : 수정하지 않을 것임을 명시하는 '네이밍 컨벤션'

4-1. fontFamily

2.Token에서 정의한 토큰$typoFont을 참조합니다.

4-2. 폰트 렌더링 속성

폰트 렌더링을 부드럽게 처리하기 위해 아래 속성들을 추가했습니다.

아래 두 속성은 함께 써야 Chrome, Safari, Firefox 환경 모두에서 동일하게 부드러운 폰트 렌더링을 보장할 수 있어요.

WebkitFontSmoothing: 'antialiased'

  • 적용 환경 : macOS + Chrome/Safari (webkit 계열)
  • 역할 : 폰트 렌더링 방식을 안티앨리어싱으로 설정
  • 효과 : 폰트 외곽선을 부드럽게 처리해서 얇고 선명하게 보임

MozOsxFontSmoothing: 'grayscale'

  • 적용 환경 : macOS + Firefox (Moz 계열)
  • 역할 : Firefox에서 macOS 폰트 스무딩을 그레이스케일 방식으로 설정
  • 효과 : webkit과 동일한 부드러운 렌더링 효과를 Firefox에서도 구현

그외 추가적으로 설정하고자 하는 요소들을 붙여줍니다!!


5. Styled Builder

" 스타일 배열 조합기 "

TypoConfig 객체를 받아 스타일 배열을 반환하는 함수입니다.

5-1. TypoConfig

  • 필수값 : size(string), weight(number), lh(string / line-height)
  • 선택값 : clamp(number / line-clamp), extra(CSSObject)

5-2. styleOf( )

@emotion/styled는 스타일 인자로 배열을 받을 수 있습니다.
배열 내부를 순서대로 순회하며 병합해주고, null은 자동으로 무시해요.

객체 방식이라면 조건부 스타일을 추가할 때마다 spread 연산자(...)가 필요하지만,
배열 방식은 조건부 결과를 요소로 넣기만 하면 Emotion이 알아서 처리해줍니다!!!
( 최고다...!!!! )
덕분에 BASE_TYPO, 개별 값, clamp, extra 같은 스타일 레이어를 독립적으로 깔끔하게 조합할 수 있었습니다.

styleOf 함수 흐름을 정리하자면, 다음과 같습니다.

  1. styleOf 함수가 TypoConfig 타입의 객체를 인자로 받아 구조 분해
  2. BASE_TYPO(공통 기본값)를 배열 첫 번째 요소로 추가
  3. fontSize / fontWeight / lineHeight 개별 값을 객체로 묶어 추가
  4. clamp가 있으면 clampLines(clamp), 없으면 null 삼항 연산자로 조건부 추가
  5. extra가 있으면 그대로, 없으면 null ?? 연산자로 조건부 추가

6. Config Map

" 타이포 스펙 명세 "

타입별 스펙을 한곳에 모아 정의하는 명세서입니다!

6-1. $typoFont.weight.sb 참조

숫자 대신 토큰을 참조해서 의미를 명확하게 했습니다.

피그마에서 "SemiBold"를 확인하고 바로 $typoFont.weight.sb로
작성할 수 있게 되어 0-1 문제가 해결됐습니다!

6-2 가변 요소만 노출($lh 유틸, size )

intro에서 세운 목표를 다시 떠올려볼게요.
" color를 제외한 가변요소만 표현하고, 불변요소는 숨기자 "

Config Map을 보면 size, weight, lh —
타입별로 달라지는 가변요소만 명세되어 있어요.
font-family, font-style 같은 불변요소는 Config Map 어디에도 없습니다.
불변요소는 4. Base의 BASE_TYPO에서 처리했기 때문에
Config Map에서는 신경 쓸 필요가 없거든요!

color 역시 Config Map에 없어요.
사용하는 쪽에서 오버라이딩으로 처리하기 때문이에요.

결과적으로 Config Map은
딱 "바뀌는 것만" 보이는 명세서가 됐습니다. 0-2 문제 해결!!!

6-3. satisfies Record<string, TypoConfig>

각 항목이 5-1. TypoConfig 타입을 만족하는지 컴파일 단계에서 검사하며, 각 키의 정확한 타입 추론을 유지할 수 있습니다.

6-4. extra

Caption, Button 타입처럼 필요한 개별 속성을 추가할 수 있습니다.

이미지에는 flexShrink:0 추가함!


7. Factory

" styled 컴포넌트 자동 생성 "

Config Map을 순회하며 styled.span 컴포넌트를 자동 생성하는 함수입니다.

핵심은 styled.span(styleOf(map[key])) 한 줄이에요.

  1. Config Map의 각 키
  2. styleOf( )로 스타일 배열 생성
  3. styled.span( )에 주입
  4. styled 컴포넌트 반환

제네릭 T를 활용하여 반환 타입이 Record<keyof T, StyledSpan>으로 추론됩니다.
즉, T에 없는 키로 접근하면, TypeScript가 바로 에러를 반환해요!

7-1. StyledSpan

type StyledSpan = ReturnType<typeof styled.span>;
styled.span이 반환하는 타입을 추출해서 별도 타입으로 정의한 것입니다.
ReturnType<typeof styled.span>을 직접 쓰면 너무 길고 가독성이 떨어지기 때문에, StyledSpan이라는 이름으로 추출해서 재사용할 수 있게 했습니다.

📝 용어 정리
ReturnType : TypeScript 유틸리티 타입으로, 함수 T의 반환 타입을 추출해줍니다.
ReturnType<typeof styled.span>styled.span()이 반환하는 타입을 그대로 가져옴

7-2. createTypo

function createTypo<T extends Record<string, TypoConfig>>(
  map: T
): Record<keyof T, StyledSpan>

Config Map을 받아 styled.span 컴포넌트를 자동으로 생성해주는 팩토리 함수입니다.
제네릭 T를 활용한 덕분에 두 가지 이점이 생겨요.

1. 타입 안전성
T는 Record<string, TypoConfig>를 확장해야 하므로, TypoConfig 형태에 맞지 않는 값을 넘기면 컴파일 단계에서 바로 에러가 발생합니다.

2. 반환 타입 자동 추론
반환 타입이 Record<keyof T, StyledSpan>으로 추론되기 때문에, T에 없는 키로 접근하면 TypeScript가 바로 잡아줘요.

const T = createTypo(CONFIGS);

T.Headline1  // ✅ 정상
T.존재하지않는키  // ❌ TypeScript 에러

6. Config Map에서 정의한 CONFIGS를 인자로 넘기면,
각 키에 해당하는 styled.span 컴포넌트가 자동으로 생성됩니다.


8. Build / Named Export

" 외부 노출 "

마지막으로 createTypo(CONFIGS)를 실행해 전체 컴포넌트를 생성하고,
Named Export하나씩 내보냅니다.

import { Headline1, Body2 } from '@/styles/typo';

Named Export를 사용하면 사용하는 쪽에서 필요한 컴포넌트만 골라서 import할 수 있어요.

야호!! 사용 방식은 그대로 유지하되, 내부 로직만 변경 성공이에요!!
마치 젠가 가운데 블럭을 뺀 것 같이..내부 로직만 수정 변경한게 너무 짜릿하지 않나요!~!~!


9. 적용 예시 및 결과

프로젝트 보안상 실제 적용 화면 첨부가 어렵지만,
아래는 실제 사용 예시 코드입니다!

(왼쪽) 타이포 컴포넌트를 오버라이딩해서 사용 / (오른쪽) 실제 사용 예시

기존에 타이포 시스템을 사용하던 방식(export/import, 오버라이딩)은 그대로 유지하되,
타이포 시스템 구축 자체는 훨씬 효율적인 구조로 개선되었습니다!

결과

이제 시스템 변경이 필요하면, 6. Config Map8. export 문만 추가/수정하면 됩니다.

실제 커밋 기록을 보면, 1개 파일에서 304줄이 제거되고 213줄로 재작성되었습니다.


약 100줄 이상의 코드를 줄이고, 재사용 가능한 구조를 만들 수 있었어요.
( 코드양 약 33% 감소!! )

각 레이어별 역할이 분리되어 있어서, 폰트 자체를 변경하더라도
Token 한 줄만 고치면 전체에 반영됩니다!!

언젠간 지금보다 더 나은 방식으로 발전시킬 수 있겠죠?
야호~ 이렇게 기분좋게 기록 마무리합니다~ ^0^~

🔗 참고
Emotion 공식 문서 : https://emotion.sh/docs/styled

profile
바라는 색이 있다면 눈이 멀도록 바라볼 것. 가능한 온몸으로 부서질 것.

0개의 댓글