Music 서비스 구현하기 with Webflux & GraphQL

semin·2024년 3월 21일
post-thumbnail

본격적인 서비스 구현은 모두 kotlin 으로 코드를 작성하였습니다.

모듈 구성

module 계층


모듈 계층에는 각 인프라에 필요한 설정, 의존성, 기본 객체 등을 모아서 구성했다. '공통 모듈'이 되지 않도록 최대한 실용성 있고 작은 단위로 모듈을 분리했다.

domain 계층


도메인 클래스, Repository, Service 및 DTO로 구성했다. DB 인프라 연결에 필요한 설정은 각각의 모듈의 application.yml 파일에 작성하여 application 계층에서는 DB관련 설정을 작성하지 않고, 계층에 맞는 관심사에만 집중할 수 있도록 하였다.

application 계층


독립적으로 실행되는 모듈을 구성한 계층이다. 하위 계층에서는 전이 의존성을 허용하도록 gradle에 api 키워드를 사용하였고, application 계층에서는 implementation 키워드를 사용하였다.

구현

BaseDocument 클래스 구현

BaseDocument

abstract class BaseDocument(
    id: String = ObjectId().toHexString()
) {
    @Id
    @Field(name = "_id", targetType = FieldType.OBJECT_ID)
    var id = id
        private set

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is BaseDocument) return false

        if (id != other.id) return false

        return true
    }

    override fun hashCode(): Int {
        return id.hashCode()
    }
}

id 프로퍼티

'id' 프로퍼티의 경우에만 한 번 생성된 후에는 확실히 변경될 수 없기 때문에 val 키워드를 사용해주었다.

equals & hashCode

ObjectID가 동일하면 같은 인스턴스로 식별할 수 있도록 하였다. ObjectID는 같은 머신에서 milliSecond 단위로 대량 생성하는 것이 아니면 서로 다른 컬렉션이라고 하더라도 중복될 가능성이 없다. 따라서 다른 타입의 인스턴스라면 같은 ObjectID를 가질 가능성이 거의 없다고 판단하여, BaseDocument에 정의하였다.

BaseTimeDocument

abstract class BaseTimeDocument: BaseDocument {
    constructor() : super()
    constructor(id: String) : super(id)

    @CreatedDate
    var createdAt: OffsetDateTime = OffsetDateTime.now()
        private set

    @LastModifiedDate
    var updatedAt: OffsetDateTime = OffsetDateTime.now()
        private set
}

id를 제외한 프로퍼티를 var, private set으로 정의한 이유
var과 priavte set을 함께 사용할바에는 val을 사용하는게 나을 수 있다고 생각할 수 있다. 하지만 Document는 비즈니스 로직에 의해 데이터가 변경될 수 있는 객체이면서, DB에 직접적으로 영향을 줄 수 있으므로 변경은 가능하나 의도된 변경만 가능해야 한다고 생각했다. 그래서 기본적으로 Document 클래스의 모든 필드를 var, private set으로 정의했다.

데이터 클래스도 data class : 데이터 클래스는 immutable 클래스 성격이 강하다고 생각한다. 하지만 Document 클래스는 그렇지 않다.

오류 케이스

다음과 같은 경우에는 오류가 발생한다.
1. val 타입 변수를 초기화할 수 있는 생성자가 없는 경우
2. @PersistenceCreator 를 선택하지 않고, constroctor가 여러개인 경우
3. 컬렉션에 존재하지 않는 필드에 대한 생성자 매개변수가 있는 경우

Java + lombok을 사용할 때에는 @AllArgsConstructor@Builder 를 사용했었다. 코틀린은 lombok 사용이 어려워서 모든 프로퍼티를 정의하는 생성자와, 비즈니스 로직을 위해 필요한 생성자를 직접 정의해줘야해서 오히려 보일러 플레이트 코드 작성량이 늘어났다.

Music 저장 로직 구현

MongoDB 데이터 모델링하기 편에서도 설명했듯 ArtistAlbum 을 저장할 때에는 레퍼런스를 저장하지 않는다. 비교적 간단한 ArtistAlbum 저장 로직은 건너뛰고 Music 로직만 구현해보려 한다.

그 후 Music 을 저장할 때 관련된 아티스트와 앨범 정보에 레퍼런스를 업데이트한다.

schema.graphqls

type Music {
    id: ID!
    name: String!
    releaseDate: DateTime
    genre: Genre
}

input MusicInput {
    name: String!
    releaseDate: DateTime
    genre: Genre
    albumId: String!
    artistIds: [String!]!
}

type Mutation {    
   	...
    createMusics(musicInputs: [MusicInput!]!): [Music]!
}

Music.kt

@Document
class Music : BaseTimeDocument {
    var name: String
        private set

    var releaseDate: OffsetDateTime
        private set

    var genre: Genre
        private set

    var lyrics: String?
        private set

    var album: EmbeddableAlbum
        private set

    var artists: Set<EmbeddableArtist>
        private set

    @PersistenceCreator
    constructor(id: String, name: String, releaseDate: OffsetDateTime, genre: Genre, lyrics: String? = null, album: EmbeddableAlbum, artists: Set<EmbeddableArtist>) : super(id = id) {
        this.name = name
        this.releaseDate = releaseDate
        this.genre = genre
        this.lyrics = lyrics
        this.album = album
        this.artists = artists
    }
    
    constructor(name: String, releaseDate: OffsetDateTime, genre: Genre, lyrics: String? = null, album: Album, artists: List<Artist>) {
        this.name = name
        this.releaseDate = releaseDate
        this.genre = genre
        this.lyrics = lyrics
        this.album = EmbeddableAlbum(album)
        this.artists = artists.map(::EmbeddableArtist).toSet()
    }
}

음악을 저장할 때, 아티스트의 _idname, 앨범의 _idname 를 저장해야한다. 하지만 데이터 정합성을 위해 클라이언트로부터 name 정보를 요청받지 않고 DB로부터 조회해서 구현하고 싶었다.

여러개의 음악을 한 번에 저장할 때, 같은 앨범이나 아티스트 정보를 여러번 쿼리하고 싶지 않았다. 그래서 앨범과 아티스트 레퍼런스를 다음과 같이 추출하고 id를 key로 가지는 Map을 생성했다.

val albumMap = createAlbumMap(musicInputs)
val artistMap = createArtistMap(musicInputs)

...

private fun createAlbumMap(musicInputs: Flux<MusicInput>): Mono<Map<String, Album>> {
    return albumService.findAllByIds(
        musicInputs.groupBy(MusicInput::albumId).map(GroupedFlux<String, MusicInput>::key)
    ).collectMap(Album::id, Function.identity())
}

private fun createArtistMap(musicInputs: Flux<MusicInput>): Mono<Map<String, Artist>> {
    return artistService.findAllByIds(
        musicInputs
            .flatMap { musicInput -> Flux.fromIterable(musicInput.artistIds) }
            .distinct()
    ).collectMap(Artist::id, Function.identity())
}

그런 다음, MusicInput DTO를 Music Document 클래스로 변환하기 위해 다음과 같이 로직을 작성했다.

우선, flatMap 중첩을 피하기 위해 albumMapartistMapzip() 으로 묶어주었다.

val albumAndArtistMapZip = albumMap.zipWith(artistMap)

mapMusicInput을 활용해 Music으로 변환해주었다.

private fun convertMusic(
    musicInputs: Flux<MusicInput>,
    albumAndArtistMapZip: Mono<Tuple2<Map<String, Album>, Map<String, Artist>>>
): Flux<Music> {
    return albumAndArtistMapZip.flatMapMany { albumArtistZip ->
        musicInputs.map { musicInput ->
            val album = albumArtistZip.t1.getValue(musicInput.albumId)
            val artists = ArrayList<Artist>()
            musicInput.artistIds.map {
                artists.add(albumArtistZip.t2.getValue(it))
            }
            musicInput.toEntity(album, artists)
        }
    }
}

Artist & Album 업데이트

다음은 관련된 ArtistAlbum 을 업데이트 해줘야 한다. 과정은 비슷하므로 Artist를 업데이트 하는 코드만 첨부하였다.
음악을 기준으로 순회하면서 음악에 관여한 아티스트의 정보를 업데이트하려면, 하나의 아티스트를 여러번 수정해야 한다.
이러한 연산의 비효율을 막기 위해 아티스트의 collectMultimap() 을 활용해 _id 를 기준으로 Map<String,Collection<Music> 형태로 만든 뒤에 아티스트별로 정보를 업데이트할 수 있도록 하였다.

fun updateArtists(musics: Flux<Music>, artistMapMono: Mono<Map<String, Artist>>) {
    val artistMusicsMapMono = musics.flatMap { music ->
        Flux.fromIterable(music.artists.map { artist ->
            Pair(artist.id, music)
        })
    }.collectMultimap(
        { pair -> pair.first },
        { pair -> pair.second }
    )

    val artistZip = artistMapMono.zipWith(artistMusicsMapMono)

    val artists = artistZip.flatMapMany { zip ->
        val artistMap = zip.t1
        val artistMusicsMap = zip.t2

        val artistIds = Flux.fromIterable(artistMap.keys)
        artistIds.map { artistId ->
            val artist = artistMap.getValue(artistId)
            val musics = artistMusicsMap.getValue(artistId)

            artist.addMusics(musics.map { it.id })
            artist.addAlbums(musics.flatMap { it.artists.map { it.id } })

            artist
        }
    }

    artistRepository.saveAll(artists).subscribe()
}

시행착오

  1. subscribe() 호출 부재로 인한 로직 미수행
    artistRepository.saveAll(artists) 코드를 호출한 뒤 subscribe() 를 호출하지 않아 업데이트가 실제로 실행되지 않았다. Music의 경우에도 직접 subscribe() 를 호출하지 않았는데 업데이트가 됐기 때문에 왜 업데이트가 발생하는지 이유를 찾지 못하고 굉장히 해맸다.

Reactor 문서에서 확인했듯이, subscribe()를 호출하지 않으면 Publisher는 아무런 동작이 일어나지 않는다.

  1. @DbRef 사용
    Reactive 환경에서는 Spring data mongo 에서 제공하는 @Dbref 어노테이션을 사용할 수 없다.

  2. Flux 남용
    컨트롤러 @Argument 혹은 @Document 프로퍼티에 Flux 객체를 남용하면서 오류를 겪었다.@Argument 로 복수 형태의 인자를 입력받으려면 List 혹은 Mono<List> 형태로 인자를 전달받아야 한다. Flux는 subscribe() 호출 후에 데이터 스트림을 읽기 때문에 프로퍼티로 사용하기에 적절하지 않다.

문제점

  1. 트랜잭션
    MongoDB에서 트랜잭션을 사용하기 위해서는 Replica-set을 적용해야한다. 이 과정이 크게 어려운 것은 아니지만, Replica-set이 필요 없어도 트랜잭션을 위해서는 강제된다는 점이 조금 아쉽다.

  2. 데이터 정합성
    Artist, Album, Music 이 서로의 정보를 가지고 있고, FK와 같은 제약조건도 없기 때문에 비즈니스 로직에 의한 CUD 작업이 늘어나면 개발잘의 실수로 인한 정합성 유지가 안될수도 있을 것 같다는 생각이 들었다.

마무리하며

Webflux, kotlin 의 조합으로 간단한 로직을 작성하는데도 굉장히 많은 시행착오를 겪었던 것 같다. 그 외에도 코루틴과 같이 부수적으로 공부할 것이 많지만, 지금 적용중인 기술보다도 자바, 코틀린, 스프링 기반을 더 탄탄히 하는 것이 중요하므로 너무 깊게 파고들진 않을 생각이다.

profile
블로그 이전 -> https://choicco.tistory.com/

2개의 댓글

comment-user-thumbnail
2025년 8월 8일

Thanks for this spotify project can u tell me is it ok to clone apk app like https://spotipremiums.com.mx/? I am trying to

답글 달기
comment-user-thumbnail
2025년 8월 8일

why no comment

답글 달기