Next.js TypeScript 고급 설정: tsconfig 최적화와 타입 안전성 실전 가이드

테스트 보고서에 눈에 띄는 빨간 문구가 떴습니다. Production Error: Cannot read property 'id' of undefined. 사용자 제보에 따르면 프로필 페이지를 클릭하는 순간 화면이 하얗게 변했습니다. 코드를 다시 살펴보니 라우트를 /user/profile이 아니라 /users/profile로 작성해 s 하나가 더 들어가 있었습니다. TypeScript도, IDE도 아무런 경고를 하지 않았고 코드는 그대로 배포됐습니다.
너무 초보적인 실수 아닌가요? 맞습니다. 하지만 제가 관리하던 프로젝트에서는 이런 ‘초보적인 실수’가 지칠 만큼 자주 발생했습니다. 라우트 오타, 잘못 쓴 환경 변수 이름, 전부 any인 함수 매개변수까지 있었습니다. TypeScript는 ‘타입 안전성’을 내세우지만 실제로 써 보면 JavaScript와 별 차이가 없는 듯했습니다.
나중에야 TypeScript가 문제가 아니라 제 설정이 엉망이었다는 사실을 알았습니다. tsconfig.json에는 옵션이 잔뜩 있었지만 무엇을 켜고 꺼야 할지 몰랐습니다. 온라인 튜토리얼도 제각각이어서 엄격 모드가 개발 부담만 늘린다는 사람도 있었고, 엄격 모드를 쓰지 않으면 TypeScript를 쓸 이유가 없다는 사람도 있었습니다. Next.js와 TypeScript를 거의 1년 동안 사용했는데도 프로젝트에는 여전히 any가 넘쳐났습니다.
이 글에서는 지난 1년 동안 시행착오를 거치며 얻은 경험을 나누려고 합니다. tsconfig 최적화 설정부터 타입 안전 라우팅 구현, 환경 변수 타입 정의까지 차근차근 적용해 TypeScript를 ‘걸림돌’이 아닌 ‘수호자’로 바꾸는 방법을 살펴봅니다. 어려운 이론이 아니라 바로 활용할 수 있는 실전 내용입니다.
tsconfig 최적화 설정 - 기초 다지기
strict 모드의 진짜 의미 이해하기
많은 사람은 과거의 저처럼 strict: true가 단순한 스위치라고 생각합니다. 이것만 켜면 TypeScript가 엄격해진다고 말이죠. 하지만 실제로는 그렇지 않습니다.
TypeScript 공식 문서를 보면 strict는 사실 7가지 컴파일 옵션을 한꺼번에 활성화하는 단축 설정입니다.
{
"compilerOptions": {
"strict": true,
// 아래 7가지 옵션을 모두 true로 설정한 것과 같습니다
"strictNullChecks": true, // 엄격한 null 검사
"strictFunctionTypes": true, // 엄격한 함수 타입 검사
"strictBindCallApply": true, // 엄격한 bind/call/apply 검사
"strictPropertyInitialization": true, // 엄격한 속성 초기화 검사
"noImplicitAny": true, // 암시적 any 금지
"noImplicitThis": true, // 암시적 this 금지
"alwaysStrict": true // 항상 엄격 모드로 파싱
}
}
가장 유용한 것은 처음 세 가지입니다. 먼저 strictNullChecks를 살펴보겠습니다. 이 옵션을 켜면 TypeScript는 null과 undefined를 ‘모든 타입에서 허용되는 값’이 아니라 독립된 타입으로 처리합니다.
예를 들어 데이터베이스에서 사용자 정보를 조회한다고 가정해 보겠습니다.
// strictNullChecks를 끈 경우
const user = await db.user.findOne({ id: userId })
console.log(user.name) // TypeScript 오류는 없지만 user가 null일 수 있습니다
// 활성화한 경우
const user = await db.user.findOne({ id: userId })
console.log(user.name) // ❌ TypeScript 오류: 객체가 null일 수 있습니다
// 다음과 같이 작성해야 합니다
if (user) {
console.log(user.name) // ✅ 통과
}
기존 프로젝트에서 이 옵션을 처음 켰을 때 IDE에 빨간 물결선이 200개 넘게 한꺼번에 나타났습니다. 당황한 나머지 다시 끌 뻔했습니다. 하지만 차분히 살펴보니 이 ‘오류’는 모두 잠재적인 버그였습니다. null 검사를 하지 않은 부분은 실제 운영 환경에서도 언제든 문제를 일으킬 수 있었습니다.
noImplicitAny도 매우 중요합니다. 이 옵션은 함수 매개변수나 변수가 ‘암시적으로’ any 타입이 되는 것을 막습니다.
// noImplicitAny를 끈 경우
function handleData(data) { // data가 자동으로 any가 됩니다
return data.value // 어떤 작업을 해도 오류가 나지 않습니다
}
// 활성화한 경우
function handleData(data) { // ❌ 오류: 매개변수에 암시적으로 any 타입이 지정됩니다
return data.value
}
// 타입을 명시해야 합니다
function handleData(data: { value: string }) { // ✅
return data.value
}
솔직히 처음에는 번거롭게 느껴집니다. 예전에는 함수를 바로 작성하면 됐지만 이제는 타입까지 정의해야 합니다. 그러나 익숙해지고 나면 IDE의 제안 기능이 훨씬 똑똑해졌다는 것을 알게 됩니다. data.를 입력하는 순간 모든 속성이 나타나므로 더는 문서를 찾아다닐 필요가 없습니다.
Next.js에 특화된 TypeScript 설정
Next.js 프로젝트의 tsconfig.json에는 몇 가지 특수한 설정이 있습니다. 제가 현재 사용하는 모범 설정을 바로 소개하겠습니다.
{
"compilerOptions": {
// 기본 설정
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
// Next.js 필수 설정
"allowJs": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
// 엄격 모드(핵심)
"strict": true,
"skipLibCheck": true,
// 성능 최적화
"incremental": true,
// Next.js 플러그인
"plugins": [
{
"name": "next"
}
],
// 경로 별칭
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/styles/*": ["./src/styles/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
놓치기 쉬운 몇 가지를 중점적으로 설명하겠습니다.
1. incremental: 증분 컴파일
이 옵션은 대규모 프로젝트의 컴파일 속도를 크게 높일 수 있습니다. 활성화하면 TypeScript가 이전 컴파일 정보를 캐시하고 다음에는 변경된 파일만 컴파일합니다. 300개가 넘는 컴포넌트가 있는 프로젝트에서 테스트했을 때 컴파일 시간이 45초에서 약 18초로 줄어들어 효과가 뚜렷했습니다.
2. paths: 경로 별칭
예전에는 import 경로를 다음과 같이 작성했습니다.
import Button from '../../../components/ui/Button'
import { formatDate } from '../../../../lib/utils'
..가 몇 개인지 세기도 어렵고 폴더 구조를 조금만 바꿔도 전부 오류가 발생했습니다.
별칭을 설정하면 다음처럼 바뀝니다.
import Button from '@/components/ui/Button'
import { formatDate } from '@/lib/utils'
훨씬 깔끔합니다. TypeScript가 타입도 올바르게 추론하고 IDE의 정의 이동 기능도 사용할 수 있습니다.
3. plugins: Next.js 플러그인
"plugins": [{ "name": "next" }]는 단순해 보이지만 TypeScript가 Next.js 고유 요소를 이해하게 해 줍니다. 예를 들어 app 디렉터리의 layout.tsx, page.tsx 같은 특수 파일의 타입과 서버 컴포넌트와 클라이언트 컴포넌트의 차이를 인식합니다.
이 플러그인을 추가하지 않으면 서버 컴포넌트를 작성할 때 TypeScript가 잘못된 타입 오류를 표시할 수 있습니다.
엄격 모드를 점진적으로 활성화하기
프로젝트를 이미 한동안 운영해 코드가 많다면 strict: true를 바로 켜는 일이 고통스러울 수 있습니다. 무작정 맞서지 않는 편이 좋습니다.
전략 1: 새 코드는 엄격하게, 기존 코드는 천천히 수정하기
tsconfig.json에서는 strict: true를 유지하되 당장 모두 고칠 수 없는 기존 파일에는 파일 상단에 다음 주석을 추가할 수 있습니다.
// @ts-nocheck // 파일 전체의 타입 검사를 건너뜁니다
또는 특정 줄에만 적용할 수 있습니다.
// @ts-ignore // 다음 줄의 타입 오류를 무시합니다
단, @ts-ignore와 @ts-expect-error에는 차이가 있습니다.
// @ts-ignore
const x = 1 as any // 다음 줄에 오류가 없어도 경고하지 않습니다
// @ts-expect-error
const y = 1 // 다음 줄에 오류가 없으면 TypeScript가 '불필요한 주석'이라고 경고합니다
저는 @ts-expect-error를 더 권장합니다. ‘주석을 지우는 것을 잊는’ 상황을 막아 주기 때문입니다. 버그를 수정하면 TypeScript가 이 주석이 더는 필요 없다고 알려 줍니다.
전략 2: 기능 모듈별로 차례로 활성화하기
예를 들어 먼저 components 디렉터리의 파일을 모두 수정하고 다른 디렉터리는 당분간 느슨한 설정을 유지할 수 있습니다. 다음처럼 설정하면 됩니다.
// tsconfig.strict.json(엄격 모드)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/components/**/*"]
}
평소 개발할 때는 일반 tsconfig.json을 사용하고 특정 모듈을 리팩터링할 때 strict 버전으로 전환합니다.
엄격 모드는 개발자를 괴롭히기 위한 기능이 아닙니다. 기존 컴포넌트를 리팩터링하다가 strictNullChecks를 켰더니 null 검사가 빠진 곳을 5군데 발견한 적이 있습니다. 그중 3곳은 운영 환경에서 이미 오류가 발생했지만 try-catch에 가려져 드러나지 않았습니다. 그 순간 빨간 물결선이 꽤 사랑스럽게 보였습니다.
타입 안전 라우팅 구현 - 오타와 작별하기
Next.js 내장 Typed Routes
글 도입부에서 언급한 운영 환경 버그를 기억하시나요? 라우트에 s 하나가 더 들어가 페이지가 404를 반환했습니다. 사실 이런 오류는 완전히 예방할 수 있습니다.
Next.js 13에는 typedRoutes라는 실험적 기능이 도입됐습니다. 이 기능을 활성화하면 TypeScript가 모든 라우트의 타입 정의를 생성합니다.
활성화 방법
next.config.ts에 한 줄을 추가합니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
typedRoutes: true, // 타입 안전 라우팅 활성화
},
}
export default nextConfig
그다음 개발 서버(npm run dev)를 다시 시작하면 Next.js가 app 디렉터리를 자동으로 스캔하고 .next/types 폴더에 라우트 타입 정의를 생성합니다.
어떤 효과가 있나요?
프로젝트 구조가 다음과 같다고 가정해 보겠습니다.
app/
├── page.tsx // 홈
├── blog/
│ ├── page.tsx // 블로그 목록
│ └── [slug]/
│ └── page.tsx // 블로그 상세
└── user/
└── [id]/
└── profile/
└── page.tsx // 사용자 프로필
typedRoutes를 활성화하고 Link 컴포넌트나 useRouter에서 라우트를 작성하면 IDE가 자동 완성을 제공합니다.
import Link from 'next/link'
export default function Nav() {
return (
<nav>
<Link href="/">홈</Link>
<Link href="/blog">블로그</Link>
<Link href="/blog/hello-world">게시글 상세</Link>
<Link href="/user/123/profile">프로필</Link>
{/* ❌ TypeScript 오류: 라우트가 존재하지 않습니다 */}
<Link href="/users/123/profile" /> // users가 아니라 user여야 합니다
</nav>
)
}
href="/를 입력하는 순간 IDE가 사용 가능한 모든 라우트를 표시하고, 잘못 입력하면 즉시 빨간색 오류가 나타납니다.
솔직히 이 기능을 처음 경험했을 때 머릿속에는 ‘정말 좋다’는 생각뿐이었습니다.
제한 사항
다만 현재는 몇 가지 제한이 있습니다.
- App Router만 지원: 프로젝트에서 아직
pages디렉터리를 사용한다면 이 기능을 쓸 수 없습니다. - 동적 라우트 매개변수는 직접 전달:
/blog/[slug]같은 경로에서는 여전히 slug 값을 직접 조합해야 합니다. - 쿼리 매개변수는 검사하지 않음:
/user?tab=settings의tab매개변수는 타입 검사 대상이 아닙니다.
즉, 경로 자체의 오타는 막을 수 있지만 매개변수 값은 직접 주의해야 합니다.
서드파티 라이브러리: nextjs-routes
아직 pages 디렉터리를 사용하거나 쿼리 매개변수까지 포함한 더 완전한 타입 안전 라우팅이 필요하다면 nextjs-routes 라이브러리를 사용해 볼 수 있습니다.
설치와 설정
npm install nextjs-routes
그다음 next.config.ts에 다음 내용을 추가합니다.
const nextRoutes = require('nextjs-routes/config')
const nextConfig = nextRoutes({
// 기존 Next.js 설정
})
export default nextConfig
사용 방법
이 라이브러리는 객체 형태로 라우트를 정의할 수 있는 route 함수를 생성합니다.
import { route } from 'nextjs-routes'
// 타입 안전 라우트 객체
const profileRoute = route({
pathname: '/user/[id]/profile',
query: {
id: '123',
tab: 'settings', // 쿼리 매개변수도 타입 검사를 받습니다
}
})
router.push(profileRoute) // 완전한 타입 안전성
// 경로를 잘못 입력한 경우
const wrongRoute = route({
pathname: '/users/[id]/profile', // ❌ TypeScript 오류: 경로가 존재하지 않습니다
})
Next.js 내장 방식과 비교했을 때 nextjs-routes의 장점은 다음과 같습니다.
pages디렉터리 지원- 쿼리 매개변수 타입 검사
- 문자열을 직접 조합하지 않고 객체 형태로 라우트 정의
단점은 별도 의존성을 설치해야 하고 라우트 구조를 변경할 때마다 타입 파일을 다시 생성해야 한다는 것입니다. 다만 생성 과정은 자동으로 처리됩니다.
라우트 매개변수 타입 추론
동적 라우트의 매개변수는 어떻게 처리할까요? 예를 들어 app/blog/[slug]/page.tsx에서 slug 매개변수의 타입은 무엇일까요?
Next.js는 params 타입을 자동으로 생성합니다.
// app/blog/[slug]/page.tsx
export default function BlogPost({
params,
}: {
params: { slug: string }
}) {
return <h1>게시글: {params.slug}</h1>
}
하지만 여기서 slug는 단순한 string 타입이므로 어떤 문자열이든 전달할 수 있습니다. 특정 형식의 slug만 허용하는 등 더 엄격한 검사가 필요하다면 zod로 런타임 검증을 적용할 수 있습니다.
import { z } from 'zod'
const slugSchema = z.string().regex(/^[a-z0-9-]+$/)
export default function BlogPost({
params,
}: {
params: { slug: string }
}) {
// slug 형식 검증
const validatedSlug = slugSchema.parse(params.slug)
return <h1>게시글: {validatedSlug}</h1>
}
slug가 형식에 맞지 않으면, 예를 들어 대문자나 특수 문자가 포함되면 zod가 오류를 발생시킵니다.
이 방법은 API 라우트를 처리할 때 특히 유용합니다. 사용자가 어떤 값을 보낼지는 통제할 수 없으므로 운영 환경에서 문제가 터진 뒤 대응하는 것보다 미리 검증하는 편이 낫습니다.
환경 변수 타입 정의 - any를 완전히 없애기
문제의 원인
환경 변수에 대한 TypeScript의 기본 지원은 솔직히 부족한 편입니다.
다음과 같은 코드를 작성해 본 적이 있을 것입니다.
const apiKey = process.env.API_KEY
마우스를 apiKey 위에 올리면 타입이 string | undefined로 표시됩니다. 적어도 undefined일 가능성은 알려 줍니다.
하지만 더 흔한 상황은 다음과 같습니다.
const apiUrl = process.env.NEXT_PUBLIC_API_URL
console.log(apiUrl.toUpperCase()) // 런타임 오류: apiUrl is undefined
TypeScript는 오류를 표시하지 않고 실제 실행 시점에야 환경 변수가 아예 설정되지 않았다는 사실을 알게 됩니다.
환경 변수 이름에 오타가 있어도 TypeScript는 알지 못합니다.
const key = process.env.API_SECRE // T 하나 누락
// TypeScript: 문제없습니다. string | undefined일 뿐입니다
난감한 상황입니다. TypeScript를 사용하면서도 환경 변수 이름을 눈으로 확인해야 하니 JavaScript를 쓰는 것과 무엇이 다른지 의문이 듭니다.
T3 Env 사용하기(권장)
현재 이 문제를 해결하는 방법 가운데 커뮤니티에서 가장 널리 인정받는 선택지는 T3 Env입니다. 타입 검사와 런타임 검증을 동시에 제공합니다.
설치
npm install @t3-oss/env-nextjs zod
설정
프로젝트 루트에 env.mjs 또는 env.ts를 만듭니다.
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
// 서버 환경 변수(클라이언트에서 접근할 수 없음)
server: {
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(32),
SMTP_HOST: z.string().min(1),
},
// 클라이언트 환경 변수(NEXT_PUBLIC_으로 시작해야 함)
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),
},
// 런타임 환경 변수 매핑
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
API_SECRET: process.env.API_SECRET,
SMTP_HOST: process.env.SMTP_HOST,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
},
})
사용법
import { env } from './env.mjs'
// ✅ 완전한 타입 안전성과 자동 완성
const dbUrl = env.DATABASE_URL // string
const appUrl = env.NEXT_PUBLIC_APP_URL // string
// ❌ TypeScript 오류: 오타
const wrong = env.DATABASE_UR
// ❌ TypeScript 오류: 클라이언트에서는 서버 변수에 접근할 수 없습니다
// 클라이언트 컴포넌트에서
'use client'
const secret = env.API_SECRET // 컴파일 오류
가장 편리한 점
- 시작 시 검증: 환경 변수가 누락됐거나 형식이 잘못되면 런타임까지 기다리지 않고 애플리케이션 시작 시점에 오류가 발생합니다.
- 타입 추론: 모든 환경 변수에 정확한 타입이 지정되며 더는
string | undefined가 아닙니다. - 유출 방지: 클라이언트 코드에서 서버 변수에 접근하면 즉시 컴파일 오류가 발생합니다.
T3 Env를 사용하기 전에는 테스트 환경에서 특정 환경 변수 설정을 빠뜨려 서비스가 시작되지 않는 일이 잦았습니다. 그때마다 로그를 확인해야 어떤 변수가 누락됐는지 알 수 있었습니다. 이제는 시작 단계에서 바로 발견할 수 있어 시간을 많이 아끼고 있습니다.
사용자 정의 타입 선언 파일 방식
T3 Env를 도입하고 싶지 않거나 프로젝트 규모가 작다면 ProcessEnv 타입을 직접 확장할 수도 있습니다.
// env.d.ts
namespace NodeJS {
interface ProcessEnv {
// 서버 변수
DATABASE_URL: string
API_SECRET: string
SMTP_HOST: string
// 클라이언트 변수
NEXT_PUBLIC_APP_URL: string
NEXT_PUBLIC_ANALYTICS_ID?: string // 선택 변수에는 ? 사용
}
}
이제 TypeScript가 변수의 타입을 알 수 있습니다.
const dbUrl = process.env.DATABASE_URL // string
const apiSecret = process.env.API_SECRET // string
// ❌ TypeScript 오류
const wrong = process.env.DATABASE_UR // Property 'DATABASE_UR' does not exist
단점
- 런타임 검증이 없어 환경 변수가 누락돼도 실행할 때까지 알 수 없습니다.
- 클라이언트가 서버 변수에 접근하는 것을 막을 수 없습니다.
- 타입 정의를 직접 관리해야 합니다.
소규모 프로젝트나 타입 안전성 요구가 높지 않은 상황에 적합합니다. 하지만 TypeScript를 사용한다면 T3 Env로 한 번에 해결하는 방식을 권장합니다.
TypeScript 엄격 모드 적용 - 실전 기법
서드파티 라이브러리 타입 문제 처리하기
내 코드가 아니라 서드파티 라이브러리가 타입 정의를 제공하지 않거나 정의에 버그가 있는 경우도 있습니다.
상황 1: 라이브러리에 타입 정의가 전혀 없는 경우
예를 들어 오래된 npm 패키지를 사용했는데 import한 값이 전부 any라고 가정해 보겠습니다.
import oldLib from 'some-old-lib' // any
먼저 npm에서 @types/some-old-lib가 있는지 찾아봅니다.
npm install -D @types/some-old-lib
없다면 직접 작성해야 합니다. types/some-old-lib.d.ts를 만듭니다.
declare module 'some-old-lib' {
export function doSomething(param: string): number
export default someOldLib
}
이제 TypeScript가 이 라이브러리의 타입을 알 수 있습니다.
상황 2: 타입 정의에 문제가 있는 경우
때로는 @types 패키지의 타입 정의가 실제 API와 맞지 않습니다. 특히 빠르게 변경되는 라이브러리에서 자주 발생합니다. 이때는 ‘타입 단언’으로 임시 처리할 수 있습니다.
import { someFunction } from 'buggy-lib'
// 타입 정의에는 string을 반환한다고 되어 있지만 실제 반환값은 number입니다
const result = someFunction() as number
다만 이는 임시 해결책일 뿐이므로 가능하면 해당 라이브러리의 GitHub에 issue나 PR을 올리는 것이 좋습니다.
skipLibCheck를 켜야 할까요?
tsconfig에는 skipLibCheck 옵션이 있습니다. 활성화하면 TypeScript가 node_modules 내부의 타입 검사를 건너뜁니다.
제 권장 사항은 활성화입니다.
왜일까요? node_modules의 타입 오류는 직접 수정하기 어렵고 컴파일 속도만 떨어뜨립니다. TypeScript로 수많은 서드파티 라이브러리의 타입 문제를 검사하기보다 자체 코드에 집중하는 편이 낫습니다.
흔한 any 탈출 상황과 해결 방법
엄격 모드를 켜도 일부 코드는 쉽게 any 타입으로 빠져나갈 수 있습니다.
상황 1: 이벤트 핸들러
// ❌ 좋지 않은 방식
const handleSubmit = (e: any) => {
e.preventDefault()
}
// ✅ 올바른 방식
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// e.currentTarget에 완전한 타입 제안이 제공됩니다
}
자주 사용하는 이벤트 타입은 다음과 같습니다.
React.MouseEvent<HTMLButtonElement>React.ChangeEvent<HTMLInputElement>React.KeyboardEvent<HTMLDivElement>
상황 2: API 응답 데이터
// ❌ 좋지 않은 방식
const res = await fetch('/api/user')
const data = await res.json() // any
// ✅ 방법 1: 인터페이스 직접 정의
interface User {
id: string
name: string
email: string
}
const data: User = await res.json()
// ✅ 방법 2: zod로 검증(권장)
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const data = UserSchema.parse(await res.json()) // 타입 자동 추론
zod의 장점은 타입 검사와 런타임 검증을 모두 제공한다는 것입니다. 백엔드가 반환하는 데이터 구조가 바뀌면 즉시 발견할 수 있습니다.
상황 3: 동적 import
// ❌ 좋지 않은 방식
const module = await import('./utils') // any
// ✅ 올바른 방식
const module = await import('./utils') as typeof import('./utils')
또는 필요한 항목을 직접 가져올 수 있습니다.
const { formatDate } = await import('./utils') // 타입 자동 추론
TypeScript 유틸리티 타입으로 개발 효율 높이기
TypeScript에는 코드를 크게 줄일 수 있는 여러 유틸리티 타입이 내장돼 있습니다.
Pick: 일부 속성 추출
interface User {
id: string
name: string
email: string
password: string
createdAt: Date
}
// 공개할 사용자 정보만 필요합니다
type PublicUser = Pick<User, 'id' | 'name' | 'email'>
// { id: string; name: string; email: string }
Omit: 특정 속성 제외
// 사용자를 생성할 때는 id와 createdAt이 필요하지 않습니다
type CreateUserInput = Omit<User, 'id' | 'createdAt'>
Partial: 모든 속성을 선택 사항으로 변경
// 사용자를 업데이트할 때는 모든 필드가 선택 사항입니다
type UpdateUserInput = Partial<User>
Required: 모든 속성을 필수 항목으로 변경
type RequiredUser = Required<Partial<User>> // 반대 연산
사용자 정의 유틸리티 타입
내장 타입으로 충분하지 않다면 직접 만들 수 있습니다.
// 모든 문자열 속성에 undefined를 허용합니다
type PartialString<T> = {
[K in keyof T]: T[K] extends string ? T[K] | undefined : T[K]
}
솔직히 이런 유틸리티 타입은 처음 보면 조금 어렵게 느껴집니다. 하지만 익숙해지면 특히 복잡한 객체 타입을 처리할 때 중복 코드를 크게 줄여 주는 정말 유용한 기능이라는 것을 알게 됩니다.
결론
여기까지 쓰고 나니 글 도입부에서 이야기한 새벽 3시의 버그가 다시 떠오릅니다.
당시 Next.js의 typedRoutes를 활성화했다면 라우트 오타가 배포될 일은 없었을 것입니다. T3 Env를 사용했다면 누락된 환경 변수를 시작 단계에서 발견했을 것이고, 엄격 모드를 제대로 설정했다면 TypeScript가 암시적 any를 진작 찾아냈을 것입니다.
TypeScript의 타입 안전성은 개발자를 괴롭히기 위한 것이 아니라 버그 발견 시점을 ‘런타임’에서 ‘작성 시점’으로 앞당기기 위한 장치입니다. 사용자가 운영 환경에서 흰 화면을 마주하게 두기보다 코드를 작성할 때 IDE가 빨간색 오류를 보여 주는 편이 훨씬 낫습니다.
마지막으로 이 글의 핵심을 정리해 보겠습니다.
- tsconfig 최적화: strict 모드를 활성화하고 incremental과 paths를 설정하며 Next.js 플러그인을 사용합니다.
- 타입 안전 라우팅: Next.js 13+에서 typedRoutes를 활성화하거나 nextjs-routes 라이브러리를 사용합니다.
- 환경 변수 타입: T3 Env로 타입 검사와 런타임 검증을 구현합니다.
- 엄격 모드 실전 적용: 점진적으로 활성화하고 서드파티 라이브러리 타입 문제를 처리하며 흔한
any탈출 상황을 없앱니다.
처음에는 설정이 번거롭고 타입 표기가 복잡하게 느껴질 수 있습니다. 하지만 IDE의 정확한 제안과 코드를 수정하는 순간 잠재적인 문제를 발견하는 경험에 익숙해지면 타입 보호 없이 JavaScript를 쓰던 시절로 돌아가고 싶지 않을 것입니다.
지금 바로 tsconfig.json을 열고 strict를 true로 바꿔 보세요. 빨간 물결선이 많다는 것은 그만큼 잠재적인 버그를 많이 발견했다는 뜻입니다. 좋은 신호입니다.
FAQ
strict 모드를 켜면 프로젝트 컴파일이 느려지나요?
기존 프로젝트에서 엄격 모드를 안전하게 활성화하려면 어떻게 해야 하나요?
T3 Env와 ProcessEnv 타입을 직접 정의하는 방식은 무엇이 다른가요?
Next.js의 typedRoutes는 pages 디렉터리를 지원하나요?
skipLibCheck를 활성화하면 안전성 문제가 생기나요?
5분 읽기 · 게시일: 2026년 1월 6일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js Sitemap과 robots.txt 설정 가이드: 검색 엔진에 웹사이트를 빠르게 등록하는 방법
Next.js에서 Sitemap과 robots.txt를 설정하는 방법, 세 가지 생성 방식, 자주 발생하는 오류와 Google Search Console 연동 방법까지 자세히 설명하여 새 웹사이트가 검색 엔진에 빠르게 등록되도록 돕습니다.
45편 중 25편
다음
Next.js 엔지니어링 설정: ESLint + Prettier + Husky 한 번에 구축하기
금요일 저녁, 서식 문제로 PR이 반려되었나요? 팀의 코드 스타일이 제각각이라 불필요한 충돌이 생기나요? 이 글에서는 ESLint, Prettier, Husky를 설정해 코드 검사와 서식 지정을 자동화하고 팀 협업 효율을 높이는 방법을 단계별로 설명합니다.
45편 중 27편



댓글
GitHub로 로그인하여 댓글을 남기세요