From 74ed48ae106926e609ffe65aa665749245938fa2 Mon Sep 17 00:00:00 2001 From: ParkYunHo Date: Thu, 16 Feb 2023 23:27:38 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:feature=20:=20=EC=B9=9C=EA=B5=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 친구관리 기능 작업 --- build.gradle.kts | 4 + .../account/adapter/in/web/AccountHandler.kt | 2 +- .../nexters/pimo/common/constants/CommCode.kt | 5 +- .../follow/adapter/in/web/FollowHandler.kt | 116 +++++++++++++++ .../follow/adapter/in/web/FollowRouter.kt | 29 ++++ .../adapter/in/web/dto/RegisterInput.kt | 11 ++ .../persistence/FollowPersistenceAdapter.kt | 132 ++++++++++++++++++ .../out/persistence/FollowRepository.kt | 24 ++++ .../pimo/follow/application/FollowService.kt | 54 +++++++ .../application/dto/FollowAccountDto.kt | 16 +++ .../follow/application/dto/FollowCntDto.kt | 13 ++ .../application/port/in/DeleteUseCase.kt | 11 ++ .../follow/application/port/in/FindUseCase.kt | 17 +++ .../application/port/in/RegisterUseCase.kt | 11 ++ .../follow/application/port/out/FindPort.kt | 21 +++ .../follow/application/port/out/SavePort.kt | 12 ++ .../com/nexters/pimo/follow/domain/Follow.kt | 27 ++++ 17 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowHandler.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowRouter.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/dto/RegisterInput.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowPersistenceAdapter.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowRepository.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/FollowService.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowAccountDto.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowCntDto.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/port/in/DeleteUseCase.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/port/in/FindUseCase.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/port/in/RegisterUseCase.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/port/out/FindPort.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/application/port/out/SavePort.kt create mode 100644 src/main/kotlin/com/nexters/pimo/follow/domain/Follow.kt diff --git a/build.gradle.kts b/build.gradle.kts index f054e37..ad4e990 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { kotlin("jvm") version "1.7.22" kotlin("plugin.spring") version "1.7.22" kotlin("plugin.jpa") version "1.7.22" + kotlin("kapt") version "1.7.22" } group = "com.nexters" @@ -52,6 +53,9 @@ dependencies { implementation("io.jsonwebtoken:jjwt-api:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + + implementation("com.infobip:infobip-spring-data-r2dbc-querydsl-boot-starter:6.2.0") + kapt("com.infobip:infobip-spring-data-jdbc-annotation-processor-common:6.2.0") } tasks.withType { diff --git a/src/main/kotlin/com/nexters/pimo/account/adapter/in/web/AccountHandler.kt b/src/main/kotlin/com/nexters/pimo/account/adapter/in/web/AccountHandler.kt index f44d98d..712746a 100644 --- a/src/main/kotlin/com/nexters/pimo/account/adapter/in/web/AccountHandler.kt +++ b/src/main/kotlin/com/nexters/pimo/account/adapter/in/web/AccountHandler.kt @@ -107,7 +107,7 @@ class AccountHandler( "profile" -> saveUserUseCase.updateProfileImgUrl( request.userId(), request.queryParam("profile").orElseThrow { BadRequestException("필수 입력값이 누락되었습니다.") }) - else -> Mono.error(BadRequestException("유효한 경로가 아닙니다.")) + else -> throw BadRequestException("유효한 경로가 아닙니다.") } .flatMap { BaseResponse().success(it) } diff --git a/src/main/kotlin/com/nexters/pimo/common/constants/CommCode.kt b/src/main/kotlin/com/nexters/pimo/common/constants/CommCode.kt index 8c5fcca..1a5e95b 100644 --- a/src/main/kotlin/com/nexters/pimo/common/constants/CommCode.kt +++ b/src/main/kotlin/com/nexters/pimo/common/constants/CommCode.kt @@ -8,13 +8,16 @@ import com.nexters.pimo.common.exception.BadRequestException */ class CommCode { companion object { + const val DEFAULT_PAGING_START: String = "0" + const val DEFAULT_PAGING_SIZE: String = "10" + const val DEFAULT_SORT_OPTION: String = "0" + fun findPrefix(type: String): String { for(item in Social.values()) { if(item.code == type) { return item.prefix } } - throw BadRequestException("지원하지 않는 소셜로그인입니다.") } } diff --git a/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowHandler.kt b/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowHandler.kt new file mode 100644 index 0000000..f429d5d --- /dev/null +++ b/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowHandler.kt @@ -0,0 +1,116 @@ +package com.nexters.pimo.follow.adapter.`in`.web + +import com.nexters.pimo.common.constants.CommCode +import com.nexters.pimo.common.dto.BaseResponse +import com.nexters.pimo.common.exception.BadRequestException +import com.nexters.pimo.common.filter.userId +import com.nexters.pimo.follow.adapter.`in`.web.dto.RegisterInput +import com.nexters.pimo.follow.application.dto.FollowAccountDto +import com.nexters.pimo.follow.application.dto.FollowCntDto +import com.nexters.pimo.follow.application.port.`in`.DeleteUseCase +import com.nexters.pimo.follow.application.port.`in`.FindUseCase +import com.nexters.pimo.follow.application.port.`in`.RegisterUseCase +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.server.ServerRequest +import org.springframework.web.reactive.function.server.ServerResponse +import reactor.core.publisher.Mono + +/** + * @author yoonho + * @since 2023.02.16 + */ +@Component +class FollowHandler( + private val registerUseCase: RegisterUseCase, + private val findUseCase: FindUseCase, + private val deleteUseCase: DeleteUseCase +) { + /** + * 친구 등록 + * + * @param userId [String] + * @return boolean [Boolean] + * @author yoonho + * @since 2023.02.18 + */ + fun register(request: ServerRequest): Mono = + request.bodyToMono(RegisterInput::class.java) + .flatMap { registerUseCase.register(it.userId, request.userId()) } + .flatMap { BaseResponse().success(it) } + + /** + * 친구 취소 + * + * @param userId [String] + * @return boolean [Boolean] + * @author yoonho + * @since 2023.02.18 + */ + fun delete(request: ServerRequest): Mono = + request.bodyToMono(RegisterInput::class.java) + .flatMap { deleteUseCase.delete(it.userId, request.userId()) } + .flatMap { BaseResponse().success(it) } + + /** + * 친구 목록수 조회 + * + * @return List [FollowCntDto] + * @author yoonho + * @since 2023.02.18 + */ + fun count(request: ServerRequest): Mono = + findUseCase.count(request.userId()) + .flatMap { BaseResponse().success(it) } + + /** + * 친구목록 계정정보 조회 + *
+     *     1. path Variable
+     *      - follower: 나만 친구추가한 친구목록 조회
+     *      - followee: 상대방만 나를 친구추가한 친구목록 조회
+     *      - followforfollow: 맞팔한 친구목록 조회
+     *     2. sort
+     *      - 0: 최근 친구추가한 순서
+     *      - 1: 닉네임 가나다순
+     *     3. paging
+     *      - start: 시작Index
+     *      - size: 해당 페이지에서 조회할 갯수
+     *
+     * @param sort [String]
+     * @param start [String]
+     * @param size [String]
+     * @return List [FollowAccountDto]
+     * @author yoonho
+     * @since 2023.02.18
+     */
+    fun list(request: ServerRequest): Mono =
+        when(request.pathVariable("target")) {
+            "follower" -> {
+                findUseCase.follower(
+                    request.userId(),
+                    request.queryParam("sort").orElse(CommCode.DEFAULT_SORT_OPTION),
+                    request.queryParam("start").orElse(CommCode.DEFAULT_PAGING_START).toInt(),
+                    request.queryParam("size").orElse(CommCode.DEFAULT_PAGING_SIZE).toInt()
+                )
+            }
+            "followee" -> {
+                findUseCase.followee(
+                    request.userId(),
+                    request.queryParam("sort").orElse(CommCode.DEFAULT_SORT_OPTION),
+                    request.queryParam("start").orElse(CommCode.DEFAULT_PAGING_START).toInt(),
+                    request.queryParam("size").orElse(CommCode.DEFAULT_PAGING_SIZE).toInt()
+                )
+            }
+            "followforfollow" -> {
+                findUseCase.followForFollow(
+                    request.userId(),
+                    request.queryParam("sort").orElse(CommCode.DEFAULT_SORT_OPTION),
+                    request.queryParam("start").orElse(CommCode.DEFAULT_PAGING_START).toInt(),
+                    request.queryParam("size").orElse(CommCode.DEFAULT_PAGING_SIZE).toInt()
+                )
+            }
+            else -> throw BadRequestException("유효한 경로가 아닙니다.")
+        }
+        .collectList()
+        .flatMap { BaseResponse().success(it) }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowRouter.kt b/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowRouter.kt
new file mode 100644
index 0000000..5e9ea9f
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/FollowRouter.kt
@@ -0,0 +1,29 @@
+package com.nexters.pimo.follow.adapter.`in`.web
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.http.MediaType
+import org.springframework.web.reactive.function.server.RouterFunction
+import org.springframework.web.reactive.function.server.ServerResponse
+import org.springframework.web.reactive.function.server.router
+
+/**
+ * @author yoonho
+ * @since 2023.02.15
+ */
+@Configuration
+class FollowRouter(
+    private val followHandler: FollowHandler
+) {
+
+    @Bean
+    fun followRouterFunction(): RouterFunction =
+        router {
+            accept(MediaType.APPLICATION_JSON).nest {
+                POST("/follows", followHandler::register)
+                DELETE("/follows", followHandler::delete)
+                GET("/follows/count", followHandler::count)
+                GET("/follows/{target}", followHandler::list)
+            }
+        }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/dto/RegisterInput.kt b/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/dto/RegisterInput.kt
new file mode 100644
index 0000000..7515c3f
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/adapter/in/web/dto/RegisterInput.kt
@@ -0,0 +1,11 @@
+package com.nexters.pimo.follow.adapter.`in`.web.dto
+
+import java.io.Serializable
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+data class RegisterInput(
+    val userId: String
+): Serializable
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowPersistenceAdapter.kt b/src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowPersistenceAdapter.kt
new file mode 100644
index 0000000..7b29789
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowPersistenceAdapter.kt
@@ -0,0 +1,132 @@
+package com.nexters.pimo.follow.adapter.out.persistence
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.nexters.pimo.account.adapter.out.persistence.AccountRepository
+import com.nexters.pimo.common.exception.BadRequestException
+import com.nexters.pimo.follow.application.dto.FollowAccountDto
+import com.nexters.pimo.follow.application.dto.FollowCntDto
+import com.nexters.pimo.follow.application.port.out.FindPort
+import com.nexters.pimo.follow.application.port.out.SavePort
+import com.nexters.pimo.follow.domain.Follow
+import org.springframework.data.domain.PageRequest
+import org.springframework.stereotype.Repository
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+import reactor.kotlin.core.publisher.switchIfEmpty
+import java.util.*
+import java.util.stream.Collectors
+
+/**
+ * @author yoonho
+ * @since 2023.02.15
+ */
+@Repository
+class FollowPersistenceAdapter(
+    private val followRepository: FollowRepository,
+    private val accountRepository: AccountRepository,
+    private val objectMapper: ObjectMapper,
+): SavePort, FindPort {
+
+    override fun register(followeeUserId: String, followerUserId: String): Mono =
+        Mono.zip(
+            accountRepository.findByUserId(followeeUserId)
+                .switchIfEmpty { throw BadRequestException("등록되지 않은 사용자입니다.") },
+            accountRepository.findByUserId(followerUserId)
+                .switchIfEmpty { throw BadRequestException("등록되지 않은 사용자입니다.") }
+        )
+        .flatMap { followRepository.save(
+            Follow(
+                followeeUserId = followeeUserId,
+                followeeNickName = it.t1.nickName,
+                followerUserId = followerUserId,
+                followerNickName = it.t2.nickName
+            )
+        )
+        .flatMap { Mono.just(true) } }
+
+    override fun delete(followeeUserId: String, followerUserId: String): Mono =
+        followRepository.deleteByFollowerUserIdAndFolloweeUserId(followerUserId, followeeUserId)
+
+    override fun count(userId: String): Mono =
+        Mono.zip(
+            followRepository.findAllByFollowerUserId(userId)
+                .filterWhen {
+                    followRepository.existsByFollowerUserIdAndFolloweeUserId(it.followeeUserId, userId)
+                        .map { !it }    // not exists 조건
+                }
+                .count(),
+            followRepository.findAllByFolloweeUserId(userId)
+                .filterWhen {
+                    followRepository.existsByFollowerUserIdAndFolloweeUserId(userId, it.followerUserId)
+                        .map { !it }    // not exists 조건
+                }
+                .count(),
+            followRepository.findAllByFollowerUserId(userId)
+                .filterWhen {
+                    followRepository.existsByFollowerUserIdAndFolloweeUserId(it.followeeUserId, userId)
+                }
+                .count()
+        )
+        .map {
+            FollowCntDto(
+                followerCnt = it.t1,
+                followeeCnt = it.t2,
+                followForFollowCnt = it.t3
+            )
+        }
+
+    override fun follower(userId: String, start: Int, size: Int): Flux =
+        followRepository.findAllByFollowerUserIdOrderByCreatedAt(userId, PageRequest.of(start, size))
+            .filterWhen {
+                followRepository.existsByFollowerUserIdAndFolloweeUserId(it.followeeUserId, userId)
+                    .map { !it }    // not exists 조건
+            }
+            .flatMap { findFollowAccountDto(it.followeeUserId) }
+
+    override fun followee(userId: String, start: Int, size: Int): Flux =
+        followRepository.findAllByFolloweeUserIdOrderByCreatedAt(userId, PageRequest.of(start, size))
+            .filterWhen {
+                followRepository.existsByFollowerUserIdAndFolloweeUserId(userId, it.followerUserId)
+                    .map { !it }    // not exists 조건
+            }
+            .flatMap { findFollowAccountDto(it.followerUserId) }
+
+    override fun followForFollow(userId: String, start: Int, size: Int): Flux  =
+        followRepository.findAllByFollowerUserIdOrderByCreatedAt(userId, PageRequest.of(start, size))
+            .filterWhen {
+                followRepository.existsByFollowerUserIdAndFolloweeUserId(it.followeeUserId, userId)
+            }
+            .flatMap { findFollowAccountDto(it.followeeUserId) }
+
+    override fun followerByNickname(userId: String, start: Int, size: Int): Flux =
+        followRepository.findAllByFollowerUserIdOrderByFollowerNickName(userId, PageRequest.of(start, size))
+            .filterWhen {
+                followRepository.existsByFollowerUserIdAndFolloweeUserId(it.followeeUserId, userId)
+                    .map { !it }    // not exists 조건
+            }
+            .flatMap { findFollowAccountDto(it.followeeUserId) }
+
+    override fun followeeByNickname(userId: String, start: Int, size: Int): Flux =
+        followRepository.findAllByFolloweeUserIdOrderByFolloweeNickName(userId, PageRequest.of(start, size))
+            .filterWhen {
+                followRepository.existsByFollowerUserIdAndFolloweeUserId(userId, it.followerUserId)
+                    .map { !it }    // not exists 조건
+            }
+            .flatMap { findFollowAccountDto(it.followerUserId) }
+
+    override fun followForFollowByNickname(userId: String, start: Int, size: Int): Flux =
+        followRepository.findAllByFollowerUserIdOrderByFollowerNickName(userId, PageRequest.of(start, size))
+            .filterWhen {
+                followRepository.existsByFollowerUserIdAndFolloweeUserId(it.followeeUserId, userId)
+            }
+            .flatMap { findFollowAccountDto(it.followeeUserId) }
+
+
+    private fun findFollowAccountDto(userId: String): Mono =
+        accountRepository.findByUserId(userId)
+            .switchIfEmpty { throw BadRequestException("등록되지 않은 사용자입니다.") }
+            .map { it.toDto() }
+            .map { objectMapper.convertValue(it, FollowAccountDto::class.java) }
+
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowRepository.kt b/src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowRepository.kt
new file mode 100644
index 0000000..3533ab8
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/adapter/out/persistence/FollowRepository.kt
@@ -0,0 +1,24 @@
+package com.nexters.pimo.follow.adapter.out.persistence
+
+import com.nexters.pimo.follow.domain.Follow
+import org.springframework.data.domain.Pageable
+import org.springframework.data.repository.reactive.ReactiveCrudRepository
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+interface FollowRepository: ReactiveCrudRepository {
+    fun deleteByFollowerUserIdAndFolloweeUserId(followerUserId: String, followeeUserId: String): Mono
+
+    fun findAllByFollowerUserId(followerUserId: String): Flux
+    fun findAllByFolloweeUserId(followeeUserId: String): Flux
+    fun existsByFollowerUserIdAndFolloweeUserId(followerUserId: String, followeeUserId: String): Mono
+
+    fun findAllByFollowerUserIdOrderByCreatedAt(followerUserId: String, pageable: Pageable): Flux
+    fun findAllByFolloweeUserIdOrderByCreatedAt(followeeUserId: String, pageable: Pageable): Flux
+    fun findAllByFollowerUserIdOrderByFollowerNickName(followerUserId: String, pageable: Pageable): Flux
+    fun findAllByFolloweeUserIdOrderByFolloweeNickName(followeeUserId: String, pageable: Pageable): Flux
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/FollowService.kt b/src/main/kotlin/com/nexters/pimo/follow/application/FollowService.kt
new file mode 100644
index 0000000..0c78beb
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/FollowService.kt
@@ -0,0 +1,54 @@
+package com.nexters.pimo.follow.application
+
+import com.nexters.pimo.common.exception.BadRequestException
+import com.nexters.pimo.follow.application.dto.FollowAccountDto
+import com.nexters.pimo.follow.application.dto.FollowCntDto
+import com.nexters.pimo.follow.application.port.`in`.DeleteUseCase
+import com.nexters.pimo.follow.application.port.`in`.FindUseCase
+import com.nexters.pimo.follow.application.port.`in`.RegisterUseCase
+import com.nexters.pimo.follow.application.port.out.FindPort
+import com.nexters.pimo.follow.application.port.out.SavePort
+import org.springframework.stereotype.Service
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+@Service
+class FollowService(
+    private val savePort: SavePort,
+    private val findPort: FindPort
+): RegisterUseCase, FindUseCase, DeleteUseCase {
+
+    override fun register(followeeUserId: String, followerUserId: String): Mono =
+        savePort.register(followeeUserId, followerUserId)
+
+    override fun delete(followeeUserId: String, followerUserId: String): Mono =
+        savePort.delete(followeeUserId, followerUserId)
+
+    override fun count(userId: String): Mono =
+        findPort.count(userId)
+
+    override fun follower(userId: String, sort: String, start: Int, size: Int): Flux =
+        when(sort) {
+            "0" -> findPort.follower(userId, start, size)
+            "1" -> findPort.followerByNickname(userId, start, size)
+            else -> throw BadRequestException("유효하지 않은 정렬옵션입니다")
+        }
+
+    override fun followee(userId: String, sort: String, start: Int, size: Int): Flux =
+        when(sort) {
+            "0" -> findPort.followee(userId, start, size)
+            "1" -> findPort.followeeByNickname(userId, start, size)
+            else -> throw BadRequestException("유효하지 않은 정렬옵션입니다")
+        }
+
+    override fun followForFollow(userId: String, sort: String, start: Int, size: Int): Flux =
+        when(sort) {
+            "0" -> findPort.followForFollow(userId, start, size)
+            "1" -> findPort.followForFollowByNickname(userId, start, size)
+            else -> throw BadRequestException("유효하지 않은 정렬옵션입니다")
+        }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowAccountDto.kt b/src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowAccountDto.kt
new file mode 100644
index 0000000..194e1b9
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowAccountDto.kt
@@ -0,0 +1,16 @@
+package com.nexters.pimo.follow.application.dto
+
+import java.io.Serializable
+
+/**
+ * @author yoonho
+ * @since 2023.02.18
+ */
+data class FollowAccountDto(
+    val userId: String,
+    var nickName: String,
+    var profileImgUrl: String,
+    var status: String,
+    var updatedAt: String,
+    val createdAt: String
+): Serializable
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowCntDto.kt b/src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowCntDto.kt
new file mode 100644
index 0000000..80fc889
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/dto/FollowCntDto.kt
@@ -0,0 +1,13 @@
+package com.nexters.pimo.follow.application.dto
+
+import java.io.Serializable
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+data class FollowCntDto(
+    val followerCnt: Long,
+    val followeeCnt: Long,
+    val followForFollowCnt: Long
+): Serializable
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/port/in/DeleteUseCase.kt b/src/main/kotlin/com/nexters/pimo/follow/application/port/in/DeleteUseCase.kt
new file mode 100644
index 0000000..b47b412
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/port/in/DeleteUseCase.kt
@@ -0,0 +1,11 @@
+package com.nexters.pimo.follow.application.port.`in`
+
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+interface DeleteUseCase {
+    fun delete(followeeUserId: String, followerUserId: String): Mono
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/port/in/FindUseCase.kt b/src/main/kotlin/com/nexters/pimo/follow/application/port/in/FindUseCase.kt
new file mode 100644
index 0000000..9ddf702
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/port/in/FindUseCase.kt
@@ -0,0 +1,17 @@
+package com.nexters.pimo.follow.application.port.`in`
+
+import com.nexters.pimo.follow.application.dto.FollowAccountDto
+import com.nexters.pimo.follow.application.dto.FollowCntDto
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+interface FindUseCase {
+    fun count(userId: String): Mono
+    fun follower(userId: String, sort: String, start: Int, size: Int): Flux
+    fun followee(userId: String, sort: String, start: Int, size: Int): Flux
+    fun followForFollow(userId: String, sort: String, start: Int, size: Int): Flux
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/port/in/RegisterUseCase.kt b/src/main/kotlin/com/nexters/pimo/follow/application/port/in/RegisterUseCase.kt
new file mode 100644
index 0000000..d521933
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/port/in/RegisterUseCase.kt
@@ -0,0 +1,11 @@
+package com.nexters.pimo.follow.application.port.`in`
+
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+interface RegisterUseCase {
+    fun register(followeeUserId: String, followerUserId: String): Mono
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/port/out/FindPort.kt b/src/main/kotlin/com/nexters/pimo/follow/application/port/out/FindPort.kt
new file mode 100644
index 0000000..92b77c4
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/port/out/FindPort.kt
@@ -0,0 +1,21 @@
+package com.nexters.pimo.follow.application.port.out
+
+import com.nexters.pimo.follow.application.dto.FollowAccountDto
+import com.nexters.pimo.follow.application.dto.FollowCntDto
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+interface FindPort {
+    fun count(userId: String): Mono
+    fun follower(userId: String, start: Int, size: Int): Flux
+    fun followee(userId: String, start: Int, size: Int): Flux
+    fun followForFollow(userId: String, start: Int, size: Int): Flux
+
+    fun followerByNickname(userId: String, start: Int, size: Int): Flux
+    fun followeeByNickname(userId: String, start: Int, size: Int): Flux
+    fun followForFollowByNickname(userId: String, start: Int, size: Int): Flux
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/application/port/out/SavePort.kt b/src/main/kotlin/com/nexters/pimo/follow/application/port/out/SavePort.kt
new file mode 100644
index 0000000..f78dbcf
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/application/port/out/SavePort.kt
@@ -0,0 +1,12 @@
+package com.nexters.pimo.follow.application.port.out
+
+import reactor.core.publisher.Mono
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+interface SavePort {
+    fun register(followeeUserId: String, followerUserId: String): Mono
+    fun delete(followeeUserId: String, followerUserId: String): Mono
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/nexters/pimo/follow/domain/Follow.kt b/src/main/kotlin/com/nexters/pimo/follow/domain/Follow.kt
new file mode 100644
index 0000000..bd5e441
--- /dev/null
+++ b/src/main/kotlin/com/nexters/pimo/follow/domain/Follow.kt
@@ -0,0 +1,27 @@
+package com.nexters.pimo.follow.domain
+
+import org.springframework.data.annotation.Id
+import org.springframework.data.relational.core.mapping.Column
+import org.springframework.data.relational.core.mapping.Table
+import java.time.LocalDateTime
+
+/**
+ * @author yoonho
+ * @since 2023.02.16
+ */
+@Table(name = "FollowTB")
+data class Follow (
+    @Id
+    @Column("id")
+    val id: Long = 0,
+    @Column("followerUserId")
+    val followerUserId: String,
+    @Column("followerNickName")
+    val followerNickName: String,
+    @Column("followeeUserId")
+    val followeeUserId: String,
+    @Column("followeeNickName")
+    val followeeNickName: String,
+    @Column("createdAt")
+    val createdAt: LocalDateTime = LocalDateTime.now()
+)
\ No newline at end of file