로스트아크 시세 조회 기능
Updated:
LostArk Open API를 사용해 시세를 수집해 DB에 저장하는 기능을 구현하였다. 이제 이 데이터를 활용해서 프론트에서 조회할 수 있는 기능을 구현한다.
BackEnd
시세 수집 기능을 구현했을 때 만들었던 package에 다음 기능을 추가로 구현한다.
- 특정 아이템 최신 시세 조회
- 기간별 시세 히스토리 조회
먼저 최신 시세를 return하기 위해 DTO를 하나 추가한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MarketItemLatestPriceResponse {
@JsonProperty("ItemId")
private Long itemId;
@JsonProperty("Name")
private String name;
@JsonProperty("Date")
private String date;
@JsonProperty("AvgPrice")
private Double avgPrice;
@JsonProperty("TradeCount")
private Integer tradeCount;
}
전에 service에 만들었던 MarketItemService의 역할을 분리한다(책임 분리 명확). 기존에 수집을 위해 만들었던 코드는 MarketItemCollectService로 변경하고 조회를 위한 코드는 MarketItemQueryService로 생성한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class MarketItemQueryService {
private final MarketItemRepository marketItemRepository;
private final MarketItemPriceHistoryRepository marketItemPriceHistoryRepository;
// 특정 아이템 최신 시세 조회
@Transactional
public MarketItemLatestPriceResponse getLatestPrice(Long itemId) {
MarketItem item = getItemOrThrow(itemId);
MarketItemPriceHistory latestHistory = marketItemPriceHistoryRepository.findTopByMarketItem_IdOrderByPriceDateDesc(item.getId())
.orElseThrow(() ->
new IllegalStateException("해당 아이템의 시세 데이터가 존재하지 않습니다."));
return new MarketItemLatestPriceResponse(
item.getItemId(),
item.getName(),
latestHistory.getPriceDate().toString(),
latestHistory.getAvgPrice(),
latestHistory.getTradeCount()
);
}
// 기간별 시세 히스토리 조회
@Transactional
public List<MarketItemStatsResponse> getPriceHistory(Long itemId, LocalDate startDate, LocalDate endDate) {
MarketItem item = getItemOrThrow(itemId);
List<MarketItemPriceHistory> histories = marketItemPriceHistoryRepository.findByMarketItem_IdAndPriceDateBetweenOrderByPriceDate(
item.getId(), startDate, endDate
);
return histories.stream()
.map(MarketItemStatsResponse::from)
.toList();
}
// 아이템 존재 여부 확인
private MarketItem getItemOrThrow(Long itemId) {
return marketItemRepository.findByItemId(itemId)
.orElseThrow(() ->
new IllegalArgumentException("존재하지 않는 아이템입니다. itemId=" + itemId));
}
}
- MarketItemLatestPriceResponse getLatestPrice(Long itemId)
- MarketPriceHistoryRepository에 Optional
findTopByMarketItem_IdOrderByPriceDateDesc(Long itemId)를 추가한다. - Spring을 실행했을 때 OpenAPI에서 제공하는 ItemId를 이용해 조회하면 “해당 아이템의 시세 데이터가 존재하지 않습니다.”가 출력되었다. 이는 ItemPriceHistory에서 item 테이블을 외래키 참조하고 있는데, 이때 itemPriceHistory의 item_id는 item의 id와 연결되어 있기 때문이었다.
- 그래서 기존 findTopByMarketItem_IdOrderByPriceDateDesc(itemId)를 item.getId()로 수정해 해결하였다.
- MarketPriceHistoryRepository에 Optional
- List
getPriceHistory(Long itemId, LocalDate startDate, LocalDate endDate) - MarketPriceHistoryRepository에 List
findByMarketItem_IdAndPriceDateBetweenOrderByPriceDate(Long itemId, LocalDate startDate, LocalDate endDate)를 추가한다. - stream(): 리스트를 하나씩 흘려보내면서 처리할 수 있는 흐름으로 바꿔줌
- histories.stream(): MarketItemPriceHistory들을 하나씩 꺼내서 가공하겠다는 선언
- map(): 각 요소를 다른 형태로 변환(매핑)
- .map(MarketItemStatsResponse::from): histories 안의 각 엔티티를 DTO로 바꾸는 작업
- ::from: 각 history를 DTO로 바꿔주는 변환 함수
- 이를 위해 MarketItemStatsResponse에 다음을 추가해준다.
public static MarketItemStatsResponse from(MarketItemPriceHistory history) { return new MarketItemStatsResponse( history.getPriceDate().toString(), history.getAvgPrice(), history.getTradeCount() ); }
- 이를 위해 MarketItemStatsResponse에 다음을 추가해준다.
- .toList(): 스트림을 리스트로 수집
- MarketPriceHistoryRepository에 List
- MarketItem getItemOrThrow(Long itemId): 아이템 존재 여부 확인
이제 이 service를 사용하기위해 marketItemcontroller에 추가한다.
// 특정 아이템 최신 시세 조회
@GetMapping("/{itemId}/latest")
public MarketItemLatestPriceResponse getLatestPrice(@PathVariable Long itemId) {
return marketItemQueryService.getLatestPrice(itemId);
}
// 기간별 시세 히스토리 조회
@GetMapping("/{itemId}/history")
public List<MarketItemStatsResponse> getPriceHistory(@PathVariable Long itemId,
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate endDate
) {
return marketItemQueryService.getPriceHistory(itemId, startDate, endDate);
}
이제 다음 사항들을 테스트코드로 작성해본다.
- 정상 아이템 조회 테스트
- 날짜 범위 조회 테스트
- 데이터 없는 날짜 조회 시 처리 검증
- 잘못된 itemId 요청 시 예외 테스트
@SpringBootTest
@Transactional
class MarketItemQueryServiceTest {
@Autowired
private MarketItemQueryService marketItemQueryService;
@Autowired
private MarketItemRepository marketItemRepository;
@Autowired
private MarketItemPriceHistoryRepository marketItemPriceHistoryRepository;
private MarketItem item;
@BeforeEach
void setUp() {
// 외부 ID = 652039050
item = marketItemRepository.save(
MarketItem.builder()
.itemId(652039050L)
.name("테스트 아이템")
.build()
);
marketItemPriceHistoryRepository.save(
MarketItemPriceHistory.builder()
.marketItem(item)
.priceDate(LocalDate.of(2025, 12, 1))
.avgPrice(1000.0)
.tradeCount(10)
.build()
);
marketItemPriceHistoryRepository.save(
MarketItemPriceHistory.builder()
.marketItem(item)
.priceDate(LocalDate.of(2025, 12, 2))
.avgPrice(1200.0)
.tradeCount(12)
.build()
);
}
@Test
void 정상_아이템_기간별_조회() {
List<MarketItemStatsResponse> result =
marketItemQueryService.getPriceHistory(
652039050L,
LocalDate.of(2025, 12, 1),
LocalDate.of(2025, 12, 2)
);
Assertions.assertThat(result).hasSize(2);
Assertions.assertThat(result.get(0).getAvgPrice()).isEqualTo(1000.0);
Assertions.assertThat(result.get(1).getAvgPrice()).isEqualTo(1200.0);
}
@Test
void 날짜_범위로_부분_조회() {
List<MarketItemStatsResponse> result =
marketItemQueryService.getPriceHistory(
652039050L,
LocalDate.of(2025, 12, 2),
LocalDate.of(2025, 12, 2)
);
Assertions.assertThat(result).hasSize(1);
Assertions.assertThat(result.get(0).getDate()).isEqualTo("2025-12-02");
}
@Test
void 데이터_없는_날짜_조회시_빈리스트_반환() {
List<MarketItemStatsResponse> result =
marketItemQueryService.getPriceHistory(
652039050L,
LocalDate.of(2025, 11, 1),
LocalDate.of(2025, 11, 5)
);
Assertions.assertThat(result).isEmpty();
}
@Test
void 존재하지_않는_아이템ID_조회시_예외발생() {
assertThatThrownBy(() ->
marketItemQueryService.getPriceHistory(
99999999L,
LocalDate.of(2025, 12, 1),
LocalDate.of(2025, 12, 2)
)
).isInstanceOf(IllegalArgumentException.class);
}
}
- @BeforeEach: 각 테스트 메서드 실행 직전에 특정 초기화 로직을 반복적으로 실행하도록 하는 어노테이션으로, 테스트 메서드마다 독립적인 환경을 보장하고 코드 중복을 피하기 위해 사용
대량 데이터 조회 시 성능 고려 (쿼리 최적화)
시세 히스토리 데이터는 하루 단위로 지속적으로 누적되며, 장기적으로는 아이템별로 수백~수천 건 이상의 시계열 데이터가 저장된다. 프론트엔드에서 시세 변화를 차트로 시각화하기 위해서는 특정 아이템의 일정 기간 데이터를 한 번에 조회해야 하므로, 대량 데이터 환경에서도 안정적인 조회 성능을 보장할 필요가 있었다.
시세 조회 기능에서 사용되는 쿼리는 다음과 같은 특징을 가진다.
- 특정 아이템 1개 기준 조회
- 날짜 범위 조건(startDate ~ endDate)
- 날짜 오름차순 정렬 (차트 시각화 목적)
SELECT *
FROM market_item_price_history
WHERE item_id = ?
AND price_date BETWEEN ? AND ?
ORDER BY price_date;
이와 같은 쿼리는 인덱스가 존재하지 않을 경우 데이터 증가에 따라 풀 테이블 스캔이 발생할 수 있으며, 이는 조회 성능 저하의 원인이 될 수 있다.
특히 item_id 단일 인덱스만 사용할 경우 날짜 범위 및 정렬 과정에서 추가적인 정렬 비용이 발생할 수 있으므로, 조회 조건과 정렬 조건을 동시에 만족하는 (item_id, price_date) 기준의 복합 인덱스를 추가하였다.
이를 통해 DB는 item_id 조건으로 먼저 검색 범위를 축소한 뒤, price_date 기준으로 정렬된 인덱스를 활용하여 추가 정렬 없이 결과를 반환할 수 있다.
@Entity
@Table(
name = "market_item_price_history",
indexes = {
@Index(
name = "idx_market_item_date",
columnList = "item_id, price_date"
)
}
)
public class MarketItemPriceHistory {
...
}
- item_id 조건으로 조회 범위를 즉시 축소
- price_date 기준 범위 검색(BETWEEN) 최적화
- ORDER BY price_date 정렬 비용 최소화
본 기능은 페이지 단위 조회가 아닌 시계열 차트 시각화를 위한 데이터 조회를 목적으로 한다. 따라서 본 기능에서는 일반적인 목록 조회에 사용되는 페이징(Pageable) 방식보다는, 기간 조건 기반 조회가 요구사항과 더 부합한다고 판단하였다.
- 차트 렌더링 시 전체 기간 데이터를 한 번에 필요
- 일반적인 조회 범위는 수개월 ~ 1년 이내
- 인덱스를 활용한 조건 조회가 페이징보다 효율적
FrontEnd
아이템의 정보를 보여줄 페이지를 Figma로 디자인 해보았다.

Router 설정
먼저 시세 조회 페이지로 이동하기 위한 라우터 성정부터 진행한다.
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Header from '../components/header/Header';
import MainPage from '../pages/main/MainPage';
import MarketItemPage from '../pages/market/MarketItemPage';
const Router = () => {
return (
<BrowserRouter>
<Header />
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/market/items" element={<MarketItemPage />} />
</Routes>
</BrowserRouter>
);
};
export default Router;
- /: 메인 페이지
- /markte/items: 아이템 페이지
MarketItemPage
MarketItemPage는 아이템 시세 조회 기능의 진입점으로 검색, 카테고리 필터링, 아이템 선택, 상세 조회까지의 전체 상태를 관리하는 컨테이너 역할을 수행한다.
import { useState } from 'react';
import ItemSearchBar from './components/ItemSearchBar/ItemSearchBar';
import CategoryTabs from './components/CategoryTabs/CategoryTabs';
import ItemList from './components/ItemList/ItemList';
import ItemDetail from './components/ItemDetail/ItemDetailHeader';
import { mockMarketItems } from './constants/mockMarketItems';
import { ITEM_CATEGORY } from './constants/itemCategory';
import './MarketItemPage.css';
const MarketItemPage = () => {
const [searchKeyword, setSearchKeyword] = useState('');
const [selectedCategory, setSelectedCategory] = useState(ITEM_CATEGORY.ALL);
const [selectedItem, setSelectedItem] = useState(null); // ⭐ 핵심
return (
<div className="market-page">
<div className="market-layout">
<aside className="market-sidebar">
<div className="market-search">
<ItemSearchBar onSearch={setSearchKeyword} />
</div>
<CategoryTabs
selectedCategory={selectedCategory}
onSelect={setSelectedCategory}
/>
<div className="market-list">
<ItemList
items={mockMarketItems}
searchKeyword={searchKeyword}
selectedCategory={selectedCategory}
selectedItem={selectedItem}
onSelectItem={setSelectedItem}
/>
</div>
</aside>
<section className="market-content">
<ItemDetail item={selectedItem} />
</section>
</div>
</div>
);
};
export default MarketItemPage;
- State 설계
const [searchKeyword, setSearchKeyword] = useState(''); const [selectedCategory, setSelectedCategory] = useState(ITEM_CATEGORY.ALL); const [selectedItem, setSelectedItem] = useState(null);- searchKeyword: 아이템 검색 키워드
- selectedCategory: 선택된 아이템 카테고리
- selectedItem: 현재 선택된 아이템(상세 조회 대상)
- 레이아웃 구조
- 화면은 크게 좌측 사이드바와 우측 상세 영역 구조로 나뉜다. ```javascript
{/* 아이템 상세 */} - 좌측: 아이템 탐색 영역 - 우측: 선택 아이템 상세 정보 ```css /* 왼쪽 사이드바 */ .market-sidebar { width: 530px; min-width: 530px; background-color: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; display: flex; flex-direction: column; height: 100%; } /* 아이템 리스트 */ .market-list { flex: 1; overflow-y: auto; padding: 16px; }- 아이템 리스트만 스크롤
- 상세 영역은 독립적으로 고정
- 검색 영역
<ItemSearchBar onSearch={setSearchKeyword} />- 검색어 입력은 MarketItemPage에서 상태로 관리
- ItemSearchBar는 입력 이벤트만 전달하는 제어 컴포넌트
- 카테고리 필터
<CategoryTabs selectedCategory={selectedCategory} onSelect={setSelectedCategory} />- 현재 선택된 카테고리를 상태로 유지
- ITEM_CATEGORY 상수를 기준으로 동작
- 아이템 리스트
<ItemList items={mockMarketItems} searchKeyword={searchKeyword} selectedCategory={selectedCategory} selectedItem={selectedItem} onSelectItem={setSelectedItem} />- ItemList는 아이템 목록 렌더링, 검색어 / 카테고리 기준 필터링, 아이템 클릭시 onSelectItem 호출
- 아이템 상세
<ItemDetail item={selectedItem} />- 선택된 아이템이 없을 경우: 안내 UI 표시
- 아이템이 선택되면 상세 정보 렌더링
Header
ItemSearchBar
ItemSearchbar는 아이템 목록에서 아이템 이름을 기준으로 검색하기 위한 입력 컴포넌트이다.
import { useState } from 'react';
import './ItemSearchBar.css';
const ItemSearchBar = ({ onSearch }) => {
const [keyword, setKeyword] = useState('');
const handleChange = (e) => {
setKeyword(e.target.value);
onSearch?.(e.target.value);
};
return (
<div className="item-search-bar">
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="아이템 이름 검색"
value={keyword}
onChange={handleChange}
/>
</div>
);
};
export default ItemSearchBar;
- 컴포넌트 역할
const ItemSearchBar = ({ onSearch }) => { ... }- 검색 입력 UI 제공
- 입력 이벤트 방생 시 상위 컴포넌트로 값 전달
- State 설계
const [keyword, setKeyword] = useState('');- 입력 필드는 제어 컴포넌트 방식으로 구현
<input type="text" placeholder="아이템 이름 검색" value={keyword} onChange={handleChange} /> - input의 value는 항상 React 상태와 동기화
- 입력 필드는 제어 컴포넌트 방식으로 구현
- 입력 이벤트 처리
const handleChange = (e) => { setKeyword(e.target.value); onSearch?.(e.target.value); };- 입력 값 변경 시 내부 상태 업데이트, 상위 컴포넌트에 즉시 전달
- onSearch?.() 문법을 사용해 콜백이 없는 경우에도 안전하게 동작
CategoryTabs
아이템 시세 조회 페이지에서는 다양한 아이템을 카테고리별로 빠르게 탐색할 수 있어야 한다. 이를 위해 CategoryTabs 컴포넌트를 별도로 분리하고, UI 표현과 도메인 카테고리 정의를 명확히 분리하여 설계했다.
import { CATEGORY_TABS } from '../../constants/categoryTabs';
import './CategoryTabs.css';
const CategoryTabs = ({ selectedCategory, onSelect }) => {
return (
<div className="category-tabs">
{CATEGORY_TABS.map((tab) => (
<button
key={tab.value}
className={`category-tab ${
selectedCategory === tab.value ? 'active' : ''
}`}
onClick={() => onSelect(tab.value)}
>
{tab.label}
</button>
))}
</div>
);
};
export default CategoryTabs;
- 컴포넌트 역할
const CategoryTabs = ({ selectedCategory, onSelect }) => { ... }- 카테고리 탭 UI 렌더링
- 선택된 카테고리 강조 표시
- 클릭 이벤트 방생 시 상위 컴포넌트로 값 전달
- 카테고리 데이터 구조 분리
export const ITEM_CATEGORY = { ALL: 'ALL', // ===== UI에 노출되는 카테고리 ===== ENGRAVING: 'ENGRAVING', // 각인서 REFORGING: 'REFORGING', // 강화 재료 (무기 진화 재료 포함) ARK_GRID: 'ARK_GRID', // 아크 그리드 재료 GEM: 'GEM', // 보석 ACCESSORY: 'ACCESSORY', // 악세서리 LIFESKILL: 'LIFESKILL', // 생활 // ===== UI에는 안 보이지만 데이터에는 존재 ===== BATTLE_ITEM: 'BATTLE_ITEM', // 전투 용품 COOKING: 'COOKING', // 요리 };- 도메인 기준 카테고리 ```javascript import { ITEM_CATEGORY } from ‘./itemCategory’;
export const CATEGORY_TABS = [ { label: ‘전체’, value: ITEM_CATEGORY.ALL }, { label: ‘재련 재료’, value: ITEM_CATEGORY.REFORGING }, { label: ‘각인서’, value: ITEM_CATEGORY.ENGRAVING }, { label: ‘아크 그리드 재료’, value: ITEM_CATEGORY.ARK_GRID }, { label: ‘보석’, value: ITEM_CATEGORY.GEM }, { label: ‘악세서리’, value: ITEM_CATEGORY.ACCESSORY }, { label: ‘생활’, value: ITEM_CATEGORY.LIFESKILL }, ]; ```
- UI 렌더링용 카테고리
- CategoryTabs 렌더링 방식
{CATEGORY_TABS.map((tab) => ( <button key={tab.value} className={`category-tab ${ selectedCategory === tab.value ? 'active' : '' }`} onClick={() => onSelect(tab.value)} > {tab.label} </button> ))}- map() 기반 렌더링
- 선택된 탭은 active 클래스 적용, 클릭 시 선택된 카테고리 값을 상위로 전달
ItemList / ItemRow
ItemList와 ItemRow는 아이템 시세 조회 페이지에서 아이템 목록 표시와 선택 인터랙션을 담당하는 핵심 컴포넌트이다.
import { useState, useMemo } from 'react';
import { ITEM_CATEGORY } from '../../constants/itemCategory';
import ItemRow from './ItemRow';
import './ItemList.css';
const ItemList = ({
items,
searchKeyword,
selectedCategory,
selectedItemId,
onSelectItem,
}) => {
const filteredItems = useMemo(() => {
return items.filter((item) => {
if (
selectedCategory !== ITEM_CATEGORY.ALL &&
item.category !== selectedCategory
) {
return false;
}
if (searchKeyword && !item.name.includes(searchKeyword)) {
return false;
}
return true;
});
}, [items, searchKeyword, selectedCategory]);
return (
<div className="item-list">
{filteredItems.map((item) => (
<ItemRow
key={item.itemId}
item={item}
isSelected={item.itemId === selectedItemId}
onClick={() => onSelectItem(item)}
/>
))}
{filteredItems.length === 0 && (
<div className="item-list-empty">
검색 결과가 없습니다.
</div>
)}
</div>
);
};
export default ItemList;
- 컴포넌트 역할
const ItemList = ({ items, searchKeyword, selectedCategory, selectedItemId, onSelectItem, }) => { ... }- 검색어 및 카테고리 기반 필터링
- 아이템 리스트 랜더링
- 선택된 아이템 상태 전달
- 빈 결과 처리
- 필터링 로직
const filteredItems = useMemo(() => { return items.filter((item) => { if ( selectedCategory !== ITEM_CATEGORY.ALL && item.category !== selectedCategory ) { return false; } if (searchKeyword && !item.name.includes(searchKeyword)) { return false; } return true; }); }, [items, searchKeyword, selectedCategory]);- 불필요한 재계산 방지를 위해 useMemo 사용
- 필터 조건을 명확히 분리
- 카테고리, 검색어 순으로 조건 적용
- 리스트 렌더링 방식
{filteredItems.map((item) => ( <ItemRow key={item.itemId} item={item} isSelected={item.itemId === selectedItemId} onClick={() => onSelectItem(item)} /> ))}- key는 itemId 기준으로 지정
- 클릭 시 선택된 아이템을 상위로 전달
- 선택 여부는 boolean 값으로만 전달
- 빈 상태 처리
{filteredItems.length === 0 && ( <div className="item-list-empty"> 검색 결과가 없습니다. </div> )}- 검색 결과가 없는 경우에는 사용자에게 즉시 피드백
const ItemRow = ({ item, isSelected, onClick }) => {
const isUp = item.priceChange > 0;
const isDown = item.priceChange < 0;
return (
<div
className={`item-row grade-${item.grade.toLowerCase()} ${
isSelected ? 'selected' : ''
}`}
onClick={onClick}
>
{/* 아이콘 */}
<div className="item-icon-wrapper">
<img
src={item.iconUrl}
alt={item.name}
className="item-icon"
/>
</div>
{/* 이름 + 묶음 */}
<div className="item-info">
<div className="item-name">{item.name}</div>
<div className="item-bundle">x{item.bundleCount}</div>
</div>
{/* 가격 */}
<div className="item-price">
<div className="current-price">
{item.currentPrice.toLocaleString()}
</div>
<div
className={`price-change ${
isUp ? 'up' : isDown ? 'down' : ''
}`}
>
{isUp && '+'}
{item.priceChange.toLocaleString()} ({item.changeRate}%)
</div>
</div>
</div>
);
};
export default ItemRow;
- 컴포넌트 역할
const ItemRow = ({ item, isSelected, onClick }) => { ... }- 아이템 정보 시각화, 가격 / 변동 정보 표시, 선택 상태에 따른 UI 변화
- 가격 변동 표시 로직
const isUp = item.priceChange > 0; const isDown = item.priceChange < 0; <div className="current-price"> {item.currentPrice.toLocaleString()} </div> <div className={`price-change ${ isUp ? 'up' : isDown ? 'down' : '' }`} > {isUp && '+'} {item.priceChange.toLocaleString()} ({item.changeRate}%) </div>- 상승: 빨간색, 하락: 파란색, 변동 없음: 기본 색상
- 아이템 등급별 UI 표현
<div className={`item-row grade-${item.grade.toLowerCase()} ${ isSelected ? 'selected' : '' }`} onClick={onClick} >.grade-common .item-icon-wrapper { background: linear-gradient(135deg, #313131, #585858); }- 아이템 등급에 해당하는 색상 매핑
- 선택된 아이템 강조
/* 선택된 아이템 */ .item-row.selected { background-color: #fff7ed; border-left: 4px solid #f97316; }
ItemDetail / ItemDetailHeader
아이템 시세 조회 페이지의 우측 영역은 선택된 아이템의 핵심 정보를 직관적으로 보여주는 상세 영역이다.
import './ItemDetailHeader.css';
const ItemDetailHeader = ({ item }) => {
if (!item) return null;
return (
<div className="item-detail-header">
<div className={`item-icon-wrapper grade-${item.grade.toLowerCase()}`}>
<img
src={item.iconUrl}
alt={item.name}
className="item-detail-icon"
/>
</div>
<span className="item-detail-name">
{item.name}
</span>
</div>
);
};
export default ItemDetailHeader;
import './ItemDetail.css';
const ItemDetail = ({ item }) => {
return (
<div className="item-detail">
<div className="item-detail-header">
<div className={`detail-icon grade-${item.grade.toLowerCase()}`}>
<img src={item.iconUrl} alt={item.name} />
</div>
<div className="detail-info">
<div className="detail-name">{item.name}</div>
<div className="detail-grade">{item.grade}</div>
</div>
</div>
<div className="item-detail-price">
<div className="price-label">현재 최저가</div>
<div className="price-value">
{item.currentPrice.toLocaleString()} G
</div>
</div>
</div>
);
};
export default ItemDetail;
댓글남기기