
컨트롤러 (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);
}
}
이 방식의 장점은 다음과 같습니다:
- 관심사 분리: 컨트롤러는 데이터 처리 및 서비스 호출에만 집중하고, HATEOAS 링크 주입은 인터셉터가 담당합니다.
- 재사용성: 다른 컨트롤러에서도 @UseInterceptors(HateoasInterceptor)만 추가하여 쉽게 HATEOAS를 적용할 수 있습니다.
- 유지보수: 링크 구조나 경로가 변경될 경우, 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 시스템에 의해 인식되도록 다음 두 가지 중 하나를 수행해야 합니다.
- 가장 유연한 방법 (개별 적용): DanmeCalcModule의 providers 배열에 HateoasInterceptor를 추가하고, 컨트롤러 메서드에 @UseInterceptors(HateoasInterceptor)를 명시적으로 적용합니다. (위의 4.A 섹션의 주석 해제된 방식)
- 전역 적용: AppModule에서 APP_INTERCEPTOR를 통해 전역적으로 등록합니다. (권장하지 않음, 특정 로직에만 HATEOAS를 적용해야 하므로)
따라서 4.A의 방식으로 인터셉터만 providers에 등록하고, 컨트롤러에 @UseInterceptors를 사용하시면 됩니다.
'Nest.js를 배워보자' 카테고리의 다른 글
| 🏛️NestJS Prisma CRUD 모듈을 만들어보자!! (0) | 2025.12.10 |
|---|---|
| 🚀 prisma db push는 무엇인가요? (0) | 2025.12.09 |
| 🔄 MySQL CREATE TABLE 문으로 Prisma 스키마 만들기 (0) | 2025.12.09 |
| 🛠️ NestJS Prisma 6에서 자료가 없을 때 Seed 데이터 자동 추가하기 (0) | 2025.12.09 |
| 🛠️ NestJS + Prisma에서 MyBatis처럼 XML로 SQL 소스를 관리하는 방법 (0) | 2025.12.09 |