Skip to content

AI Slop: 새 코드 도입 이후 참조를 수정 안하거나 중복된 구현을 방치함 #45

Description

@bipolarrr

배경

대규모 리팩터링을 반복하는 과정에서 기존 구현을 완전히 대체하지 않고 새 구현을 추가한 뒤 기존 구현도 남겨두는 패턴이 일부 확인되었다.

그 결과 같은 책임이나 같은 사실이 여러 위치에 존재하고, 코드가 어느 쪽을 기준으로 동작하는지 파악하기 어려워지는 문제가 생기고 있다.

대표적인 사례는 인증 guard다.

현재 다음 두 파일에 requireLogin, requireRole이 각각 구현되어 있다.

apps/api/src/plugins/auth.ts
apps/api/src/shared/auth-guards.ts

두 구현은 실질적으로 같은 역할을 하며, controller에 따라 서로 다른 위치에서 import하고 있다.

또한 프로젝트 전역을 확인했을 때 이와 비슷한 형태의 중복 또는 다중 source of truth가 다른 영역에서도 발견된다.


확인된 문제 유형

1. 동일 책임의 구현이 여러 위치에 존재

대표적으로 authentication/authorization 영역에서 동일한 guard 구현이 두 곳에 존재한다.

새로운 구조가 추가된 뒤 기존 구조가 완전히 제거되지 않은 것으로 보이며, 현재 production code가 두 구현을 모두 사용하고 있다.

이 경우 기능 자체보다 다음 문제가 크다.

  • 어느 구현이 canonical한지 코드만 보고 바로 알기 어렵다.
  • 한 구현만 수정하면 controller별 동작이 달라질 수 있다.
  • 한쪽이 오래된 구현인지 의도적인 별도 구현인지 판단하려면 history까지 조사해야 한다.
  • 향후 또 다른 구현이 추가될 가능성이 있다.

2. 같은 validation이 여러 계층에서 반복됨

현재 API에는 전역 HTTP runtime contract가 있고, 해당 contract가 Fastify route에 Zod schema를 연결하여 handler 진입 전에 request validation을 수행한다.

동시에 여러 controller에서는 동일하거나 매우 유사한 schema를 이용하여 다시 parseBody()를 호출한다.

개념적으로 다음과 같은 형태가 생긴다.

HTTP request
 ↓
route runtime schema validation
 ↓
controller
 ↓
동일 schema를 이용한 manual validation
 ↓
service

모든 parseBody()가 중복이라는 뜻은 아니다.

예를 들어 multipart 내부의 JSON 문자열처럼 Fastify의 route body validation이 직접 다룰 수 없는 별도 boundary는 application code에서 다시 검증할 필요가 있다.

문제는 어디까지가 실제 별도 boundary이고 어디부터가 과거 validation 경로의 잔존인지 현재 구조만으로 명확하지 않다는 점이다.


3. 실제 Fastify route와 별도의 route registry가 같은 정보를 함께 보유

실제 controller에는 다음과 같은 route declaration이 있다.

app.patch('/projects/:id', ...)

동시에 http-route-schemas.ts에는 해당 endpoint에 대해 별도로:

HTTP method
URL
route family
params
querystring
body boundary
response boundary
body schema
response schema

등을 기술한다.

따라서 HTTP endpoint 하나에 대한 정보가 실제 route와 별도 registry 양쪽에 존재한다.

현재 별도 registry는 실제 runtime validation과 전체 route contract 검증에 사용되고 있으므로 단순한 dead code는 아니다.

다만 구조적으로는 동일 endpoint에 대한 여러 source of truth가 존재하며, 이를 다시 테스트로 동기화하는 형태가 되었다.

이 때문에 route 하나를 추가하거나 수정할 때 실제 handler뿐 아니라 별도의 contract inventory까지 이해해야 한다.


4. transport type과 runtime schema가 서로 독립적으로 작성됨

packages/contracts에는 같은 HTTP payload에 대해:

  • TypeScript transport type
  • Zod runtime schema

가 각각 존재하는 경우가 많다.

예를 들어 AdminExhibitionItem의 TypeScript 구조와 AdminExhibitionItemSchema의 Zod 구조가 별도로 작성되어 있다.

두 표현의 목적 자체는 다르다.

TypeScript type
→ compile-time 검사

Zod schema
→ runtime 검사

문제는 둘이 같은 transport shape를 표현하면서도 서로 독립적으로 유지된다는 것이다.

따라서 필드를 추가하거나 optional 여부를 변경할 때 둘 중 하나만 변경될 가능성이 있고, 이를 방지하기 위한 추가 테스트와 검증이 필요해진다.

구체적으로 Google auth 요청 계약은 다음 두 위치에 나뉘어 작성되어 있다.

packages/contracts/src/auth.ts:3-6
packages/contracts/src/schemas.ts:110-112

auth.ts에는 GoogleAuthRequest 타입이 수동으로 선언되어 있고, schemas.ts에는 같은 { credential: string } 구조를 표현하는 GoogleAuthRequestSchema가 별도로 선언되어 있다. 또한 packages/contracts/src/schemas.ts:134에는 schema에서 추론한 GoogleAuthRequestSchemaInput 타입도 존재한다.

이는 서로 다른 boundary를 위한 의도적인 모델 분리가 아니라, 하나의 Google auth 요청 계약을 TypeScript 타입과 runtime schema로 각각 유지하는 schema/type definition duplication 및 source of truth 분산 사례다. 현재 두 타입의 동등성을 확인하는 테스트가 있더라도, 계약 변경 시 두 선언을 함께 수정해야 한다는 구조적 중복은 남는다.

이 경우 runtime schema를 기준 정의로 삼고 요청 타입을 schema에서 추론하도록 정리할 수 있다.

// packages/contracts/src/schemas.ts
export const GoogleAuthRequestSchema = z.object({
  credential: z.string().min(1),
});

export type GoogleAuthRequest =
  z.infer<typeof GoogleAuthRequestSchema>;

이렇게 하면 기존 소비자는 GoogleAuthRequest를 계속 사용할 수 있으면서, 요청 필드와 validation 규칙은 한 위치에서만 변경하면 된다.

Google auth 요청 외에도 인증 응답과 오류 계약에서 같은 형태가 추가로 확인된다.

수동 TypeScript 정의 대응 runtime schema
packages/contracts/src/auth.ts:26-31DevAuthErrorScenario packages/contracts/src/schemas.ts:10-16DevAuthErrorScenarioSchema
packages/contracts/src/auth.ts:37-58ApiErrorCode packages/contracts/src/response-schemas.ts:28-50ApiErrorCodeSchema
packages/contracts/src/auth.ts:9-15AuthUser packages/contracts/src/response-schemas.ts:104-110AuthUserSchema
packages/contracts/src/auth.ts:18-20GoogleAuthResponse packages/contracts/src/response-schemas.ts:112-114GoogleAuthResponseSchema
packages/contracts/src/auth.ts:64-66MeResponse packages/contracts/src/response-schemas.ts:120-126MeResponseSchema

DevAuthErrorScenario의 5개 literal과 ApiErrorCode의 21개 literal은 각각 대응 schema의 값 집합과 일치한다. AuthUser, GoogleAuthResponse, MeResponse도 정적 필드 구조는 대응 schema와 일치한다.

다만 이것을 모든 의미에서 완전히 동일한 정의라고 보아서는 안 된다. runtime schema는 .strict(), positive safe integer인 id, non-empty email 같은 제약까지 포함하므로 TypeScript type보다 실제 허용 범위가 좁다. 문제는 이 runtime 제약의 존재가 아니라, schema가 이미 제공하는 정적 shape와 literal union을 auth.ts에서 다시 수동으로 작성하고 있다는 점이다.

schema가 있는 모듈을 계약의 기준 위치로 삼고 같은 위치에서 타입을 추론하는 형태로 정리할 수 있다.

// packages/contracts/src/schemas.ts
export type DevAuthErrorScenario =
  z.infer<typeof DevAuthErrorScenarioSchema>;

// packages/contracts/src/response-schemas.ts
export type ApiErrorCode =
  z.infer<typeof ApiErrorCodeSchema>;

export type AuthUser =
  z.infer<typeof AuthUserSchema>;

export type GoogleAuthResponse =
  z.infer<typeof GoogleAuthResponseSchema>;

export type MeResponse =
  z.infer<typeof MeResponseSchema>;

packages/contracts/src/index.ts:23-33에서는 현재 이 타입들을 auth.ts에서 type export하고, packages/contracts/src/index.ts:117-118에서는 schema를 별도로 value export한다. 정리할 때 inferred type의 소유 모듈을 schema 쪽으로 옮기되, package root의 기존 타입 이름은 그대로 re-export하면 앱의 public import 경로는 유지할 수 있다. 반대로 auth.ts가 schema 모듈을 다시 참조하게 만들면 타입 소유권이 계속 분산되고 향후 import cycle 위험도 생기므로 피하는 편이 낫다.

핵심은 type/schema 동등성 테스트의 범위가 부족하다는 것이 아니다. 수동 TypeScript type과 runtime schema가 같은 계약을 독립적으로 표현하기 때문에, 두 표현이 어긋나지 않았음을 별도 테스트로 증명해야 하는 구조 자체가 문제다.

실제로 packages/contracts/src/response-schemas.test.ts:71-73GoogleAuthResponse·MeResponse assignability 검사와 apps/api/src/__tests__/validation.test.ts:105-116의 dev auth 요청 exact type 검사는 이중 정의를 유지하기 위해 필요한 동기화 장치다. 이 검사를 더 엄격하게 만들거나 다른 타입에도 확대하는 것은 source of truth를 줄이지 않으며, 오히려 중복 구조를 고착시킬 수 있다.

따라서 이 사례들은 동등성 테스트를 보강할 대상이 아니라, runtime schema에서 정적 타입을 파생하여 동등성 테스트가 필요해지는 원인 자체를 제거할 후보에 해당한다. 단일 source로 정리한 뒤에는 runtime validation 동작을 검증하는 테스트는 유지하되, 독립된 두 타입 표현의 일치 여부만 확인하던 테스트는 더 이상 필요하지 않다.

반면 Prisma model, API DTO, Web form model 등은 서로 다른 boundary를 표현하므로 형태가 비슷하다는 이유만으로 같은 문제로 취급해서는 안 된다.


5. 공통 infrastructure mechanism이 feature별로 반복됨

Serializable transaction retry와 관련된 로직이 여러 feature에 각각 존재한다.

확인된 영역에는 다음이 포함된다.

assets
admin/year
admin/project
admin/game-upload

각 구현에는 다음과 같은 공통 요소가 반복된다.

Serializable transaction
retry 횟수 제한
Prisma transaction conflict 판별
PostgreSQL serialization failure 처리

다만 각 feature에서 retry 대상으로 보는 오류나 실패 시 처리 방식에는 차이가 있으므로, 이것이 단순 복붙인지 의도적인 policy 분리인지는 추가 분석이 필요하다.

현재 문제는 공통 mechanism과 feature-specific policy의 경계가 명확하게 드러나지 않는다는 것이다.


6. Web API client에서 동일 protocol 처리 로직이 transport별로 반복됨

Web API client는 일반 요청에 fetch, 업로드 진행률이 필요한 경우 XMLHttpRequest를 사용한다.

두 transport가 별도로 존재하는 것 자체에는 명확한 이유가 있다.

하지만 양쪽에서 다음과 같은 API protocol 처리도 각각 수행한다.

HTTP 성공/실패 판정
204 처리
error body parsing
JSON parsing
ApiError 생성
{ ok: true, data } envelope 해제

따라서 transport의 차이와 API response semantics의 차이가 한 코드 안에서 섞여 있으며, 향후 한쪽만 수정될 가능성이 존재한다.


원인 분석

이 문제들은 대체로 하나의 패턴으로 설명할 수 있다.

기존 구조를 대체하기보다 새 구조를 추가하는 리팩터링

프로젝트는 여러 차례 안정성 및 architecture 리팩터링을 거쳤다.

이 과정에서 기존 코드를 직접 변경하거나 제거하기보다:

기존 구현
+
새 implementation / wrapper / registry / guard
+
양쪽의 일관성을 확인하는 test

형태로 변화한 흔적이 있다.

이 방식은 개별 리팩터링 시점에서는 위험이 작다. 기존 동작을 유지하면서 새로운 구조를 추가할 수 있기 때문이다.

하지만 반복되면 코드베이스에 이전 세대와 이후 세대의 구현이 동시에 남게 된다.


작은 범위의 문제를 해결한 변경들이 누적됨

각 변경 자체만 보면 합리적인 경우가 많다.

예를 들어:

session resolution과 authorization 분리
runtime response validation 추가
route contract 완전성 검증
transaction concurrency 안정화

등은 모두 실제 문제를 해결한다.

하지만 각 작업이 기존 전체 구조를 단순화하는 것보다 해당 요구사항을 안전하게 충족시키는 것에 집중하면, 새로운 계층이 기존 계층 위에 추가되기 쉽다.

결과적으로 각 기능은 안전해지지만 전체 구조에서는 같은 정보나 책임이 여러 위치에 존재하게 된다.


중복을 방지하는 검증이 오히려 중복 구조를 고착시킬 수 있음

현재 코드에는 architecture guard와 runtime contract test 등 강한 검증 장치가 존재한다.

이들은 이미 존재하는 여러 표현이 서로 어긋나지 않도록 하는 데 효과적이다.

그러나 경우에 따라서는:

A
B

A와 B가 같은지 검사하는 테스트

가 존재하는 것 자체가, 원래 하나의 source of truth로 표현할 수 있었던 정보를 둘로 유지하게 만드는 역할도 할 수 있다.

따라서 "테스트가 있으므로 이 구조가 필요하다"와 "이 구조가 존재하므로 동기화 테스트가 필요하다"를 구분해서 볼 필요가 있다.


의도적인 boundary 분리와 우연한 구현 중복이 섞여 있음

프로젝트에는 실제로 별도로 존재해야 하는 구조도 많다.

예:

DB model ↔ API DTO
transport validation ↔ Web form validation
각 durability state machine의 transition

이들은 모양이 비슷하더라도 독립적인 변경 이유가 있다.

반대로 auth guard처럼 동일 책임의 구현이 두 벌 존재하는 사례도 있다.

현재는 이 두 종류가 코드 전체에 섞여 있기 때문에 단순한 검색 결과만으로 "중복이니 제거"라고 판단하기 어렵다.


이 이슈의 목적

이 이슈에서는 당장 특정 architecture나 해결 방식을 정하지 않는다.

우선 프로젝트 전역의 중복/다중 source of truth를 조사하여 각 사례를 다음과 같이 구분하는 것이 목적이다.

1. 동일 책임의 불필요한 중복
2. 같은 사실을 여러 곳에서 유지하는 다중 source of truth
3. 공통 mechanism과 개별 policy가 섞여 발생한 유사 구현
4. 서로 다른 boundary이므로 의도적으로 유지되어야 하는 표현

이 구분이 끝난 이후에야 각 영역별로 무엇을 기준 구현으로 삼는 것이 실제로 더 단순하고 안전한지를 판단한다.

중복 제거 자체를 목표로 하지 않는다.

최종 목적은:

코드 한 부분의 의미를 이해하거나 변경하기 위해 확인해야 하는 위치를 줄이고, 어떤 구현과 정의가 기준인지 명확하게 만드는 것

이다.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions