[코틀린 스프링] 팔로우 API 설계
[코틀린 스프링] 팔로우 API 설계 — #개발자의도구들 #팔로우기능 #스프링api설계 🚨📝 📖 📒✏️💡🔍 AI스쿨 msa기반 java 백엔...
#개발자의도구들 #팔로우기능 #스프링api설계
🚨📝 📖 📒✏️💡🔍
AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다
\* 현재 Spring x 프로젝트를 진행하고 있습니다. 전체 목차를 보시려면 여기를 눌러주세요.
\* 해당 프로젝트 이전에 공부한 내용을 보시려면 여기를 눌러주세요.
Index
- Purpose
- DB Design
- Logics
- Impl
- Entity
- Controller
- Service
- Repostiory
- Question
- Answer
- Feedback
| ✅ Tips: |
|---|
| 🙅 return |
|---|
| 💡 Tips |
|---|
Purpose
SNS 플랫폼에서 빼놓을 수 없는 핵심기능인 Follow에 대해 정리하고자 합니다.
X뿐 아니라 인스타그램, 페이스북 등 모든 플랫폼에서 Follow 기능은 볼 수 있습니다. Follow 기능과 더불어서 유저의 팔로잉 수, 팔로워 수를 확인할 수 있습니다.
팔로잉, 팔로우를 클릭하면 해당 유저를 팔로우 한 모든 사용자의 정보를 요약해서 보여줍니다. 요약된 정보는 사용자의 고유 username과 id가 표기되며, 정보 옆에 팔로우 버튼을 통해 또 다른 사용자들을 follow할 수 있습니다.
팔로우는 sns플랫폼의 핵심기능이며, 추가로 구현하게될 notification을 구현할 때도 유용하게 사용됩니다.
DB Design
follwers 테이블 생성
id, followingId, followerId
1 343 433
2 433 343
이전글의 좋아요와 비슷하게 DB를 설계하였습니다. follow 정보 자체를 관리하는 primary key를 제공하고, 여러 사용자가 여러명과 서로 팔로우가 가능하기 때문에 테이블을 따로 만들어 관리해두는 것이 좋겠다 판단하였습니다.
| 💡 DML |
|---|
CREATE TABLE followers(
id BIGINT PRIMARY KEY,
following_id BIGINT,
follower_id BIGINT
)
| 💡 INDEX SET |
|---|
팔로워와 팔로잉 정보를 통해 user 정보를 클라이언트에게 내보내야 하니, 각 col에 대하여 index를 설정합니다. 또한 follwingId와 FollwerId를 묶어 유니크 키로 등록합니다.
CREATE INDEX fidx_following_id
ON followers(following_id);
CREATE INDEX fidx_follower_id
ON followers(follwer_id);
ALTER TABLE followers
ADD CONSTRAINT uq_follow_cols UNIQUE (following, follower);
Logics
Client/Server
무엇이든 만들기전에 로직을 정리하는 편이 좋습니다. 저는 클라이언트와 서버의 각각이 어떤 동작을 수행하는지를 우선적으로 나누어 보았습니다.
🤵 클라이언트
- follow 버튼을 클릭하여 follow api를 호출한다.
-> Post
- 팔로워, 팔로잉 보기 버튼을 클릭하여 follow api를 호출한다.
-> GET
👩🍳 서버
- client가 follow를 누르면 Post 요청이 들어온다
- 이를 서버는 적절히 처리하여 데이터를 저장해야한다.
- client가 팔로워, 팔로잉 목록을 요청한다(GET).
- 이때 서버는 적절히 처리하여 UserSummary(유저 요약 정보)를 클라이언트에게 제공한다.
클라이언트의 요청을 서버가 적절히 처리하는 것이 매우 중요하겠습니다. 그리고 지금은 벡엔드 시간이니 서버가 요청을 어떻게 처리할지만 생각해보죠.
Impl
| 🧑💻 Entiy |
|---|
우선 Table처럼 Entity를 만들어 주었습니다.
package com.example.frontServer.entity
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
@Entity
@Table(
name = "followes",
)
class Follow(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "following_id")
var followingId: Long,
// @CreatedBy
@Column(name = "follower_id")
var followerId: Long? = null
)
저는 논리적인 외래키를 사용하기 때문에 Entity간의 관계를 명시적으로 추가하지 않습니다. 또한 @CreatedBy역시 실제 프로젝트에서는 잘 사용하지 않기 때문에 제거해 줍니다.
| 🧑💻 Controller |
|---|
Client는 API 버튼을 통해 서버와 통신합니다. 이때 주요 기능은 1. 팔로우 수행, 2. 팔로우&팔로잉 목록 가져오기 정도 밖에 없습니다.
package com.example.frontServer.controller
import com.example.frontServer.dto.user.UserSummaryDto
import com.example.frontServer.security.AuthUserDetails
import com.example.frontServer.service.FollowService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
class FollowController(
private val followService: FollowService
) {
@PostMapping("/follow")
fun save(
@RequestParam followingName: String,
@AuthenticationPrincipal user: AuthUserDetails
): ResponseEntity<Void> {
followService.save(followingName, user.getUserId())
return ResponseEntity.ok().build()
}
@GetMapping("/following/users")
fun findFollowingUsers(
@RequestParam username: String
): ResponseEntity<List<UserSummaryDto>> {
val followingSummaries: List<UserSummaryDto> =
followService.findFollowings(username)
return ResponseEntity.ok().body(followingSummaries)
}
@GetMapping("/follower/users")
fun findFollowerUsers(
@RequestParam username: String
): ResponseEntity<List<UserSummaryDto>> {
val followerSummaries: List<UserSummaryDto>? =
followService.findFollowers(username)
if (followerSummaries == null) {
return ResponseEntity.badRequest().body(null)
}
return ResponseEntity.ok().body(followerSummaries)
}
}
각 api 기능에 맞는 url을 매핑하였습니다. user정보는 @AuthenticationPrincipal user: AuthUserDetails에서 가져오면 되기 때문에 매우 편리합니다.
팔로잉, 팔로워 목록 결과는 현재 UserSummaryList로 제공하고 있습니다. 코드는 아래와 같습니다.
| 💡 UserSuammaryDro |
|---|
package com.example.frontServer.dto.user
import com.example.frontServer.entity.User
data class UserSummaryDto(
val username: String,
val userImg: String?,
) {
companion object {
fun of(user: User): UserSummaryDto {
return UserSummaryDto(
username = user.username,
userImg = user.userImg,
)
}
}
}
위에 보시면 아시다 싶이, 유저 프로필 이미지, 유저이름, userId가 있는데. 우선 현재는 프로필 이미지와, 유저이름 정도만 보내도록 하였습니다. (User table 설계를 손봐야 합니다...)
| 🧑💻 Service |
|---|
이 서비스를 프로젝트 초창기에 구현한 코드인데, 잘못 설계된 코드가 있어서 짚고 넘어가려 합니다.
package com.example.frontServer.service
import com.example.frontServer.dto.user.UserSummaryDto
import com.example.frontServer.entity.Follow
import com.example.frontServer.repository.FollowRepository
import com.example.frontServer.repository.UserRepository
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
@Service
class FollowService(
private val followRepository: FollowRepository,
private val userRepository: UserRepository
) {
@Transactional
fun save(followingName: String, followerId: Long): String {
val userId = userRepository.findIdByUsername(followingName)
if (userId != null) {
followRepository.save(
Follow(
followingId = userId,
followerId = followerId
)
)
return "save success"
}
return "Invalid Username"
}
}
| 🙅 return |
|---|
현재 save에 return을 String으로 하고 있는데, 이렇게 return 할 경우 프로그래밍 관점에서 실패와 성공 여부를 따지기가 애매하며, 만약 복잡한 프로그램이라면, 메시지 내용 자체를 외워야 상위 레이어에서 처리가 가능합니다.
ex) 어떤 곳에서는 "save success", 어떤 곳에서는 "success".. 등 일관되지 않게 결과 메세지를 return 할 가능성이 생기는데, 프로그램이 복잡해지고, 참여하는 사람이 많아진다면 혼동이 생길 수 밖에 없다.
또한 message 만 return이 가능하기 때문에 다양한 결과(예를 들어 에러코드 + 메세지 같은 형식)를 return하기가 어렵습니다.
이를 수정하여 올바르게 retrun 하도록 합니다.
@Transactional
fun save(followingName: String, followerId: Long) {
// username(unique)를 통해 id를 찾늗다.
val userId = userRepository.findIdByUsername(followingName)
if (userId != null) {
followRepository.save(
Follow(
followingId = userId,
followerId = followerId
)
)
} else {
throw InvalidIdException("can't find the user")
}
}
| 🚩 팔로우, 팔로잉 목록 |
|---|
@Transactional
fun findFollowers(
username: String
): List<UserSummaryDto>? {
return followRepository.findFollowersByUsername(username)
.map { UserSummaryDto.of(it) }
// exception? unknown username?
}
@Transactional
fun findFollowings(
username: String,
): List<UserSummaryDto> {
return followRepository.findFollowingsByUserId(username)
.map { UserSummaryDto.of(it) }
}
팔로우, 팔로잉 목록은 Service보다는 Repository에서 더 자세히 다루겠습니다.
| 🧑💻 Repository |
|---|
QueryDsl을 사용하여 쿼리를 작성하였습니다.
팔로워 로직 정리
1. usename을 parameter로 받는다.
2. username을 통해서 대상(id)을 찾는다.
3. 2번의 대상(id)를 팔로우 하고 있는 모든 id를 찾는다 = ids
4. 3번의 찾은 ids에 대한 user정보를 찾는다.
5. 최종적으로 List<User>을 반환한다.
package com.example.frontServer.repository
import com.example.frontServer.entity.QFollow
import com.example.frontServer.entity.QUser
import com.example.frontServer.entity.User
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.stereotype.Repository
@Repository
class FollowQueryDslRepositoryImpl(
private val queryFactory: JPAQueryFactory
): FollowQueryDslRepository {
private val qFollow = QFollow.follow
private val qUser = QUser.user
// following Users
override fun findFollowersByUsername(username: String): List<User> {
return queryFactory
.selectFrom(qUser)
.where(
qUser.id.`in` (
JPAExpressions
.select(qFollow.followerId)
.from(qFollow)
.where(
qFollow.followingId.eq(
JPAExpressions
.select(qUser.id)
.from(qUser)
.where(qUser.username.eq(username))
)
)
)
)
.fetch()
}
override fun findFollowingsByUserId(username: String): List<User> {
return queryFactory
.selectFrom(qUser)
.where(
qUser.id.`in` (
JPAExpressions
.select(qFollow.followingId)
.from(qFollow)
.where(qFollow.followerId.eq(
JPAExpressions
.select(qUser.id)
.from(qUser)
.where(qUser.username.eq(username))
))
)
)
.fetch()
}
}
로직이 조금 복잡하다 보니 서브쿼리가 조금 길어졌습니다. 하지만, 해석하는데는 큰 어려움이 없으리라 생각됩니다.
| 🧑💻 Others |
|---|
Question
| 🤔 write Question |
|---|
Feedback
| 🙋♂️ self - 보완하기: Result, Response Dto 따로 나누기 - Error handling |
|---|


