PocketQuest

플랜 기반 가계부 — 기간 로직과 구조화된 데이터 모델링

모바일가계부React Native
2026모바일가계부React NativeRepo ↗

데모

영상

PocketQuest - Mobile Demo (React Native + Expo)

시스템 아키텍처

구조

이 프로젝트는 명확한 아키텍처 경계를 기반으로 설계되었습니다. 경계 계층에는 공유 계약을 두고, 서비스 계층에는 도메인 규칙을 배치했으며, 영속성은 리포지토리 계층 뒤에 명확히 분리했습니다.

Mobile (React Native) ↓ 공유 계약 (SSOT) ↓ API Route (경계) ↓ Service (도메인 규칙) ↓ Repository (Prisma) ↓ Database (PostgreSQL)

도메인 원칙

원칙

PocketQuest는 예측 가능한 동작과 장기적인 유지보수성을 목표로 설계되었습니다. 도메인 정책은 서비스 계층에 명확히 위치하며, 아키텍처 경계는 분명하게 정의되어 있습니다. 인프라 구현 세부사항은 UI 계층으로 전달되지 않도록 분리했습니다.

Impact: 이 구조는 클라이언트와 서버 간 API 형태의 불일치를 방지하고, 중복된 검증 로직을 제거하며, 타임존 차이로 인한 집계 오류를 예방합니다. 리팩터링은 특정 계층 내부에 국한되므로, UI 전반에 연쇄적인 수정이 발생하지 않습니다.

Trade-offs: 명시적인 계층 구조(route → service → repository)를 도입하면 단일 핸들러 방식보다 코드가 다소 늘어날 수 있습니다. 그러나 장기적인 유지보수성이 향상되고, 도메인 정책을 독립적으로 테스트할 수 있는 구조를 갖추게 됩니다.

명확한 경계

라우트는 인증과 요청 파싱을 담당하고, 서비스는 도메인 정책을 적용하며, 리포지토리는 영속성 처리를 분리합니다. 각 계층는 단일하고 명확한 책임을 가집니다.

공유 계약 (SSOT)

클라이언트와 서버가 동일한 enum과 스키마를 공유하여 API 구조를 안정적으로 유지합니다. 이를 통해 API 드리프트를 줄이고, 리팩터링을 보다 안전하고 예측 가능하게 수행할 수 있습니다.

결정적 동작

보고 및 집계는 사용자 시간대를 고려한 경계를 기준으로 서버에서 계산됩니다. 그 결과, 다양한 기기 환경에서도 UI 동작이 일관되게 유지됩니다.

일관된 오류 처리

인프라 수준의 오류는 HTTP 의미 체계로 변환되어 처리됩니다. 이를 통해 클라이언트의 에러 처리 로직을 일관되게 유지하면서, 내부 구현 세부사항이 외부로 노출되지 않도록 합니다.

Example: 이러한 원칙은 Transactions 모듈에서 구체적으로 구현됩니다. EXPENSE / INCOME / SAVING은 단순한 분류가 아닌 도메인 규칙을 가지는 핵심 개념으로 설계되었습니다.

코드 발췌

코드

Transactions 모듈의 아키텍처 경계와 도메인 정책을 보여주는 핵심 코드 발췌입니다.

공유 계약 (SSOT)

클라이언트와 서버가 동일한 enum과 스키마를 공유해, API 형태가 흔들리지 않도록 일관성을 유지합니다.

// packages/shared/src/transactions/types.ts
export const RANGE_VALUES = ["THIS_MONTH","LAST_MONTH","THIS_YEAR","ALL"] as const;
export const rangeSchema = z.enum(RANGE_VALUES as any);

export const transactionCreateSchema = z.object({
  type: z.enum(["EXPENSE","INCOME","SAVING"] as any),
  amountMinor: z.number().int().nonnegative(),
  category: z.string().min(1),
  savingsGoalId: z.string().nullable().optional(),
  occurredAtISO: z.string().datetime(),
});

카테고리 정규화

별칭(alias)은 서버에서 사용하는 표준 키로 정규화됩니다. 또한 SAVING은 카테고리를 항상 `savings`로 고정합니다.

// packages/shared/src/transactions/categories.ts
const ALIASES: Record<string, string> = {
  restaurant: "dining",
  transportation: "transport",
};

export function canonicalCategoryKeyForServer(raw: string, type?: string) {
  const key = String(raw ?? "").trim().toLowerCase();
  const folded = ALIASES[key] ?? key;
  if (type === "SAVING") return "savings";
  return folded || "other";
}

라우트 경계

라우트는 인증, 스키마 파싱/검증까지만 수행하고, 도메인 정책은 서비스에서 처리합니다.

// apps/server/src/app/api/transactions/route.ts
const auth = await requireUserId(request);
if (!auth.ok) return auth.response;

const parsedRange = rangeSchema.safeParse(rawRange.toUpperCase());
if (!parsedRange.success) return json400();

const data = transactionCreateSchema.parse(body);
return NextResponse.json(
  await createTransactionForUser({ userId: auth.userId, data }),
  { status: 201 },
);

도메인 규칙: SAVING 시맨틱

SAVING은 특정 저축 목표에 연결되어야 하며, 사용자 소유권 검증을 강제하는 도메인 타입입니다.

// apps/server/src/domain/transaction/transaction.service.ts
if (type === "SAVING") {
  if (!savingsGoalId) {
    throw new HttpError(400, "savingsGoalId required");
  }
  await assertSavingsGoalOwnership({ userId, savingsGoalId });
  category = "savings";
}

사용자 시간대 기반 집계

집계 로직은 사용자 시간대를 기준으로 서버에서 계산되어, 기기나 환경에 관계없이 항상 일관된 결과를 제공합니다.

// apps/server/src/domain/transaction/transaction.service.ts
const { fromISO, toISO } = computeOccurredAtFilter({
  range, // THIS_MONTH / LAST_MONTH / ...
  timezone: user.timeZone,
});

return repo.listTransactions({
  userId,
  occurredAtGte: fromISO,
  occurredAtLt: toISO,
});

인프라 오류의 도메인 추상화

Prisma 레벨의 예외를 HTTP 계층의 의미로 매핑하여, 인프라 구현 세부사항이 API 경계를 넘어 유출되지 않도록 했습니다.

// apps/server/src/lib/prismaErrors.ts
return prismaHttpGuard(async () => {
  return await prisma.transaction.update({
    where: { id, userId },
    data,
  });
});

클라이언트 데이터 정규화

UI 계층은 단일 데이터 모델만을 소비하도록 유지하고, 레거시 필드나 API 스펙 변화는 스토어에서 정규화하여 흡수합니다.

// apps/mobile/src/app/store/transactionsStore.ts
const amountMinor =
  typeof tx.amountMinor === "number" ? tx.amountMinor :
  typeof tx.amountCents === "number" ? tx.amountCents : 0;

const occurredAtISO = tx.occurredAtISO ?? tx.occurredAt ?? "";
return { ...tx, amountMinor, occurredAtISO };

전송 계층 규칙 (DTO 정규화)

API 경계에 도달하기 전에, 표준 카테고리 매핑과 부분 업데이트 안전성이 사전 보장됩니다.

// apps/mobile/src/app/api/transactionsApi.ts
function normalizeCreateDTO(input: any) {
  return {
    ...input,
    category: canonicalCategoryKeyForServer(input.category, input.type),
    savingsGoalId: input.type === "SAVING" ? input.savingsGoalId : undefined,
  };
}

각 코드 발췌는 애플리케이션의 핵심 아키텍처 경계를 드러냅니다: 공유 계약(SSOT), 카테고리 정규화, API 라우트 경계, 서비스 계층 정책, 사용자 시간대 기반 집계 필터, 영속성 계층 추상화, 클라이언트 데이터 정규화, 전송 계층 규칙.

이 아키텍처 패턴은 Savings, Plans(기간 로직), Dashboard 집계 등 다른 도메인 모듈에도 일관되게 확장되어, 시스템 전체의 동작을 구조적으로 안정화합니다.