CodeLog 개발기 (1) 아키텍처와 코어 패턴
Next.js App Router 환경에서 계층형 아키텍처를 적용하고 TypeScript로 타입 안전성을 확보한 과정을 다룹니다.
1. 들어가며: 기획 의도
오로지 개발자를 위한 SNS가 있다면 어떤 모습일까요? 우리는 스레드(Threads), X, 링크드인 등 다양한 플랫폼에서 테크 소식을 접하곤 합니다. 하지만 소통 과정에서 소스 코드가 중심이 되는 경험은 여전히 부족하다고 느꼈습니다.
그래서 저는 비록 SNS 형태이지만, 소스 코드 그 자체를 깊이 있게 다룰 수 있는 서비스를 구상했습니다. 그 핵심으로 동료의 코드를 직접 검토하는 **'코드 리뷰'**를 선정했습니다. AI의 리뷰 성능이 비약적으로 발전했지만, 여전히 사람 대 사람으로 전해지는 맥락과 피드백은 대체 불가능한 가치가 있다고 믿기 때문입니다.
동시에 마이크로 블로그의 가치에도 주목했습니다. 깊이 있는 기술 아티클도 중요하지만, 매일 겪는 소소한 배움과 발견을 부담 없이 기록하고 공유하는 것만으로도 동료들에게 새로운 시야를 제공할 수 있을 것이라 기대했습니다.
2. 프로젝트의 목표
이 프로젝트는 현재 웹 개발의 표준으로 자리 잡은 Next.js를 실전에서 학습하고, 동시에 실질적인 문제를 해결하는 도구를 만드는 데 목적이 있습니다. 개인 프로젝트로 시작한 이유는 저만의 속도에 맞춰 충분히 고민하고 학습하며 개발할 수 있는 환경이 필요했기 때문입니다.
독학으로 진행되는 개인 프로젝트는 자칫 익숙한 패턴에만 안주하게 되어 성장이 정체될 위험이 있습니다. 또한 외부의 마감 기한이 없기에 난관 앞에서 쉽게 중단될 우려도 있었습니다. 이를 극복하고자 다른 개발자들과 소통하며 함께 성장할 수 있는 사이드 프로젝트 스터디를 직접 개설하여 지속 가능한 개발 동력을 확보하고자 했습니다.
추가로, 개발 속도와 학습 효율 사이의 균형을 위해 BaaS인 Supabase를 도입했습니다. 하지만 프로젝트가 진행됨에 따라 Supabase SDK에 대한 의존성이 지나치게 높아지는 문제를 발견했습니다. 언제든 도구의 변화에 유연하게 대응할 수 있도록, 계층형 아키텍처(Layered Architecture)를 도입하여 구조적 안정성을 확보하기로 했습니다.
BaaS: Backend as a Service, 백엔드 기능을 서비스 형태로 제공 SDK: Software Development Kit, 서비스 이용을 위한 도구 모음
3. 계층형 아키텍처: 이론과 실전 적용
전통적인 아키텍처가 Next.js App Router라는 현대적인 프레임워크와 만났을 때, 각 계층은 어떤 역할을 담당하게 될까요? 제가 프로젝트에 적용한 구조를 보며 같이 알아보겠습니다.
3-1. Model (types/)
- 역할: 데이터의 형태를 정의하는 순수한 객체 (식당의 '식자재')
- 유저, 게시글 등의 인터페이스나 클래스 정의를 중앙 관리합니다.
- 예:
{ id: "uuid", username: "parkblo", nickname: "박블로" ... }
3-2. View (components/, app/**/page.tsx)
- 역할: 사용자에게 보여지는 화면 (식당의 '플레이팅')
- 비즈니스 로직에 대해서는 철저히 몰라야 하며, 주어진 데이터를 렌더링하는 데 집중합니다.
3-3. Controller (page.tsx, Server Actions, route.ts)
- 역할: 사용자의 요청을 받는 진입점 (식당의 '웨이터')
- Next.js에서는 파일 시스템 기반 라우팅이 이 역할을 대신합니다. 요청을 검증하고 적절한 서비스로 전달합니다.
| 파일명 | Controller로서의 상세 역할 |
|---|---|
page.tsx | GET 요청을 처리하고 데이터를 가져와 View를 조율 |
Server Actions | POST, PUT, DELETE 등 상태 변경(Mutation)을 전담 |
route.ts | REST API 엔드포인트가 필요할 때 사용 |
middleware.ts | 인증/인가 및 리다이렉트 전처리 |
3-4. Service (services/)
- 역할: 실제 비즈니스 로직을 수행하는 핵심 (식당의 '셰프')
- "비밀번호 암호화", "포인트 적립" 등 구체적인 규칙을 처리합니다. Controller로부터 중복되는 로직을 분리하여 관리하기에 최적의 계층입니다.
3-5. Repository (결정: Service로 통합)
- 역할: 데이터 저장소에 직접 접근 (식당의 '식자재 배달원')
- 초기에는 별도의 Repository 패턴을 고려했으나, SDK 자체가 이미 강력한 데이터 접근 계층을 제공하기에 Service에 관련 책임을 통합했습니다.
UserRepository가 단순히 SDK의getUser()를 다시 감싸는 것은 불필요한 중복(Over-abstraction)만 초래하기 때문입니다.

Next.js는 Request Memoization, Data Cache, Full Route Cache 등 다층적 캐싱을 제공합니다. Next.js 14까지는
fetch가 기본적으로 캐싱되었으나, Next.js 15+ 부터는 기본값이 캐싱되지 않음(no-store)으로 변경되었습니다. 따라서 서버 컴포넌트에서 효율적인 데이터 페칭을 위해 의도적으로 캐시 옵션을 지정하거나,revalidatePath를 통해 필요한 시점에만 캐시를 갱신하는 제어가 필수적입니다.
4. 실질적인 구현 사례: 인증(Auth) 도메인
CodeLog가 계층형 아키텍처를 어떻게 실전에서 활용하고 있는지, 가장 복잡한 로직이 담긴 Auth 도메인을 통해 살펴봅니다.
4-1. Service Layer & 격리 전략
구현체에 의존하지 않고 서비스의 행위를 인터페이스로 정의한 뒤, 서버 환경에 최적화된 서비스를 구현합니다.
인터페이스(Interface): 클래스나 서비스가 공통적으로 가져야 할 '규격'
// services/auth/auth.interface.ts
export interface IAuthService {
getCurrentUser(): Promise<UserAuth | null>;
signOut(): Promise<{ error: Error | null }>;
}
// services/auth/server-auth.service.ts
export class ServerAuthService implements IAuthService {
async getCurrentUser(): Promise<UserAuth | null> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from("users")
.select("...")
.eq("id", user.id)
.single();
return profile as UserAuth;
}
}인증 로직의 안정성을 위해 서버 환경(Server Functions)에서는 매번 Request-scoped 인스턴스를 생성하도록 관리합니다. 이는 서버 세션이 섞이는 보안 사고를 방지하기 위함입니다. 반면 클라이언트 사이드에서는 성능을 위해 싱글톤을 활용합니다.
4-2. Action Layer & 사용자 피드백
서버 액션은 컨트롤러 계층으로서 요청을 중계합니다. 이때 공통 유틸리티를 사용하여 Mutation 이후의 피드백 로직을 표준화합니다.
Mutation: 데이터 수정 및 상태 변경 작업
// utils/handle-action.ts
export async function handleAction<T>(
promise: Promise<ActionResponse<T>>,
options?: {
successMessage?: string;
onSuccess?: (data: T | null) => void;
onError?: (error: string) => void;
},
) {
try {
const result = await promise;
if (result.error) {
toast.error(result.error);
options?.onError?.(result.error);
return null;
}
if (result.message || options?.successMessage) {
toast.success(result.message || options?.successMessage);
}
options?.onSuccess?.(result.data || null);
return result.data;
} catch (e) {
toast.error("알 수 없는 에러가 발생했습니다. 관리자에게 문의하세요.");
return null;
}
}4-3. 클라이언트 상태 관리와 OAuth Callback
사용자 인증 상태를 매번 서버에서 확인하는 것은 비효율적입니다. AuthProvider를 통해 앱 전반의 컨텍스트를 유지하며, useEffect 내에서 비동기 함수 호출 시 클린업 함수를 통해 경쟁 상태(Race Condition)를 방지하는 패턴이 권장됩니다. CodeLog 실제 구현에서는 activeUserId.current를 활용하여 이를 관리하고 있습니다.
또한, GitHub 등 외부 인증 성공 후 사용자를 복귀시키는 redirectTo 경로는 반드시 /auth/callback 라우트를 거치도록 설정해야 서버 사이드 세션이 쿠키에 정상 기록됩니다.

5. 서버와 클라이언트의 공존 패턴
계층 구조를 통해 역할이 명확해졌다면, 다음 과제는 서버 컴포넌트(Server Component)와 클라이언트 컴포넌트(Client Component) 사이의 효율적인 상호작용입니다. Next.js App Router 환경에서 데이터 페칭과 렌더링을 어떻게 최적화했는지 다룹니다.
5-1. 데이터 페칭: Waterfall resolving과 Suspense
부모 컴포넌트에서 모든 데이터를 await로 가져온 뒤 자식에게 넘겨주는 방식은 Waterfall(직렬 처리) 문제를 야기했습니다. 이를 해결하기 위해 데이터 페칭 로직을 자식 컴포넌트로 위임하고, Suspense로 감싸는 패턴을 적용했습니다.
// Page.tsx (부모)
export default function Page() {
return (
<main>
<Header />
{/* 데이터 로딩은 FeaturedTags 내부에서 일어남. 부모는 기다리지 않음. */}
<Suspense fallback={<TagSkeleton />}>
<FeaturedTags />
</Suspense>
</main>
);
}이 패턴은 느린 데이터가 있더라도 페이지의 다른 부분(Header 등)을 즉시 보여주어(TTFB 개선) 사용자 경험을 향상시킵니다.

5-2. use 훅과 Promise Passing
클라이언트 컴포넌트에서도 비동기 데이터를 선언적으로 처리하기 위해 **"Promise를 Props로 전달하고 use 훅으로 해제(Unwrap)하는 패턴"**을 사용합니다. 이를 통해 클라이언트 컴포넌트에서도 스트리밍의 이점을 누릴 수 있습니다.
// Server Component (Parent)
export default function Page() {
const tagsPromise = getTrendingTagsAction(); // await 없이 Promise만 생성
return (
<Suspense fallback={<Loader />}>
<ClientTagSection tagsPromise={tagsPromise} />
</Suspense>
);
}
// Client Component (Child)
"use client";
import { use } from "react";
export function ClientTagSection({ tagsPromise }: { tagsPromise: Promise<Tag[]> }) {
const tags = use(tagsPromise); // 여기서 데이터를 꺼내고 필요시 Suspend
return <div>{tags.map(...)}</div>;
}
5-3. 캐싱과 데이터 최신성
트래픽 증가에 대비해 다양한 캐싱 전략을 혼합 사용합니다.
unstable_cache: DB 쿼리 결과(랭킹 등)를 서버 메모리에 캐싱fetchCache: 외부 API 응답 캐싱- Full Route Cache: 정적 페이지 캐싱
그리고 데이터 변경 시에는 revalidateTag("posts")와 같이 태그 기반의 On-demand Revalidation을 수행하여 즉시 캐시를 무효화합니다.
5-4. 하이드레이션 불일치: 결정론적 난수
"탐색" 기능을 위해 태그 목록을 랜덤으로 섞을 때, 서버와 클라이언트의 Math.random() 결과가 달라 발생하는 Hydration Error를 해결해야 했습니다. 저는 시드(Seed) 기반의 결정론적 난수 생성기(Mulberry32 PRNG)를 도입했습니다.
서버에서 생성한 시드값(예: 12345)을 클라이언트에 Props로 전달하면, 양쪽에서 동일한 난수 순서를 보장받아 하이드레이션 불일치를 완벽히 해결할 수 있습니다.
6. TypeScript를 활용한 안정성 강화
건고한 아키텍처와 효율적인 컴포넌트 패턴의 밑바탕에는 **타입 안전성(Type Safety)**이 있습니다. BaaS 환경에서, DB 스키마와 클라이언트 타입 간의 괴리를 좁히기 위한 전략을 소개합니다.
6-1. 유틸리티 타입의 실전 활용 (Pick & Omit)
DB 테이블 타입을 그대로 쓰기보다 목적에 맞게 가공합니다.
- UserAuth (Omit):
password,deleted_at등 민감/불필요 정보 제외 - Author (Pick): 프로필 렌더링에 필요한
username,avatar등만 선택 이렇게 하면 DB 스키마가 변경되어도Tables<"users">만 갱신하면 모든 파생 타입이 자동으로 검증되어 유지보수성이 높아집니다.
6-2. Supabase Query Typing (QueryData)
복잡한 조인 쿼리의 결과를 as unknown as Type으로 강제 형변환하는 것은 위험합니다. 대신 Supabase의 QueryData 헬퍼를 사용하여 쿼리 자체로부터 타입을 추출합니다.
// 쿼리 객체 정의
const query = supabase
.from("follows")
.select(`
follower:users!follows_follower_id_fkey (id, username, ...)
`);
// 쿼리로부터 정확한 반환 타입 자동 추출
type FollowersWithAuthor = QueryData<typeof query>;
// 안전한 매핑
const result = (data as FollowersWithAuthor).map(...)이 방식은 쿼리 문이나 필드가 변경되면 즉시 컴파일 에러를 발생시켜, 런타임 에러를 사전에 방지합니다.
6-3. Null 타입 가드
배열 내 null 값을 걸러낼 때 TS가 이를 인지하지 못하는 문제를 해결하기 위해, User Defined Type Guard(is 키워드)를 사용합니다. filter((u): u is User => u !== null) 패턴을 통해 반환 타입을 (User | null)[]에서 User[]로 좁혀 안전하게 Optional Chaining 없이 사용할 수 있게 됩니다.
7. 결론
계층형 아키텍처를 도입하고 리포지터리, 서비스, 컨트롤러의 역할을 명확히 함으로써, 비즈니스 로직과 UI 로직을 효과적으로 분리할 수 있었습니다. 여기에 Server/Client 컴포넌트의 적절한 역할 분배와 TypeScript의 강력한 타입 시스템이 더해져, 시스템의 복잡도가 증가해도 데이터의 정합성과 개발의 생산성을 유지할 수 있는 기반이 마련되었습니다. 이러한 구조적 고민들은 CodeLog가 기능 추가에 유연하게 대응하며 지속 성장할 수 있는 원동력이 되고 있습니다.