
매쉬업 15기 kokkok 프로젝트를 하면서 들었던 고민이 있어 공유하려고 해요.
REST API로 CRUD를 구현할 때 이런 식으로 API를 만드신 적 있으실 것이에요.
GET /api/v1/posts/{postId}
GET /api/v1/comments/{commentId}
kokkok은 모바일 프로젝트니까 postId, commentId 와 같은 PK(Primary Key)가 노출되어도 상관 없나? 라고 생각했던 적이 있었어요.
그런데 카카오톡으로 공유하기 기능을 만들면서 초대 URL을 생성할 때 reservationId 가 URL에 그대로 노출됐어요.
해당 리소스에 접근 가능한 사용자인지 명확히 권한 처리를 할 수 있다면 문제가 없겠지만, 그렇지 않으면 의도치 않은 사용자에게 리소스 정보가 넘어갈 수 있어요.
특히 PK에 AutoIncrement 를 쓴다면 리소스 생성 순서에 따라 순차적으로 PK가 생성되기 때문에 인젝션 공격이나 리소스 개수 추측, 즉 유저가 예약이 DB에 몇 개가 있는지 도 알 수 있다는 문제점도 있었어요.
지금은 프로젝트가 종료되었지만, 이 점이 매우 찜찜했기 때문에 해결법이 있지 않을까? 싶어서 이 블로그 글을 작성하게 되었어요.
첫 번째로 든 생각은 PK가 AutoIncrement 형식이라 문제라면, UUID 를 사용하면 좋지 않을까? 였어요.
분명 무분별한 접근을 막을 수는 있지만 다음과 같은 문제가 있을 것이라고 생각했어요.
저장 공간 낭비
일반적인 BigInt (8 bytes) 보다 UUID (16 bytes) 는 2배 이상의 공간을 차지해요. 만약 문자열로 저장한다면 VARCHAR (36 bytes)는 4배 이상의 공간을 차지해요.
PK는 해당 테이블뿐만 아니라 다른 테이블의 FK(외래키)로서 여기저기 퍼지기 때문에 무시하지 못한다고 생각했어요.
인덱싱 성능 저하
이 부분이 가장 치명적이라고 생각했어요.
MySQL 같은 RDBMS는 보통 PK를 기준으로 정렬해서 저장하는 Clustered Index 방식을 사용해요.
AutoIncrement 는 순차적으로 숫자가 증가하니 데이터가 차곡차곡 쌓이게 돼요.
하지만 UUID(v4) 는 완전 랜덤한 값이에요.
그렇기 때문에 새로운 데이터 삽입 시 인덱스 테이블을 비집고 들어가야 하기 때문에 페이지 분할이 발생, 디스크 I/O가 폭증하게 돼요.
해결방법을 찾기 위해 두 가지를 고려했어요.
1. 클라이언트와는 암호화(string으로)하여 소통하되, 내부 비즈니스 로직은 여전히 숫자(number)로 처리하자.
2. 거창한 외부 라이브러리를 사용하지 말자.
저는 AOP를 지키기 위해 NestJS의 Pipe 와 Decorator 를 사용하여 컨트롤러로 오는 id 를 암호화/복호화 하기로 결정했어요.
NestJS의 Pipe 에 대해 정리한 이전 글도 있답니다.
Controller나 Service 안에서 직접 암/복호화를 진행할 수도 있지만, 비즈니스 로직과 상관없는 영역이라고 생각했기 때문에 해당 암/복호화 로직을 비즈니스 로직과 분리 하고 싶었어요.

진입점(Request)에서는 Pipe 를 사용해 암호화된 ID를 복호화,
반환점(Response)에서는 Interceptor 와 Decorator 를 사용해 ID를 다시 암호화 해보기로 결정했어요.
이제 어떻게 구현했는지 적어볼게요.
구현은 크게 세 단계로 나뉘어요.
1. 암호화 & 복호화를 담당할 CipherService
2. 들어온 ID를 컨트롤러 이전에 가로채 복호화할 DecryptIdPipe
3. 나가는 응답을 가로채서 암호화할 @SecretPk
Node.js 기본 모듈인 crypto 모듈을 사용하여 구현해볼게요.
저는 AES-256-CTR 알고리즘을 사용하여 암호화 서비스를 만들었어요.
전체 코드가 길기 때문에 나눠서 이야기 해볼게요.
@Injectable()
export class CipherService {
private static ALGORITHM = 'aes-256-ctr';
// [1] 데코레이터용 (전역 접근 가능)
private static STATIC_KEY: Buffer;
private static STATIC_IV: Buffer;
// [2] Pipe와 Service용 (인스턴스 접근)
private readonly key: Buffer;
private readonly iv: Buffer;
constructor(private readonly configService: ConfigService) {
// ... (.env 로딩 및 키 생성 로직)
// 인스턴스 변수에 할당 (추후 Pipe에서 사용)
this.key = key;
this.iv = iv;
// Static 변수에도 할당 (Decorator에서 사용)
CipherService.STATIC_KEY = key;
CipherService.STATIC_IV = iv;
}
}
KEY
말 그대로 비밀 키, 암/복호화 시 같은 키를 사용해야해요.
AES-256 알고리즘은 32바이트의 길이를 맞춰야해요.
저는 .env 에 저장된 비밀번호 문자열을 그대로 쓰지 않고, crypto 모듈에 있는 scryptSync 메소드를 통해 salt를 섞어 32바이트의 키로 변환했어요.
IV (Initialization Vector, 초기화 벡터)
암호화의 '출발점'을 정해주는 값이에요. (데이터의 무작위성을 보장하는 salt 역할)
보통은 보안을 위해 매번 다른 IV를 생성해야 하지만, 우리는 입력값이 같으면 결과값이 항상 같아야 하기 때문에 고정된 IV를 .env에 저장하여 사용했어요.
왜?
만약 유저 1번(id: 1)의 암호화된 ID가 조회할 때마다 바뀐다면
사용자가 URL을 공유했는데 내일 접속하니 없는 페이지라고 뜨는 문제가 생길거에요.
그런데, 코드를 보면 KEY와 IV가 Instance 변수, Static 변수 이렇게 두 쌍으로 있는 것을 볼 수 있어요.
이는 NestJS의 의존성 주입(DI) 시스템과 데코레이터(Decorator) 모두 사용하기 위함이에요.
우리가 사용할 Pipe는 NestJS 컨테이너 안에서 동작하기 때문에 CipherService 를 주입받아 this.key (인스턴스 변수)를 사용할 수 있어요.
하지만, 나중에 응답을 암호화할 @SecretPk 데코레이터는 NestJS가 서비스를 생성하기도 전에, 혹은 컨테이너 외부에서 작동하기 때문에 this 에 접근할 수 없어요.
즉, 의존성 주입을 받을 수 없어요.
그래서 생성자가 실행되는 시점, 즉 앱이 실행되고 키가 생성되는 그 순간에 Static 변수에도 키를 복사했어요.
this.key 사용CipherService.STATIC_KEY 사용변수 세팅이 끝났으니, 이제 실제로 암호화를 수행하는 메서드들을 살펴볼게요.
/**
* [1] 인스턴스 메서드 (Pipe용)
* NestJS가 주입해준 인스턴스 변수(this.key)를 사용합니다.
*/
encrypt(id: number): string {
return CipherService.performEncrypt(id, this.key, this.iv);
}
decrypt(encryptedId: string): number {
return CipherService.performDecrypt(encryptedId, this.key, this.iv);
}
/**
* [2] 정적 메서드 (Decorator용)
* 인스턴스 없이도 호출 가능하며, Static 변수를 사용합니다.
*/
static encryptStatic(id: number): string {
// 만약 Static Key가 없으면 직접 .env에서 로딩
if (!this.STATIC_KEY) this.loadEnvDirectly();
return this.performEncrypt(id, this.STATIC_KEY, this.STATIC_IV);
}
/**
* [3] 핵심 로직
* 인스턴스 메서드와 정적 메서드가 공통으로 사용하는 실제 로직
* 중복 코드를 방지하기 위해 별도의 private static 메서드로 분리.
*/
// 암호화: 숫자 -> 16진수 문자열
private static performEncrypt(id: number, key: Buffer, iv: Buffer): string {
const cipher = createCipheriv(this.ALGORITHM, key, iv);
// 숫자를 문자로 바꾼 뒤, 16진수 문자열로 변환하여 URL에 적합하게 만듭니다.
let encrypted = cipher.update(id.toString(), 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
}
// 복호화: 16진수 문자열 -> 숫자
private static performDecrypt(
encryptedId: string,
key: Buffer,
iv: Buffer,
): number {
try {
const decipher = createDecipheriv(this.ALGORITHM, key, iv);
let decrypted = decipher.update(encryptedId, 'hex', 'utf8');
decrypted += decipher.final('utf8');
const result = parseInt(decrypted, 10);
// 복호화 결과가 숫자가 아니면 에러 처리
if (isNaN(result)) throw new Error();
return result;
} catch (error) {
console.error('복호화 실패 원인 :', error.message);
// 사용자가 직접 조작하여 잘못된 URL이 들어온 경우
throw new Error('잘못된 URL입니다');
}
}
// 혹시라도 Static Key가 없을 때(테스트 등) process.env에서 직접 로드
private static loadEnvDirectly() {
const password = process.env.SECRET_PK_KEY || 'fallback';
const salt = process.env.SECRET_PK_SALT || 'salt';
this.STATIC_KEY = scryptSync(password, salt, 32);
this.STATIC_IV = Buffer.from(
process.env.SECRET_PK_IV || '0123456789012345',
);
}
3가지를 이야기 해볼게요.
encrypt()와 encryptStatic()은 하는 일은 같지만, 누가 호출하는지에 따라 나누어서 구현했어요.
encrypt(): Pipe처럼 DI가 가능한 곳에서 this.key를 넣어 호출.encryptStatic(): Decorator처럼 DI가 불가능한 곳에서 STATIC_KEY를 넣어 호출.performEncrypt 와 performDecrypt 라는 private static 메서드를 만들어 실제로 crypto 모듈을 사용하는 로직을 한곳에 모았어요.
이 덕분에 알고리즘을 변경하거나 로직을 수정할 때 이 곳만 고치면 돼요.
performDecrypt 에는 try-catch 를 넣어 누군가 URL의 ID를 악의적으로 조작해서 보낼 경우, 복호화 과정에서 에러가 발생하거나 숫자가 아닌 엉뚱한 값이 나올 수 있어요.
이때 서버가 죽지 않고 에러를 명확히 던지도록 구현했어요.
클라이언트에서 보낸 암호화된 ID(string)을 내부 비즈니스 로직에서 사용할 number로 바꿀 Pipe에요.
// src/common/decrypt-id.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { CipherService } from './cipher.service';
@Injectable()
export class DecryptIdPipe implements PipeTransform {
// 아까 만든 CipherService를 주입 받아요 (DI)
// Pipe는 NestJS 컨테이너 내부라서 this.key(인스턴스)를 쓸 수 있어요.
constructor(private readonly cipherService: CipherService) {}
transform(value: string): number {
try {
// 암호문이 들어오면 숫자로 변환합니다.
return this.cipherService.decrypt(value);
} catch (error) {
// CipherService에서 '잘못된 URL입니다' 에러가 넘어오면
// 여기서 400 Bad Request 에러로 변환해서 클라이언트에게 알려줍니다.
throw new BadRequestException('잘못된 ID 형식입니다.');
}
}
}
이 Pipe는 Controller에서 다음과 같이 적용할 수 있어요.
@Get(':id')
// URL: /api/v1/sample/get/32eaa... (string)
findOne(@Param('id', DecryptIdPipe) id: number) {
// 파이프를 통과한 id는 이제 number 에요.
console.log(typeof id); // 'number'
return this.sampleService.findOne(id);
}
이제 반대로 DB에서 꺼낸 정보를 내보낼 때 ID를 암호화하는 데코레이터를 만들어야해요.
import { Transform } from 'class-transformer';
import { CipherService } from './cipher.service';
export function SecretPk() {
return Transform(({ value }) => {
// 값이 숫자인 경우에만 암호화 수행
if (typeof value === 'number') {
// 데코레이터는 DI를 받을 수 없으니, 정적 메서드를 사용
return CipherService.encryptStatic(value);
}
// 숫자가 아니면 건들지 않음
return value;
});
}
Decorator는 컨테이너 외부에서 작동하기 때문에 미리 복사해둔 STATIC_KEY를 사용하는 정적 메서드를 호출했어요.
만든 데코레이터를 응답 DTO에 다음과 같이 붙였어요.
실제 프로젝트였다면 Swagger의 @ApiProperty({ type: 'string' }) 등을 활용해 id가 string으로 오간다고 명시해주면 더 좋을 것 같아요.
// src/dto/sample-response.dto.ts
import { Exclude, Expose } from 'class-transformer';
import { SecretPk } from '../common/secret-pk.decorator';
@Exclude()
export class SampleResponseDto {
@Expose()
@SecretPk() // 응답으로 나갈 때 자동으로 암호화
id: number | string;
@Expose()
email: string;
@Expose()
nickname: string;
constructor(partial: Partial<SampleResponseDto>) {
Object.assign(this, partial);
}
}
NestJS가 응답을 내보낼 때 이 데코레이터를 읽어서 실행시키게끔 하려면
ClassSerializerInterceptor를 등록해야해요.
등록하지 않으면 @SecretPk 는 무시돼요. main.ts 에 전역으로 등록해볼게요.
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 응답 직렬화 인터셉터 등록
// 이 줄이 있어야 @Expose 같은 데코레이터가 동작해요
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
// 파이프 설정
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.listen(3000);
}
bootstrap();
저는 Controller를 다음과 같이 작성했어요.
@Controller('api/v1/sample')
export class AppController {
// 가상의 DB
private users = [
{ id: 1, email: 'user1@email.com', nickname: 'NestMaster' },
{ id: 100, email: 'user2@email.com', nickname: 'MashUp-Crew' },
{ id: 123456, email: 'user3@gmail.com', nickname: 'MashUp-Master' },
];
// 전체 유저 목록 조회
@Get()
findAll(): SampleResponseDto[] {
return this.users.map((user) => new SampleResponseDto(user));
}
// 유저 생성
@Post()
create(@Body() body: CreateSampleDto): SampleResponseDto {
// 다음 ID 생성 (Auto Increment)
// 현재 유저 중 가장 큰 ID를 찾아서 1을 더합니다. 유저가 없으면 1번부터 시작.
const nextId =
this.users.length > 0 ? Math.max(...this.users.map((u) => u.id)) + 1 : 1;
// 새 유저 객체 생성
const newUser = {
id: nextId,
email: body.email,
nickname: body.nickname,
};
// 가상 DB에 저장
this.users.push(newUser);
console.log(`[서버 로그] 새 유저 생성됨: ID ${nextId} (${body.nickname})`);
// 응답
return new SampleResponseDto(newUser);
}
// 상세 조회
@Get('get/:id')
getOne(@Param('id', DecryptIdPipe) id: number | string): SampleResponseDto {
const numericId = id as number;
console.log(`[서버 로그] 요청한 ID(복호화됨): ${numericId}`);
const foundUser = this.users.find((u) => u.id === numericId);
if (!foundUser) {
throw new NotFoundException(
`ID가 ${numericId}인 유저를 찾을 수 없습니다.`,
);
}
return new SampleResponseDto(foundUser);
}
}

요청 결과 분명 id가 db에는 1,100, 123456 으로 되어있지만, 암호화되어 응답된 것을 볼 수 있어요.

새로운 유저가 생성되고, ID가 암호화되어 나왔어요.
이 ID가 과연 로그처럼AutoIncrement 로직에 맞춰 123457 로 만들어졌을까요?

무사히 ID가 123457 로 생성되었고, 응답 또한 암호화되어 나온 것을 확인할 수 있어요.
NestJS로 프로젝트하면서 PK를 숨기고 싶었는데, 이렇게 구축해보니 Pipe 와 Decorator , Interceptor 에 대해 이해도가 조금 높아진 느낌이 들었어요.
앞으로 프로젝트할 때 필요하다면 해당 기능을 적극적으로 사용할 예정이에요.
도움이 되셨으면 좋겠습니다. 읽어주셔서 감사합니다!