“타입을 어떻게 고쳐야 하는지는 알겠는데, 막상 내 코드에 적용하려니 어디까지 바꿔야 할지 모르겠다” 라는 이야기를 실무에서 정말 많이 듣습니다.
그래서 이번 글은 설명을 최대한 줄이고, 로그인 API 하나를 처음 상태부터 운영 가능한 구조까지 실제 코드 흐름 그대로 리팩터링합니다.
예제는 다음 조건을 가정합니다.
- NestJS + TypeScript 백엔드
- JWT 기반 로그인
- 실무에서 흔히 보는 미완성 타입 상태
초기 상태: “일단 동작하는” 로그인 API
아래 코드는 실제 프로젝트에서 매우 흔한 형태입니다.
// auth.controller.ts
@Post('login')
async login(@Body() body: any) {
return this.authService.login(body.email, body.password);
}
// auth.service.ts
async login(email: string, password: string): Promise<any> {
const user = await this.userRepo.findByEmail(email);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
if (user.password !== password) {
throw new Error('INVALID_PASSWORD');
}
return {
accessToken: 'token',
refreshToken: 'token',
};
}
이 코드는 동작은 하지만, 실무 기준으로 보면 문제가 분명합니다.
- 입력/출력 타입이 전혀 드러나지 않는다
- 실패 케이스가 문자열 + throw에 묻혀 있다
- 컨트롤러는 성공만 가정한다
1단계: 요청과 응답 타입부터 고정한다
가장 먼저 하는 작업은 “데이터 구조를 숨기지 않는 것”입니다.
type LoginRequest = {
email: string;
password: string;
};
type LoginResponse = {
accessToken: string;
refreshToken: string;
};
// auth.controller.ts
@Post('login')
async login(@Body() body: LoginRequest) {
return this.authService.login(body.email, body.password);
}
이 단계의 핵심은 “안전”이 아니라 “가시성”입니다. 이제 API 계약이 코드에 드러납니다.
2단계: 실패를 throw가 아닌 타입으로 끌어낸다
다음으로 손댈 부분은 실패 흐름입니다.
type LoginError =
| { type: 'USER_NOT_FOUND' }
| { type: 'INVALID_PASSWORD' };
type Result<T, E> =
| { ok: true; data: T }
| { ok: false; error: E };
// auth.service.ts
async login(
email: string,
password: string,
): Promise<Result<LoginResponse, LoginError>> {
const user = await this.userRepo.findByEmail(email);
if (!user) {
return { ok: false, error: { type: 'USER_NOT_FOUND' } };
}
if (user.password !== password) {
return { ok: false, error: { type: 'INVALID_PASSWORD' } };
}
return {
ok: true,
data: {
accessToken: 'token',
refreshToken: 'token',
},
};
}
이제 함수 시그니처만 봐도 다음이 명확합니다.
- 로그인은 실패할 수 있다
- 실패 유형이 제한되어 있다
- 호출부는 분기 처리를 해야 한다
3단계: 컨트롤러에서 HTTP 책임을 명확히 한다
이제 컨트롤러가 “타입을 해석하는 곳”이 됩니다.
// auth.controller.ts
@Post('login')
async login(@Body() body: LoginRequest, @Res() res: Response) {
const result = await this.authService.login(
body.email,
body.password,
);
if (!result.ok) {
switch (result.error.type) {
case 'USER_NOT_FOUND':
return res.status(404).json({ message: 'User not found' });
case 'INVALID_PASSWORD':
return res.status(401).json({ message: 'Invalid password' });
}
}
return res.json(result.data);
}
이 구조의 장점은 운영에서 바로 체감됩니다.
- 응답 코드 누락 가능성이 줄어든다
- 에러 추가 시 컴파일 단계에서 분기 누락이 드러난다
- API 동작을 컨트롤러 코드만 봐도 이해할 수 있다
4단계: 운영 로그와 연결한다
실무에서는 여기서 끝나지 않습니다. 에러 타입은 곧 운영 지표가 됩니다.
function logLoginFailure(error: LoginError) {
logger.warn('login_failed', {
reason: error.type,
});
}
이제 로그에서는
- USER_NOT_FOUND 비율
- INVALID_PASSWORD 시도 횟수
를 타입 기준으로 바로 집계할 수 있습니다. 문자열 로그보다 훨씬 안정적입니다.
실무에서 일부러 하지 않은 것들
이 예제에는 일부러 넣지 않은 것들이 있습니다.
- 과도한 제네릭 공통화
- 에러 코드 enum 일반화
- 모든 함수에 Result 강제
이 단계에서는 “하나의 API가 명확해지는 것”이 목표이기 때문입니다.
실무 적용 체크리스트
- 요청/응답 타입이 명시되어 있는가
- 실패 흐름이 타입으로 드러나는가
- 컨트롤러가 HTTP 책임을 가지는가
- 에러 타입이 운영 로그와 연결되는가
- 타입 하나 추가 시 영향 범위를 예측할 수 있는가
실무에서 좋은 타입 리팩터링은 추상적인 패턴이 아니라 API 하나를 끝까지 책임지는 구조에서 나옵니다.
로그인 API 하나만 제대로 정리해도, 그 패턴은 다른 API로 자연스럽게 확산됩니다. 타입은 그 결과로 따라오는 것이지, 목표가 되어서는 안 됩니다.
'개발 > Typescript' 카테고리의 다른 글
| [TYPESCRIPT] Decorator를 쓰기 전에 먼저 고민하게 되는 것들 (0) | 2026.02.05 |
|---|---|
| [TYPESCRIPT] Variadic Tuple Types를 실무에서 쓰는 경우 (0) | 2026.02.04 |
| [TYPESCRIPT] API 코드로 보는 타입 리팩터링 전체 흐름: NestJS 서비스에 바로 적용하는 기준 (0) | 2026.02.02 |
| [TYPESCRIPT] 리팩터링 우선순위 예제로 보는 타입 개선 전략: 어디서부터 손대야 효과가 나는가 (0) | 2026.02.01 |
| [TYPESCRIPT] 리팩터링 예제로 보는 타입 개선 과정: 나쁜 TypeScript 타입을 좋은 타입으로 바꾸는 방법 (0) | 2026.01.31 |
