Open API 공부 그 두번째
패키지 구조
playlist/domain/content/api
- config/TheSportsConfig.java
- controller/TheSportsApiController.java
- handler/TheSportsDateHandler.java
- response/TheSportsResponse.java
- service/TheSportsApiService.java
playlist/domain/content/api
- config/TheSportsConfig.java
- handler/TheSportsDateHandler.java
- controller/TheSportsApiController.java
- handler/TheSportsDateHandler.java
- response/TheSportsResponse.java
- service/TheSportsApiService.java
config
TheSportsConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class TheSportsConfig {
@Bean
public WebClient theSportsClient() {
return WebClient.builder()
.baseUrl("https://www.thesportsdb.com/api/v1/json")
.build();
}
}
변경된 TheSportsConfig.java
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
public class TheSportsConfig {
@Value("${spring.api.sportsdb.base-url}")
private String baseUrl;
@Bean
public WebClient theSportsClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(5))
.doOnConnected(connection -> connection
.addHandlerLast(new ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
.addHandlerLast(new WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS))
);
return WebClient.builder()
.baseUrl(baseUrl)
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
☆Handler★
TheSportsDateHandler.java
package com.codeit.playlist.domain.content.api.handler;
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
public class TheSportsDateHandler {
private final WebClient webClient;
private final ObjectMapper objectMapper;
// @Value("${api.sportsdb.key}")
// private String apiKey;
public TheSportsDateHandler(@Qualifier("theSportsClient") WebClient webClient, ObjectMapper objectMapper) {
this.webClient = webClient;
this.objectMapper = objectMapper;
}
public List<TheSportsResponse> getSportsEvent(LocalDate localDate) {
log.info("Sports API 수집 시작 : localDate = {}", localDate);
String stringDate = localDate.toString(); // String으로 변환해서 localDate를 uri에 넣음
String sportsJson = webClient.get()
.uri("/123/eventsday.php?d=" + localDate)
.retrieve()
.bodyToMono(String.class)
.block();
if(sportsJson == null || sportsJson.isEmpty()) { // 비어있다면 리스트 반환
return List.of();
}
try {
JsonNode root = objectMapper.readTree(sportsJson); // 예외필요
JsonNode jsonEvent = root.get("events");
if(jsonEvent == null || !jsonEvent.isArray()) { // 비어있다면 리스트 반환
return List.of();
}
List<TheSportsResponse> sportsList = new ArrayList<>(); // 반환할것 생성
for(int i = 0; i < jsonEvent.size(); i++) {
JsonNode eventSize = jsonEvent.get(i);
String strEvent = eventSize.path("strEvent").asText(null);
String strFileName = eventSize.path("strFilename").asText(null);
String dateEventLocal = eventSize.path("dateEventLocal").asText(null);
TheSportsResponse response = new TheSportsResponse(
strEvent, strFileName, dateEventLocal
);
sportsList.add(response);
}
log.info("Sports API 수집 완료, localDate = {}", localDate);
return sportsList;
} catch (JsonProcessingException e) {
log.error("Sports API 단일 날짜 수집 실패, localDate = {}", localDate, e);
return List.of();
}
}
}
해야할것
.block() 사용은 리액티브 프로그래밍 원칙을 위반합니다.
Spring WebFlux의 핵심은 논블로킹 리액티브 스트림입니다. .block()을 사용하면 스레드가 블로킹되어 리액티브의 장점을 잃게 되며, 특히 타임아웃이 설정되지 않아 무한정 대기할 수 있습니다. PR 목표에서 명시한 Flux 기반 접근 방식을 사용해야 합니다.
또한, 한 달치 데이터를 순차적으로 수집할 때 각 날짜마다 블로킹 호출이 발생하면 전체 처리 시간이 매우 길어집니다.
전체 메서드를 리액티브 방식으로 리팩토링하세요 (아래 전체 메서드 리팩토링 제안 참고).
수정한 handler
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
public class TheSportsDateHandler {
private final WebClient webClient;
private final ObjectMapper objectMapper;
// @Value("${api.sportsdb.key}")
// private String apiKey;
public TheSportsDateHandler(@Qualifier("theSportsClient") WebClient webClient, ObjectMapper objectMapper) {
this.webClient = webClient;
this.objectMapper = objectMapper;
}
public List<TheSportsResponse> getSportsEvent(LocalDate localDate) {
log.info("Sports API 수집 시작 : localDate = {}", localDate);
String sportsJson = webClient.get()
.uri("/123/eventsday.php?d=" + localDate)
.retrieve()
.bodyToMono(String.class)
.block();
if(sportsJson == null || sportsJson.isEmpty()) { // 비어있다면 리스트 반환
return List.of();
}
try {
JsonNode root = objectMapper.readTree(sportsJson); // 예외필요
JsonNode jsonEvent = root.get("events");
if(jsonEvent == null || !jsonEvent.isArray()) { // 비어있다면 리스트 반환
return List.of();
}
List<TheSportsResponse> sportsList = new ArrayList<>(); // 반환할것 생성
for(int i = 0; i < jsonEvent.size(); i++) {
JsonNode eventSize = jsonEvent.get(i);
String strEvent = eventSize.path("strEvent").asText(null);
String strFileName = eventSize.path("strFilename").asText(null);
String dateEventLocal = eventSize.path("dateEventLocal").asText(null);
String strThumb = eventSize.path("strThumb").asText(null);
TheSportsResponse response = new TheSportsResponse(
strEvent, strFileName, dateEventLocal, strThumb
);
log.info("썸네일 : {}", response.strThumb());
sportsList.add(response);
}
log.info("Sports API 수집 완료, localDate = {}", localDate);
return sportsList;
} catch (JsonProcessingException e) {
log.error("Sports API 단일 날짜 수집 실패, localDate = {}", localDate, e);
return List.of();
}
}
}
controller : 스케줄러 적용으로 삭제됨, 테스트용
TheSportsApiController.java
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import com.codeit.playlist.domain.content.api.service.TheSportsApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class TheSportsApiController {
private final TheSportsApiService theSportsApiService;
@GetMapping("/sports")
public List<TheSportsResponse> getSports(@RequestParam int year,
@RequestParam int month) {
return theSportsApiService.searchSports(year, month);
}
}
현재 변경된 TheSportsApiController.java
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import com.codeit.playlist.domain.content.api.service.TheSportsApiService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class TheSportsApiController {
private final TheSportsApiService theSportsApiService;
@GetMapping("/sports")
public List<TheSportsResponse> getSports(@RequestParam @Min(2000) @Max(2100) int year,
@RequestParam @Min(1) @Max(12) int month) {
return theSportsApiService.searchSports(year, month);
}
}
바꿔야하는것
리액티브 타입(Flux)을 반환하도록 변경하세요.
Spring WebFlux를 사용하는 목적은 논블로킹 리액티브 스트림을 활용하는 것입니다. 현재 블로킹 List를 반환하면 WebFlux의 이점을 살릴 수 없습니다.
다음과 같이 수정하세요:
+import reactor.core.publisher.Flux;
+
- public List<TheSportsResponse> getSports(@RequestParam int year,
+ public Flux<TheSportsResponse> getSports(@RequestParam int year,
@RequestParam int month) {
return theSportsApiService.searchSports(year, month);
}
참고: TheSportsApiService.searchSports()도 Flux<TheSportsResponse>를 반환하도록 수정해야 합니다.
Mapper
TheSportsMapper.java
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import com.codeit.playlist.domain.content.entity.Content;
import com.codeit.playlist.domain.content.entity.Type;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface TheSportsMapper {
@Mapping(target = "type", expression = "java(type)")
@Mapping(target = "description", source = "theSportsResponse.strFilename")
@Mapping(target = "title", source = "theSportsResponse.strEvent")
@Mapping(target = "thumbnailUrl", source = "theSportsResponse.strThumb")
Content sportsResponseToContent(TheSportsResponse theSportsResponse, Type type);
}
Response
TheSportsResponse.java
public record TheSportsResponse(
String strEvent, // "strEvent": "UAB vs South Florida"
String strFileName, // "strFilename": "NCAA Division 1 2025-11-22 UAB vs South Florida"
String dateEventLocal // "dateEventLocal": "2025-11-21"
) {
}
수정된 Response
import com.fasterxml.jackson.annotation.JsonProperty;
public record TheSportsResponse(
@JsonProperty("strEvent") String strEvent, // "strEvent": "UAB vs South Florida"
@JsonProperty("strFilename") String strFilename, // "strFilename": "NCAA Division 1 2025-11-22 UAB vs South Florida"
@JsonProperty("dateEventLocal") String dateEventLocal, // "dateEventLocal": "2025-11-21"
@JsonProperty("strThumb") String strThumb // https://r2.thesportsdb.com/images/media/event/thumb/pu62u01694795331.jpg
) {
}
Service
TheSportsApiService.java
import com.codeit.playlist.domain.content.api.handler.TheSportsDateHandler;
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class TheSportsApiService {
private final TheSportsDateHandler dateHandler;
public List<TheSportsResponse> searchSports(int year, int month) {
// Calendar calendar = Calendar.getInstance();
// calendar.set(Calendar.YEAR, year);
// calendar.set(Calendar.MONTH, month - 1);
YearMonth sportsMonth = YearMonth.of(year, month);
LocalDate startDate = sportsMonth.atDay(1);
LocalDate lastDate = sportsMonth.atEndOfMonth();
List<TheSportsResponse> sportsList = new ArrayList<>();
for(LocalDate date = startDate; !date.isAfter(lastDate); date = date.plusDays(1)) {
sportsList.addAll(dateHandler.getSportsEvent(date));
}
return sportsList;
}
}
해야할것
리액티브 프로그래밍 패턴(Flux)을 적용하세요.
PR 목표에서 명시한 대로 Spring WebFlux와 Flux 기반의 리액티브 흐름을 우선적으로 적용해야 합니다. 현재 구현은 블로킹 방식이며 순차적으로 실행되어 성능이 저하될 수 있습니다. 특히 한 달치 데이터를 수집할 때 각 날짜별 API 호출이 순차적으로 이루어져 전체 처리 시간이 길어집니다.
다음과 같이 Flux를 사용하도록 리팩토링하세요:
+import reactor.core.publisher.Flux;
+
- public List<TheSportsResponse> searchSports(int year, int month) {
+ public Flux<TheSportsResponse> searchSports(int year, int month) {
YearMonth sportsMonth = YearMonth.of(year, month);
LocalDate startDate = sportsMonth.atDay(1);
LocalDate lastDate = sportsMonth.atEndOfMonth();
- List<TheSportsResponse> sportsList = new ArrayList<>();
-
- for(LocalDate date = startDate; !date.isAfter(lastDate); date = date.plusDays(1)) {
- sportsList.addAll(dateHandler.getSportsEvent(date));
- }
- return sportsList;
+ return Flux.fromStream(startDate.datesUntil(lastDate.plusDays(1)))
+ .flatMap(date -> dateHandler.getSportsEvent(date));
}
참고: TheSportsDateHandler.getSportsEvent()도 Flux<TheSportsResponse>를 반환하도록 수정해야 합니다.
저장용
// public void createTheSportsToContent(LocalDate date) { // response랑 content를 맵핑함
// List<TheSportsResponse> sportsResponseList = theSportsDateHandler.getSportsEvent(date);
// for(int i = 0; i < sportsResponseList.size(); i++) {
// TheSportsResponse response = sportsResponseList.get(i); // TheSportsResponse를 가져옴
// theSportsMapper.sportsResponseToContent(response, Type.SPORT); // Mapper를 통해 TheSportsResponse를 Content타입으로 바꿈
// saveContentsUsingContents(response);
// }
// }
// private final TheSportsDateHandler dateHandler;
// public List<TheSportsResponse> searchSports(int year, int month) {
//
// YearMonth sportsMonth = YearMonth.of(year, month);
// LocalDate startDate = sportsMonth.atDay(1);
// LocalDate lastDate = sportsMonth.atEndOfMonth();
//
// List<TheSportsResponse> sportsList = new ArrayList<>();
//
// for(LocalDate date = startDate; !date.isAfter(lastDate); date = date.plusDays(1)) {
// sportsList.addAll(dateHandler.getSportsEvent(date));
// }
// return sportsList;
// }
수정된 Service
import com.codeit.playlist.domain.content.api.handler.TheSportsDateHandler;
import com.codeit.playlist.domain.content.api.response.TheSportsResponse;
import com.codeit.playlist.domain.content.entity.Content;
import com.codeit.playlist.domain.content.repository.ContentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class TheSportsApiService {
private final TheSportsDateHandler theSportsDateHandler;
private final ContentRepository contentRepository;
@Transactional
public void saveContentsUsingContents(LocalDate localDate) {
List<TheSportsResponse> theSportsResponseList = theSportsDateHandler.getSportsEvent(localDate);
for(int i = 0; i < theSportsResponseList.size(); i++) {
TheSportsResponse theSportsResponse = theSportsResponseList.get(i);
log.debug("TheSportsResponse 확인: event = {}, thumb = {}",
theSportsResponse.strEvent(), theSportsResponse.strThumb());
Content content = Content.createContent(
theSportsResponse.strEvent(),
theSportsResponse.strFilename(),
theSportsResponse.strThumb()
);
log.debug("매핑된 Content 확인: title = {}, thumbnailUrl = {}",
content.getTitle(), content.getThumbnailUrl());
contentRepository.save(content); // 여기에서 저장함
}
}
}
수정사안

'Playlist > Open API' 카테고리의 다른 글
| TvSeriesApiService (0) | 2025.12.17 |
|---|---|
| TheMovieApiService (0) | 2025.12.17 |
| Open API 사용하기 : The Movie (진행중) (0) | 2025.11.20 |
| Open API : TheMovie (0) | 2025.11.20 |
| Open API 설정하기 (진행중) (0) | 2025.11.16 |