From c26bd692d701d64ba6fc0bf421784bced5f273d5 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 23 Dec 2025 15:44:29 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20artist-genre=20=EB=8B=A4=EB=8C=80?= =?UTF-8?q?=EB=8B=A4=20=EA=B4=80=EA=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artists/controller/ArtistsController.java | 2 +- .../artists/dto/request/CreateRequest.java | 6 + .../dto/response/ArtistListResponse.java | 19 +- .../domain/artists/entity/Artist.java | 34 ++-- .../domain/artists/entity/ArtistGenre.java | 29 +++ .../domain/artists/entity/Genre.java | 9 +- .../artists/repository/ArtistRepository.java | 12 +- .../artists/repository/GenreRepository.java | 2 + .../domain/artists/service/ArtistService.java | 14 +- .../artists/service/SpotifyService.java | 166 ++++++++++++++---- 10 files changed, 233 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/ArtistGenre.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java index acfc9213..1f1bafae 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java @@ -62,7 +62,7 @@ public RsData fetchMusicBrainzIds( public RsData create( @Valid @RequestBody CreateRequest reqBody ) { - artistService.createArtist(reqBody.artistName(), reqBody.artistGroup(), reqBody.artistType(), reqBody.genreName()); + artistService.createArtist(reqBody.spotifyID(), reqBody.artistName(), reqBody.artistGroup(), reqBody.artistType(), reqBody.genreName()); return RsData.success("아티스트 생성이 완료되었습니다.", null); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java index dd0ea6c1..e19b6ca2 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java @@ -1,12 +1,18 @@ package com.back.web7_9_codecrete_be.domain.artists.dto.request; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType; +import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record CreateRequest( + @NotBlank + @Size(max = 30, message = "Spotify ID 는 필수로 입력해야합니다.") + @Schema(description = "Spotify ID 입니다.") + String spotifyID, + @NotBlank(message = "아티스트 이름은 필수로 입력해야합니다.") @Size(max = 200, message = "아티스트 이름은 200자를 넘길 수 없습니다.") @Schema(description = "아티스트 이름입니다.") diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java index 2ab700d8..d10ecffa 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java @@ -3,6 +3,8 @@ import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + public record ArtistListResponse( @Schema(description = "아티스트 아이디입니다.") Long id, @@ -13,8 +15,8 @@ public record ArtistListResponse( @Schema(description = "아티스트 소속 그룹입니다. 아티스트 이름이 그룹인 경우, null 로 처리됩니다.") String artistGroup, - @Schema(description = "장르 이름입니다.") - String genreName, + @Schema(description = "장르입니다.") + List genres, @Schema(description = "받은 좋아요 수(찜한 사람 수) 입니다.") int likeCount, @@ -25,12 +27,20 @@ public record ArtistListResponse( @Schema(description = "로그인한 유저의 좋아요 여부입니다. 비회원인 경우 false입니다.") Boolean isLiked ) { + public static List getGenre(Artist artist) { + return artist.getArtistGenres().stream() + .map(ag -> ag.getGenre().getGenreName()) + .distinct() + .toList(); + } + public static ArtistListResponse from(Artist artist) { + List genres = getGenre(artist); return new ArtistListResponse( artist.getId(), artist.getArtistName(), artist.getArtistGroup(), - artist.getGenre().getGenreName(), + genres, artist.getLikeCount(), artist.getImageUrl(), false // 기본값은 false @@ -38,11 +48,12 @@ public static ArtistListResponse from(Artist artist) { } public static ArtistListResponse from(Artist artist, boolean isLiked) { + List genres = getGenre(artist); return new ArtistListResponse( artist.getId(), artist.getArtistName(), artist.getArtistGroup(), - artist.getGenre().getGenreName(), + genres, artist.getLikeCount(), artist.getImageUrl(), isLiked diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java index 5356e321..6da9a7e9 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java @@ -6,6 +6,9 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.HashSet; +import java.util.Set; + @Entity @Getter @@ -28,9 +31,6 @@ public class Artist { @Column(name = "artist_type") private ArtistType artistType; - @ManyToOne(fetch = FetchType.LAZY) - private Genre genre; - @Column(name = "spotify_artist_id", unique = true) private String spotifyArtistId; @@ -46,19 +46,25 @@ public class Artist { @Column(name = "image_url") private String imageUrl; + @OneToMany(mappedBy = "artist", cascade = CascadeType.ALL, orphanRemoval = true) + private Set artistGenres = new HashSet<>(); + public Artist(String spotifyArtistId, String artistName, String artistGroup, ArtistType artistType, Genre genre) { this.spotifyArtistId = spotifyArtistId; this.artistName = artistName; this.artistGroup = artistGroup; // 옵션 B: seed에서는 null this.artistType = artistType; // 옵션 B: seed에서는 "SINGER" - this.genre = genre; + if (genre != null) { + addGenre(genre); + } } - public Artist(String artistName, String artistGroup, ArtistType artistType, Genre genre) { + // 장르 없이 생성하는 생성자 (시드 데이터용) + public Artist(String spotifyArtistId, String artistName, String artistGroup, ArtistType artistType) { + this.spotifyArtistId = spotifyArtistId; this.artistName = artistName; this.artistGroup = artistGroup; this.artistType = artistType; - this.genre = genre; } public void updateProfile(String nameKo, String artistGroup, ArtistType artistType) { @@ -79,10 +85,6 @@ public void changeType(ArtistType type) { this.artistType = type; } - public void changeGenre(Genre genre) { - this.genre = genre; - } - public void increaseLikeCount() { this.likeCount++; } @@ -96,4 +98,16 @@ public void decreaseLikeCount() { public void setMusicBrainzId(String musicBrainzId) { this.musicBrainzId = musicBrainzId; } + + public void addGenre(Genre genre) { + this.artistGenres.add(new ArtistGenre(this, genre)); + } + + public void replaceGenres(Set genres) { + this.artistGenres.clear(); // orphanRemoval=true라 매핑 row 삭제됨 + for (Genre genre : genres) { + this.artistGenres.add(new ArtistGenre(this, genre)); + } + } + } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/ArtistGenre.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/ArtistGenre.java new file mode 100644 index 00000000..fda51c2c --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/ArtistGenre.java @@ -0,0 +1,29 @@ +package com.back.web7_9_codecrete_be.domain.artists.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ArtistGenre { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "artist_genre_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "artist_id", nullable = false) + private Artist artist; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "genre_id", nullable = false) + private Genre genre; + + public ArtistGenre(Artist artist, Genre genre) { + this.artist = artist; + this.genre = genre; + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java index 5089a420..1ecfad02 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java @@ -17,15 +17,8 @@ public class Genre { @Column(name = "genre_name", nullable = false, length = 30) private String genreName; - @Column(name = "genre_group", length = 30) - private String genreGroup; - @Column(name = "genre_memo") - private String genreMemo; - - public Genre(String genreName, String genreGroup, String genreMemo) { + public Genre(String genreName) { this.genreName = genreName; - this.genreGroup = genreGroup; - this.genreMemo = genreMemo; } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java index 79f021ba..2cd07ae6 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java @@ -12,6 +12,7 @@ @Repository public interface ArtistRepository extends JpaRepository { boolean existsBySpotifyArtistId(String spotifyArtistId); + java.util.Optional findBySpotifyArtistId(String spotifyArtistId); @Query("SELECT a FROM Artist a WHERE a.nameKo IS NULL ORDER BY a.id ASC") List findByNameKoIsNullOrderByIdAsc(Pageable pageable); @@ -23,7 +24,16 @@ public interface ArtistRepository extends JpaRepository { boolean existsByNameKo(String nameKo); List findTop5ByArtistGroupAndIdNot(String artistGroup, long excludeId); - List findTop5ByGenreIdAndIdNot(Long genreId, long excludeId); + + @Query(""" + SELECT DISTINCT a FROM Artist a + JOIN a.artistGenres ag + WHERE ag.genre.id = :genreId AND a.id != :excludeId + ORDER BY a.likeCount DESC + """) + List findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId, + @org.springframework.data.repository.query.Param("excludeId") long excludeId, + Pageable pageable); List findAllByArtistNameContainingIgnoreCaseOrNameKoContainingIgnoreCase(String artistName1, String artistName2); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java index af356f18..fa896030 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java @@ -4,9 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface GenreRepository extends JpaRepository { Optional findByGenreName(String genreName); + List findByGenreNameIn(List genreNames); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java index 94ffd1a3..cfdca533 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -52,12 +52,12 @@ public int setArtist() { } @Transactional - public Artist createArtist(String artistName, String artistGroup, ArtistType artistType, String genreName) { + public Artist createArtist(String spotifyArtistId, String artistName, String artistGroup, ArtistType artistType, String genreName) { Genre genre = genreService.findByGenreName(genreName); if(artistRepository.existsByArtistName(artistName) || artistRepository.existsByNameKo(artistName)) { throw new BusinessException(ArtistErrorCode.ARTIST_ALREADY_EXISTS); } - Artist artist = new Artist(artistName, artistGroup, artistType, genre); + Artist artist = new Artist(spotifyArtistId, artistName, artistGroup, artistType, genre); artistRepository.save(artist); return artist; } @@ -114,13 +114,19 @@ public ArtistDetailResponse getArtistDetail(Long artistId, User user) { isLiked = artistLikeRepository.existsByArtistAndUser(artist, user); } + // 첫 번째 장르 ID 가져오기 (없으면 null) + Long genreId = artist.getArtistGenres().stream() + .findFirst() + .map(ag -> ag.getGenre().getId()) + .orElse(null); + return spotifyService.getArtistDetail( artist.getSpotifyArtistId(), artist.getArtistGroup(), artist.getArtistType(), likeCount, artist.getId(), - artist.getGenre() != null ? artist.getGenre().getId() : null, + genreId, isLiked ); } @@ -149,7 +155,7 @@ public void updateArtist(Long id, UpdateRequest req) { if (req.genreName() != null && !req.genreName().isBlank()) { Genre genre = genreService.findByGenreName(req.genreName().trim()); - artist.changeGenre(genre); + artist.replaceGenres(Set.of(genre)); changed = true; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java index 993f929b..a4520924 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java @@ -5,6 +5,7 @@ import com.back.web7_9_codecrete_be.domain.artists.dto.response.RelatedArtistResponse; import com.back.web7_9_codecrete_be.domain.artists.dto.response.TopTrackResponse; import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; +import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistGenre; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType; import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; @@ -25,9 +26,8 @@ import se.michaelthelin.spotify.model_objects.specification.Paging; import se.michaelthelin.spotify.model_objects.specification.Track; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; import static java.util.stream.Collectors.toList; @@ -48,8 +48,9 @@ public int seedKoreanArtists300() { final int targetCount = 300; final int limit = 50; - int totalSaved = 0; - + + // 1단계: Spotify에서 아티스트 300명 조회 (spotifyArtistId, 이름, genres[] 포함) + List artistDataList = new ArrayList<>(); List queries = List.of( "k-pop", "korean pop", "BTS", "BLACKPINK", "NewJeans", "LE SSERAFIM", "aespa", "IVE", "NCT", @@ -59,11 +60,11 @@ public int seedKoreanArtists300() { ); for (String q : queries) { - if (totalSaved >= targetCount) break; + if (artistDataList.size() >= targetCount) break; int offset = 0; - while (totalSaved < targetCount) { + while (artistDataList.size() < targetCount) { Paging paging = api.searchArtists(q) .limit(limit) .offset(offset) @@ -74,35 +75,25 @@ public int seedKoreanArtists300() { if (items == null || items.length == 0) break; for (var spotifyArtist : items) { - if (totalSaved >= targetCount) break; + if (artistDataList.size() >= targetCount) break; String spotifyId = spotifyArtist.getId(); String name = spotifyArtist.getName(); + String[] genres = spotifyArtist.getGenres(); if (spotifyId == null || name == null || name.isBlank()) continue; - if (artistRepository.existsBySpotifyArtistId(spotifyId)) continue; if (!isLikelyKoreanMusic(spotifyArtist)) continue; - String mainGenreName = pickMainGenreName(spotifyArtist); - Genre genre = findOrCreateGenreByName(mainGenreName, null); - String artistTypeStr = inferArtistType(spotifyArtist); ArtistType artistType = ArtistType.valueOf(artistTypeStr); - - // Spotify에서 이미지 URL 가져오기 String imageUrl = pickImageUrl(spotifyArtist.getImages()); - Artist artistEntity = new Artist( - spotifyId, - name.trim(), - null, // artistGroup - artistType, - genre - ); - artistEntity.setImageUrl(imageUrl); - - artistRepository.save(artistEntity); - totalSaved++; + // genres 배열을 List로 변환 (null 제거) + List genreList = genres != null + ? Arrays.stream(genres).filter(Objects::nonNull).filter(g -> !g.isBlank()).collect(toList()) + : List.of(); + + artistDataList.add(new ArtistData(spotifyId, name.trim(), artistType, imageUrl, genreList)); } offset += limit; @@ -112,19 +103,133 @@ public int seedKoreanArtists300() { } } - if (totalSaved == 0) { + if (artistDataList.isEmpty()) { throw new BusinessException(ArtistErrorCode.ARTIST_SEED_FAILED); } - return totalSaved; + // 2단계: 각 아티스트를 DB에 upsert (spotifyArtistId 기준으로 있으면 업데이트, 없으면 생성) + Map artistMap = new HashMap<>(); // spotifyId -> Artist 매핑 + for (ArtistData data : artistDataList) { + Optional existingArtistOpt = artistRepository.findBySpotifyArtistId(data.spotifyId); + + Artist artist; + if (existingArtistOpt.isPresent()) { + // 업데이트 + artist = existingArtistOpt.get(); + artist.setArtistName(data.name); + artist.setArtistType(data.artistType); + artist.setImageUrl(data.imageUrl); + // 기존 장르 관계 제거 + artist.getArtistGenres().clear(); + } else { + // 생성 + artist = new Artist(data.spotifyId, data.name, null, data.artistType); + artist.setImageUrl(data.imageUrl); + } + + artistRepository.save(artist); + artistMap.put(data.spotifyId, artist); + } + + // 3단계: 모든 genres[]를 모아서 Set으로 중복 제거 + Set allGenreNames = artistDataList.stream() + .flatMap(data -> data.genres.stream()) + .filter(Objects::nonNull) + .filter(g -> !g.isBlank()) + .collect(Collectors.toSet()); + + if (allGenreNames.isEmpty()) { + log.warn("수집된 장르가 없습니다."); + return artistDataList.size(); + } + + // 4단계: DB에서 genre_name in (...)으로 기존 장르 한 번에 조회 + List existingGenres = genreRepository.findByGenreNameIn(new ArrayList<>(allGenreNames)); + Set existingGenreNames = existingGenres.stream() + .map(Genre::getGenreName) + .collect(Collectors.toSet()); + + // 5단계: 없는 장르만 insert + List newGenreNames = allGenreNames.stream() + .filter(name -> !existingGenreNames.contains(name)) + .collect(toList()); + + if (!newGenreNames.isEmpty()) { + List newGenres = newGenreNames.stream() + .map(Genre::new) + .collect(toList()); + genreRepository.saveAll(newGenres); + existingGenres.addAll(newGenres); + log.info("새로운 장르 {}개 생성: {}", newGenres.size(), newGenreNames); + } + + // Genre Map 생성 (genreName -> Genre) + Map genreMap = existingGenres.stream() + .collect(Collectors.toMap(Genre::getGenreName, g -> g, (g1, g2) -> g1)); + + // 6단계: 각 아티스트별로 genres[]를 돌면서 artist_genre에 매핑 insert + int totalMappings = 0; + for (ArtistData data : artistDataList) { + Artist artist = artistMap.get(data.spotifyId); + if (artist == null) { + log.warn("아티스트를 찾을 수 없음: spotifyId={}", data.spotifyId); + continue; + } + + for (String genreName : data.genres) { + if (genreName == null || genreName.isBlank()) continue; + + Genre genre = genreMap.get(genreName); + if (genre == null) { + log.warn("장르를 찾을 수 없음: genreName={}, spotifyId={}", genreName, data.spotifyId); + continue; + } + + // 중복 방지는 UNIQUE(artist_id, genre_id)로 해결 + // 이미 존재하는지 확인 + boolean alreadyExists = artist.getArtistGenres().stream() + .anyMatch(ag -> ag.getGenre().getId() == genre.getId()); + + if (!alreadyExists) { + ArtistGenre artistGenre = new ArtistGenre(artist, genre); + artist.getArtistGenres().add(artistGenre); + totalMappings++; + } + } + + artistRepository.save(artist); + } + + log.info("아티스트 시드 완료: 아티스트 {}명, 장르 {}개, 매핑 {}개", + artistDataList.size(), genreMap.size(), totalMappings); + + return artistDataList.size(); } catch (BusinessException e) { throw e; } catch (Exception e) { + log.error("아티스트 시드 실패", e); throw new BusinessException(ArtistErrorCode.SPOTIFY_API_ERROR); } } + // 아티스트 데이터 임시 저장용 클래스 + private static class ArtistData { + final String spotifyId; + final String name; + final ArtistType artistType; + final String imageUrl; + final List genres; + + ArtistData(String spotifyId, String name, ArtistType artistType, String imageUrl, List genres) { + this.spotifyId = spotifyId; + this.name = name; + this.artistType = artistType; + this.imageUrl = imageUrl; + this.genres = genres; + } + } + private static final List KOREAN_GENRE_HINTS = List.of( "k-pop", "korean", "trot", "k-hip hop", "k-rap", "k-ballad", "k-r&b", "k-indie", "k-rock", @@ -178,10 +283,6 @@ private String inferArtistType(se.michaelthelin.spotify.model_objects.specificat return "SOLO"; } - private Genre findOrCreateGenreByName(String genreName, String genreGroup) { - return genreRepository.findByGenreName(genreName) - .orElseGet(() -> genreRepository.save(new Genre(genreName, genreGroup, null))); - } @Transactional(readOnly = true) public ArtistDetailResponse getArtistDetail( @@ -304,7 +405,8 @@ private List fallbackRelatedFromDb(String artistGroup, lo .toList(); } if (genreId != null) { - return artistRepository.findTop5ByGenreIdAndIdNot(genreId, artistId).stream() + return artistRepository.findTop5ByGenreIdAndIdNot(genreId, artistId, + org.springframework.data.domain.PageRequest.of(0, 5)).stream() .map(a -> new RelatedArtistResponse( a.getArtistName(), null,