Skip to content

Commit bd9e9d7

Browse files
authored
feat: 거대한 뿔피리 OPEN API 스케쥴러 구현 (#90)
* feat: hornbungle 컬럼 추가 flyway script 작성 및 엔티티 반영 * feat: hornbugle repository 및 scheduler 구현 * feat: hornbugle controller 구현 * fix: hornbugle expression에서 mapstrcut context 제거 * feat: logging xml 설정에 spring local profile 추가 * fix: hornbugle data_send UTC to KST 변경 * fix: optimize imports * docs: README 업데이트 * fix: auction history duplicator 중복 제거 시 date_auction_buy, auction_buy_id 둘 다 사용하도록 로직 수정 * fix: optimize imports
1 parent e1ccb08 commit bd9e9d7

46 files changed

Lines changed: 1558 additions & 191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 190 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# 🎮 마비노기 경매장 거래 내역 조회 및 통계 서비스
1+
# 마비노기 경매장 거래 내역 조회 및 통계 서비스
22

33
[![codecov](https://codecov.io/gh/devnogi/open-api-batch-server/branch/dev/graph/badge.svg)](https://codecov.io/gh/devnogi/open-api-batch-server)
44
[![License](https://img.shields.io/github/license/devnogi/open-api-batch-server)](LICENSE)
@@ -8,65 +8,165 @@
88

99
<br>
1010

11-
### 📌 주요 기능
11+
## 주요 기능
1212

13-
- **데이터 수집**: 매 1시간 0분 0초에 마비노기 경매장 거래 내역 Open API를 통해 데이터 수집
14-
- **데이터 분석**: 수집된 데이터를 기반으로 아이템별 최저가, 최고가, 평균가, 거래량 등의 통계 산출
15-
- **경매장 거래 내역 조회**: 사용자가 아이템의 시세를 조회하고 견적을 받을 수 있는 기능
13+
### 경매장 데이터 수집
14+
- 매 1시간마다 마비노기 경매장 거래 내역을 Nexon Open API를 통해 수집
15+
- 약 70개의 아이템 카테고리에 대한 커서 기반 페이지네이션으로 데이터 수집
16+
- 카테고리별 요청 간 딜레이 설정으로 API Rate Limit 준수
17+
18+
### 뿔피리 데이터 수집
19+
- 5분마다 거대한 외침의 뿔피리 내역 수집
20+
- 4개 서버 지원 (류트, 만돌린, 하프, 울프)
21+
- 지수 백오프 기반 재시도 로직 구현
22+
23+
### 통계 분석
24+
- **일간 통계**: 경매 데이터 수집 완료 시 자동 트리거 (이벤트 기반)
25+
- **주간 통계**: 매주 월요일 04:00에 집계
26+
- **전일 통계**: 매일 00:10에 전일 통계 확정
27+
28+
### 데이터 조회
29+
- 아이템별 최저가, 최고가, 평균가, 거래량 등 시세 조회
30+
- 서버별/전체 뿔피리 내역 조회
31+
- 아이템 옵션 필터링 (무기 공격력, 방어구 방어력 등)
1632

1733
<br>
1834

19-
### 🛠 기술 스택
35+
## 기술 스택
2036

21-
- **Backend**: Java 21, Spring Boot, Data JPA (Hibernate)
22-
- **Test**: JUnit5, Mockito, K6
23-
- **Database**: MySQL 8, Redis
24-
- **DevOps**: Docker Compose, Flyway, GitHub Actions
25-
- **Deployment**: Oracle Cloud
26-
- **Document**: Swagger, Notion
37+
| 분류 | 기술 |
38+
|------|------|
39+
| **Backend** | Java 21, Spring Boot 3.5.0, Spring Data JPA, QueryDSL |
40+
| **HTTP Client** | Spring WebFlux (WebClient) |
41+
| **Database** | MySQL 8, Flyway |
42+
| **Test** | JUnit5, Mockito, AssertJ, Testcontainers |
43+
| **Code Quality** | Spotless (Google Java Format AOSP), Jacoco |
44+
| **Documentation** | Swagger, Spring REST Docs |
45+
| **DevOps** | Docker Compose, GitHub Actions |
46+
| **Deployment** | Oracle Cloud |
2747

2848
<br>
2949

30-
### 📈 프로젝트 구조
50+
## 프로젝트 구조
3151

32-
- `auction-history/`: 경매장 거래 내역 및 통계 검색
33-
- `nexon-open-api/`: Open API 호출 및 데이터 수집
34-
- `statics/`: 배치 작업을 통한 통계 산출
52+
```
53+
src/main/java/until/the/eternity/
54+
├── auctionhistory/ # 경매장 거래 내역 수집 및 검색
55+
├── hornBugle/ # 뿔피리 내역 수집 및 검색
56+
├── statistics/ # 일간/주간 통계 집계
57+
├── iteminfo/ # 아이템 메타데이터
58+
├── itemoptioninfo/ # 아이템 옵션 정보
59+
├── metalwareinfo/ # 금속류 아이템 정보
60+
├── auctionsearchoption/ # 검색 옵션 메타데이터
61+
├── common/ # 공통 유틸리티, 예외, 필터
62+
└── config/ # 설정 (Security, QueryDSL, Web, OpenAPI)
63+
```
64+
65+
### 아키텍처 (Clean Architecture)
66+
```
67+
interfaces/ # Controller, DTO, External API Client
68+
└── rest/ # REST API 엔드포인트
69+
└── external/ # 외부 API 연동 (Nexon Open API)
70+
application/ # Service, Scheduler, Event
71+
domain/ # Entity, Repository Port, Mapper
72+
infrastructure/ # Repository 구현체, JPA
73+
```
3574

3675
<br>
3776

38-
### 💻 for developers
77+
## API 엔드포인트
78+
79+
| Endpoint | Method | 설명 |
80+
|----------|--------|------|
81+
| `/auction-history/search` | GET | 경매 내역 검색 (필터 및 페이징) |
82+
| `/auction-history/{id}` | GET | 단일 거래 내역 조회 |
83+
| `/auction-history/batch` | POST | 배치 수동 실행 |
84+
| `/horn-bugle` | GET | 뿔피리 내역 검색 (서버별/전체) |
85+
| `/horn-bugle/batch` | POST | 뿔피리 배치 수동 실행 |
86+
| `/statistics/daily/items` | GET | 일간 아이템 통계 |
87+
| `/statistics/daily/subcategories` | GET | 일간 서브카테고리 통계 |
88+
| `/statistics/daily/top-categories` | GET | 일간 상위카테고리 통계 |
89+
| `/statistics/weekly/items` | GET | 주간 아이템 통계 |
90+
| `/api/item-infos` | GET | 아이템 메타데이터 |
91+
| `/api/v1/item-option-infos` | GET | 아이템 옵션 정보 |
92+
| `/actuator/health` | GET | 헬스체크 |
93+
| `/swagger-ui/index.html` | - | API 문서 |
3994

40-
- **How To Run**: Notion | [프로젝트 실행 방법](https://periwinkle-bridge-1c6.notion.site/How-to-run-2385c107dcf380f993d8e733d664caf9?source=copy_link)
41-
- **API 명세서**: Notion | [API 명세서](https://periwinkle-bridge-1c6.notion.site/API-2195c107dcf380f2a465f9840b5d5dbf?source=copy_link)
42-
- **Git branch 전략**: Git-flow [관련 블로그](https://velog.io/@kw2577/Git-branch-%EC%A0%84%EB%9E%B5)
95+
<br>
96+
97+
## 스케줄러
98+
99+
| 스케줄러 | Cron 표현식 | 설명 |
100+
|----------|-------------|------|
101+
| 경매 내역 수집 | `0 0 * * * *` | 매 시 정각 |
102+
| 뿔피리 수집 | `0 */5 * * * *` | 5분마다 |
103+
| 전일 통계 확정 | `0 10 0 * * *` | 매일 00:10 |
104+
| 주간 통계 집계 | `5 0 4 * * MON` | 매주 월요일 04:00 |
43105

44106
<br>
45107

46-
### 🐳 로컬 개발 환경 (Docker)
108+
## 환경 변수
109+
110+
### 필수 환경 변수
111+
```bash
112+
# 서버
113+
SERVER_PORT=8080
114+
115+
# 데이터베이스
116+
DB_IP=localhost
117+
DB_PORT=3306
118+
DB_SCHEMA=devnogi
119+
DB_USER=username
120+
DB_PASSWORD=password
121+
122+
# JWT
123+
JWT_SECRET_KEY=your-secret-key
124+
JWT_ACCESS_TOKEN_VALIDITY=3600000
125+
JWT_REFRESH_TOKEN_VALIDITY=86400000
126+
127+
# Nexon Open API
128+
NEXON_OPEN_API_KEY=your-api-key
129+
```
130+
131+
### 선택 환경 변수
132+
```bash
133+
# 경매 내역 배치
134+
AUCTION_HISTORY_CRON=0 0 * * * *
135+
AUCTION_HISTORY_DELAY_MS=1000
136+
137+
# 뿔피리 배치
138+
HORN_BUGLE_CRON=0 */5 * * * *
139+
HORN_BUGLE_MAX_RETRIES=3
140+
HORN_BUGLE_RETRY_DELAY_MS=2000
141+
142+
# 통계
143+
STATISTICS_PREVIOUS_DAY_CRON=0 10 0 * * *
144+
STATISTICS_WEEKLY_CRON=5 0 4 * * MON
145+
```
146+
147+
<br>
47148

48-
로컬에서 코드를 수정하면서 개발할 때는 Docker Hub에 푸시하지 않고 로컬 빌드로 실행할 수 있습니다.
149+
## 로컬 개발 환경 (Docker)
49150

50-
#### 1. 환경 설정
151+
### 1. 환경 설정
51152

52153
```bash
53154
# .env.local.sample을 복사하여 .env.local 생성
54155
cp .env.local.sample .env.local
55156

56-
# .env.local 파일을 열어서 필요한 값들을 수정
57-
# - NEXON_OPEN_API_KEY: Nexon Open API 키 입력
58-
# - DB_PASSWORD: 로컬 MySQL 비밀번호 입력
59-
# - 기타 필요한 설정 수정
157+
# .env.local 파일 수정
158+
# - NEXON_OPEN_API_KEY: Nexon Open API 키
159+
# - DB_PASSWORD: 로컬 MySQL 비밀번호
60160
```
61161

62-
#### 2. 로컬에서 Docker로 실행
162+
### 2. Docker로 실행
63163

64164
```bash
65-
# 로컬 코드를 빌드하고 Docker 컨테이너로 실행
66-
docker-compose -f docker-compose-local.yml up --build
165+
# 빌드 및 실행
166+
docker-compose -f docker-compose-local.yml --env-file .env.local up --build
67167

68168
# 백그라운드 실행
69-
docker-compose -f docker-compose-local.yml up -d --build
169+
docker-compose -f docker-compose-local.yml --env-file .env.local up -d --build
70170

71171
# 로그 확인
72172
docker-compose -f docker-compose-local.yml logs -f spring-app
@@ -75,29 +175,72 @@ docker-compose -f docker-compose-local.yml logs -f spring-app
75175
docker-compose -f docker-compose-local.yml down
76176
```
77177

78-
#### 3. 코드 수정 후 재실행
178+
### 3. 환경별 Docker Compose 파일
179+
180+
| 환경 | 파일 | 설명 |
181+
|------|------|------|
182+
| 로컬 개발 | `docker-compose-local.yml` | 로컬 빌드, 낮은 리소스 |
183+
| 개발 서버 | `docker-compose-dev.yml` | 개발 환경 배포 |
184+
| 운영 서버 | `docker-compose-prod.yml` | 운영 환경 배포 |
185+
186+
<br>
187+
188+
## 빌드 및 테스트
79189

80190
```bash
81-
# 코드 수정 후 다시 빌드하여 실행
82-
docker-compose -f docker-compose-local.yml up --build
191+
# 빌드
192+
./gradlew clean build
83193

84-
# 또는 기존 컨테이너 정리 후 재실행
85-
docker-compose -f docker-compose-local.yml down
86-
docker-compose -f docker-compose-local.yml up --build
194+
# 테스트
195+
./gradlew test
196+
197+
# 코드 포맷팅
198+
./gradlew spotlessApply
199+
200+
# 테스트 커버리지 리포트
201+
./gradlew jacocoTestReport
202+
203+
# REST Docs 생성
204+
./gradlew asciidoctor
205+
206+
# 로컬 실행
207+
./gradlew bootRun
87208
```
88209

89-
#### 4. 환경별 실행 방법
210+
<br>
211+
212+
## API 응답 형식
90213

91-
| 환경 | Docker Compose 파일 | 설명 |
92-
|------|---------------------|------|
93-
| **로컬 개발** | `docker-compose.local.yml` | 로컬 코드 빌드, 낮은 리소스 사용 |
94-
| **개발/운영 서버** | `docker-compose.yaml` | Docker Hub 이미지 사용 |
214+
```json
215+
{
216+
"success": true,
217+
"code": "string",
218+
"message": "string",
219+
"data": {},
220+
"timestamp": "2025-01-01T12:00:00Z"
221+
}
222+
```
223+
224+
<br>
95225

96-
#### 5. 참고사항
226+
## 개발자 문서
97227

98-
- **로컬 개발**: 코드 수정 시마다 `--build` 옵션으로 재빌드 필요
99-
- **메모리 설정**: 로컬 환경은 메모리 사용량이 낮게 설정되어 있음 (512MB)
100-
- **데이터베이스**: `DB_IP=host.docker.internal`로 호스트 머신의 MySQL에 접근
101-
- **포트**: 기본 8080 포트 사용 (`.env.local`에서 변경 가능)
228+
- **실행 방법**: [Notion - How to run](https://periwinkle-bridge-1c6.notion.site/How-to-run-2385c107dcf380f993d8e733d664caf9)
229+
- **API 명세서**: [Notion - API 명세서](https://periwinkle-bridge-1c6.notion.site/API-2195c107dcf380f2a465f9840b5d5dbf)
230+
- **Git branch 전략**: Git-flow ([관련 블로그](https://velog.io/@kw2577/Git-branch-%EC%A0%84%EB%9E%B5))
102231

103232
<br>
233+
234+
## 지원 아이템 카테고리
235+
236+
약 70개의 마비노기 아이템 카테고리 지원:
237+
- **전투 장비**: 한손/양손 무기, 검, 도끼, 둔기, 랜스 등
238+
- **원거리 장비**: 활, 석궁, 총, 수리검, 아틀라틀
239+
- **마법 장비**: 실린더, 스태프, 완드, 마법서, 오브
240+
- **방어구**: 중갑/경갑/천옷, 장갑, 신발, 모자, 방패, 로브
241+
- **악세서리**: 얼굴 장식, 날개, 꼬리, 일반 악세서리
242+
- **특수 장비**: 악기, 라이프 도구, 마리오네트, 에코스톤, 유물
243+
- **소모품**: 물약, 음식, 허브, 던전 통행증, 보석, 염료
244+
- **강화 재료**: 인챈트 스크롤, 마법 가루, 설계도, 악마 스크롤
245+
- **서적**: 책, 페이지, 마비노벨
246+
- **구조물**: 의자, 팜 아일랜드 아이템

docker-compose-local.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,15 @@ services:
5050
# === JVM Configuration (Local - 경량 개발용) ===
5151
# Heap: 256m~512m, Non-Heap: 256m, Total: ~768m
5252
JAVA_OPTS: >-
53-
-Xms${JAVA_OPTS_XMS:-256m}
54-
-Xmx${JAVA_OPTS_XMX:-512m}
55-
-XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE:-150m}
56-
-XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-48m}
57-
-XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE:-64m}
58-
-Xss${JAVA_OPTS_XSS:-512k}
53+
-Xms${JAVA_OPTS_XMS:-512m}
54+
-Xmx${JAVA_OPTS_XMX:-1024m}
55+
-XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE:-300m}
56+
-XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-96m}
57+
-XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE:-128m}
58+
-Xss${JAVA_OPTS_XSS:-1024k}
5959
-XX:+UseG1GC
60-
-XX:MaxGCPauseMillis=${JAVA_OPTS_MAX_GC_PAUSE_MILLIS:-200}
61-
-XX:G1HeapRegionSize=${JAVA_OPTS_G1_HEAP_REGION_SIZE:-2m}
60+
-XX:MaxGCPauseMillis=${JAVA_OPTS_MAX_GC_PAUSE_MILLIS:-400}
61+
-XX:G1HeapRegionSize=${JAVA_OPTS_G1_HEAP_REGION_SIZE:-4m}
6262
-XX:InitiatingHeapOccupancyPercent=${JAVA_OPTS_INITIATING_HEAP_OCCUPANCY_PERCENT:-45}
6363
-XX:+TieredCompilation
6464
-XX:TieredStopAtLevel=${JAVA_OPTS_TIERED_STOP_AT_LEVEL:-2}

src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ public void fetchAndSaveAuctionHistoryAll() {
113113
totalSavedCount);
114114

115115
// 통계 업데이트를 위한 이벤트 발행
116-
log.debug("> [SCHEDULE] Publishing AuctionHistorySavedEvent with {} records", totalSavedCount);
116+
log.debug(
117+
"> [SCHEDULE] Publishing AuctionHistorySavedEvent with {} records",
118+
totalSavedCount);
117119
eventPublisher.publishEvent(new AuctionHistorySavedEvent(totalSavedCount));
118120
}
119121
}

src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import java.util.ArrayList;
1313
import java.util.List;
14+
import java.util.OptionalInt;
1415

1516
@Slf4j
1617
@Component
@@ -47,15 +48,24 @@ public List<OpenApiAuctionHistoryResponse> fetch(ItemCategory category) {
4748
}
4849

4950
var batch = response.auctionHistory();
50-
result.addAll(batch);
5151

52-
if (duplicateChecker.hasDuplicate(batch.getLast())) {
52+
OptionalInt duplicateIndex = duplicateChecker.checkDuplicateInBatch(batch, category);
53+
54+
if (duplicateIndex.isPresent()) {
55+
int index = duplicateIndex.getAsInt();
56+
if (index > 0) {
57+
result.addAll(batch.subList(0, index));
58+
}
5359
log.debug(
54-
"> [SCHEDULE] [{}] this fetched data has duplicate data, skip to next item subcategory",
55-
category.getSubCategory());
60+
"> [SCHEDULE] [{}] duplicate found at index {}, added {} items before duplicate",
61+
category.getSubCategory(),
62+
index,
63+
index);
5664
break;
5765
}
5866

67+
result.addAll(batch);
68+
5969
cursor = response.nextCursor();
6070

6171
if (cursor == null || cursor.isEmpty()) {

src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public List<AuctionHistory> filterOutExisting(
2424
List<OpenApiAuctionHistoryResponse> dtoList, ItemCategory category) {
2525

2626
List<AuctionHistory> entities =
27-
mapper.toEntityList(duplicateChecker.filterExisting(dtoList), category);
27+
mapper.toEntityList(duplicateChecker.filterExisting(dtoList, category), category);
2828

2929
entities.forEach(AuctionHistory::linkItemOptions);
3030

src/main/java/until/the/eternity/auctionhistory/domain/event/AuctionHistorySavedEvent.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44

55
import java.time.LocalDateTime;
66

7-
/**
8-
* 경매장 거래 내역 저장 완료 이벤트
9-
* AuctionHistoryScheduler가 거래 내역을 성공적으로 저장한 후 발행됩니다.
10-
*/
7+
/** 경매장 거래 내역 저장 완료 이벤트 AuctionHistoryScheduler가 거래 내역을 성공적으로 저장한 후 발행됩니다. */
118
@Getter
129
public class AuctionHistorySavedEvent {
1310

src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@
1313
public interface OpenApiAuctionHistoryMapper {
1414

1515
@Named("toEntity(OpenApiAuctionHistoryResponse, ItemCategory)")
16-
@Mapping(
17-
source = "dateAuctionBuy",
18-
target = "dateAuctionBuy",
19-
qualifiedByName = "utcToKst")
16+
@Mapping(source = "dateAuctionBuy", target = "dateAuctionBuy", qualifiedByName = "utcToKst")
2017
@Mapping(source = "openApiAuctionItemOptionResponse", target = "auctionItemOptions")
2118
@Mapping(
2219
target = "itemTopCategory",

0 commit comments

Comments
 (0)