거래소 연동 (Exchange) 모듈
- 목적: 거래소와의 모든 통신을 담당
- 경로: src/exchange
- 주요 기능:
1. 실시간 시세 조회
2. 잔고 조회
3. 주문 실행 (매수/매도)- 확장성:
1. 인터페이스를 통해 새로운 거래소 쉽게 추가 가능
2. 현재 업비트 구현, 향후 바이낸스 추가 예정
저번에는 exchange 모듈의 실시간 시세 조회 기능을 구현했었죠. 오늘은 잔고 조회 기능을 구현해보겠습니다!
먼저, 업비트의 전체 계좌 조회 API 요청, 응답 형식부터 살펴보겠습니다.
const request = require('request')
const uuidv4 = require("uuid/v4")
const sign = require('jsonwebtoken').sign
const access_key = process.env.UPBIT_OPEN_API_ACCESS_KEY
const secret_key = process.env.UPBIT_OPEN_API_SECRET_KEY
const server_url = process.env.UPBIT_OPEN_API_SERVER_URL
const payload = {
access_key: access_key,
nonce: uuidv4(),
}
const token = sign(payload, secret_key)
const options = {
method: "GET",
url: server_url + "/v1/accounts",
headers: {Authorization: `Bearer ${token}`},
}
request(options, (error, response, body) => {
if (error) throw new Error(error)
console.log(body)
})
// 200 status
[
{
"currency":"KRW",
"balance":"1000000.0",
"locked":"0.0",
"avg_buy_price":"0",
"avg_buy_price_modified":false,
"unit_currency": "KRW",
},
{
"currency":"BTC",
"balance":"2.0",
"locked":"0.0",
"avg_buy_price":"101000",
"avg_buy_price_modified":false,
"unit_currency": "KRW",
}
]
// 4XX status
{
"error": {
"name": "error name",
"message": "error message"
}
}
TDD 방식으로 구현을 하고 있으니, 실시간 시세 조회 기능과 같이 테스트 코드 먼저 작성(Red단계)를 거쳐 서비스 코드를 작성(Green단계)을 해야겠죠?!
// src/exchange/upbit/upbit.service.spec.ts
...
// 실시간 잔고 조회
describe('Balance API', () => {
// getBalance 메소드 정의
it('should have getBalance method', () => {
expect(service.getBalance).toBeDefined();
});
// API key 미존재시, 에러 발생 테스트
it('should throw error when API key is not provided', async () => {
await expect(service.getBalance()).rejects.toThrow(
'API key is required for private API',
);
});
// API 응답 테스트
it('should return balance list', async () => {
// API 응답 모킹
mockedAxios.get.mockResolvedValueOnce({
data: [
{
currency: 'KRW',
balance: '1000000.0',
locked: '0.0',
avg_buy_price: '0',
avg_buy_price_modified: true,
unit_currency: 'KRW',
},
],
});
const balances = await service.getBalance();
expect(balances).toEqual([
{
currency: 'KRW',
balance: 1000000,
locked: 0,
avg_buy_price: 0,
},
]);
});
});
// src/exchange/upbit/upbit.service.ts
...
import { Balance } from '../interfaces/exchange.interface';
import { sign } from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
...
async getBalance(): Promise<Balance[]> {
const accessKey = this.configService.get<string>('UPBIT_ACCESS_KEY');
const secretKey = this.configService.get<string>('UPBIT_SECRET_KEY');
if (!accessKey || !secretKey) {
throw new BadRequestException('API keys are required for private API');
}
// JWT 토큰 생성
const payload = {
access_key: accessKey,
nonce: uuidv4(),
};
const token = sign(payload, secretKey);
try {
const { data } = await axios.get<any[]>(`${this.apiUrl}/accounts`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return data.map((item) => ({
currency: item.currency,
balance: parseFloat(item.balance),
locked: parseFloat(item.locked),
avg_buy_price: parseFloat(item.avg_buy_price),
}));
} catch (error: any) {
const errorMessage =
error?.response?.data?.error?.message || error.message;
throw new BadRequestException(`Failed to fetch balance: ${errorMessage}`);
}
}
}
테스트 결과입니다. 어... 실패라고...? 는 내가 .env 설정파일에 API_KEY 셋팅을 안해줬었다... 바로 하고 다시 돌아오겠음!
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
FAIL src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (8 ms)
symbol validation
✓ should throw error when symbol is empty (8 ms)
✓ should throw error when symbol does not start with KRW- (1 ms)
✓ should throw error when symbol is not in the valid list (1 ms)
return value validation
✓ should return a valid numeric price (2 ms)
price range validation
✓ should return valid BTC price range (1 ms)
✓ should return valid ETH price range (1 ms)
✓ should return valid XRP price range (1 ms)
✓ should return price with valid decimal places (1 ms)
API integration
✓ should make API call to correct endpoint (2 ms)
✓ should include markets parameter in API call (1 ms)
✓ should handle empty response data (1 ms)
✓ should handle API error
Balance API
✓ should have getBalance method
✓ should throw error when API keys are not provided (11 ms)
✕ should return balance list
... (생략)
.env 파일을 추가했는데도! 여전히, 위와 같이 실패를 했다.
문제 해결을 위해, 먼저 jest.config.js 파일에 setupFiles 속성을 다음과 같이 추가해줬다. 결과는!?!?!
module.exports = {
...
setupFiles: ['dotenv/config'],
};
여전히, 실패이다..
생각해보니, ConfigService를 이렇게 모킹해놨으니 될리가!!!!!!! 멍충!!
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UpbitService,
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<UpbitService>(UpbitService);
});
그래서, 이렇게 ConfigService의 get mock 함수를 실제 환경변수를 반환해주도록 수정을 하고! API key 미존재 에러 테스트는 null을 반환하도록 수정했다!
const module: TestingModule = await Test.createTestingModule({
providers: [
UpbitService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
// 실제 환경변수 반환
return process.env[key];
}),
},
},
],
}).compile();
...
// API key 미존재시, 에러 발생 테스트
it('should throw error when API key is not provided', async () => {
// 이 테스트에서만 ConfigService를 다시 모킹하여 null 반환
jest.spyOn(service['configService'], 'get').mockReturnValue(null);
await expect(service.getBalance()).rejects.toThrow(
'API keys are required for private API',
);
});
과연, 테스트 결과는?
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
PASS src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (5 ms)
symbol validation
✓ should throw error when symbol is empty (8 ms)
✓ should throw error when symbol does not start with KRW- (1 ms)
✓ should throw error when symbol is not in the valid list (1 ms)
return value validation
✓ should return a valid numeric price (1 ms)
price range validation
✓ should return valid BTC price range (1 ms)
✓ should return valid ETH price range (1 ms)
✓ should return valid XRP price range (1 ms)
✓ should return price with valid decimal places (2 ms)
API integration
✓ should make API call to correct endpoint (1 ms)
✓ should include markets parameter in API call (1 ms)
✓ should handle empty response data
✓ should handle API error (1 ms)
Balance API
✓ should have getBalance method (1 ms)
✓ should throw error when API key is not provided (1 ms)
✓ should return balance list (4 ms)
Test Suites: 1 passed, 1 total
Tests: 16 passed, 16 total
Snapshots: 0 total
Time: 1.447 s, estimated 2 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.
// test/exchange.e2e-spec.ts
...
describe('Upbit Balance API', () => {
it('should fetch real balance', async () => {
const balances = await upbitService.getBalance();
// 응답 형식 검증
expect(Array.isArray(balances)).toBe(true);
// 각 잔고 항목 검증
balances.forEach((balance) => {
expect(balance).toHaveProperty('currency');
expect(balance).toHaveProperty('balance');
expect(balance).toHaveProperty('locked');
expect(balance).toHaveProperty('avg_buy_price');
// 값 타입 검증
expect(typeof balance.currency).toBe('string');
expect(typeof balance.balance).toBe('number');
expect(typeof balance.locked).toBe('number');
expect(typeof balance.avg_buy_price).toBe('number');
});
// 결과 출력
console.log('Balances:', balances);
}, 10000); // 타임아웃 10초
});
테스트 결과는?!?!?
FAIL test/exchange.e2e-spec.ts
Exchange E2E Test
Upbit Price API
✓ should fetch real KRW-BTC price (142 ms)
✓ should fetch real KRW-ETH price (59 ms)
✓ should fetch real KRW-XRP price (50 ms)
Upbit Balance API
✕ should fetch real balance (144 ms)
● Exchange E2E Test › Upbit Balance API › should fetch real balance
BadRequestException: Failed to fetch balance: This is not a verified IP.
96 | const errorMessage =
97 | error?.response?.data?.error?.message || error.message;
> 98 | throw new BadRequestException(`Failed to fetch balance: ${errorMessage}`);
| ^
99 | }
100 | }
101 | }
예... 실패랍니다... 로그를 확인해보니 허용된 ip가 아니라는군요.
업비트 개발자센터에 들어가서 key를 확인해보니, 허용 ip주소가 있더라구요. 여기에 제 ip를 추가해줬습니다! 이 링크로 들어가시면 외부 ip주소 확인이 가능합니다!
허용 ip주소를 추가한 후, 다시 테스트를 진행해보겠씁니다! 과연!! 결과는!!
npm run test:e2e test/exchange.e2e-spec.ts
> auto-coin-trader-bot@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json test/exchange.e2e-spec.ts
console.log
KRW-BTC price: 139,885,000원
at Object.<anonymous> (exchange.e2e-spec.ts:43:17)
console.log
KRW-ETH price: 5,578,000원
at Object.<anonymous> (exchange.e2e-spec.ts:43:17)
console.log
KRW-XRP price: 3,561원
at Object.<anonymous> (exchange.e2e-spec.ts:43:17)
console.log
Balances: [
{
currency: 'KRW',
balance: 비밀,
locked: 0,
avg_buy_price: 0
},
{
currency: 'ETC',
balance: 비밀,
locked: 0,
avg_buy_price: 164400
},
{
currency: 'META',
balance: 비밀,
locked: 0,
avg_buy_price: 297.62924668
},
{ currency: 'ORBS', balance: 100, locked: 0, avg_buy_price: 230 },
{
currency: 'LAMB',
balance: 비밀,
locked: 0,
avg_buy_price: 130
}
]
at Object.<anonymous> (exchange.e2e-spec.ts:70:15)
PASS test/exchange.e2e-spec.ts
Exchange E2E Test
Upbit Price API
✓ should fetch real KRW-BTC price (102 ms)
✓ should fetch real KRW-ETH price (43 ms)
✓ should fetch real KRW-XRP price (42 ms)
Upbit Balance API
✓ should fetch real balance (80 ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 2.421 s, estimated 3 s
Ran all test suites matching /test\/exchange.e2e-spec.ts/i.
성공과 함께 4년 전에 사서 수익률을 -80%를 찍어버린 제 기묘하고 슬픈 계좌 잔고들이 있군요... 다행히 그 당시 돈이 10만원 밖에 없어서 다행입니다... 계좌 잔액은 비밀입니다.
이렇게, 잔고조회 기능은 마무리 되었습니다. 휴...
다음번에는 실제 주문 실행 기능 구현으로 찾아오겠습니다!! 굿나잇!!