1편에서는 프로젝트의 전체 아키텍처와 서비스 흐름을 정리했다. 이번 글에서는 백엔드 개발과 배포 과정을 중심으로 정리해보려고 한다.
이번 프로젝트의 백엔드는 단순히 데이터를 저장하고 조회하는 API가 아니라, 사용자 상태, 밋업 상태, XRPL 트랜잭션 상태를 함께 관리해야 했다. 특히 지갑 생성, Trust Line 설정, Escrow 생성, 정산 처리처럼 블록체인 트랜잭션과 DB 상태가 함께 움직이는 기능이 많아서 생각보다 고려할 부분이 많았다.
1. 백엔드 기술 스택
백엔드는 NestJS 기반으로 개발했다.
Backend
- NestJS
- TypeScript
- Prisma
- PostgreSQL
- JWT Authentication
- XRPL SDK
NestJS는 모듈 단위로 기능을 나누기 좋아서 인증, 지갑, 토큰, 밋업, 신청, 정산 같은 도메인을 분리하기에 적합했다. 이번 프로젝트를 하면서 NestJS와 Prisma 를 완전히 사용해본건 처음이어서 같이 공부하면서 진행했다.
NestJS란?
1. NestJS 란?
NestJS는 Node.js 기반의 백엔드 프레임워크다. Express 위에서 동작할 수 있고, TypeScript를 기본으로 사용한다. 구조적으로는 Angular와 비슷하게 Module, Controller, Service 개념을 사용한다.
일반적인 Express 서버는 자유도가 높은 대신 프로젝트가 커질수록 구조를 직접 잡아야 한다. 반면 NestJS는 처음부터 모듈, 의존성 주입, 컨트롤러, 서비스 구조를 제공하기 때문에 기능별로 코드를 나누고 관리하기 좋다.
이번 프로젝트처럼 인증, 지갑, 토큰, 밋업, 정산처럼 도메인이 여러 개로 나뉘는 경우에는 NestJS의 구조가 특히 잘 맞았다.
2. NestJS의 모듈 단위 구조
NestJS에서는 기능을 Module 단위로 나눈다. 각 모듈은 관련된 Controller와 Service를 함께 가진다.
예를 들어 인증 기능은 `AuthModule`, 지갑 기능은 `WalletsModule`, 밋업 기능은 `MeetupsModule`처럼 나눌 수 있다.
AuthModule
- AuthController
- AuthService
WalletsModule
- WalletsController
- WalletsService
MeetupsModule
- MeetupsController
- MeetupsService
Controller는 클라이언트의 요청을 받는 역할을 하고, Service는 실제 비즈니스 로직을 처리한다.
예를 들어 밋업 생성 요청이 들어오면 MeetupsController가 요청을 받고, 실제로 밋업 정보를 저장하거나 Escrow를 생성하는 로직은 MeetupsService에서 처리한다.
이렇게 나누면 API 라우팅과 비즈니스 로직이 분리되어 코드가 훨씬 읽기 쉬워진다.
3. NestJS를 사용하면서 느낀 점
처음에는 Module, Controller, Service 구조가 조금 낯설었다. 단순히 API 하나를 만들기 위해 여러 파일을 나누는 것이 번거롭게 느껴지기도 했다.
하지만 기능이 늘어날수록 이 구조의 장점이 보였다. 인증, 지갑, 밋업, 정산 기능이 각각 다른 모듈로 분리되어 있으니 어떤 기능을 수정해야 할 때 관련 파일을 찾기 쉬웠다.
또한 Service 단위로 비즈니스 로직이 모여 있어서, 밋업 신청이나 정산처럼 복잡한 흐름을 Controller에 직접 작성하지 않고 별도로 관리할 수 있었다.
이번 프로젝트를 통해 NestJS는 작은 API 서버보다는, 도메인이 여러 개이고 상태 관리가 복잡한 백엔드에 더 잘 맞는 프레임워크라는 생각이 들었다.
Priama란?
1. Prisma란?
Prisma는 TypeScript 환경에서 많이 사용하는 ORM이다. ORM은 Object-Relational Mapping의 약자로, SQL을 직접 작성하지 않고도 코드로 데이터베이스를 다룰 수 있게 해주는 도구다.
Prisma에서는 schema.prisma 파일에 데이터 모델을 정의한다. 예를 들어 User, Wallet, Meetup 같은 모델을 정의하면 Prisma Client를 통해 타입이 적용된 DB 쿼리를 작성할 수 있다.
model User {
id String @id @default(cuid())
email String @unique
name String
createdAt DateTime @default(now())
}
이렇게 모델을 정의하면 코드에서는 다음처럼 사용할 수 있다.
const user = await prisma.user.findUnique({
where: { email },
})
SQL을 직접 작성하지 않아도 되고, TypeScript 타입이 적용되기 때문에 잘못된 필드명을 사용하면 개발 단계에서 오류를 확인할 수 있다.
2. Prisma Schema와 Migration
Prisma에서 가장 중요한 파일은 schema.prisma다. 이 파일에는 DB 모델, 관계, enum, index 등을 정의한다.
이번 프로젝트에서는 User, Wallet, TokenAccount, Meetup, Application, Escrow, Settlement 같은 모델을 Prisma schema에 정의했다.
DB 구조가 바뀌면 migration을 생성하고 적용해야 한다. 예를 들어 주최자 보증금 기능을 추가하면서 HostMeetupEscrow 모델이 필요했고, 이를 schema에 추가한 뒤 migration을 적용했다.
schema.prisma 수정
→ migration 생성
→ DB에 migration 적용
→ Prisma Client 재생성
3. Prisma를 사용하면서 느낀 점
Prisma를 처음 사용할 때는 schema, migration, Prisma Client의 관계가 조금 헷갈렸다. schema를 수정했는데 DB에는 반영되지 않거나, DB에는 반영했지만 Prisma Client가 갱신되지 않아 타입 오류가 나는 경우도 있었다.
하지만 익숙해지고 나니 Prisma는 DB 구조를 코드로 명확하게 관리할 수 있다는 점이 좋았다. 특히 모델 간 관계를 schema에서 한눈에 볼 수 있어서, 밋업 신청, Escrow, 정산처럼 여러 테이블이 연결되는 구조를 이해하는 데 도움이 되었다.
또한 TypeScript와 함께 사용할 때 장점이 컸다. DB 필드명이 틀리거나 include 관계를 잘못 작성하면 타입 체크 단계에서 잡을 수 있었기 때문에, 런타임 오류를 줄이는 데 도움이 되었다.
이번 프로젝트에서는 DB 상태와 XRPL 트랜잭션 상태를 함께 관리해야 했기 때문에 모델 설계가 중요했다. Prisma를 사용하면서 User, Wallet, Meetup, Application, Escrow, Settlement 사이의 관계를 명확히 정의할 수 있었고, 이 점이 백엔드 구조를 잡는 데 큰 도움이 되었다.
2. 주요 백엔드 모듈
백엔드는 기능별로 모듈을 나누어 구성했다.
Auth
- 회원가입
- 로그인
- 이메일 인증
- JWT 발급
Wallet
- XRPL 지갑 생성
- 지갑 주소 조회
DORRI / Token
- Trust Line 설정
- 토큰 잔액 조회
- 충전 quote 생성
- 충전 처리
Meetups
- 밋업 목록 조회
- 밋업 생성
- 밋업 상세 조회
- 참가 신청
Organizer
- 참가 신청 수락/거절
- 체크인/노쇼 처리
- 밋업 종료
- 참가자 평가
Applications
- 참가자 리뷰 작성
- 신청 취소
- 정산 처리
처음에는 밋업 생성과 신청 정도만 생각했는데, 구현을 하다 보니 상태가 계속 늘어났다. 신청을 수락할 수 있어야 하고, 거절하면 바로 정산되어야 하고, 수락된 신청은 체크인 또는 노쇼로 나뉘어야 했다. 체크인된 유저는 리뷰를 작성한 뒤 정산을 받고, 노쇼 유저는 리뷰 없이 정산된다.
여기에 신청 취소 기능까지 추가되면서 백엔드에서 처리해야 할 정책이 더 많아졌다.
3. DB 모델 설계
핵심 모델은 다음과 같이 구성했다.
User
Wallet
DorriAccount
Meetup
MeetupApplication
Escrow
HostMeetupEscrow
Settlement
SettlementLine
LedgerTx
Review
OrganizerParticipantEvaluation
User는 회원 정보를 가지고 있고, Wallet은 XRPL 지갑 주소와 암호화된 seed를 저장한다. DorriAccount는 Trust Line 상태와 잔액 스냅샷을 관리한다.
PENDING_APPROVAL
APPROVED
REJECTED
CHECKED_IN
NO_SHOW
REVIEWED
SETTLED
CANCELED
처음에는 상태가 이렇게 많아질 줄 몰랐다. 하지만 실제 서비스 플로우를 생각해보니 각 상태에서 가능한 액션이 다르고, 정산 결과도 달라져야 했다. 그래서 상태를 명확히 나누는 것이 중요했다.
4. XRPL 지갑과 Trust Line
회원가입 후 사용자는 XRPL 지갑을 생성한다. 이 지갑은 XRPL Testnet에서 생성되며, seed는 그대로 저장하지 않고 암호화해서 DB에 저장했다.
토큰을 받기 위해서는 Trust Line 설정이 필요하다. XRPL의 발행 토큰은 사용자가 발행자에 대해 Trust Line을 열어야 받을 수 있기 때문이다.
흐름은 다음과 같다.
회원가입
→ 이메일 인증
→ XRPL 지갑 생성
→ DORRI 발행자에 대한 Trust Line 설정
→ 토큰 사용 가능
이 과정에서 백엔드는 XRPL SDK를 사용해 TrustSet 트랜잭션을 만들고, 성공한 트랜잭션 hash를 LedgerTx에 저장했다.
블록체인 트랜잭션은 성공 여부를 외부 explorer에서도 확인할 수 있기 때문에, DB에는 트랜잭션 hash와 상태를 함께 저장했다.
5. 밋업 신청과 Escrow 생성
참가자가 밋업에 신청하면, 참가자의 DORRI가 Escrow로 잠긴다.
무료 밋업의 경우에는 보증금 20 DORRI가 잠기고, 유료 밋업의 경우에는 참가비가 잠긴다.
무료 밋업 신청
→ 참가자 20 DORRI 보증금 Escrow 생성
유료 밋업 신청
→ 참가비 DORRI Escrow 생성
밋업 신청 API에서는 먼저 사용자의 지갑과 Trust Line 상태를 확인한다. 이후 사용 가능한 DORRI 잔액이 충분한지 확인하고, XRPL Escrow 트랜잭션을 생성한다. 트랜잭션이 성공하면 DB에 신청 정보와 Escrow 정보를 저장한다.
중요했던 점은 DB 상태와 XRPL 상태를 함께 다루는 것이었다. 단순히 신청 row만 만드는 것이 아니라, 실제 XRPL 트랜잭션이 성공했는지 확인한 뒤 신청 상태를 저장해야 했다.
6. 주최자 보증금 추가
처음에는 참가자만 보증금을 거는 구조로 개발했지만, 참가자 입장에서도 주최자가 밋업을 제대로 운영하지 않을 가능성이 있다. 그래서 무료/유료 밋업 모두에서 주최자가 밋업 생성 시 20 DORRI를 보증금으로 걸도록 수정했다.
밋업 생성
→ 주최자 20 DORRI Escrow 생성
→ 밋업 생성 정보 저장
이를 위해 참가자 Escrow와 별도로 HostMeetupEscrow 모델을 추가했다.
HostMeetupEscrow
- meetupId
- ownerAddress
- destinationAddress
- lockedDorriAmount
- createTxHash
- finishTxHash
- status
밋업이 종료되고 주최자가 참가자 평가를 제출하면, 주최자 보증금이 다시 환급된다.
이 부분을 구현하면서 “참가자와 주최자 모두에게 책임을 부여하는 구조”가 서비스 신뢰도를 높이는 중요한 포인트라는 생각이 들었다.
7. 정산 처리
정산은 밋업 상태와 참가자 상태에 따라 다르게 처리된다.
예를 들어 참가 신청이 거절되면 참가자에게 환불된다. 체크인된 참가자는 리뷰 작성 후 정산을 받고, 노쇼 참가자는 리뷰 단계 없이 정산된다.
취소 정책도 고려해야 했다.
주최자 승인 전 취소
→ 패널티 없음
밋업 시작 48시간 이전 취소
→ 대부분 환불
밋업 시작 24~48시간 전 취소
→ 일부 수수료 발생
밋업 시작 24시간 이내 취소
→ 더 높은 수수료 발생
8. 잔액 표시와 locked balance
개발 중 헷갈렸던 부분 중 하나는 잔액 표시였다.
처음에는 XRPL trust line의 balance 값만 읽어서 화면에 보여주었다. 하지만 Token Escrow를 사용하면 escrow에 잠긴 금액이 balance에서 바로 차감되어 보이는 것이 아니라, 별도의 locked_balance로 관리될 수 있다.
예를 들어 사용자가 100 DORRI를 가지고 있고, 밋업 신청으로 20 DORRI를 escrow에 걸었다고 하면 단순 balance는 여전히 100으로 보일 수 있다.
그래서 백엔드에서 잔액을 조회할 때 단순히 balance만 반환하지 않고, locked_balance를 함께 고려하도록 수정했다.
사용 가능 잔액 = balance - locked_balance
이렇게 처리하면 밋업 신청이나 밋업 생성 과정에서 DORRI가 escrow로 잠겼을 때, 화면에서도 사용 가능한 잔액이 줄어든 것처럼 보여줄 수 있다.
이 부분을 구현하면서 블록체인 데이터를 그대로 화면에 보여주는 것과, 서비스에서 사용자에게 의미 있는 값으로 가공해 보여주는 것은 다르다는 것을 느꼈다. 사용자는 “내가 지금 실제로 사용할 수 있는 잔액”을 알고 싶어 하기 때문에, 백엔드에서 XRPL 데이터를 서비스 맥락에 맞게 해석해서 내려주는 것이 필요했다.
9. 배포
Frontend: Vercel
Backend API: Render
Database: Supabase PostgreSQL
Blockchain: XRPL Testnet
백엔드는 Render에 배포했고, DB는 Supabase PostgreSQL을 사용했다.
10. 프로그램 참여 및 밋업 정산 플랫폼을 개발하면서 배운 점
이번 백엔드 개발에서 가장 크게 느낀 점은, 블록체인 기능 자체보다 서비스 상태 관리가 더 어렵다는 것이다.
XRPL 트랜잭션을 생성하는 것도 물론 중요하지만, 실제 서비스에서는 그 트랜잭션이 어떤 사용자 행동과 연결되는지가 더 중요했다. 또한 배포 과정에서는 로컬에서 동작하던 코드가 그대로 배포 환경에서 동작하지 않는다는 점도 많이 배웠다. 환경변수, DB migration, Prisma Client, API base URL처럼 작은 설정 하나가 전체 기능을 막을 수 있었다.
11. 앞으로 보완하고 싶은 보안 요소
사실 블록체인 서비스 라고 하면 보안이 가장 중요하게 생각되어야 한다고 늘 생각하고 있다. 이번 PoC에서는 기능 구현과 전체 플로우 검증에 집중했기 때문에, 실제 서비스 수준의 보안을 모두 갖추지는 못했다. 만약 다음 단계로 프로젝트를 발전시키게 된다면 가장 먼저 보완하고 싶은 부분은 지갑 seed 관리와 정산 안정성이다.
현재 구조에서는 서버가 XRPL 지갑을 생성하고 seed를 암호화해 저장한다. PoC 단계에서는 충분하지만, 실제 서비스라면 KMS나 Vault 같은 별도의 키 관리 시스템을 사용하고, 암호화 키 회전과 접근 권한 제한을 적용해야 한다.
또한 정산 API에는 idempotency 처리가 필요하다. 네트워크 지연이나 사용자의 중복 클릭으로 같은 정산 요청이 여러 번 들어와도 중복 지급이 발생하지 않도록, 요청 단위의 고유 키를 저장하고 이미 처리된 요청은 다시 실행하지 않도록 만들어야 한다.
JWT 인증도 refresh token, token rotation, 로그아웃 시 토큰 무효화 등을 추가해 보완할 수 있다. 밋업 생성, 신청, 정산, 리뷰 작성 API에는 권한 검증을 더 촘촘히 적용해야 하며, Supabase DB 접근 권한도 최소화할 필요가 있다.
추가로 감사 로그와 모니터링을 도입해 정산 실패, 중복 요청, 비정상적인 잔액 변화, XRPL 트랜잭션 실패를 빠르게 확인할 수 있도록 만들고 싶다.
만약 예선에 통과한다면 다음 단계에서는 다음 항목을 우선적으로 보완해보고 싶다.
- KMS 또는 Vault 기반 지갑 seed 관리
- refresh token 및 token rotation
- 정산 idempotency 처리
- 중복 정산 방지
- API 권한 검증 강화
- 환경변수 및 DB 접근 권한 관리
- 감사 로그와 모니터링
관련 내용 조사
현재 구조에서는 서버가 XRPL 지갑을 생성하고, 지갑 seed를 암호화해 저장한다. PoC 단계에서는 환경변수에 저장된 암호화 키를 사용했지만, 실제 서비스에서는 KMS(Key Management Service)나 Vault 같은 별도의 키 관리 시스템을 사용하는 것이 더 안전하다.
KMS나 Vault를 사용하면 암호화 키를 코드나 서버 환경변수에 직접 두지 않고, 별도의 보안 시스템에서 관리할 수 있다. 이를 통해 키 접근 권한을 제한하고, 누가 언제 키에 접근했는지도 추적할 수 있다.
2. 암호화 키 회전과 접근 권한 제한
암호화 키는 한 번 정해두고 계속 사용하는 것보다 주기적으로 교체하는 것이 안전하다. 이를 키 회전이라고 한다. 만약 특정 시점에 키가 유출되더라도, 키를 주기적으로 교체하면 피해 범위를 줄일 수 있다.
또한 모든 서버나 모든 개발자가 키에 접근할 수 있도록 두는 것은 위험하다. 실제 운영 환경에서는 필요한 서비스와 담당자만 키에 접근할 수 있도록 권한을 최소화해야 한다.
3. Idempotency 처리
정산 API처럼 금전적 가치가 있는 요청은 중복 실행을 막아야 한다. 예를 들어 사용자가 정산 버튼을 여러 번 누르거나, 네트워크 문제로 같은 요청이 재시도되면 같은 정산이 두 번 실행될 위험이 있다.
이를 방지하기 위해 idempotency 처리가 필요하다. Idempotency란 같은 요청이 여러 번 들어와도 결과가 한 번만 처리되도록 만드는 방식이다.
예를 들어 정산 요청마다 고유한 idempotency key를 생성하고 DB에 저장한다. 이후 같은 key로 요청이 다시 들어오면 새로운 정산을 실행하지 않고, 이전에 처리된 결과를 반환한다.
정산 요청 수신
→ idempotency key 확인
→ 이미 처리된 key라면 기존 결과 반환
→ 처음 들어온 key라면 정산 실행
→ 처리 결과와 key 저장
이렇게 하면 중복 지급이나 중복 트랜잭션 발생을 줄일 수 있다.
4. 요청 단위의 고유 키 저장
Idempotency를 구현하려면 요청 단위의 고유 키를 저장해야 한다. 예를 들어 밋업 정산, 참가 신청 취소, 보증금 환급 같은 요청마다 고유한 key를 만들고, 해당 요청이 처리되었는지 DB에 기록한다.
이 key는 클라이언트에서 생성할 수도 있고, 서버에서 특정 요청 조건을 기반으로 생성할 수도 있다. 중요한 점은 같은 요청을 구분할 수 있어야 한다는 것이다.
예를 들어 다음과 같은 정보를 조합해 key를 만들 수 있다.
applicationId + actionType
meetupId + settlementType
userId + requestTimestamp
이렇게 요청 처리 기록을 남겨두면, 장애 상황에서도 어떤 요청이 이미 처리되었고 어떤 요청이 실패했는지 추적하기 쉽다.
5. Refresh Token과 Token Rotation
현재 인증은 JWT 기반 access token으로 처리했다. 하지만 실제 서비스에서는 access token만 사용하는 것보다 refresh token을 함께 사용하는 구조가 더 안전하다.
Access token은 짧은 만료 시간을 가지고, refresh token은 새로운 access token을 발급받는 데 사용한다. 이렇게 하면 access token이 유출되더라도 사용할 수 있는 시간이 짧아진다.
Token rotation은 refresh token을 사용할 때마다 새로운 refresh token을 발급하고, 기존 refresh token은 폐기하는 방식이다. 만약 이전 refresh token이 다시 사용되면 토큰 탈취 가능성을 의심하고 세션을 무효화할 수 있다.
로그인
→ access token + refresh token 발급
→ access token 만료
→ refresh token으로 새 access token 요청
→ 새 refresh token도 함께 발급
→ 기존 refresh token 폐기
6. 로그아웃 시 토큰 무효화
JWT는 기본적으로 stateless한 구조라서, 한 번 발급된 토큰은 만료 전까지 유효하다. 그래서 단순히 클라이언트에서 토큰을 삭제하는 것만으로는 서버 입장에서 해당 토큰을 즉시 무효화하기 어렵다.
실제 서비스에서는 로그아웃한 토큰이나 탈취가 의심되는 토큰을 서버에서 관리하는 blocklist에 등록할 수 있다. 또는 refresh token을 DB에 저장하고, 로그아웃 시 해당 refresh token을 폐기하는 방식도 사용할 수 있다.
이렇게 하면 사용자가 로그아웃했을 때 더 이상 새로운 access token을 발급받을 수 없고, 세션을 더 안전하게 관리할 수 있다.