요구사항
- RIOT 에서 제공하는 API 사용
- API KEY 값 공유되지 않도록 설정
- Json 기반의 API 를 자바 객체로 역직렬화
- 검색시 최근 10게임 전적 표기
- 다음 필드 조회 및 커스터마이징
- 소환사 정보 (닉네임, 레벨, 티어, 승/패) 표기
- 플레이한 챔피언, 승/패, 경기 시간, KDA
미리보기

1. 소환사 정보 조회
1.1 사용 API: https://developer.riotgames.com/apis#summoner-v4/GET_getBySummonerName
Riot Developer Portal
developer.riotgames.com
1.2 SummonerDTO.java: 소환사 정보 조회시 사용되는 객체
//SummonerDTO.java
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class SummonerDTO {
private String id;
private String accountId;
private String puuid;
private String name;
@JsonProperty("profileIconId")
private String profileImg;
private long revisionDate;
private int summonerLevel;
// 프로필 이미지 URL
public void setProfileImg(String profileImg) {
this.profileImg = "http://ddragon.leagueoflegends.com/cdn/13.24.1/img/profileicon/"+ profileImg + ".png";
}
}
1.3 RiotApi.java: 소환사 정보 조회 API 호출 및 역직렬화
//RiotApi.java
import ...
@Service
@RequiredArgsConstructor
public class RiotApi {
private final RestTemplate restTemplate;
@Autowired
private final RiotApiConfig riotApiConfig; // APIKEY 설정
private final String RiotUri_getSummoner = "https://kr.api.riotgames.com/lol/summoner/v4/summoners/by-name/{summonerName}";
public SummonerDTO getSummoner(String summonerName){
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getSummoner, HttpMethod.GET, entity, SummonerDTO.class, summonerName).getBody();
}
...
}
1.4 SummonerService.java: 소환사 정보 조회 로직 구현
//SummonerService.java
import ...
@Service
@RequiredArgsConstructor
public class SummonerService {
private final RiotApi riotApi;
public SummonerDTO findSummoner(String summonerName){
return riotApi.getSummoner(summonerName);
}
...
}
2. 리그(티어) 조회
2.1 사용 API: https://developer.riotgames.com/apis#league-v4/GET_getLeagueEntriesForSummoner
Riot Developer Portal
developer.riotgames.com
2.2 LeagueEntryDTO.java: 리그(티어) 조회시 사용되는 객체
// LeagueEntryDTO.java
import com.fasterxml.jackson.annotation.JsonCreator;
import lombok.*;
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class LeagueEntryDTO {
private String leagueId;
private String summonerId;
private String summonerName;
private String queueType;
private String tier;
private String rank;
private int leaguePoints;
private int wins;
private int losses;
private boolean hotStreak;
private boolean veteran;
private boolean freshBlood;
private boolean inactive;
}
2.3 RiotApi.java: 리그(티어) 조회 API 호출 및 역직렬화
//RiotApi.java
import ...
@Service
@RequiredArgsConstructor
public class RiotApi {
private final RestTemplate restTemplate;
@Autowired
private final RiotApiConfig riotApiConfig;
...
private final String RiotUri_getLeagueEntries = "https://kr.api.riotgames.com/lol/league/v4/entries/by-summoner/{encryptedSummonerId}";
public LeagueEntryDTO[] getLeagueEntries(String encryptedSummonerId) {
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getLeagueEntries, HttpMethod.GET, entity, LeagueEntryDTO[].class, encryptedSummonerId).getBody();
}
...
}
2.4 SummonerService.java: 리그(티어) 조회 로직 구현
//SummonerService.java
import ...
@Service
@RequiredArgsConstructor
public class SummonerService {
private final RiotApi riotApi;
public LeagueEntryDTO[] findLeagueEntry(String encryptedSummonerId){
return riotApi.getLeagueEntries(encryptedSummonerId);
}
...
}
3. 최근 매치 ID 조회
3.1 사용 API: https://developer.riotgames.com/apis#match-v5/GET_getMatchIdsByPUUID
Riot Developer Portal
developer.riotgames.com
3.2 RiotApi.java: 최근 매치 ID 조회 API 호출 및 역직렬화
//RiotApi.java
import ...
@Service
@RequiredArgsConstructor
public class RiotApi {
private final RestTemplate restTemplate;
@Autowired
private final RiotApiConfig riotApiConfig;
...
private final String RiotUri_getMatchList = "https://asia.api.riotgames.com/lol/match/v5/matches/by-puuid/{puuid}/ids?start=0&count=10";
public String[] getMatchList(String puuid){
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getMatchList, HttpMethod.GET, entity, String[].class, puuid).getBody();
}
...
}
3.3 SummonerService.java: 최근 매치 ID 조회 로직 구현
//SummonerService.java
import ...
@Service
@RequiredArgsConstructor
public class SummonerService {
private final RiotApi riotApi;
public String[] findMatchList(String puuid){
return riotApi.getMatchList(puuid);
}
...
}
4. 상세 매치 정보 조회
4.1 사용 API: https://developer.riotgames.com/apis#match-v5/GET_getMatch.
Riot Developer Portal
developer.riotgames.com
4.2 MatchDto.java: 상세 매치 정보 조회시 역직렬화에 사용되는 객체
// MatchDto.java
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;
import lombok.*;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@NoArgsConstructor
public class MatchDto {
@JsonProperty("info")
private InfoDto info;
@Getter @Setter
public static class InfoDto{
@JsonProperty("participants")
private List<ParticipantDto> participants;
@JsonProperty("gameDuration") // 게임 시간(초)
private String gameDuration;
@JsonProperty("gameMode") // CLASSIC: 일반, ARAM: 무작위 총력전
private String gameMode;
public void setGameDuration(int gameDuration) {
this.gameDuration = String.valueOf(gameDuration/60) + "분 " + String.valueOf(gameDuration%60) + "초" ;
}
public void setGameMode(String gameMode) {
if (gameMode.equals("CLASSIC")){
this.gameMode = "일반";
} else if (gameMode.equals("ARAM")) {
this.gameMode = "무작위 총력전";
} else {
this.gameMode = "기타";
}
}
}
@Getter @Setter
public static class ParticipantDto{
@JsonProperty("win")
boolean win;
@JsonProperty("puuid")
String puuid;
@JsonProperty("teamId")
int teamId;
@JsonProperty("kills")
int kills;
@JsonProperty("deaths")
int deaths;
@JsonProperty("assists")
int assists;
@JsonProperty("championId")
String championId;
@JsonProperty("championName")
String championImg;
public void setChampionImg(String championImg) {
this.championImg = "http://ddragon.leagueoflegends.com/cdn/13.24.1/img/champion/" + championImg + ".png";
}
}
}
- @JsonIgnoreProperties(ignoreUnknown = true) : 필드가 전부 선언되지 않아도 가져올수있도록 설정. MatchDto는 매우 복잡한 구조라 필요한 필드만을 선언
- ParticipantDto 등의 복잡한 하위 필드들은 내부 클래스가 아닌 static으로 선언: 다른 디렉토리에서도 참조하기 위해
- 그 외 챔피언 이미지, 게임 타입등이 표기 되도록 적절히 커스터마이징
4.3 필드 커스터마이징에 사용되는 객체
package dodgekr.lolcommunity.summoner.domain;
import lombok.Data;
@Data
public class OwnMatchDto {
MatchDto.ParticipantDto participantDto = new MatchDto.ParticipantDto();
private String gameMode;
private String gameDuration;
}
- MatchDto.java 의 경우 ParticipantDto를 리스트 형태의 필드로 가짐.
- 전적 검색한 소환사에 대한 정보만을 가져오기 위해 ParticipantDto를 단일 필드로 가지는 OwnMatchDto 생성
4.4 RiotApi.java: 상세 매치 정보 조회 API 호출 및 역직렬화
//RiotApi.java
import ...
@Service
@RequiredArgsConstructor
public class RiotApi {
private final RestTemplate restTemplate;
@Autowired
private final RiotApiConfig riotApiConfig;
...
private final String RiotUri_getMatch = "https://asia.api.riotgames.com/lol/match/v5/matches/{matchId}";
public MatchDto getMatch(String matchId){
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getMatch, HttpMethod.GET, entity, MatchDto.class, matchId).getBody();
}
...
}
4.5 SummonerService.java: 상세 매치 정보 조회 로직 구현
//SummonerService.java
import ...
@Service
@RequiredArgsConstructor
public class SummonerService {
private final RiotApi riotApi;
public List<OwnMatchDto> getOwnMatchDtoList(String[] matchIds, SummonerDTO summonerDTO) {
// 1. 조회할 매치 id 값(리스트)과 검색한 소환사 정보값(단일) 파라미터로 들어옴
// 2. 결과로 출력할 List<OwnMatchDto> 객체 생성
List<OwnMatchDto> ownMatchDtoList = new ArrayList<>();
// 3. 매치 id loop
for (String matchId : matchIds) {
// 3.1 매치 아이디별 상세 매치 정보 조회
OwnMatchDto ownMatchDto = new OwnMatchDto();
MatchDto matchDto = this.findMatch(matchId);
// 3.2 ownMatchDto 게임 정보 설정
ownMatchDto.setGameDuration(matchDto.getInfo().getGameDuration());
ownMatchDto.setGameMode(matchDto.getInfo().getGameMode());
// 3.3 ParticipantDto 중 소환사 정보값과 일치하는 값만 ownMatchDto 값으로 set
List<MatchDto.ParticipantDto> participants = matchDto.getInfo().getParticipants();
for (MatchDto.ParticipantDto p : participants) {
if (p.getPuuid().equals(summonerDTO.getPuuid())) {
ownMatchDto.setParticipantDto(p);
break;
}
}
ownMatchDtoList.add(ownMatchDto);
}
// 4. ownMatchDtoList 리턴
return ownMatchDtoList;
}
...
}
5. 전체 코드
5.1 SummonerController
// SummonerController.java
import dodgekr.lolcommunity.summoner.domain.LeagueEntryDTO;
import dodgekr.lolcommunity.summoner.domain.OwnMatchDto;
import dodgekr.lolcommunity.summoner.domain.SummonerDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class SummonerController {
private final SummonerService summonerService;
@RequestMapping(value = "/summoner/{summonerName}", method = RequestMethod.GET)
public String searchSummoner(Model model, @PathVariable String summonerName){
SummonerDTO summonerDTO = summonerService.findSummoner(summonerName);
LeagueEntryDTO[] leagueEntryDTO = summonerService.findLeagueEntry(summonerDTO.getId());
String[] matchList = summonerService.findMatchList(summonerDTO.getPuuid());
List<OwnMatchDto> ownMatchDtoList = summonerService.getOwnMatchDtoList(matchList, summonerDTO);
model.addAttribute("summonerInfo", summonerDTO);
model.addAttribute("entryInfo", leagueEntryDTO[0]);
model.addAttribute("playerRecords", ownMatchDtoList);
return "summoner_detail";
}
}
5.2 SummonerService
// SummonerService.java
package dodgekr.lolcommunity.summoner;
import dodgekr.lolcommunity.summoner.domain.LeagueEntryDTO;
import dodgekr.lolcommunity.summoner.domain.MatchDto;
import dodgekr.lolcommunity.summoner.domain.OwnMatchDto;
import dodgekr.lolcommunity.summoner.domain.SummonerDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SummonerService {
private final RiotApi riotApi;
public SummonerDTO findSummoner(String summonerName) {
return riotApi.getSummoner(summonerName);
}
public LeagueEntryDTO[] findLeagueEntry(String encryptedSummonerId) {
return riotApi.getLeagueEntries(encryptedSummonerId);
}
public String[] findMatchList(String puuid) {
return riotApi.getMatchList(puuid);
}
public MatchDto findMatch(String matchId) {
return riotApi.getMatch(matchId);
}
public List<OwnMatchDto> getOwnMatchDtoList(String[] matchIds, SummonerDTO summonerDTO) {
// 1. 조회할 매치 id 값(리스트)과 검색한 소환사 정보값(단일) 파라미터로 들어옴
// 2. 결과로 출력할 List<OwnMatchDto> 객체 생성
List<OwnMatchDto> ownMatchDtoList = new ArrayList<>();
// 3. 매치 id loop
for (String matchId : matchIds) {
// 3.1 매치 아이디별 상세 매치 정보 조회
OwnMatchDto ownMatchDto = new OwnMatchDto();
MatchDto matchDto = this.findMatch(matchId);
// 3.2 ownMatchDto 게임 정보 설정
ownMatchDto.setGameDuration(matchDto.getInfo().getGameDuration());
ownMatchDto.setGameMode(matchDto.getInfo().getGameMode());
// 3.3 ParticipantDto 중 소환사 정보값과 일치하는 값만 ownMatchDto 값으로 set
List<MatchDto.ParticipantDto> participants = matchDto.getInfo().getParticipants();
for (MatchDto.ParticipantDto p : participants) {
if (p.getPuuid().equals(summonerDTO.getPuuid())) {
ownMatchDto.setParticipantDto(p);
break;
}
}
ownMatchDtoList.add(ownMatchDto);
}
// 4. ownMatchDtoList return
return ownMatchDtoList;
}
}
5.3 RiotApi.java
// RiotApi.java
import dodgekr.lolcommunity.RiotApiConfig;
import dodgekr.lolcommunity.summoner.domain.LeagueEntryDTO;
import dodgekr.lolcommunity.summoner.domain.MatchDto;
import dodgekr.lolcommunity.summoner.domain.SummonerDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpHeaders;
@Service
@RequiredArgsConstructor
public class RiotApi {
private final RestTemplate restTemplate;
@Autowired
private final RiotApiConfig riotApiConfig;
private final String RiotUri_getSummoner = "https://kr.api.riotgames.com/lol/summoner/v4/summoners/by-name/{summonerName}";
private final String RiotUri_getLeagueEntries = "https://kr.api.riotgames.com/lol/league/v4/entries/by-summoner/{encryptedSummonerId}";
private final String RiotUri_getMatchList = "https://asia.api.riotgames.com/lol/match/v5/matches/by-puuid/{puuid}/ids?start=0&count=10";
private final String RiotUri_getMatch = "https://asia.api.riotgames.com/lol/match/v5/matches/{matchId}";
public SummonerDTO getSummoner(String summonerName){
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getSummoner, HttpMethod.GET, entity, SummonerDTO.class, summonerName).getBody();
}
public LeagueEntryDTO[] getLeagueEntries(String encryptedSummonerId) {
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getLeagueEntries, HttpMethod.GET, entity, LeagueEntryDTO[].class, encryptedSummonerId).getBody();
}
public String[] getMatchList(String puuid){
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getMatchList, HttpMethod.GET, entity, String[].class, puuid).getBody();
}
public MatchDto getMatch(String matchId){
final HttpHeaders headers = new HttpHeaders();
headers.set("X-Riot-Token", riotApiConfig.getRIOT_API_KEY());
final HttpEntity<String> entity = new HttpEntity<>(headers);
return restTemplate.exchange(RiotUri_getMatch, HttpMethod.GET, entity, MatchDto.class, matchId).getBody();
}
}
5.4 summoner_detail.com
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<div class="card" style="border-radius: 15px;">
<div class="card-body p-4">
<div class="d-flex text-black">
<div class="flex-shrink-0">
<img th:src="${summonerInfo.profileImg}"
alt="Generic placeholder image" class="img-fluid"
style="width: 180px; border-radius: 10px;">
</div>
<div class="flex-grow-1 ms-3">
<h5 class="mb-1" th:text="${summonerInfo.name}">Danny McLoan</h5>
<p class="mb-2 pb-1" style="color: #2b2a2a;" th:text="|LEVEL ${summonerInfo.summonerLevel}|"></p>
<div class="d-flex justify-content-start rounded-3 p-2 mb-2"
style="background-color: #efefef;">
<div>
<p class="small text-muted mb-1">티어</p>
<p class="mb-0" th:text="|${entryInfo.tier} ${entryInfo.rank}|"></p>
</div>
<div class="px-3">
<p class="small text-muted mb-1">승</p>
<p class="mb-0" th:text="${entryInfo.wins}"></p>
</div>
<div>
<p class="small text-muted mb-1">패</p>
<p class="mb-0" th:text="${entryInfo.losses}"></p>
</div>
</div>
</div>
</div>
</div>
</div>
<br>
<div class="card" style="padding: 5px;">
<table>
<tbody style="border-radius: 15px;">
<tr th:each="match, loop : ${playerRecords}" th:style="${match.participantDto.win} == false ? 'background: #FFD8D9; border-radius: 15px;' : 'background: #D5E3FF; border-radius: 15px;' ">
<td><img th:src="${match.participantDto.championImg}" class="img-fluid" style="width: 70px; border-radius: 15px; margin:10px"></td>
<td th:text="${match.participantDto.win} ? '승리': '패배'"></td>
<td th:text="${match.gameMode}"></td>
<td th:text="${match.gameDuration}"></td>
<td th:text="|${match.participantDto.kills} / ${match.participantDto.deaths} / ${match.participantDto.assists}|"></td>
</tr>
</tbody>
</table>
</div>
</div>
</html>
6. 참조
오픈 API 사용해서 데이터 가져오기 (네이버 영화 검색 API)
https://leveloper.tistory.com/24
[스프링] 오픈 api 사용해서 데이터 가져오기 (네이버 영화 검색 api)
네이버 오픈 api를 사용해서 영화를 검색하는 애플리케이션을 구현해보려고 한다. 오픈 api에 대한 정보를 네이버 오픈 api 검색 > 영화에 있다. 오픈 API 이용 신청 오픈 api를 사용하려면 보통 key가
leveloper.tistory.com
이미지파싱
https://ondolroom.tistory.com/708
라이엇 api CDN
챔피언 챔피언 용 데이터 파일에는 두 가지 종류가 있습니다. champion.json데이터 파일에 대한 간단한 요약과 함께 챔피언의 목록을 반환합니다. 개별 챔피언 JSON 파일에는 각 챔피언에 대한 추가
ondolroom.tistory.com
롤 전적 검색 사이트 예시1
https://onda2me.github.io/springboot/riot-api-of-lol1/
라이엇 게임즈의 오픈API를 이용한 롤 전적 검색 사이트 만들기 (1/2)
프로젝트 진행 동기 ? 공부도 잠도 휴식도 모두 버리고, 롤에만 올인하는 아들의 감시 및 취미 목적의 프로젝트 라이엇 게임즈의 오픈 API를 이용한 op.gg 나 lol.ps 같은 롤 전적 검색 및 분석 사이
onda2me.github.io
롤 전적 검색 사이트 예시2
롤 전적 검색 사이트 프로젝트
3주간 진행했던 토이 프로젝트가 끝이 났다. 스프링부트에서 외부 api를 사용한 것은 처음이라 생각보다 시간이 오래 걸렸다. 간단한 프로젝트 주제로 어떤게 좋을까 고민을 하다가 즐겨하는 게
velog.io
롤 API 정보 DB 에 저장하기(DB 캐싱)
https://pigstew.tistory.com/11
[fLOL] 01. 롤 API를 이용해 유저 정보 불러와 DB에 저장하기(1)
fLOL의 다른 글은 아래 더보기 버튼을 누르면 확인할 수 있습니다. 더보기 2020/02/01 - [fLOL] - [fLOL] 기획 2020/02/01 - [fLOL] - [fLOL] 개발 환경 세팅 개발환경 JAVA 8 Spring Boot 2.1.7 + JPA mariaDB 주의사항 이 글
pigstew.tistory.com
API 키 값 숨기기
https://wpioneer.tistory.com/223
[Spring Boot] properties 를 통해서 키 값 숨기기
우리가 프로젝트를 진행하다보면 API key와 같은 민감 정보들을 사용할때가 있다. 그런 민감 정보들을 소스에다가 적어두고 깃허브에 올리거나 타인에게 공유를 하게 되면 민감 정보들이 유출될
wpioneer.tistory.com
CDN 이미지 파싱 & DB 캐싱
League of Legends 챔피언 데이터 가져오기
롤 api로 데이터를 가져오게 되면 "소환사 명", "챔피언 명"보다는 각각에 붙어있는 고유 번호를 통해 정보를 얻게 된다. 따라서 롤 api를 사용하기 전에 우리는 League of legends의 static 데이터를 미리
velog.io
롤 API 사용예시
Riot API - APIS 에 존재하는 API들 호출해보기
우선 롤 전적검색 사이트를 만들기에 앞서 라이엇에서 제공하는 데이터가 무엇인지 확인할 필요가 있다고 생각했다. 리그오브레전드, 리그오브룬테라, TFT전략적팀전투(롤토체스), 발로란트 의
iamiet.tistory.com
복잡한 api 역직렬화
https://jjingho.tistory.com/22
[Java] 역직렬화(Deserialize)
어떤 요청에 대한 응답으로 JSON을 많이 이용하고 있다. 이때 필요한 Data만 전달받는 DTO를 만들고 적용해보자. (카카오톡 챗봇 예제를 이용해 진행했습니다.) 1. 역직렬화(Deserialize)란? byte로 변환
jjingho.tistory.com
'백앤드 개발 > Spring boot 기반 롤 전적 사이트 개발' 카테고리의 다른 글
[Spring 프로젝트] 라이엇 API 분석 (1) | 2024.01.08 |
---|---|
[Spring 프로젝트] 스프링부트 jar 배포 (EC2, RDS, 고정 IP, FileZila) (0) | 2023.12.23 |
[Spring 프로젝트] 프로젝트 설계 3일차 기능 명세서 (2) | 2023.12.06 |
[Spring 프로젝트] 프로젝트 설계 2일차 테이블 설계 (0) | 2023.11.16 |
[Spring 프로젝트] 프로젝트 설계 1일차 (0) | 2023.11.10 |