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

[코틀린 스프링] 팔로우 API 설계

[코틀린 스프링] 팔로우 API 설계 — #개발자의도구들 #팔로우기능 #스프링api설계 🚨📝 📖 📒✏️💡🔍 AI스쿨 msa기반 java 백엔...

#Spring#Naver Blog

#개발자의도구들 #팔로우기능 #스프링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

text 코드 예제
                                    follwers 테이블 생성

id, followingId, followerId
1      343       433
2      433       343

이전글의 좋아요와 비슷하게 DB를 설계하였습니다. follow 정보 자체를 관리하는 primary key를 제공하고, 여러 사용자가 여러명과 서로 팔로우가 가능하기 때문에 테이블을 따로 만들어 관리해두는 것이 좋겠다 판단하였습니다.

💡 DML
sql 코드 예제
                                    CREATE TABLE followers(
    id BIGINT PRIMARY KEY,
    following_id BIGINT,
    follower_id BIGINT
)
💡 INDEX SET

팔로워와 팔로잉 정보를 통해 user 정보를 클라이언트에게 내보내야 하니, 각 col에 대하여 index를 설정합니다. 또한 follwingId와 FollwerId를 묶어 유니크 키로 등록합니다.

sql 코드 예제
                                    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

무엇이든 만들기전에 로직을 정리하는 편이 좋습니다. 저는 클라이언트와 서버의 각각이 어떤 동작을 수행하는지를 우선적으로 나누어 보았습니다.

text 코드 예제
                                    🤵 클라이언트

- follow 버튼을 클릭하여 follow api를 호출한다.
  -> Post

- 팔로워, 팔로잉 보기 버튼을 클릭하여 follow api를 호출한다.
  -> GET
text 코드 예제
                                    👩‍🍳 서버

- client가 follow를 누르면 Post 요청이 들어온다
  - 이를 서버는 적절히 처리하여 데이터를 저장해야한다.

- client가 팔로워, 팔로잉 목록을 요청한다(GET).
  - 이때 서버는 적절히 처리하여 UserSummary(유저 요약 정보)를 클라이언트에게 제공한다.

클라이언트의 요청을 서버가 적절히 처리하는 것이 매우 중요하겠습니다. 그리고 지금은 벡엔드 시간이니 서버가 요청을 어떻게 처리할지만 생각해보죠.

Impl

🧑‍💻 Entiy

우선 Table처럼 Entity를 만들어 주었습니다.

kotlin 코드 예제
                                    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. 팔로우&팔로잉 목록 가져오기 정도 밖에 없습니다.

kotlin 코드 예제
                                    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
kotlin 코드 예제
                                    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

이 서비스를 프로젝트 초창기에 구현한 코드인데, 잘못 설계된 코드가 있어서 짚고 넘어가려 합니다.

kotlin 코드 예제
                                    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 하도록 합니다.

kotlin 코드 예제
                                     @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")
        }
    }
🚩 팔로우, 팔로잉 목록
kotlin 코드 예제
                                     @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을 사용하여 쿼리를 작성하였습니다.

text 코드 예제
                                    팔로워 로직 정리
1. usename을 parameter로 받는다.
2. username을 통해서 대상(id)을 찾는다.
3. 2번의 대상(id)를 팔로우 하고 있는 모든 id를 찾는다 = ids
4. 3번의 찾은 ids에 대한 user정보를 찾는다.
5. 최종적으로 List<User>을 반환한다.
kotlin 코드 예제
                                    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

​