코틀린 서버 프로그래밍: 타임라인 API 설계
코틀린 서버 프로그래밍: 타임라인 API 설계 — #개발자의도구들 #코틀린서버프로그래밍 #코틀린API #타임라인API 🚨📝 📖 📒✏️💡🔍 ...
#개발자의도구들 #코틀린서버프로그래밍 #코틀린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을 먼저 생각해봅시다.
🤵 클라이언트
상황1. 나는 팔로우 10명을 가진 A이다.
- 나는 오늘 "밥 뭐먹을지 추천해주세요"라는 글을 작성하였다.
- 이 게시글은 나를 팔로우한 10명에게 도잘해야한다.
상황2. 나는 A라는 유저를 팔로우하고 있는 B이다.
- 나는 오늘 A가 "밥 뭐먹을지 추천해주세요"라는 글을 보고, 내 주변의 맛집을 생각하게 되었다.
가장 기본적이면서도 핵심적인 timeline 기능은 위와 같이 정리해 볼 수 있겠습니다. 클라이언트의 동작은 매우 단순하지만, 클라이언트의 요구사항을 만족하기 위해 여러 로직이 필요합니다.
👩🍳 서버
상황 1로부터...
- A로 부터 게시글 "밥 뭐먹을지 추천해주세요"를 저장해달라는 요청을 받았다.
- 게시글을 저장후, A의 팔로워들에게 전달하기 위해 팔로워 별 게시글 id별
timeline table에 게시글 번호를 저장한다.
상황 2로부터...
- B로부터 자신이 팔로웆 중인 모든 유저의(A포함) 게시글을 달라는 요청을 받았다.
- timeline table을 참고하여, B에게 최신 timeline부터 10개씩 잘라서 전송한다.
\* 추가로 클라이언트는 타임라인을 양방향으로 스크롤링이 가능합니다. 지금 받은 시점보다 더 이전 글을 얻을수도, 더 이후글을 얻을 수도 있습니다.
DB Design
timelines 테이블 생성
id receiver_id board_id created_at
1 10 12 ...
2 11 12 ...
이런식으로 board\_id 12가 발행되면, 작성자의 팔로워들을 모두 찾아 timeline에 저장해두면됩니다.
\* 이번글부터는 DDL 및 idenx 선언을 생략하겠습니다.
\* 인덱스는 board\_id별로 만들어 두면 됩니다.
Impl
| 🧑💻 Entiy |
|---|
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 |
|---|
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 |
|---|
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 |
|---|
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에서 고려한 사항은 아래와 같습니다.
* 양방향 스크롤 대응
1. 양방향 스크롤을 구현하려면, 이전과 이후에 대한 기준이 필요합니다.
2. timeline은 현재 구조상 id값이 낮을수록 오래된 timeline 높을수록 최신 timeline입니다.
3. client가 가지고 있는 board_id의 최소, 최대 값을 기준으로 이전 타임라인과, 이후 타임라인을
가져올 수 있습니다.
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
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부터 가져옵니다. |
|---|


