티스토리 뷰

Project

[개발일지] Spotify Web API 사용기 (1)

Nickolodeon 2023. 1. 31. 14:50
Introduction
Spotify Web API 를 사용하던 도중, Album 과 Track 은 불러오는데에 성공하면서도 Artist 를 불러오지 못하는 문제가 발생하였다.

💥 문제 발생

artist_name 에 빈 값들이 들어갔다.

 

아무리 코드를 둘러봐도 문제점을 발견하지 못했고, 빈 값으로 가져오는 이유가 명확하게 찾아지지 않았다.

혹시나 tracks/{offset}/artists/name 의 경로 설정이 다를 수도 있겠다고 판단이 되어서 로직 수정이 아닌 오타를 찾는 데에 집중했다. 하지만 찾을 수 없었다. 온갖 수정을 하던 차에 Json 데이터 값에서 key artist 의 value 를 추출하여 JsonNode 객체에 할당할 때 문제가 있을 수도 있으니 추출하는 코드를 변경하고 있었다. 그리고 기존 at 메서드 사용에서 get 메서드를 사용하여 추출하도록 변경 후 문제점을 발견할 수 있었다.

 

아래 코드는 변경 전과 후의 코드이고, count 는 tracks 하위 여러 노드들 중에 모든 노드를 반복문 내에서 돌기 때문에 반복문에서 사용하는 변수이다.

 

코드 변경 전:

JsonNode artistNode = root.at("/tracks/" + count + "/artists");

코드 변경 후:

JsonNode artistNode = root.get("tracks").get(count).get("artists");

📌 문제 특정

예외 처리가 되는 메서드를 사용하는 것은 도움이 된다. 그 이유는 JsonNode 클래스 객체를 사용하면서 알게 되었다. JsonNode 의 get 메서드는 예외를 잡아주고 at 메서드는 잡아주지 않는다. at 을 사용할 때에는 Json 데이터를 읽어들일 때null 값이 리턴되고 있는지 알지 못했다. 하지만 get 메서드를 사용하자 NullPointerException이 발생했고, Json 데이터로 가져오는 아티스트명이 빈 값이라는 사실을 발견했으며 결론적으로 Json 데이터를 가져올 때 artist 라는 key 는 value 로 리스트를 포함하고 있어서 offset (또는 index) 을 지정하지 않으면 name 값에 도달할 수 없다는 것을 알 수 있었다.

 

at() 이 아닌 get() 을 사용하니 에러를 발견할 수 있었다.

 

한 트랙에 가수가 여러명이라니! 예상하지 못한 일이었지만 우선 첫 번째 가수명만 가져오도록 해 보았다. 역시나 어떤 값을 찾아야하는지 알지 못해서 빈 값을 가져오던 쿼리가 첫 번째 가수명을 가져올 수 있게 되면서 아티스트명을 모두 (물론 여러 가수 중 첫번째 가수 이름만이었지만) 테이블에 저장했는데 성공이었다!

 

😂 문제 해결

 

이후 로직을 설계 해 아티스트의 수를 세고 수에 맞게 쉼표로 구분된 문자열로 아티스트명들을 하나의 문자열로 합쳐서 DB 에 저장하도록 했다.

트랙과 앨범에 더해 아티스트 정보도 가져왔다.

💡 + 연관 관계 설정

 

이제 아티스트와 앨범을 트랙에 이어주는 일이 남아있었다.

문제는 아티스트, 트랙, 앨범을 저장할 때마다 모두 각각 API 를 호출하므로 계속해서 순서가 뒤바뀐다는 데에 있었다. 다른 말로, Artist 테이블 첫 줄에 저장된 아티스트가 Song 테이블 첫 줄에 저장된 음원의 아티스트가 아니였다.

어떻게 해야 될지 고민을 한 결과, 음원, 아티스트, 앨범 모두 한 번의 API 호출으로부터 시작되게 한 후, 결과 응답으로 받은 JSON 형태 데이터에서 추출되는 부분만 다르게 하여 저장하면 되겠다는 생각이 들었다. 그리고 실제로 분리했다.

 

아래는 이전의 코드이다.

  public List<String> fetchTracks(String accessToken, Fetch<?> fetchedType) throws IOException {
    RestTemplate restTemplate = new RestTemplate();

    String trackUri = TrackEnum.BASE_URL.getValue() + "/tracks?ids=";

    HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(headerOf(accessToken));

    String title = "";

    List<List<String>> spotifyIds = findSpotifyIds(accessToken);
    List<String> titles = new ArrayList<>();

    for (List<String> spotifyId : spotifyIds) {
      StringBuilder ids = new StringBuilder();
      for(int i = 0; i < spotifyId.size(); i++) {
        ids.append(spotifyId.get(i));
        if (i != 49) ids.append(",");
      }
      log.info("ids:{}", ids);

      ResponseEntity<String> response = restTemplate
              .exchange(trackUri + ids, HttpMethod.GET, httpEntity, String.class);

      log.info("info:{}",response.getBody());

      /* track uri 이용해서 50개씩 모아져 있는 아이디들로 찾아지는 음원들에 대한 JSON 형태 응답을 모두 읽어들이고,
       * 읽어들인 응답에서 필요한 부분만 추출한다. 추출은 매개 변수로 받은 인터페이스의 구현체에 따라 달라진다. */
      JsonNode infoRoot = objectMapper.readTree(response.getBody());

      for (int j = 0; j < 50; j++) {
        title = fetchedType.extractTitle(infoRoot, j);
        titles.add(title);
      }

    }

    return titles;
  }

분리 후 코드는 다음과 같이 수정됐다.

  public List<JsonNode> callTracksApi(String accessToken) throws IOException {
    List<JsonNode> collectedJsonNodes = new ArrayList<>();

    RestTemplate restTemplate = new RestTemplate();

    String trackUri = TrackEnum.BASE_URL.getValue() + "/tracks?ids=";

    HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(headerOf(accessToken));

    List<List<String>> spotifyIds = findSpotifyIds(accessToken);

    for (List<String> spotifyId : spotifyIds) {
      StringBuilder ids = new StringBuilder();
      for (int i = 0; i < spotifyId.size(); i++) {
        ids.append(spotifyId.get(i));
        if (i != 49) {
          ids.append(",");
        }
      }
      log.info("ids:{}", ids);

      ResponseEntity<String> response = restTemplate
              .exchange(trackUri + ids, HttpMethod.GET, httpEntity, String.class);

      log.info("info:{}", response.getBody());

      /* track uri 이용해서 50개씩 모아져 있는 아이디들로 찾아지는 음원들에 대한 JSON 형태 응답을 모두 읽어들이고,
       * 읽어들인 응답에서 필요한 부분만 추출한다. 추출은 매개 변수로 받은 인터페이스의 구현체에 따라 달라진다. */
      JsonNode infoRoot = objectMapper.readTree(response.getBody());

      collectedJsonNodes.add(infoRoot);

    }
    return collectedJsonNodes;
  }

  public List<String> fetchTracks(List<JsonNode> infoRoots, Fetch<?> fetchedType) throws IOException {
    List<String> titles = new ArrayList<>();

    for (JsonNode infoRoot : infoRoots) {
      for (int j = 0; j < 50; j++) {
        titles.add(fetchedType.extractTitle(infoRoot, j));
      }
    }

    return titles;
  }

이렇게 수정하면 음원, 앨범, 아티스트를 저장할 때마다 Spotify Web API 를 호출하는 것이 아니라 한 번 호출해서 JsonNode 객체를 얻은 후에 필요한 정보를 추출하므로 모든 데이터들의 나열 순서가 같아질 것이다.

 

처음에 변경한 후에 순서가 일치하지 않아서, 로그를 찍어 보고 문제를 해결하려 하다가 우선 그대로 모두 종료한 후 인텔리제이를 종료했다. 다음 날 다시 인텔리제이를 켜서 다시 run 했을 떄, 순서가 일치하는 것을 볼 수 있었다. 코드가 적용되는 데에 시간이 소요되기도 하고 때로는 인텔리제이를 종료 후 재시작해야만 적용이 잘 된다는 사실을 알게 되었다.

 

아래는 각각 song, album, artist 테이블들이다.

song 테이블
album 테이블
artist 테이블

위에 보다시피, 연관관계 설정이 필요한 상황이다. 지금 설정으로는 먼저 각 테이블의 데이터를 모두 저장한 후에 연관관계를 맺어야 한다. 이후 리팩토링할 것을 고려하여 우선 다음 방법을 사용했다:

1. findAll() 을 이용해 song 테이블에 저장된 모든 데이터를 찾아 리스트 변수 A 에 저장한다.

2. setter 를 사용하여 A 의 각 엔티티가 어떤 artist 와 album 와 연관관계가 있는지 지정해준다. (setter 로 지정만 해도 테이블에 연관관계가 반영된다.)

 

하지만 이 방법으로는 연관관계 설정이 불가능했다. 이유인즉슨, DB 에 저장하기 전 API 호출을 마치고 난 후에 얻은 엔티티 객체들에 대해 setter 을 사용해봤자 transaction 이 commit 되기 이전이므로 POJO 만 변하지 영속성 객체는 변하지 않기 때문이었다.

 

해결방법은 간단했다. 애초에 Song 엔티티 객체를 save 할 때 Album 과 Artist 객체를 참조하는 Song 엔티티 객체를 생성하여 저장하는 방법을 사용해야만 했다.

 

그래서 대대적인 수정에 들어갔다:

 

1. fetchTracks() 자체에서 문자열 리스트가 아닌 엔티티 객체 리스트를 반환하도록 변경하기

우선, 이전에는 fetchTracks() 에서 객체의 이름 값들(음원과 앨범은 title, 아티스트는 name 을 사용)이 포함된 문자열 리스트를 반환하면 이를 받아서 DBSaveOption 인터페이스 구현체에서 엔티티 객체를 생성하여 저장하고 있었다. 이 방식은 DBSaveOption 의 구현체 클래스의 제너릭 타입에 한정된 엔티티만 빌드할 수 있고, 다른 엔티티에 대한 참조를 함께 포함해서 엔티티를 생성할 수가 없다는 한계가 있었다. 다른 방법을 생각해보던 중, fetchTracks() 자체에서 엔티티를 생성한 후 반환하고 이 엔티티를 받아서 DBSaveOption 에서는 생성은 담당하지 않고 저장만 담당하도록 변경하면, fetchTracks() 안에서나 중간과정에서 참조 관계를 쉽게 설정할 수 있겠다는 생각이 들었다. 그래서 다음과 같이 코드를 변경했다:

  public <T> List<T> fetchTracks(List<JsonNode> infoRoots, Fetch<T> fetchedType) {
    List<String> titles = new ArrayList<>();
    for (JsonNode infoRoot : infoRoots) {
      for (int j = 0; j < 50; j++) {
        titles.add(fetchedType.extractTitle(infoRoot, j));
      }
    }
    return fetchedType.parseIntoEntities(titles);
  }

2. createMusicDatabase() 에서 엔티티 리스트를 받고, 저장만 담당하게 변경하기

이제 fetchTracks() 는 titles, 즉 제목 문자열 값들이 들어있는 리스트를 가지고 Fetch<T> 인터페이스의 구현체에서 정의한 동작에 따라 다른 형태로 엔티티를 생성하여 반환하는 역할을 한다(parseInto. createMusicDatabase()는 fetchTracks() 에서 받은 엔티티 리스트를 매개 변수로 사용하여 저장만 담당하고, 이렇게 함으로서 Song 엔티티 생성과 저장 의 중간 과정에서 Song 엔티티 객체의 참조 관계를 설정할 수 있게 되었다.

  public <T> void createMusicDatabase(List<T> entities, DBSaveOption<T> saveOption) {
    for (T entity : entities) {
      saveOption.saveNewRow(entity);
    }
  }

 

3. 참조 관계가 설정되지 않은 객체에 Setter 를 통해 참조되어야 하는 객체들 매핑하기

Song 엔티티는 Album과 Artist 를 참조하므로, Album 과 Artist 가 먼저 저장된 이후에 참조 관계를 설정해야 했다. 아래 코드는 저장된 Album 과 Artist 객체들의 리스트들과 생성된 Song 엔티티 객체 사이에서 setter 를 사용해 참조 관계 설정을 한다.

  private List<Song> parseForSong(List<Artist> artists, List<Album> albums, List<Song> songs) {
    for (int i = 0; i < songs.size(); i++) {
      songs.get(i).setArtist(artists.get(i));
      songs.get(i).setAlbum(albums.get(i));
    }
    return songs;
  }

결과적으로 이전과는 다르게 연관관계 매핑이 성공적으로 되는 것을 볼 수 있었다! 이후 같은 방식으로 Album 엔티티와 Artist 엔티티의 연관관계도 설정해주었다:

  private List<Album> parseForAlbum(List<Artist> artists, List<Album> albums) {
    for (int i = 0; i < albums.size(); i++) {
      albums.get(i).setArtist(artists.get(i));
    }
    return albums;
  }

아래 코드는 실제 위 두 메서드를 사용하여 세 종류의 엔티티 객체들을 DB 에 저장하는 코드이다:

  public void createAllThreeTypesDB(String token) throws IOException {
    /* TODO: 세 자원을 모두 저장을 할 때 여기도 템플릿 콜백 패턴 적용 가능 */
    List<JsonNode> jsonData = callTracksApi(token);

    List<Artist> artistEntities = fetchTracks(jsonData, new ArtistFetch());
    createMusicDatabase(artistEntities, new ArtistSave(artistRepository));

    List<Album> albumEntities = fetchTracks(jsonData, new AlbumFetch());
    List<Album> albumsAndArtists = parseForAlbum(artistEntities, albumEntities);
    createMusicDatabase(albumsAndArtists, new AlbumSave(albumRepository));

    List<Song> songEntities = fetchTracks(jsonData, new TrackFetch());
    List<Song> songsAlbumsAndArtists = parseForSong(artistEntities, albumEntities, songEntities);
    createMusicDatabase(songsAlbumsAndArtists, new TrackSave(songRepository));
  }

Artist 객체만 바로 저장하고, Album 과 Song 객체들은 Parsing 을 거친 후에 저장하는 것을 볼 수 있다.

 

📢 결론

에러를 잡아주는 메서드를 사용하는 것이 우선순위가 되어야 한다. 예전까지는 에러를 잡고 안 잡고의 차이가 무엇이지? try-catch 블럭을 사용하면 그만이지 않은가? 하는 의문이 있었는데, 이번 경험으로 왜 에러 처리를 해주는 메서드를 사용해야 하는지 배울 수 있었다. 그리고 연관관계 설정을 하면서 긴 시간의 삽질(?)을 거치고 DB 와 더 친해지는 시간이었다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함