Skip to content

교육 마이크로앱 구현

Emotion(CSS-in-JS)으로 스타일링하고 Jotai로 전역 상태를 관리하며, 교육 콘텐츠 목록/상세 페이지를 구현하는 교육 마이크로앱을 완성한다.

학습 목표

  • Emotion(@emotion/styled, @emotion/react)을 활용한 CSS-in-JS 스타일링 패턴을 적용할 수 있다
  • Jotai의 atom과 useAtomValue/useSetAtom 훅으로 경량 전역 상태를 관리할 수 있다
  • 교육 콘텐츠 목록과 상세 페이지 간 라우팅을 구현할 수 있다
  • Layout 컴포넌트에서 데이터를 사전 로딩하여 하위 컴포넌트에 공급하는 패턴을 이해한다

1. 교육 마이크로앱 아키텍처

교육 마이크로앱은 포스팅 앱과 동일한 Module Federation 패턴으로 Shell에 연결되지만, 기술 스택이 다르다. Emotion으로 스타일링하고 Jotai로 상태를 관리한다.


2. Emotion을 활용한 스타일링

Emotion은 JavaScript 안에서 CSS를 작성하는 CSS-in-JS 라이브러리다. @emotion/styled로 Styled Components 패턴을 사용하고, 각 컴포넌트의 스타일 파일을 *.styles.ts로 분리한다.

typescript
// src/components/layout.styles.ts
import styled from "@emotion/styled";

export const LayoutWrapper = styled.div`
  display: flex;
  flex-direction: row;
  gap: 24px;
  max-width: 1128px;
  margin: 0 auto;
  padding: 16px;

  .edu--layout-left {
    display: flex;
    flex-direction: column;
    width: 225px;
    gap: 10px;
  }

  .edu--layout-center {
    display: flex;
    flex-direction: column;
    width: 879px;
    gap: 10px;
  }
`;
typescript
// src/components/profile.styles.ts
import styled from "@emotion/styled";

export const ProfileWrapper = styled.div`
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  text-align: center;

  .edu--profile-avatar {
    width: 64px;
    height: 64px;
    border-radius: 50%;
    object-fit: cover;
  }

  .edu--profile-name {
    font-size: 18px;
    font-weight: bold;
    margin-top: 8px;
  }

  .edu--profile-email {
    font-size: 14px;
    color: #666;
  }
`;

Emotion 스타일 격리 전략: 모든 클래스명에 edu-- 접두사를 붙여 다른 마이크로앱의 스타일과 충돌을 방지한다. Emotion이 자동 생성하는 해시 클래스와 결합되어 이중 격리가 이루어진다.


3. Jotai를 활용한 전역 상태 관리

Jotai는 React의 Context 없이 원자적(atomic) 상태를 관리하는 라이브러리다. atom 단위로 상태를 정의하고, 필요한 컴포넌트에서만 구독한다.

typescript
// src/atoms.ts
import { atom } from "jotai";
import type { UserType, CourseType } from "./types";

export const userAtom = atom<UserType | null>(null);
export const coursesAtom = atom<CourseType[]>([]);
typescript
// src/types.ts
import type { User } from "@auth0/auth0-spa-js";

export interface UserType extends User {
  viewCount: number;
  updateCount: number;
  courses: { courseId: number; done: boolean }[];
}

export interface CourseType {
  id: number;
  thumbnail: string;
  title: string;
  description: string;
}

export interface CourseContentsType {
  id: number;
  goals: string[];
  summaries: string[];
}

4. Layout 컴포넌트에서 데이터 사전 로딩

Layout은 앱 최상위에 위치하며, 렌더 시점에 토큰을 획득하고 API를 병렬 호출하여 atom에 데이터를 저장한다.

typescript
// src/components/Layout.tsx
import React, { useEffect } from "react";
import { useSetAtom } from "jotai";
import { LayoutWrapper } from "./layout.styles";
import useAuth0Client from "../hooks/useAuth0Client";
import { userAtom, coursesAtom } from "../atoms";
import { getUser, getCourses } from "../api";
import ProfileContainer from "../containers/ProfileContainer";
import MyCourseInfoContainer from "../containers/MyCourseInfoContainer";

const Layout: React.FC<React.PropsWithChildren> = ({ children }) => {
  const auth0Client = useAuth0Client();
  const setUser = useSetAtom(userAtom);
  const setCourses = useSetAtom(coursesAtom);

  useEffect(() => {
    (async () => {
      try {
        const token = await auth0Client.getTokenSilently();
        // 병렬 API 호출: 동시에 시작하여 성능 향상
        getUser(token).then(setUser);
        getCourses(token).then(setCourses);
      } catch (e) {
        alert(e);
      }
    })();
  }, [auth0Client, setCourses, setUser]);

  return (
    <LayoutWrapper>
      <div className="edu--layout-left">
        <ProfileContainer />
        <MyCourseInfoContainer />
      </div>
      <div className="edu--layout-center">{children}</div>
    </LayoutWrapper>
  );
};

export default Layout;

5. API 연동과 라우팅

교육 마이크로앱은 목록(/)과 상세(/:id) 두 개의 라우트를 갖는다.

typescript
// src/api.ts
export async function getCourses(token: string): Promise<CourseType[]> {
  const response = await fetch(
    "http://localhost:4000/courses?_sort=id&_order=desc",
    { headers: { authorization: `Bearer ${token}` } }
  );
  return await response.json();
}

export async function getCourseContents(
  token: string,
  id: number
): Promise<CourseContentsType> {
  const response = await fetch(
    `http://localhost:4000/courseContents/${id}`,
    { headers: { authorization: `Bearer ${token}` } }
  );
  return await response.json();
}

export async function getUser(token: string): Promise<UserType> {
  const response = await fetch("http://localhost:4000/user", {
    headers: { authorization: `Bearer ${token}` }
  });
  return await response.json();
}
typescript
// src/routes.tsx
import React from "react";
import type { RouteObject } from "react-router-dom";
import Auth0ClientProvider from "./providers/Auth0ClientProvider";
import { AppRoutingManager } from "@career-up/shell-router";
import Layout from "./components/Layout";
import PageList from "./pages/PageList";
import PageDetail from "./pages/PageDetail";

export const routes: RouteObject[] = [
  {
    path: "/",
    element: (
      <Auth0ClientProvider>
        <Layout>
          <AppRoutingManager type="app-edu" />
        </Layout>
      </Auth0ClientProvider>
    ),
    errorElement: <div>app-edu-error</div>,
    children: [
      { index: true, element: <PageList /> },
      { path: ":id", element: <PageDetail /> }
    ]
  }
];

6. Container/Presentational 분리

데이터 로직(Container)과 UI 렌더링(Presentational)을 분리하여 테스트와 재사용을 용이하게 한다.

typescript
// src/containers/ProfileContainer.tsx
import React from "react";
import { useAtomValue } from "jotai";
import { userAtom } from "../atoms";
import Profile from "../components/Profile";

const ProfileContainer: React.FC = () => {
  const user = useAtomValue(userAtom);
  if (!user) return null;
  return (
    <Profile
      name={user.name}
      email={user.email}
      picture={user.picture}
    />
  );
};

export default ProfileContainer;
계층역할예시
Containeratom에서 데이터 읽기, API 호출ProfileContainer, MyCourseInfoContainer
Presentationalprops로 받은 데이터 렌더링Profile, MyCourseInfo
atom전역 상태 정의userAtom, coursesAtom

핵심 정리

  1. Emotion의 Styled Components 패턴(styled.div)으로 컴포넌트와 스타일을 긴밀하게 연결하고, edu-- 접두사로 네임스페이스를 확보한다
  2. Jotaiatom()으로 상태를 정의하고 useSetAtom/useAtomValue로 쓰기/읽기를 분리하여 불필요한 리렌더링을 방지한다
  3. Layout에서 getTokenSilently()getUser()getCourses()병렬 호출(.then() 패턴)하여 로딩 시간을 단축한다
  4. routes.tsx에서 path: ":id"를 정의하면 useParams()로 동적 라우트 파라미터를 추출할 수 있다
  5. Container/Presentational 분리는 상태 관리 로직과 UI 렌더링을 독립시켜 테스트와 유지보수를 용이하게 한다

다음 단계

  • 04-네트워킹-서비스.md: Tailwind CSS로 유틸리티 우선 스타일링을 적용하고, 인맥 관리 기능과 유틸리티 공유 패턴을 구현한다