Spring+Kotlin) 채팅방에 필요한 기능 구현

성승모·2024년 6월 12일
0

2024.06.07

채팅 방 Entity를 정의하고 생성 API 및 채팅방 초대 API를 만드려한다.

우선 채팅방에 관련된 Entity들을 정의해보자.
유저-채팅방은 다대다 관계이기 때문에 양쪽을 잇는 entity도 선언해야한다.
users - (chatting_room_infos) - chatting_room

@Entity @Table(name = "chatting_room_infos")
class ChattingRoomInfo(
    @OneToMany
    @JoinColumn(name = "user_id")
    val userId: List<User>,
    @OneToMany
    @JoinColumn(name = "user_id")
    val chatRoom: List<ChattingRoom>,
){
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
}
@Entity @Table(name = "chatting_rooms")
class ChattingRoom(
    val name: String,
    val userTokens: List<String>
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
}

위와 같이 정의하고 API를 만들어보자.

@RestController
@RequestMapping("/chat")
class ChatController {
    @Autowired
    private lateinit var chatService: ChatService
    @Autowired
    private lateinit var chatRoomService: ChatRoomService

    @PostMapping("/create/room")
    fun createChatRoom(@RequestBody requestDto: CreateChatRoomRequestDto,
                       @AuthenticationPrincipal userDetails: UserDetailsImpl
    ): ResponseEntity<Boolean> {
        if(requestDto.userId != userDetails.user.id) {
            return ResponseEntity.badRequest().build()
        }

        return ResponseEntity.ok(chatRoomService.createChatRoom(
            requestDto.roomName,
            requestDto.userList.apply { add(requestDto.userId) }
        ))
    }
}
data class CreateChatRoomRequestDto (
    var roomName: String,	// 방 이름
    val userId: Long,		// 방을 만드는 유저 id
    val userList: MutableList<Long>	// 유저 초대 리스트
)

controller에서 생성자 id와 security context에 잡힌 정보와 같은지 확인 후 service에 요청

@Component
class ChatRoomServiceImpl: ChatRoomService {
    @Autowired
    private lateinit var chatRoomDao: ChatRoomDao
    @Autowired
    private lateinit var userDao: UserDao

    override fun createChatRoom(roomName: String, userIdList: List<Long>): Boolean {
        val tokens = userDao.findTokens(userIdList)
        try {
            val newRoomId = chatRoomDao.create(roomName, tokens)!!
            val subscribeResult = FirebaseMessaging.getInstance().subscribeToTopic(tokens, newRoomId.toString())

            logger.info {
                "${subscribeResult.successCount}'s Devices success to subscribe a topic: ${newRoomId}"
                if(subscribeResult.failureCount > 0) {
                    "${subscribeResult.failureCount}'s Devices fail to subscribe a topic: ${newRoomId}"
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
            return false
        }

        return true
    }

}

service에서 직접 기기를 해당 채팅룸에 구독시킨다.
dao는 토큰 리스트를 받아 새로 생성된 Chatting Room Id를 반환한다.

2024.06.10

비슷한 방식으로 채팅방 초대, 나가기를 구현해보자
Controller

@PostMapping("/invite")
    fun inviteUsers(@RequestBody requestDto: InviteRequestDto): ResponseEntity<Boolean> {
        return ResponseEntity.ok(chatRoomService.invite(requestDto.roomId, requestDto.userIdList))
    }
    @PostMapping
    fun exitRoom(@RequestBody requestDto: ExitRoomRequestDto): ResponseEntity<Boolean> {
        return ResponseEntity.ok(chatRoomService.exit(requestDto.roomId, requestDto.userId))
    }

Service
Firebase에 요청하여 unscribe해주는 것을 잊어버리지 않는다!

override fun invite(roomId: Long, userIdList: List<Long>): Boolean {
        try {
            val tokens = userDao.findTokens(userIdList)
            chatRoomDao.invite(roomId, tokens)

            val subscribeResult = FirebaseMessaging.getInstance().subscribeToTopic(tokens, roomId.toString())
            logger.info {
                "${subscribeResult.successCount}'s Devices success to subscribe a topic: $roomId"
                if(subscribeResult.failureCount > 0) {
                    "${subscribeResult.failureCount}'s Devices fail to subscribe a topic: $roomId"
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
            return false
        }

        return true
    }

    override fun exit(roomId: Long, userId: Long): Boolean {
        try {
            val tokens = userDao.findTokens(listOf(userId))
            chatRoomDao.delete(roomId, tokens[0])

            val subscribeResult = FirebaseMessaging.getInstance().unsubscribeFromTopic(tokens, roomId.toString())
            logger.info {
                "${subscribeResult.successCount}'s Devices success to unsubscribe a topic: $roomId"
                if(subscribeResult.failureCount > 0) {
                    "${subscribeResult.failureCount}'s Devices fail to unsubscribe a topic: $roomId"
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
            return false
        }

        return true
    }

DAO

override fun invite(roomId: Long, tokens: List<String>) {
        val roomOptional = chatRoomRepository.findById(roomId)
        if(!roomOptional.isPresent) throw Exception("There is a no Room id is $roomId")

        val room = roomOptional.get()
        room.userTokens.addAll(tokens)
    }

    override fun delete(roomId: Long, token: String) {
        val roomOptional = chatRoomRepository.findById(roomId)
        if(!roomOptional.isPresent) throw Exception("There is a no Room id is $roomId")

        val room = roomOptional.get()
        room.userTokens.remove(token)
    }

API를 추가할때마다 항상 느낀다. Controller-Service-Dao의 역할을 명확히 나누기가 힘든거같다. Controller에서 validate하고 service엔 비즈니스 코드를, dao에선 repository에 접근하여 필요한 데이터를 뽑아오는... 개념은 쉽지만 이를 직접 고민하여 적용하는건 항상 어려운것 같다.

2024.06.12

사용자 본인이 속해있는 채팅방을 가져오는 API를 작성하려 한다.
그러기 위해선 사용자를 특징해야 하며 userId가 필요하다.
-> sercurity context 내 user 가져오기

ChatController.kt

	@GetMapping("/get")
    fun getRooms(@AuthenticationPrincipal userDetails: UserDetailsImpl): ResponseEntity<GetChatRoomsResponseDto> {
        return ResponseEntity.ok(chatRoomService.getRooms(userDetails.user.id))
    }

GetChatRoomsResponseDto는 채팅방 id와 그 채팅방에 해당하는 마지막 메시지 내용을 리스트의 형태로 갖고 있다.

2024.06.13


ChattingRoom Entity에 라스트 메시지를 추가하려고 고민을 하다가 객체 지향의 Java는 다대다 관계를 표현하는데 적합하지 않다는 글을 읽었다. 이를 중간 테이블을 추가하여 해결할 수 있지만 잘못 이해하고 사용한 부분이 있어 정리해보려고 한다.

정리

다대다: @ManyToMany 가 있어 가능은 하지만 하이버네이트가 중간 entity를 만들어버린다. 개발자의 기대와 다르게 작동할 가능성이 있다. 따라서 직접 중간 entity를 정의하는 것이 좋은 방법이다. 하지만 의문이 들었다.

  • User가 List<ChattingRoom>의 프로퍼티를 가지면 안되나??

  • ChattingRoom가 List<User>의 프로퍼티를 가지면 안되나??

      서칭해본 결과, 쓸 수는 있다. 하지만 큰 단점이 있는데 User-ChattingRoom 간의 동일성을 보장하기 어렵다는 것이다. User가 채팅방에서 퇴장하였을때 User.roomList.remove('나간방')을 하고 ChattingRoom.userList.remove('나간유저')를 해줘야한다. 코드 상으로 두 repository에 접근하는건 매우 비효율적이고 개발자가 이런 예외를 생각해서 코드를 작성해야하는건 너무 이상적인 생각이다. 따라서 이런 동시성에 도움을 주기 위해 Spring에서 여러 Annotation을 제공한다.

  1. @OneToMany,@ManyToOne, @OneToOne: 연관관계를 설정한다.
  • 한 Entity에만 작성시 단방향, 양쪽에 작성시 양방향
  • OneToOne과 OneToOne / ManyToOne과 OneToMany

공통 field)

  • targetEntity: 관계를 맺을 entity 클래스 but, 프로퍼티 정의 시 명시하는 것이 좋다.
  • cascade: 연관된 엔티티도 함께 영속 상태를 관리하기 위해 이용
      - CascadeType.PERSIST: 해당 entity 저장 시 연관된 entity도 자동 영속화
      - CascadeType.REMOVE: 해당 entity 삭제 시 연관된 entity도 삭제
      - CascadeType.MERGE: 해당 entity를 병합할 때 연관된 entity도 병합
  • fetch: 연관된 엔티티를 언제 조회할지
      - FetchType.Eager: 해당 entity를 조회할때 연관된 entity를 바로 조회
      - FetchType.LAZY: 연관된 entity 필드를 이용할때 조회
      > 성능 상 Lazy가 유리
  • optional: 해당 연관 entity 프로퍼티가 nullable한지

OneToOne, OneToMany의 추가 field)

  • mappedBy: 양방향 매핑에 이용하며 부하 entity 내에서 mappedBy = '주인 entity'로 설정하여 부하 entity는 주인 entity를 읽기만 가능하도록 하여 양쪽 entity 모두에서 수정이 일어나는 것을 방지한다.
  • orphanRemoval: 주인과 관계가 끊어진 부하 entity를 orphan(고아)라고 한다. 만약 이 entity가 orphan이 된다면 자동으로 삭제할지를 정한다.

    ① cascadeType.REMOVE vs ② orphanRemoval=true
    : 부하의 상태 ①과 ②에 따른 작동 경우의 수
    경우1) ① && ②
      • 주인 삭제 -> 부하 삭제
      • 주인이 부하 연관관계 끊음 -> 부하 삭제
    경우2) !① && ②
      • 주인 삭제 -> 부하 삭제
      • 주인이 부하 연관관계 끊음 -> 부하 삭제
    경우3) ① && !②
      • 주인 삭제 -> 부하 삭제
      • 주인이 부하 연관관계 끊음 -> 부하 삭제x
    경우4) !① && !②
      • 주인 삭제 -> 부하 삭제x
      • 주인이 부하 연관관계 끊음 -> 부하 삭제

  1. @JoinColumn: 연관관계를 담당하는 프로퍼티에 대한 설정 담당
  • @Column과 비슷하게 작동하는 듯하다.

field)

  • name: 필드 이름 설정. (default: '연관 entity 이름'_id)
  • referencedColumnName: 연관 entity의 참고할 필드 지정 (default: PK)
  • unique: 해당 프로퍼티가 unique한지
  • nullable: 해당 프로퍼티가 nullable한지
  • insertable: insert를 막을지 안막을지
  • updatable: update를 막을지 안막을지
  • columnDefinition: 해당 프로퍼티의 자료형을 정의함.

  default 값이 잘 정의되어 있어 특별한 경우가 아니라면 생략한다고 한다.

테스트 중 발견한 차이점) CascadeType.PERSIST vs CascadeType.MERGE
jpaRepository는 내부적으로 EntityManager를 이용한다. EntityManager가 persist를 이용할때 CascadeType.PERSIST 설정된 부하 entity가 영속화된다. 하지만 jpaRepository는 persist 함수로 save 하는 것이 아닌 merge를 이용하여 save하기 때문에 CascadeType.PERSIST는 작동이 안되는것이다.


2024.06.17

User - UserChattingRoom - ChattingRoom 을 정의해보자
User.kt

Entity @Table(name = "users")
class User(): Serializable {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long? = null

  var token: String? = null
  var email: String? = null

  var naverId: String? = null
  var kakaoId: Long? = null

  @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
  val chatRooms = mutableSetOf<UserChattingRoom>()

  var role: UserRoleEnum = UserRoleEnum.Member

  constructor(kakaoId: Long): this() {
      this.kakaoId = kakaoId
  }
  constructor(naverId: String): this() {
      this.naverId = naverId
  }
}

fetch는 lazy로 해주었다. user를 부를때마다 chatRooms를 이용하진 않을테니 말이다.

ChattingRoom.kt

  @Entity @Table(name = "chatting_rooms")
class ChattingRoom(
    var name: String,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
) {
    val userTokens = mutableSetOf<String>()

    @OneToMany(mappedBy = "chatting_rooms", fetch = FetchType.EAGER)
    val users = mutableSetOf<UserChattingRoom>()

}

반대로 여기선 fetch = eager로 해주었다. 무조건 필요한건 아닐테지만 대부분 users에 접근해야 할 것이다. ex) chat message 보내기, 채팅방 내 user 조회

UserChattingRoom.kt
user와 chattingRoom 프로퍼티가 필요하다. 또한 둘의 조합이 Unique해야 하기 때문에 Fk인 user와 chattingRoom을 복합 Pk키로 이용해보았다.

@Entity @Table(name = "user_chatting_room")
class UserChattingRoom(
  @ManyToOne(cascade = [CascadeType.REMOVE], fetch = FetchType.LAZY,optional = false)
  @MapsId("userId")
  @JoinColumn(name = "user_id")
  val user: User,

  @ManyToOne(cascade = [CascadeType.REMOVE], fetch = FetchType.LAZY, optional = false)
  @MapsId("chattingRoomId")
  @JoinColumn(name = "chatting_room_id")
  val chattingRoom: ChattingRoom,
){
  @EmbeddedId
  val id = UserChattingRoomId(user.id!!, chattingRoom.id!!)
}

@MapId("value"): id를 당당하는 프로퍼티의 이름인 value에 연관관계인 Entity의 Id 값을 매핑한다. 따라서 연관된 Entity의 Pk를 가져와 직접 FPk로 이용할 수 있으며 sql 상에서 부가적인 Column이 생성되지 않는다.

2024.06.18

이제 만들기로 한 API를 작성해보자.
메시지 entity를 만든다.

@Entity
@Table(name = "chatting_messages")
class ChattingMessage(
  @ManyToOne(cascade = [CascadeType.REMOVE])
  var chattingRoomId: ChattingRoom,

  var userId: Long,
  var userName: String,
  var body: String,
) {
  @EmbeddedId
  val id = ChattingMessageId(chattingRoomId.id!!)

  @CreationTimestamp
  @Column(nullable = false, updatable = false)
  var createdAt: LocalDateTime? = null
}

service 레이어에서 getRooms 정의

override fun getRooms(userId: Long): GetChatRoomsResponseDto {
      val result = GetChatRoomsResponseDto(
          mutableListOf(), mutableListOf()
      )

      try {
          val roomIdList = mutableListOf<Long>()
          val lastMsgList = mutableListOf<String>()

          chatRoomDao.getRoomsByUserId(userId).forEach {
              roomIdList.add(it.id!!)
              lastMsgList.add(chatMessageDao.getLastMessageBody(it.id))
          }
      } catch (e: Exception) {
          e.printStackTrace()
      }

      return result
  }

chatRoomDao.getRoomsByUserId 정의

override fun getLastMessageBody(roomId: Long): String 
      = chatMessageRepository.findTopByIdChattingRoomIdOrderByCreatedAt(roomId).get().body ?: ""
함수 이름 극혐

함수이름을 해석하자면, roomId에 해당하는 message를 생성 일자 기준으로 내림차순 정렬하여 제일 위에 있는 것으로 가져온다라는 뜻...


2024.06.19

채팅방 리스트를 가져왔으니 특정 채팅방에 입장하였을때 최근 메시지 50개를 주는 API를 만들어보자.

페이징이란??
: 정렬된 데이터를 특정 개수로 나누어 부분 제공하는 기법

쇼핑 앱을 이용하다 보면 한번씩 보았을것이다. 최신순, 인기순, 평점순 등으로 정렬한 후 일정 개수씩 가져와 한 페이지에 보여준다.

ChatController.kt
해당하는 채팅방의 id인 roomId와 몇번째 페이지인지를 뜻하는 page를 RequestParam으로 받는다.

@GetMapping("/get/recent_messages")
  fun getMessages(
      @RequestParam(value = "roomId") roomId: Long,
      @RequestParam(value = "page") page: Int
  ): ResponseEntity<GetMessagesResponse> {
      return ResponseEntity.ok(chatService.getMessages(roomId, page))
  }

ChatService.kt
list로 받아와 dto를 반환한다.

override fun getMessages(roomId: Long, page: Int): GetMessagesResponse {
      val messageList = chattingMessageDAO.getMessages(roomId, page)
      return GetMessagesResponse(messageList)
  }

ChatMessageDao.kt
한 페이지 내 아이템 개수인 pageSize를 50으로 정하고 pageable을 선언한다. 이를 jpaRepository의 파라미터로 넘겨주면 자동으로 Page<ChattingMessage>를 반환한다. 이후 Dto의 List값을 반환한다.

override fun getMessages(roomId: Long, page: Int): List<GetMessagesResponse.Message> {
      val pageSize = 50
      val pageable = PageRequest.of(page, pageSize)
      val pageResult = chatMessageRepository.findByIdChattingRoomIdOrderByCreatedAt(roomId, pageable)

      return pageResult.map {
          GetMessagesResponse.Message(it)
      }.toList()
  }

여기까지 채팅방 로직을 다 완성했다!

profile
안녕하세요!

0개의 댓글