회사의 MVP로 API 서버 구축을 담당하게 되었습니다. 프론트엔드 팀 내에서 이번 MVP를 담당하게 되다보니 그렇게 되었습니다. 얼떨결에 백엔드 경험이 있던 제가 API 서버를 전담하여 개발하게 되었는데요, 서버를 설계하고 개발하면서 고민했던 여섯 가지 지점들을 복기해보면서 개발 후기를 남겨보려 합니다.
이전 회사에서 goLang기반의 API서버를 개발/운영해본 경험(내장 go/http 기반), Express 기반의 BFF를 구축해본 경험이 있어 백엔드 구조와 아키텍처를 이해하는 데 큰 부담이 없었던 것은 사실입니다. 걱정이 되었던 부분은 길지 않은 시간(한 스프린트, 약 2주) 내에 개발을 끝마쳐야 했고, 사용해보지 않았던 기술 스택들을 사용해야 한다는 부담감이었습니다. 특히 레퍼런스가 많지 않던 AWS App Runner를 통해 서비스를 배포하기로 결정하면서, CI/CD와 VPC(사내에서는 사설망을 사용하고 있습니다, S3 및 RDS 프라이빗 서브넷에 구성 필요)까지 설정해줘야 한다는 점들이 부담으로 다가왔습니다.
팀 내에서 기술들을 검토하고 모두가 동의한 Nest.js, TypeORM, App Runner이었지만 실제 개발하면서 마주한 이들은 첫인상과 비슷하면서도 달랐습니다. TypeORM과 App Runner는 별도의 포스팅에서 톺아볼 것이기 때문에 여기서는 Nest.js만 살펴보려 합니다.
Nest.js는 생각한 것처럼 생산성과 모듈화 측면에서 Express보다 유리했습니다. 우려했던 복잡한 기능이나 확장에 신속하게 대응하지 못할 것 같다는 단점은 생각했던 것보다 큰 문제가 되지 않았습니다. Nest.js에서 인박스로 제공하는 내장 라이브러리들이 탄탄했고 다양했기 때문이었습니다. 덕분에 스케줄링, 캐시(캐시 매니저), 스웨거 등을 별도의 외부 라이브러리 없이 간단하게 구현할 수 있었습니다. 오히려 Nest.js의 단점이라 느껴졌던 부분은 Airbnb 린트와의 상성이었습니다. 클래스와 데코레이터를 주로 사용하는 Nest.js의 개발 방식이 AirBnb의 린트 규칙과 빈번하게 충돌하여 많은 규칙들을 off
로 돌릴 수 밖에 없었습니다.
// 이 모든 규칙들을 off나 warn으로 돌려야 했습니다.
'no-useless-constructor': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-return-await': 'off',
'comma-dangle': 'off',
'no-empty-function': 'off',
'class-methods-use-this': 'off',
'no-console': 'warn',
'object-curly-newline': 'off',
'prefer-template': 'off',
'operator-linebreak': 'off',
'implicit-arrow-linebreak': 'off',
'dot-notation': 'off',
'no-await-in-loop': 'off',
'no-restricted-syntax': 'off',
quotes: 'off',
indent: 'off'
Nest.js에서 제공하는 다큐먼트를 읽으면서 Nest.js의 핵심은 모듈 시스템에 있다는 생각이 들었습니다. Go, Express 기반의 서버에서는 네이밍을 통해 기능 범위를 한정시킬뿐, 별도의 시스템을 통해서 기능 범위를 한정시키는 구조는 존재하지 않습니다. 이에 반해 Nest.js는 모듈 시스템을 통해 기능의 범위를 한정하는 동시에 서비스의 복잡도를 낮추는 전략을 취하고 있었습니다. (이러한 점은 Bean
을 통해 컴포넌트들을 관리하는 Spring 서버와 유사한 지점 같네요)
일반적으로 모듈이라고 하면 하나의 작업을 처리하기 위해 여러 컴포넌트들을 묶어놓은 것으로 말할 수 있습니다. Nest.js에서의 모듈 시스템도 이와 크게 다르지 않았고, 필자는 이에 덧붙여 하나의 마이크로서비스로 분리할 수 있는 기능의 범위로 모듈을 이해했습니다.
개발하게 될 API 서버는 크게 두 기능을 가지고 있다고 정리했습니다.
(1) 상품에 대한 정보를 처리하는 기능
(2) 상품의 이미지를 처리하고 조작하는 기능
따라서 필자는 ProductModule
, ImageModule
로 모듈을 분리하였습니다.
각 모듈 안에서는 Controller
, Service
, Repository
, Entity
의 독립적인 4중 레이어를 두어 로직들을 분리하였습니다. products.module.ts
내에서는 기능을 더 세분화하여 여러 개의 컨트롤러와 프로바이더를 두었습니다. 즉, 카테고리와 관련된 정보는 categories.controller.ts
에서, 키워드에 관련된 정보는 keywords.controller.ts
에서, 식품과 즉결된 정보는 foods.controller.ts
에서 담당하도록 구성하였습니다. 현재 레포지토리 레이어는 컨트롤러의 분류에 따라 3가지의 레이어(categories
, foods
, keywords
)로 구성해 놓았는데, 11개의 엔터티와 일대일 대응되도록 수정해두려 합니다.
# 서비스의 구조
public
src
├── configs
│ ├── ssh
│ ├── swagger
│ └── typeorm
├── models
│ ├── dtos
│ ├── entities
│ └── tables
├── modules
│ ├── products
│ │ ├── controllers
│ │ │ ├── categories.controller.ts
│ │ │ ├── foods.controller.ts
│ │ │ ├── keywords.controller.ts
│ │ │ ├── categories.controller.spec.ts // 테스트 코드
│ │ │ ├── foods.controller.spec.ts // 테스트 코드
│ │ │ └── keywords.controller.spec.ts // 테스트 코드
│ │ ├── dtos
│ │ ├── entities
│ │ ├── repositories
│ │ ├── services
│ │ ├── typings
│ │ ├── utils
│ │ └── products.module.ts
│ └── ...
├── decorators
├── filters
├── guards
├── middlewares
├── pipes
├── guards
├── typings
├── utils
├── app.module.ts
└── main.ts
리퀘스트의 헤더를 바탕으로 외부의 API를 호출하여 인가와 인증을 수행하여야 했습니다(JWT를 통한 인가와 인증은 기존재하는 별도의 인증 서버를 통해 수행해야 했습니다). 따라서 엔드포인트로 진입하는 플로우의 제일 앞단에서 이를 수행해주고, 권한이 없는 경우 에러(401 또는 403)를 리턴할 수 있도록 개발하여야 했습니다.
다만 일부 API(식품의 상세 정보를 호출하는 경우)에는 인증만 수행하여 사용자의 정보만을 얻어야 했고, 일부 API(식품에 좋아요 표식을 넘기는 경우)에는 인가까지 수행하여 허가되지 않은 클라이언트의 리소스 접근과 수정을 차단해야 했습니다. 따라서 인증만 수행하는 경우에는 미들웨어를 통과하도록, 인가까지 수행해야하는 경우에는 가드를 통과하도록 로직을 개발하였습니다.
// 인가 가드
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Inject(HttpService) private readonly httpService,
) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return this.validateRequest(request);
}
private async validateRequest(request: any) {
if (헤더에 적절한 값이 없는 경우) {
throw new BaseException({
statusCode: HttpStatus.BAD_REQUEST,
detail: '헤더에 적절한 값이 들어있지 않습니다.'
});
}
try {
/* 외부 인증 API 호출 */
} catch (err) {
throw new BaseException({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
detail: err.response.data.message
});
}
if (Unauthorized한 경우) {
this.logger.error('UNAUTHORIZED');
throw new BaseException({
statusCode: HttpStatus.UNAUTHORIZED,
detail: 'unAuthorized 에러를 리턴한 이유 서술'
});
}
if (Forbidden인 경우) {
this.logger.error('FORBIDDEN');
throw new BaseException({
statusCode: HttpStatus.FORBIDDEN,
detail: 'forbidden 에러를 리턴한 이유 서술'
});
}
// 인증정보 전달
request.user = { /* 인증 정보 전달 */ };
return true;
}
}
미들웨어도 유사한 로직을 통과하도록 구성하였습니다. 다만 가드의 경우에는 인가를 수행하여야 하므로 일부 경우에 에러를 throw하는 반면, 미들웨어에서는 권한이 없는 경우 next()
를 통해 다음 미들웨어를 호출하고 request
객체를 인증 정보를 추가하지 않도록 구성해주었습니다.
인증/인가 관련 미들웨어와 가드를 통과하는 엔드포인트는 한정되어 있기에, 해당 엔드포인트에 데코레이터를 별도로 주었습니다.
// foods.controller.ts
@Controller({
path: 'foods',
version: '1',
})
export class FoodsController {
@UseGuards(AuthGuard)
식품상세정보 가져오기() { ... }
}
RFC7807에 따르면 에러(익셉션) 객체에도 일종의 규칙이 있어야 한다고 합니다(참고). 해당 규칙을 준수하며 에러 발생시 아래와 같은 익셉션을 클라이언트에 넘겨줄 수 있도록 커스텀 에러 객체(BaseException
)를 생성하였습니다.
Error: Not Found(404)
Response body
{
"type": "https://datatracker.ietf.org/doc/html/rfc2616#section-10.4.5",
"title": "해당 리소스가 없습니다.",
"detail": "존재하지 않는 foodId입니다."
}
클라이언트에 에러가 발생하였음을 알려주어야 하는 경우 아래와 같이 BaseException
을 선언하여 throw해주었습니다.
// foods.service.ts 중 일부
if (err.code === 'ER_NO_REFERENCED_ROW_2') {
throw new BaseException({
statusCode: HttpStatus.NOT_FOUND,
detail: '존재하지 않는 foodId입니다.'
});
}
단순히 에러를 throw해주는 것으로 클라이언트에 에러 객체를 넘길 수 있었던 이유는 Nest.js에서 제공하는 익셉션 필터(Exception Filters) 덕분이었습니다. 다만 익셉션 필터가 커스텀 에러 객체인 BaseException
형태의 객체를 클리언트에 넘겨야 했기 때문에 기본 ExceptionFilter
객체를 확장한 커스텀 익셉션 필터 ExceptionsFilter
를 생성하여 이를 서비스의 필터로 사용하였습니다.
@Catch()
export class ExceptionsFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
if (exception instanceof BaseException) {
response.status(exception.getStatus()).json(exception.getResponse());
return;
}
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
type: ExceptionType[HttpStatus.INTERNAL_SERVER_ERROR].type,
title: ExceptionType[HttpStatus.INTERNAL_SERVER_ERROR].title,
detail: exception?.message || '서버에서 에러가 발생하였습니다.'
});
}
}
이를 통해 제어하기 어려운 TypeORM과 데이터베이스에서 발생하는 에러들을 동일한 객체로 처리하고, 익셉션 필터를 통해 클라이언트에 전달할 수 있었습니다.
상품의 검색 키워드, 카테고리 등에서 optional한 파라미터(쿼리)를 날려주고 있었는데, Nest.js에서 제공하는 내장 파이프와 이를 확장한 커스텀 파이프를 활용하여 유효성 검사를 수행하였습니다.
일례로, 상품의 인기도순 정렬을 위해서는 식품 리스트 API를 호출하며 OrderBy
파라미터에 POPULAR
키워드를 함께 날려주는 것으로 설계하였습니다.
서버에서는 클라이언트가 전달한 요청에
(1) OrderBy
파라미터가 존재하는지 판단하고,
(2) OrderBy
파라미터의 값이 적절한지 판단하며,
(3) OrderBy
파라미터의 값인 스트링(string
) 타입을 서버에서 관리하는 OrderBy
Enum
타입으로 형변환 시켜주어야했습니다.
이 모든 작업을 ParseOrderByOrNull
커스텀 파이프를 통해 수행하도록 각 엔드포인트에 파이프를 달아주었습니다. 유효하지 않은 파라미터로 판단되면, BaseException
을 통해 클라이언트에 에러를 전달해주었습니다.
@Injectable()
class ParseOrderByOrNullPipe implements PipeTransform<string | null, OrderBy> {
transform(value: string | null, metadata: ArgumentMetadata) {
if (!value) return null;
if (this.isValid(value)) {
return value as OrderBy;
}
throw new BaseException({
statusCode: HttpStatus.BAD_REQUEST,
detail: '전달받은 OrderBy 값이 유효하지 않습니다.'
});
}
private isValid(value: string) {
return Object.values(OrderBy).includes(value as OrderBy);
}
}
export default ParseOrderByOrNullPipe;
이 외에도 Nest.js에서 제공하는 DefaultValuePipe
를 통해 null
로 넘어온 파라미터나 쿼리의 값에 기본값을 설정하는 등(OrderDirection
이 null
인 경우 기본적으로 오름차순-ASC
로 처리하도록 파이프를 추가하였음) 여러 파이프를 활용하여 리퀘스트 파라미터(쿼리)를 검증/선처리 해주었습니다.
앞서 살펴본 가드와 미들웨어에서는 외부 API를 호출하여 사용자를 인증하고 있었습니다. 사용자의 정보를 확보하기 위해서 외부 API를 호출하여야 했으므로 성능과 비용 측면에서 최적화가 필요했습니다. 특히 개발하는 서비스의 특성상 짧은 시간 내에 다수의 식품 상세보기 API와 좋아요 API가 발생할 수 있기 때문에, API를 호출할 때마다 외부 API를 호출하는 것은 성능 측면에서 효율적이지 않다고 판단할 수 밖에 없는 상황이었습니다. 더욱이 배포되는 서비스는 AWS의 VPC 내부의 private 서브넷에 존재하고, 외부 API를 요청하려면 건당 비용이 발생하는 NAT 게이트웨이의 설정이 필수적이었기 때문에 비용 절감을 위해서라도 사용자 정보를 캐싱해두어야 했습니다. 이를 위해 Redis
등의 외부 캐시 매니저도 검토하였으나, 우선 작은 규모의 @nest/cache-manager
을 사용해보기로 결정하였습니다.
인증/인가 미들웨어와 가드에 아래와 같이 로직을 추가해주었습니다. 리퀘스트 헤더로 넘겨받은 accesstoken
의 값으로 캐싱된 데이터가 서버의 캐시 매니저에 있는 경우에는 해당 데이터를 사용할 수 있도록 설정한 것입니다.
// auth.guard.ts 중
try {
const memberInfoCached = await this.cacheManager.get(accesstoken);
if (memberInfoCached) {
this.logger.log(`AccessToken From Data Cached`);
/* 캐시된 데이터를 사용 */
} else {
this.logger.log(`AccessToken From External API`);
/* 외부 인증 API 호출 */
}
}
...
app.module.ts
에는 캐시 매니저에 대한 설정을 추가해주었습니다. 서버가 캐싱된 정보를 들고 있는 것도 서버의 부담을 가중시키는 것이기에, 적절한 타협점을 찾아 캐싱될 데이터의 개수(max
)와 유효 시간(ttl
)을 설정해주었습니다.
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync(TypeOrmModuleOptions),
ThrottlerModule.forRoot([
{
ttl: 60,
limit: 10
}
]),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public')
}),
CacheModule.register({
ttl: 10 * 60 * 1000, // 10분동안 유지
max: 50, // 최대 50개의 데이터만 들고 있도록 유지
isGlobal: true
}),
HttpModule,
LoggersModule,
ProductsModule,
ImagesModule
],
controllers: [],
providers: []
})
Nest.js에서 제공하는 캐시 매니저를 통해 캐싱 구현에 대한 부담없이 빠르게 개발을 완료할 수 있었습니다.
해당 지점 외에도
- App Runner의 로깅을 활용하기 위해 별도의 커스텀 로깅 프로바이더를 생성한 점
- TypeORM을 활용하여 데이터를 처리하며 다층적인 서브쿼리과 조인 환경에서 페이징 처리를 구현하며 다양한 문제들과 마주한 점
- Sharp 라이브러리를 통해 이미지의 크기를 조절하는 등의 이미지 프로바이더 엔드포인트를 생성한 점
- 내장 스웨거 라이브러리를 통해 스웨거 문서도 빠르게 생성한 점
- 내장 라이브러리를 통해 테스트 코드를 생성한 점
등 다양한 지점에서 문제들을 만나고 해결하였습니다. 여유가 되면 나머지 지점들에 대한 생각들도 글로 정리해보려 합니다.
비록 6개의 API로 구성된 작은 API 서버이지만, 경험하지 못했던 기술 스택이라는 부담감 속에서 고생하며 구현해냈다는 점에서 뿌듯한 경험으로 남을 것 같습니다.
해당 API서버를 활용한 [테이블즈] 서비스 보러가기 👉 https://thetables.co.kr/