티스토리 뷰
LostArkAPI를 이용하여 거래소 검색사이트를 간단하게 구현하는 방법에 대해 알아보겠습니다.
SpringBoot와 Thymeleaf를 이용하여 구현하였습니다. 전체소스는 GitHub를 참고해 주세요.
1. 사이트 접속
Lostark OpenAPI Developer Portal
Open API For All Developers START BUILDING YOUR OWN CLIENTS TODAY USING OFFICIAL DATA. GET ACCESS TO LOSTARK API
developer-lostark.game.onstove.com
- 로그인 후, API 키를 발급받습니다.
- https://developer-lostark.game.onstove.com/getting-started#login 사이트에서 가이드 문서를 확인할 수 있습니다.
2. build.gradle 구성
plugins {
id 'java'
id 'war'
id 'org.springframework.boot' version '3.3.1'
id 'io.spring.dependency-management' version '1.1.5'
}
apply plugin: 'war'
bootWar {
archiveBaseName = '-'
archiveFileName = 'ROOT.war'
archiveVersion = "1.0.0"
}
group = 'com.api'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
}
dependencies {
// basic
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
3. application.yml 구성
spring:
thymeleaf:
enabled: true
cache: true
prefix: classpath:templates/
suffix: .html
check-template-location: true
server:
port: 1234
servlet:
encoding:
charset: UTF-8
loa:
apiKey: #발급받은API키#
apiUrl: https://developer-lostark.game.onstove.com
---
spring:
config:
activate:
on-profile: "local"
---
spring:
config:
activate:
on-profile: "prod"
4. 컨트롤러 구현
package com.api.locah.controller;
import com.api.locah.controller.dto.MarketDetailReq;
import com.api.locah.controller.dto.MarketDetailRes;
import com.api.locah.controller.dto.MarketSearchReq;
import com.api.locah.controller.dto.MarketSearchRes;
import com.api.locah.service.ApiService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {
private final ApiService _apiService;
/**
* 거래소 검색
*/
@PostMapping("/market/search")
public ResponseEntity<MarketSearchRes> marketSearch(@RequestBody MarketSearchReq params) {
return _apiService.marketSearch(params);
}
/**
* 거래소 상세 검색
*/
@PostMapping("/market/detail")
public ResponseEntity<MarketDetailRes> marketDetail(@RequestBody MarketDetailReq params) {
return _apiService.marketDetail(params);
}
}
5. 서비스 구현
package com.api.locah.service;
import com.api.locah.controller.dto.MarketDetailReq;
import com.api.locah.controller.dto.MarketDetailRes;
import com.api.locah.controller.dto.MarketSearchReq;
import com.api.locah.controller.dto.MarketSearchRes;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.util.List;
@Service
public class ApiService {
@Value("${loa.apiKey}")
private String apiKey;
@Value("${loa.apiUrl}")
private String apiUrl;
HttpHeaders headers;
RestTemplate restTemplate;
@PostConstruct
public void init() {
headers = new HttpHeaders();
headers.set("Accept", "application/json");
headers.set("Authorization", "bearer " + apiKey);
restTemplate = new RestTemplate();
}
/**
* 거래소검색
*/
public ResponseEntity<MarketSearchRes> marketSearch(final MarketSearchReq params) {
HttpEntity<Object> entity = new HttpEntity<>(params, headers);
return restTemplate.exchange(
apiUrl + "/markets/items",
HttpMethod.POST,
entity,
MarketSearchRes.class);
}
/**
* 거래소 상세 검색
*/
public ResponseEntity<MarketDetailRes> marketDetail(final MarketDetailReq params) {
// HTTP 요청을 위한 HttpEntity 설정
HttpEntity<Object> entity = new HttpEntity<>(headers);
// API 호출 및 응답 받기
ResponseEntity<String> jsonResult = restTemplate.exchange(
apiUrl + "/markets/items/" + params.getItemId(),
HttpMethod.GET,
entity,
String.class);
// API 응답을 JSON 문자열로 받아옴
String jsonArray = jsonResult.getBody();
// ObjectMapper를 사용하여 JSON 문자열을 MarketDetailRes 객체로 변환
ObjectMapper objectMapper = new ObjectMapper();
MarketDetailRes marketDetailRes = null;
try {
List<MarketDetailRes> marketDetails = objectMapper.readValue(jsonArray, new TypeReference<List<MarketDetailRes>>() {
});
if (!marketDetails.isEmpty()) {
marketDetailRes = marketDetails.get(0); // 첫 번째 객체를 선택
}
} catch (IOException e) {
e.printStackTrace();
}
// 변환된 객체를 ResponseEntity에 담아 반환
if (marketDetailRes != null) {
return new ResponseEntity<>(marketDetailRes, HttpStatus.OK);
} else {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
}
6. 공통 Javascript 구현
// POST 호출
function postApi(url, params, successCallback, errorCallback) {
$.ajax({
type: 'POST',
url: url,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
data: JSON.stringify(params),
success: function (data) {
if (typeof successCallback === 'function') {
successCallback(data);
}
},
error: function (err) {
if (typeof errorCallback === 'function') {
errorCallback(err);
}
}
});
}
// 모달 열기
function openModal() {
document.querySelector('.modal').style.display = 'block';
}
// 모달 닫기
function closeModal() {
document.querySelector('.modal').style.display = 'none';
}
function onApiError(err) {
alert('오류');
}
7. 공통 헤더 구현 [templates > layout > fragments > head.html]
<!DOCTYPE html>
<html lagn="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="headFragment">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<link th:href="@{/css/styles.css}" rel="stylesheet"/>
<link rel="icon" th:href="@{/favicon.ico}" type="image/x-icon">
<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
<script th:src="@{/js/common.js}"></script>
<script src="https://kit.fontawesome.com/a9eb1f10be.js" crossorigin="anonymous"></script>
<title>로차</title>
</head>
</th:block>
</html>
8. css 구성
.hide {
display: none;
}
.cursor {
cursor: pointer;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
header {
background-color: #333;
color: #fff;
padding: 10px;
text-align: center;
}
main {
padding: 10px;
}
label {
display: block;
margin-top: 10px;
font-weight: bold;
}
input[type="text"], select {
width: 100%;
padding: 10px;
margin-top: 5px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
margin-top: 20px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f2f2f2;
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
- 디자인은 챗GPT의 도움을 받아 작성하였습니다.
9. 화면 구현
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{layout/fragments/head.html :: headFragment}"/>
<body>
<main>
<div class="search-container">
<label for="sort">정렬:</label>
<select id="sort">
<option value="GRADE">등급</option>
<option value="CURRENT_MIN_PRICE">최저가</option>
<option value="RECENT_PRICE" selected>최근판매가</option>
<option value="YDAY_AVG_PRICE">전일평균가</option>
</select>
<label for="categoryCode">카테고리 선택:</label>
<select id="categoryCode">
<option value="">전체</option>
<option value="10100">장비 상자 전체</option>
<option value="20005">아바타 > 무기</option>
<option value="20010">아바타 > 머리</option>
<option value="20020">아바타 > 얼굴1</option>
<option value="20030">아바타 > 얼굴2</option>
<option value="20050">아바타 > 상의</option>
<option value="20060">아바타 > 하의</option>
<option value="20070">아바타 > 상하의 세트</option>
<option value="21400">아바타 > 악기</option>
<option value="21500">아바타 > 아바타 상자</option>
<option value="21600">아바타 > 이동 효과</option>
<option value="20000">아바타 전체</option>
<option value="40000">각인서 전체</option>
<option value="50010">강화 재료 > 재련 재료</option>
<option value="50020">강화 재료 > 재련 추가 재료</option>
<option value="51000">강화 재료 > 기타 재료</option>
<option value="51100">강화 재료 > 무기 진화 재료</option>
<option value="50000" selected>강화 재료 전체</option>
<option value="60200">전투 용품 > 배틀 아이템 -회복형</option>
<option value="60300">전투 용품 > 배틀 아이템 -공격형</option>
<option value="60400">전투 용품 > 배틀 아이템 -기능성</option>
<option value="60500">전투 용품 > 배틀 아이템 -버프형</option>
<option value="60000">전투 용품 전체</option>
<option value="70000">요리 전체</option>
<option value="90200">생활 > 식물채집 전리품</option>
<option value="90300">생활 > 벌목 전리품</option>
<option value="90400">생활 > 채광 전리품</option>
<option value="90500">생활 > 수렵 전리품</option>
<option value="90600">생활 > 낚시 전리품</option>
<option value="90700">생활 > 고고학 전리품</option>
<option value="90800">생활 > 기타</option>
<option value="90000">생활 전체</option>
<option value="100000">모험의 서</option>
<option value="110100">항해 > 선박 재료</option>
<option value="110110">항해 > 선박 스킨</option>
<option value="111900">항해 > 선박 재료 상자</option>
<option value="110000">항해 전체</option>
<option value="140100">펫 > 펫</option>
<option value="140200">펫 > 펫 상자</option>
<option value="140000">펫 전체</option>
<option value="160100">탈것 > 탈것</option>
<option value="160200">탈것 > 탈것 상자</option>
<option value="160000">탈것 전체</option>
<option value="170000">기타</option>
<option value="220000">보석 상자</option>
</select>
<label for="itemName">아이템 이름:</label>
<input type="text" id="itemName" placeholder="아이템 이름 입력" value="오레하">
<label for="sortCondition">정렬 조건:</label>
<select id="sortCondition">
<option value="ASC">오름차순</option>
<option value="DESC" selected>내림차순</option>
</select>
<button onclick="searchMarket()">검색</button>
</div>
<div id="pagination">
<span id="pageInfo">페이지 1 / 전체 페이지 0</span>
<button class="hide" id="prevBtn" onclick="prevPage()">이전</button>
<button class="hide" id="nextBtn" onclick="nextPage()">다음</button>
</div>
<table id="resultsTable">
<thead>
<tr>
<th>ID</th>
<th>이름</th>
<th>등급</th>
<th>아이콘</th>
<th>묶음 수</th>
<th>전일 평균 가격</th>
<th>최근 가격</th>
<th>현재 최소 가격</th>
</tr>
</thead>
<tbody id="results">
</tbody>
</table>
<!-- 모달 창 -->
<div id="modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">×</span>
<div id="modalResults">
<!-- 상세 정보 -->
</div>
</div>
</div>
</main>
<script th:inline="javascript">
let currentPage = 1;
let pageSize = 10; // 페이지당 보여주는 개수
function searchMarket() {
currentPage = 1; // 검색을 시작할 때 페이지 초기화
let data = {
sort: document.getElementById('sort').value,
categoryCode: document.getElementById('categoryCode').value,
itemName: document.getElementById('itemName').value,
pageNo: currentPage,
pageSize: pageSize,
sortCondition: document.getElementById('sortCondition').value
}
postApi('/api/market/search', data, onSearchMarketSuccess, onApiError);
}
function onSearchMarketSuccess(data) {
const results = document.getElementById('results');
results.innerHTML = ''; // 기존 결과 초기화
data.Items.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="cursor" onclick="searchDetail('${item.Id}')">${item.Id}</td>
<td>${item.Name}</td>
<td>${item.Grade}</td>
<td><img src="${item.Icon}" alt="${item.Name} 아이콘"></td>
<td>${item.BundleCount}</td>
<td>${item.YDayAvgPrice}</td>
<td>${item.RecentPrice}</td>
<td>${item.CurrentMinPrice}</td>
`;
results.appendChild(row);
});
updatePage(data.PageNo, data.TotalCount);
}
function searchDetail(itemId) {
let data = {
itemId: itemId
}
postApi('/api/market/detail', data, onSearchDetailSuccess, onApiError);
}
function onSearchDetailSuccess(data) {
const modalResults = document.getElementById('modalResults');
modalResults.innerHTML = ''; // 기존 결과 초기화
const item = data;
const content = `
<h2>${item.Name}</h2>
<h3>Stats</h3>
<table>
<thead>
<tr>
<th>Date</th>
<th>Avg Price</th>
<th>Trade Count</th>
</tr>
</thead>
<tbody>
${item.Stats.map(stat => `
<tr>
<td>${stat.Date}</td>
<td>${stat.AvgPrice}</td>
<td>${stat.TradeCount}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
modalResults.innerHTML = content;
openModal();
}
/**
* 페이징 처리
*/
function updatePage(pageNo, totalCount) {
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
if (pageNo > 1) {
prevBtn.style.display = 'inline-block';
prevBtn.disabled = false;
} else {
prevBtn.style.display = 'none';
prevBtn.disabled = true;
}
if (pageNo * pageSize < totalCount) {
nextBtn.style.display = 'inline-block';
nextBtn.disabled = false;
} else {
nextBtn.style.display = 'none';
nextBtn.disabled = true;
}
const pageInfo = document.getElementById('pageInfo');
const totalPages = Math.ceil(totalCount / pageSize);
pageInfo.textContent = `페이지 ${pageNo} / 전체 페이지 ${totalPages}`;
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
fetchResults();
}
}
function nextPage() {
currentPage++;
fetchResults();
}
</script>
</body>
</html>
10. 실행화면
감사합니다.
'프로그래밍 언어 > API' 카테고리의 다른 글
[LostArkAPI] 캐릭터 정보 검색 추가 구현하기 (1) | 2024.07.17 |
---|---|
[ElasticSearch] 계정 생성하기 (0) | 2024.05.21 |
[ElasticSearch] Window 에 설치하기 (0) | 2024.05.21 |
[YoutubeAPI] SpringBoot + Kotlin 으로 구현하기 (0) | 2024.05.21 |
[YoutubeAPI] Youtube Data API v3 사용방법 (0) | 2024.05.21 |
최근에 올라온 글
- Total
- Today
- Yesterday