본문 바로가기

Playlist/Open API

Open API : The Sports

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