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()로 수정해 해결하였다.
  • 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()
          );
        }
        
    • .toList(): 스트림을 리스트로 수집
  • 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 표시
    • 아이템이 선택되면 상세 정보 렌더링

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;

댓글남기기