Meta-framework(Next.js, Nuxt)가 기본 시작점이 되다
1. 개요
몇 년 전만 해도 새 프론트엔드 프로젝트는 보통 순수 React나 Vue로 시작했고, 그 다음 라우터, 번들러, 데이터 페칭 방식, 서버 렌더링 방식을 직접 덧붙였습니다. 요즘엔 첫 질문이 보통 “Next.js를 쓸까, 아니면 Nuxt를 쓸까?“입니다. 이제 메타프레임워크가 기본 시작점이 되고 있습니다.
이 글에서는 메타프레임워크가 무엇인지, 그리고 왜 기본으로 선택되는지를 설명합니다. Next.js와 Nuxt로 상품 페이지를 만들고 Spring Boot 백엔드를 호출하는 방법을 보게 됩니다. 글의 끝부분에서는 흔한 실수들과 메타프레임워크를 쓰면 안 되는 경우를 다룹니다.
2. 메타프레임워크란?
React와 Vue는 UI 라이브러리입니다. 이들은 컴포넌트 렌더링과 상태 관리만 담당합니다. 어떤 URL이 어떤 페이지에 대응되는지, 데이터를 어디서 로드할 것인지, HTML을 서버에서 생성할지 브라우저에서 생성할지는 결정하지 않습니다.
메타프레임워크는 UI 라이브러리 위에 있는 계층입니다. 이런 결정들을 미리 내려줍니다:
- Next.js는 React 위에 구축되었습니다.
- Nuxt는 Vue 위에 구축되었습니다.
- 같은 범주에는 SvelteKit, Remix/React Router, Astro, SolidStart 등이 있습니다.
2.1. 미리 포함된 기능들
일반적인 메타프레임워크는 다음을 제공합니다:
- 파일 기반 라우팅: 폴더 구조가 URL을 결정합니다. 라우트를 수동으로 선언할 필요가 없습니다.
- 다양한 렌더링 모드: SSR(매 요청마다 서버에서 렌더링), SSG(빌드 시에 미리 렌더링), 그리고 재검증이나 클라이언트 전용 렌더링 같은 혼합 모드.
- 라우트와 연결된 데이터 페칭: HTML을 브라우저로 보내기 전에 서버에서 데이터를 먼저 로드합니다.
- Server Route: 프론트엔드 프로젝트 내에서 작은 API 엔드포인트를 작성할 수 있으며, 보통 BFF(Backend for Frontend)로 사용됩니다.
- 빌드 및 최적화: 코드 분할, 이미지 최적화, 폰트와 에셋 모두 미리 구성되어 있습니다.
이를 Spring Boot와 비교할 수 있습니다. Spring Framework는 모든 벽돌을 제공합니다. Spring Boot는 관례와 자동 구성을 추가해서 바로 실행할 수 있게 합니다. 메타프레임워크는 React와 Vue에서 비슷한 역할을 합니다.
3. 왜 기본 선택지가 되었나요?
몇 가지 근본적인 이유가 있습니다.
초기 의사 결정 감소. 라우터, SSR, 번들러 설정, 코드 분할을 직접 설정하는 데는 많은 작업이 필요합니다. 팀마다 다르게 합니다. 메타프레임워크는 공통 구조를 제공하므로, 새로 팀에 들어온 사람들이 코드를 읽기 쉽습니다.
SEO와 초기 표시 속도. 순수 SPA는 거의 비어있는 HTML 파일 하나를 보낸 다음 JavaScript가 로드될 때까지 기다립니다. SSR이나 SSG를 사용하면 브라우저와 검색 봇이 이미 내용이 있는 HTML을 받습니다. 이것은 상품 페이지, 블로그, 랜딩 페이지에 중요합니다.
라우트별 유연한 렌더링. 홈페이지는 빌드 시에 미리 렌더링될 수 있습니다. 상품 페이지는 캐시되고 주기적으로 새로고쳐집니다. 관리 페이지는 클라이언트 전용 렌더링만 됩니다. 이 모든 것이 하나의 프로젝트 안에 있습니다.
Server/Client 경계가 더 명확합니다. 서버에서 실행되는 코드는 비밀 환경 변수를 읽고 내부 API를 호출할 수 있으며 브라우저에 노출되지 않습니다. 또한 백엔드를 위해 CORS를 열 필요가 없습니다.
생태계와 문서가 이 방향을 향하고 있습니다. React의 공식 문서는 이미 프레임워크로 시작할 것을 권장합니다. 많은 호스팅 플랫폼도 Next.js와 Nuxt의 배포 방식을 기본으로 지원합니다.
4. Next.js로 적용하기
Spring Boot 백엔드가 GET /api/products/{id}를 제공한다고 가정합시다. SEO가 좋고 매 요청마다 API를 호출하지 않는 /products/{id} 페이지가 필요합니다.
4.1. 프로젝트 초기화
npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev
이 명령어는 App Router를 사용하는 프로젝트를 만듭니다. app/ 폴더가 라우트를 담습니다. 애플리케이션은 http://localhost:3000에서 실행됩니다.
.env.local에서 백엔드 주소를 설정합니다:
API_URL=http://localhost:8080
이 변수는 NEXT_PUBLIC_ 접두사가 없습니다. 따라서 서버 측에만 존재하며 브라우저로 전송되는 JavaScript에 번들되지 않습니다.
4.2. Server Component를 사용한 상품 페이지
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import AddToCartButton from "./AddToCartButton";
type Product = { id: number; name: string; price: number };
async function getProduct(id: string): Promise<Product | null> {
const res = await fetch(`${process.env.API_URL}/api/products/${id}`, {
next: { revalidate: 60 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error("상품을 불러올 수 없습니다");
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<p>{product.price.toLocaleString("vi-VN")} ₫</p>
<AddToCartButton productId={product.id} />
</main>
);
}
이 코드는 다음을 수행합니다:
app/products/[id]/page.tsx경로가 자동으로/products/:id라우트를 생성합니다.[id]는 동적 매개변수입니다.- 컴포넌트는
async이고 서버에서 실행됩니다.await fetch를 직접 사용할 수 있으며,useEffect나 로딩 상태가 필요하지 않습니다. next: { revalidate: 60 }은 결과를 캐시하고 최대 60초마다 새로고칩니다. 백엔드는 모든 액세스에서 호출되지 않습니다.notFound()는 백엔드가 상품을 찾지 못했을 때 표준 404 페이지를 반환합니다.
http://localhost:3000/products/1을 열고 HTML 소스를 봅니다. 콘텐츠가 이미 HTML에 있는 것을 볼 수 있습니다:
<main><h1>후에 논 라</h1><p>150.000 ₫</p>...
4.3. Client Component로 상호작용 구현
// app/products/[id]/AddToCartButton.tsx
"use client";
import { useState } from "react";
export default function AddToCartButton({ productId }: { productId: number }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)} disabled={added}>
{added ? "추가됨" : `상품 #${productId}을(를) 장바구니에 추가`}
</button>
);
}
"use client" 지시문이 경계를 표시합니다. 이 컴포넌트와 이것이 import하는 것만 JavaScript로 브라우저에 전송됩니다. 페이지의 나머지는 HTML만입니다.
5. Nuxt로 같은 개념 적용하기
Nuxt는 Vue 방식으로 같은 문제를 해결합니다. 이번에는 BFF 역할을 하는 server route를 추가합니다. 브라우저는 Nuxt와만 통신하고, Nuxt가 Spring Boot로 전달합니다.
프로젝트 초기화:
npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev
nuxt.config.ts에서 구성:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiUrl: "http://localhost:8080", // 서버에만 존재
},
routeRules: {
"/": { prerender: true }, // 빌드 시에 미리 렌더링
"/products/**": { swr: 60 }, // 캐시, 60초마다 백그라운드 새로고침
"/admin/**": { ssr: false }, // 클라이언트 전용 렌더링
},
});
routeRules는 “라우트별 렌더링” 이점을 잘 보여줍니다. 세 가지 다른 전략이 몇 줄의 설정으로 표현됩니다. apiUrl 키는 public 외부에 있으므로 브라우저에 노출되지 않습니다. 배포할 때 NUXT_API_URL 환경 변수로 이 값을 재정의할 수 있습니다.
BFF 역할을 하는 server route:
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const config = useRuntimeConfig(event);
return await $fetch(`${config.apiUrl}/api/products/${id}`);
});
파일명 [id].get.ts는 Nuxt 애플리케이션 내에서 GET /api/products/:id 엔드포인트를 자동으로 생성합니다.
상품을 표시하는 페이지:
<!-- pages/products/[id].vue -->
<script setup lang="ts">
type Product = { id: number; name: string; price: number };
const route = useRoute();
const { data: product, error } = await useFetch<Product>(
`/api/products/${route.params.id}`
);
</script>
<template>
<main>
<p v-if="error">상품을 찾을 수 없습니다.</p>
<template v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.price.toLocaleString("vi-VN") }} ₫</p>
</template>
</main>
</template>
useFetch는 첫 로드에서 서버에서 실행됩니다. Nuxt는 데이터를 클라이언트로 전송하므로, hydrate할 때 브라우저는 API를 다시 호출하지 않습니다. 사용자가 클라이언트 측에서 페이지를 탐색할 때, 요청은 /api/products/:id로 같은 origin으로 가므로 여전히 CORS가 필요하지 않습니다.
6. Spring Boot 백엔드 쪽
두 프론트엔드 모두 같은 API를 호출합니다:
// Product.java
public record Product(long id, String name, long price) {}
// ProductCatalog.java
@Component
public class ProductCatalog {
private final Map<Long, Product> products = Map.of(
1L, new Product(1, "후에 논 라", 150_000),
2L, new Product(2, "실크 아오자이", 1_200_000));
public Optional<Product> findById(long id) {
return Optional.ofNullable(products.get(id));
}
}
// ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductCatalog catalog;
public ProductController(ProductCatalog catalog) {
this.catalog = catalog;
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable long id) {
return ResponseEntity.of(catalog.findById(id));
}
}
ResponseEntity.of는 상품이 있으면 200과 본문을 반환하고, Optional이 비어있으면 404를 반환합니다. 위의 섹션 4.2의 Next.js 페이지는 정확히 이 404 상태 코드에 의존해서 notFound()를 호출합니다.
컨트롤러에는 @CrossOrigin이 없습니다. 모든 요청이 메타프레임워크의 서버에서 나오며 브라우저에서 나오지 않습니다. 따라서 백엔드는 내부 네트워크에 있을 수 있습니다.
이 API 계약은 두 프론트엔드가 모두 의존하므로 테스트가 필요합니다:
@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void returnsProductWhenFound() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/1")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
.contains("\"name\":\"후에 논 라\"")
.contains("\"price\":150000");
}
@Test
void returns404WhenMissing() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/99")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(404);
}
}
첫 번째 테스트는 프론트엔드가 읽는 JSON 구조(name, price)를 확인합니다. 두 번째 테스트는 프론트엔드의 notFound() 로직이 의존하는 404 동작을 유지합니다.
7. 흔한 실수들
비밀 변수를 클라이언트로 노출. Next.js에서 NEXT_PUBLIC_ 접두사가 있는 모든 변수는 번들에 포함됩니다. Nuxt에서 runtimeConfig.public의 모든 것도 마찬가지입니다. API 키를 거기에 두지 마세요.
주의: Server Component가 실수로 Client Component를 import하면 서버 코드가 브라우저로 내려오거나 빌드 오류가 발생합니다. 어떤 파일이 어디서 실행되는지 명확히 분리하세요. 진정한 상호작용이 필요한 컴포넌트에만
"use client"를 표시하세요.
서버 렌더링 중에 브라우저 API 사용. SSR 코드에서 window나 localStorage를 호출하면 window is not defined 오류가 발생합니다. 이러한 부분을 Client Component에 넣거나 Vue에서는 onMounted에 넣으세요.
Hydration 불일치. 이 오류는 서버에서 렌더링된 HTML이 클라이언트의 첫 렌더링과 다를 때 발생합니다. 일반적인 원인은 new Date(), Math.random() 또는 서버와 브라우저 간에 다른 로케일에 따른 숫자 형식입니다.
캐시 메커니즘을 이해하지 못함. 데이터가 “업데이트되지 않는” 것은 보통 백엔드가 아니라 프레임워크의 캐시 때문입니다. 각 라우트에 대해 revalidate나 swr을 의도적으로 선언하세요.
메타프레임워크를 주 백엔드로 만드는 것. Server Route는 BFF에 매우 편리합니다. 하지만 비즈니스 로직, 트랜잭션, 데이터베이스 접근을 거기에 넣으면 두 개의 백엔드를 유지해야 합니다. 비즈니스 로직은 Spring Boot에 유지하세요.
8. 언제 사용하지 말아야 할까요
기본이 항상 맞다는 뜻은 아닙니다. 다음 경우에는 다른 방안을 고려하세요:
- 애플리케이션이 완전히 로그인 뒤에 있고 SEO가 필요하지 않습니다. 예: 내부 대시보드, 관리 도구. Vite로 만든 순수 SPA가 더 간단할 수 있습니다: 정적 파일로 배포하고 Node.js 서버가 필요하지 않습니다.
- 인프라가 Node.js를 실행할 수 없고, 또는 운영 팀이 JAR 배포에만 익숙합니다. SSR은 별도의 런타임이 필요하며 모니터링과 스케일링이 필요합니다.
- 페이지가 주로 정적 콘텐츠이고 상호작용이 적습니다. 정적 사이트 생성기나 Spring Boot의 Thymeleaf 렌더링이 충분할 수 있습니다.
- 애플리케이션이 임베드되어 있는 경우, 예: 위젯, 기존 페이지 내의 마이크로 프론트엔드, 데스크톱 애플리케이션. 메타프레임워크의 라우팅과 SSR이 오버헤드가 됩니다.
- 작은 팀이고 Server/Client 모델을 아직 익숙하지 않습니다. Server Component, hydration, 캐시의 개념은 자신의 학습 곡선이 있습니다. 이들을 이해하지 못하면 찾기 어려운 버그가 발생합니다.
간단한 질문이 결정을 도와줍니다: 서버에서 즉시 콘텐츠가 있는 HTML이 필요한 페이지가 있나요? 답이 아니오라면, 메타프레임워크의 가장 큰 이점이 거의 사라집니다.
9. 결론
Next.js와 Nuxt 같은 메타프레임워크는 라우팅, 렌더링 모드, 데이터 페칭, server route를 미리 포함합니다. React와 Vue에 대한 역할은 Spring에 대한 Spring Boot의 역할과 같습니다. 초기 의사 결정을 줄이고, SEO와 표시 속도를 지원하며, 라우트별로 렌더링 전략을 선택할 수 있게 하고, 명확한 Server/Client 경계를 만들기 때문에 기본 선택이 됩니다.
실제로는 효율적인 모델이 메타프레임워크가 프레젠테이션과 BFF를 담당하고 Spring Boot가 비즈니스 로직과 데이터를 유지하는 것입니다. Server/Client 경계와 캐시 메커니즘을 잘 이해해야 합니다. 그리고 SEO가 필요 없는 내부 애플리케이션의 경우, 순수 SPA가 여전히 합리적인 선택입니다.