본문 바로가기

개발 & IT/프론트엔드

TypeScript 인터페이스 확장: Declaration Merging과 Module Augmentation

Next.js에서 NextAuth를 사용하여 인증 시스템을 구축하던 중, 다음과 같은 TypeScript 에러를 만났습니다.

async session({ session, token }) {
    if (session.user) {
        session.user.id = token.id as string;  // ❌ 에러!
        // Property 'id' does not exist on type
    }
    return session;
}

session.user에 id 속성을 추가하려고 했지만, TypeScript는 이 속성이 존재하지 않는다고 불평합니다. NextAuth의 기본 타입에는 name, email, image만 있고 id가 없기 때문이죠.

이 문제를 해결하면서 TypeScript의 강력한 기능인 Declaration MergingModule Augmentation을 배우게 되었습니다.

Declaration Merging이란?

Declaration Merging은 TypeScript의 독특한 특성으로, 같은 이름의 인터페이스를 여러 번 선언하면 자동으로 합쳐지는 기능입니다.

기본 예시

interface User {
    name: string;
}

interface User {
    email: string;
}

// 결과: User 타입은 자동으로 병합됩니다
const user: User = {
    name: "홍길동",
    email: "hong@example.com"  // 둘 다 필요!
};

두 개의 User 인터페이스가 하나로 합쳐져서, 최종적으로 name과 email 모두를 가진 타입이 됩니다.

왜 유용한가?

  1. 점진적 타입 확장: 여러 파일에 걸쳐 타입을 확장할 수 있습니다
  2. 플러그인 시스템: 라이브러리 사용자가 타입을 확장할 수 있게 합니다
  3. 코드 분리: 관련 타입을 여러 곳에 나눠서 정의할 수 있습니다

Module Augmentation

Module Augmentation은 외부 라이브러리의 타입을 확장할 수 있게 해주는 기능입니다. 라이브러리 코드를 직접 수정하지 않고도 타입을 추가할 수 있죠.

문법

declare module "라이브러리-이름" {
    // 여기서 타입 확장
}

NextAuth 실전 예시

처음에 만난 문제를 해결하기 위해 다음과 같이 작성했습니다.

파일: src/types/next-auth.d.ts

import { DefaultSession } from "next-auth";

declare module "next-auth" {
    interface Session {
        user: {
            id: string;
        } & DefaultSession["user"];
    }

    interface User {
        id: string;
    }
}

declare module "next-auth/jwt" {
    interface JWT {
        id?: string;
    }
}

이렇게 하면:

  1. next-auth 모듈의 Session 인터페이스에 id가 추가됩니다
  2. 기존의 name, email, image 속성은 DefaultSession["user"]로 유지됩니다
  3. User와 JWT 타입에도 id를 추가합니다

결과

// 이제 에러 없이 작동합니다!
async session({ session, token }) {
    if (session.user) {
        session.user.id = token.id as string;  // ✅ 정상 작동!
    }
    return session;
}

Java/Spring과의 비교

Java나 Spring에 익숙하다면, 이것이 상속과 비슷해 보일 수 있습니다. 하지만 중요한 차이가 있습니다.

Java의 상속

// 기존 클래스
public class User {
    private String name;
    private String email;
}

// 새로운 클래스 생성
public class AdminUser extends User {
    private String id;  // 새 속성
}

새로운 타입이 만들어집니다. User와 AdminUser는 다른 타입입니다.

TypeScript의 Declaration Merging

// 기존 인터페이스
interface User {
    name: string;
    email: string;
}

// 같은 인터페이스에 추가
interface User {
    id: string;
}

기존 타입 자체가 확장됩니다. 여전히 하나의 User 타입입니다.

비교 표

특성 Java 상속 TypeScript Merging

새 타입 생성 ✅ 예 (AdminUser) ❌ 아니오
원본 수정 ❌ 아니오 ✅ 예 (User 확장)
타입 이름 다름 (User ≠ AdminUser) 같음 (User)
사용 방법 새 타입으로 사용 기존 타입으로 사용

실전 활용 가이드

언제 사용하면 좋을까?

Module Augmentation이 필요한 경우:

  1. 라이브러리 타입이 부족할 때
  2. // Express의 Request에 사용자 정보 추가 declare module "express-serve-static-core" { interface Request { user?: { id: string; email: string; } } }
  3. 커스텀 속성을 추가할 때
  4. // Material-UI 테마에 커스텀 색상 추가 declare module "@mui/material/styles" { interface Theme { status: { danger: string; } } }
  5. 플러그인 타입을 확장할 때
  6. // Vue Router에 메타 필드 추가 declare module "vue-router" { interface RouteMeta { requiresAuth?: boolean; } }

주의사항

1. .d.ts 파일 위치

타입 선언 파일은 프로젝트 어디든 둘 수 있지만, 관례적으로:

src/
├── types/
│   ├── next-auth.d.ts
│   ├── express.d.ts
│   └── ...

2. import/export가 있으면 Global Scope가 아님

// ❌ 이렇게 하면 Module Augmentation이 작동 안 함
export {};  // export가 있으면 모듈이 됨

declare module "next-auth" {
    // ...
}

하지만 import가 필요하다면:

// ✅ 올바른 방법
import { DefaultSession } from "next-auth";

declare module "next-auth" {
    interface Session {
        user: {
            id: string;
        } & DefaultSession["user"];
    }
}

3. tsconfig.json 확인

TypeScript가 .d.ts 파일을 인식하려면:

{
    "include": [
        "src/**/*"
    ]
}

4. 재시작 필요

타입 파일을 추가한 후에는:

  • TypeScript 서버 재시작 (VS Code: Cmd/Ctrl + Shift + P → "TypeScript: Restart TS Server")
  • 개발 서버 재시작

실전 예제: Express + TypeScript

실무에서 자주 사용하는 또 다른 예시를 살펴보겠습니다.

문제 상황

Express에서 미들웨어로 사용자 정보를 req.user에 추가했지만, TypeScript는 이를 인식하지 못합니다.

// middleware.ts
app.use((req, res, next) => {
    req.user = { id: "123", email: "user@example.com" };
    //  ^^^^ Property 'user' does not exist
    next();
});

해결 방법

파일: src/types/express.d.ts

declare global {
    namespace Express {
        interface Request {
            user?: {
                id: string;
                email: string;
                role: string;
            }
        }
    }
}

export {};  // 파일을 모듈로 만들기

결과

// 이제 타입 안전하게 사용 가능!
app.use((req, res, next) => {
    req.user = {
        id: "123",
        email: "user@example.com",
        role: "admin"
    };
    next();
});

app.get("/profile", (req, res) => {
    if (req.user) {
        res.json({
            id: req.user.id,        // ✅ 자동완성 작동!
            email: req.user.email   // ✅ 타입 체크 작동!
        });
    }
});

 

TypeScript의 Declaration Merging과 Module Augmentation은 처음에는 낯설 수 있지만, 실무에서 매우 유용한 기능입니다.

  1. Declaration Merging: 같은 이름의 인터페이스는 자동으로 합쳐진다
  2. Module Augmentation: declare module로 외부 라이브러리 타입을 확장할 수 있다
  3. 실용성: NextAuth, Express, Vue Router 등 실무에서 자주 사용
  4. 타입 안전성: 런타임 에러를 컴파일 타임에 잡을 수 있다

추가 학습 자료

반응형