저번 게시글에서 WebSocketConfig, WebSocketHandler를 구성했다. 이제 쪽지 기능을 구현해보겠다.
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; //닉네임
}
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에서 엔티티를 로딩할 때 사용되는 옵션 중 하나다.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 어노테이션을 사용하여 내용이 비어있지 않도록 유효성 검사를 수행하게 했다.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
메소드를 통해 클라이언트가 모든 메시지를 조회할 때 시간에 대한 내림차순으로 조회할 수 있게 해준다.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 형식의 응답을 반환한다.
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를 구현할 인터페이스이다.
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
정말 유익해요