백엔드 리팩토링 회고

이번에 upstream에 올렸던 suggestion, umbrella 이슈, 그리고 관련 MR들을 다시 보면서 백엔드 리팩토링 과정을 정리해보려 한다.

결과만 보면 나쁘지 않았다.
요청 검증이 중앙화됐고, 에러 응답 형식이 맞춰졌고, route/service/database 계층이 나뉘었고, 응답 검증과 DTO 단일화까지 이어졌다.

그런데 이 글은 “이렇게 잘했다”는 글로 쓰고 싶지는 않았다.
오히려 이번 작업을 반면교사로 삼고 싶었다.

좋은 리팩토링 방향을 잡았더라도, 그 방향이 너무 늦게 명확해지면 중간에 지나가는 비용이 커진다.
이번 작업에서 내가 배운 것은 “무엇을 고쳤는가”보다 “무엇을 처음부터 정했어야 했는가”에 가깝다.

처음에는 작은 제안이었다

처음 문제의식은 그렇게 거창하지 않았다.

API 입력값을 검사하고 Swagger 문서를 자동화하자는 suggestion(#8)이 있었다.
당시에는 API 라우트가 request.json()으로 받은 값을 거의 그대로 DB 함수에 넘기는 흐름이 있었고, “이 API가 무엇을 받고 무엇을 돌려주는지”도 코드상에서 명확히 드러나지 않았다.

이때 생각한 방향은 단순했다.

  • zod로 요청 스키마를 정의한다.
  • 그 스키마로 요청을 검증한다.
  • 같은 스키마 기반으로 OpenAPI 문서를 만든다.
  • 문서와 코드가 따로 놀지 않게 한다.

여기에 POST 생성 성공 응답을 201로 통일하자는 suggestion(#10), 공통 에러 응답 핸들러를 도입하자는 suggestion(#47)이 붙었다.

이 제안들은 각각 보면 작은 정리였다.
하지만 지금 돌아보면 모두 같은 문제를 가리키고 있었다.

백엔드 API 계약이 흩어져 있었다.

상태코드는 라우트마다 다르고, 에러 형식도 다르고, 요청 검증 위치도 다르고, 문서화 근거도 제각각이었다.
나는 처음에 이걸 “검증 추가”, “상태코드 정리”, “에러 핸들러 도입”처럼 각각의 작업으로 봤다. 틀린 접근은 아니었지만, 더 큰 이름을 먼저 붙였어야 했다.

그 이름은 아마 “API 계약 정리”였을 것이다.

상태코드와 에러 응답

API 응답 HTTP 상태코드 표준화 umbrella(#22)는 그 문제를 조금 더 크게 본 이슈였다.

예를 들어 하위 코드가 있는 코드 타입을 삭제하려고 할 때, 이건 서버 내부 오류가 아니라 업무 충돌이다.
그렇다면 500이 아니라 409가 맞다. 대상이 없으면 404, 중복 생성이면 409, 입력 검증 실패면 400이어야 한다.

이 기준을 세우면서 공통 에러 응답 핸들러가 필요해졌다.

try {
  // route logic
} catch (error) {
  return handleErrorResponse(error);
}

처음에는 이 정도의 공통화만으로도 꽤 정리된 느낌이 들었다.
업무 에러와 Prisma 에러를 한 곳에서 HTTP 상태코드로 매핑하고, 응답 형식도 { message } 중심으로 통일할 수 있었기 때문이다.

하지만 여기서도 아쉬운 점이 있었다.

에러 응답 형식을 먼저 정리한 것은 좋았지만, route마다 try/catch가 계속 남았다.
결과적으로 “에러 매핑 정책”은 공통화됐지만, “라우트가 공통 정책을 채택하는 방식”은 아직 반복이었다.

이 문제는 나중에 구조화 로깅을 도입하면서 createRoute로 한 번 더 정리하게 됐다.
돌아보면 에러 핸들러를 만들 때부터 라우트 공통 진입점까지 같이 고민했으면 더 좋았을 것 같다.

요청 검증 중앙화

MR !40에서는 요청 본문 파싱과 zod 검증을 parseJson, validate 헬퍼로 중앙화했다.

각 라우트에서 직접 request.json()을 호출하고, 검증 실패를 따로 처리하던 흐름을 공용 헬퍼로 모았다.

이 작업은 꽤 만족스러웠다.

  • 잘못된 JSON이 500이 아니라 400으로 떨어졌다.
  • zod 검증 실패 응답이 한 형식으로 정리됐다.
  • route는 “어떤 스키마로 검증할지”만 알면 됐다.

다만 여기서도 뒤늦게 드러난 문제가 있었다.

요청은 검증하는데, 응답은 검증하지 않고 있었다.
응답 스키마는 이미 OpenAPI 문서 생성을 위해 존재했지만, 실제 런타임 응답 계약을 강제하지는 않았다.

즉 스키마가 있었지만, 반만 쓰고 있었다.

route, service, database를 나누다

웹 계층 분리 umbrella(#11)는 더 직접적인 구조 개선이었다.

기존 구조는 대략 이런 모양이었다.

app/api/*/route.ts
  -> databases/tbl-*.ts

라우트는 얇아 보였지만, databases/tbl-*.ts 안에는 Prisma 호출과 업무 규칙이 같이 들어 있었다.

예를 들어 삭제 전에 하위 데이터가 있는지 확인하거나, 생성할 ID를 조합하거나, 사용 중이면 삭제를 막는 규칙이 데이터 접근 함수 안에 있었다.
처음에는 편했다. DB와 가까운 곳에서 처리하면 코드가 짧아 보이기 때문이다.

하지만 시간이 지나면 이 구조는 금방 애매해진다.

  • 업무 규칙만 따로 테스트하기 어렵다.
  • 여러 테이블을 조합하는 트랜잭션 경계를 둘 자리가 없다.
  • 데이터 접근 함수가 “쿼리”인지 “업무 동작”인지 흐려진다.
  • 다른 도메인의 규칙을 재사용하려고 할 때 import 방향이 꼬인다.

그래서 #11에서는 이렇게 나누기로 했다.

app/api/*/route.ts      입력 검증, 응답 생성
services/*.ts           업무 규칙, orchestration, 트랜잭션 경계
databases/tbl-*.ts      순수 DB 접근

MR !48에서 code-types를 파일럿으로 적용했고, 이후 predefined/sensor(!51), locations, core, containments(!55) 등으로 넓혔다.

이때 좋았던 점은 파일럿을 먼저 둔 것이다.
처음부터 모든 도메인을 한 번에 바꾸지 않고, code-types에서 규칙을 세운 뒤 나머지가 따라가게 했다.

다만 반면교사도 있었다.

이 계층 분리는 기능 개발이 어느 정도 진행된 뒤에 시작했다.
그래서 이미 databases/tbl-*에 쌓인 규칙을 꺼내는 작업이 됐다. “앞으로 복잡해질 것 같으니 미리 나누자”는 판단은 맞았지만, 이미 일부 복잡도는 쌓인 뒤였다.

다음에는 첫 API 몇 개가 만들어지는 시점부터 최소한의 계층 규칙을 먼저 정하고 싶다.
처음부터 거대한 아키텍처를 만들자는 뜻은 아니다. 다만 “DB 함수에는 업무 규칙을 넣지 않는다” 같은 금지선은 빨리 그을수록 싸다.

응답 검증에서 다시 문제가 드러났다

요청 검증이 정리되고, 계층도 나뉘고 나니 다음 문제는 응답이었다.

응답 검증 umbrella(#70)의 문제의식은 명확했다.

  • 요청 body는 zod로 검증한다.
  • 그런데 응답 body는 서비스 반환값을 그대로 NextResponse.json으로 직렬화한다.
  • $queryRaw<Array<IUGData*>> 제네릭은 컴파일타임 캐스트일 뿐이다.
  • DB 반환값이 API 계약과 달라도 런타임에서는 모른다.

그래서 MR !59에서 jsonResponse 헬퍼를 도입했다.

return jsonResponse(CodeTypeResponseSchema.array(), rows);

이제 응답도 직렬화 전에 zod schema로 검증한다.
응답 계약 위반은 클라이언트 잘못이 아니라 서버 버그이므로 500으로 처리하고, 자세한 issues는 서버 로그로 남긴다.

이 작업을 하면서 “스키마는 문서용으로만 두면 안 된다”는 걸 다시 느꼈다.
문서, 타입, 런타임 검증이 각각 따로 놀면 셋 중 하나는 언젠가 틀어진다.

DTO 단일화 umbrella

응답 검증을 도입한 뒤 더 큰 문제가 보였다.
바로 API 응답 DTO 정리 umbrella(#86)다.

이슈 #86은 두 가지 문제를 정리했다.

첫째, 같은 응답 모양이 두 곳에 중복 정의되어 있었다.

  • zod 응답 스키마: 검증과 문서용
  • IUGData* 타입: TypeScript 타입용

둘째, DB row를 응답 모양으로 바꾸는 위치가 섞여 있었다.

  • 어떤 조회는 SQL alias로 이미 응답 형태를 만든다.
  • 어떤 생성/수정은 서비스의 toXxxResponse 같은 함수가 JS에서 변환한다.

결국 같은 응답 모양을 만들면서도 도메인과 상황에 따라 방식이 달랐다.

이건 단순한 취향 문제가 아니었다.
응답 검증 작업(#70)을 하면서 스키마와 타입을 손으로 맞추는 일이 반복됐고, 그 과정에서 드리프트 위험이 계속 보였다.

그래서 #86에서는 방향을 확정했다.

  • 응답 스키마를 단일 출처로 둔다.
  • 타입은 z.infer<typeof *ResponseSchema>로 파생한다.
  • IUGData* 응답 DTO는 점진적으로 제거한다.
  • DB row → 응답 모양 변환은 데이터 계층이 소유한다.
  • 생성/수정도 가능하면 SQL RETURNING + alias로 GET과 같은 응답 모양을 바로 반환한다.

MR !74에서 containments를 파일럿으로 적용했고, !75~!80에서 locations, code-types, codes, predefined/interface, predefined/sensor, core로 이어졌다.

이 구조는 지금 봐도 좋다.
하지만 “좋다”와 “처음부터 이렇게 갔어야 한다”는 별개다.

처음 zod 스키마를 도입할 때 이미 이 질문을 했어야 했다.

이 스키마는 문서용인가, 검증용인가, 타입의 단일 출처인가?

그 질문을 뒤늦게 하면서 #70, #86 같은 큰 정리 작업이 생겼다.

리팩토링은 내부 변경만은 아니었다

리팩토링 이슈에는 자주 “내부 구조 개선”이라고 적는다.
그런데 이번 작업을 하면서 내부 구조 개선이 항상 동작 불변을 뜻하지는 않는다는 걸 느꼈다.

예를 들어 #91은 P2025 의존을 제거하고 서비스에서 대상 존재 가드를 먼저 두는 작업이었다.
상태코드는 여전히 404지만, 메시지는 더 도메인에 맞게 바뀐다.

대상을 찾을 수 없습니다
-> 수정할 코드가 존재하지 않습니다

이건 사용자 관점에서 보면 응답 body가 바뀐 것이다.

#108도 비슷하다.
요청 스키마의 DB nullable 컬럼을 .nullish()로 통일하면서 PATCH에서 undefinednull의 의미를 Prisma와 맞췄다.

  • 생략: 미변경
  • null: 값을 비움

이건 좋은 정합성 개선이지만, 기존에 400이던 요청이 허용되는 동작 변경이기도 하다.

앞으로는 리팩토링 이슈를 만들 때 “내부 구조만 개선”이라고 쉽게 쓰지 말아야겠다고 생각했다.
상태코드가 같아도 메시지가 바뀌면 계약이 바뀐 것이다. 400이던 요청이 200이 되면 그것도 계약 변경이다.

stale base가 남긴 교훈

MR !75에는 stale base 관련 복구가 있었다.
직전 작업이 오래된 base에서 분기되면서, 다른 MR에서 추가된 export가 누락되어 route와 openapi import가 깨진 것이다.

이런 문제는 코드 설계와 별개다.
설계가 맞아도 작업 순서와 브랜치 관리가 틀어지면 리팩토링은 쉽게 흔들린다.

특히 여러 도메인에 같은 패턴을 순차 적용하는 작업은 이런 위험이 크다.

  • 파일럿 MR이 먼저 병합된다.
  • 후속 도메인 MR들이 비슷한 파일을 계속 만진다.
  • 그 사이 다른 기능 MR이 같은 schema/openapi/route를 건드린다.
  • stale base에서 출발하면 누락이나 되돌림이 생긴다.

이번에는 staged하게 잘게 나눠서 관리한 편이었지만, 그래도 base 동기화는 더 엄격했어야 했다.

다음부터는 도메인별 반복 리팩토링을 할 때 아래 규칙을 더 강하게 지키고 싶다.

  • 후속 MR 생성 전 upstream main을 반드시 동기화한다.
  • 파일럿에서 확정한 패턴을 문서화하고, 후속 MR은 그 체크리스트를 그대로 따른다.
  • 같은 파일을 만지는 기능 MR이 있으면 리팩토링 MR의 순서를 다시 잡는다.
  • “타입만 변경”처럼 보여도 schema export, openapi import, route import를 같이 확인한다.

좋았던 점

그래도 이번 과정에서 좋았던 점도 분명히 있었다.

첫째, suggestion을 이슈로 먼저 남긴 점이다.
작은 불편을 그냥 코드에서 바로 고치지 않고, 배경과 목표를 적어두니 나중에 왜 이 작업을 했는지 되짚기 쉬웠다.

둘째, umbrella 이슈를 둔 점이다.
#11, #70, #86처럼 큰 방향을 umbrella로 두고 하위 task를 도메인별로 나눈 덕분에 작업 범위를 통제할 수 있었다.

셋째, 파일럿을 먼저 적용한 점이다.
code-types, containments 같은 작은 도메인에서 패턴을 먼저 만들고, 그다음 다른 도메인으로 확장했다. 이 방식은 계속 가져가고 싶다.

넷째, MR 본문에 검토 포인트와 비고를 남긴 점이다.
예를 들어 계층 의존 방향이 애매한 부분, 테스트 하니스가 없어 통합 테스트를 후속으로 둔 부분, stale base를 복구한 부분이 MR 본문에 남아 있었다. 회고할 때 이 기록이 꽤 도움이 됐다.

반면교사

반면교사로 삼고 싶은 건 더 선명하다.

첫째, 계약의 단일 출처를 늦게 정했다.
요청 스키마, 응답 스키마, 타입, OpenAPI 문서가 각각 어떤 관계인지 처음부터 더 강하게 정했어야 했다.

둘째, 계층 경계를 늦게 나눴다.
처음에는 DB 함수에 업무 규칙이 들어가는 것이 빠르게 느껴졌지만, 나중에는 그 규칙을 꺼내는 비용으로 돌아왔다.

셋째, “내부 리팩토링”의 의미를 좁게 봤다.
메시지, null 허용 여부, 응답 형태는 모두 API 계약이다. 내부 구조를 바꾼다고 해서 외부 영향이 없는 것은 아니다.

넷째, 반복 MR의 운영 리스크를 과소평가했다.
도메인별로 잘게 나누는 건 맞지만, 그만큼 base 동기화와 import/export 확인이 중요해진다.

다섯째, 테스트 기반이 충분하지 않았다.
MR마다 tsc, lint, dev 서버 e2e를 확인했지만, 저장소 차원의 통합 테스트 하니스가 있었다면 더 싸게 검증했을 것이다.

다음에는 먼저 정할 것

다음에 비슷한 백엔드 리팩토링을 하게 된다면, 코드부터 고치기 전에 아래를 먼저 정하고 싶다.

  • API 요청 계약의 단일 출처는 무엇인가?
  • API 응답 계약의 단일 출처는 무엇인가?
  • 타입은 어디서 파생할 것인가?
  • route, service, database의 금지선은 무엇인가?
  • 업무 에러와 HTTP 에러 매핑은 어디에서만 하는가?
  • nullable 컬럼의 undefinednull 의미는 무엇인가?
  • 존재 가드는 P2025에 맡길 것인가, service에서 명시할 것인가?
  • 응답 변환은 service가 할 것인가, database가 할 것인가?
  • 파일럿 도메인은 무엇이고, 후속 도메인은 어떤 체크리스트를 따를 것인가?
  • 이 리팩토링이 외부 API 계약을 바꾸는 지점은 어디인가?

이 질문을 먼저 답하면 리팩토링이 줄어든다는 뜻은 아니다.
하지만 중간에 “이것도 맞춰야 하네”, “이 타입도 중복이네”, “이 응답도 검증 안 하네” 같은 발견이 줄어든다.

마무리

이번 백엔드 리팩토링은 필요한 작업이었다.
요청 검증, 에러 응답, 서비스 계층, 응답 검증, DTO 단일화까지 이어진 흐름은 결과적으로 코드의 책임 경계를 더 선명하게 만들었다.

하지만 동시에 이렇게도 기억하고 싶다.

리팩토링은 코드를 예쁘게 고치는 일이 아니라, 뒤늦게 드러난 설계 결정을 갚는 일일 때가 많다.
이번 작업은 그 빚을 꽤 많이 갚은 작업이었다. 그리고 다음에는 같은 빚을 덜 만들고 싶다.

그래서 이 회고의 결론은 단순하다.

불편함을 발견하면 바로 고치기 전에, 그 불편함이 어떤 경계가 없어서 생긴 것인지 먼저 이름 붙이자.

그 이름을 빨리 붙일수록 리팩토링은 덜 아프다.

댓글남기기