타입 의존성 마주하기

이문기
14 min readJun 2, 2023

--

출처: https://pin.it/6yNFwdb

시작

얼마전 사이드 프로젝트를 하다가 작년에 겪었던 문제를 다시 한 번 겪었습니다. 다른 점이라면 이전엔 프론트엔드였고 이번엔 백엔드에서 경험했다는 사실입니다. 어떤 문제이고 어떤 점을 알게 됐는지 공유하려고 합니다.

문제의 시작

이번 문제의 시작은 아래와 같은 요청사항에서 시작됐습니다.

“[백엔드 확인필요] 코드젠 스키마에서 startedTime, endedTime 타입이 문자열이 아님.”
(코드젠은 지금 조직에서 만들고 관리하는 Open API Specification 문서를 기반으로 타입스크립트의 타입과 react-query 훅을 생성해주는 모듈입니다.)

처음엔 응답값에 문제가 있는 걸로 파악을 해서 코드를 아래와 같이 수정했습니다. (사이드프로젝트의 백엔드는 NestJS를 활용하고 있습니다.)

// Before
class HabitFindListResponse {
...
@ApiProperty({
type: LocalTime,
description: '습관 시작 시간',
example: '00:00',
})
@Expose()
get startedTime() {
return this.startedTime;
}

@ApiProperty({
type: LocalTime,
description: '습관 종료 시간',
example: '23:59',
})
@Expose()
get endedTime() {
return this.endedTime;
}
...
}

// After
class HabitFindListResponse {
...
@ApiProperty({
type: 'string', // <- 변경
description: '습관 시작 시간',
example: '00:00',
})
@Expose()
get startedTime() {
return this.startedTime;
}

@ApiProperty({
type: 'string', // <- 변경
description: '습관 종료 시간',
example: '23:59',
})
@Expose()
get endedTime() {
return this.endedTime;
}
...
}

그래도 문제는 해결되지 않았고 커뮤니케이션 끝에 요청 타입에서 발생한 문제라는 걸 알게 됐습니다.

class HabitCreateRequest {
...
@ApiProperty({
type: 'string',
example: '00:00',
description: '습관 시작 시간',
})
@ValidateIf((dto: HabitCreateRequest) => !dto.isAllDay)
@IsNotEmpty()
@ToLocalTime()
startedTime: LocalTime;

@ApiProperty({
type: 'string',
example: '23:59',
description: '습관 종료 시간',
})
@ValidateIf((dto: HabitCreateRequest) => !dto.isAllDay)
@IsNotEmpty()
@ToLocalTime()
endedTime: LocalTime;
...
}

Swagger에선 요청 값이 string 타입으로 보이지만 코드젠에선 LocalTime을 타입으로 참고하다보니 문제가 생긴것이었습니다. 그럼 ToLocalTime 데코레이터를 외부에서 실행하고 LocalTimestring으로 바꿔주기만 하면 되는데 왜 문제가 됐을까요?

class HabitCreateRequest {
...
@ApiProperty({
type: 'string',
example: '00:00',
description: '습관 시작 시간',
})
@ValidateIf((dto: HabitCreateRequest) => !dto.isAllDay)
@IsNotEmpty()
@ToLocalTime() // <- 이걸 제거하고
startedTime: string; // <- 이렇게만 해주면 되는데?
...
}

진짜 문제와 원인

진짜 문제는 따로 있었습니다. 바로 HabitCreateRequest를 너무 여러 곳에서 사용(의존)하고 있었습니다.

// HabitController.ts
@Controller('api/habits')
class HabitController {
...
@Post()
@ApiCookieAuth()
@ApiOperation({
summary: '습관 생성 API',
description: '습관을 생성합니다.',
})
@ApiOkResponseBy(HabitCreateResponse)
async createHabit(
@Body() request: HabitCreateRequest, // <- 여기
@Session() user: AuthSessionDto,
) {
...
}
}

// HabitService.ts
class HabitService {
...
async createHabit(
request: HabitCreateRequest, // <- 여기
userId: number,
): Promise<Habit> {
...
}
}

// HabitApiService.spec.ts
it('습관을 정상적으로 생성합니다.', async () => {
// given
const request = plainToInstance(HabitCreateRequest, { // <- 여기
...
};
...
});

그렇게 많아보이지 않고 간단하게 수정할 수 있지만 이런 생각이 들었습니다. ‘만약 사이드프로젝트가 아니라 실제 운영하고 있는 기업의 서비스라면?’’, ‘만약 코드 규모가 이것보다 컸다면?’. 즉, 지금은 간단해보이지만 무시하고 넘어간다면 유지보수하기 어려운 레거시로 남을 가능성이 커보였습니다. 또한 HabitCreateRequest를 Repository 등과 같이 변경이 크지 않은 곳에서 사용하고 있다면 그나마 나았겠지만 변경의 빈도수가 상대적으로 높은 Controller와 Service에서 주로 사용하고 있다는 것도 문제였습니다.

그리고 HabitControllerHabitService 모두 HabitCreateRequest에 의존하고 있는데 HabitControllerHabitService 각각 변경에 대응해야 하는 이유가 다릅니다. Controller는 조직의 다른 구성원, 특히 프론트엔드 개발자의 요청에 따라 수정될 가능성이 있지만 Service는 다른 이유도 포함하고 있습니다. 이렇게 되면 프론트엔드 개발자로부터 API 명세에 대한 수정 요청이 간단하더라도 Service부터 사용되는 모든 곳을 살펴봐야 합니다. 게다가 로직측면에서 수정할 이유가 없음에도 수정을 해야할 수도 있습니다. 제가 겪은 상황이 바로 이러한 경우 입니다.

이런 문제는 프론트엔드에서도 발생할 수 있습니다. 만약 아래와 같이 API 응답 타입을 여기저기에서 사용하고 있다면 비슷한 문제를 경험할 가능성이 큽니다.

// 응답 타입
export type PostsResponse = {
...
};

const ParentComponent = (...) => {
const [posts, setPosts] = useState<PostsResponse>();

useEffect(() => {
(async function fetchPosts() {
try {
const response = await fetch(...);
const data: PostsResponse = response.json();
...
setPosts(data);
} ...
})();
}, []);

return (
<ChildComponent1 posts={posts}>...</ChildComponent1>
...
);
}

// ChildComponent1
import { PostsResponse } from './ParentComponent'; // <- 의존성 발생
...

이렇게 응답 타입을 여러 곳에서 사용하게 되면 타입을 변경해야 할 때 큰 고통을 겪을 수도 있습니다. 특히 타입을 컴포넌트의 상태로 의존한다는 건 타입이 UI와 직접적으로 결합됐다는 것이고, 변경의 가능성이 프로젝트 내에서 가장 높은 곳에서 API 명세(응답값 타입)와 결합이 발생했다는 걸 의미합니다. 이 문제 역시 작년에 제가 경험을 했고, 이 경험은 아주 뼈아픈 경험 중 하나로 남았습니다.

대응 방법

그렇다면 어떻게 대응하면 좋을까요?

미리 예방하기

사실 이런 유형의 문제의 가장 큰 원인은 귀찮음 입니다. 조금 귀찮더라도 대비를 했다면 나중에라도 발생할 이런 종류의 문제를 맞닥뜨리지 않을 수도 있습니다.

예방하는 첫 번째 방법은 DRY 원칙을 지키는 것과 같습니다. 즉, 수정의 이유가 같은 경우에만 타입을 import 해서 사용합니다. Controller에서 요청 값의 타입을 정하는 건 프론트엔드 개발자와 API 명세를 논의 하는 과정을 거칩니다. 하지만 Service 레이어 메서드의 매개변수를 수정하는 건 그렇지 않습니다. 프론트엔드의 경우 API 응답 값은 API 명세를 설정하면서 정하게 되고 백엔드 개발자와 소통을 통해 수정합니다. 반면 컴포넌트의 상태는 UI와 깊게 관련있기 때문에 기획, 디자인 그리고 코드 내부 요인에 의해 변경될 수 있습니다. 따라서 타입을 서로 공유하는 건 좋지 않은 판단일 수 있습니다. 이럴 땐 번거롭더라도 타입을 그대로 복사해서 사용하는 게 더 좋은 선택입니다.

// 응답 타입
// export 하지 않습니다.
type PostsResponse = {
...
};

const ParentComponent = (...) => {
const [posts, setPosts] = useState<PostsResponse>();

useEffect(() => {
(async function fetchPosts() {
try {
const response = await fetch(...);
const data: PostsResponse = response.json();
...
setPosts(data);
} ...
})();
}, []);

return (
<ChildComponent1 posts={posts}>...</ChildComponent1>
...
);
};

// ChildComponent1
type PostItem = {
... // PostsResponse의 값을 전부 또는 일부 포함
};


const ChildComponent1 = ({ posts: PostItem[] }) => {
...
};

그리고 이런 변경의 여파를 줄일 수 있는 다른 방법 중 하나는 아래 그림의 Data Structure 처럼 DTO와 같은 개념을 사용하는 것입니다.

참고: 프론트엔드 아키텍처: API 요청 관리

또, 타입을 분해하는 방법도 있습니다. 예를 들어 아래와 같은 타입이 있다고 할 때

type Post = {
title: string;
body: string;
createdAt: Date;
nickname: string;
profileImageUrl: string;
like: number;
bookmark: number;
};

아래와 같이 분해합니다.

type PostingUser = {
nickname: string;
profileImageUrl: string;
};

type PostReaction = {
like: number;
bookmark: number;
};

type Post = {
title: string;
body: string;
createdAt: Date;
} & PostingUser & PostReaction;

그러면 Post 전체가 아니라 일부만 변경해서 사용할 때 변경의 범위가 좁아지고 더 명확해집니다.

예방에 실패했다면

예방에 실패했다면 슬픈 일이지만 코드를 다수 수정해야 할 가능성이 높습니다. 그리고 이런 일이 생각보다 빈번하게 발생합니다. 다만 미리 고려하고 준비한다면 변경과정에서 발생할 수 있는 에러 또는 버그를 줄일 수 있고 소요하는 시간 역시 감소시킬 수 있습니다.

만약 API 요청과 관련해서 변경을 해야하고 대규모 수정이 예상된다면 API를 버저닝하거나 deprecated 처리를 할 수 있습니다. 만약 제가 경험한 문제처럼 같은 속성의 타입이 변경되었다면 버저닝을 합니다.

class HabitCreateRequest {
...
@ToLocalTime()
startedTime: LocalTime; // <- V1에서 사용하는 속성
...
}

class HabitCreateRequestV2 {
...
startedTime: string; // <- V2에서 사용하는 속성
...
}

@Controller('api/habits')
class HabitController {
...
@Post('v1') // POST: /api/habits/v1
...
async createHabit(
@Body() request: HabitCreateRequest,
@Session() user: AuthSessionDto,
) {
...
}

// V2
@Post('v2') // POST: /api/habits/v2
...
async createHabit(
@Body() request: HabitCreateRequestV2,
@Session() user: AuthSessionDto,
) {
...
}
}

그리고 요청하는 쪽에서 새로운 버전으로 전환을 완료했다면 이전 버전은 제거합니다. 만약 같은 속성이 아니라 기존 속성을 사용하지 않는 변경이 발생했다면 deprecated 처리합니다.

class HabitCreateRequest {
...
/**
* @deprecated startTime을 대신 사용하세요.
*/
@ApiProperty({
type: 'string',
example: '00:00',
description: '습관 시작 시간',
deprecated: true,
})
@ValidateIf((dto: HabitCreateRequest) => !dto.isAllDay)
@IsNotEmpty()
@ToLocalTime()
startedTime: LocalTime;

@ApiProperty({
type: 'string',
example: '00:00',
description: '습관 시작 시간',
})
@ValidateIf((dto: HabitCreateRequest) => !dto.isAllDay)
@IsNotEmpty()
@ToLocalTime()
startTime: LocalTime;
...
}

이 역시 반영이 완료된다면 제거합니다.

마무리

출처: https://pin.it/1DenyUL

이 이슈를 경험하면서 타입도 모듈을 보듯 의존성, 결합 등을 고려하여 사용해야겠다고 다시 새기는 계기가 되었습니다. 만약 여기저기서 특별히 신경쓰지 않고 가져다 쓴다면 걷잡을 수 없는 부채를 남기게 됩니다. 그리고 구체적으로 다루지 않았지만 중복을 제거할 때 기준과 엔티티, 버저닝 등 많은 개념을 다시 돌아보는 기회가 되었습니다. 마지막으로 도움 준 루카스에게 감사를 !

--

--

이문기

사용자를 생각하고 개발자를 생각하는 프런트엔드를 만드는 데 관심이 많습니다. 표준, 접근성, 아키텍처, 테스트 등을 꾸준히 훈련하고 적용하려고 노력합니다.