[Spring Boot] WebSocket을 활용한 실시간 쪽지 기능 구현(2)

한동근·2023년 11월 27일
1

SpringBoot

목록 보기
4/12
post-thumbnail

저번 게시글에서 WebSocketConfig, WebSocketHandler를 구성했다. 이제 쪽지 기능을 구현해보겠다.

구현

UserEntity

package com.example.SignServer.Entity;

import com.example.SignServer.Dto.UserDto;
import lombok.*;

@Entity(name = "user")
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
@Setter
@Builder
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 회원번호 자동생성
    private Long id; //회원번호
    private String uid; //아이디
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) // JSON 결과로 출력하지 않을 데이터
    private String password; //비밀번호
    private String nickname; //닉네임
    }
  • 쪽지를 보내기 위해서는 유저가 필요하다. 그래서 기본적인 UserEntity를 생성해줬다.

MessageEntity

package com.example.SignServer.Entity;

import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity(name = "message")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
@Builder
public class MessageEntity {
    @Id
    @GeneratedValue(strategy =GenerationType.IDENTITY)
    private Long messageId; // 쪽지 번호
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "sender_id")
    private UserEntity sender; // 쪽지 송신자 회원 번호
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name ="receiver_id")
    private UserEntity receiver; // 쪽지 수신자 회원 번호
    private String content; // 내용

    @CreationTimestamp // 현재 시간 자동 삽입
    private LocalDateTime sendTime; // 송신 시간
    @ColumnDefault("FALSE")
    private boolean readStatus; // 읽음 여부
}

한 명의 유저는 여러 개의 쪽지를 보낼 수 있다. 그래서 sender와 receiver에 ManyToOne 설정을 해준다. DB에 저장될 때는 유저의 회원번호가 저장된다.

  • @CreationTimestamp는 데이터의 생성 시간을 기록하기 위해 사용된다. 예를 들어, 데이터베이스의 테이블에 새로운 레코드가 추가될 때, 해당 레코드의 생성 시간을 CreationTimestamp로 기록할 수 있다.
  • @ColumnDefault 는 데이터베이스 테이블의 컬럼에 기본값을 설정하는 기능이다. 데이터베이스에 새로운 레코드가 추가될 때 해당 컬럼이 비어있으면 자동으로 설정한 기본값이 적용된다.
  • (fetch = FetchType.LAZY)는 JPA에서 엔티티를 로딩할 때 사용되는 옵션 중 하나다.
    FetchType.LAZY는 연관된 엔티티를 실제로 사용하는 시점까지 로딩을 지연시키는 방식이다.
    FetchType.EAGER는 연관된 엔티티를 즉시 로딩하는 방식이다. 해당 엔티티를 조회할 때 연관된 엔티티도 함께 로딩된다.
    예를 들어, 게시물과 댓글이 일대다 관계일 때, 게시물을 조회할 때는 댓글을 함께 로딩하지 않고, 게시물을 실제로 사용하는 시점에서 댓글을 로딩하도록 설정할 수 있다.

MessageDto

package com.example.SignServer.Dto;

import com.example.SignServer.Entity.MessageEntity;
import com.example.SignServer.Entity.UserEntity;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.validation.constraints.NotBlank;
import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class MessageDto {
    private Long messageId; // 쪽지 번호
    @JsonProperty("sender")
    private String sender; // 쪽지 송신자
    @JsonProperty("receiver")
    private String receiver; // 쪽지 수신자
    @NotBlank(message = "내용을 입력해 주세요")
    private String content; // 내용
    private LocalDateTime sendTime; // 송신 시간
    private boolean readStatus; // 읽음 여부

    public MessageEntity dtoToEntity(UserEntity sender, UserEntity receiver){
        return new MessageEntity(messageId,sender,receiver,content,sendTime,readStatus);
    }

    public static MessageDto entityToDto(MessageEntity messageEntity){
        return new MessageDto(
                messageEntity.getMessageId(),
                messageEntity.getSender().getNickname(),
                messageEntity.getReceiver().getNickname(),
                messageEntity.getContent(),
                messageEntity.getSendTime(),
                messageEntity.isReadStatus()
        );
    }
}
  • sender : 쪽지 송신자의 닉네임을 나타내는 String 타입의 필드이다. @JsonProperty 어노테이션을 사용하여 JSON 직렬화 시에 필드명을 "sender"로 지정했다.
  • receiver : 쪽지 수신자의 닉네임을 나타내는 String 타입의 필드이다. @JsonProperty 어노테이션을 사용하여 JSON 직렬화 시에 필드명을 "receiver"로 지정했다.
  • content : 쪽지의 내용을 나타내는 String 타입의 필드이다. @NotBlank 어노테이션을 사용하여 내용이 비어있지 않도록 유효성 검사를 수행하게 했다.

MessageRepository

package com.example.SignServer.Repository;

import com.example.SignServer.Entity.MessageEntity;
import com.example.SignServer.Entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MessageRepository extends JpaRepository<MessageEntity,Long> {
    Long countByReceiverIdAndReadStatus(Long receiverId, boolean readStatus);
    List<MessageEntity> findAllByReceiverOrderBySendTimeDesc(UserEntity receiver);
}

JPARepository를 상속받는 MessageRepository를 만들어준다.

  • countByReceiverIdAndReadStatus 메소드를 통해 웹소켓이 연결되면 읽지 않은 쪽지의 개수를 반환한다.
  • findAllByReceiverOrderBySendTimeDesc 메소드를 통해 클라이언트가 모든 메시지를 조회할 때 시간에 대한 내림차순으로 조회할 수 있게 해준다.

MessageController

package com.example.SignServer.Controller;

import com.example.SignServer.Dto.MessageDto;
import com.example.SignServer.Service.MessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/message")
public class MessageController {
    private final MessageService messageService;

    public MessageController(
            @Autowired MessageService messageService){
        this.messageService = messageService;
    }
    // @AuthenticationPrincipal 사용 시 Authentication 객체의 getPrincipal() 메소드를 통해 반환되는 객체를 받는다.
    @PostMapping("/send") // 쪽지 전송
    public ResponseEntity<MessageDto> SendMessage(@RequestBody MessageDto messageDto, @AuthenticationPrincipal UserDetails userDetails){
        MessageDto send = messageService.SendMessage(messageDto, userDetails);
        return ResponseEntity.status(HttpStatus.CREATED).body(send);
    }

    @GetMapping("/read/all/{nickname}") // nickname의 모든 쪽지 조회
    public ResponseEntity<List<MessageDto>> ReadAllMessage(@PathVariable String nickname,@AuthenticationPrincipal UserDetails userDetails){
        List<MessageDto> messageList = messageService.ReadAllMessage(nickname,userDetails);
        return ResponseEntity.status(HttpStatus.OK).body(messageList);
    }

    @GetMapping("/read/{messageid}")
    public ResponseEntity<MessageDto> ReadMessage(@PathVariable String messageid,@AuthenticationPrincipal UserDetails userDetails){
        MessageDto readMessage = messageService.ReadMessage(Long.valueOf(messageid),userDetails);
        return ResponseEntity.status(HttpStatus.OK).body(readMessage);
    }
}

쪽지를 조회할 RestController를 만들어준다. JSON 형식의 응답을 반환한다.

MessageService

package com.example.SignServer.Service;

import com.example.SignServer.Dto.MessageDto;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public interface MessageService {
    MessageDto SendMessage(MessageDto messageDto, UserDetails userDetails);

    List<MessageDto> ReadAllMessage(String nickname, UserDetails userDetails);

    MessageDto ReadMessage(Long messageId, UserDetails userDetails);
}

MessageService를 구현할 인터페이스이다.

MessageServiceImpl

package com.example.SignServer.Service;

import com.example.SignServer.Config.WebSocketMessageHandler;
import com.example.SignServer.Dto.MessageDto;
import com.example.SignServer.Entity.MessageEntity;
import com.example.SignServer.Entity.UserEntity;
import com.example.SignServer.Repository.MessageRepository;
import com.example.SignServer.Repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
public class MessageServiceImpl implements MessageService {
    private final MessageRepository messageRepository;
    private final UserRepository userRepository;
    private final WebSocketMessageHandler webSocketMessageHandler;

    public MessageServiceImpl(
            @Autowired MessageRepository messageRepository,
            UserRepository userRepository,
            WebSocketMessageHandler webSocketMessageHandler) {
        this.messageRepository = messageRepository;
        this.userRepository = userRepository;
        this.webSocketMessageHandler = webSocketMessageHandler;
    }

    public MessageDto SendMessage(MessageDto messageDto, UserDetails userDetails) {

        if (!userDetails.getUsername().equals(messageDto.getSender())) {
            throw new IllegalArgumentException("인증된 사용자가 아닙니다.");
        }
        log.info("JWT 인증이 완료되었습니다.");

        if (!userRepository.existsByNickname(messageDto.getReceiver())) {
            throw new IllegalArgumentException("받는 분이 존재하지 않습니다.");
        }

        UserEntity sender = userRepository.findByNickname(messageDto.getSender()); // 닉네임으로 유저 조회
        UserEntity receiver = userRepository.findByNickname(messageDto.getReceiver());

        MessageEntity messageEntity = messageDto.dtoToEntity(sender, receiver);
        messageRepository.save(messageEntity);

        try { // uid로 연결한 세션에 쪽지 전송
            webSocketMessageHandler.sendNotification(receiver.getUid(), "새로운 쪽지가 도착했습니다.");
            webSocketMessageHandler.sendNotification(receiver.getUid(), "읽지 않은 쪽지의 개수 : " +
                    messageRepository.countByReceiverIdAndReadStatus(receiver.getId(), false));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        return MessageDto.entityToDto(messageEntity);
    }

    @Override
    public List<MessageDto> ReadAllMessage(String nickname, UserDetails userDetails) {

        log.info("(MessageService) ReadAllMessage 실행.");

        if (!userDetails.getUsername().equals(nickname)) {
            throw new AccessDeniedException("인증된 사용자가 아닙니다.");
        }
        log.info("JWT 인증이 완료되었습니다.");

        UserEntity receiver = userRepository.findByNickname(nickname);
        log.info(String.valueOf(receiver));

        return messageRepository.findAllByReceiverOrderBySendTimeDesc(receiver)
                .stream()
                .map(MessageDto::entityToDto)
                .collect(Collectors.toList());
    }


    @Override
    public MessageDto ReadMessage(Long messageId, UserDetails userDetails) {

        log.info("(MessageService) ReadMessage 실행.");

        MessageEntity messageEntity = messageRepository.findById(messageId)
                .orElseThrow(() -> new IllegalArgumentException("해당 쪽지가 존재하지 않습니다."));

        if (!messageEntity.getReceiver().getUsername().equals(userDetails.getUsername())) {
            throw new AccessDeniedException("쪽지를 읽을 권한이 없습니다."); // 접근할 권한이 없다는 예외
        }

        messageEntity.setReadStatus(true); // 메시지 조회시 읽음 여부 true로 변경
        messageRepository.save(messageEntity); // 저장

        return MessageDto.entityToDto(messageEntity);
    }
}

나는 프로젝트 구현 당시 JWT 인증기능까지 구현해서 요청을 보낼 때 토큰값이 필요하도록 했다. JWT에 대해서도 추후 다룰 예정이지만 지금은 인증에 대한 부분을 빼놓고 보도록 하자.

  • SendMessage : 쪽지를 보내는 메소드이다. 수신인이 있는지 확인한 후 받은 MessageDTO를 Entity로 변환 후 데이터베이스에 저장한다. 그 후 수신인의 Uid를 받아와 해시맵에서 세션을 찾아 그 웹소켓 세션에 메시지를 전송한다.
  • ReadAllMessage 는 받은 모든 쪽지를 조회하는 메소드이다.
  • ReadMessage는 모든 쪽지를 조회한 후. 그 중 하나의 쪽지만 조회할 때 쓰는 메소드이다

추가적으로 쪽지를 삭제하는 메소드도 필요할 것이다.

포스트맨 테스트

이제 포스트맨으로 테스트를 해보자.

먼저 닉네임이 Han인 유저 A, Dong인 유저 B를 생성해준다. 그 다음 유저 A의 웹소켓 연결을 해주었다.

테스트 전 쪽지를 하나 보낸 상태라서 접속 시 읽지 않은 쪽지의 개수가 1개가 이미 존재한다.
그 후 유저 B가 쪽지를 전송했다.

다시 유저 A의 웹소켓으로 돌아가서 확인해보면 웹소켓 메시지가 온 걸 확인할 수 있다.

구현 중 느낀 점

프론트와 합치면서 쪽지를 전송할 때 닉네임으로 수신인과 송신인을 나누는게 ID를 알려주지 않아 보안을 높이는 것이라 생각했다. 그래서 코드가 복잡해지게 되었고 동시에 웹소켓 연결은 파라미터로 ID를 보내서 연결하는 일관성이 없는 코드가 되어버렸다. 다음에 또 웹소켓에 대한 구현을 하게 된다면 이에 대한 생각을 더 해보고 개발을 할 것이다.

잘못된 부분이 있으면 댓글 부탁드립니다!!

Spring Boot

  • version : 2.7.16
  • java : 11
  • Gradle - Groovy
  • Java
profile
와플대조교의 개발 블로그

1개의 댓글

comment-user-thumbnail
2023년 11월 29일

정말 유익해요

답글 달기

관련 채용 정보