개요
모든 프레임워크의 목적은 단 하나이다. 생산성을 높이고 비용을 줄이는 것이다. 단순히 코드를 작성하는 편의성만 뿐만 아니라 학습비용과 관리비용 유지비용까지 고려하여 어플리케이션 개발에 투입되는 전반적인 비용을 줄이는 것이 그 목표이다.
싱글톤
import { Pool } from "pg";
export const pool = new Pool({ /* options...*/ });
import { pool } from "@/lib/db"; // "pool" is singleton
// Something to do...
싱글톤은 nodejs의 import, require 구문을 사용하면 보장된다. Java 프로그램을 하다가 전향한 사람으로서 과거 다양한 방법으로 싱글톤을 유지하려고 애를 썼었는데 nodejs의 경우에는 그냥 파일로 찢어서 관리하면 되기 때문에 사용도 편리하고 관리하기에도 좋다.
다만, nextjs의 버전에 따라 개발모드에서 마치 싱글톤이 아닌 것 처럼 보이는 현상이 있다. 라우트 페이지별로 개별적인 컨텍스트를 생성하는 이유에서 였는데 빌드 이후에 하나로 합쳐진 어플리케이션에서는 다시 정상적으로 싱글톤이 유지되었다. 이러한 부분은 확실하게 테스트하여 작업전에 확실하게 숙지해두어야 한다.
전역 스코프 지양
전역 스코프는 단연코 사용하지 않는 것을 추천한다. 대단한 어플리케이션을 작성하는 것이 아니라면 어차피 몇 번 쓰고 버려지기 때문에 굳이 전역으로 선언하여 지저분하게 만들 필요가 없다. 반드시 필요하다면 별도의 파일로 분할하여 싱글톤을 유지시키는 방식을 추천한다. 클라이언트측에서는 redux나 zustand와 같은 상태관리를 통하여 기능을 유지할 수 있다.
NEXT_PUBLIC 지양
DB_URL=postgresql://localhost:5432
export const db = {
url: process.env.DB_URL
}
전역 스코프로 활용할 수 있는 값은 오직 환경변수만으로 제약 하는 것을 추천한다. 또한, 환경변수를 매번 사용시마다 process.env로 접근하여 코드를 지저분하게 만들도록 유도하지 말고 파일로 분리하여 싱글톤으로 유지하고 코드의 가독성을 높이는 것을 추천한다.
import { db } from "@/config"
export default async function HomePage() {
return (
<section>
<MyClientComponent url={{db.url}} />
</section>
)
}
nextjs에서는 기본적으로 환경변수를 클라이언트 컴포넌트에서 사용할 수 없기 때문에 NEXT_PUBLIC_ 접두사로 선언된 환경변수만이 허용된다. 되도록이면 이 기능은 사용하지 않는 것을 추천한다. 사용할 부분에서 process.env를 명시적으로 선언1해야 사용할 수 있으며 마찬가지로 코드를 지저분하게 만들고 빌드시 결과물에 값이 그대로 포함되기 때문에 보안에도 그다지 좋지 않다. 우회법으로 환경변수를 서버 컴포넌트에서 읽은 후에 props를 통하여 파라미터 형식으로 넘겨주는 방식을 사용하는 것이 좋다.
동적 라우팅 + 동적 컴포넌트 연동 지양
.
└── app
└── [...url]
└── page.tsx
nextjs의 라우팅을 동적으로 지정하여 다양한 URL을 대응할 수 있는 기능을 제공한다. 동적인 파라미터로써 지정된 하나의 페이지를 렌더링하는 경우라면 큰 문제 없이 사용할 수 있다.
여기서 말하고 싶은 점은 이 값을 활용하여 컴포넌트를 동적으로 로드할 때 이다. URL마다 대응되는 페이지를 동적으로 불러들여 CMS 기능을 구현하려는 경우 심사숙고 해야 한다. Nuxt2 시절부터 이런 작업을 진행하였는데 이 방식은 빌드 성능을 극악으로 떨어뜨리게 된다.
이것은 빌드와 운영방식에 영향을 미치게 된다. SSG 빌드시에 지정된 HTMLEndpoint외의 URL에 대응하기 위해서 URL Rewrite와 같은 방안이 추가적으로 필요해진다.
단순히 라우팅 때문이라면 감당할 만도 하겠지만 조금만 깊이 들어가면 문제는 더 심각해진다. SSG로 생성한 경우 지정된 라우팅마다 생성된 페이지들은 각각이 곧 사용자의 접근 초기 페이지가 되어야한다. 어느 URL로 접근할지 모르기 때문이다. 다시 말해 각 페이지가 모든 의존성에 대해서 독립성을 지녀야 페이지가 정상작동 할 수 있다는 말이 된다. 번들러는 이 경우를 대비하기 위해 생성되는 모든 HTML에 각각 필요한 모든 모듈을 별도로 구성시키는데 이 때 빌드성능과 출력결과에 기하급수적인 타격을 주게된다.
SPA 방식의 어플리케이션이라면 rewrite를 통해 index페이지로 되돌려 다시 접근시키는 이상한 방법을 사용할 수도 있겠다만 오류는 안 나지만 쓰고 싶지 않은, 사용자 접근성이 급격히 저하된 서비스가 될 것이다.
SSR방식도 상황은 크게 다르지 않다. 프레임워크가 준비해주는 전용 서버가 있기 때문에 빌드 결과는 조금 더 간략해질지 모르지만 여전히 사용자가 어느 URL로 들어올지는 부처님도 모르기 때문에 마찬가지로 모든 모듈을 빌드하여 사용자 접근에 대비하게 된다.
또한, 이 빌드라는 것은 비단 운영용 번들링만이 아니라 개발시 실행되는 모듈 빌드 단계에도 영향을 미친다. 다시 말해 작업자가 텍스트 하나 바꿨는데 모든 모듈을 재빌드하는 기행을 볼 수 있다.2 프로젝트의 크기가 커질수록 생산성에 치명적인 타격이 되기 때문에 반드시 숙고하여야 한다.
SSR 활용 방안
SSR은 Ajax 방식의 웹 서비스가 대세를 점하게 되면서 검색 엔진에 본문 내용이 크롤링되지 않는 문제를 해결하기 위해 도입 되었다. 브라우저에서 다양한 스크립트가 작동되고 난 이후에 본문이 완성되는 방식이라 검색 엔진 입장에서는 텅 비어있는 스크립트 덩어리들만 보이기 때문이다.
비동기로 작동하는 페이지들의 데이터들을 빌드 시점에 미리 준비시켜서 검색 엔진이 크롤링 하는 시점과 브라우저로 접근할 떄의 시점을 동일하게 유지하여 별도의 작업 없이도 SEO가 가능하도록 함이 가장 큰 이점이었다. 이 현상을 해결하기 위해서 스크립트 범벅의 사용자 접근용 페이지와 별도로 검색 엔진이 접근할 때 응답할 페이지를 별도로 작업하는 경우도 있었다.3
최근에는 단순히 SEO 목적만을 위해서 사용되지 않는다. 하드웨어 성능의 비약적인 발전으로 복잡한 컴포넌트의 일부 렌더링을 서버에서 대신 진행해주어 클라이언트의 부담을 줄이고 서비스의 질을 향상하는 방식으로도 많이 사용된다. 최근엔 이것이 주 목적이 되어가고 있다.
// app/some/path/of/page.tsx
import { pool } from "@/lib/db";
export default async function SomePage() {
const client = await pool.connect();
try {
const { rows } = await client.query(`SELECT * FROM posts`);
return (
<ul>
{rows.map(({ title }, i) => (
<li key={`title-${i}`}>
<h2>{title}</h2>
</li>
))}
</ul>
);
} catch (err) {
throw err;
} finally {
client.release();
}
}
SSR은 위에서 말했던 새로운 기술로 인한 서비스 운영의 문제점들을 해결할 뿐만이 아니라 개발 편의성에도 많은 발전을 이루었다. 서버 컴포넌트에서 DB를 제어 할 수 있는 모듈을 제공하면 작업자가 하나의 라우트에 대한 서버/클라이언트 작업을 한 페이지파일에서 작성할 수 있도록 유도할 수 있다.
이것은 실무자가 비슷한 이름의 파일4을 이리저리 뒤적거릴 필요가 없고 DB -> 서비스 -> 클라이언트로 이어지는 하나의 요청과 응답하나의 기능을 파일 하나에서 작업 하도록 할 수 있다. 파일과 실무자를 1:1로 연결할 수 있기 때문에 책임에 대한 부분도 명확해져 전체 프로젝트도 관리도 편해진다.
다만, 작업자의 SSR 기능에 대한 지식이 필수적으로 요구되기 떄문에 요즘처럼 프론트/백으로 나뉜 인력구조에서는 학습비용이 필요한 경우가 많다.5 전체 프로젝트에서 인력구조에 따라 SQL 서버작업을 API 호출로 대체한다든지 대체할 수 있는 방안은 많기 때문에 구조를 구성할 때 세부적인 내용들은 깊이 고려하는 것이 좋다.
Footnotes
-
구조 분해 할당과 같은 문법을 사용하면 값이 참조되지 않는 현상도 있다. ↩
-
세미콜론 하나 찍고 빌드를 20초 이상 기다려야 할 수도 있다. 웹팩이나 Vite 설정을 통해 극복하려고 수 많은 시도를 해보았지만 멍청한 나는 해결할 수 없었다. ↩
-
모바일 페이지, PC 페이지, 검색 엔진 페이지까지 하나의 서버로 모든 것을 해결하려고 했기 때문에 말도 안되게 너덜너덜한 프로젝트들이 많았던 느낌이다. ↩
-
이를테면 UserController.ts, UserService.ts, User.ts, pages/user.tsx 등의 MVC패턴으로 나누는 전통적인 구분 방식 ↩
-
몇 가지 예를 들면, 서버 컴포넌트에서는 이벤트 바인딩을 할 수 없다거나 클라이언트 컴포넌트 내에서 서버 컴포넌트를 호출할 수 없고, SQL을 직접 작성하게 해야 하거나 nodejs와 browser javascript의 기능 차이 등 가이드 해야 할 것들이 꽤 있다. ↩