[Riot API 2편] 프로필 조회 기능 만들기

2025. 9. 28. 18:38·개인 프로젝트

1편에서 Riot API를 살펴보고 닉네임 + 태그 → PUUID → 소환사 정보 흐름을 확인했다면, 이번 글에서는 Spring Boot로 실제로 화면까지 출력되는 프로필 조회 웹을 만들어보자.
목표는 닉네임과 태그를 입력하면 Riot API를 호출해 레벨, 아이콘, 마지막 수정 시간 등을 보여주는 사이트를 만드는 것이다.

닉네임과 태그로 프로필 정보를 얻어올 수 있다

 


🧩 구현 순서 한눈에 보기

  • application.yml 에 API 키와 호스트 등록
  • Config 클래스에서 공통 전처리(WebClient) 구성
  • DTO 3종 설계 (Account, Summoner, View)
  • Service에서 Riot API 연동
  • Controller로 JSON API 노출
  • HTML 폼 + fetch()로 결과 출력

⚙️ 의존성 간단 정리

  • HTTP 클라이언트: spring-boot-starter-webflux
    → WebClient, Reactor, Reactor Netty 포함
  • 서버(컨트롤러) + 뷰: spring-boot-starter-web, spring-boot-starter-thymeleaf

서버는 MVC(@RestController),
외부 API 호출은 WebClient(reactive)로 분리해도 전혀 문제없다.


application.yml

riot:
  api-key: RGAPI-12345678-1234-1234-1234-123456789012
  # KR 지역의 '플랫폼 라우팅' (summoner-v4, league-v4 등)
  platform-host: kr.api.riotgames.com
  # 매치/어카운트 같은 '리저널 라우팅'
  regional-host: asia.api.riotgames.com
  • 실제 배포에서는 환경변수나 프로필 분리로 키를 관리하자.
  • 소환사 정보는 플랫폼(예: kr), 계정/매치 등은 리저널(예: asia)로 나뉜다.

Config: 공통 전처리(WebClient) 구성

package lol.jen.lol.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class RiotClientConfig {
    @Value("${riot.api-key}")
    private String apiKey;

    @Value("${riot.platform-host}")
    private String platformHost;

    @Value("${riot.regional-host}")
    private String regionalHost;

    /** 공통 로깅(개발용) */
    private ExchangeFilterFunction logRequest() {
        return ExchangeFilterFunction.ofRequestProcessor(req -> {
            // 토큰, 개인정보는 찍지 말기
            // 요청이 들어 오면 가로 채어 로그를 찍어줌.
            // ➡️ GET https://asia.api.riotgames.com/riot/account/v1/accounts/by-riot-id/나는김재현/KR1
            System.out.println("➡️ " + req.method() + " " + req.url());
            return Mono.just(req);
        });
    }
    private WebClient buildClient(String baseHost) {
        // Netty HttpClient 블록
        HttpClient httpClient = HttpClient.create()
                // TCP 연결이 5초를 넘으면 실패
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
                //응답 전체 ( 헤더 + 바디 ) 수신이 10초를 넘으면 실패
                .responseTimeout(Duration.ofSeconds(10))
                .doOnConnected(conn -> conn
                        // 연결 후 읽기가 10초동안 활동이없으면 실패
                        .addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
                        // 쓰기가 10초동안 활동이 없으면 실패
                        .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS)));
        // WebClient 블록
        return WebClient.builder()
                // 기본 도메인 고정, 이후에 .uri("/lol/...") 처럼 상대 경로만 적으면 됨.
                .baseUrl("https://" + baseHost)
                // JSON 값을 기대
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                // 모든 요청에 인증키 자동 첨부
                .defaultHeader("X-Riot-Token", apiKey)
                // 위에서 만든 Netty 타임아웃 정책을 적용시킴.
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                // 로깅 설정 ( 필터 )
                .filter(logRequest())
                // 완성
                .build();
    }

    @Bean("riotPlatformClient")
    public WebClient riotPlatformClient() {
        return buildClient(platformHost);
    }

    @Bean("riotRegionalClient")
    public WebClient riotRegionalClient() {
        return buildClient(regionalHost);
    }
}

📌 요약하자면

  • ExchangeFilterFunction: 요청을 가로채서 로그 출력
  • HttpClient: 연결/읽기/쓰기 타임아웃 세팅
  • WebClient: Riot API 호출 전처리 (인증키, 로깅 포함)
  • 플랫폼/리저널 클라이언트를 분리해 두면 나중에 관리하기 훨씬 편하다.

DTO 설계

이번 기능에서는 API가 세 종류이므로,
응답 DTO도 Account, Summoner, View 세 가지로 나눴다.

AccountDto (ACCOUNT-V1 응답)

package lol.jen.lol.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
 * Account-v1 API 호출 : gameName, tagLine 로 puuid 얻음
 */
@Getter
@Setter
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class AccountDto {
    // 소환사의 글로벌 고유 Id
    private String puuid;
    // 닉네임
    private String gameName;
    // 태그
    private String tagLine;
}

→ 닉네임 + 태그로 puuid를 얻는 단계에서 사용된다.

SummonerDto (SUMMONER-V4 응답)

package lol.jen.lol.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
 * Summoner-v4 API 호출 : puuid로 Summoner 얻음
 */
@Getter
@Setter
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class SummonerDto {
    // 소환사의 글로벌 고유 Id
    private String puuid;
    // 프로필에 표시되는 아이콘의 정수ID, Data Dragon에서 이미지로 랜더링됨.
    private Integer profileIconId;
    // 이 소환사의 데이터가 마지막으로 변경된 시각
    private Long revisionDate;
    // 소환사 계정 레벨
    private Long summonerLevel;
}

 

→ puuid로 소환사 프로필 정보를 가져올 때 사용.

ViewDto ( 화면 통합용 )

package lol.jen.lol.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
 * 프론트로 넘겨줄 View용 Dto이다.
 * AccountDto 와 SummonerDto의 내용이 합쳐짐
 */
@Getter
@Setter
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class ViewDto {
    // AccountDto 의 필드
    // 소환사의 글로벌 고유 Id
    private String puuid;
    // 닉네임
    private String gameName;
    // 태그
    private String tagLine;

    // SummonerDto 의 필드
    // 프로필에 표시되는 아이콘의 정수ID, Data Dragon에서 이미지로 랜더링됨.
    private Integer profileIconId;
    // 이 소환사의 데이터가 마지막으로 변경된 시각
    private Long revisionDate;
    // 소환사 계정 레벨
    private Long summonerLevel;
}

AccountDto + SummonerDto의 합본이다.
프론트에서는 이 하나만 받으면 모든 정보가 나온다.


⚙️ Service 레이어

Riot API 호출 흐름은 이렇다

닉네임 + 태그 → AccountDto(puuid) → SummonerDto → ViewDto
package lol.jen.lol.service;

import lol.jen.lol.dto.AccountDto;
import lol.jen.lol.dto.SummonerDto;
import lol.jen.lol.dto.ViewDto;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Service
public class SummonerService {
    @Qualifier("riotPlatformClient")
    private final WebClient platformClient;
    @Qualifier("riotRegionalClient")
    private final WebClient riotRegionalClient;

    public SummonerService(
            @Qualifier("riotPlatformClient") WebClient platformClient,
            @Qualifier("riotRegionalClient") WebClient riotRegionalClient
    ) {
        this.platformClient = platformClient;
        this.riotRegionalClient = riotRegionalClient;
    }

    /** 공통 에러 변환, 에러발생시 사용할 메서드 */
    private static Mono<? extends Throwable> toApiError(String api, ClientResponse resp) {
        int code = resp.statusCode().value();
        return resp.bodyToMono(String.class)
                .defaultIfEmpty("") // body가 없을 수도 있음
                .flatMap(body -> {
                    String msg = "[RiotAPI:" + api + "] HTTP " + code + " body=" + body;
                    return Mono.error(new RuntimeException(msg));
                });
    }
    /** 닉네임 + 태그로 AccountDto 반환 */
    public Mono<AccountDto> getAccountDtoByGameNameAndTagLine(String gameName, String tagLine) {
        return riotRegionalClient.get()
                .uri(uri -> uri.path("/riot/account/v1/accounts/by-riot-id/{gameName}/{tagLine}")
                .build(gameName, tagLine))
                // 여기부터 역직렬화 하겠다
                .retrieve()
                // 상태코드가 에러일경우, 에러코드를 던지자
                .onStatus(HttpStatusCode::isError, r -> toApiError("account-by-gameName-tagLine", r))
                // 결과를 dto에 저장
                .bodyToMono(AccountDto.class);
    }
    /** puuid로 SummonerDto 반환 */
    public Mono<SummonerDto> getSummonerDtoByPuuid(String puuid) {
        return platformClient.get()
                .uri(uri -> uri.path("/lol/summoner/v4/summoners/by-puuid/{puuid}")
                                        .build(puuid))
                // 여기부터 역직렬화 하겠다
                .retrieve()
                // 상태코드가 에러일경우, 에러코드를 던지자
                .onStatus(HttpStatusCode::isError, r -> toApiError("summoner-by-puuid", r))
                // 결과를 dto에 저장
                .bodyToMono(SummonerDto.class);
    }
    /** AccountDto + SummonerDto 합친 ViewDto 반환 ( View페이지용 Dto ) */
    public Mono<ViewDto> getViewDtoByGameNameAndTagLine(String gameName, String tagLine){
        return getAccountDtoByGameNameAndTagLine(gameName, tagLine)
                .flatMap(acc ->
                        getSummonerDtoByPuuid(acc.getPuuid())
                        .map(sum -> {
                            ViewDto viewDto = new ViewDto();
                            // Account 쪽
                            viewDto.setPuuid(acc.getPuuid());
                            viewDto.setGameName(acc.getGameName());
                            viewDto.setTagLine(acc.getTagLine());
                            // Summoner 쪽
                            viewDto.setProfileIconId(sum.getProfileIconId());
                            viewDto.setRevisionDate(sum.getRevisionDate());
                            viewDto.setSummonerLevel(sum.getSummonerLevel());
                            return viewDto;
                        })
                );
    }
}

 

⚡ 핵심 포인트

  • 요청 전: RiotClientConfig에서 타임아웃·로깅 처리
  • 요청 후: toApiError()로 상태코드별 예외 변환
  • Mono<ViewDto>로 반환해 비동기 흐름을 그대로 유지

🌐 Controller: JSON 엔드포인트

package lol.jen.lol.controller;

import lol.jen.lol.dto.AccountDto;
import lol.jen.lol.dto.SummonerDto;
import lol.jen.lol.dto.ViewDto;
import lol.jen.lol.service.SummonerService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

@RestController
@RequiredArgsConstructor
@RequestMapping("/summoner")
public class SummonerController {
    private final SummonerService summonerService;
    // UI용 통합 엔드 포인트
    @PostMapping("view/{gameName}/{tagLine}")
    public Mono<ViewDto> getViewDto(@PathVariable String gameName, @PathVariable String tagLine) {
        return summonerService.getViewDtoByGameNameAndTagLine(gameName, tagLine);
    }
    // 디버깅용 AccountDto 받아 오기
    @GetMapping("/search/{gameName}/{tagLine}")
    public Mono<AccountDto> getAccountDtoByGameNameAndTagLine(@PathVariable String gameName, @PathVariable String tagLine) {
        return summonerService.getAccountDtoByGameNameAndTagLine(gameName, tagLine);
    }
    // 디버깅용 SummonerDto 받아 오기
    @GetMapping("/by-puuid/{puuid}")
    public Mono<SummonerDto> getSummonerDtoBypuuid(@PathVariable String puuid) {
        return summonerService.getSummonerDtoByPuuid(puuid);
    }
}

🖼️ 프론트 : 폼 태그 + fetch()

간단한 입력 폼과 fetch() 호출로 결과를 바로 볼 수 있다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>검색</title>
    <style>
        #result { margin-top: 12px; }
        .row { margin: 4px 0; }
    </style>
</head>
<body>
<form id="searchForm">
    닉네임 : <input type="text" id="gameName" name="gameName"><br/>
    태그 : <input type="text" id="tagLine" name="tagLine"><br/>
    <button type="submit">제출</button>
</form>

<div id="result"></div>

<script>
    document.getElementById("searchForm").addEventListener("submit", async (e) => {
        e.preventDefault();
        const gameName = document.getElementById("gameName").value.trim();
        const tagLine  = document.getElementById("tagLine").value.trim();
        const g = encodeURIComponent(gameName);
        const t = encodeURIComponent(tagLine);

        try {
            const res = await fetch(`/summoner/view/${g}/${t}`, { method: "POST" });
            if (!res.ok) throw new Error(`HTTP ${res.status} - ${await res.text()}`);
            const v = await res.json();

            // Data Dragon 프로필 아이콘(버전은 따로 관리; 여기선 예시로 ddVer 변수 가정)
            const ddVer = "15.18.1"; // TODO: 서버에서 주입하거나 최신 버전 API로 갱신
            const iconUrl = `https://ddragon.leagueoflegends.com/cdn/${ddVer}/img/profileicon/${v.profileIconId}.png`;

            document.getElementById("result").innerHTML = `
                <div class="row"><strong>Riot ID</strong> : ${v.gameName}#${v.tagLine}</div>
                <div class="row"><strong>닉네임</strong> : ${v.gameName}</div>
                <div class="row"><strong>태그</strong> : #${v.tagLine}</div>
                <div class="row"><strong>PUUID</strong> : ${v.puuid}</div>
                <div class="row"><strong>레벨</strong> : ${v.summonerLevel ?? ""}</div>
                <div class="row"><strong>마지막 수정 시간</strong> : ${v.revisionDate ?? ""}</div>
                <div class="row"><strong>아이콘</strong> :
                 <img src="${iconUrl}" alt="profile icon" width="64" height="64"
                         onerror="this.style.display='none'">
                 </div>
            `;
        } catch (err) {
            document.getElementById("result").innerHTML = `<pre>요청 실패: ${String(err)}</pre>`;
        }
    });
</script>
</body>
</html>

포인트

  • encodeURIComponent: 한글/공백/# 같은 문자를 URL 세그먼트에 안전하게 넣어준다.
  • 날짜 포맷: revisionDate(epoch ms)는 toLocaleString으로 가독성 개선.
  • Riot API가 실시간 데이터(레벨, 랭크, 매치 리스트 등)를 준다면, Data Dragon 은 정적데이터(이미지 등)를 CDN으로 제공한다.
  • 🧠 Data Dragon 한눈 요약
    • 역할: 정적 데이터(CDN) 제공
    • 제공 항목: 프로필 아이콘, 챔피언, 아이템, 스펠, 룬, JSON 메타데이터
    • 기본 URL: https://ddragon.leagueoflegends.com/cdn/{version}/...
    최신 버전은 /api/versions.json이나
    https://ddragon.leagueoflegends.com/realms/kr.json로 확인 가능하다.

WebFlux & Reactor 한 줄 개념

  • Mono <T>: “0~1개” 비동기 결과를 담는 컨테이너 (예: 단건 조회)
  • Flux <T>: “0~N개” 스트림 (예: 목록/스트림)
  • 우리는 단건 API들을 호출하므로 Mono를 반환한다.
  • 컨트롤러에서 Mono<ViewDto>를 바로 리턴하면, Spring이 논블로킹으로 결과를 작성해 응답한다.

🚀 마무리

이번 글에서는 application.yml 설정부터
WebClient 전처리, DTO/Service/Controller,
그리고 fetch()로 프로필을 띄우는 화면까지 하나의 흐름으로 완성했다.

 

이제 프로필은 완성됐으니,
다음 편에서는 랭크 정보 + 최근 전적 리스트를 불러오는 기능을 붙여볼 예정이다.

'개인 프로젝트' 카테고리의 다른 글

[Spotify Web API 2편] API 분석  (0) 2026.06.05
[Spotify Web API 1편] 주제 및 기능 설계  (0) 2026.06.05
[Riot API 4편] LoL 전적 조회 – Service/Controller 구현  (0) 2025.10.30
[Riot API 3편] 롤 전적 DTO 설계하기  (0) 2025.10.30
[Riot API 1편] 키 발급과 사용법  (0) 2025.09.16
'개인 프로젝트' 카테고리의 다른 글
  • [Spotify Web API 1편] 주제 및 기능 설계
  • [Riot API 4편] LoL 전적 조회 – Service/Controller 구현
  • [Riot API 3편] 롤 전적 DTO 설계하기
  • [Riot API 1편] 키 발급과 사용법
MvA
MvA
백엔드 개발자 김재현입니다. 주로 공부하면서 느낀점을 기록합니다.
  • MvA
    Man vs Ai
    MvA
  • 전체
    오늘
    어제
    • 분류 전체보기 (94)
      • Java (6)
      • Python (8)
        • 딥러닝 (1)
        • 머신러닝 (7)
      • JavaScript (2)
      • 내배캠 (60)
      • 개인 프로젝트 (11)
      • 책 후기 (5)
      • 기타 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Riot API
    내일배움캠프
    아키텍처
    TiL
    딥러닝
    배포
    머신러닝
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
MvA
[Riot API 2편] 프로필 조회 기능 만들기
상단으로

티스토리툴바