JHeeJinDev 님의 상세페이지[6팀 장희진] Chapter 4-1 성능 최적화

과제 체크포인트

배포 링크

https://jheejindev.github.io/front_6th_chapter4-1/react/ https://jheejindev.github.io/front_6th_chapter4-1/vanilla/

기본과제 (Vanilla SSR & SSG)

Express SSR 서버

  • Express 미들웨어 기반 서버 구현
  • 개발/프로덕션 환경 분기 처리
  • HTML 템플릿 치환 (<!--app-html-->, <!--app-head-->)

서버 사이드 렌더링

  • 서버에서 동작하는 Router 구현
  • 서버 데이터 프리페칭 (상품 목록, 상품 상세)
  • 서버 상태관리 초기화

클라이언트 Hydration

  • window.__INITIAL_DATA__ 스크립트 주입
  • 클라이언트 상태 복원
  • 서버-클라이언트 데이터 일치

Static Site Generation

  • 동적 라우트 SSG (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포

심화과제 (React SSR & SSG)

React SSR

  • renderToString 서버 렌더링
  • TypeScript SSR 모듈 빌드
  • Universal React Router (서버/클라이언트 분기)
  • React 상태관리 서버 초기화

React Hydration

  • Hydration 불일치 방지
  • 클라이언트 상태 복원

Static Site Generation

  • 동적 라우트 SSG (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포

구현 과정 돌아보기

가장 어려웠던 부분과 해결 과정

그동안 Next.js를 쓸 때 SSR을 그냥 “이럴 때 쓰는 거구나”, “이렇게 쓰면 되네” 정도로만 이해하고 있었습니다. 사실 서버랑 클라이언트가 어떤 과정을 거쳐서 화면을 만들어내는지는 깊게 고민해본 적도 없었고, 그냥 Next.js가 편하게 제공해주는 기능 중 하나라고만 생각했던 것 같습니다. 그런데 이번 과제를 하면서 단순히 결과만 얻는 것과, 그 결과가 만들어지는 과정을 이해하는 건 전혀 다른 문제라는 걸 깨달았습니다. SSR의 구조와 원리를 하나하나 따라가 보니, 제가 얼마나 겉핥기 식으로만 접근했는지를 알게 됐고, 동시에 더 깊은 이해가 필요하다는 점도 크게 느꼈습니다.

그래서 이번 과제에서 가장 어려웠던 부분은 SSR의 전체적인 동작 과정을 파악하는 것이었습니다. 단순히 코드를 짜는 걸 넘어서, 서버에서 HTML이 생성되고 클라이언트에서 Hydration이 이어지는 흐름을 제대로 이해하는 게 정말 쉽지 않았습니다. 특히 발제를 보면서 이 정도로 이해가 안 된 적은 처음이었는데, 무려 네 번이나 다시 봤지만 결국 머릿속에 남은 건 준일 코치님의 코딩 실력에 대한 감탄뿐이었습니다. 이번 경험 덕분에 단순히 "쓰는 법"이 아니라 "왜 그렇게 동작하는지"를 이해하는 게 얼마나 중요한지 다시 돌아보게 된 것 같습니다.

SSR 동작 과정 단계별 이해

  • 1단계: 서버에서의 초기 요청 처리
app.get("*all", async (req, res) => {
  const rendered = await render(req.originalUrl, req.query);
  // ...
});

사용자가 URL에 접근하면 서버가 먼저 요청을 받고, 해당 URL에 맞는 데이터를 준비해야 한다.

  • 2단계:데이터 프리페칭과 스토어 초기화
if (route.path === "/") {
  const [productsResponse, categories] = await Promise.all([
    getProducts(router.query),
    getCategories()
  ]);
  
  // 스토어에 데이터 설정
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: { products: productsResponse.products, ... }
  });
}

서버에서 미리 데이터를 가져와서 스토어에 저장해야 클라이언트에서도 동일한 상태를 유지할 수 있다.

  • 3단계: React 컴포넌트를 HTML로 변환
const html = renderToString(<App />);

React 컴포넌트가 실제로 HTML 문자열로 변환되는 과정을 직접 경험했습니다. 이 과정에서 컴포넌트의 모든 상태가 HTML에 반영되어야 한다는 것을 깨달았습니다.

  • 4단계:초기 데이터를 HTML에 주입
const html = template
  .replace(`<!--app-head-->`, rendered.head ?? "")
  .replace(`<!--app-data-->`, `<script>window.__INITIAL_DATA__ = ${rendered.data}</script>`)
  .replace(`<!--app-html-->`, rendered.html ?? "");

서버에서 준비한 데이터를 클라이언트로 전달하기 위해 window.__INITIAL_DATA__에 주입하는 것이 핵심.

  • 5단계:클라이언트에서 HTML 수신 및 Hydration
function hydrateFromSSRDataSync() {
  const d = window.__INITIAL_DATA__;
  
  // 서버에서 전달된 데이터로 스토어 초기화
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: d.products ? { products: d.products, ... } : { ... }
  });
}

// React Hydration
hydrateRoot(rootElement, <App />);

클라이언트에서는 서버로부터 받은 HTML과 데이터를 사용해서 React 앱을 활성화(혹은 연결) 시키는 과정이 Hydration이다.

구현하면서 새롭게 알게 된 개념

useSyncExternalStore의 getServerSnapshot의 중요성

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
  const shallowSelector = useShallowSelector(selector);
  return useSyncExternalStore(
    store.subscribe,
    () => shallowSelector(store.getState()),
    () => shallowSelector(store.getState()), // getServerSnapshot
  );
};

useSyncExternalStore의 세 번째 인자인 getServerSnapshot이 하이드레이션 불일치를 방지하는 핵심이라는 것을 알게 되었습니다. 서버에서 렌더링된 상태와 클라이언트에서 초기 상태가 정확히 일치해야 React가 하이드레이션을 성공적으로 수행할 수 있습니다.

ServerRouter와 Client Router의 차이점

export class ServerRouter<Handler extends (...args: any[]) => any> {
  push(url: string = "/") {
    try {
      this.#route = this.#findRoute(url);
    } catch (error) {
      console.error("라우터 네비게이션 오류:", error);
    }
  }
  
  start(url = "/", query = {}) {
    this.#route = this.#findRoute(url);
    this.#currentQuery = query;
  }
}

서버에서는 브라우저 히스토리 API가 없기 때문에 push 메서드가 실제로는 URL을 변경하지 않고 단순히 라우트 매칭만 수행한다는 것을 알게 되었습니다. 서버 사이드에서는 네비게이션이 불가능하고, 오직 URL 파싱과 라우트 매칭만 가능합니다.

성능 최적화 관점에서의 인사이트

SSG 배치 처리로 메모리 효율성 확보

async function generateStaticSite() {
  // 2) 주요 상품 상세 페이지들 생성
  const productIds = items.slice(100, 130).map((p) => p.productId);
  productIds.push(items.find((product) => product.productId === "86940857379").productId);

  for (const id of productIds) {
    const url = `${BASE}product/${id}/`;
    const outDir = `../../dist/react/product/${id}`;
    await fs.mkdir(outDir, { recursive: true });
    await writeRoute(url, template, `${outDir}/index.html`);
  }
}

성능 인사이트: 전체 상품(수만 개)을 한 번에 SSG로 생성하면 메모리 부족이 발생할 수 있어서, 선택적 배치 처리를 도입했습니다. 인기 상품 31개만 선별하여 생성함으로써 빌드 시간을 단축하고 메모리 사용량을 최적화했습니다.

학습 갈무리

Q1. 현재 구현한 SSR/SSG 아키텍처에서 확장성을 고려할 때 어떤 부분을 개선하시겠습니까?

상태 관리 아키텍처의 중앙화

// 현재: 각 엔티티별로 개별 스토어
export const productStore = createStore(productReducer, initialProductState);
export const cartStore = createStore(cartReducer, initialCartState);

개선 방안:

  • 통합 스토어 패턴 도입으로 상태 간 의존성 관리 개선
  • 상태 정규화를 통해 중복 데이터 제거 (예: 상품 정보가 여러 곳에 중복 저장)
  • 상태 분할 전략으로 필요한 상태만 컴포넌트에 주입

캐싱 전략의 부재

// 현재: 매번 새로운 API 호출
const [productsResponse, categories] = await Promise.all([
  getProducts(router.query), 
  getCategories()
]);

개선 방안:

  • Redis 기반 서버 사이드 캐싱으로 API 응답 캐싱
  • SWR/React Query 도입으로 클라이언트 사이드 캐싱
  • CDN 캐싱 전략으로 정적 자산 최적화

Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요?

현재 코드에서 fs, path, crypto 등을 사용하고 있는데, 이들을 Web Standard API로 대체해야 합니다.

  • fs.readFile → fetch() 또는 KV Store
  • process.env → 런타임별 환경변수 접근 방식
  • Node.js Buffer → TextEncoder/TextDecoder

Cold Start 최적화

  • Edge Functions는 Cold Start 문제가 있어서, 자주 사용되지 않는 컴포넌트들을 미리 컴파일하거나 필수적이지 않은 모듈들을 지연 로딩해야 합니다. React 컴포넌트들도 React.memo를 적극 활용해서 불필요한 리렌더링을 방지해야 할 것 같습니다.

Q3. 현재 구현에서 성능 병목이 될 수 있는 지점은 어디이고, 어떻게 개선하시겠습니까?

동기적 데이터 페칭 main-server.tsx에서 Promise.all을 사용하고 있지만, 캐싱이 없어서 매 요청마다 API를 호출합니다.

const [productsResponse, categories] = await Promise.all([
  getProducts(router.query), 
  getCategories()
]);

카테고리 정보는 자주 변경되지 않으므로 Redis나 메모리 캐시를 도입해서 5분 정도 캐싱하면 API 호출 횟수를 크게 줄일 수 있을 것 같습니다.

Q4. 1000개 이상의 상품 페이지를 SSG로 생성할 때 고려해야 할 사항은 무엇입니까?

빌드 시간 관리

  • 1000개 페이지를 순차적으로 빌드하면 시간이 너무 오래 걸립니다. Worker Threads를 활용한 병렬 빌드나 클러스터링을 도입해야 할 것 같습니다. CPU 코어 수만큼 워커를 생성해서 페이지를 청크 단위로 나누어 처리하는 방식을 생각해봤습니다.

메모리 관리

  • 모든 상품 데이터를 메모리에 로드하면 OOM(Out of Memory) 에러가 발생할 수 있습니다. 빌드 과정에서 메모리 사용량을 모니터링하면서, 임계값을 넘으면 가비지 컬렉션을 강제로 실행하거나 빌드를 일시 중단하는 로직이 필요할 것 같습니다.

증분 빌드

  • 상품 정보가 변경될 때마다 전체 빌드를 다시 하는 것은 비효율적입니다. 각 상품의 해시값을 계산해서 변경된 페이지만 재빌드하는 증분 빌드 시스템을 구축해야 합니다.

CDN 캐시 관리

  • 생성된 정적 파일들을 CDN에 배포할 때 캐시 무효화도 고려해야 합니다. 변경된 페이지들만 선별적으로 캐시를 무효화하는 전략이 필요하고, 배치 처리로 API 호출 횟수도 최적화해야 합니다.

Q5. Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 무엇이고, 어떻게 개선할 수 있을까요?

Hydration Mismatch로 인한 깜빡임 현상

// 현재: 서버와 클라이언트 상태 불일치 가능성
function hydrateFromSSRDataSync() {
  if (typeof window === "undefined" || !window.__INITIAL_DATA__ || window.__HYDRATED__) return;
  
  const d = window.__INITIAL_DATA__;
  // 서버에서 전달받은 데이터로 상태 복원
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: { /* ... */ }
  });
}

UX 이슈: 서버에서 렌더링된 HTML과 클라이언트 상태가 다를 때 React가 DOM을 다시 렌더링하면서 깜빡임 발생 개선 방안

  • Skeleton UI 도입으로 로딩 상태 표시
  • Progressive Enhancement로 기본 기능부터 점진적 향상
  • CSS-in-JS 동기화로 스타일 불일치 방지

인터랙션 차단 시간

// 현재: 하이드레이션 완료까지 사용자 입력 차단
function main() {
  router.start();
  hydrateFromSSRDataSync(); // 이 과정에서 지연 발생
  hydrateRoot(rootElement, <App />);
}

UX 이슈: 하이드레이션 완료까지 사용자가 버튼 클릭이나 입력을 할 수 없음 개선 방안

  • Event Delegation으로 하이드레이션 전에도 기본 인터랙션 가능
  • Streaming SSR으로 점진적 하이드레이션

Q6. 이번 과제에서 학습한 내용을 실제 프로덕션 환경에 적용할 때 추가로 고려해야 할 사항은?

모니터링 및 로깅 체계

// 현재: 기본적인 console.error만 사용
} catch (error) {
  console.error(`Error setting storage item for key "${key}":`, error);
}
  • Winston 같은 라이브러리로 로그 레벨을 분리하고, SSR 렌더링 시간, 캐시 히트율, 에러율 등을 추적
  • 렌더링 실패 시 어떤 데이터가 문제였는지 파악할 수 있도록 요청별 고유 ID를 부여하고, 컨텍스트 정보를 함께 로깅

에러 핸들링 및 Fallback 전략

// 현재: 기본적인 try-catch만 사용
try {
  const [productsResponse, categories] = await Promise.all([
    getProducts(router.query), 
    getCategories()
  ]);
} catch (dataError: any) {
  // 단순한 에러 상태 설정
  initialData.error = dataError.message ?? "서버 오류";
}
  • API 타임아웃 시 캐시된 버전 제공
  • 특정 상품 페이지 오류 시 유사 상품 추천
  • 전체 서비스 장애 시 정적 에러 페이지 표시

Q7. Next.js 같은 프레임워크 대신 직접 구현한 SSR/SSG의 장단점은 무엇인가요?

1.깊은 이해와 학습 효과

// 직접 구현으로 SSR의 핵심 개념 이해
export const render = async (url: string, query: Record<string, string>) => {
  router.start(url, query);
  const route = router.route;
  // 데이터 페칭, 상태 초기화, 렌더링 과정을 직접 제어
  const html = renderToString(<App />);
  return { html, head, data: JSON.stringify(initialData) };
};

SSR의 내부 동작 원리를 정확히 이해할 수 있었습니다. renderToString이 어떻게 동작하는지, hydration 과정에서 무슨 일이 일어나는지 직접 경험할 수 있었습니다.

2.완전한 커스터마이징 자유도

// 필요한 기능만 구현하여 번들 크기 최적화
const memoryStorage = () => {
  const storage = new Map(); // Map 사용으로 성능 최적화
  return { /* ... */ };
};

라우팅 로직, 캐싱 전략, 빌드 과정을 모두 직접 구현할 수 있어서 프로젝트 특성에 맞게 최적화할 수 있습니다.

3.번들 크기 필요한 기능만 구현하므로 Next.js보다 작은 번들 크기를 유지할 수 있습니다.

Q8. Next.js 를 이용하여 SSG 방식으로 배포하려면 어떻게 해야 좋을까요?

App Router 활용 Next.js의 App Router를 사용해서 폴더 기반 라우팅으로 변경하고, generateStaticParams와 generateMetadata를 활용해서 동적 상품 페이지들을 빌드 시점에 생성할 수 있습니다.

export async function generateStaticParams() {
  const products = await getProducts({ limit: '1000' });
  
  return products.products.map((product) => ({
    id: product.productId,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  
  return {
    title: `${product.title} - 쇼핑몰`,
    description: product.brand,
  };
}

Incremental Static Regeneration (ISR) 모든 상품 페이지를 빌드 시점에 생성하는 것보다는, 인기 상품들만 미리 생성하고 나머지는 ISR로 처리하는 것이 효율적일 것 같습니다.

export const revalidate = 3600; // 1시간마다 재검증

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  return <ProductDetail {...product} />;
}

코드 품질 향상

자랑하고 싶은 구현

개선하고 싶은 부분

  1. 성능 최적화 무한스크롤에서 메모리 누수를 방지하기 위한 가상화나 아이템 수 제한 로직을 추가하고 싶습니다. 또한 이미지 지연 로딩과 placeholder를 구현해서 초기 로딩 속도를 개선하고 싶습니다.

  2. 테스트 코드 현재 테스트 코드가 부족한 상태입니다. 특히 SSR 렌더링 로직이나 상태 관리 부분에 대한 단위 테스트와 통합 테스트를 추가해야 합니다.

리팩토링 계획

  1. 관심사 분리 현재 productUseCase.ts에서 비즈니스 로직과 API 호출이 섞여있는데, 이를 분리하고 싶습니다.
// 현재: 하나의 파일에 모든 로직
export const loadProducts = async (resetList = true) => {
  // API 호출 + 상태 업데이트 + 에러 처리
};

// 개선 계획: 레이어 분리
// services/productService.ts - API 호출만 담당
// useCases/productUseCase.ts - 비즈니스 로직만 담당
// stores/productStore.ts - 상태 관리만 담당
  1. 컴포넌트 분해 ProductDetail 컴포넌트가 너무 크므로, 브레드크럼, 이미지 뷰어, 수량 선택기 등으로 분해해서 재사용성을 높이고 싶습니다.

학습 연계

다음 학습 목표

  1. Serverless 환경에서의 SSR 최적화 현재 Express 서버 기반 구현을 Cloudflare Workers나 Vercel Edge Functions로 마이그레이션하면서 Cold Start 최적화와 메모리 제약 대응 방안을 학습하고 싶습니다. 특히 현재 구현의 vite.ssrLoadModule 같은 동적 모듈 로딩을 Edge Runtime에서 어떻게 최적화할 수 있는지 궁금합니다.

  2. GraphQL과 SSR/SSG 연동 방안 현재 REST API 기반의 데이터 페칭을 GraphQL로 전환하여 N+1 쿼리 문제를 해결하고, DataLoader 패턴을 활용한 효율적인 데이터 로딩을 구현해보고 싶습니다.

  3. React 18의 Concurrent Features 활용 현재 renderToString을 사용한 동기적 렌더링을 Streaming SSR과 Suspense를 활용한 점진적 렌더링으로 개선하여 사용자 경험을 향상시키는 방법을 학습하고 싶습니다.

실무 적용 계획

  1. 랜딩 페이지 SSG 적용

  2. 이벤트 랜딩 페이지들을 SSG로 구현해서 SEO 점수를 개선하고, CDN 캐싱을 통해 로딩 속도를 향상시키고 싶습니다.

  3. 상태 관리 개선 현재 Redux Toolkit를 사용하고 있는데, 이번에 구현한 것처럼 더 간단한 커스텀 스토어로 리팩토링을 검토해보고 싶습니다. 특히 보일러플레이트 코드가 많은 부분들을 개선할 수 있을 것 같습니다.

리뷰 받고 싶은 내용

이번 과제는 SSR과 SSG를 직접 구현해보는 것이었는데, 확실히 쉽지 않았습니다. 특히 SSR의 전체적인 동작 과정과 SSG의 차이를 실제 코드로 구현해보는 과정에서 많이 헤맸습니다. 그래서 리뷰받고 싶은 내용을 구체적으로 정리하기는 어려웠던거 같습니다 ㅠㅠ

  1. 이번 과제 주제와 관련해서 면접에서는 어떤 질문들이 나올 수 있을지
  2. 또한, 이번 과제에서는 충분히 구현하지 못했지만, 항해가 끝난 후 개인적으로 시간을 내어 더 도전적으로 시도해볼 수 있는 부분에는 무엇이 있을지 궁금합니다. 예를 들어 SSR과 SSG를 실제 서비스 수준에서 적용하거나, Hydration 과정 최적화, 서버 사이드 데이터 처리와 캐싱 전략 등과 관련해서 어떤 부분을 공부하고 실습하면 좋을까요?

과제 피드백

수고했습니다. 이번 과제는 SSR과 SSG를 직접 구현해보면서 렌더링 전략의 차이점과 성능 최적화 방법을 체험하는데 목적이 있었습니다.

"그동안 Next.js를 쓸 때 SSR을 그냥 '이럴 때 쓰는 거구나', '이렇게 쓰면 되네' 정도로만 이해하고 있었습니다"라는 솔직한 고백이 인상적이네요. 이번 과제를 통해 "단순히 '쓰는 법'이 아니라 '왜 그렇게 동작하는지'를 이해하는 게 얼마나 중요한지"를 깨달았다고 하셨는데, 맞아요!! ㅎ 그게 과제의 핵심 목표죠. 아주 잘했습니다 :)

SSR 동작 과정을 1~5단계로 체계적으로 정리하신 부분도 좋았습니다. 특히 2단계에서 서버에서 미리 데이터를 가져와서 스토어에 저장해야 클라이언트와 동일한 상태를 유지할 수 있다는 핵심을 파악하신 점이 좋았어요.

useSyncExternalStoregetServerSnapshot 중요성을 언급하신 것도 날카로운 관찰이네요. 하이드레이션 불일치를 방지하는 핵심이라는 걸 직접 체험하셨군요.

Q) 이번 과제 주제와 관련해서 면접에서는 어떤 질문들이 나올 수 있을지 => 사실 면접에서의 시작 질문은 뻔하게 물어봅니다. SSR을 써보셨나요? SSR와 CSR의 차이는 무엇인가요? 왜 SSR을 써야 하나요? 등등이요. 그리고 이후의 대답에 따라서 어디까지 해봤는지를 물어보는 질문들을 이어나가게 되어있어요. 그러면서 자연스럽게 본인이 경험한 만큼의 대답을 듣고선 판단을 하게 됩니다.

=> 그래서 뭔가 어떠한 질문들을 설정하기 보다는 지금 본인이 작성한 회고들을 바탕으로 직접 깨달은 부분들 그래서 SSR을 쓰는구나 SSR은 이런 원리로 동작하는 구나를 이해하고, 왜 이런걸 만들려고 했을까에 대해서 기술의 등장배경에 대해서 납득을 한 이후에는 자신의 언어로 모르는 사람에게 설명을 많이 해보는 연습을 해보세요.

=> 면접에서의 어떤 질문의 형태라는 건 AI를 통해서도 알 수 있지만, 특정 형태에 대한 대답을 외우는 식으로 공부가 되는건 바람직하지 않습니다. SSR이라는 주제로 내가 충분히 설명을 해주고 수다를 떨 수 있도록 그리고 정답이 아닌 자신의 언어와 생각들을 말해보는 경험이 필요해요. 이미 회고에 좋은 질문들이 많네요. 작성한 내용을 바탕으로 자신의 언어로 한번 육성으로 말해보는 연습으로 면접 대비를 해보면 좋겠네요 :)

Q) 또한, 이번 과제에서는 충분히 구현하지 못했지만, 항해가 끝난 후 개인적으로 시간을 내어 더 도전적으로 시도해볼 수 있는 부분에는 무엇이 있을지 궁금합니다. 예를 들어 SSR과 SSG를 실제 서비스 수준에서 적용하거나, Hydration 과정 최적화, 서버 사이드 데이터 처리와 캐싱 전략 등과 관련해서 어떤 부분을 공부하고 실습하면 좋을까요?

=> 이제는 Next.js에 대해서 그리고 Remix나 Nuxt 등 다른 SSR 라이브러리들의 공식문서들을 읽어보면서 전반적으로 메타 프레임워크의 양상에 대해서 한번 이해해보세요. 그러면서 각 프레임워크에서 공통적으로 발견되는 기능이 무엇인지 파악해보고 한번 구현을 해볼 수 있겠다? 아니면 이게 어떤 원리로 동작하게 되는 건지를 곰곰히 상상해보고 이런 기능은 도대체 왜 만들려고 했을까? 에 대해서 곰곰히 생각해보면서 자신만의 가설을 세워보고 이제 AI와 함께 그 가설과 원리등을 검증해나가면서 공부를 한번 해보는건 어떨까요? 무엇보다 실전에서 써먹어보는 경험이 제일 중요하겠지만 모든 걸 다 해볼 수는 없으니 우선 내 언어로 말해보는 연습을 통해 내 것으로 만들어 보기를 권합니다.

수고했어요. 마지막 과제도 화이팅입니다 :)