인터페이스 설계 전략 (백엔드)

CHOI HYUK·2026년 5월 31일

개발 기법

목록 보기
2/2

🖥️ 시작하기에 앞서

이전 글에서는 프론트엔드 관점에서 백엔드 인터페이스를 어떻게 받아와야 하는지 정리했다.

프론트엔드는 백엔드가 제공하는 요청/응답 스키마, 에러 코드, 상태 코드, 이벤트 이름을 기준으로 화면을 만든다. 그래서 프론트엔드가 API 응답을 화면마다 임의로 재해석하기 시작하면 유지보수가 어려워진다.

결국 프론트엔드는 백엔드 인터페이스에 전적으로 의존한다.

따라서 백엔드는 프론트엔드가 추측하지 않아도 되는 명확한 패턴으로 응답을 전달해야 한다. 성공 응답, 에러 응답, 이벤트 payload, 상태 코드가 기능마다 다르게 구성되면 프론트엔드는 매번 새로운 예외 처리를 만들 수밖에 없다.

그렇다면 반대로 백엔드는 어떤 인터페이스를 제공해야 할까?

단순히 API 응답 타입을 예쁘게 만드는 것만으로는 부족하다. 백엔드 API는 최종적으로 프론트엔드에 노출되는 인터페이스지만, 그 뒤에는 데이터베이스, 외부 서비스, 도메인 로직, 인증, 캐시 같은 여러 내부 인터페이스가 얽혀 있다.

이 내부 구조가 정리되어 있지 않으면 API 인터페이스도 오래 안정적으로 유지되기 어렵다.

처음에는 컨트롤러에서 데이터베이스를 조회하고, 필요한 외부 API를 호출하고, 응답을 만들어도 크게 문제가 없어 보인다. 하지만 기능이 늘어나면 한 API가 여러 provider를 직접 알고, 여러 서비스가 서로를 호출하고, 공통 로직이 여기저기 복사된다.

이때부터 백엔드 코드는 단순히 길어지는 것이 아니라 의존 방향이 흐려진다.

이번 글에서는 백엔드 인터페이스를 API, 데이터베이스, 외부 서비스 관점으로 나누고, 이들을 어떤 책임으로 연결해야 하는지 정리해보려고 한다.


🧩 백엔드가 다루는 세 가지 인터페이스

백엔드에서 인터페이스라고 하면 보통 API만 떠올리기 쉽다.

하지만 실제로 백엔드가 다루는 인터페이스는 하나가 아니다. 크게 보면 다음 세 가지를 생각할 수 있다.

  • API 인터페이스 레이어
  • 데이터베이스 인터페이스 레이어
  • 외부 서비스 인터페이스 레이어

API 인터페이스 레이어는 외부 요청 주체가 직접 접근하는 경계이다. 요청을 받고, 응답을 내려주고, 에러를 정해진 형태로 반환한다. OpenAPI 문서로 관리되는 부분도 대부분 이 레이어에 해당한다.

데이터베이스 인터페이스 레이어는 애플리케이션이 데이터를 저장하고 조회하기 위해 사용하는 경계이다. ORM, repository, query builder, raw SQL 등 구현 방식은 다를 수 있지만 핵심은 같다. 애플리케이션 코드가 데이터 저장소를 어떤 방식으로 다룰지 정하는 내부 계약이다.

외부 서비스 인터페이스 레이어는 결제, 메일, 알림, 파일 저장소, 인증 제공자 같은 외부 시스템과 연결되는 경계이다. 외부 API의 요청/응답 구조, 인증 방식, 실패 케이스, 재시도 정책을 감싸는 역할을 한다.

Client
  ↓
API Interface Layer
  ↓
Application / Domain Service
  ↓
Database Provider
External Service Provider

여기서 중요한 것은 API 인터페이스가 혼자 존재하지 않는다는 점이다.

백엔드가 최종적으로 보여주는 것은 API 인터페이스이지만, 그 API는 데이터베이스와 외부 서비스에서 가져온 값을 조합해서 만들어진다. 따라서 좋은 API 인터페이스를 만들려면 내부 provider들을 어떤 기준으로 연결할지 먼저 정해야 한다.


📄 API 인터페이스는 최종 출력이다

백엔드에서 API 응답 스키마는 최종 출력에 가깝다.

데이터베이스에서 조회한 값, 외부 서비스에서 가져온 값, 내부 정책을 적용한 값이 최종적으로 API 응답 스키마를 통해 밖으로 나간다. 따라서 응답 스키마는 단순히 데이터를 담는 객체가 아니라, 사용자가 접근할 수 있는 데이터의 경계이다.

예를 들어 FastAPI와 Pydantic을 사용한다면 응답 스키마를 다음처럼 명시할 수 있다.

from pydantic import BaseModel, EmailStr


class UserProfileResponse(BaseModel):
    id: int
    email: EmailStr
    name: str
    plan: str
    email_verified: bool

이 스키마에 포함된 필드는 API를 호출하는 사용자가 접근 가능한 데이터가 된다. 그래서 응답 스키마는 명확해야 한다. 어떤 필드를 제공할지, 어떤 이름으로 제공할지, 어떤 타입으로 제공할지를 백엔드가 의도적으로 결정해야 한다.

응답 스키마를 만들 때는 최소한 성능, 보안, 데이터 적절성을 함께 봐야 한다.

  • 성능: 응답에 포함된 필드를 만들기 위해 불필요한 join, 추가 query, 외부 API 호출이 발생하지 않는가?
  • 보안: password hash, token, 내부 식별자, 관리자 메모 같은 민감한 값이 노출되지 않는가?
  • 데이터 적절성: 이 API의 목적에 맞는 데이터만 포함되어 있는가?

API 인터페이스는 내부 구현을 그대로 노출하는 창구가 아니라, 외부에서 접근해도 되는 데이터만 정리해서 제공하는 최종 출력이어야 한다.

그래서 백엔드에서는 API 응답을 만들 때 다음 질문을 먼저 해야 한다.

  • 이 필드는 이 API의 목적에 필요한 값인가?
  • 이 필드는 내부 DB 컬럼명을 그대로 노출하고 있지는 않은가?
  • 외부 서비스의 응답 구조를 그대로 API로 흘려보내고 있지는 않은가?
  • 이 필드를 만들기 위해 과한 비용의 조회나 외부 호출이 필요하지 않은가?
  • 이 필드가 사용자에게 노출되어도 보안상 문제가 없는가?

API 인터페이스가 최종 출력이라는 말은 단순히 response DTO를 만들라는 뜻이 아니다.

데이터베이스와 외부 서비스의 세부 구현을 숨기고, 외부에 공개 가능한 데이터만 명확한 규약으로 제공하는 책임이 API 레이어에 있다는 뜻이다.

이 관점은 HTTP API에만 적용되지 않는다.

SSE, Socket, WebSocket 같은 이벤트 기반 인터페이스도 마찬가지로 최종 출력 인터페이스이다. 요청/응답 형태가 아니더라도 외부 사용자는 서버가 보내는 이벤트 이름, payload schema, 상태 값, 에러 이벤트를 기준으로 동작을 해석한다.

따라서 SSE나 Socket을 구성할 때도 API 응답 스키마와 같은 기준이 필요하다.

  • 이벤트 이름은 도메인 기준으로 예측 가능해야 한다.
  • payload schema는 명확하게 고정되어야 한다.
  • 내부 DB 모델이나 외부 서비스 응답을 그대로 흘려보내지 않아야 한다.
  • 이벤트 payload에 포함되는 데이터도 성능, 보안, 데이터 적절성을 기준으로 판단해야 한다.
  • 실패 이벤트나 종료 이벤트도 정상 이벤트와 같은 인터페이스로 관리해야 한다.

즉, HTTP API는 response schema를 통해 인터페이스를 제공하고, SSE나 Socket은 event schema를 통해 인터페이스를 제공한다. 형태만 다를 뿐, 백엔드가 외부에 공개하는 최종 출력이라는 점은 같다.


🧱 데이터베이스와 외부 서비스는 provider로 감싼다

백엔드에서 책임 분리를 하려면 먼저 provider의 기준을 명확히 해야 한다.

여기서 provider는 특정 외부 자원에 접근하는 구체적인 구현을 감싼 객체라고 볼 수 있다. 데이터베이스 provider는 DB 조회와 저장을 담당하고, 외부 서비스 provider는 외부 API 호출을 담당한다.

예를 들어 사용자 정보를 조회하는 코드가 있다고 해보자.

class UserRepository {
    async findById(userId: number) {
        return db.user.findUnique({
            where: { id: userId },
        });
    }
}

이 코드는 데이터베이스 접근을 담당한다. 사용자 프로필 API가 이 repository를 사용할 수는 있지만, repository가 API 응답 형태나 서비스 정책까지 알아서는 안 된다.

외부 결제 서비스를 호출하는 코드도 마찬가지이다.

class PaymentProvider {
    async getSubscription(userId: number) {
        const response = await paymentClient.get(`/subscriptions/${userId}`);

        return {
            plan: response.data.plan,
            status: response.data.status,
            expiredAt: response.data.expired_at,
        };
    }
}

이 provider의 책임은 결제 서비스와 통신하고, 애플리케이션이 다루기 쉬운 형태로 외부 응답을 변환하는 것이다. 여기서도 최종 API 응답을 만들어서는 안 된다.

provider는 외부 세계와 연결되는 가장 낮은 경계이다.

따라서 provider는 다음 책임에 집중하는 편이 좋다.

  • 데이터베이스 또는 외부 서비스에 접근한다.
  • 외부 시스템의 응답을 애플리케이션 내부에서 다룰 수 있는 형태로 변환한다.
  • 외부 시스템의 장애, 인증 실패, 타임아웃 같은 실패를 명확한 에러로 바꾼다.
  • 도메인 정책이나 API 응답 정책을 직접 결정하지 않는다.

이 기준이 없으면 provider가 점점 커진다. 처음에는 단순한 API 호출 함수였지만, 어느 순간 할인 정책을 계산하고, 사용자 권한을 확인하고, 프론트엔드 응답 필드까지 조립하게 된다.

그렇게 되면 provider는 더 이상 provider가 아니라 여러 책임이 섞인 서비스가 된다.


🔗 중간 서비스 레이어가 필요한 이유

provider가 외부 자원과의 연결을 담당한다면, 여러 provider를 조합하는 책임은 어디에 있어야 할까?

이때 필요한 것이 application service 또는 use case service 같은 중간 서비스 레이어이다. 이름은 팀마다 다를 수 있지만 역할은 비슷하다.

중간 서비스 레이어는 API 요청을 처리하기 위해 필요한 provider들을 조합하고, 도메인 정책을 적용한 뒤, API 레이어가 응답으로 만들 수 있는 결과를 반환한다.

예를 들어 사용자 프로필 API를 만든다고 해보자.

class UserProfileService {
    constructor(
        private readonly userRepository: UserRepository,
        private readonly paymentProvider: PaymentProvider,
        private readonly authProvider: AuthProvider,
    ) {}

    async getProfile(userId: number) {
        const user = await this.userRepository.findById(userId);
        const subscription = await this.paymentProvider.getSubscription(userId);
        const emailStatus = await this.authProvider.getEmailStatus(userId);

        return {
            id: user.id,
            name: user.name,
            emailVerified: emailStatus.verified,
            plan: subscription.plan,
        };
    }
}

이 코드는 여러 provider를 조합한다. 사용자 기본 정보는 DB에서 가져오고, 구독 정보는 결제 provider에서 가져오고, 이메일 인증 상태는 인증 provider에서 가져온다.

여기서 중요한 점은 API 컨트롤러가 이 provider들을 직접 모두 알지 않아도 된다는 것이다.

class UserController {
    constructor(private readonly userProfileService: UserProfileService) {}

    async getProfile(request: Request) {
        const profile = await this.userProfileService.getProfile(
            request.user.id,
        );

        return {
            data: profile,
        };
    }
}

컨트롤러는 HTTP 요청과 응답에 집중한다. 어떤 provider를 몇 개 호출해야 하는지, 어떤 순서로 조합해야 하는지, 어떤 내부 정책을 적용해야 하는지는 service가 담당한다.

이렇게 나누면 각 레이어의 책임이 비교적 명확해진다.

  • controller: HTTP 요청과 응답을 다룬다.
  • service: use case 흐름과 도메인 정책을 조합한다.
  • provider/repository: DB 또는 외부 서비스와 연결한다.
  • util: 여러 곳에서 반복되는 순수 계산 또는 변환을 담당한다.

책임 분리는 파일을 많이 나누는 것이 아니다. 코드를 보고 "이 변경은 어디에서 해야 하는가"를 예측할 수 있게 만드는 것이다.


🔁 공통 기능은 util로 빼되, 정책은 service에 둔다

여러 서비스에서 반복해서 쓰는 기능이 생기면 util로 옮기는 것이 좋다.

하지만 무엇이든 util로 빼면 오히려 구조가 흐려진다. util은 가능하면 외부 상태에 의존하지 않는 순수한 기능이어야 한다.

예를 들어 날짜 포맷 변환, 금액 계산, 문자열 정규화 같은 기능은 util로 옮기기 좋다.

function formatMoney(amount: number, currency: string) {
    return new Intl.NumberFormat("ko-KR", {
        style: "currency",
        currency,
    }).format(amount);
}

반대로 다음과 같은 코드는 util로 빼기 애매하다.

async function canCancelOrder(orderId: number) {
    const order = await orderRepository.findById(orderId);
    const payment = await paymentProvider.getPayment(order.paymentId);

    return order.status === "PAID" && payment.status !== "REFUNDED";
}

이 코드는 단순 유틸이 아니다. DB를 조회하고, 외부 결제 서비스를 호출하고, 주문 취소 정책을 판단한다. 이런 코드를 util로 빼면 이름은 util이지만 실제로는 여러 provider를 몰래 알고 있는 서비스가 된다.

이런 로직은 OrderService 같은 use case service에 두는 편이 낫다.

class OrderService {
    constructor(
        private readonly orderRepository: OrderRepository,
        private readonly paymentProvider: PaymentProvider,
    ) {}

    async canCancelOrder(orderId: number) {
        const order = await this.orderRepository.findById(orderId);
        const payment = await this.paymentProvider.getPayment(order.paymentId);

        return order.status === "PAID" && payment.status !== "REFUNDED";
    }
}

util과 service의 차이를 구분하는 기준은 의존성이다.

외부 상태 없이 입력값만으로 결과를 만들 수 있다면 util에 가깝다. DB, 외부 API, 인증 정보, 현재 사용자, 트랜잭션 같은 컨텍스트가 필요하다면 service에 가깝다.


🚨 책임 분리가 무너지면 생기는 문제

백엔드 코드에서 가장 조심해야 할 구조 중 하나는 여러 서비스가 서로를 직접 사용하는 구조이다.

예를 들어 UserServiceOrderService를 호출하고, OrderService가 다시 UserService를 호출하는 구조를 생각해볼 수 있다.

UserService → OrderService
OrderService → UserService

처음에는 편하다. 이미 만들어둔 메서드를 가져다 쓰면 되기 때문이다. 하지만 이런 호출이 늘어나면 서비스 간 의존 방향이 금방 꼬인다.

더 위험한 경우는 다음과 같다.

UserService → OrderService → PaymentService → UserService

이 구조는 순환 의존성을 만들기 쉽다. 어떤 서비스를 수정하려고 해도 다른 서비스의 초기화 순서, 테스트 더블, 트랜잭션 범위, 예외 처리 방식까지 함께 고려해야 한다.

의존성 역전도 비슷한 문제를 만든다.

원래는 고수준 정책이 저수준 구현을 사용해야 한다. 예를 들어 OrderService는 주문 정책을 담당하고, PaymentProvider는 결제 외부 API 호출을 담당한다. 그런데 PaymentProviderOrderService의 정책을 알아야 하거나, repository가 service를 호출하기 시작하면 의존 방향이 뒤집힌다.

좋은 방향
OrderService → PaymentProvider

위험한 방향
PaymentProvider → OrderService

provider가 service를 알기 시작하면 provider는 더 이상 외부 연결 경계가 아니다. 특정 use case에 종속된 구현이 된다. 그러면 다른 use case에서 재사용하기 어렵고, 외부 서비스 변경이 도메인 서비스 전체로 퍼진다.

따라서 여러 서비스가 서로를 직접 사용해야 할 것 같다면 먼저 질문해야 한다.

  • 이 로직은 정말 기존 service를 호출해야 하는가?
  • 공통으로 필요한 순수 로직이라면 util로 분리할 수 있는가?
  • 여러 provider를 조합하는 별도의 use case service가 필요한가?
  • 한 서비스가 너무 많은 책임을 가지고 있지는 않은가?
  • provider가 service의 정책을 알고 있지는 않은가?

서비스를 무조건 작게 나누는 것이 정답은 아니다. 하지만 의존 방향이 흐려지는 순간부터 코드는 빠르게 관리하기 어려워진다.


🔒 API 응답은 내부 모델을 그대로 내보내지 않는다

데이터베이스 인터페이스와 API 인터페이스를 분리해야 하는 이유는 응답 모델에서도 드러난다.

예를 들어 DB의 user 테이블이 다음 필드를 가진다고 해보자.

from datetime import datetime
from pydantic import BaseModel, EmailStr


class UserEntity(BaseModel):
    id: int
    email: EmailStr
    password_hash: str
    name: str
    plan_code: str
    created_at: datetime
    updated_at: datetime

이 entity를 API 응답으로 그대로 반환하면 문제가 생긴다.

return user

당장은 빠르지만, API 인터페이스가 DB 스키마에 강하게 묶인다. password_hash 같은 민감한 필드가 노출될 위험도 있고, plan_code처럼 내부 코드값이 그대로 전달될 수도 있다.

API 응답은 명시적으로 만들어야 한다.

from typing import Literal


class UserResponse(BaseModel):
    id: int
    email: EmailStr
    name: str
    plan: Literal["free", "pro", "enterprise"]


def to_user_response(user: UserEntity) -> UserResponse:
    return UserResponse(
        id=user.id,
        email=user.email,
        name=user.name,
        plan=map_plan_code(user.plan_code),
    )

이 변환은 단순한 보일러플레이트가 아니다. DB 인터페이스와 API 인터페이스 사이의 경계를 명확히 하는 코드이다.

백엔드에서도 DB 모델과 API 응답 모델은 구분해야 한다. DB 모델은 저장 구조를 표현하고, API 응답 모델은 외부에 공개할 데이터 계약을 표현한다.

이 둘을 같은 타입으로 취급하면 저장소 변경이 곧 API 변경이 된다.


⚠️ 에러 스키마에서 결합도를 바라보는 관점

정상 응답 스키마만큼 중요한 것이 에러 응답 스키마이다.

에러 응답도 최종 API 인터페이스의 일부이다. 성공했을 때 어떤 데이터를 내려줄지 정하는 것처럼, 실패했을 때 어떤 코드와 메시지, 상세 정보를 내려줄지도 명확하게 정해야 한다.

예를 들어 다음처럼 공통 에러 스키마를 둘 수 있다.

from typing import Any
from pydantic import BaseModel


class ErrorResponse(BaseModel):
    code: str
    message: str
    details: dict[str, Any] | None = None

문제는 이 에러 스키마를 어디에서 구성할 것인가이다.

가장 단순한 방법은 service 안에서 에러 응답을 직접 만드는 것이다.

class UserService:
    async def get_profile(self, user_id: int):
        user = await self.user_repository.find_by_id(user_id)

        if user is None:
            raise ApiError(
                status_code=404,
                code="USER_NOT_FOUND",
                message="User not found.",
            )

        return build_user_profile(user)

이 방식은 구현하기 쉽다. 하지만 service 안에 status_code, code, message 같은 API 에러 스키마 정보가 직접 들어간다. service 레이어가 최종 API 인터페이스를 고려하게 되는 것이다.

이 경우 API 라우터와 service의 결합도는 올라간다.

그렇다고 에러 스키마 구성을 전부 라우터로 옮기면 문제가 사라질까? 그렇지 않다. 라우터는 service에서 발생할 수 있는 모든 실패 경우의 수를 알아야 한다.

@router.get("/users/{user_id}")
async def get_user_profile(user_id: int):
    try:
        return await user_service.get_profile(user_id)
    except UserNotFoundError:
        return ErrorResponse(
            code="USER_NOT_FOUND",
            message="User not found.",
        )
    except SubscriptionExpiredError:
        return ErrorResponse(
            code="SUBSCRIPTION_EXPIRED",
            message="Subscription expired.",
        )

이렇게 되면 라우터가 service 내부의 실패 케이스를 모두 고려해야 한다. 라우터와 service의 결합도는 여전히 높다. 위치만 바뀌었을 뿐, 최종 출력 인터페이스와 중간 service 레이어 사이의 결합이 사라진 것은 아니다.

나는 이 지점에서 결합도를 무조건 낮추려는 접근보다, 결합도가 올라갈 수밖에 없다는 사실을 먼저 인정하는 편이 낫다고 생각한다.

API 라우터는 최종 출력 인터페이스이다. 중간 service 레이어는 데이터베이스와 외부 서비스를 통합해서 use case를 완성한다. 이 둘은 필연적으로 맞물릴 수밖에 없다. service에서 어떤 실패가 발생할 수 있는지 알아야 API는 올바른 에러 스키마를 제공할 수 있다.

외부 서비스나 데이터베이스 연동도 비슷하다.

결제 서비스를 연동하면 해당 결제사의 SDK나 API 에러 코드에 의존하게 된다. 데이터베이스를 사용하면 ORM, driver, transaction 처리 방식에 의존하게 된다. 최종 출력 인터페이스가 있는 지점은 항상 특정 구현과 높은 결합도를 가질 수밖에 없다.

중요한 것은 결합도를 없애는 것이 아니라 결합되는 지점을 명확히 관리하는 것이다.

그래서 에러 스키마도 별도의 handler 계층으로 분리해서 도메인 또는 service 단위로 관리하는 편이 좋다. handler는 service에서 발생한 실패 조건을 API 에러 스키마로 바꾸는 전담 계층이다.

class UserNotFoundError(Exception):
    pass


class ApiException(Exception):
    def __init__(self, status_code: int, response: ErrorResponse):
        self.status_code = status_code
        self.response = response


class UserErrorHandler:
    @staticmethod
    def user_not_found() -> ApiException:
        return ApiException(
            status_code=404,
            response=ErrorResponse(
                code="USER_NOT_FOUND",
                message="User not found.",
            ),
        )

    @staticmethod
    def payment_unavailable() -> ApiException:
        return ApiException(
            status_code=503,
            response=ErrorResponse(
                code="PAYMENT_UNAVAILABLE",
                message="Payment service is unavailable.",
            ),
        )

service는 provider와 database를 조합하다가 실패 조건을 만나면 해당 도메인의 error handler를 호출한다.

class UserService:
    def __init__(
        self,
        user_repository: UserRepository,
        payment_provider: PaymentProvider,
        error_handler: UserErrorHandler,
    ):
        self.user_repository = user_repository
        self.payment_provider = payment_provider
        self.error_handler = error_handler

    async def get_profile(self, user_id: int):
        user = await self.user_repository.find_by_id(user_id)

        if user is None:
            raise self.error_handler.user_not_found()

        try:
            subscription = await self.payment_provider.get_subscription(user_id)
        except PaymentProviderError:
            raise self.error_handler.payment_unavailable()

        return build_user_profile(user, subscription)

라우터는 service가 반환한 정상 결과를 내려주고, ApiException은 공통 exception handler에서 최종 응답으로 변환한다.

@router.get("/users/{user_id}")
async def get_user_profile(user_id: int):
    return await user_service.get_profile(user_id)


@app.exception_handler(ApiException)
async def handle_api_exception(request: Request, error: ApiException):
    return JSONResponse(
        status_code=error.status_code,
        content=error.response.model_dump(),
    )

이 구조에서도 결합도는 존재한다. UserService는 실패 조건을 알고 있고, UserErrorHandler는 그 실패 조건을 API 에러 스키마로 바꾸는 방법을 안다. 결국 service와 최종 API 인터페이스 사이의 결합은 사라지지 않는다.

하지만 결합되는 지점이 명확하다.

service는 provider와 database를 조합하는 흐름을 유지하고, 에러 스키마 생성 규칙은 handler에 모인다. router는 service 내부 실패 케이스를 전부 나열하지 않고, 공통 exception handler를 통해 최종 응답만 처리한다.

따라서 에러 스키마를 설계할 때의 목표는 결합도를 0으로 만드는 것이 아니다. 어떤 도메인에서 어떤 에러가 발생하고, 그 에러가 어떤 API 스키마로 변환되는지 한 곳에서 추적할 수 있게 만드는 것이다.


⚡ 성능을 고려한 반환 형식

백엔드가 명확한 인터페이스를 제공한다는 말이 데이터를 정해진 형식으로 전달하기만 하면 된다는 뜻은 아니다.

이상적으로는 백엔드와 프론트엔드가 각자의 책임만 지키면 된다. 백엔드는 안정적인 인터페이스를 제공하고, 프론트엔드는 그 인터페이스를 받아 UI를 구성한다. 하지만 현실적으로 백엔드는 프론트엔드가 데이터를 받은 뒤 어떤 비용을 치르는지도 고려할 수밖에 없다.

예를 들어 백엔드가 엄청난 양의 데이터를 한 번에 내려준다고 해보자. 서버 입장에서는 단순히 조회 결과를 반환한 것처럼 보일 수 있다. 하지만 프론트엔드에서 그 트래픽을 받고 파싱하고 상태에 반영하고 화면에 렌더링하는 데 수초가 걸린다면, 이 문제는 프론트엔드만의 문제가 아니다.

이 경우 백엔드 단위에서도 조치가 필요하다. 페이지네이션, cursor 기반 조회, 필드 축소, 요약 응답, lazy loading을 고려한 API 분리처럼 라우터 반환 형식을 더 구체적으로 설계해야 한다.

개발자가 처음부터 모든 데이터 크기와 화면 처리 비용을 예측해서 구성하면 좋겠지만, 현실적으로는 어렵다. 그래서 성능 문제가 드러나는 순간 바로 고도화 기법을 적용할 수 있도록 인터페이스를 구성해야되는 것이다.


😘 마무리

외부 요청 주체는 백엔드 API 인터페이스에 의존한다. 그래서 백엔드는 사용자가 신뢰할 수 있는 안정적인 API 인터페이스를 제공해야 한다.

하지만 좋은 API 인터페이스는 응답 타입만 잘 만든다고 생기지 않는다. API 뒤에 있는 데이터베이스 인터페이스외부 서비스 인터페이스가 명확하게 분리되어 있어야 한다.

나는 백엔드 인터페이스를 설계할 때 다음 기준을 먼저 떠올리려고 한다.

API는 최종 출력이다.
DB와 외부 서비스는 provider로 감싼다.
여러 provider를 조합하는 책임은 중간 service에 둔다.
공통 순수 로직은 util로 분리한다.
service끼리 직접 얽히기 시작하면 의존 방향을 다시 본다.
에러 스키마는 결합도를 인정하고 handler 계층에서 관리한다.

결국 백엔드에서 인터페이스를 설계한다는 것은 내부 구현을 감추는 동시에, 각 구현이 어디에 속해야 하는지 정하는 일이다.

따라서 구현을 시작할 때마다 먼저 확인해야 한다.

지금 작성하는 코드는 provider인가, 여러 provider를 조합하는 service인가, 아니면 사용자에게 공개할 API 인터페이스인가?

이 질문에 답할 수 있으면 백엔드 코드는 훨씬 예측 가능한 방향으로 정리된다.

0개의 댓글