[오픈소스][emotion][MUI] MUI 컴포넌트는 어떻게 만들어질까? (끝)

Gyuwon Lee·2023년 3월 14일
1

디자인 시스템

목록 보기
6/6
post-thumbnail

어차피 스타일은 emotion

지난번 삽질에서, MUI의 theme을 이해하려면 아무래도 기반이 되는 emotion의 코드를 이해해야 할 것 같아 살짝 들여다보았다. 어라? theme 객체를 사용해 컴포넌트에 스타일을 씌우는 방식이 MUI 고유의 구조가 아니라, emotion의 기능이었다.

emotion은 Context API를 기반으로, ThemeProvider 컴포넌트를 통해 프로그램 전역적으로 theme을 적용할 수 있는 기능을 제공한다. 이를 통해 필요한 곳에서 useTheme 을 사용해 theme 객체를 불러오거나, styled 함수 안에서 theme 을 사용하는 등 간편한 스타일링이 가능해지는 것이다.

이제부터는 관점을 바꿔서, 콜백 함수가 실행되는 부분까지 로직을 타고 쫓아가는 것이 아니라 theme 값을 불러와 style 로 resolve 시키는 부분이 어디인지 찾아내 보려고 한다.

방금 적었듯, theme을 가져오는 코드는 styled 함수뿐만 아니라 useTheme도 있다. 그래서 useTheme은 어떻게 theme을 가져오는지 확인해보기로 했다.

// emotion/packages/react/src/theming.js

export const useTheme = () => React.useContext(ThemeContext)

아. 그냥 useContext로 ThemeContext 안에 저장되어 있을 theme 객체를 가져오는 아주 간단한 코드였다.

마찬가지로, emotion/styled 디렉토리 안에서 'theme' 으로 전체 검색했을 때, theme 값을 가져오는 부분은 오직 한 줄, base.js 파일의 line 117 뿐이었다.

mergedProps.theme = React.useContext(ThemeContext)

즉, 그토록 궁금해했던 theme은 결국 ThemeProvider에 넘겨준 theme 값을 Context API를 통해 가져온 것이었다.

이제 MUI가 emotion을 기반으로 하되, 별도의 createStyled 와 resolver 함수를 만든 이유도 알 수 있다. 그냥 emotion을 사용하는 경우와 달리, MUI의 경우 사용자가 ThemeProvider를 사용하지 않은 경우에도 기본적으로 MUI의 default theme이 적용된 컴포넌트가 렌더링되어야 하기 때문에 emotion을 한번 더 감싸 사용자가 주입한 theme이 없는 경우 default theme이 사용될 수 있도록 한 것 같다. 물론 sx prop이나 theme의 component 객체처럼 MUI의 고유한 커스터마이즈 방식이 지원되어야 하는 것 역시 이유다.

이제 명확한 갈피를 잡았으니, emotion 코드를 차근차근 뜯어보려고 한다.

@emotion/styled

styled 함수는 스타일링할 태그를 메서드 형식으로 명시하거나, 함수의 인자로 넣는 두 가지 방법을 모두 사용할 수 있다.

styled.div`
	// style
`

styled(div)({
	// style
})

styled 라는 객체가 어떻게 생겼길래 꽤나 달라 보이는 두 가지 방법이 모두 가능한건지 궁금했는데, 아래 코드를 찾을 수 있었다.

// emotion/packages/styled/src/index.js

// @flow
import styled from './base'
import { tags } from './tags'

// bind it to avoid mutating the original function
const newStyled = styled.bind()

tags.forEach(tagName => {
  // $FlowFixMe: we can ignore this because its exposed type is defined by the CreateStyled type
  newStyled[tagName] = newStyled(tagName)
})

export default newStyled

여기서 forEach 문을 사용해 newStyled 의 실행 결과를 tagName 프로퍼티의 값으로 넣어주는 것이다.

tags.forEach(tagName => {
  // $FlowFixMe: we can ignore this because its exposed type is defined by the CreateStyled type
  newStyled[tagName] = newStyled(tagName)
})

템플릿 리터럴을 사용하는 경우 괄호 없이도 인자로 인식된다는 것을 처음 알았다…; 아무튼 겉보기에 달라 보였던 두 가지 방법은 둘다 styled 함수에 tagName 을 넘겨 리턴받은 resolver 함수에 style 객체를 넘기는 것으로 크게 다르지 않다는 것이다.

그러면 resolver 함수는 전달받은 첫 번째 인자가 함수인지, 문자열인지 판단하는 부분을 반드시 가지고 있어야 할 것이다.

styled.div`
	// style
`

styled('div')(
	({ theme, ownerState }) => ({
		//style
	})
)

위 코드의 첫 번째 방식과 두 번째 방식에서 resolver가 전달받은 인자는 각각 문자열과 콜백함수다.

문자열일 경우 이를 바로 parse 해서 스타일에 반영하면 되지만, 함수일 경우 해당 함수를 실행시키는 부분이 필요하다.

자 그럼 newStyled(tagName) 의 결과를 보자. styled(tagName) 의 결과와 같다.

import styled from './base'
import { tags } from './tags'

styled[tagName] === styled(tagName)

styled(tagName)createStyled(tagName) 이다.

// emotion/packages/styled/src/base.js

const createStyled = (tag: any, options?: StyledOptions) => {...}
...
export default createStyled

여기서 그동안 놓치고 있었던 부분을 찾았다. createStyled 가 리턴하는 건

return function <Props>(): PrivateStyledComponent<Props> {
	...
	return Styled
}

이렇게 생긴 익명함수였다. 리턴값인 Styled 는 함수가 아니라, 컴포넌트다.

const Styled: PrivateStyledComponent<Props> = withEmotionCache(
	(props, cache, ref) => {
		return (
      <>
        <Insertion
          cache={cache}
          serialized={serialized}
          isStringTag={typeof FinalTag === 'string'}
        />
        <FinalTag {...newProps} />
      </>
    )
	}
)

Styled는 이렇게 생겨서 컴포넌트를 리턴하고 있길래 이 콜백 자리에 내가 가장 겉에서 넘긴 styleArg 콜백이 들어가는 줄 알았는데, 아무리 봐도 인자의 형태가 맞지 않은 것 같아 이상하다고 생각했다. 내가 넘긴 콜백을 사용한다면 반드시 어딘가에서 theme 및 기타 props 를 resolve 하는 부분이 있어야 하는데 없는 것 역시 이상했다.

withEmotionCache(...)

이 형태이므로 withEmotionCache 의 실행값이고

let withEmotionCache = function withEmotionCache<Props, Ref: React.Ref<*>>(
  func: (props: Props, cache: EmotionCache, ref: Ref) => React.Node
): React.AbstractComponent<Props> {
  // $FlowFixMe
  return forwardRef((props: Props, ref: Ref) => {
    // the cache will never be null in the browser
    let cache = ((useContext(EmotionCacheContext): any): EmotionCache)

    return func(props, cache, ref)
  })
}

withEmotionCache 는 이런 함수다. 사실 그동안 React.forwardRef 를 사용하면서 정확한 리턴값이 뭔지 이해를 못하고 사용했던 것 같다. 어렴풋이 “컴포넌트가 ref 를 사용할 수 있도록 전달해주는 함수” 라고만 알고 있을 뿐이었다. 사실 위 설명에 단서가 있었다. React.forwardRef 는 컴포넌트를 인자로 받아야 한다. 따라서 위 코드에서 인자로 넘겨진 콜백은 함수형 컴포넌트여야 하며, 그 말은 곧 func() 는 React.Node 라고 나와 있듯 컴포넌트라는 뜻이다.

const RefDiv = React.forwardRef(
	(props, ref) => {
		return <div ref={ref}>{props}</div>
	}
)

return <RefDiv />

따라서 위 예제처럼, withEmotionCache 역시 컴포넌트를 리턴하게 된다. 그 말인즉슨 Styled 역시 컴포넌트다. 함수가 아니다!

return function <Props>(): PrivateStyledComponent<Props> {
	...
	return Styled
}

styled() 호출로 리턴되는 함수는 이것이다. 처음에 이 함수를 지나쳤던 이유가, 아무 인자도 받고 있지 않아서였다.

let args = arguments

함수의 바로 첫 줄부터 arguments 를 사용하고 있는 줄도 모르고…^^

styled.div`
	// style
`

styled('div')(
	({ theme, ownerState }) => ({
		//style
	})
)

이렇게 넘긴 문자열이든, 콜백함수든 전부 arguments[0]으로 접근 가능하다.

if (args[0] == null || args[0].raw === undefined) {
  styles.push.apply(styles, args)
}

위 코드에서 raw 는 받은 인자가 템플릿 리터럴인지 판단하는 데 쓰이는 듯하다. 이번에 처음 본 프로퍼티인데, 템플릿 리터럴의 태그 함수로 이스케이프가 처리되지 않은 원시 문자열을 가져오는 기능이라고 한다. 따라서 raw 가 있으면 템플릿 리터럴이고, 없으면 (위의 if 조건에 걸리는 경우) 함수일 것이다.



var vegetables = ['설탕당근', '감자'];
var moreVegs = ['셀러리', '홍당무'];

// 첫번째 배열에 두번째 배열을 합친다.
// vegetables.push('셀러리', '홍당무'); 하는 것과 동일하다.
Array.prototype.push.apply(vegetables, moreVegs);

console.log(vegetables); // ['설탕당근', '감자', '셀러리', '홍당무']

array.push.apply() 형태의 로직 역시 처음 보는 코드라서 MDN 문서를 통해 배울 수 있었다. 첫 번째 인자로 받은 배열에 두 번째 인자의 모든 요소를 합치는 작업을 수행한다.

styles.push.apply(styles, args)

따라서 이 함수는 styles 배열에 내가 넣어준 인자들 args 를 ‘모두’ 합치는 작업을 수행할 것이다.

const serialized = serializeStyles(
  styles.concat(classInterpolations),
  cache.registered,
  mergedProps
)

이 styles 배열은 따로 계산된 classInterpolation 값까지 포함해 serializeStyles 함수의 첫 번째 인자로 넘겨진다. (그나저나 Interpolation 이 하도 나와서 무슨 뜻인지도 이번에 알게 되었다)

export const serializeStyles = function (
  args: Array<Interpolation>,
  registered: RegisteredCache | void,
  mergedProps: void | Object
): SerializedStyles

styles 배열은 serializeStyles 안에서 다시 args 라는 변수명으로 쓰이게 되는데

let styles = ''
...
for (let i = 1; i < args.length; i++) {
  styles += handleInterpolation(mergedProps, registered, args[i])
  if (stringMode) {
    if (process.env.NODE_ENV !== 'production' && strings[i] === undefined) {
      console.error(ILLEGAL_ESCAPE_SEQUENCE_ERROR)
    }
    styles += strings[i]
  }
}

우리가 넣어준 함수를 포함하고 있을 이 args 배열의 모든 인자들에 대해 handleInterpolation 을 호출해 styles 문자열에 넣어주게 된다. (아까의 styles 랑 다른 변수다!)

function handleInterpolation(
  mergedProps: void | Object,
  registered: RegisteredCache | void,
  interpolation: Interpolation
): string | number

handleInterpolation은 위처럼 생겼다. createStyled 안에서 mergedProps 에 useContext() 를 사용해 theme 값을 넣어준 상태라는 점을 기억하자!

case 'function': {
  if (mergedProps !== undefined) {
    let previousCursor = cursor
    let result = interpolation(mergedProps)
    cursor = previousCursor

    return handleInterpolation(mergedProps, registered, result)
  } else if (process.env.NODE_ENV !== 'production') {
    console.error(
      'Functions that are interpolated in css calls will be stringified.\n' +
        'If you want to have a css call based on props, create a function that returns a css call like this\n' +
        'let dynamicStyle = (props) => css`color: ${props.color}`\n' +
        'It can be called directly with props or interpolated in a styled call like this\n' +
        "let SomeComponent = styled('div')`${dynamicStyle}`"
    )
  }
  break
}

드디어! 진짜 원리까지 제대로 이해할 수 있게 됐다.

let result = interpolation(mergedProps)

이 라인이 핵심이다.

interpolation 값은 위의 serializeStyles 함수에서 args[i] 로 넣어주고 있고, mergedProps 는 우리가 컴포넌트에 넘긴 모든 prop들이다. MUI 컴포넌트의 theme 과 ownerState 를 모두 포함한다.

<Button variant="secondary" disabled />

이런 식으로 MUI 컴포넌트를 만들었다고 치면, variantdisabled 라는 props 가 들어가게 된다.

이 props 가 React.forwardRef 에 props 로 들어가는 것이고, 내부적으로 func() 함수를 실행해 리턴시키므로 콜백 func 로 받았던 함수가 props 를 인자로 받아 실행되는 것!

따라서 우리가 MUI를 포함한 styled 컴포넌트 마음대로 custom prop을 넣어도 잘 실행될 수 있는 것이다.

배운 점과 배울 점

코드를 따라다니며 끊임없는 콜백과 선언형 로직의 향연에 처음엔 머리가 어질했다.

하지만 처음으로 대규모 오픈소스를 깊이 파고들어보며 아래의 것들을 배웠다:

1. 라이브러리에 대한 관심

나는 함수가 무조건 가장 겉에서부터 계층적으로 실행된다고 생각했다. 값 역시 위에서 아래로 무조건 흘러내려가야 하는 것이라고 생각했다. 그런데 emotion 의 코드는 생소했다.

내가 styled 로 어떤 컴포넌트를 만들어 렌더시켰다고 생각해 보자.

const Tmp = styled('div')(({theme, ...})=>({...}))

원래 나는 Tmp → styled → resolver → return div 일 것이라고 생각했다. 따라서

return <Tmp disabled squared blurred ... />

위처럼 코드를 작성했을 때 이 props 들이 당연히 top-down 방식으로 처리될 줄 알았다. styled 에서 props 를 보고, 처리해서 resolver 에게 넘기고, 마지막으로 div 에 적용되고..?

하지만 그렇지 않았다.

잘 생각해보면, 리액트에서 <Component /> 문법이 제대로 트랜스파일 되려면 이 <Component />props => React.Node 형태의 함수여야 한다.

즉, ‘컴포넌트가 생성된다’ 고 할 때 정확히 어떤 형태의, 어떤 함수가 실행되어야 하는지 먼저 생각해봤어야 한다. props 를 받는 콜백 func 이 실행되고, 이 콜백 func 안에서 여러 유틸 함수들이 실행되어 컴포넌트(React.Node)가 생긴다.

라이브러리란 무엇인가! 라이브러리란 개발 과정에서 필요한 기능들이 구현된 집합으로, 프로그래머가 원하는 위치에서 호출하여 사용한다. 그동안 리액트를 사용해 UI를 구현하는 작업만 해 왔는데, 이는 DOM 노드라는 ‘값’을 만들어내는 과정이다. 그런데 라이브러리란 그 값을 만드는 데 필요한 ‘기능’을 구현해 둔 것이다.

따라서 값을 받았을 때 그것을 어떻게 처리할 것인지가 기술되어 있으므로, 코드를 읽을 때 역시 그 관점으로 쫓아가야 했다. 나는 계속 ‘그래서 뭐가 리턴되는 건데!’ 하고 ‘컴포넌트가 리턴된다’ 는 사실에만 집착했는데, 그게 아니라 값을 어떻게 처리해주고 있는지를 차근차근 쫓아갔어야 했던 것 같다.

2. 구조가 선행하는 코드

위의 (당연한) 사실을 깨닫고 코드를 제대로 이해하는 데 며칠이나 걸렸던 경험을 통해, 길고 복잡하게 이어져있는 로직을 보거나 언젠가 직접 구현하게 될 때 어떤 관점을 가져야 할 지 어렴풋이 알게 된 것 같다.

먼저, 처음부터 코드를 세세하게 이해하기보다 전체적인 로직을 정확히 이해하는 것이 중요한 것 같다.

한 흐름에 코드를 전부 이해하고 싶은 내 바람과는 달리, 대부분의 대규모 코드는 긴 시간에 걸쳐 여러 사람이 협력해서 만든 코드다. 작업 과정에서 로직이 분리되기도 하고, 끼워넣어지기도 했을 것이다. 따라서 코드의 디테일한 부분까지 전부 이해해 가며 로직의 아래로 내려가려다가는 지엽적인 부분에 빠져 길을 잃기가 쉬웠다.

또, 전체적으로 어떤 형태의 데이터를 주고받는지 를 정확하게 이해하고, 도식화해서 머릿속에 그려질 수 있도록 해두는 것이 중요한 것 같다. 이는 곧 코드의 목적 을 이해하는 것과 같다. 목적을 이해하지 못하고 내용부터 보다 보면 금새 길을 잃게 되었다.

코드를 읽으면서 기능 별로 함수를 분리해두었다 는 점을 느꼈다. 함수가 불필요하게 길고 광범위한 게 아니라, 최대한 구체적인 목적을 수행하고 있다는 느낌? 예를 들어 컴포넌트 하나를 만드는 데 클래스명 가져오기, 스타일 객체 파싱하기, prop forward 여부 가져오기 등등을 별도 함수로 분리해서 처리되게 한 것이 그렇다.

이러면 컴포넌트를 만드는 함수는 딱 ‘컴포넌트를 구성하는 것’ 에 집중할 수 있고, 각 구성 단계의 세부적인 로직은 또 별도의 함수가 처리하는 구조가 만들어질 수 있다. 따라서 이렇게 함으로써 각 코드의 의미가 좀 더 명확해질 수 있다. 다만 많은 함수들이 데이터를 주고받게 되므로 각 함수에 어떤 데이터를 넘길 것인지, 그 데이터에 정확히 어떤 처리를 하고 싶은건지 등등을 먼저 잘 정의한 뒤 구현으로 넘어가야 할 것이다.

그러므로 전체적인 구조를 이해하며 각 함수의 기능을 정의한 뒤, 의사 코드 또는 다이어그램 등을 사용하여 전체적인 로직을 점검한 후 코드를 파악해야 우왕좌왕하지 않는다.

어제 점심 먹으면서 팀원 분이 했던 말 중 “큰 배가 있는데, 어디 깊숙한 데 있는 작은 버튼을 고치래요. 근데 그 버튼이 어딨는지는 몰라요” 라는 말이 문득 생각난다. 어떤 기능을 하나 고치라는 업무를 받았을 때, 다음과 같은 문제들을 만나게 된다:

  • 해당 기능을 수행하는 코드가 정확히 어딘지 찾는데 시간이 걸린다.
  • 해당 코드가 다른 함수들과 맞물려 있을 경우 (대체로 그렇다) , 이 코드를 고칠 경우 발생하는 side effect 또는 이 코드가 필요했던 이유를 파악해야 한다.
  • 1과 2의 결과를 바탕으로, 어느 지점에서 코드를 수정하는 것이 최선인지 파악하고, 적절한 로직을 구상 후 구현해야 한다.

특히 2번 과정 중 클래스형 컴포넌트라든가, 순수 redux 같은 레거시 코드를 만나 복잡해지기 시작하면… 오랜 시간을 잡아먹게 된다. 따라서 적어도 내가 수정하고자 하는 기능에 한해서라도 전체적인 로직의 맥락을 파악해 두고 작업해야 불필요한 수정을 줄일 수 있다.

나 역시 버그 픽스를 전담하며 온갖 처음 보는 기능들에 다짜고짜 들어가 수정하는 작업을 반복하고 있다. 이제는 코드의 세부적인 구현 이전에, 로직의 구조와 각 함수의 기능 및 데이터를 먼저 정확히 파악한 후 해결책을 구상하는 습관을 들이도록 노력 중이다.

profile
하루가 모여 역사가 된다

0개의 댓글