Nest.js를 배워보자

🛠️ NestJS 컨트롤러에 HATEOAS 적용 (수정된 danme-calc.controller.ts)

_Blue_Sky_ 2025. 12. 11. 12:06
728x90

컨트롤러 (danme-calc.controller.ts)

컨트롤러는 들어오는 HTTP 요청을 처리하고, 서비스를 호출하며, 응답을 반환합니다.

// src/danme-calc/danme-calc.controller.ts

import {
  Controller,
  Get,
  Post,
  Body,
  Put,
  Param,
  Delete,
  NotFoundException,
} from '@nestjs/common';
import { DanmeCalcService } from './danme-calc.service';
import { Prisma } from '@prisma/client';

@Controller('danme-calc')
export class DanmeCalcController {
  constructor(private readonly danmeCalcService: DanmeCalcService) {}

  // URL 매개변수에서 복합 키를 추출하는 헬퍼 함수
  private getKeyParams(params: { Open: string; Code: string; DanmesaCode: string }) {
    return {
      Open: params.Open,
      Code: params.Code,
      DanmesaCode: params.DanmesaCode,
    };
  }

  // POST /danme-calc (생성)
  @Post()
  create(@Body() createDanmeCalcDto: Prisma.wrk_danme_calcCreateInput) {
    return this.danmeCalcService.create(createDanmeCalcDto);
  }

  // GET /danme-calc (전체 조회)
  @Get()
  findAll() {
    return this.danmeCalcService.findAll();
  }

  // GET /danme-calc/:Open/:Code/:DanmesaCode (단일 조회)
  // 참고: 키의 세 부분이 모두 URL을 통해 전달되어야 합니다.
  @Get(':Open/:Code/:DanmesaCode')
  async findOne(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    const key = this.getKeyParams(params);
    const record = await this.danmeCalcService.findOne(key);

    if (!record) {
      throw new NotFoundException(
        `키 ${key.Open}/${key.Code}/${key.DanmesaCode} 를 가진 레코드를 찾을 수 없습니다.`,
      );
    }
    return record;
  }

  // PUT /danme-calc/:Open/:Code/:DanmesaCode (업데이트)
  @Put(':Open/:Code/:DanmesaCode')
  update(
    @Param() params: { Open: string; Code: string; DanmesaCode: string },
    @Body() updateDanmeCalcDto: Prisma.wrk_danme_calcUpdateInput,
  ) {
    const key = this.getKeyParams(params);
    return this.danmeCalcService.update(key, updateDanmeCalcDto);
  }

  // DELETE /danme-calc/:Open/:Code/:DanmesaCode (삭제)
  @Delete(':Open/:Code/:DanmesaCode')
  remove(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    const key = this.getKeyParams(params);
    return this.danmeCalcService.remove(key);
  }
}

 


HATEOAS(Hypermedia as an Engine of Application State)는 응답에 리소스와 관련된 다른 리소스에 대한 링크를 포함하여, 클라이언트가 다음 가능한 작업을 알 수 있도록 하는 REST 아키텍처 스타일입니다.

제공해주신 NestJS 컨트롤러 코드에 HATEOAS 방식을 적용하여, 단일 레코드 조회(findOne)와 전체 목록 조회(findAll) 응답에 해당 리소스에 대한 하이퍼링크를 추가해 보겠습니다.

참고: NestJS에서 HATEOAS를 구현하는 표준화된 방법은 없으며, 일반적으로 응답 객체에 _links 또는 links와 같은 필드를 추가하여 링크 정보를 포함합니다.

1. HATEOAS 응답 구조 정의

먼저, HATEOAS 링크를 포함하는 응답 구조를 정의합니다.

// (가상의) HATEOAS 응답 구조를 위한 인터페이스

interface Link {
  rel: string; // 관계 (예: self, update, delete)
  href: string; // 리소스 URI
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // HTTP 메서드 (선택 사항)
}

interface HATEOASResponse<T> {
  data: T;
  _links: Link[];
}

2. 수정된 컨트롤러 코드 (danme-calc.controller.ts)

단일 조회와 전체 조회 메서드에 HATEOAS를 적용했습니다.

// src/danme-calc/danme-calc.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Put,
  Param,
  Delete,
  NotFoundException,
} from '@nestjs/common';
import { DanmeCalcService } from './danme-calc.service';
import { Prisma } from '@prisma/client';

// HATEOAS 링크 구조 정의
interface Link {
  rel: string; // 관계 (예: self, list, create)
  href: string; // 리소스 URI
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
}

// 단일 항목에 대한 HATEOAS 응답 구조
type DanmeCalcWithLinks = Prisma.wrk_danme_calcGetPayload<{}> & {
  _links: Link[];
};

// 목록에 대한 HATEOAS 응답 구조
interface DanmeCalcListResponse {
  data: DanmeCalcWithLinks[];
  _links: Link[]; // 목록 자체에 대한 링크 (예: self, create)
}

@Controller('danme-calc')
export class DanmeCalcController {
  constructor(private readonly danmeCalcService: DanmeCalcService) {}

  // 기본 경로
  private readonly BASE_URL = '/danme-calc';

  // URL 매개변수에서 복합 키를 추출하는 헬퍼 함수
  private getKeyParams(params: { Open: string; Code: string; DanmesaCode: string }) {
    return {
      Open: params.Open,
      Code: params.Code,
      DanmesaCode: params.DanmesaCode,
    };
  }

  // 단일 리소스에 대한 링크를 생성하는 헬퍼 함수
  private createResourceLinks(key: { Open: string; Code: string; DanmesaCode: string }): Link[] {
    const resourcePath = `${this.BASE_URL}/${key.Open}/${key.Code}/${key.DanmesaCode}`;
    return [
      { rel: 'self', href: resourcePath, method: 'GET' },
      { rel: 'list', href: this.BASE_URL, method: 'GET' },
      { rel: 'update', href: resourcePath, method: 'PUT' },
      { rel: 'delete', href: resourcePath, method: 'DELETE' },
    ];
  }

  // POST /danme-calc (생성)
  // 생성 요청에는 링크를 추가하지 않고, 생성된 리소스의 정보를 반환합니다.
  @Post()
  create(@Body() createDanmeCalcDto: Prisma.wrk_danme_calcCreateInput) {
    return this.danmeCalcService.create(createDanmeCalcDto);
  }

  // GET /danme-calc (전체 조회) - HATEOAS 적용
  @Get()
  async findAll(): Promise<DanmeCalcListResponse> {
    const records = await this.danmeCalcService.findAll();
    
    const dataWithLinks: DanmeCalcWithLinks[] = records.map((record) => {
        const key = {
            Open: record.Open,
            Code: record.Code,
            DanmesaCode: record.DanmesaCode,
        };
        // 각 항목에 대한 링크 추가
        return {
            ...record,
            _links: this.createResourceLinks(key),
        } as DanmeCalcWithLinks;
    });

    // 목록 자체에 대한 링크 추가
    const listLinks: Link[] = [
      { rel: 'self', href: this.BASE_URL, method: 'GET' },
      { rel: 'create', href: this.BASE_URL, method: 'POST' },
    ];

    return {
        data: dataWithLinks,
        _links: listLinks,
    };
  }

  // GET /danme-calc/:Open/:Code/:DanmesaCode (단일 조회) - HATEOAS 적용
  @Get(':Open/:Code/:DanmesaCode')
  async findOne(@Param() params: { Open: string; Code: string; DanmesaCode: string }): Promise<DanmeCalcWithLinks> {
    const key = this.getKeyParams(params);
    const record = await this.danmeCalcService.findOne(key);

    if (!record) {
      throw new NotFoundException(
        `키 ${key.Open}/${key.Code}/${key.DanmesaCode} 를 가진 레코드를 찾을 수 없습니다.`,
      );
    }

    // 조회된 레코드에 HATEOAS 링크 추가
    const links = this.createResourceLinks(key);

    return {
        ...record,
        _links: links,
    } as DanmeCalcWithLinks;
  }

  // PUT /danme-calc/:Open/:Code/:DanmesaCode (업데이트)
  @Put(':Open/:Code/:DanmesaCode')
  update(
    @Param() params: { Open: string; Code: string; DanmesaCode: string },
    @Body() updateDanmeCalcDto: Prisma.wrk_danme_calcUpdateInput,
  ) {
    const key = this.getKeyParams(params);
    return this.danmeCalcService.update(key, updateDanmeCalcDto);
  }

  // DELETE /danme-calc/:Open/:Code/:DanmesaCode (삭제)
  @Delete(':Open/:Code/:DanmesaCode')
  remove(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    const key = this.getKeyParams(params);
    return this.danmeCalcService.remove(key);
  }
}

3. HATEOAS 적용 결과 예시

A. 단일 리소스 조회 (GET /danme-calc/A/100/DMS)

항목 설명
data 실제 조회된 wrk_danme_calc 레코드 데이터
_links 현재 리소스와 관련된 하이퍼링크 배열
 
{
  "Open": "A",
  "Code": "100",
  "DanmesaCode": "DMS",
  // ... 기타 레코드 필드 ...
  "_links": [
    {
      "rel": "self",
      "href": "/danme-calc/A/100/DMS",
      "method": "GET"
    },
    {
      "rel": "list",
      "href": "/danme-calc",
      "method": "GET"
    },
    {
      "rel": "update",
      "href": "/danme-calc/A/100/DMS",
      "method": "PUT"
    },
    {
      "rel": "delete",
      "href": "/danme-calc/A/100/DMS",
      "method": "DELETE"
    }
  ]
}

B. 전체 목록 조회 (GET /danme-calc)

항목 설명
data 개별 리소스 데이터와 링크를 포함하는 배열
_links 목록 자체에 대한 하이퍼링크 배열
 
{
  "data": [
    {
      "Open": "A",
      "Code": "100",
      "DanmesaCode": "DMS",
      // ... 기타 필드 ...
      "_links": [
        /* (위의 단일 조회와 동일한 링크) */
      ]
    },
    {
      "Open": "B",
      "Code": "200",
      "DanmesaCode": "DMS2",
      // ... 기타 필드 ...
      "_links": [
        /* ... */
      ]
    }
  ],
  "_links": [
    {
      "rel": "self",
      "href": "/danme-calc",
      "method": "GET"
    },
    {
      "rel": "create",
      "href": "/danme-calc",
      "method": "POST"
    }
  ]
}

이러한 변경으로 클라이언트는 서버의 응답만으로 다음 액션(업데이트, 삭제, 목록 조회 등)을 위한 URI와 HTTP 메서드를 알 수 있게 됩니다.


HATEOAS 로직을 컨트롤러 메서드에 직접 구현하는 대신, **NestJS 인터셉터(Interceptor)**를 사용하여 응답을 중앙 집중식으로 처리하고 링크를 주입하는 것이 훨씬 더 깔끔하고 재사용 가능하며 유지보수하기 좋습니다.

🔗 HATEOAS 인터셉터 구현 방안

NestJS에서 HATEOAS를 구조적으로 적용하기 위해 다음과 같은 단계를 따를 수 있습니다.

1. 응답 구조 정의 (DTO)

먼저, HATEOAS 링크를 포함할 최종 응답 DTO를 정의합니다.

// src/common/dto/hateoas-link.dto.ts
interface Link {
  rel: string;
  href: string;
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
}

export interface HateoasResponse<T> {
  data: T; // 실제 리소스 데이터
  _links: Link[];
}

2. HATEOAS 인터셉터 생성

@Injectable() 데코레이터를 사용하여 인터셉터를 생성하고, intercept 메서드에서 응답 스트림을 조작합니다.

// src/common/interceptors/hateoas.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HateoasResponse } from '../dto/hateoas-link.dto';

@Injectable()
export class HateoasInterceptor<T> implements NestInterceptor<T, HateoasResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<HateoasResponse<T>> {
    const request = context.switchToHttp().getRequest();
    const basePath = '/danme-calc'; // 컨트롤러의 기본 경로

    return next.handle().pipe(
      map((data) => {
        // 1. 단일 리소스 처리 (예: findOne, create, update)
        if (data && data.Open && data.Code && data.DanmesaCode) {
          const key = {
            Open: data.Open,
            Code: data.Code,
            DanmesaCode: data.DanmesaCode,
          };
          const resourcePath = `${basePath}/${key.Open}/${key.Code}/${key.DanmesaCode}`;
          
          const links = [
            { rel: 'self', href: resourcePath, method: 'GET' },
            { rel: 'list', href: basePath, method: 'GET' },
            { rel: 'update', href: resourcePath, method: 'PUT' },
            { rel: 'delete', href: resourcePath, method: 'DELETE' },
          ];

          return { data, _links: links };
        } 
        
        // 2. 목록 리소스 처리 (예: findAll)
        else if (Array.isArray(data)) {
            const listLinks = [
                { rel: 'self', href: basePath, method: 'GET' },
                { rel: 'create', href: basePath, method: 'POST' },
            ];

            // 배열 내의 각 항목에 대한 링크 추가 (옵션)
            const dataWithItemLinks = data.map(item => {
                if (item.Open && item.Code && item.DanmesaCode) {
                    const key = {
                        Open: item.Open,
                        Code: item.Code,
                        DanmesaCode: item.DanmesaCode,
                    };
                    const resourcePath = `${basePath}/${key.Open}/${key.Code}/${key.DanmesaCode}`;
                    return {
                        ...item,
                        _links: [
                            { rel: 'self', href: resourcePath, method: 'GET' },
                        ],
                    };
                }
                return item;
            });
            
            return { data: dataWithItemLinks, _links: listLinks } as any;
        }

        // 3. 기타 응답 (예: 삭제 성공 메시지)
        return { data, _links: [] };
      }),
    );
  }
}

3. 컨트롤러에 인터셉터 적용

이제 컨트롤러에서는 순수한 데이터만 반환하고, 링크 주입은 인터셉터가 담당하게 됩니다.

// src/danme-calc/danme-calc.controller.ts (수정된 버전)
import {
  // ... (기존 임포트)
  UseInterceptors, // 추가
} from '@nestjs/common';
// ... (기존 서비스, 프리즈마 임포트)
import { HateoasInterceptor } from '../../common/interceptors/hateoas.interceptor'; // 인터셉터 임포트

@Controller('danme-calc')
@UseInterceptors(HateoasInterceptor) // 컨트롤러 전체에 인터셉터 적용
export class DanmeCalcController {
  constructor(private readonly danmeCalcService: DanmeCalcService) {}
  
  // ... getKeyParams 헬퍼 함수 유지

  // POST /danme-calc (생성) - 응답에 HATEOAS 자동 적용됨
  @Post()
  create(@Body() createDanmeCalcDto: Prisma.wrk_danme_calcCreateInput) {
    // 인터셉터가 반환된 결과를 받아서 링크를 추가합니다.
    return this.danmeCalcService.create(createDanmeCalcDto); 
  }

  // GET /danme-calc (전체 조회) - 응답에 HATEOAS 자동 적용됨
  @Get()
  findAll() {
    // 순수 배열 반환
    return this.danmeCalcService.findAll(); 
  }

  // GET /danme-calc/:Open/:Code/:DanmesaCode (단일 조회) - 응답에 HATEOAS 자동 적용됨
  @Get(':Open/:Code/:DanmesaCode')
  async findOne(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    const key = this.getKeyParams(params);
    const record = await this.danmeCalcService.findOne(key);

    if (!record) {
      throw new NotFoundException(
        `키 ${key.Open}/${key.Code}/${key.DanmesaCode} 를 가진 레코드를 찾을 수 없습니다.`,
      );
    }
    // 순수 객체 반환
    return record; 
  }

  // PUT /danme-calc/:Open/:Code/:DanmesaCode (업데이트) - 응답에 HATEOAS 자동 적용됨
  @Put(':Open/:Code/:DanmesaCode')
  update(
    @Param() params: { Open: string; Code: string; DanmesaCode: string },
    @Body() updateDanmeCalcDto: Prisma.wrk_danme_calcUpdateInput,
  ) {
    const key = this.getKeyParams(params);
    // 업데이트된 객체 반환 (인터셉터가 링크 추가)
    return this.danmeCalcService.update(key, updateDanmeCalcDto);
  }

  // DELETE /danme-calc/:Open/:Code/:DanmesaCode (삭제)
  @Delete(':Open/:Code/:DanmesaCode')
  remove(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    const key = this.getKeyParams(params);
    // 삭제는 보통 204 No Content를 반환하므로, 서비스에서 직접 204를 처리하거나,
    // 간단한 성공 메시지를 반환하도록 할 수 있습니다. (현재 코드는 삭제 결과 반환)
    return this.danmeCalcService.remove(key); 
  }
}

이 방식의 장점은 다음과 같습니다:

  1. 관심사 분리: 컨트롤러는 데이터 처리 및 서비스 호출에만 집중하고, HATEOAS 링크 주입은 인터셉터가 담당합니다.
  2. 재사용성: 다른 컨트롤러에서도 @UseInterceptors(HateoasInterceptor)만 추가하여 쉽게 HATEOAS를 적용할 수 있습니다.
  3. 유지보수: 링크 구조나 경로가 변경될 경우, HateoasInterceptor 파일 하나만 수정하면 됩니다.

위에서 제시해 드린 인터셉터 방식이 가장 모범적인 NestJS 구현 방법이며, 이를 통해 요청의 흐름 속에서 응답 데이터를 어떻게 가공하는지 시각적으로 이해하는 데 도움이 될 수 있습니다.

NestJS 인터셉터의 작동 흐름에 대한 다이어그램을 추가하여 설명해 드릴게요.

🧩 NestJS 인터셉터의 작동 원리 (HATEOAS 적용 지점)

NestJS의 인터셉터는 요청/응답 처리 파이프라인의 특정 시점에 로직을 삽입할 수 있게 해줍니다.

단계 설명 역할
요청 클라이언트에서 HTTP 요청이 들어옵니다.  
전처리 (Pre-handle) 인터셉터의 intercept 메서드가 실행됩니다. 여기서 next.handle()을 호출하기 이전에 로직을 실행하여 요청을 변형하거나 로깅할 수 있습니다. 요청 로깅, 인증/권한 검사
핸들러 실행 컨트롤러 메서드가 실행되고, 서비스가 호출되어 데이터베이스 작업을 수행합니다. 컨트롤러, 서비스
스트림 반환 컨트롤러 메서드가 순수한 데이터(wrk_danme_calc 객체 또는 배열)를 반환합니다. 이는 Observable 스트림의 값(value)이 됩니다.  
후처리 (pipe(map(...))) 인터셉터는 이 Observable 스트림을 pipe 연산자를 사용하여 가로챕니다. map 연산자 내부의 로직이 실행됩니다. HATEOAS 적용 지점
응답 변형 map 내부에서 순수한 데이터에 _links 필드를 추가하여 HATEOAS 규격을 만족하는 새로운 응답 객체를 생성합니다. HateoasInterceptor
응답 최종적으로 링크가 포함된 응답이 클라이언트에게 전송됩니다.  

인터셉터 코드 재확인 및 상세 설명

다시 한번 HateoasInterceptor 코드를 살펴보면, map을 통해 **컨트롤러가 반환한 데이터(data)**를 가져와서 조건에 따라 링크를 붙여주는 것을 볼 수 있습니다.

// src/common/interceptors/hateoas.interceptor.ts
// ... (생략)
export class HateoasInterceptor<T> implements NestInterceptor<T, HateoasResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<HateoasResponse<T>> {
    // ... (경로 설정 생략)

    return next.handle().pipe(
      map((data) => {
        // 1. 단일 리소스 처리 (데이터에 복합 키 필드가 모두 있는지 확인)
        if (data && data.Open && data.Code && data.DanmesaCode) {
            // ... 단일 리소스 링크 생성 로직 ...
            return { data, _links: links }; // HATEOAS 형식으로 변환하여 반환
        } 
        
        // 2. 목록 리소스 처리 (데이터가 배열인지 확인)
        else if (Array.isArray(data)) {
            // ... 목록 및 개별 항목 링크 생성 로직 ...
            return { data: dataWithItemLinks, _links: listLinks } as any; // HATEOAS 형식으로 변환하여 반환
        }

        // 3. 기타 응답 처리 (링크가 불필요한 경우)
        return { data, _links: [] };
      }),
    );
  }
}

이 방식은 컨트롤러 코드를 간결하게 유지하면서도 RESTful API의 중요한 원칙인 HATEOAS를 구조적으로 적용할 수 있게 해줍니다.


🌟 HATEOAS 인터셉터의 고급 활용

1. 동적 경로 설정 (basePath) 처리

인터셉터 내에서 하드코딩된 경로(const basePath = '/danme-calc';)를 사용하는 것은 재사용성을 떨어뜨립니다. 이를 해결하기 위해 두 가지 방법을 사용할 수 있습니다.

A. ExecutionContext를 이용한 동적 경로 (권장)

ExecutionContext를 사용하여 요청 객체에서 실제 요청된 경로를 추출할 수 있습니다.

// src/common/interceptors/hateoas.interceptor.ts (수정된 부분)

import {
  // ... (기존 임포트)
} from '@nestjs/common';
// ...

@Injectable()
export class HateoasInterceptor<T> implements NestInterceptor<T, HateoasResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<HateoasResponse<T>> {
    const request = context.switchToHttp().getRequest();
    
    // 요청 URL에서 기본 경로(베이스 URL)를 동적으로 추출
    // 예시: /danme-calc/A/100/DMS 요청 -> /danme-calc 를 추출
    const fullPath = request.url.split('?')[0];
    let basePath = '';

    // 현재 실행 중인 핸들러(메서드)를 확인하여 경로를 분리합니다.
    const handler = context.getHandler(); // 컨트롤러 메서드 (e.g., findOne, findAll)
    
    // findAll 같은 목록 조회 메서드인 경우: /danme-calc
    if (handler.name === 'findAll' || request.method === 'POST') {
        basePath = fullPath;
    } 
    // findOne, update, delete 같은 단일 항목 조회인 경우: /danme-calc
    else { 
        // /danme-calc/Open/Code/DanmesaCode 에서 마지막 세 파트를 제거
        const segments = fullPath.split('/');
        basePath = segments.slice(0, -3).join('/'); 
        // 또는, 컨트롤러의 메타데이터를 사용하여 @Controller('path')의 'path'를 가져올 수 있지만, 더 복잡합니다.
    }
    
    // ... (이후 로직에서 basePath 사용)
    // ...
  }
}

B. @SetMetadata 및 리플렉터 이용

NestJS의 리플렉터를 사용하여, 컨트롤러나 메서드에 부가적인 메타데이터(basePath)를 설정하고 인터셉터에서 읽어와 사용할 수도 있습니다.

2. 특정 메서드에만 HATEOAS 적용하기

findAll과 findOne 메서드에만 HATEOAS를 적용하고 싶다면, 컨트롤러 전체에 @UseInterceptors를 적용하는 대신, 개별 메서드에만 적용하면 됩니다.

danme-calc.controller.ts (수정)

// src/danme-calc/danme-calc.controller.ts

// ... (기존 임포트)
import { HateoasInterceptor } from '../../common/interceptors/hateoas.interceptor'; 

@Controller('danme-calc')
export class DanmeCalcController {
  // ... (생성자 및 헬퍼 함수)

  // POST /danme-calc (생성) - HATEOAS 적용
  @Post()
  @UseInterceptors(HateoasInterceptor) // Post 메서드에만 명시적으로 적용
  create(@Body() createDanmeCalcDto: Prisma.wrk_danme_calcCreateInput) {
    return this.danmeCalcService.create(createDanmeCalcDto);
  }

  // GET /danme-calc (전체 조회) - HATEOAS 적용
  @Get()
  @UseInterceptors(HateoasInterceptor) // Get 메서드에만 명시적으로 적용
  findAll() {
    return this.danmeCalcService.findAll();
  }

  // GET /danme-calc/:Open/:Code/:DanmesaCode (단일 조회) - HATEOAS 적용
  @Get(':Open/:Code/:DanmesaCode')
  @UseInterceptors(HateoasInterceptor) // 단일 조회에만 명시적으로 적용
  async findOne(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    // ... (기존 로직)
    return record;
  }

  // PUT /danme-calc/:Open/:Code/:DanmesaCode (업데이트) - HATEOAS 적용 (선택적)
  @Put(':Open/:Code/:DanmesaCode')
  @UseInterceptors(HateoasInterceptor) 
  update(
    @Param() params: { Open: string; Code: string; DanmesaCode: string },
    @Body() updateDanmeCalcDto: Prisma.wrk_danme_calcUpdateInput,
  ) {
    // ... (기존 로직)
    return this.danmeCalcService.update(key, updateDanmeCalcDto);
  }

  // DELETE /danme-calc/:Open/:Code/:DanmesaCode (삭제) - HATEOAS 미적용
  // 삭제 후에는 보통 204 No Content나 단순 메시지를 반환하므로, 링크가 필요 없을 수 있습니다.
  @Delete(':Open/:Code/:DanmesaCode')
  remove(@Param() params: { Open: string; Code: string; DanmesaCode: string }) {
    const key = this.getKeyParams(params);
    return this.danmeCalcService.remove(key);
  }
}

이렇게 개별 메서드에 @UseInterceptors(HateoasInterceptor)를 명시적으로 붙임으로써, 원하는 응답에만 HATEOAS 형식을 적용할 수 있습니다.


메타데이터를 이용한 동적 경로 설정은 NestJS의 강력한 기능 중 하나인 **리플렉션(Reflection)**을 활용하는 표준적이고 깔끔한 방법입니다.

이 방식을 사용하면, 인터셉터가 현재 요청이 어떤 컨트롤러에서 실행되었는지 확인하고, 해당 컨트롤러의 @Controller('경로') 데코레이터에 설정된 기본 경로(basePath)를 정확하게 가져올 수 있습니다.

3. 메타데이터를 이용한 동적 경로 설정 (권장)

단계 1: NestJS 리플렉터 임포트 및 사용

Reflector는 NestJS의 핵심 기능으로, 클래스나 핸들러에 설정된 메타데이터를 읽어오는 역할을 합니다.

// src/common/interceptors/hateoas.interceptor.ts (Reflector를 사용하도록 수정)
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Optional, // 선택적 의존성 주입을 위해 필요
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HateoasResponse } from '../dto/hateoas-link.dto';
import { Reflector } from '@nestjs/core'; // Reflector 임포트

@Injectable()
export class HateoasInterceptor<T> implements NestInterceptor<T, HateoasResponse<T>> {
  
  // 생성자를 통해 Reflector 주입
  constructor(@Optional() private readonly reflector: Reflector) {} 

  intercept(context: ExecutionContext, next: CallHandler): Observable<HateoasResponse<T>> {
    const request = context.switchToHttp().getRequest();
    
    // 1. 컨트롤러의 기본 경로(basePath) 추출
    const classRef = context.getClass();
    // @Controller('danme-calc')에서 'danme-calc'를 추출
    const controllerPaths = this.reflector.get<string[]>('path', classRef);
    const basePath = controllerPaths && controllerPaths.length > 0 ? `/${controllerPaths[0]}` : '';

    // 2. 단일 리소스 링크 생성 헬퍼 함수 (내부로 이동 또는 별도 함수로 정의)
    const createResourceLinks = (key: { Open: string; Code: string; DanmesaCode: string }): any[] => {
      // basePath를 사용하여 경로 동적 생성
      const resourcePath = `${basePath}/${key.Open}/${key.Code}/${key.DanmesaCode}`; 
      return [
        { rel: 'self', href: resourcePath, method: 'GET' },
        { rel: 'list', href: basePath, method: 'GET' },
        { rel: 'update', href: resourcePath, method: 'PUT' },
        { rel: 'delete', href: resourcePath, method: 'DELETE' },
      ];
    };
    
    return next.handle().pipe(
      map((data) => {
        // 1. 단일 리소스 처리
        if (data && data.Open && data.Code && data.DanmesaCode) {
            const key = { Open: data.Open, Code: data.Code, DanmesaCode: data.DanmesaCode };
            const links = createResourceLinks(key);
            return { data, _links: links };
        } 
        
        // 2. 목록 리소스 처리
        else if (Array.isArray(data)) {
            const listLinks = [
                { rel: 'self', href: basePath, method: 'GET' },
                { rel: 'create', href: basePath, method: 'POST' },
            ];

            const dataWithItemLinks = data.map(item => {
                if (item.Open && item.Code && item.DanmesaCode) {
                    const key = { Open: item.Open, Code: item.Code, DanmesaCode: item.DanmesaCode };
                    // 개별 항목에는 self 링크만 포함
                    return { ...item, _links: [ { rel: 'self', href: `${basePath}/${key.Open}/${key.Code}/${key.DanmesaCode}`, method: 'GET' } ] };
                }
                return item;
            });
            
            return { data: dataWithItemLinks, _links: listLinks } as any;
        }

        return { data, _links: [] };
      }),
    );
  }
}

리플렉터 사용의 이점

  • 견고성: basePath를 하드코딩하거나 요청 URL을 파싱하여 추측할 필요 없이, NestJS 메타데이터 시스템이 보장하는 정확한 컨트롤러 경로를 가져올 수 있습니다.
  • 재사용성 극대화: 이 인터셉터를 다른 이름의 컨트롤러(@Controller('products'), @Controller('users') 등)에 적용해도, basePath가 자동으로 올바르게 설정됩니다.

이로써 HATEOAS 인터셉터가 동적으로 컨트롤러의 기본 경로를 읽어와 어떤 컨트롤러에서든 유연하게 작동할 수 있게 됩니다.


Reflector를 사용하려면 해당 인터셉터를 애플리케이션에 등록할 때 Reflector가 모듈을 통해 제공(provide)되어야 합니다.

일반적으로 NestJS는 코어 서비스(Core Services)인 Reflector를 자동으로 주입할 수 있도록 설정되어 있지만, 모듈 수준에서 인터셉터를 사용하려면 APP_INTERCEPTOR를 통해 등록하거나, 해당 모듈에서 필요한 종속성이 제공되는지 확인해야 합니다.

4. Reflector를 위한 모듈 설정

가장 간단하고 일반적인 방법은 인터셉터를 해당 컨트롤러가 속한 모듈에 등록하고, Reflector를 사용할 수 있도록 NestJS 코어 모듈을 사용하는 것입니다.

A. DanmeCalcModule 설정

DanmeCalcController가 DanmeCalcModule에 속해 있다고 가정하면, 이 모듈에 HATEOAS 인터셉터를 등록하고 Reflector를 사용합니다.

// src/danme-calc/danme-calc.module.ts

import { Module } from '@nestjs/common';
import { DanmeCalcController } from './danme-calc.controller';
import { DanmeCalcService } from './danme-calc.service';
import { HateoasInterceptor } from '../common/interceptors/hateoas.interceptor';
import { APP_INTERCEPTOR } from '@nestjs/core'; // APP_INTERCEPTOR 임포트

@Module({
  imports: [], // 다른 모듈 임포트 (예: PrismaModule 등)
  controllers: [DanmeCalcController],
  providers: [
    DanmeCalcService,
    // (선택 사항) 만약 모든 엔드포인트에 전역적으로 적용하려면:
    /*
    {
      provide: APP_INTERCEPTOR,
      useClass: HateoasInterceptor,
    },
    */
    // 하지만, 우리는 컨트롤러나 메서드에 @UseInterceptors로 개별 적용하므로 
    // 위 코드는 필요하지 않으며, 단지 HateoasInterceptor 클래스만 제공되면 됩니다.
    HateoasInterceptor // 인터셉터 자체를 모듈의 Provider로 등록
  ],
})
export class DanmeCalcModule {}

B. Reflector는 어디서 오는가?

HateoasInterceptor는 생성자에서 Reflector를 주입받고 있습니다.

// HateoasInterceptor.ts
constructor(@Optional() private readonly reflector: Reflector) {} 

Reflector는 NestJS의 코어(Core)에 의해 제공되는 서비스입니다.

  • @Module의 providers에 HateoasInterceptor를 등록하면, NestJS의 DI(Dependency Injection) 시스템이 작동합니다.
  • DI 시스템은 HateoasInterceptor를 인스턴스화하려고 시도할 때 Reflector 종속성을 발견합니다.
  • Reflector는 이미 NestJS 코어 모듈에 의해 애플리케이션 전체에 싱글톤으로 제공되고 있으므로, 별도로 imports에 추가하거나 providers에 등록하지 않아도 자동으로 주입됩니다.

결론:

Reflector를 사용하기 위해 특별히 모듈 파일(.module.ts)을 수정할 필요는 없지만, HateoasInterceptor가 NestJS DI 시스템에 의해 인식되도록 다음 두 가지 중 하나를 수행해야 합니다.

  1. 가장 유연한 방법 (개별 적용): DanmeCalcModule의 providers 배열에 HateoasInterceptor를 추가하고, 컨트롤러 메서드에 @UseInterceptors(HateoasInterceptor)를 명시적으로 적용합니다. (위의 4.A 섹션의 주석 해제된 방식)
  2. 전역 적용: AppModule에서 APP_INTERCEPTOR를 통해 전역적으로 등록합니다. (권장하지 않음, 특정 로직에만 HATEOAS를 적용해야 하므로)

따라서 4.A의 방식으로 인터셉터만 providers에 등록하고, 컨트롤러에 @UseInterceptors를 사용하시면 됩니다.

728x90