H4Pay 개발기 - API 요청을 일원화해보자

2022-01-07

문제의 원인과 자기반성

H4Pay는 개발 초기 단계부터 단추가 잘못 끼워졌다. 처음으로 진행하는 프로젝트를, 처음 배우는 언어와 Framework로 진행하면서 또 프로덕트단에 적용하려고도 했고, 또 Data Structure나 API에 대한 기본적인 원칙을 세우기 보다는 우선 코딩부터 진행한 전형적인 실패를 경험했다.

그중 하나가 API 그 자체와 API를 요청하는 것은 정말 심각한 수준인데, 우선 API를 개발하기 시작할 때 Request와 Response에 대한 기본적인 대원칙같은 것도 세워놓지 않은 채로 무턱대고 코딩부터 시작해 지금와서 Retrofit과 같은 Serialize하는 코드를 생성해주는 라이브러리를 적용하기도 어려운 것이다.

그런데, 사실 일반화되지 않은 각각의 API들에 대한 함수를 수동으로(..) 작성해서 어찌저찌 개발을 진행했다. 정규화 할 시간에 그냥 내가 노가다를 하자는 생각으로 임했던 것 같은데, 이렇게 되면 요청을 넣고 응답을 받는 부분을 수정하려고 하는 상황에서 정말 많은 코드를 뜯어 고쳐야하는 상황이 생기고, 이는 개발 과정에서의 심각한 비효율로 이어졌다. 이는 '클린 코드'에서도 말하는 바이기도 하다.

"기한을 맞추는 유일한 방법은, 그러니까 빨리 가는 유일한 방법은, 언제나 코드를 최대한 깨끗하게 유지하는 습관이다"

발단

H4Pay는 기존에 단일 학교만 지원했고, 생각보다 꽤 간단한 구조를 갖고 있었기 때문에 일반화되지 않았더라도 조금만 빡세게 하면 어느정도 빠른 수정이 가능했다. 하지만 문제는 다중 학교 지원을 적용하면서 시작됐다.

다중 학교 지원은 아래와 같은 방식으로 이루어진다.

  1. 기존 데이터베이스 스키마에 'school' 필드를 추가하고, 학교 코드를 넣어 학교를 식별한다.
  2. 'Schools' 컬렉션에는 서비스를 사용 중인 학교들의 정보(학교 코드, 학교명, 매점 사업자 정보 등..)을 담는다.
  3. User DB에 있는 school 필드를 Access Token에 포함시키고, 이를 API 요청 마다 헤더에 넣어 Backend에서 학교를 식별한다.

3번이 문제다. 다른건 그렇다 쳐도, 3번의 경우에는 Flutter 앱 단에서 나가는 모든 API 요청의 헤더에 Access Token을 포함시켜주어야 하는데, 그러려면 또 많은 수의 함수에 또 헤더 옵션을 통해 추가해줘야 한다.

고민

Network.dart 라는 파일을 만든 후, 이 파일에 get과 post를 호출하는 함수를 작성해 헤더를 포함시킨, 또 추가적으로 헤더를 넣을 경우 반영해서 요청을 보내고 받는 함수를 작성하려 했지만, 결과값을 UI단에 어떻게 반환할지가 문제였다.

사실 이 고민을 하기 전에도 API를 정규화하기 위해 'H4PayResult'라는 클래스를 만들어 H4PayResult 객체를 통해 진행했는데, 이럴 경우에 요청이 잘 처리되 객체가 반환된 경우, 배열이 반환된 경우, 객체/배열 없이 메시지만 반환된 경우, 서버 오류가 발생한 경우, 데이터 가공에 문제가 생긴 경우 등 다양한 데이터 타입을 사용할 수 있도록 dynamic으로 지정하다보니 데이터 타입이 너무 불분명해져 유지보수성이 떨어진다는 문제가 있었다.

하지만 바닥부터 다시 생각해보니, 그 함수의 역할을 UI가 필요로 하는 데이터를 반환하는 것으로 정의하고 그 역할을 수행하는 도중 서버로 인한 문제든, 데이터 가공에 의한 문제든 간에 문제가 발생했을 경우 오류로 처리해버리면 되는 것이었다. 이런 생각을 왜 못했을까. 내 자신을 질타하며 dartpad.dev (opens new window) 에서 A 함수를 호출하고, A가 B를 호출하면 B의 throw가 main 함수의 try 문으로 잡히는지 테스트했을 때 잘 작동해 실제 적용해보기로 했다.

해법

  1. 로컬 저장소에서 사용자 정보, 특히 토큰을 가져와 http 요청을 보내는 함수를 만들고, 기존의 API 요청을 보내는 함수를 대체한다. 반환값은 Response라서 기존의 함수를 재사용 가능하다는 장점이 있다.
  2. 각각의 API를 호출하는 함수들은 UI가 필요로 하는 값 만을 반환한다.
  3. 서버에서 200이 아닌 다른 status code를 반환하면 모두 다 NetworkException을 throw 한다.
  4. 해당 함수를 호출하는 측에서 try 문을 이용하거나 FutureBuilder를 이용하는 경우에는 snapshot.hasError를 통해 거르고, snapshot.error 를 통해 에러에 대한 정보를 받아온다.

그렇게 해서 탄생한 get 함수다.

Future<http.Response> get(Uri uri, {Map? headers}) async {
  final H4PayUser? user = await userFromStorage();
  if (user == null) {
    throw UserNotFoundException();
  }
  final networkResponse = await http.get(
    uri,
    headers: {
      "X-Access-Token": user.token,
      'Content-Type': 'application/json; charset=UTF-8',
      ...(headers ?? {}),
    },
  ).timeout(Duration(seconds: 5), onTimeout: () {
    throw TimeoutException("서버 응답 시간이 만료되었습니다.");
  });
  return networkResponse;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

항상 본질을 생각하자

첫 단추를 잘못 끼워 생각의 방향이 이상한 쪽으로 흐르다 보니 그 생각을 하느라 짜증도 쌓이고 한것이 사실이다. 그래서 구조를 어떻게 짤 지는 코딩 자체보다 훨씬 중요한 것이라는 말을 다시금 되새길 수 있었다.. 그리고 물론, 이 구조가 완벽한 구조라고 생각하지도 않는다.

하지만 유지보수성과 시간을 모두 생각한 현재로서는 가장 최선이 아닐까 싶다.

"나쁜 코드는 업무 속도를 낮춘다"