아래 링크를 클릭하면 각 단계에 맞는 깃허브 리포지토리로 이동합니다 🚀
서버 개발에서 에러를 핸들링할 때 항상 고민하던 것이 있습니다.
고민의 유형은 아래와 같습니다.
이런 고민을 따라가며 해결책을 찾기 위해 다양한 방법을 찾아보았고, 우연히 Spring Guide - Exception 전략 글에서 영감을 얻어 포스트를 작성하게 되었습니다.
(이 글을 만난 건 정말 행운이었어요🍀)
우선 예외 필터가 필요한 이유부터 알아보아요!
예외 필터가 없다면, 런타임시 서버는 적절하게 예외를 처리할 수 없고 응답을 포맷팅되지 않은 형태로 출력하거나 예외 로그를 출력하는 과정에서 민감한 정보를 외부에 노출할 수도 있습니다.
NestJS에서 소개하고 있는 요청(request)의 생명 주기는 아래와 같습니다.
우리가 어떤 처리를 하지 않더라도 NestJS에는 내장 필터가 있으며, 핸들링하지 않는 예외는 ‘19번(Exception filters)’에서 JSON 타입으로 변환되어 출력됩니다. 아래처럼요!
{
"statusCode": 500,
"message": "Internal server error"
}
아래에서 소개된 전략 1, 전략 2와 솔루션은 모두 예외 필터를 사용합니다. 결과는 적절한 HTTP 에러를 응답하는 거지만 어느 계층에서 어떻게 예외를 처리하는지에 차이가 있다는 점이 중요합니다.
유저 서비스가 있고 유저 컨트롤러가 있다고 가정합시다.
우리가 주목해야 할 부분은 3-b 입니다.
우선 기본적인 코드 구조는 아래와 같습니다.
// src/users/users.controller.ts
@Controller('users')
export class UsersController {
constructor(private userService: UsersService) {}
@Get('/:id')
public async findUser(@Param('id') id: string) {
return await this.userService.findUser(+id);
}
...
}
// src/users/users.service.ts
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
...
public async findUser(id: number) {
const user = await this.userRepository.findOneBy({ id });
return new ReadUserBasicDto(user);
}
}
// src/users/dto/read-user.dto.ts
export class ReadUserBasicDto {
id: number;
username: string;
constructor(user: User) {
this.id = user.id;
this.username = user.username;
Object.seal(this);
}
}
전략 1과 전략 2 그리고 솔루션에 대한 요약은 아래와 같습니다.
[전략 1] 서비스에서 HTTP 예외 throw | [전략 2] 서비스에서 에러를 메시지와 함께 throw 컨트롤러에서 HTTP 예외로 변환, throw | 솔루션 | |
---|---|---|---|
결합도 | 높음 | 낮음 | 낮음 |
단일 책임 원칙 준수 (Single Responsibility Principle) | X | O | O |
복잡성 | 낮음 | 높음 | 중간 (예외 처리 많을수록 이점 많음) |
전략1부터 살펴볼까요?
아마 가장 간단하게 HTTP 예외를 던질 수 있는 방법이 아닐까 싶습니다.
user가 존재하지 않는다면 HTTP 404 예외를 발생시키는 로직을 UsersService 클래스의 findUser 메서드 안에 집어넣기만 하면 됩니다!
// src/users/users.service.ts
@Injectable()
export class UsersService {
...
public async findUser(id: number) {
const user = await this.userRepository.findOneBy({ id });
// 해당 id 값의 user가 존재하지 않는다면
// HTTP 404 Exception throw
if (!user) {
throw new NotFoundException('user not found');
}
return new ReadUserBasicDto(user);
}
}
이 전략은 가장 단순하다는 장점이 있지만, 컨트롤러 계층에서 해야 할 일을 서비스 계층이 함으로써 컨트롤러가 서비스에 의존하는데 서비스는 컨트롤러에 종속된 이상한 관계가 형성됩니다.
보통은 컨트롤러가 서비스에 의존하고 서비스는 리포지토리에 의존하는 단방향 관계가 형성돼야 각 레이어의 구현체가 변경되더라도 쉽게 구현체를 교체할 수 있는 구조가 되기 때문에 이상한 관계라고 표현했습니다.
예를 들어 컨트롤러가 지금은 HTTP 프로토콜을 사용하고 있지만, 다른 프로토콜(gRPC, 웹소켓, MQTT, AMQP 등)을 사용하면 서비스에 작성된 HTTP 예외 처리 로직은 전부 수정해야 합니다.
따라서, 전략 1은 결합도가 높은 예외 처리 방식이라고 할 수 있습니다.
두번째 전략은 SRP 원칙을 준수하고 결합도가 낮아진다는 장점이 있지만, 그 외 모든 것이 단점인 전략입니다.
저 같으면 이 전략 쓸 바에는 전략 1을 쓰는 게 나아보이네요😂
코드를 보면서 그 이유를 파악해봅시다.
우선 서비스 로직에서는 user가 없을 경우 “user not found”라는 메시지와 함께 Error를 던집니다.
// src/users/users.service.ts
@Injectable()
export class UsersService {
...
public async findUser(id: number) {
const user = await this.userRepository.findOneBy({ id });
if (!user) {
throw new Error('user not found');
}
return new UserBasicInfoDto(user);
}
}
그 다음 서비스의 findUser를 호출한 컨트롤러는 에러 객체를 HTTP 예외로 변환해서 throw해야 ExceptionFilter가 제대로 처리됩니다. 에러 객체를 제대로 변환하지 않는다면 500 에러(사실상 서버)가 터지니까요.
아래 컨트롤러의 findUser는 에러(e)를 캐치해서 에러 메시지가 “user not found”인지 확인하고 맞다면 NotFoundException을 throw하고 있습니다.
// src/users/users.controller.ts
@Controller('users')
export class UsersController {
...
@Get('/:id')
public async findUser(@Param('id') id: string) {
try {
return await this.userService.findUser(+id);
} catch (e) {
if (e.message === 'user not found') {
throw new NotFoundException(e.message);
}
}
}
...
}
뭐가 문제인 걸까요?
// src/users/users.controller.ts
@Controller('users')
export class UsersController {
...
@Get('/:id')
public async findUser(@Param('id') id: string) {
try {
return await this.userService.findUser(+id);
} catch (e) {
if (e.message === 'user not found') {
throw new NotFoundException(e.message);
} /*
else if (e.message=== '다른 에러 메시지 1'){
throw new 다른예외1(e.message);
} else if (e.message=== '다른 에러 메시지 2'){
throw new 다른예외2(e.message);
} else if (e.message=== '다른 에러 메시지 3'){
throw new 다른예외3(e.message);
} else{
throw new 다른예외4(e.message);
*/
}
}
...
}
그렇다면 SRP 원칙을 지켜 결합도를 낮춘 아키텍처를 가져가면서도 전략 2의 단점을 보완할 수 있는 해결법은 뭘까요?
우리가 만들 커스텀 예외 필터(ServiceExceptionToHttpExceptionFilter)는 아래와 같이 동작합니다.
다음은 ServiceException의 상속 다이어그램입니다. Error 객체를 상속받아야 예외를 던질 수 있기 때문에 상속받아 클래스를 만들었습니다.
에러 코드는 서비스 레이어에서 던질 예외에 사용될 예정이니 프로토콜에 맞는 응답 코드와 적절한 디폴트 메시지를 작성해주시면 되겠습니다~!
// src/common/exception/error-code/error.code.ts
class ErrorCodeVo {
readonly status;
readonly message;
constructor(status, message) {
this.status = status;
this.message = message;
}
}
export type ErrorCode = ErrorCodeVo;
// 아래에 에러코드 값 객체를 생성
// Create an error code instance below.
export const ENTITY_NOT_FOUND = new ErrorCodeVo(404, 'Entity Not Found');
위에서 ErrorCodeVo 클래스를 export하면 타입 체킹도 될텐데 왜 그 타입을 굳이 ErrorCode로 따로 선언했는지 궁금하실 수 있습니다.
그 이유는 ErrorCodeVo 클래스의 생성자를 통해서 값 객체 인스턴스를 생성할 수 있는 범위를 error.code.ts 파일 범위 내로 한정하고 싶었기 때문입니다.
이렇게 범위를 한정하면 모든 에러 코드를 한 파일 내에서 관리할 수 있습니다.
같은 용도로 사용되는 에러 코드를
하는 등의 불상사를 막을 수 있다 이 말이죠~! 😎
이제 error-code를 모듈화해서 내보낼 index 파일을 작성합니다.
// src/common/exception/error-code/index.ts
export * from './error.code';
Error 클래스를 상속받은 ServiceException 클래스를 만들었습니다.
이 ServiceException 클래스의 역할은
만약 인스턴스 생성 메서드가 아니라 하위 클래스(subClass) 형식으로 ServiceException을 상속받은 예외 클래스를 만들고 싶으시다면 클래스로 구현하시면 됩니다.
(만약 프로토콜별로 서비스 예외 클래스를 만들고 싶다면, ServiceException을 상속받은 ServiceHttpException과 같은 class를 만들고 그 하위 클래스를 만들거나 인스턴스 생성 메서드를 만들면 커스텀 예외 필터에서 ServiceHttpException를 캐치하게 만들 수도 있습니다. 단, 타입 식별이 제대로 가능하게 ServiceHttpException에 리터럴 타입과 같은 식별 코드가 추가되어야 합니다)
// src/common/exception/service.exception.ts
// ENTITY_NOT_FOUND 값 객체(status, default-message)를 가진
// ServiceException 인스턴스 생성 메서드
export const EntityNotFoundException = (message?: string): ServiceException => {
return new ServiceException(ENTITY_NOT_FOUND, message);
};
export class ServiceException extends Error {
readonly errorCode: ErrorCode;
constructor(errorCode: ErrorCode, message?: string) {
if (!message) {
message = errorCode.message;
}
super(message);
this.errorCode = errorCode;
}
}
이제 ServiceException을 캐치해서 원하는 프로토콜 컨텍스트의 에러로 변환해 응답시킬 커스텀 예외 필터를 만들어 봅시다.
아래 커스텀 필터 클래스에서는 호스트를 HTTP 컨텍스트로 바꾸어 그에 맞게 응답함수를 실행하고 있습니다. 추가적인 작업을 원하신다면 캐치 메서드 내부에 코드를 더 작성하시면 됩니다😊
// src/common/exception-filter/index.ts
export * from './service.exception.to.http.exception.filter';
// src/common/exception-filter/service.exception.to.http.exception.filter.ts
@Catch(ServiceException)
export class ServiceExceptionToHttpExceptionFilter implements ExceptionFilter {
catch(exception: ServiceException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const status = exception.errorCode.status;
response.status(status).json({
statusCode: status,
message: exception.message,
path: request.url,
});
}
}
다음은 전역 레벨에서의 필터 사용 선언입니다. NestJS의 공식문서 설명에 따르면 두 가지 방식으로 전역 레벨에서 필터 사용을 적용할 수 있습니다.
// 방법 1: main.ts 글로벌 필터 사용선언
// src/main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
...
// 전역 레벨에서 ServiceExceptionToHttpExceptionFilter 사용
app.useGlobalFilters(new ServiceExceptionToHttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
// 방법 2: APP_FILTER 토큰 값에 클래스 주입 방식
// src/app.module.ts
@Module({
imports: [UsersModule, DatabaseModule],
providers: [
{
provide: APP_FILTER,
useClass: ServiceExceptionToHttpExceptionFilter,
},
],
})
export class AppModule {}
긴 여정의 끝이 보입니다. 이제 서비스에서 ServiceException을 던져봅시다✌️
이제 서비스와 컨트롤러에서 에러 처리 로직은 굉장히 심플해졌습니다.
Service에서는 ServiceException 타입의 인스턴스 생성 메서드(EntityNotFoundException)를 던지기만 하면 됩니다.
// src/users/users.controller.ts
@Controller('users')
export class UsersController {
...
@Get('/:id')
public async findUser(@Param('id') id: string) {
return await this.userService.findOneUser(+id);
}
}
// src/users/users.service.ts
@Injectable()
export class UsersService {
...
public async findOneUser(id: number) {
const user = await this.userRepository.findOneBy({ id });
if (!user) {
throw EntityNotFoundException(id + ' is not found');
}
return new ReadUserInfoDto(user);
}
}
이렇게 함으로써 컨트롤러와 서비스의 결합도를 낮추고 내부 코드를 간결하게 가져갈 수 있게 되었습니다.
긴 글 읽어주셔서 감사합니다 💙
(아직 한발 남았다...)
음.. 지금은 단지 유저 서비스 클래스에 작성된 메서드가 많지 않아서 논리 로직이 별로 돋보이지 않습니다.
하지만, 서비스 클래스에 논리 로직(주석 아래 부분)이 많아지게 되면 클래스가 지저분해지고 코드 중복도 많이 생길 것입니다.
Service 클래스가 검증하는 책임까지 갖는 게 과도한 책임을 가진 게 아닌가 싶기도 하구요.
@Injectable()
export class UsersService {
...
public async findOneUser(id: number) {
...
// 정확히 이 부분이 거슬립니다,,
if (!user) {
throw EntityNotFoundException(id + ' is not found');
}
...
}
}
논리 로직을 따로 떼어내어 관리할 수는 없는 걸까요? 당연히 가능합니다 😀
UsersService에 UsersManager를 주입해 논리 로직과 관련된 작업 처리는 UsersManager에게 위임합시다.
// src/users/users.manager.ts
@Injectable()
export class UsersManager {
public validateUser = (id: number, user: User): void => {
if (!user) {
throw EntityNotFoundException(id + ' is not found');
}
};
}
// src/users/users.module.ts
@Module({
...
// UsersManager 주입 설정(프로바이더 등록)
providers: [UsersService, UsersManager],
})
export class UsersModule {}
// src/users/users.service.ts
@Injectable()
export class UsersService {
constructor(
...
// usersManager를 생성자 방식으로 주입
private usersManager: UsersManager,
) {}
public async findOneUser(id: number) {
const user = await this.userRepository.findOneBy({ id });
// usersManager는
// 이제 유저와 관련된 논리 로직을 처리하는 책임을 가지고
// usersService와 협력한다.!
this.usersManager.validateUser(id, user);
return new ReadUserInfoDto(user);
}
}
이로써, 논리 로직은 따로 떼어내어 UsersManager에서 관리할 수 있게 되었고 UsersService 코드베이스를 더 간결하게 유지할 수 있게 되었습니다.
👍👍