Spring발행일 2024. 12. 4.원본 https://blog.naver.com/jword_/223682761114 ↗

코틀린 서버 프로그래밍: 타임라인 API 설계

코틀린 서버 프로그래밍: 타임라인 API 설계 — #개발자의도구들 #코틀린서버프로그래밍 #코틀린API #타임라인API 🚨📝 📖 📒✏️💡🔍 ...

#Spring#Naver Blog

#개발자의도구들 #코틀린서버프로그래밍 #코틀린API #타임라인API

🚨📝 📖 📒✏️💡🔍

​

AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다

\* 현재 Spring x 프로젝트를 진행하고 있습니다. 전체 목차를 보시려면 여기를 눌러주세요.

\* 해당 프로젝트 이전에 공부한 내용을 보시려면 여기를 눌러주세요.

이미지

Index

  • Purpose
  • Logics
  • DB Design
  • Impl
  • Entity
  • Controller
  • Service
  • Repostiory
  • Question
  • Answer
  • Feedback

​

Purpose

sns 플랫폼의 핵심 기능으로 타임라인이 있습니다. 지금은 개인 선호도에 맞춰서 알고리즘이 동작하여 궂이 follow하지 않더라도 사용자가 좋아할 것 같은 게시물을 제동하지만, 기본적인 개념은 follow한 사용자의 게시글을 가져와서 받아 볼 수 있는게 핵심입니다.

Logics

본격적으로 DB설계에 들어가기 전에 고려해야할 Logic을 먼저 생각해봅시다.

javascript 코드 예제
                                    🤵 클라이언트

상황1. 나는 팔로우 10명을 가진 A이다.

- 나는 오늘 "밥 뭐먹을지 추천해주세요"라는 글을 작성하였다.
- 이 게시글은 나를 팔로우한 10명에게 도잘해야한다.

상황2. 나는 A라는 유저를 팔로우하고 있는 B이다.
- 나는 오늘 A가 "밥 뭐먹을지 추천해주세요"라는 글을 보고, 내 주변의 맛집을 생각하게 되었다.

가장 기본적이면서도 핵심적인 timeline 기능은 위와 같이 정리해 볼 수 있겠습니다. 클라이언트의 동작은 매우 단순하지만, 클라이언트의 요구사항을 만족하기 위해 여러 로직이 필요합니다.

javascript 코드 예제
                                    👩‍🍳 서버
상황 1로부터...
- A로 부터 게시글 "밥 뭐먹을지 추천해주세요"를 저장해달라는 요청을 받았다.
- 게시글을 저장후, A의 팔로워들에게 전달하기 위해 팔로워 별 게시글 id별
   timeline table에 게시글 번호를 저장한다.

상황 2로부터...
- B로부터 자신이 팔로웆 중인 모든 유저의(A포함) 게시글을 달라는 요청을 받았다.
- timeline table을 참고하여, B에게 최신 timeline부터 10개씩 잘라서 전송한다.

\* 추가로 클라이언트는 타임라인을 양방향으로 스크롤링이 가능합니다. 지금 받은 시점보다 더 이전 글을 얻을수도, 더 이후글을 얻을 수도 있습니다.

DB Design

javascript 코드 예제
                                    timelines 테이블 생성

id  receiver_id  board_id  created_at
1      10         12         ...
2      11         12         ...

이런식으로 board\_id 12가 발행되면, 작성자의 팔로워들을 모두 찾아 timeline에 저장해두면됩니다.

​

\* 이번글부터는 DDL 및 idenx 선언을 생략하겠습니다.

\* 인덱스는 board\_id별로 만들어 두면 됩니다.

Impl

🧑‍💻 Entiy
javascript 코드 예제
                                    package com.example.frontServer.entity

import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime

@Entity
@Table(name = "timelines")
@EntityListeners(AuditingEntityListener::class)
class Timeline(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    val receiverId: Long,

    val boardId: Long,

    @CreatedDate
    var createdAt: LocalDateTime? = null
)

Entity를 간단히 설계합니다. createAt은 Spring의 @CreatedData 기능을 사용하면 편리합니다.


\* 이번글 부터는 핵심 로직을 제외하고는 최대한 코드를 생략하겠습니다.

🧑‍💻 Service ⭐⭐

\* 사실 timeline 기능을 상당히 많이 수정하였는데, 오답노트는 따로 정리해둘 생각입니다. 이번글에는 성공한 케이스를 정리하였습니다.

✅ Tips: 내부 서비스 계층 분리

원래는 Service를 BoardService와 TimelineService를 같은 레이어 상에 두고 코드를 작성하였었습니다. 하지만 순환 호출문제로 인해 서비스 내부에서도 레이어를 나눠서 외부용 Service와, 내부용 Service로 나눌 수 밖에 없게되었습니다.

이미지

문제는 이렇습니다. User가 글을 발행하면, Service에서, timelineService를 호출하여 timeline을 저장합니다. 이후 follower 클라이언트가 타임라인을 요청하면 timelineService에서는 timeline table에서 얻은 ids 정보를 통해 boardService에게 글을 요청합니다.

​

이 문제를 해결하기 위해 레이어를 쪼개었고, Cotnroller를 통합하였습니다.

이미지

BoardService의 하위층에 BoardTimelineService를 만들고 여기에서 timeline service를 호출하여 타임라인을 저장하거나, 타임라인 정보를통해 board를 찾을 수 있습니다.

​

\* 아직 MSA로 두 서비스가 분리되지 않았지만, 분리되었다고 가정하고 작성하였습니다.

\* timeline Controller를 없앴지만, 정답은 아닙니다. 만들어도 상관없으며, 멘토님은 만드는걸 선호한다고 하였습니다.

​

🚩 BoardService
javascript 코드 예제
                                    package com.example.frontServer.service.board

import com.example.frontServer.dto.*
import com.example.frontServer.dto.board.BoardAllResult
import com.example.frontServer.dto.board.BoardCommentResult
import com.example.frontServer.dto.board.BoardResult
import com.example.frontServer.dto.board.BoardSaveRequest
import com.example.frontServer.dto.timeline.TimelineBoardResult
import com.example.frontServer.dto.timeline.TimelineRequest
import com.example.frontServer.entity.Board
import com.example.frontServer.repository.BoardRepository
import com.example.frontServer.repository.FollowRepository
import com.example.frontServer.service.FileService
import com.example.frontServer.service.LikeService
import com.example.frontServer.service.NotificationService
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import java.util.*

@Service
class BoardService(
    private val boardRepository: BoardRepository,
    private val notificationService: NotificationService,
    private val fileService: FileService,
    private val followRepository: FollowRepository,
    private val likeService: LikeService,
    private val boardTimelineService: BoardTimelineService
) {
    // (중략)
    @Transactional
    fun save(request: BoardSaveRequest, userId: Long, username: String) {
        // (내부 중략)

        // 유저의 팔로워 목록
        val receivers = followRepository.findFollowersByUsername(username).map {it.id!!}
        saveTimeline(boardId, receivers)
    }

    private fun saveTimeline(boardId: Long, receivers: List<Long>) {
        boardTimelineService.saveTimeline(boardId, receivers)
    }
}

게시글이 발행되면, followerRep를 통해 직접 팔로워들을 찾고 timeline 저장을 지시합니다. 팔로워는 이전글에 설계를 완성한 상태입니다.

🚩 BoardTimelineService
javascript 코드 예제
                                    package com.example.frontServer.service.board

import com.example.frontServer.dto.timeline.TimelineBoardResult
import com.example.frontServer.dto.timeline.TimelineRequest
import com.example.frontServer.repository.BoardRepository
import com.example.frontServer.service.TimelineService
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service

@Service
class BoardTimelineService( // board-timeline rep or api connection
    private val timelineService: TimelineService,
    private val boardRepository: BoardRepository,
) {
    @Transactional
    fun findTimelineNext(timelineRequest: TimelineRequest): List<TimelineBoardResult> {
        val ids = findTimelineNextBoardIds(timelineRequest)
            return boardRepository.findAllWithCommentCountByIds(ids)
            .map { TimelineBoardResult.of(it) }
    }

    @Transactional
    fun findTimelineBefore(timelineRequest: TimelineRequest): List<TimelineBoardResult> {
        val ids = findTimelineBeforeBoardIds(timelineRequest)
        return boardRepository.findAllWithCommentCountByIds(ids)
            .map { TimelineBoardResult.of(it) }
    }

    private fun findTimelineNextBoardIds(timelineRequest: TimelineRequest): List<Long> {
        return timelineService.findAllNextBoardIds(timelineRequest)
    }

    private fun findTimelineBeforeBoardIds(timelineRequest: TimelineRequest): List<Long> {
        return timelineService.findAllBeforeBoardIds(timelineRequest)
    }

    fun saveTimeline(boardId: Long, followers: List<Long>) {
        timelineService.save(boardId, followers)
    }
}
🚩 TimelineService
javascript 코드 예제
                                    package com.example.frontServer.service

import com.example.frontServer.dto.timeline.TimelineRequest
import com.example.frontServer.entity.Timeline
import com.example.frontServer.repository.timeline.TimelineCursorRepository
import com.example.frontServer.repository.timeline.TimelineRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class TimelineService(
    private val timelineRepository: TimelineRepository,
    private val timelinesCursorRepository: TimelineCursorRepository,
) {
    fun findAllNextBoardIds(timelineRequest: TimelineRequest): List<Long> {
        return timelineRepository.findAllNextBoardIds(timelineRequest)
            .map { it.boardId }
    }

    fun findAllBeforeBoardIds(timelineRequest: TimelineRequest): List<Long> {
        return timelineRepository.findAllBeforeBoardIds(timelineRequest)
            .map { it.boardId }
    }

    @Transactional
    fun save(boardId: Long, followersId: List<Long>) {
        followersId.map {
            timelineRepository.save(
                Timeline(
                    boardId = boardId,
                    receiverId = it
                )
            )
        }
    }

}

timelineService는 timeline을 직접적으로 저장(Repsitory와 직접 연결)하고, 양방향 스크롤을 구현하기 위해 Prev와 Next를 나누어서 구현하였습니다


🧑‍💻 Repository

Repostiory에서 고려한 사항은 아래와 같습니다.

javascript 코드 예제
                                    * 양방향 스크롤 대응

1. 양방향 스크롤을 구현하려면, 이전과 이후에 대한 기준이 필요합니다.
2. timeline은 현재 구조상 id값이 낮을수록 오래된 timeline 높을수록 최신 timeline입니다.
3. client가 가지고 있는 board_id의 최소, 최대 값을 기준으로 이전 타임라인과, 이후 타임라인을
    가져올 수 있습니다.
javascript 코드 예제
                                    package com.example.frontServer.repository.timeline

import com.example.frontServer.dto.timeline.TimelineRequest
import com.example.frontServer.entity.QTimeline
import com.example.frontServer.entity.Timeline
import com.querydsl.jpa.impl.JPAQueryFactory

class TimelineQueryDslRepositoryImpl(
    private val jpaQueryFactory: JPAQueryFactory
): TimelineQueryDslRepository {
    private val timeline = QTimeline.timeline

    override fun findAllNextBoardIds(timelineRequest: TimelineRequest): List<Timeline> {
        return jpaQueryFactory
            .selectFrom(timeline)
            .where(timeline.receiverId.eq(timelineRequest.receiverId)
                .and(timeline.id.gt(timelineRequest.endBoardId))
            )
            .orderBy(timeline.createdAt.desc())
            .limit(15)
            .fetch()
    }

    override fun findAllBeforeBoardIds(timelineRequest: TimelineRequest): List<Timeline> {
        return jpaQueryFactory
            .selectFrom(timeline)
            .where(timeline.receiverId.eq(timelineRequest.receiverId)
                .and(timeline.id.lt(timelineRequest.startBoardId))
            )
            .orderBy(timeline.createdAt.desc())
            .limit(15)
            .fetch()
    }
}

id값을 기준으로 타임라인에 해당하는 board id값을 가져오고, 내림차순으로 정렬합니다. (id 값이 높을수록 최신정보) - 이렇게 해야 최신 timeline부터 데이터를 가져올 수 있습니다.

🧑‍💻 Others

사용된 dto

javascript 코드 예제
                                    package com.example.frontServer.dto.timeline

data class TimelineRequest(
    val receiverId: Long,

    val startBoardId: Long,

    val endBoardId: Long,
)

​

Question

🤔 write Question

Feedback

🙋‍♂️ mentor - timeline init()을 만드는 것이 일반적입니다. - 가장 최신의 timeline부터 가져옵니다.

​