보통 UI라고 하면 개발보다는 디자인과 퍼블리싱의 영역으로 여겨집니다. 그렇기에 개발에서 주로 다루는 아키텍쳐에 관한 논의가 UI에 적용되는 일은 별로 없습니다. 그러나 오늘날에는 다채로우면서도 체계적인 디자인이 웹 디자인에 적용되면서, 이를 아키텍쳐의 측면에서 바라보고자 하는 시도도 많이 도입되고 있습니다. 잘 구조화된 UI는 개발을 빠르게 하고, 디자인 일관성을 지킬 수 있게 하며, 디자이너와 개발자의 소통을 편리하게 합니다.
아래 제가 임의로 UI 아키텍쳐의 원칙들 몇 개를 정리해 두었습니다. 우리가 사용하는 Next13과 mui에 대한 의존성을 적당한 수준으로 유지하면서도 서비스팀에서 만들어 준 디자인 시스템에 적합하도록 고안해 보았습니다. 물론 전혀 검증되지 않은 아키텍쳐이므로, 자유로운 의견 부탁드리겠습니다.
프로그래머에게 있어 아키텍쳐를 짠다는 것은 기본적으로 계층에 따라 코드를 분류하고, 그것들 간의 위계와 상호작용을 정립한다는 것을 의미합니다. UI 역시 마찬가지입니다. 이전에 논의한 바처럼, 우리는 화면을 페이지 - 모듈 - 컴포넌트의 세 계층으로 나누고 각각의 역할과 용법을 명시함으로써 UI를 하나의 아키텍쳐로 정리할 것입니다.
UI의 가장 큰 단위인 페이지는 Next.js에서 기본으로 제공하는 라우팅 방식에 따라 나뉘어집니다. 페이지는 특정한 주소에 접속했을 때 사용자가 보게 되는 화면 전체를 의미합니다. 페이지는 여러 모듈과 컴포넌트가 결합되어 생성됩니다.
다음으로 모듈은 컴포넌트와 각종 jsx 태그들이 조립된 UI의 중간 단위로, 특정한 기능을 담당하기 위해서는 더 이상 쪼개질 수 없는 단위를 의미합니다. 예를 들어 아이디를 입력하는 Text Input 컴포넌트 하나만으로는 로그인이라는 기능을 담당할 수 없으니 모듈이 될 수 없습니다. 그러나 아이디와 비밀번호를 입력하는 두 개의 Text Input, 그리고 로그인을 시도하는 버튼을 포함한 UI의 묶음은 하나의 모듈을 할 수 있습니다.
컴포넌트는 UI의 가장 작은 단위로, 특정한 정보를 표현하거나 사용자와 상호작용할 수 있는 원자적인 요소를 의미합니다. <div/>, <input/>과 같은 jsx 태그나 <Button/>과 같은 mui의 리액트 컴포넌트는 이미 만들어져 있는 컴포넌트의 좋은 예시들입니다. 컴포넌트는 어떠한 로직도 들고 있지 않아서, 어떤 용도로든 사용될 수 있습니다. 즉, Button 컴포넌트라면 클릭으로 수행되는 모든 기능에 대응할 수 있어야 합니다. 이는 모든 컴포넌트가 마찬가지입니다.
페이지를 제외하면, 계층의 구분은 조금 애매할 수 있습니다. 아래 내용을 참고하시면 자칫 헷갈릴 수 있는 각 계층의 구분을 명확히 할 수 있을 겁니다.
16 에서 이야기한 유즈케이스는 모듈과 가장 잘 대응하는 단위입니다. 모듈이 하나의 기능을을 담당하는, 분리될 수 없는 요소들의 묶음이기 때문입니다. 따라서 아래와 같은 내용이 성립합니다.
한 페이지에는 여러 유즈케이스가 존재할 수 있습니다.
한 모듈에는 하나의 유즈케이스만 존재하는 것이 이상적입니다. 특히,
컴포넌트의 내부에는 어떤 유즈케이스도 존재할 수 없습니다.
계층의 포함관계는 역전될 수 없습니다. 물론 모듈 내부에 모듈이 위치하거나, 컴포넌트 내부에 컴포넌트가 존재하는 것은 가능합니다. 그러나 컴포넌트 내부에 모듈이 오거나, 모듈 내부에 페이지가 올 수는 없습니다.
페이지에는 보통 서버의 데이터를 가져오거나 보내는 코드를 배치할 필요가 없습니다. 한 모듈이 하나의 기능을 담당하므로, 대부분의 로직은 모듈 단위에서 다루면 됩니다. 그러나 여러 모듈에서 공통으로 쓰이는 서버 데이터가 있는 등의 경우에는 효율을 위해 페이지에서 특정 데이터를 미리 불러올 수도 있습니다.
모듈은 페이지에 귀속됩니다. 완전히 동일한 모듈이 아니라면, 페이지 별로 따로 분리하여 따로 작업하는 것이 원칙입니다. 만약 인접한 페이지 간에 공유되는 모듈이 있다면 layout으로 빼는 것을 고려해보세요.
컴포넌트 만들기
기본 원칙
컴포넌트는 src/components 디렉토리에 위치합니다. 컴포넌트는 어떤 페이지나 모듈에서든 사용할 수 있게 확장성이 높게 제작되어야 합니다. 컴포넌트는 먼저 디자인과 기능을 확인한 후, jsx과 mui를 커스터마이징하고, 사용하기 편하게 export하는 방식으로 제작됩니다.
각 컴포넌트의 디렉토리가 너무 잘게 나누는 것을 주의하세요. 예를 들어 CapsuleButton과 SquareButton, TextButton등이 다 다른 디렉토리에 존재하게 된다면 components 디렉토리를 너무 많이 차지하게 될 것입니다. 그보다는 src/components/Button 디렉토리 내에 Capsule.tsx, Square.tsx 등의 파일이 위치하고, index.tsx에서 컴포넌트들을 묶어서 <Button.Capsule/>, <Button.Square/>와 같은 방식으로 사용하게끔 내보내는 게 이상적입니다.
예시 코드
코드에 주석을 달아놓았고, 자세한 설명은 아래 달아두겠습니다.
/** @jsxImportSource @emotion/react */ // emotion을 위한 jsx pragma
"use client"; // 이게 필요한 이유는 최하단의 '호환성 문제'에서 설명합니다.
import typography from "@/styles/typography"; // import style foundation
import { css } from "@emotion/react"; // jsx와 mui의 커스터마이징에 사용되는 라이브러리. mui가 아니라 @emotion/react에서 import해야 합니다.
import { HTMLAttributes } from "react";
type HeadingProps = HTMLAttributes<HTMLHeadingElement>; // 확장성을 위해 이 컴포넌트가 대체하는 <h1/>~<h6/>의 props를 그대로 가져옵니다.
const heading = css` // heading에 공통으로 적용할 스타일 지정
margin: 0;
`;
function H1(props: HeadingProps) {
return (
<h1
// 미리 지정한 스타일과 foundation을 혼합하여 style 결정
css={css`
${heading}
${typography.h1}
`}
// 기본 props 일괄 적용
{...props}
>
{props.children}
</h1>
);
}
/** 중략 **/ // H2 ~ H6 마저 정의
const Heading = { H1, H2, H3, H4, H5, H6 }; // 가독성을 위해 하나의 객체로 묶기
export default Heading;
디자인 확인
Xction 디자인 시스템 가이드를 보시면 Button, Cards와 같은 Component들이 정리되어 있습니다. 기본적으로 개발의 컴포넌트는 여기에 정의된 재사용 가능한 디자인 단위에 따라 제작됩니다. 그러나 예시로 다룬 Heading과 같이, 필요에 따라 따로
같은 페이지에서 Foundation이라고 정리된 디자인 항목들도 존재할 것입니다. 이는 컴포넌트가 아니라 재사용 가능한 스타일 규칙, 즉 css 코드만을 정리해둔 것입니다. 이는 src/styles 디렉토리에 따로 정리될 것입니다. Heading에서 사용한 typography가 그 예입니다.
Props 확장하기
컴포넌트는 jsx나 mui에 존재하는 특정 태그를 대체합니다. 확장성을 위해서는 대체하는 대상의 prop을 extend하여 사용하는 것이 권장됩니다. jsx태그나 특정 컴포넌트의 props 목록을 불러올 때는 다음과 같은 방법을 사용할 수 있습니다.
// 기본 jsx 태그를 대체할 경우
// HTMLAttributes라는 고차 타입을 이용합니다.
import { HTMLAttributes } from "react";
type JsxProps = HTMLAttributes<HTMLDivElement>;
// 특정 리액트 컴포넌트를 대체할 경우
// 리액트 함수 컴포넌트의 첫 번째 인자가 props 객체임을 이용합니다.
import { Button } from "@mui/material";
type ComponentProps = type AProps = Parameters<typeof Button>[0]; // Button은 mui의 컴포넌트
rest와 spread 문법을 이용하면 props를 쉽게 전달할 수 있습니다. 아래는 기본 태그의 props를 전달하면서, 동시에 content라는 새로 정의한 prop을 이용하는 버튼 대체 컴포넌트의 예시입니다.
샘플 PR을 보면 아시겠지만, 컴포넌트는 css 파일을 포함하고 있지 않습니다. 이는 @emotion/react 라이브러리를 이용한 css-in-js 방식의 커스터마이징을 권장하기 때문입니다. 공식 문서는 이쪽.
emotion을 사용하기 위해서는 먼저 맨 파일 위에 /** @jsxImportSource @emotion/react */이라는 jsx pragma 구문을 붙여주어야 합니다. 그러면 모든 jsx 태그들에 css라는 prop이 추가됩니다. 여기에 @emotion/react에서 임포트한 css 함수를 이용하여 마치 css 코드와 같은 string을 넣어주면, 마치 인라인 스타일같이 생긴 컴포넌트가 최적화된 상태로 스타일링 됩니다.
주의사항:@mui/material에서도 emotion에서 사용하는 함수들을 import할 수 있습니다. mui에서도 같은 기능을 하는 라이브러리를 제공하기 때문입니다. 다만 후술할 호환성 문제 때문에 @emotion/react에서 import하여 사용하길 강력히 권장합니다.
내보내기
비슷한 종류의 컴포넌트들은 하나의 객체로 묶으면 쉽게 사용할 수 있습니다. 리액트 컴포넌트는 객체의 프로퍼티에 접근하듯 <SomeComponent.SomeType/> 같은 식의 문법을 지원하기 때문입니다. 따라서 아래와 같이 묶어서 export하는 방식을 권장합니다.
모듈은 src/modules 폴더에 위치합니다. 특별한 이유가 있지 않다면, 해당 모듈을 사용할 layout이나 page의 이름을 붙인 디렉토리 안에 위치시켜주세요.
모듈은 컴포넌트와 달리 각종 로직에 관련된 코드와 가 있어 코드가 쉽게 길어집니다. 따라서 컴포넌트와 같이 emotion의 css함수를 이용한 방식으로 디자인을 하면 가독성이 심히 떨어질 수 있습니다. 따라서 @emotion/styled를 이용한 디자인 커스터마이징을 추천드립니다.
styled는 jsx 태그의 이름이나 다른 리액트 컴포넌트(특히 components 폴더에 정의했거나 mui에서 import한)를 인자로 전달할 수 있는 고차 함수입니다. 그리고 나서 원하는 style을 객체나 문자열로 전달하면 스타일이 커스터마이징된 임시 컴포넌트가 생성됩니다.
만들어진 임시 컴포넌트는 아래와 같이 실제 컴포넌트처럼 사용할 수 있습니다.
<Section>
<SectionTitle># 어떤 제목</SectionTitle>
어떤 내용
</Section>
styled를 이용한 임시 컴포넌트는 코드 최하단에 따로 작성하는 것이 일반적입니다. 샘플 PR의 DevSection 모듈에서 확인할 수 있습니다.
페이지 펴내기
페이지는 지금까지 했던 것처럼, /src/app 폴더 내에 Next13의 문법 대로 작성해주시면 됩니다. 페이지에서는 필요한 Module과 Component를 잘 import하여 사용하시면 됩니다. 모듈과 마찬가지로, styled 함수를 이용하여 디자인을 적용하면 간편합니다.
export default function Dev() {
return (
<Main>
<Title>COMPONENTS</Title>
<DevSection title="Heading">
<Heading.H1>H1: 가장 큰 제목입니다</Heading.H1>
<Heading.H2>H2: 큰 제목입니다</Heading.H2>
<Heading.H3>H3: 중간 제목입니다</Heading.H3>
<Heading.H4>H4: 작은 제목입니다</Heading.H4>
<Heading.H5>H5: 아주 작은 제목입니다</Heading.H5>
<Heading.H6>H6: 가장 작은 제목입니다</Heading.H6>
</DevSection>
</Main>
);
}
const Main = styled("main")({
display: "flex",
flexDirection: "column",
backgroundColor: "black",
color: "white",
minHeight: "100vh",
});
const Title = styled(Heading.H1)({
textAlign: "center",
margin: "40px 0",
});
위와 같은 방식으로 UI 아키텍쳐를 짜보았다니 치명적인 문제가 하나 발생했습니다. 바로 Next13의 app router와 emotion 사이에서 호환성 문제가 발생한다는 것입니다. 작년부터 제보된 문제인데, 아직도 해결중인 것 같습니다...
일단 가장 간단한 해결책은 css()나 styled()와 같은 emotion의 기능을 사용하는 모든 페이지에 "use client"를 명시하여, client 컴포넌트로 만드는 것입니다. 따라서 일단 지금은 "use client"를 마구 사용하는 게 유일한 방법일 것 같습니다. Server 컴포넌트에서 emotion을 사용하려면 좀 복잡한 우회로를 이용해야 하는데, 이는 좀 더 알아보고 해결할테니 일단 client 컴포넌트로 모두 작업해주시기 바랍니다.
최악의 경우 Next13을 덜어내야 할 지도 모르겠습니다...
프론트엔드 작업자를 위한 아키텍쳐 원칙 정리 (UI)
보통 UI라고 하면 개발보다는 디자인과 퍼블리싱의 영역으로 여겨집니다. 그렇기에 개발에서 주로 다루는 아키텍쳐에 관한 논의가 UI에 적용되는 일은 별로 없습니다. 그러나 오늘날에는 다채로우면서도 체계적인 디자인이 웹 디자인에 적용되면서, 이를 아키텍쳐의 측면에서 바라보고자 하는 시도도 많이 도입되고 있습니다. 잘 구조화된 UI는 개발을 빠르게 하고, 디자인 일관성을 지킬 수 있게 하며, 디자이너와 개발자의 소통을 편리하게 합니다.
아래 제가 임의로 UI 아키텍쳐의 원칙들 몇 개를 정리해 두었습니다. 우리가 사용하는 Next13과 mui에 대한 의존성을 적당한 수준으로 유지하면서도 서비스팀에서 만들어 준 디자인 시스템에 적합하도록 고안해 보았습니다. 물론 전혀 검증되지 않은 아키텍쳐이므로, 자유로운 의견 부탁드리겠습니다.
목차
계층들
프로그래머에게 있어
아키텍쳐를 짠다
는 것은 기본적으로 계층에 따라 코드를 분류하고, 그것들 간의 위계와 상호작용을 정립한다는 것을 의미합니다. UI 역시 마찬가지입니다. 이전에 논의한 바처럼, 우리는 화면을페이지
-모듈
-컴포넌트
의 세 계층으로 나누고 각각의 역할과 용법을 명시함으로써 UI를 하나의 아키텍쳐로 정리할 것입니다.페이지
는 Next.js에서 기본으로 제공하는 라우팅 방식에 따라 나뉘어집니다. 페이지는 특정한 주소에 접속했을 때 사용자가 보게 되는 화면 전체를 의미합니다. 페이지는 여러 모듈과 컴포넌트가 결합되어 생성됩니다.모듈
은 컴포넌트와 각종 jsx 태그들이 조립된 UI의 중간 단위로, 특정한 기능을 담당하기 위해서는 더 이상 쪼개질 수 없는 단위를 의미합니다. 예를 들어 아이디를 입력하는 Text Input 컴포넌트 하나만으로는 로그인이라는 기능을 담당할 수 없으니 모듈이 될 수 없습니다. 그러나 아이디와 비밀번호를 입력하는 두 개의 Text Input, 그리고 로그인을 시도하는 버튼을 포함한 UI의 묶음은 하나의 모듈을 할 수 있습니다.컴포넌트
는 UI의 가장 작은 단위로, 특정한 정보를 표현하거나 사용자와 상호작용할 수 있는 원자적인 요소를 의미합니다.<div/>
,<input/>
과 같은 jsx 태그나<Button/>
과 같은 mui의 리액트 컴포넌트는 이미 만들어져 있는 컴포넌트의 좋은 예시들입니다. 컴포넌트는 어떠한 로직도 들고 있지 않아서, 어떤 용도로든 사용될 수 있습니다. 즉, Button 컴포넌트라면 클릭으로 수행되는 모든 기능에 대응할 수 있어야 합니다. 이는 모든 컴포넌트가 마찬가지입니다.페이지를 제외하면, 계층의 구분은 조금 애매할 수 있습니다. 아래 내용을 참고하시면 자칫 헷갈릴 수 있는 각 계층의 구분을 명확히 할 수 있을 겁니다.
16 에서 이야기한
유즈케이스
는모듈
과 가장 잘 대응하는 단위입니다. 모듈이 하나의 기능을을 담당하는, 분리될 수 없는 요소들의 묶음이기 때문입니다. 따라서 아래와 같은 내용이 성립합니다.컴포넌트 만들기
기본 원칙
컴포넌트는
src/components
디렉토리에 위치합니다. 컴포넌트는 어떤 페이지나 모듈에서든 사용할 수 있게 확장성이 높게 제작되어야 합니다. 컴포넌트는 먼저 디자인과 기능을 확인한 후, jsx과 mui를 커스터마이징하고, 사용하기 편하게 export하는 방식으로 제작됩니다. 각 컴포넌트의 디렉토리가 너무 잘게 나누는 것을 주의하세요. 예를 들어 CapsuleButton과 SquareButton, TextButton등이 다 다른 디렉토리에 존재하게 된다면 components 디렉토리를 너무 많이 차지하게 될 것입니다. 그보다는src/components/Button
디렉토리 내에Capsule.tsx
,Square.tsx
등의 파일이 위치하고,index.tsx
에서 컴포넌트들을 묶어서<Button.Capsule/>
,<Button.Square/>
와 같은 방식으로 사용하게끔 내보내는 게 이상적입니다.예시 코드
코드에 주석을 달아놓았고, 자세한 설명은 아래 달아두겠습니다.
디자인 확인
Xction 디자인 시스템 가이드를 보시면 Button, Cards와 같은 Component들이 정리되어 있습니다. 기본적으로 개발의 컴포넌트는 여기에 정의된 재사용 가능한 디자인 단위에 따라 제작됩니다. 그러나 예시로 다룬 Heading과 같이, 필요에 따라 따로 같은 페이지에서 Foundation이라고 정리된 디자인 항목들도 존재할 것입니다. 이는 컴포넌트가 아니라 재사용 가능한 스타일 규칙, 즉 css 코드만을 정리해둔 것입니다. 이는
src/styles
디렉토리에 따로 정리될 것입니다. Heading에서 사용한typography
가 그 예입니다.Props 확장하기
컴포넌트는 jsx나 mui에 존재하는 특정 태그를 대체합니다. 확장성을 위해서는 대체하는 대상의 prop을 extend하여 사용하는 것이 권장됩니다. jsx태그나 특정 컴포넌트의 props 목록을 불러올 때는 다음과 같은 방법을 사용할 수 있습니다.
rest와 spread 문법을 이용하면 props를 쉽게 전달할 수 있습니다. 아래는 기본 태그의 props를 전달하면서, 동시에 content라는 새로 정의한 prop을 이용하는 버튼 대체 컴포넌트의 예시입니다.
스타일 커스터마이징
샘플 PR을 보면 아시겠지만, 컴포넌트는 css 파일을 포함하고 있지 않습니다. 이는
@emotion/react
라이브러리를 이용한 css-in-js 방식의 커스터마이징을 권장하기 때문입니다. 공식 문서는 이쪽. emotion을 사용하기 위해서는 먼저 맨 파일 위에/** @jsxImportSource @emotion/react */
이라는 jsx pragma 구문을 붙여주어야 합니다. 그러면 모든 jsx 태그들에 css라는 prop이 추가됩니다. 여기에@emotion/react
에서 임포트한css
함수를 이용하여 마치 css 코드와 같은 string을 넣어주면, 마치 인라인 스타일같이 생긴 컴포넌트가 최적화된 상태로 스타일링 됩니다.주의사항:
@mui/material
에서도 emotion에서 사용하는 함수들을 import할 수 있습니다. mui에서도 같은 기능을 하는 라이브러리를 제공하기 때문입니다. 다만 후술할 호환성 문제 때문에@emotion/react
에서 import하여 사용하길 강력히 권장합니다.내보내기
비슷한 종류의 컴포넌트들은 하나의 객체로 묶으면 쉽게 사용할 수 있습니다. 리액트 컴포넌트는 객체의 프로퍼티에 접근하듯
<SomeComponent.SomeType/>
같은 식의 문법을 지원하기 때문입니다. 따라서 아래와 같이 묶어서 export하는 방식을 권장합니다.모듈 조립하기
모듈은
src/modules
폴더에 위치합니다. 특별한 이유가 있지 않다면, 해당 모듈을 사용할 layout이나 page의 이름을 붙인 디렉토리 안에 위치시켜주세요. 모듈은 컴포넌트와 달리 각종 로직에 관련된 코드와 가 있어 코드가 쉽게 길어집니다. 따라서 컴포넌트와 같이 emotion의 css함수를 이용한 방식으로 디자인을 하면 가독성이 심히 떨어질 수 있습니다. 따라서@emotion/styled
를 이용한 디자인 커스터마이징을 추천드립니다.styled
는 jsx 태그의 이름이나 다른 리액트 컴포넌트(특히 components 폴더에 정의했거나 mui에서 import한)를 인자로 전달할 수 있는 고차 함수입니다. 그리고 나서 원하는 style을 객체나 문자열로 전달하면 스타일이 커스터마이징된 임시 컴포넌트가 생성됩니다. 만들어진 임시 컴포넌트는 아래와 같이 실제 컴포넌트처럼 사용할 수 있습니다.styled를 이용한 임시 컴포넌트는 코드 최하단에 따로 작성하는 것이 일반적입니다. 샘플 PR의
DevSection
모듈에서 확인할 수 있습니다.페이지 펴내기
페이지는 지금까지 했던 것처럼,
/src/app
폴더 내에 Next13의 문법 대로 작성해주시면 됩니다. 페이지에서는 필요한 Module과 Component를 잘 import하여 사용하시면 됩니다. 모듈과 마찬가지로,styled
함수를 이용하여 디자인을 적용하면 간편합니다.페이지의 디자인은 여기에서 확인하실 수 있습니다.
호환성 문제
위와 같은 방식으로 UI 아키텍쳐를 짜보았다니 치명적인 문제가 하나 발생했습니다. 바로 Next13의 app router와 emotion 사이에서 호환성 문제가 발생한다는 것입니다. 작년부터 제보된 문제인데, 아직도 해결중인 것 같습니다... 일단 가장 간단한 해결책은
css()
나styled()
와 같은 emotion의 기능을 사용하는 모든 페이지에 "use client"를 명시하여, client 컴포넌트로 만드는 것입니다. 따라서 일단 지금은 "use client"를 마구 사용하는 게 유일한 방법일 것 같습니다. Server 컴포넌트에서 emotion을 사용하려면 좀 복잡한 우회로를 이용해야 하는데, 이는 좀 더 알아보고 해결할테니 일단 client 컴포넌트로 모두 작업해주시기 바랍니다. 최악의 경우 Next13을 덜어내야 할 지도 모르겠습니다...