[JB]
시리즈 · 1/1소프트웨어 공학
2026.07.27

경계와 인터페이스

어디에 둘 것인가

인턴으로 처음 회사에 들어와서 코드 베이스를 망치고 싶지 않았다. 그래서 코드를 쓸 때마다 이 코드가 여기 있어도 되는지 확인했다. 동작하는지보다 위치가 맞는지에 시간을 더 썼다.

지금 돌이켜 보면 그때 반복한 판단이 모듈의 경계를 긋는 연습이었다. 위치를 정하는 일은 무엇을 붙이고 무엇을 떼어낼지 정하는 일이다.

모듈은 크기가 아니라 속성이다

프로그래밍을 공부하면 모듈이라는 단어를 계속 만난다. 그런데 모듈이 무엇이냐는 질문에는 답이 잘 나오지 않았다. 함수도 모듈이라 부르고 패키지도 모듈이라 부르니 하나의 크기로 정리되지 않았다.

답이 막힌 이유는 모듈이라는 단어가 상대적이라는 데 있었다. 모듈보다 모듈성을 먼저 보면 정리된다.

모듈성

시스템 또는 컴퓨터 프로그램이 한 컴포넌트의 변경이 다른 컴포넌트에 미치는 영향이 최소화되도록 이산적 컴포넌트들로 구성된 정도

— ISO/IEC 25010 (소프트웨어 품질 모델), 원문

정의가 말하는 모듈성은 높은 응집도와 낮은 결합도라는 속성이다. 정해진 크기의 물리적 단위가 아니다. 그래서 함수, 클래스, 파일, 패키지, 서비스가 각자의 레이어에서 모듈이 된다. 모듈을 크기라는 단위로 설명하려고 하는 것 자체가 성립하지 않았던 것이다.

경계는 층위마다 있다

모듈이 상대적이면 모듈이 맞닿는 지점인 경계도 상대적이다. 결제 화면 하나를 그리는 코드에도 여러 층위의 경계가 함께 있다.

// 패키지·파일 경계
import { PaymentSummary } from '@/widgets/payment-summary/ui/PaymentSummary'
 
function CheckoutPage({ orderId }: { orderId: string }) {
  // 훅 호출 경계
  const { data: order } = useOrder(orderId)
  // 함수 호출 경계
  const total = calcOrderTotal(order.items)
  // 컴포넌트 경계
  return <PaymentSummary order={order} total={total} />
}

함수를 호출하는 코드, 컴포넌트가 훅을 부르는 지점, JSX에 놓인 컴포넌트, 패키지를 import해 인스턴스를 만드는 코드가 모두 경계다. 실제로 다루게 되는 대상은 개별 모듈이 아니라 이 경계를 넘나드는 상호작용이다.

경계에서 주고받는 규약

상호작용에는 규약이 필요하다. 제공자가 소비자에게 주는 그 규약이 인터페이스다. 경계의 층위가 여러 개니 인터페이스도 층위마다 모습이 다르다.

// 함수의 매개변수와 반환 타입
function calcOrderTotal(items: OrderItem[]): Money
 
// 훅의 인자와 반환 타입
function usePaymentMethods(userId: string): { methods: PaymentMethod[]; isLoading: boolean }
 
// JSX의 구조화된 형식
type PaymentSummaryProps = { order: Order; total: Money }
 
// Context Provider의 props 타입
type PaymentGatewayProviderProps = { gateway: PaymentGateway; children: ReactNode }

패키지가 내보내는 타입도 같은 역할을 한다. 형태는 달라도 하는 일은 하나다. 소비자가 알아야 할 것을 정하고, 나머지는 몰라도 되게 만든다. 건전한 경계를 원한다면 결국 인터페이스를 설계해야 한다.

경계가 흐려지면 모듈이 비대해진다

보통의 요구사항은 이렇게 간단하게 시작한다.

function CheckoutPage({ order }: { order: Order }) {
  const payable = order.total + Math.round(order.total * 0.1)
  return <button onClick={() => capture(order.payment.id, payable)}>{payable}원 결제</button>
}

세율이 화면에 있지만 화면이 하나였으므로 비용이 없었다. 이후 요구가 들어올 때마다 가장 작은 변경을 골랐고, 그 선택이 쌓이면서 모듈은 옆의 일을 하나씩 흡수했다.

변경의 비용

재결제 화면이 필요해졌을 때 계산을 복사했다. 도메인 모듈이 없으므로 그것이 작성 당시의 회귀가 없는 최소 변경이 된다.

// apps/checkout/CheckoutPage.tsx
const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0)
const discount = order.coupon ? Math.floor(total * order.coupon.rate) : 0
const payable = total - discount + Math.round((total - discount) * 0.1)
 
// apps/admin/RepaymentModal.tsx — 복사한 뒤 따로 자랐다
const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0)
const discount = order.coupon ? Math.floor(total * order.coupon.rate) : 0
// 여기에만 추가된 검사
const isCouponValid = new Date(order.coupon?.expiresAt ?? 0) > new Date()
// 세금 기준액이 다르다
const payable = total - (isCouponValid ? discount : 0) + Math.round(total * 0.1)

복사한 시점에는 두 계산이 같았다. 이후 만료 검사가 한쪽에만 들어가고 세금 기준액에서 할인을 빼는지가 갈렸다. 지금 두 화면은 같은 주문에 다른 금액을 청구한다. 세율을 바꾸려면 세 곳을 찾아야 하고, 그중 하나는 다른 팀이 관리하게 된다.

  • 한 모듈을 수정했는데 장애는 예상하지 못한 곳에서 발생한다
  • 변경이 어디까지 전파되는지 미리 알 수 없다
  • 순환 의존이 생기면 분리는 시스템이 커질 수록 어려워진다

인지 부하

목록에 배지를 추가하고, 관리자에게 필드를 하나 더 노출하고, 다국어를 붙였다. 매번 인자 하나와 분기 하나였다.

export function getPaymentView(dto: PaymentDto, isAdmin: boolean, locale: string) {
  const status = dto.status === 'CAPTURED' && dto.refundedAmount ? 'PARTIAL_REFUND' : dto.status
  const label = isAdmin ? ADMIN_LABEL[status] : (USER_LABEL[status] ?? dto.statusText)
 
  return {
    label: locale === 'ko' ? label : translate(label, locale),
    amount: formatCurrency(Number(dto.amount), dto.currency),
    gatewayCode: isAdmin ? dto.gatewayResultCode : undefined,
    // 환불 정책
    canRefund: isAdmin && dto.status === 'CAPTURED' && !dto.settledAt,
  }
}

이 함수는 서버 응답, 권한, 국제화, 환불 정책의 언어를 함께 쓴다. 어느 레이어에 속한 함수인지 말할 수 없다. 환불 조건이 바뀌면 뷰 함수를 고쳐야 한다.

  • 레이어별 언어가 섞여 도메인 지식이 코드에 드러나지 않는다
  • 한 부분을 읽으려면 주변 코드까지 함께 이해해야 한다
  • 비즈니스 로직이 흩어져 아는 사람만 아는 코드가 쌓인다

안정성

부분 환불 배지 규칙을 테스트로 고정하려 한다.

vi.mock('@/lib/session', () => ({ useSession: () => ({ user: { role: 'ADMIN' } }) }))
vi.mock('@/lib/featureFlag', () => ({ useFlag: () => true }))
vi.mock('@/lib/analytics')
 
it('부분 환불이면 배지를 보여준다', async () => {
  server.use(http.get('/api/orders/:id', () => HttpResponse.json(orderFixture)))
  render(<CheckoutPage orderId="o1" />)
  expect(await screen.findByText('부분 환불')).toBeInTheDocument()
})

검증 대상은 refundedAmount가 있으면 부분 환불이라는 한 줄이다. 그 한 줄을 직접 부를 수 없어 세션과 플래그를 대체하고 화면을 렌더한다. 테스트가 실패해도 원인이 규칙인지 목인지 구분되지 않는다.

  • 의존이 복잡해 테스트를 붙일 자리가 없다
  • 레이어 기준이 없어 새 코드를 어디에 쓸지 매번 다시 판단한다

협업

승인 이후에 할 일이 하나씩 추가됐다. 각 추가는 두세 줄이었고, 요구는 각각 다른 팀에서 왔다.

const onPay = async () => {
  const res = await capture(order.payment.id, payable)
 
  // 감사 로그
  if (session.user.role === 'ADMIN') void logAudit({ orderId: order.id, payable })
  // 전환 지표
  track('payment_captured', { variant: flags.checkoutVariant, payable })
  // 실패 건 재처리
  if (!res.ok) await enqueueSettlementRetry(order.id, res.status)
  // 실패 사유 노출
  if (!res.ok) setError(FAILURE_MESSAGE[res.body?.code] ?? '결제에 실패했습니다')
}

결제 로직을 고치려면 네 요구를 함께 이해해야 한다. 이 파일을 건드리는 변경은 서로의 리뷰와 배포를 기다린다.

  • 모듈이 강하게 엮여 다른 팀의 변경과 격리되지 않는다
  • 거대한 모놀리식 앱이 되어 독립 배포가 막힌다
  • 작은 수정에도 전체 파이프라인이 돌아 CI/CD 비용이 커진다

네 항목은 따로 오지 않는다. 의존이 얽히면 읽는 비용이 오르고, 읽는 비용이 오르면 테스트가 빠지고, 테스트가 없으면 격리가 안 된다. 그래서 시스템이 커질수록 문제는 기하급수적으로 커진다.

마지막 단계는 코드가 무서워서 아무도 건드리지 못하는 교착 상태다. 결제처럼 실패가 돈으로 환산되는 도메인에서는 이러한 부채는 더 직접적이다.

그래서 규칙을 화면에서 떼어낸다. 환불 판정을 payment.ts로, 주문 완료 판정을 order.ts로 옮긴다.

// payment.ts
import { isOrderCompleted } from './order'
 
export function canRefund(payment: Payment) {
  return payment.status === 'captured' && isOrderCompleted(payment.orderId)
}
 
// order.ts
import { isPaymentSettled } from './payment' // 순환
 
export function isOrderCompleted(orderId: string) {
  return isPaymentSettled(orderId)
}

환불 가능 여부는 주문 상태를 알아야 하고, 주문 완료 여부는 결제 상태를 알아야 한다. 각자 상대를 부르면서 두 파일이 서로를 참조한다. 파일은 둘로 나뉘었지만 한 덩어리로 빌드되고 함께 변한다.

경계는 파일을 나눈 개수가 아니다. 나눈 뒤에도 변경이 서로에게 흘러가면 선은 그어지지 않은 가짜 추상화가 된다.


선을 유지하려면

경계가 흐려서 생기는 문제는 선을 다시 분명하게 그어서 해결한다.

의존이 흐르는 방향을 고정하는 일과 경계에서 주고받는 것을 통제하는 일로 이를 실천할 수 있다.

의존은 한 방향으로만

먼저 정할 것은 변경의 영향이 흐르는 방향이다.

app → widgets → features → entities → shared
  • 문제가 생겼을 때 하위 모듈이 그대로라면 원인 범위를 좁힐 수 있다
  • 그래프가 명확하면 모듈을 지우는 데 비용이 들지 않는다

방향의 기준은 변경 빈도다. 변하기 쉬운 것이 변하기 어려운 것에 의존한다. 결제 수단 UI는 분기마다 바뀌지만 승인과 취소의 도메인 규칙은 그만큼 자주 바뀌지 않는다.

// widgets(변하기 쉬움)가 entities(변하기 어려움)를 의존한다
import { listPayments } from '@/entities/payment/api/listPayments'
 
// 역방향은 금지. entities가 불필요하게 UI를 알게 된다
import { PaymentSummary } from '@/widgets/payment-summary/ui/PaymentSummary'

하위가 상위를 필요로 할 때

단방향을 지키다 보면 하위 모듈이 상위의 기능을 필요로 하는 상황을 만난다. 결제 도메인이 실제 승인을 내려면 PG사 SDK를 호출해야 하는데, 그 SDK를 붙이는 일은 앱 조립 단계의 관심사다. 도메인이 특정 PG사를 직접 import하면 의존의 방향이 깨진다.

대신 하위 모듈이 필요한 능력만 포트로 선언하고, 상위 모듈이 그것을 구현해 주입한다.

// entities/payment — 도메인이 필요한 능력만 선언한다
export type PaymentGateway = {
  authorize(req: { orderId: string; amount: Money; idempotencyKey: string }): Promise<AuthorizeResult>
  capture(req: { paymentId: string; amount: Money; idempotencyKey: string }): Promise<CaptureResult>
}
 
export function createPaymentService(gateway: PaymentGateway) {
  return {
    authorize: (order: Order) =>
      gateway.authorize({
        orderId: order.id,
        amount: calcOrderTotal(order.items),
        idempotencyKey: `authorize:${order.id}:${order.revision}`,
      }),
 
    // 승인된 결제만 받는다. 멱등키는 도메인이 만든다.
    capture: (payment: AuthorizedPayment, amount: Money) =>
      gateway.capture({
        paymentId: payment.id,
        amount,
        idempotencyKey: `capture:${payment.id}:${amount.value}`,
      }),
  }
}
 
// app — PG SDK를 포트 모양에 맞춘 어댑터로 주입한다
const paymentService = createPaymentService(createTossGateway(tossSdk))

도메인은 결제사가 어디인지, 리다이렉트를 어떻게 처리하는지, 실패 코드가 어떤 문자열로 오는지 모른다. 그건 어댑터가 감춘다. 결제사를 추가하거나 교체하는 작업이 어댑터 한 파일로 끝나고, 승인 규칙은 그대로 남는다. 아래에서 위로 향할 화살표를 인터페이스로 끊는 방식이다.

멱등키를 만드는 규칙도 이 안에 있다. 같은 주문에 같은 키가 나오므로 네트워크 실패로 재시도해도 승인은 한 번만 일어난다. 호출부가 키를 만들면 이 보장이 호출부 수만큼 흩어진다.


제공자는 소비자를 모른다

방향을 정했으면 다음은 경계에서 주고받는 내용이다. 원칙은 두 줄이다. 제공자는 소비자를 알면 안 되고, 제공자 레이어는 소비자 레이어의 언어를 알면 안 된다.

이 선이 무너지면 하위 모듈이 오염된다. 오염은 모듈이 자기 책임을 넘어선 일을 하게 되는 상태다.

// 오염된 하위 모듈 — 화면 이름으로 분기한다
export function listPayments(params: PaymentListParams, from: 'admin' | 'checkout') {
  if (from === 'admin') {
    return client.get('payments', {
      ...params,
      statuses: ['CAPTURED', 'FAILED', 'REFUNDED'],
      size: 100,
    })
  }
  return client.get('payments', {
    ...params,
    statuses: ['CAPTURED'],
    size: 20,
    // 전역에서 로그인 사용자까지 꺼내 쓴다
    payerId: currentUser.id,
  })
}

UI 레이어의 흐름이 결제 도메인에 넘쳐 불필요한 분기가 생겼다. 조회 범위와 페이지 크기는 화면의 정책인데 추상화 레벨이 높은 모듈이 그것을 들고 있다. 정산 정책이 추가되면 분기가 선형적으로 늘어난다.

하위 모듈은 질의 능력만 도메인 어휘로 노출하고, 조합은 소비자가 한다.

// entities/payment — 질의 능력만 제공한다
export type PaymentQuery = {
  status?: PaymentStatus[]
  payerId?: UserId
  period?: DateRange
  page: { size: number; cursor?: string }
}
 
export function listPayments(query: PaymentQuery): Promise<Page<Payment>> {
  return client.get('payments', serializeQuery(query)).then(toPaymentPage)
}
// widgets/settlement-table — 정산 화면이 자기 정책을 조합한다
const settlements = usePaymentList({
  status: ['captured', 'refunded'],
  period: thisMonth(),
  page: { size: 100 },
})
 
// widgets/payment-history — 결제 이력은 본인 것만
const history = usePaymentList({
  payerId: me.id,
  status: ['captured'],
  page: { size: 20 },
})

화면이 하나 더 생겨도 listPayments는 열리지 않는다. 사용 패턴 자체를 몰라야 한다는 뜻은 아니다. 실패한 결제까지 조회하는 사용이 있다는 사실은 알아야 status를 인자로 열어 둘 수 있다. 패턴은 알고, 개별 소비자는 모른다.

들어오는 데이터는 자기 언어로 바꾼다

경계에서 입력을 받을 때는 자기 레이어의 언어로 변환해야 한다. 변환 없이 통과시키면 외부의 변경이 안쪽까지 깊게 전파된다.

결제 응답이 대표적이다. 서버 명세는 값이 있을 수도 있다는 optional 정보만 준다. 어떤 상태에서 어떤 필드가 보장되는지는 알려주지 않는다. 금액은 최소 단위 문자열로 와서 통화와 짝지어 다뤄야 한다.

// 서버 응답 — 상태와 필드의 관계가 타입에 없다
type PaymentResponse = {
  id: string
  status: 'PENDING' | 'AUTHORIZED' | 'CAPTURED' | 'FAILED'
  amount: string // 최소 단위 문자열
  currency: 'KRW' | 'USD'
  authorizedAt?: string
  capturedAmount?: string
  failureCode?: string
}

경계에서 상태별로 보장되는 필드만 갖는 형태로 바꾼다. 런타임 검증과 변환을 한 지점에서 끝낸다.

type Money = { value: number; currency: 'KRW' | 'USD' } // value는 최소 단위 정수
 
type Payment =
  | { id: string; status: 'pending'; amount: Money }
  | { id: string; status: 'authorized'; amount: Money; authorizedAt: Date }
  | { id: string; status: 'captured'; amount: Money; captured: Money }
  | { id: string; status: 'failed'; amount: Money; failureCode: PaymentFailureCode }
 
type AuthorizedPayment = Extract<Payment, { status: 'authorized' }>
 
const responseSchema = z.object({ /* PaymentResponse와 같은 형태 */ })
 
const PaymentSchema = responseSchema.transform((res, ctx): Payment => {
  const amount = toMoney(res.amount, res.currency)
 
  switch (res.status) {
    case 'PENDING':
      return { id: res.id, status: 'pending', amount }
    case 'AUTHORIZED':
      if (!res.authorizedAt) {
        // 계약 위반은 경계에서 끝낸다
        ctx.addIssue({ code: 'custom', message: 'AUTHORIZED 응답에 authorizedAt이 없다' })
        return z.NEVER
      }
      return { id: res.id, status: 'authorized', amount, authorizedAt: new Date(res.authorizedAt) }
    default:
      return toSettledPayment(res, amount) // CAPTURED · FAILED
  }
})

optional을 discriminated union으로 바꾸면 승인되지 않은 결제에서 authorizedAt을 읽는 코드가 컴파일 단계에서 막힌다. 금액을 Money로 감싸면 통화가 다른 값을 더하는 실수도 타입에서 걸린다.

효과는 사용처에서 드러난다.

// 변환이 없으면 사용처마다 방어 분기가 붙는다
if (res.status === 'AUTHORIZED' && res.authorizedAt) show(new Date(res.authorizedAt))
 
// 변환 후 — 존재가 타입으로 보장된다
if (payment.status === 'authorized') show(payment.authorizedAt)

스키마가 계약 검사도 겸한다. 상태와 필드가 어긋난 응답은 파싱 단계에서 실패한다. 잘못된 응답이 화면까지 흘러가 표시 단계에서 터지는 것보다, 들어온 자리에서 끊는 편이 원인 파악이 빠르다. 서버 명세가 바뀌어도 고칠 곳은 이 스키마 하나로 모인다.

공개 API만 내보낸다

변환을 해도 그 과정을 건너뛰는 경로가 열려 있으면 의미가 없다. 노출된 인터페이스가 잘못되어 있으면 계약과 다른 동작이 나온다. 모듈은 검증을 통과한 인터페이스만 제공한다.

// entities/payment/api/getPayment.ts
 
const PaymentSchema = /* ... */ // 스키마와 파서는 내보내지 않는다
 
export function getPayment(id: string): Promise<Payment> {
  return client.get(`payments/${id}`).then((res) => PaymentSchema.parse(res))
}

스키마를 함께 내보내면 소비자가 다시 서버 응답 형태에 의존한다. 그때부터는 명세가 바뀔 때마다 스키마 하나가 아니라 사용처 전체를 찾아야 한다.

쓰기는 한 곳에서만

데이터가 들어오는 경로를 정리했으면 바꾸는 경로도 정리해야 한다. 데이터의 원천은 한 모듈로 몰아 넣는다. 읽기와 쓰기를 레이어로 나누고, 쓰기 권한은 하나의 모듈만 갖게 해서 변경 지점을 예측 가능하게 만든다(CQRS).

// entities/payment — 읽기만
export function usePaymentList(query: PaymentQuery) {
  return useQuery({ queryKey: paymentKeys.list(query), queryFn: () => listPayments(query) })
}
 
// features/payment-capture — 쓰기만. 캐시 무효화가 여기서 끝난다.
export function useCapturePayment(payment: AuthorizedPayment) {
  const paymentService = usePaymentService() // app에서 주입한 인스턴스
  const queryClient = useQueryClient()
 
  return useMutation({
    mutationFn: (amount: Money) => paymentService.capture(payment, amount),
    retry: 3, // 멱등키가 도메인에 있으므로 같은 키로 재시도된다
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: paymentKeys.detail(payment.id) })
      queryClient.invalidateQueries({ queryKey: paymentKeys.list() })
    },
  })
}

원본을 아무 곳에서나 바꾸지 못하게 하고, 캐시 정책 같은 데이터의 라이프 사이클을 한 곳에서 관리하려는 분리다. 승인 경로가 하나면 재시도 정책도 한 곳에서 정한다. 화면마다 승인을 부르면 중복 클릭과 재시도를 막는 방법도 화면마다 다시 정해야 한다.

규칙은 도구가 지킨다

여기까지의 규칙은 사람이 기억하는 동안만 유효하다. 그래서 도구로 강제해 코드가 통합되기 전에 잡는다. 린트와 테스트로 컴파일 타임에 걸리게 하는 것이 목표다.

// dependency-cruiser — 레이어 순서 하나에서 규칙을 만든다
const layers = ['app', 'widgets', 'features', 'entities', 'shared']
 
forbidden: [
  ...layers.slice(1).map((layer, i) => ({
    name: `no-upward-import:${layer}`,
    from: { path: `^src/${layer}/` },
    to: { path: `^src/(${layers.slice(0, i + 1).join('|')})/` }, // 자기보다 위 레이어
  })),
  {
    name: 'no-sibling-import',
    from: { path: '^src/[^/]+/(?<slice>[^/]+)/' },
    to: { path: '^src/[^/]+/(?<slice>[^/]+)/', pathNot: '^src/[^/]+/$<slice>/' },
  },
  { name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
]

레이어 순서가 배열 한 곳에만 있으므로 레이어를 더하거나 이름을 바꿀 때 규칙이 따라온다. 서로를 부르던 두 도메인 모듈의 고리는 no-circular가 잡는다.

여기에 의존성 그래프를 시각화해 두면 순환 의존과 비대해지는 모듈을 미리 관측할 수 있다. 리뷰에서 걸러지는 규칙과 빌드에서 걸리는 규칙은 수명이 다르다.


다시 어디에 둘 것인가

글을 시작할 때의 질문은 이 코드가 여기 있어도 되는지였다. 지금은 같은 질문을 다르게 던진다. 이 코드를 고칠 때 변경이 어디까지 흘러가나.

경계는 그 흐름을 끊는 선이고, 인터페이스는 그 선에서 주고받기로 정한 약속이다. 선을 어디에 긋느냐가 다음 변경의 범위를 정한다.

FSD는 폴더 정리 규칙이 아니다

프론트엔드 아키텍처로 자주 거론되는 FSD도 단순히 디렉토리를 정리하는 방법론이 아니다. 폴더 규칙으로 읽히는 것은 결과물이 디렉토리 구조로 드러나기 때문이다. 실제로 강제하는 것은 레이어 사이의 의존 방향, 같은 레이어 슬라이스 간의 격리, 슬라이스 밖에서 접근할 수 있는 경로 세 가지다. 앞에서 다룬 단방향 의존과 공개 API가 폴더와 import 규칙의 형태로 들어가 있다.

정해 주지 않는 것도 있다. 무엇이 하나의 슬라이스인지, 두 도메인을 함께 아는 규칙을 어느 레이어에 둘지는 규칙에 없다. paymentorder가 서로를 부르던 고리에서 도구는 순환을 막아 주지만, 그 규칙을 어디로 옮길지는 사람이 정해야 했다. 아키텍처가 대신해 주는 것은 방향이고, 무엇을 한 덩어리로 볼지는 판단으로 남는다.

AI가 코드를 쓰는 환경에서는 더 중요하다

AI가 코드를 대신 쓰는데도 이런 고민이 필요하냐는 질문을 받은 적이 있다. 나는 반대로 생각한다.

AI는 제한된 컨텍스트 안에서 답을 만든다. 에이전트가 작업할 모듈의 범위가 분명하면 읽어야 할 컨텍스트의 양이 줄어든다. 결제 승인 규칙을 고치는 작업이라면 도메인 모듈과 포트 정의만 읽으면 되고, 어댑터 내부와 화면 코드는 볼 필요가 없다.

경계가 없을 때의 손해도 같은 비율로 커진다. 금액 계산이 두 파일로 갈라지는 데 6개월이 걸렸다. 같은 복제가 지금은 요청 한 번으로 일어난다. 생산 속도가 오르면 잘못된 패턴이 퍼지는 속도도 함께 오른다.

그래서 도구로 강제한 규칙의 값이 올라간다. no-upward-import는 코드를 누가 썼는지 묻지 않는다. 리뷰로 지키던 규칙은 생산량이 늘면 통과율이 떨어지고, 빌드로 지키던 규칙은 그대로 작동한다.

이상과 제약 사이

실무에서는 현실적인 제약 때문에 이상적인 경계와 인터페이스를 세우지 못하는 경우가 있다. 손대야 할 범위가 이미 너무 커져버렸거나, 당장 개선하기에는 개발 일정이 밀리는 상황이 온다.

그렇다고 이상적인 그림을 아는 것이 무의미해지지는 않는다. 그림을 알고 제약을 반영해 쓴 코드는 어느 지점을 타협했는지 코드에 남는다. 나중에 그 지점부터 고치면 된다. 그림 없이 쓴 코드는 어디부터 손대야 할지 다시 찾아야 한다.

그래서 개선을 쉽게 만들 밑작업을 함께 넣는 쪽으로 코드를 쓴다. 레이어를 다 나누지 못해도 변환은 한 함수에 모아 두고, 쓰기 경로는 되도록 좁혀 두고, 지금 지킬 수 있는 규칙만이라도 도구에 올린다.

기준은 완성이 아니다. 다음 사람이 선을 옮길 수 있게 두는 것이다. 이런 제약 안에서 판단을 반복하는 일이 요즘은 재미있게 느껴진다.

16 views