1. 외부 API로부터 데이터를 가져와 JSON으로 반환하기

전체적인 코드의 흐름은 다음과 같다.

  1. 웹 브라우저가 요청한 파라미터를 컨트롤러에서 전달받아 처리한다.
  2. 검색하는 기능을 구현한 서비스 계층의 메서드를 호출한다.
  3. 외부 API 연결을 처리하는 객체에게 필요한 매개변수를 전달하여 데이터를 가져온다. (이 때, 전달하는 요청 매개변수는 API 문서에 명시된 타입을 준수한다.)
  4. 전달받은 값을 String으로 변환한 뒤, JSON 타입으로 다시 변환하여 필요한 값들을 추출할 수 있도록 한다.
  5. 사용자에게 필요한 값들만 담기 위한 DTO 객체를 생성하여 값을 저장하여 반환한다.
  6. 컨트롤러는 해당 객체를 반환하게 되고, @ResponseBody(@RestController) 어노테이션을 통해 해당 객체가 JSON 타입으로 전달되게 한다.

서버는 프론트에서 요청한 값에 대해 적절한 응답을 내려주어야 한다. 따라서 프론트 측에서 필요한 값들이 무엇인지 판단하고 이들을 JSON 타입으로 전달하는 것이 중요하다고 판단하였다.

 

1. 외부 API 호출을 담당하는 객체

@Component
public class ApiClient {

    OkHttpClient client = new OkHttpClient();

    @Value("${football.client.key}")
    private String clientKey;

    @Value("${football.client.host}")
    private String apiHost;

    public ResponseBody teamSearch(SearchTeamReq searchTeamReq) throws IOException {

        Request request = new Request.Builder()
                .url(String.format("https://api-football-v1.p.rapidapi.com/v3/standings?season=%d&league=%d", searchTeamReq.getSeason(), searchTeamReq.getLeague()))
                .get()
                .addHeader("X-RapidAPI-Key", clientKey)
                .addHeader("X-RapidAPI-Host", apiHost)
                .build();

        Response response = client.newCall(request).execute();

        return response.body();
    }
}

API 요청에 필요한 KEY 값은 yaml 파일을 통해 관리하였으며, 서비스 계층으로부터 전달받은 값들을 통해 EndPoint로 호출한다. 서비스 계층과 API 호출 객체가 데이터를 주고 받는 별도의 DTO 객체를 생성하였다.

@Getter
public class SearchTeamReq {

    private Integer season;
    private Integer league;

    public SearchTeamReq(String league, Integer season) {
        this.league = LeagueCode.valueOf(league).getValue();
        this.season = season;
    }
}
@Getter
@RequiredArgsConstructor
public enum LeagueCode {

    ENG(39),
    FRA(61),
    ITA(71),
    GER(78),
    ESP(94);

    private final Integer value;

}

요청 DTO의 경우, league 값은 별도의 enum 객체에서 값을 가져와야 하기 때문에 생성자 관련 Lombok 어노테이션은 사용하지 않았다. enum 클래스에서 관리하는 value는 실제 API 문서에서 사용되는 리그 코드이다.

 

2. 컨트롤러와 서비스

@RestController
@RequiredArgsConstructor
@RequestMapping("/search")
public class ApiController {

    private final TeamSearchService teamSearchService;

    @GetMapping("/table")
    public List<SearchTeamRes> searchLeagueTable(@RequestParam String league, @RequestParam Integer season) throws IOException {
        return teamSearchService.search(league, season);
    }
}

컨트롤러에 @RestController 어노테이션을 붙였다. 이는 외부 API로부터 받은 값을 적절히 변환하여 새로운 JSON 타입을 내려주는 것이기 때문에 별도의 View를 반환하지 않기 때문이다. @ResponseBody를 통해 메서드에 일일이 붙여주는 작업을 피하고자 @RestController 어노테이션을 사용하였다.

@Service
@RequiredArgsConstructor
public class TeamSearchService {


    private final ApiClient apiClient;

    public List<SearchTeamRes> search(String league, Integer season) throws IOException {
        // 컨트롤러에서 전달받은 요청 파라미터로 API-Football에 요청
        SearchTeamReq searchTeamReq = new SearchTeamReq(league, season);
        ResponseBody responseBody = apiClient.teamSearch(searchTeamReq);

        String result = responseBody.string();

        JSONObject resultJson = new JSONObject(result);
        JSONArray jsonArray = resultJson.getJSONArray("response")
                .getJSONObject(0)
                .getJSONObject("league")
                .getJSONArray("standings")
                .getJSONArray(0);

        List<SearchTeamRes> results = new ArrayList<>();

        // 전달받은 JSON 값에서 경기 수, 승/무/패, 승점, 골득실 추출해서 DTO로 생성해서 반환
        for (int i = 0; i < jsonArray.length(); i++) {
            JSONObject jsonObject = jsonArray.getJSONObject(i);
            SearchTeamRes searchTeamRes = new SearchTeamRes();
            // JSON "rank"
            searchTeamRes.setRank(jsonObject.getInt("rank"));
            // JSON "all"
            searchTeamRes.setAllStats(jsonObject.getJSONObject("all").toMap());

            // JSON "team"
            searchTeamRes.setTeamInfo(jsonObject.getJSONObject("team").toMap());
            
            // JSON "goalsDiff"
            searchTeamRes.setGoalsDiff(jsonObject.getInt("goalsDiff"));
            
            // JSON "points"
            searchTeamRes.setPoints(jsonObject.getInt("points"));

            results.add(searchTeamRes);
        }

        return results;
    }
}
@Getter
@Setter
public class SearchTeamRes {

    private Integer rank;

    private Map<String, Object> allStats;

    private Map<String, Object> teamInfo;

    private Integer goalsDiff;

    private Integer points;

}

핵심적인 로직을 처리하는 계층이다. DTO 객체를 생성하여 List에 담은 뒤, 다시 컨트롤러에 반환한다. 실제 외부 API(API-Football)를 통해 가져온 JSON 값은 다음과 같다.

{
   "response":[
      {
         "league":{
            "country":"England",
            "flag":"https:\/\/media-1.api-sports.io\/flags\/gb.svg",
            "name":"Premier League",
            "logo":"https:\/\/media-1.api-sports.io\/football\/leagues\/39.png",
            "season":2021,
            "id":39,
            "standings":[
               [
                  {
                     "all":{
                        "lose":3,
                        "draw":6,
                        "played":38,
                        "win":29,
                        "goals":{
                           "against":26,
                           "for":99
                        }
                     },
                     "away":{
                        "lose":1,
                        "draw":4,
                        "played":19,
                        "win":14,
                        "goals":{
                           "against":11,
                           "for":41
                        }
                     },
                     "form":"WDWWW",
                     "rank":1,
                     "description":"Promotion - Champions League (Group Stage)",
                     "update":"2022-05-22T00:00:00+00:00",
                     "team":{
                        "name":"Manchester City",
                        "logo":"https:\/\/media-3.api-sports.io\/football\/teams\/50.png",
                        "id":50
                     },
                     "goalsDiff":73,
                     "points":93,
                     "group":"Premier League",
                     "status":"same",
                     "home":{
                        "lose":2,
                        "draw":2,
                        "played":19,
                        "win":15,
                        "goals":{
                           "against":15,
                           "for":58
                        }
                     }
                  },
                  {
                     "all":{
                        "lose":2,
                        "draw":8,
                        "played":38,
                        "win":28,
                        "goals":{
                           "against":26,
                           "for":94
                        }
                     },
                     "away":{
                        "lose":2,
                        "draw":4,
                        "played":19,
                        "win":13,
                        "goals":{
                           "against":17,
                           "for":45
                        }
                     },
                     "form":"WWWDW",
                     "rank":2,
                     "description":"Promotion - Champions League (Group Stage)",
                     "update":"2022-05-22T00:00:00+00:00",
                     "team":{
                        "name":"Liverpool",
                        "logo":"https:\/\/media-3.api-sports.io\/football\/teams\/40.png",
                        "id":40
                     },
                     "goalsDiff":68,
                     "points":92,
                     "group":"Premier League",
                     "status":"same",
                     "home":{
                        "lose":0,
                        "draw":4,
                        "played":19,
                        "win":15,
                        "goals":{
                           "against":9,
                           "for":49
                        }
                     }
                  },
                  {
                     "all":{
                        "lose":6,
                        "draw":11,
                        "played":38,
                        "win":21,
                        "goals":{
                           "against":33,
                           "for":76
                        }
                     },
                     "away":{
                        "lose":3,
                        "draw":4,
                        "played":19,
                        "win":12,
                        "goals":{
                           "against":11,
                           "for":39
                        }
                     },
                     
                     ...

상당히 복잡한 Nested JSON이다. 여기에는 필요한 데이터들도 존재하지만 필요하지 않은 데이터들도 있다. 따라서 필요한 값들만 추출하기 위해 DTO 객체에 바인딩하여 이를 List 타입으로 관리하였다. 하나의 DTO 객체는 하나의 축구 클럽에 관련된 데이터를 담고 있기 때문이다.

 

3. 문제 해결

처음에는 JSONObject를 필드로 갖는 DTO 객체를 만들었다. 따라서 이를 PostMan으로 테스트한 결과, 원하는 값이 반환되지 않았다.

따라서 DTO를 다시 수정하고 테스트한 결과 다음과 같이 나왔다.

하지만 이렇게 했을 때, 데이터에 대한 분류가 되지 않고 눈에 잘 들어오지 않는다는 문제가 생긴다. 뿐만 아니라 저렇게 많은 엔티티를 갖기 위해 DTO 객체에 생성되는 필드 또한 상당하며, 이들을 각각 저장해야 하는 서비스 계층의 메서드 코드가 복잡해진다.

@Getter
@Setter
public class SearchTeamRes {

    private Integer rank;

    private Integer win;
    private Integer draw;
    private Integer lose;
    private Integer played;
    private Integer goalsAgainst;
    private Integer goalsFor;

    private String name;
    private String logo;

    private Integer goalsDiff;

    private Integer points;

}
public List<SearchTeamRes> search(String league, Integer season) throws IOException {
        // 컨트롤러에서 전달받은 요청 파라미터로 API-Football에 요청
        SearchTeamReq searchTeamReq = new SearchTeamReq(league, season);
        ResponseBody responseBody = apiClient.teamSearch(searchTeamReq);

        String result = responseBody.string();

        JSONObject resultJson = new JSONObject(result);
        JSONArray jsonArray = resultJson.getJSONArray("response")
                .getJSONObject(0)
                .getJSONObject("league")
                .getJSONArray("standings")
                .getJSONArray(0);

        List<SearchTeamRes> results = new ArrayList<>();

        // 전달받은 JSON 값에서 경기 수, 승/무/패, 승점, 골득실 추출해서 DTO로 생성해서 반환
        for (int i = 0; i < jsonArray.length(); i++) {
            JSONObject jsonObject = jsonArray.getJSONObject(i);
            SearchTeamRes searchTeamRes = new SearchTeamRes();
            // JSON "rank"
            searchTeamRes.setRank(jsonObject.getInt("rank"));
            
            // JSON "all"
            searchTeamRes.setLose(jsonObject.getJSONObject("all").getInt("lose"));
            searchTeamRes.setDraw(jsonObject.getJSONObject("all").getInt("draw"));
            searchTeamRes.setPlayed(jsonObject.getJSONObject("all").getInt("played"));
            searchTeamRes.setWin(jsonObject.getJSONObject("all").getInt("win"));
            searchTeamRes.setGoalsAgainst(jsonObject.getJSONObject("all").getJSONObject("goals").getInt("against"));
            searchTeamRes.setGoalsFor(jsonObject.getJSONObject("all").getJSONObject("goals").getInt("for"));

            // JSON "team"
            searchTeamRes.setName(jsonObject.getJSONObject("team").getString("name"));
            searchTeamRes.setLogo(jsonObject.getJSONObject("team").getString("logo"));

            // JSON "goalsDiff"
            searchTeamRes.setGoalsDiff(jsonObject.getInt("goalsDiff"));
            // JSON "points"
            searchTeamRes.setPoints(jsonObject.getInt("points"));

            results.add(searchTeamRes);
        }

        return results;
    }

이를 해결하기 위해 JSONObject를 Map 타입으로 바꾸는 작업을 하였다. 이에 관련된 내용은 별도로 포스팅하였다.

https://whxogus215.tistory.com/91

 

[JSONObject] JSONObject를 JSON으로 전달하는 방법

JSONObject는 내부적으로 HashMap 객체를 갖고 있다. 따라서 JSONObject를 사용하여 JSON을 하나의 객체로 관리할 수 있는 것이다. 또한 내부적으로 사용되는 HashMap에 데이터를 추가함으로써 새로운 JSON

whxogus215.tistory.com

그 결과, 내가 원하던 JSON 타입을 얻을 수 있었으며 코드도 간략화할 수 있게 되었다.

수정 전(좌), 수정 후(우)
수정 후 서비스 로직(좌), 수정 후 DTO 객체(우)

 

4. 느낀점

역시 프로젝트를 하면서 여러 문제를 해결하는 과정 속에서 배우는게 제일 많고 값진 것 같다. 프로젝트를 해야 하는 가장 큰 이유는 내가 뭘 모르고 뭐가 부족한지를 배울 수 있다는 것이다. 그동안 배웠던 자바 & 스프링 개념들을 적용하고 있지만 한편으로는 정확하게 알고 있지 않아 실제 코딩에 어려움을 겪은 적도 있다. 처음에는 수많은 강의와 책을 통해 공부하는 것이 다라고 생각했지만 결국에는 실제로 적용하지 못하면 아무 소용이 없음을 느꼈다. 이로 인해 앞으로의 학습 계획도 변할 것 같다.