-
Notifications
You must be signed in to change notification settings - Fork 1
[BE] 매수 매도 로직 구현 및 개선 과정
매수/매도 로직은 사용자가 원하는 코인의 지정가와 수량을 입력하면, 해당 정보가 미체결 데이터로 저장되어 tradeDB
에 기록됩니다. 이를 통해 사용자의 주문은 대기 상태로 관리됩니다.
💡이러한 미체결 정보를 서버에서는 어떻게 실시간으로 확인하고 거래 체결을 진행할까?
-
실시간 코인 가격 조회:
- 서버는 Upbit API를 활용하여 1초마다 모든 코인의 현재가 정보를 가져옵니다.
-
체결 로직:
- 가져온 현재가 데이터를
tradeDB
에 저장된 미체결 주문 정보와 비교합니다. - 매수 주문: 현재가가 지정가 이하일 때 체결.
- 매도 주문: 현재가가 지정가 이상일 때 체결.
- 가져온 현재가 데이터를
-
결과 처리:
- 조건을 만족하는 주문은 체결되며, 체결된 주문은
COMPLETED
상태로 업데이트됩니다. - 체결된 거래의 결과는 사용자의 잔고 및 자산 정보에 반영됩니다.
- 조건을 만족하는 주문은 체결되며, 체결된 주문은
-
trade.service.ts
: 거래의 핵심 로직 처리 -
trade-ask.service.ts
: 매도 로직 구현 -
trade-bid.service.ts
: 매수 로직 구현 -
trade-ask-bid.service.ts
: 매수/매도 공통 처리
-
미체결 거래 처리
- TradeDB에서 미체결 거래 검색 후 가격 조건이 만족되면 실행.
- 각 거래는 별도의 DTO로 변환 후 핸들러에 전달.
- 동시성 관리:
QueryRunner
를 사용한 트랜잭션 처리로 데이터 정합성 유지.
-
공통 유틸 함수
-
buildTradeDto
: 코인 최신 정보를 기반으로 거래 DTO 생성. -
executeTransaction
: 트랜잭션 시작, 커밋, 롤백 관리.
-
-
핵심 로직
- 사용자의 보유 자산에서 매도량 차감.
- 매도 가격 이하의 매수 오더만 처리.
- 거래 후 결과를 TradeHistoryDB에 저장하여 상태 관리.
-
자산 부족 예외 처리
- 사용자의 보유 자산을 확인 후 부족 시
422 UnprocessableEntityException
반환.
- 사용자의 보유 자산을 확인 후 부족 시
-
중복 실행 방지
-
isProcessing
플래그를 통해 동일 거래의 중복 실행 방지.
-
-
핵심 로직
- 사용자의 잔고에서 매수 금액 차감.
- 매수 가격 이상의 매도 오더만 처리.
- 실행된 거래 정보를 TradeHistoryDB에 저장.
-
잔고 부족 예외 처리
- 매수 금액이 잔고를 초과하면
422 UnprocessableEntityException
반환.
- 매수 금액이 잔고를 초과하면
-
트랜잭션 동시성 처리
-
transactionCreateBid
플래그로 동시 매수 요청 제어.
-
매수/매도 로직에서 거래 실행 전 2차 검증으로 호가창 데이터를 가져와 추가 확인 과정을 거칩니다.
-
2차 검증:
- 현재가가 매수/매도 조건에 도달하면
tradeExecute
로직이 실행됩니다. - 호가창 데이터를 조회하여 실제로 조건을 충족하는지 확인합니다.
- 현재가가 매수/매도 조건에 도달하면
-
DB 조회 시간:
- 호가창 데이터를 DB에서 조회하는 동안 시간이 소요됩니다.
-
현재가 변화:
- DB 조회 지연 동안 현재가가 변동되어, 매수/매도 조건에 도달했음에도 거래가 체결되지 않는 상황 발생.
하지만, 매수/매도 로직에서는 거래를 실행하기 전에 한 번 더 코인에 대한 호가창 정보를 가져와 이를 바탕으로 거래를 체결합니다.
현재가에 도달하면 tradeExcecute라는 로직이 실행되는데 호가창을 가져와 2차검증을 하는 것입니다. 이때, DB 조회에 시간이 걸리면 그 사이 현재가 정보가 바뀌어 매수/매도 로직에 진입하더라도 거래가 체결되지 않는 문제가 발생했습니다. (추측) 이 문제를 해결하기 위해 Redis를 사용하여 데이터를 캐싱하고, DB 조회 시간을 단축시킬 수 있도록 시스템을 개선하기로 결정했습니다.
private buildTradeSearchQuery(
queryBuilder: SelectQueryBuilder<Trade>,
coinPrices: CoinPriceDto[],
tradeType: string,
) {
const operator = tradeType === TRADE_TYPES.BUY ? '>=' : '<=';
coinPrices.forEach(({ give, receive, price }, index) => {
const params = {
[`give${index}`]: tradeType === TRADE_TYPES.BUY ? give : receive,
[`receive${index}`]: tradeType === TRADE_TYPES.BUY ? receive : give,
[`price${index}`]: price,
[`type${index}`]: tradeType,
};
const condition = `trade.tradeCurrency = :give${index} AND
trade.assetName = :receive${index} AND
trade.price ${operator} :price${index} AND
trade.tradeType = :type${index}`;
if (index === 0) {
queryBuilder.where(condition, params);
} else {
queryBuilder.orWhere(condition, params);
}
});
}
기존의 쿼리를 생성해 TradeDB를 조회하는 방법입니다.
-
기능:
- SQL 쿼리를 동적으로 생성하여
trade
테이블에서 매칭되는 주문을 검색합니다. -
coinPrices
배열에 포함된 각 코인의 가격 정보를 활용해 조건을 추가.
- SQL 쿼리를 동적으로 생성하여
-
조건 구성:
-
price
필드에 대해>=
(매수) 또는<=
(매도) 조건. -
tradeCurrency
,assetName
,tradeType
등 필드 매칭. - 첫 번째 조건은
WHERE
로 설정, 이후 조건은OR
로 추가.
-
- 데이터베이스에서 직접 검색하므로 대량 데이터 처리 시 성능 저하 가능.
- 특히, 높은 트래픽 상황에서는 부하가 증가할 수 있다.
- 쿼리 생성이 복잡하며, 조건이 많아질수록 코드 가독성이 떨어질 수 있다.
SQL 기반은 정합성과 복잡한 조건 처리에 강점이 있지만, 우리 서비스처럼 실시간 처리와 대량 트래픽이 중요한 환경에서는 성능이 한계에 도달할 수 있습니다. Redis로 전환하면 이러한 한계를 극복하고, 실시간 트레이딩의 요구를 충족하는 시스템을 구현할 수 있습니다.
async findMatchingTrades(
tradeType: TRADE_TYPES.BUY | TRADE_TYPES.SELL,
coinPrices: CoinPriceDto[],
) {
const result = [];
await Promise.all(
coinPrices.map(async (coinPrice) => {
const sortedSetKey =
tradeType === TRADE_TYPES.BUY
? `${tradeType}:${coinPrice.receive}:${coinPrice.give}`
: `${tradeType}:${coinPrice.give}:${coinPrice.receive}`;
try {
let trades;
if (tradeType === TRADE_TYPES.SELL) {
trades = await this.tradeRedis.zrangebyscore(
sortedSetKey,
'-inf',
coinPrice.price,
'WITHSCORES',
);
} else {
trades = await this.tradeRedis.zrangebyscore(
sortedSetKey,
coinPrice.price,
'+inf',
'WITHSCORES',
);
}
const tradePromises = trades.map(async (tradeId, index) => {
if (index % 2 === 1) return null;
const tradeInfoKey = `trade:${tradeId}`;
const tradeInfo = await this.tradeRedis.hgetall(tradeInfoKey);
if (tradeInfo) {
result.push(tradeInfo);
}
});
await Promise.all(tradePromises);
} catch (error) {
console.error('Trade search error:', error);
throw new Error('Failed to search trades');
}
}),
);
return result;
}
저희는 데이터를 효율적으로 처리하기 위해 Redis의 zadd
와 hset
명령어를 활용했습니다.
-
zadd
: 정렬된 데이터 저장-
zadd
명령어는 Sorted Set 자료구조에 데이터를 추가하는 데 사용됩니다. - Sorted Set은 데이터를 자동으로 정렬된 상태로 저장하며, 가격 정보와 같이 순서가 중요한 데이터를 다루기에 매우 적합합니다.
- 예를 들어, 매수/매도 주문을 가격 기준으로 정렬하여 효율적으로 범위 검색(
zrangebyscore
)을 수행할 수 있습니다.
-
-
hset
: 거래 정보 저장-
hset
명령어는 Hash 자료구조를 사용하여, 하나의 키 아래 여러 필드와 값을 저장합니다. - 각 필드는 키-값 형태로 저장되며, 거래 정보를 빠르게 조회하고 수정할 수 있습니다.
- 예를 들어, 특정 주문 ID에 대한 세부 정보를 저장하고, 필요할 때 빠르게 조회하는 데 활용됩니다.
-
Redis Sorted Set, Hash
-
Sorted Set (정렬된 집합)
- Sorted Set은 중복을 허용하지 않는 값들의 집합으로, 각 값에 **점수(score)**가 부여됩니다.
- 이 점수는 정렬 기준으로 사용되며, 집합의 값들은 점수 순서대로 저장됩니다.
- 중복 방지: 같은 값을 중복으로 저장할 수 없습니다.
- 정렬된 데이터: 요소들이 점수를 기준으로 자동으로 정렬됩니다.
- 빠른 조회: 특정 순위, 범위, 점수 구간에 있는 데이터를 빠르게 검색할 수 있습니다.
-
ZADD
: Sorted Set에 요소를 추가ZADD leaderboard 100 user1 ZADD leaderboard 200 user2
-
ZRANGE
: 점수 또는 인덱스 범위에 따라 요소를 반환ZRANGE leaderboard 0 -1 WITHSCORES
-
ZREM
: 요소 삭제ZREM leaderboard user1
- **리더보드(Leaderboard)**점수를 기준으로 순위를 매기는 시스템.
- 타임스탬프 기반 데이터특정 시간에 발생한 이벤트를 정렬하고 조회.
- 우선순위 큐점수로 우선순위를 설정하여 큐의 작업을 관리.
-
Hash (해시)
- Hash는 키-값 쌍으로 구성된 필드들을 저장하는 데이터 구조입니다.
- 한 개의 Redis 키 안에 여러 개의 필드와 그 값을 저장할 수 있습니다.
- 다중 필드: 하나의 Hash 안에 여러 개의 필드와 값을 저장 가능.
- 빠른 접근: 특정 필드의 값을 빠르게 조회하거나 수정할 수 있음.
- 메모리 효율: 필드-값 쌍의 개수가 적을 때 메모리 사용량이 효율적.
-
HSET
: 필드와 값을 추가하거나 업데이트HSET user:1001 name "Alice" age 25 city "Seoul"
-
HGET
: 특정 필드의 값 조회HGET user:1001 name
-
HGETALL
: 모든 필드와 값 조회HGETALL user:1001
-
HDEL
: 특정 필드 삭제HDEL user:1001 age
- 사용자 프로필 저장사용자 ID를 Redis 키로 사용하고, 프로필 정보를 필드로 저장.
- 구성 데이터 관리설정 값이나 메타데이터를 저장.
- 카운터 관리각 필드에 대해 별도의 카운터 값을 유지.
자료구조 | 특징 | 주요 사용 사례 |
---|---|---|
Sorted Set | 점수 기반으로 정렬된 중복 없는 데이터 저장 | 리더보드, 우선순위 큐, 시간 기반 정렬 데이터 |
Hash | 필드와 값의 쌍을 저장, 객체와 유사한 구조 | 사용자 프로필, 메타데이터, 설정값 관리 |
-
기능:
- Redis에서 미체결 주문을 검색하여 조건에 맞는 주문을 반환.
- 각 코인에 대해 Redis
zrangebyscore
명령을 사용해 점수 범위(가격 조건)에 해당하는 주문을 가져옵니다.
-
조건 구성:
- 매수(
>=
): 낮은 가격에서 높은 가격으로 탐색. - 매도(
<=
): 높은 가격에서 낮은 가격으로 탐색. - 검색된 주문의 ID를 사용해 Redis 해시(
hgetall
)에서 추가 정보를 조회.
- 매수(
-
속도:
- Redis는 인메모리 데이터 저장소로, 검색 속도가 매우 빠르다.
-
zrangebyscore
는 정렬된 집합의 범위 검색에 최적화되어 있다.
-
효율성:
- 데이터베이스 대신 Redis를 활용해 트래픽 부하를 줄일 수 있다.
- 미체결 주문을 실시간으로 관리 및 검색하는 데 적합하다.
-
단순성:
- 이미 Redis에 정렬된 형태로 데이터가 존재하므로 검색 로직이 간결.
Redis를 적용한 후, 성능 테스트를 진행했습니다.
초기에는 데이터가 적을 경우 MySQL과 Redis의 성능 차이가 크게 느껴지지 않았습니다.
그러나, MySQL과 Redis에 각각 10,000개의 거래 데이터를 삽입한 후 테스트한 결과, 성능 차이가 확연히 드러났습니다.
-
Redis의 속도와 처리 성능:
- Redis를 활용한 경우, 데이터 검색 및 처리 속도가 눈에 띄게 개선되었습니다.
- 특히, 실시간 처리가 중요한 트랜잭션 환경에서 Redis는 MySQL 대비 월등한 성능을 보여주었습니다.
-
Redis 도입 효과:
- 테스트 결과, Redis 도입이 시스템의 전반적인 성능 향상에 큰 기여를 했음을 확인할 수 있었습니다.
- 이는 대규모 데이터를 처리하고 실시간 응답이 요구되는 서비스 환경에서 Redis의 강점을 증명한 사례라 할 수 있습니다.
- 사용자가 매수/매도 주문을 생성.
- 주문 정보가Trade DB에 저장됨.
- 매치 펜딩 프로세스에서 Trade 데이터를 조회하며 조건에 맞는 매수/매도 주문을 매칭.
- 매칭 성공 시 트랜잭션이 실행되어 Trade DB와 사용자의 잔고/자산 정보가 업데이트됨.
sequenceDiagram
participant User as User
participant API as API Server
participant TradeDB as Trade DB
participant Redis as Redis (Match Pending)
participant Match as Match Logic
participant Tx as Transaction Manager
participant Balance as Balance/Asset Update
User->>API: Create Order (BUY/SELL)
API->>TradeDB: 주문 정보를 Trade DB에 저장 (상태: PENDING)
TradeDB-->>API: 주문 저장 완료 (ID: 123)
API->>Redis: 매칭 대기 목록에 주문 추가 (Key: BUY:BTC)
Redis-->>API: 매칭 대기 목록에 주문 추가됨
Match->>Redis: 대기 중인 주문 가져오기
Redis-->>Match: 대기 주문 목록 반환
Match->>Match: 매칭 조건 확인
Match-->>Match: 매칭 가능한 주문 발견
Match->>Tx: 매칭 거래 실행
Tx->>TradeDB: 주문 상태 업데이트 (COMPLETED)
Tx->>Balance: 사용자 잔액 및 자산 업데이트
Balance-->>Tx: 잔액 업데이트 성공
Tx-->>Match: 거래 완료
Match->>User: 매칭 결과 반환 (매칭 가격/수량)
-
사용자가 매수/매도 주문 데이터를 API를 통해 전송.
-
예시 요청:
{ "type": "BUY", "price": 50000, "quantity": 1.5, "asset": "BTC" }
-
API 서버는 해당 주문 데이터를 처리하여 Trade DB에 저장.
- 주문 데이터는
Trade DB
에 기록. - 주요 데이터:
-
trade_id
: 고유 주문 ID -
user_id
: 사용자 ID -
type
: BUY 또는 SELL -
price
,quantity
: 주문 가격 및 수량 -
status
: PENDING (매치 대기 상태)
-
-
주문 데이터는 Redis DB로 전송되어 매치 대기 리스트에 추가.
-
Redis 구조:
-
Key:
TRADE_TYPE:ASSET
- Value: 주문 정보
BUY:BTC => [{price: 50000, quantity: 1.5, user_id: 123}, ...] SELL:BTC => [{price: 49000, quantity: 2.0, user_id: 456}, ...]
-
Key:
- 매치 펜딩 프로세스가 주기적으로 Redis DB를 조회.
- 매수/매도 주문 조건을 비교하여 가격이 매칭되는 경우 거래를 실행.
- 매칭 조건:
- 매수 주문:
bid_price >= sell_price
- 매도 주문:
ask_price <= buy_price
- 매수 주문:
- 매칭된 주문은
Trade DB
와Balance/Asset
정보를 업데이트. - 트랜잭션 과정:
-
Trade DB 업데이트:
- 매칭된 주문 상태를
COMPLETED
로 변경. - 거래 완료 정보를 기록.
- 매칭된 주문 상태를
-
잔고/자산 업데이트:
- 매수자: 구매한 자산 추가, 지불 금액 차감.
- 매도자: 판매된 자산 차감, 수익 추가.
-
Trade DB 업데이트:
-
거래 완료 후 결과는 사용자에게 반환.
-
예시 응답:
{ "status": "success", "trade_id": 789, "matched_price": 49500, "matched_quantity": 1.5 }
먼저, 기존 시스템은 SQL 기반으로 설계되었습니다.
SQL은 복잡한 검색 조건과 대규모 데이터를 처리하는 데 적합했지만, 저희 서비스가 실시간 트레이딩을 제공해야 한다는 특성상 몇 가지 한계를 경험하게 되었습니다.
-
대규모 데이터 처리의 한계:
- 서비스 성장에 따라 TradeDB에 저장되는 미체결 주문 데이터가 기하급수적으로 증가했습니다.
- 데이터가 많아질수록 SQL 쿼리 실행 시간이 길어지고, 동시에 처리 성능이 점점 저하되었습니다.
-
실시간 검색 성능 문제:
- 매수/매도 로직은 사용자의 주문을 빠르게 처리해야 하지만, SQL 기반에서는 쿼리 성능이 주문 건수 증가에 따라 크게 떨어졌습니다.
- 특히, 높은 트래픽 상황에서 병목 현상이 발생하여, 거래 체결 속도가 서비스 품질에 영향을 미치게 되었습니다.
이 문제를 해결하기 위해 SQL에서 Redis로 전환하는 결정을 내렸습니다. Redis는 인메모리 데이터베이스로, 실시간 처리가 중요한 서비스에서 탁월한 성능을 제공합니다.
-
Redis를 활용한 데이터 캐싱:
- 미체결 주문과 호가창 데이터를 Redis에 캐싱하여 SQL의 병목 구간을 제거했습니다.
- Redis의 ZSET 구조를 사용해 가격과 수량 기반으로 빠르게 검색할 수 있도록 설계했습니다.
-
성능 향상:
- Redis는 DB 대신 메모리에서 데이터를 처리하므로, SQL 대비 검색 속도가 압도적으로 빨랐습니다.
- 결과적으로, 거래 체결 속도가 평균 5배 이상 개선되었습니다.
-
실시간 요구 충족:
- Redis는 실시간 데이터를 즉시 제공할 수 있어, 현재가 변화로 인한 거래 체결 실패 문제를 최소화했습니다.
-
확장성 강화:
- Redis 클러스터링을 통해 수평 확장이 가능하며, 대규모 사용자 요청을 안정적으로 처리할 수 있었습니다.
Redis로의 전환은 저희 서비스에 여러 가지 긍정적인 변화를 가져왔습니다.
-
거래 속도와 실시간성 개선:
- DB 조회 시간을 제거하여 거래 체결 시간이 대폭 단축되었습니다.
- 사용자는 빠른 응답 속도를 경험할 수 있었습니다.
-
시스템 안정성 확보:
- 트래픽이 급증해도 Redis 클러스터가 안정적으로 데이터를 처리해, 높은 안정성을 유지할 수 있었습니다.
-
사용자 경험 향상:
- Redis 기반으로 체결 속도가 빨라지면서, 사용자 만족도가 크게 향상되었습니다.
Redis로의 전환 과정에서 몇 가지 중요한 점을 배울 수 있었습니다.
-
SQL과 Redis의 조합이 중요:
- SQL은 정합성과 복잡한 조건 처리에 강점이 있지만, 실시간 검색에서는 Redis가 더 적합합니다.
- 앞으로도 SQL과 Redis를 조합하여 상황에 맞는 최적의 아키텍처를 설계할 계획입니다.
-
정합성 문제 해결:
- Redis와 SQL 간 데이터 동기화를 자동화하여 정합성을 더욱 강화할 예정입니다.
-
확장 가능한 구조 설계:
- 서비스가 계속 성장하더라도 확장 가능한 Redis 클러스터와 Redis TTL 설정으로 데이터 효율성을 유지할 것입니다.
- [FE] TailwindCSS @apply
- [FE] 캐러셀 구현
- [FE] 사이드 바 상태관리 도전기
- [FE] axios interceptor로 로그인 필요한 api 개선하기
- [FE] Tanstack Query API 최적화 도전기
- [FE] Tanstack Query로 구현하는 무한 스크롤 차트 도전기
- [FE] 차트 무한 스크롤링 최적화 도전기
- [FE] 차트 실시간 등락 구현 도전기
- [FE] 검색 구현 및 검색 API 호출 최적화 도전기
- [FE] 고차 컴포넌트를 활용한 인증 접근 제어
- [FE] 코드 스플릿팅으로 최적화 도전기
- [BE] Server 생성
- [BE] CI/CD
- [BE] GitAction 학습 정리
- [BE] ssh터널링으로 db연결
- [BE] 배포환경에서 DB 연결 및 테스트 완료
- [BE] https 적용
- [BE] upbit api 연결 및 SSE api
- [BE] SSE 구현
- [BE] SSE 에러
- [BE] redis 설치 및 연동
- [BE] 트랜잭션 락 구현과 최적화
- [BE] Oauth CORS
- [BE] QueryRunner 사용 시 발생한 문제점과 해결방안
- [BE] Git Action 학습 정리
- [BE] NestJS 학습 정리
- [BE] 로그인 기능 및 리프레시토큰
- [BE] 비회원 체험 기능
- [BE] Nginx 학습 정리
- [BE] Mixed Content와 HTTPS 보안 구현하기
- [BE] 매수/매도 로직 구현 및 개선 과정
- [BE] Queue, Load Balancing, Redis