티스토리 뷰
Introduction
Spotify Web API 를 사용하던 도중, Album 과 Track 은 불러오는데에 성공하면서도 Artist 를 불러오지 못하는 문제가 발생하였다.
💥 문제 발생
아무리 코드를 둘러봐도 문제점을 발견하지 못했고, 빈 값으로 가져오는 이유가 명확하게 찾아지지 않았다.
혹시나 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 값에 도달할 수 없다는 것을 알 수 있었다.
한 트랙에 가수가 여러명이라니! 예상하지 못한 일이었지만 우선 첫 번째 가수명만 가져오도록 해 보았다. 역시나 어떤 값을 찾아야하는지 알지 못해서 빈 값을 가져오던 쿼리가 첫 번째 가수명을 가져올 수 있게 되면서 아티스트명을 모두 (물론 여러 가수 중 첫번째 가수 이름만이었지만) 테이블에 저장했는데 성공이었다!
😂 문제 해결
이후 로직을 설계 해 아티스트의 수를 세고 수에 맞게 쉼표로 구분된 문자열로 아티스트명들을 하나의 문자열로 합쳐서 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 테이블들이다.
위에 보다시피, 연관관계 설정이 필요한 상황이다. 지금 설정으로는 먼저 각 테이블의 데이터를 모두 저장한 후에 연관관계를 맺어야 한다. 이후 리팩토링할 것을 고려하여 우선 다음 방법을 사용했다:
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 와 더 친해지는 시간이었다.
'Project' 카테고리의 다른 글
[Refactoring] 캐싱 사용해 쿼리 실행 속도 개선하기 (1) | 2023.03.05 |
---|---|
[Refactoring] 전략 패턴 적용하기 (1) (0) | 2023.02.22 |
[Spring Boot] Could not load or find main class 에러 (0) | 2023.02.22 |
[리팩토링] JUNIT5 테스트 코드 리팩토링하기 - @ParameterizedTest 와 @MethodSource 활용 (0) | 2023.01.05 |
[Git] Merge 를 revert 후 다시 이를 revert 하기 (2) | 2023.01.03 |
- Total
- Today
- Yesterday
- gitlab
- DTO
- json web token
- @RequestBody
- JOIN FETCH
- 코테
- 실시간데이터
- Firebase
- spring
- 역직렬화
- 도커
- N+1
- 깃랩
- Jackson
- JPA
- 지연 로딩
- 기지국 설치
- DeSerialization
- 프로그래머스
- Java Data Types
- docker
- ci/cd
- 가상 서버
- JPQL
- LazyInitializationException
- FCM
- Spring Boot
- 알고리즘
- 인증/인가
- google cloud
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |