slowlp
← 블로그
방법 2026.06.22 · 5분 읽기

라우터는 HTTP만, 로직은 서비스로: 계층 분리

라우터·서비스·리포지토리로 계층마다 책임을 한 줄씩 깔끔하게 나눈 이유와 효과

방법

라우터·서비스·리포지토리 책임을 한 줄씩 나눈 이유

처음 API를 만들 때 나는 라우터 함수 하나에 전부 욱여넣었다. 요청 받고, DB에서 꺼내고, 계산하고, 조건 따지고, 다시 저장하고, 응답까지. 한 함수가 다 하니까 처음엔 빠르다. 그런데 같은 계산을 백그라운드 작업에서도 해야 하는 순간이 오면, 그제서야 라우터에 묶어둔 로직을 어떻게 꺼내 쓸지가 막막해진다. 복사하거나, 라우터를 억지로 호출하거나. 둘 다 답이 아니다.

그래서 계층을 셋으로 나눈다. 라우터, 서비스, 리포지토리. 각자 책임이 한 줄로 떨어진다.

라우터는 HTTP만 본다

라우터의 일은 요청을 받고 응답을 돌려주는 것까지다. 경로 파라미터·쿼리·요청 본문을 파싱하고, 의존성 주입으로 받은 세션 같은 걸 아래 계층에 넘기고, 결과를 정해진 응답 형식으로 감싼다. 여기까지.

라우터가 하면 안 되는 것: DB 리포지토리를 직접 호출하거나, 복잡한 비즈니스 로직을 품는 것. 이 두 가지를 라우터에 넣는 순간, 그 로직은 그 엔드포인트에 갇힌다.

@router.post("/{account_id}/refresh-balance", response_model=ApiResponse[AccountBalanceRefreshPublic])
async def refresh_balance(account_id: int, body: AccountBalanceSyncRequest, session: Session, session_user: SessionUser):
    repo = AccountRepository(session=session)
    account = await repo.find(account_id)
    if not account or account.user_id != session_user.user_id:
        raise HTTPException(status_code=404, detail="찾을 수 없습니다.")

    # 실제 로직은 서비스에 위임
    new_balance = await refresh_account_balance(session=session, account=account, ...)
    return ApiResponse(data=AccountBalanceRefreshPublic(account_id=account.id, balance=new_balance))

라우터 본문이 짧다. 권한 확인하고, 서비스를 부르고, 응답을 감싼다. 진짜 계산이 무엇인지는 refresh_account_balance 안에 있다.

서비스가 로직을 들고, 라우터와 작업이 같이 쓴다

비즈니스 규칙은 서비스 계층에 둔다. 데이터를 조회하거나 바꾸고, 여러 로직을 엮어 하나의 완결된 기능을 만든다. 핵심은 이거다. 서비스는 라우터에서도, 백그라운드 작업에서도 똑같이 부를 수 있는 공유 코드다.

위의 잔고 갱신 로직을 보자. API 요청으로도 부르고, 매매 워커가 주문을 처리한 뒤에도 같은 잔고 동기화 함수를 부른다. 로직이 서비스에 있으니 두 진입점이 한 함수를 공유한다. 복붙도 없고, 한쪽 고치면 양쪽이 같이 고쳐진다.

# 백그라운드 작업도 같은 서비스를 호출한다
@celery_app.task(name="trades.execute_kis_orders", bind=True)
async def execute_kis_orders_task(self, trade_id: int) -> dict:
    from app.domain.modules.account.account_balance_service import sync_account_balance
    # ... 주문 처리(생략) ...
    await sync_account_balance(session=self.session, account=account, ...)
    return {"trade_id": trade_id, "status": "done"}

서비스 파일이 모듈당 꼭 하나여야 하는 건 아니다. 공유하거나 복잡한 로직이 생길 때 책임 단위로 쪼개 추가한다. 반대로 단순 조회·CRUD만 하는 모듈이면 서비스 파일 없이 라우터가 리포지토리를 바로 써도 된다. 규칙을 위한 규칙이 아니라, 나눠야 할 이유가 있을 때 나누는 것이다.

데이터 접근은 리포지토리한테만

서비스도 raw SQL을 직접 쓰지 않는다. selectsession.execute 같은 쿼리는 리포지토리에 맡기고, 서비스는 리포지토리 메서드를 호출만 한다. 어떤 행을 어떤 조건으로 읽고 쓸지를 정의하는 건 리포지토리의 일이다. 트랜잭션 경계를 잡거나 조회한 모델의 필드를 바꾸는 인메모리 수정은 서비스가 해도 된다. 그건 비즈니스 흐름의 일부니까.

라우터 → 서비스가 기본, 작업은 무거운 것만

한 가지 헷갈리는 지점. 라우터가 백그라운드 작업을 직접 던질 수도 있지 않나? 기본 흐름은 라우터 → 서비스다. 라우터 → 작업이 아니다. 동기로 충분한 조회·계산을 굳이 작업으로 우회하면 메시지 큐 왕복 오버헤드만 늘고 응답 시간이 예측 불가능해진다.

작업으로 위임하는 건 한 경우뿐이다. 요청으로 촉발되지만 응답을 붙잡고 있으면 안 되는 무거운 비동기 작업. 외부 주문 전송 같은 것. 이때 라우터는 작업 결과를 기다리지 않고 던지고 끝낸다(fire-and-forget). 그 외엔 서비스를 부른다.

나눠두면 뭐가 좋냐면

재사용. 로직이 서비스에 있으니 라우터든 작업이든 같은 함수를 부른다. 테스트. 서비스 함수는 HTTP 없이 인자만 넣고 부를 수 있어서 단위 테스트가 쉽다. 유지보수. 버그를 고치면 한 군데만 고치면 되고, 라우터를 읽으면 “무엇을 받고 무엇을 돌려주는지”가 한눈에 보인다.

각 층이 한 가지만 하면, 어디를 봐야 할지 헤매는 시간이 사라진다. 그게 계층 분리가 주는 진짜 이득이다.

관련 프로젝트
주식앱 (마이폴리오) LAB
개별 종목 분석 없이 포트폴리오 단위로 투자하는 앱 — KIS 계좌 연동 실주문까지, 본인 계좌 전용으로 운영 중.
보기 →
댓글