styled-components와 함께하는 오픈 소스 디버깅

“개발 중 예상하지 못한 오류가 발생했거나, 의도하지 않은 동작이 일어나는 경우 어떻게 해결하시나요?”

면접에서 위 질문을 하면 열에 아홉은 Google이나 StackOverflow에서 찾아보고, 해결책을 적용한다고 대답합니다. 하지만 서비스를 개발하다 보면 더 복잡한 요구 사항을 처리해야 할 때도 있고, 우리의 스택과는 맞지 않는 해결책이 일반적인 경우도 있습니다.

클래스101의 웹 애플리케이션에 서버 사이드 렌더링을 적용하기 위한 여정이 바로 이런 경우에 속합니다. 여러 가지 이유로 클라이언트 사이드 렌더링만 지원하던 웹 애플리케이션에서 서버 사이드 렌더링까지 지원해야 했고, 이 과정에서 styled-components로 만든 컴포넌트들을 서버 사이드에서 그려줄 때 몇 가지 문제가 발생했습니다.

[그림 1] 종종 코드와의 진검 승부를 해야 하는 순간이 있습니다

물론 이 경우에도 문제를 해결하는 방법은 여러 가지가 있습니다. styled-components 대신 비슷한 API를 제공하는 다른 CSS-in-JS 라이브러리를 사용할 수도 있고, 해당 라이브러리의 코드 저장소에 이슈를 만들어 도움을 요청할 수도 있죠. 그러나 가끔은 진짜 문제를 파악하여 직접 해결해야 할 때가 있습니다.

CSS 클래스 순서 오류

서버 사이드에서 렌더링 된 앱을 다운로드하고 다른 페이지로 이동하는 경우 CSS 클래스 순서가 뒤바뀌어 스타일이 제대로 적용되지 않는 문제가 있었습니다. 이전에 이 작업을 진행했던 동료들이 styled-components의 Github 코드 저장소#2950, #2952 이슈를 남겨 두었습니다. 간단하게 재현한 코드도 있습니다.

오류 재현

  1. styled로 스타일을 적용한 컴포넌트를 조건부로 그려주는 ConditionalButton 컴포넌트와, 이 컴포넌트에 다시 styled로 스타일을 적용한 StyledButton 컴포넌트를 생성합니다.

    // ConditionalButton.js
    import React from 'react';
    import styled from 'styled-components';
    
    export const ConditionalButton = ({ className, condition, children }) => {
     if (condition) {
       return <SomeButton className={className}>{children}</SomeButton>;
     }
    
     return <OtherButton className={className}>{children}</OtherButton>;
    };
    
    const OtherButton = styled.button`
     width: 100%;
     background-color: red;
    `;
    
    const SomeButton = styled.a`
     display: block;
     background-color: red;
    `;
    
    // StyledButton.js
    import React from 'react';
    import styled from 'styled-components';
    
    import { ConditionalButton } from './ConditionalButton';
    
    export const StyledButton = styled(ConditionalButton)`
     margin-top: 16px;
     padding: 4px;
     text-align: center;
     background-color: ${props => props.color};
    `;
  2. 두 개의 페이지에서 StyledButton을 각각 사용하여 버튼은 그려줍니다.

    // GreenButtonPage.js
    import React from 'react';
    import { Link } from 'react-router-dom';
    import { StyledButton } from '../components/StyledButton';
    
    const GreenButtonPage = () => (
     <div>
       <Link href="/green">
         <a>GreenButtonPage</a>
       </Link>
       <Link href="/yellow">
         <a>YellowButtonPage</a>
       </Link>
       <StyledButton color="green" condition={true}>
         Green Button
       </StyledButton>
     </div>
    );
    
    export default GreenButtonPage;
    
    // YellowButtonPage.js
    import React from 'react';
    import { Link } from 'react-router-dom';
    import { StyledButton } from '../components/StyledButton';
    
    const YellowButtonPage = () => (
     <>
       <Link to="/blue">BlueButtonPage</Link>
       <Link to="/yellow">YellowButtonPage</Link>
       <StyledButton color="yellow" condition={false}>
         Yellow Button
       </StyledButton>
     </>
    );
    
    export default YellowButtonPage;
  3. /green 페이지를 불러온 다음, /yellow 페이지로 이동합니다. 분명 노란색 버튼이 나와야 하는데, 해당 스타일이 제대로 적용되지 않은 것을 확인할 수 있습니다. /yellow에서 /green으로 이동한 경우도 동일합니다.

    [그림 2] 버튼의 스타일이 제대로 적용되어 있지 않습니다

  4. <button /> 요소의 클래스와 <head /><style /> 요소를 확인하면 .joeIGt 클래스가 .lbjYRT 클래스 앞에 위치하여 스타일이 제대로 적용되지 않은 것을 볼 수 있습니다.

    [그림 3] 클래스 순서가 올바르지 않습니다

원인 파악

먼저 스타일 시트에 규칙을 추가하는 부분을 살펴봅니다.

// Sheet.js
export default class StyleSheet implements Sheet {
  // ...

  getTag(): GroupedTag {
    return this.tag || (this.tag = makeGroupedTag(makeTag(this.options)));
  }

  insertRules(id: string, name: string, rules: string[]) {
    this.registerName(id, name);
    this.getTag().insertRules(getGroupForId(id), rules);
  }

  // ...
}
// GroupedTag.js
export const makeGroupedTag = (tag: Tag): GroupedTag => {
  return new DefaultGroupedTag(tag);
};
// Tag.js
export const makeTag = ({
  isServer,
  useCSSOMInjection,
  target
}: SheetOptions): Tag => {
  if (isServer) {
    return new VirtualTag(target);
  } else if (useCSSOMInjection) {
    return new CSSOMTag(target);
  } else {
    return new TextTag(target);
  }
};

styled-components의 StyleSheet#insertRules에서 this.getTag().insertRules 함수를 사용하는데, 여기서 getTag()의 결과는 makeTagmakeGroupedTag로 만들어진 GroupedTag입니다. GroupedTag로 넘어가서 살펴봅시다.

// GroupedTag.js
class DefaultGroupedTag implements GroupedTag {
  // ...

  insertRules(group: number, rules: string[]): void {
    if (group >= this.groupSizes.length) {
      const oldBuffer = this.groupSizes;
      const oldSize = oldBuffer.length;

      let newSize = oldSize;
      while (group >= newSize) {
        newSize <<= 1;
        if (newSize < 0) {
          throwStyledError(16, `${group}`);
        }
      }

      this.groupSizes = new Uint32Array(newSize);
      this.groupSizes.set(oldBuffer);
      this.length = newSize;

      for (let i = oldSize; i < newSize; i++) {
        this.groupSizes[i] = 0;
      }
    }

    let ruleIndex = this.indexOfGroup(group + 1);
    for (let i = 0, l = rules.length; i < l; i++) {
      if (this.tag.insertRule(ruleIndex, rules[i])) {
        this.groupSizes[group]++;
        ruleIndex++;
      }
    }
  }

  // ...
}

GroupedTag#insertRules 함수는 ruleIndex를 계산하고, this.tag에 스타일 규칙을 추가합니다. 이 this.tag는 위의 makeTag 함수에서 확인할 수 있듯 환경에 따라 VirtualTag, CSSOMTag, TextTag 중에 하나입니다.

// Tag.js
export class CSSOMTag implements Tag {
  // ...

  insertRule(index: number, rule: string): boolean {
    // For Debug
    console.log('CSSOMTag > insertRule', { index, rule });
    try {
      this.sheet.insertRule(rule, index);
      this.length++;
      return true;
    } catch (_error) {
      return false;
    }
  }

  // ...
}

TaginsertRule 함수가 실제 스타일 시트에 스타일 규칙을 추가하는 역할을 하고 있습니다. 여기서 사용하는 CSSStyleSheet#insertRule 함수의 API를 보면, 추가할 규칙과 인덱스를 설정할 수 있습니다. 가장 마지막에 추가된 .lbjYRT 클래스의 인덱스 설정이 잘못되었을 것이라는 추측을 할 수 있습니다. 정확한 상태 파악을 위해 CSSOMTag#insertRule 함수에 로그를 찍어본 결과는 다음과 같습니다.

[그림 4] CSSOMTag#insertRule 로그

예상대로 마지막 규칙의 인덱스가 가장 크게 설정되어 있어 맨 마지막에 추가되고 있습니다. 그렇다면 이 인덱스는 어떻게 만들어지는 걸까요? DefaultGroupedTag#insertRules를 보면 this.indexOfGroup 함수를 호출해 규칙의 인덱스를 받아오는 것을 볼 수 있습니다.

// GroupedTag.js
class DefaultGroupedTag implements GroupedTag {
  // ...

  indexOfGroup(group: number): number {
    let index = 0;
    for (let i = 0; i < group; i++) {
      index += this.groupSizes[i];
    }

    // For Debug
    console.log('DefaultGroupedTag > indexOfGroup', {
      group,
      index,
      groupSizes: this.groupSizes
    });
    return index;
  }

  // ...
}

indexOfGroup 함수에서는 추가할 스타일 규칙의 그룹을 가지고, 해당 그룹 앞에 있는 규칙의 개수만큼 인덱스를 늘려서 현재 규칙이 들어갈 인덱스를 결정합니다. DefaultGroupedTag#indexOfGroup 함수에 로그를 찍어본 결과는 다음과 같습니다.

[그림 5] DefaultGroupedTag#indexOfGroup 로그

마지막에 추가된 규칙을 보니, 그룹 설정이 잘못되어 인덱스까지 잘못 설정된 것으로 보입니다. 그렇다면 이제 그룹이 어떻게 만들어지는지 확인해 봐야겠죠? 다시 처음으로 돌아가 StyleSheet#insertRules를 보면 GroupIDAllocator.jsgetGroupForId 함수를 통해 그룹을 가져오는 것을 확인할 수 있습니다.

// GroupIDAllocator.js

// ...

export const getGroupForId = (id: string): number => {
  // For Debug
  console.log('getGroupForId', { groupIDRegister });

  if (groupIDRegister.has(id)) {
    return (groupIDRegister.get(id): any);
  }

  const group = nextFreeGroup++;
  if (
    process.env.NODE_ENV !== 'production' &&
    ((group | 0) < 0 || group > MAX_SMI)
  ) {
    throwStyledError(16, `${group}`);
  }

  groupIDRegister.set(id, group);
  reverseRegister.set(group, id);
  return group;
};

// ...

그룹은 nextFreeGroup을 하나씩 늘린 값으로 설정되는 것을 볼 수 있습니다. 그룹과 컴포넌트 ID가 어떻게 매핑되어 있는지 알아보기 위해 로그를 찍어 groupIDRegister 값을 확인해 보겠습니다.

[그림 6] 서버와 클라이언트의 groupIDRegister

그랬더니 재미있는 결과가 나왔습니다. 클라이언트와 서버의 groupIDRegister 값이 다릅니다! 좌측이 서버의 groupIdRegister, 우측이 클라이언트의 groupIDRegister 입니다. 서버에서 내려준 대로 등록하면 스타일 규칙이 제대로 적용될 텐데, 클라이언트에서는 ConditionalButton__OtherButton 컴포넌트의 그룹이 1이 아니라 4로 등록되어 있습니다. 서버에서 내려준 값과 클라이언트에서 실제로 사용하는 값이 다르다면, 클라이언트의 Rehydration 로직을 의심해 볼 수 있습니다.

// Rehydrate.js
const rehydrateSheetFromTag = (sheet: Sheet, style: HTMLStyleElement) => {
  const parts = style.innerHTML.split(SPLITTER);
  const rules: string[] = [];

  for (let i = 0, l = parts.length; i < l; i++) {
    const part = parts[i].trim();
    if (!part) continue;

    const marker = part.match(MARKER_RE);

    if (marker) {
      const group = parseInt(marker[1], 10) | 0;
      const id = marker[2];

      if (group !== 0) {
        // Rehydrate componentId to group index mapping
        setGroupForId(id, group);
        // Rehydrate names and rules
        // looks like: data-styled.g11[id="idA"]{content:"nameA,"}
        rehydrateNamesFromContent(sheet, id, marker[3]);
        sheet.getTag().insertRules(group, rules);
      }

      rules.length = 0;
    } else {
      rules.push(part);
    }
  }
};

이 부분의 코드는 다소 복잡한 듯하지만, 서버에서 받아온 클래스들과 컴포넌트 ID를 sheet 인스턴스에 등록하고 있는 것으로 보입니다. 여기서 컴포넌트 ID와 그룹을 groupIDRegister에 등록하는 setGroupForId 함수를 보면,

// GroupIDAllocator.js

// ...

export const setGroupForId = (id: string, group: number) => {
  if (group >= nextFreeGroup) {
    nextFreeGroup = group + 1;
  }

  groupIDRegister.set(id, group);
  reverseRegister.set(group, id);
};

nextFreeGroup을 추가된 그룹 중 가장 큰 그룹 + 1로 설정하고 있는 것을 확인할 수 있습니다. 서버에서 내려준 데이터와 함께 볼까요?

<style data-styled="" data-styled-version="5.0.1">
.iFpqsr{display:block;background-color:red;}
data-styled.g2[id="ConditionalButton__SomeButton-sc-9v1p4n-1"]{content:"iFpqsr,"}
.eNfYlq{margin-top:16px;padding:4px;text-align:center;background-color:green;}
data-styled.g3[id="StyledButton-sc-6aufze-0"]{content:"eNfYlq,"}
</style>

서버에서는 그룹 2에 ConditionalButton__SomeButton 컴포넌트를, 그룹 3에 StyledButton 컴포넌트를 지정해 주었고, Rehydration 과정을 거치고 나면 nextFreeGroup은 4가 될 것입니다. 따라서 이후에 ConditionalButton__OtherButton 컴포넌트를 등록하게 된다면 서버에서는 그룹 1에 있던 것이 클라이언트에서는 그룹 4에 들어가게 됩니다.

문제 해결

많은 경우 일단 원인을 파악하고 나면 해결하기는 어렵지 않습니다. 여기서는 setGroupForId에서 nextFreeGroup을 설정하는 로직을 제거하고, 다음 번 등록할 그룹을 아직 사용되지 않은 그룹 중에서 찾으면 되지 않을까요? 시간 복잡도가 늘어난다는 단점은 있네요 :)

// GroupIDAllocator.js

// ...

export const getGroupForId = (id: string): number => {
  if (groupIDRegister.has(id)) {
    return (groupIDRegister.get(id): any);
  }

  // ========== Added ==========
  while (reverseRegister.has(nextFreeGroup)) {
    nextFreeGroup++;
  }
  // ===========================

  const group = nextFreeGroup++;
  if (
    process.env.NODE_ENV !== 'production' &&
    ((group | 0) < 0 || group > MAX_SMI)
  ) {
    throwStyledError(16, `${group}`);
  }

  groupIDRegister.set(id, group);
  reverseRegister.set(group, id);
  return group;
};

export const setGroupForId = (id: string, group: number) => {
  // ========= Removed =========
  // if (group >= nextFreeGroup) {
  //   nextFreeGroup = group + 1;
  // }
  // ===========================

  groupIDRegister.set(id, group);
  reverseRegister.set(group, id);
};

이 변경사항을 적용하고 다시 실행해 보면, CSS 클래스 순서가 정상적으로 적용된 것을 확인할 수 있습니다.

[그림 7] 정상적으로 적용된 CSS 클래스 순서

[그림 8] 정상적으로 적용된 스타일

가끔 이렇게 문제를 파악하다 보면 오픈 소스 라이브러리를 수정해야 하는 경우가 있습니다. 오픈 소스에 기여할 수 있는 좋은 기회죠 😎 어떤 이슈를 해결했는지, 어떤 부분이 잘못된 것인지 정리해서 Pull Request를 만들고 리뷰를 기다리면 됩니다. 이 작업은 styled-components의 #3233 PR로 등록되었고, 10/3에 마스터 브랜치에 반영되었습니다! 🎉

글로벌 스타일이 여러 번 렌더링 되는 오류

위 이슈가 클라이언트 사이드에서 styled-components를 그려줄 때 발생한 것이었다면, 이번에는 서버에서 만들어 내려주는 스타일 시트에 문제가 있었습니다. styled-components의 createGlobalStyle로 생성한 스타일이 여러 번 추가되어 이후 만들어진 스타일들을 덮어쓰는 것이었죠.

[그림 9] 글로벌 스타일 오류

오른쪽 스크린샷을 보면, .swiper-slide 클래스가 여러 개 생성되었다는 것을 확인할 수 있습니다. 서버에서 만들어준 스타일 시트에 문제가 있었으니, 서버 측 코드부터 찬찬히 살펴봅시다!

원인 파악

서버 사이드와 클라이언트 사이드 렌더링의 코드가 크게 다르지는 않습니다. 서버와 클라이언트가 대부분의 코드를 공유하고, 첫 렌더링을 어디서 하는지 정도의 차이가 있을 뿐입니다. 즉, 서버 사이드 렌더링에서만 문제가 발생한다면, server.tsx 코드에 대부분의 실마리가 있다는 거죠. 이 파일부터 확인해 보겠습니다.

// server.tsx

// ...

const extractor = new ChunkExtractor({
  stats,
  entrypoints: ['client']
});
const apolloClient = createApolloClient();
const sheet = new ServerStyleSheet();
const language = getLanguage(req);

const Wrapper = (
  <StaticRouter location={req.url} context={context}>
    <App
      cookies={req.universalCookie}
      defaultLanguage={language.substr(0, 2)}
      apolloClient={apolloClient}
      mobxStores={mobxStores}
      isServerSide
    />
  </StaticRouter>
);

try {
  const markup = await renderToStringWithData(
    extractor.collectChunks(sheet.collectStyles(Wrapper))
  );
  const helmet = Helmet.renderStatic();
  const html = renderToStaticMarkup(
    <Html
      helmet={helmet}
      markup={markup}
      styleElements={
        <>
          {assets.client.css ? (
            <link rel="stylesheet" href={assets.client.css} />
          ) : null}
          {sheet.getStyleElement()}
          {extractor.getLinkElements({
            crossOrigin: IS_NODE_ENV_PRODUCTION ? 'true' : 'anonymous'
          })}
          {extractor.getStyleElements({
            crossOrigin: IS_NODE_ENV_PRODUCTION ? 'true' : 'anonymous'
          })}
        </>
      }
      jsElements={
        <>
          <script
            src={assets.client.js}
            crossOrigin={IS_NODE_ENV_PRODUCTION ? 'true' : undefined}
          />
          {extractor.getScriptElements({
            crossOrigin: IS_NODE_ENV_PRODUCTION ? 'true' : 'anonymous'
          })}
        </>
      }
      apolloState={apolloClient.extract()}
    />
  );
  res.contentType('text/html');
  res.send(`<!doctype html>${html}`);
  res.status(200);
} catch (error) {
  Logger.error(error);
  next(error);
} finally {
  sheet.seal();
}

// ...

ServerStyleSheet 인스턴스를 생성하고, collectStyles 함수를 호출해 스타일 규칙들을 등록한 뒤 getStyleElement()를 통해 그려주는 것을 확인할 수 있습니다. 먼저 ServerStyleSheetcollectStyles 함수를 확인해 봅시다.

// ServerStyleSheet.js
export default class ServerStyleSheet {
  // ...

  constructor() {
    this.instance = new StyleSheet({ isServer: true });
    this.sealed = false;
  }

  collectStyles(children: any) {
    if (this.sealed) {
      return throwStyledError(2);
    }

    return (
      <StyleSheetManager sheet={this.instance}>{children}</StyleSheetManager>
    );
  }

  // ...
}
// StyleSheetManager.js
export default function StyleSheetManager(props: Props) {
  const [plugins, setPlugins] = useState(props.stylisPlugins);
  const contextStyleSheet = useStyleSheet();

  const styleSheet = useMemo(() => {
    let sheet = contextStyleSheet;

    if (props.sheet) {
      // eslint-disable-next-line prefer-destructuring
      sheet = props.sheet;
    } else if (props.target) {
      sheet = sheet.reconstructWithOptions({ target: props.target }, false);
    }

    if (props.disableCSSOMInjection) {
      sheet = sheet.reconstructWithOptions({ useCSSOMInjection: false });
    }

    return sheet;
  }, [props.disableCSSOMInjection, props.sheet, props.target]);

  // ...

  return (
    <StyleSheetContext.Provider value={styleSheet}>
      <StylisContext.Provider value={stylis}>
        {process.env.NODE_ENV !== 'production'
          ? React.Children.only(props.children)
          : props.children}
      </StylisContext.Provider>
    </StyleSheetContext.Provider>
  );
}

StyleSheetContext에서 사용할 StyleSheet 인스턴스를 주입해 주는 정도의 역할인 것 같습니다. styled 함수를 통해 생성한 컴포넌트들의 스타일 규칙을 ServerStyleSheetinstance에 추가할 거라고 추측해 볼 수 있겠네요. 딱히 특별한 건 없어 보입니다! 그렇다면 잠깐 덮어두고, createGlobalStyle 함수가 어떻게 작동하는지 알아봅시다.

// createGlobalStyle.js

// ...

export default function createGlobalStyle(
  strings: Array<string>,
  ...interpolations: Array<Interpolation>
) {
  const rules = css(strings, ...interpolations);
  const styledComponentId = `sc-global-${generateComponentId(
    JSON.stringify(rules)
  )}`;
  const globalStyle = new GlobalStyle(rules, styledComponentId);
  // ...

  function GlobalStyleComponent(props: GlobalStyleComponentPropsType) {
    // ...

    if (__SERVER__) {
      renderStyles(instance, props, styleSheet, theme, stylis);
    } else {
      // this conditional is fine because it is compiled away for the relevant builds during minification,
      // resulting in a single unguarded hook call
      // eslint-disable-next-line react-hooks/rules-of-hooks
      useLayoutEffect(() => {
        renderStyles(instance, props, styleSheet, theme, stylis);
        return () => globalStyle.removeStyles(instance, styleSheet);
      }, [instance, props, styleSheet, theme, stylis]);
    }

    return null;
  }

  function renderStyles(instance, props, styleSheet, theme, stylis) {
    if (globalStyle.isStatic) {
      globalStyle.renderStyles(
        instance,
        STATIC_EXECUTION_CONTEXT,
        styleSheet,
        stylis
      );
    } else {
      const context = {
        ...props,
        theme: determineTheme(props, theme, GlobalStyleComponent.defaultProps)
      };

      globalStyle.renderStyles(instance, context, styleSheet, stylis);
    }
  }

  // $FlowFixMe
  return React.memo(GlobalStyleComponent);
}

createGlobalStyle 함수는 GlobalStyleComponent라는 React 컴포넌트를 반환하는데, 서버 사이드에서 이 컴포넌트는 renderStyles 함수를 한 번 호출합니다. renderStyles 함수는 GlobalStyle#renderStyles를 호출하고 있으니, GlobalStyle 클래스를 확인해 볼까요?

// GlobalStyle.js
export default class GlobalStyle {
  // ...

  createStyles(
    instance: number,
    executionContext: Object,
    styleSheet: StyleSheet,
    stylis: Stringifier
  ) {
    const flatCSS = flatten(this.rules, executionContext, styleSheet, stylis);
    const css = stylis(flatCSS.join(''), '');
    const id = this.componentId + instance;

    // NOTE: We use the id as a name as well, since these rules never change
    styleSheet.insertRules(id, id, css);
  }

  removeStyles(instance: number, styleSheet: StyleSheet) {
    styleSheet.clearRules(this.componentId + instance);
  }

  renderStyles(
    instance: number,
    executionContext: Object,
    styleSheet: StyleSheet,
    stylis: Stringifier
  ) {
    if (instance > 2) StyleSheet.registerId(this.componentId + instance);

    // NOTE: Remove old styles, then inject the new ones
    this.removeStyles(instance, styleSheet);
    this.createStyles(instance, executionContext, styleSheet, stylis);
  }
}

GlobalStylerenderStyles 함수는 인자로 넘어온 StyleSheet 인스턴스에 글로벌 스타일 규칙을 삭제 후 재등록하고 있습니다. 이쯤에서 생각해 보면, 글로벌 스타일 규칙이 여러 번 추가되는 이유는 createGlobalStyle 함수로 만든 GlobalStyleComponent 컴포넌트가 여러 번 렌더링 되고 있기 때문인 것 같습니다. 혹시나 해서 <GlobalStyle /> 컴포넌트를 사용하는 부분을 검색해 봤지만, 최상위 컴포넌트인 App의 하위 컴포넌트로 들어가 있을 뿐이었습니다. 그럼 대체 어느 부분이 문제가 되는 걸까요? 다시 처음으로 돌아가, 서버 사이드 렌더링에 사용되는 server.tsx 코드를 살펴봅시다.

const markup = await renderToStringWithData(
  extractor.collectChunks(sheet.collectStyles(Wrapper))
);

lodable-components의 collectChunks 함수와 react-apollo의 renderToStringWithData 함수가 의심스럽지 않나요? 고백하자면 저는 loadable-components부터 보고 왔지만.. 사실 문제는 react-apollo에 있었기 때문에, 여러분의 시간을 절약하기 위해 react-apollo의 코드를 먼저 살펴보도록 하겠습니다!

// renderToStringWithData.ts

export function renderToStringWithData(
  component: ReactElement<any>
): Promise<string> {
  return getMarkupFromTree({
    tree: component,
    renderFunction: require('react-dom/server').renderToString
  });
}

renderToStringWithData 함수는 getMarkupFromTree 함수를 호출하고 있습니다. 이제 문제의 핵심인 getMarkupFromTree 함수를 보러 가볼까요?

// getDataFromTree.ts

export function getMarkupFromTree({
  tree,
  context = {},
  // The rendering function is configurable! We use renderToStaticMarkup as
  // the default, because it's a little less expensive than renderToString,
  // and legacy usage of getDataFromTree ignores the return value anyway.
  renderFunction = require('react-dom/server').renderToStaticMarkup
}: GetMarkupFromTreeOptions): Promise<string> {
  const renderPromises = new RenderPromises();

  function process(): Promise<string> | string {
    // Always re-render from the rootElement, even though it might seem
    // better to render the children of the component responsible for the
    // promise, because it is not possible to reconstruct the full context
    // of the original rendering (including all unknown context provider
    // elements) for a subtree of the original component tree.
    const ApolloContext = getApolloContext();
    const html = renderFunction(
      React.createElement(
        ApolloContext.Provider,
        { value: { ...context, renderPromises } },
        tree
      )
    );

    return renderPromises.hasPromises()
      ? renderPromises.consumeAndAwaitPromises().then(process)
      : html;
  }

  return Promise.resolve().then(process);
}

여기서 재밌는 부분은 process 함수입니다. 앞서 확인한 renderToStringWithData 함수에 넘겨준 컴포넌트를 렌더링 한 다음, renderPromisesPromise가 있다면 consumeAndAwaitPromises를 호출하여 Promise를 모두 실행 및 제거하고, 다시 process 함수를 호출합니다. 이를 renderPromisesPromise가 하나도 없을 때까지 반복합니다. 그럼 어떤 경우에 renderPromisesPromise가 들어갈까요? 컴포넌트에서 아폴로 쿼리를 사용할 때라고 짐작할 수 있는데, 확실히 하기 위해 react-apollo의 QueryData 클래스를 확인해 봅시다.

// QueryData.ts

export class QueryData<TData, TVariables> extends OperationData {
  // ...

  public execute(): QueryResult<TData, TVariables> {
    // ...

    return this.getExecuteSsrResult() || this.getExecuteResult();
  }

  public ssrInitiated() {
    return this.context && this.context.renderPromises;
  }

  private getExecuteSsrResult() {
    // ...

    let result;
    if (this.ssrInitiated()) {
      result =
        this.context.renderPromises!.addQueryPromise(
          this,
          this.getExecuteResult
        ) || ssrLoading;
    }

    return result;
  }

예상대로 쿼리가 실행될 때, contextrenderPromises가 있다면 addQueryPromise 함수를 호출해 Promise를 추가하는 것을 볼 수 있습니다. 이렇게 앱을 여러 번 그려 쿼리를 실행하는 로직이 필요한 이유를 생각해보면, 간단하게는 아폴로 쿼리를 사용하는 컴포넌트 A가 상위 컴포넌트 B의 쿼리 결과에 따라 보이거나 숨겨지는 경우가 있을 것 같군요. 동작 자체에 문제는 없어 보입니다. 또 이런 방식이라야 하위 컴포넌트들이 어떤 쿼리를 쓰는지 모르는 채로 필요한 쿼리를 모두 실행할 수 있을 것 같기도 하고요. 그럼 이제 원인은 파악했고, 어떤 오류가 있어서 그런 것은 아니었으니 해결 방법을 고민해 봅시다!

문제 해결

앞서 파악한 내용을 정리해보면, 아폴로 쿼리를 실행하기 위해 앱을 여러 번 그리면서 하나의 StyleSheet 인스턴스에 같은 스타일 규칙을 여러 번 등록하는 것이 문제였습니다. 그렇다면 앱이 렌더링 될 때마다 ServerStyleSheet 인스턴스에 등록된 스타일 규칙을 초기화해주면 해결되지 않을까요?

styled-components의 StyleSheet 클래스를 살펴보니, clearTag() 함수로 등록된 스타일 규칙을 제거할 수는 있지만, 이렇게 하면 일반 컴포넌트들의 스타일은 삭제된 채로 다시 등록되지 않아 다른 문제가 발생하게 됩니다. reconstructWithOptions() 함수도 있지만, StyleSheet의 globalStyles를 그대로 사용하게 되어 있어 글로벌 스타일 규칙을 초기화하지는 못합니다. 그래서 단순하게 Wrapper 컴포넌트가 렌더링 될 때마다 ServerStyleSheet 인스턴스를 다시 생성해 주기로 했습니다.

// server.tsx

// ...

let sheet;

const Wrapper = {
  sheet = new ServerStyleSheet();

  return (
    <StaticRouter location={req.url} context={context}>
	    <App
	      cookies={req.universalCookie}
	      defaultLanguage={language.substr(0, 2)}
	      apolloClient={apolloClient}
	      mobxStores={mobxStores}
	      isServerSide
	    />
	  </StaticRouter>
	);
};

const markup = await renderToStringWithData(extractor.collectChunks(sheet.collectStyles(<Wrapper />)));

원본 코드는 아래와 같습니다. 변경된 내용이 보이시나요?

// server.tsx

// ...

const sheet = new ServerStyleSheet();

const Wrapper = (
  <StaticRouter location={req.url} context={context}>
    <App
      cookies={req.universalCookie}
      defaultLanguage={language.substr(0, 2)}
      apolloClient={apolloClient}
      mobxStores={mobxStores}
      isServerSide
    />
  </StaticRouter>
);

const markup = await renderToStringWithData(
  extractor.collectChunks(sheet.collectStyles(Wrapper))
);

이렇게 5줄 정도의 작은 변경으로 문제를 해결했습니다! 🎉 수많은 코드를 읽고 온 것에 비해 너무 간단하게 해결되어 허무하신가요? 아무리 작은 변경일지라도 그 뒤에서 무슨 일이 일어나는지 알고 하는 것과 그렇지 못한 것에는 큰 차이가 있답니다. 예를 들어, 서버 사이드 렌더링의 성능이 문제가 된다면 이번에 수정한 두 부분을 한 번 더 확인해 볼 수 있겠죠 🙂

결론

글머리에서도 이야기했듯이, 제품을 개발하다 보면 정말 많은 문제를 마주하게 됩니다. 우리가 배우고 익히는 여러 기술들은 결국 문제를 해결하기 위한 것이죠. 이런 문제들을 얼마나 잘 해결하는지가 개발자의 실력을 나타내는 척도라고 생각합니다. 처음에는 조금 익숙하지 않을 수도 있지만, 가끔은 이렇게 오픈 소스 라이브러리의 코드를 살펴보며 내가 사용하는 기능이 어떻게 구현되어 있는지, 무엇 때문에 문제가 발생하는지 확인하는 것도 좋지 않을까요? 우리의 문제 해결 능력을 한 단계 높여줄 테니까요!

히로
읽기 좋은 코드와 확장성 있는 아키텍쳐를 사랑합니다.