[캡스톤디자인] Spring Boot, STOMP를 이용한 Chatting 구현 (3)

Dev_Sanizzang·2023년 5월 23일
0

캡스톤디자인

목록 보기
6/15
post-thumbnail

📕 개요

최근에 컴퓨터비전, 사물인터넷 시험이 있었기도 했고 갑자기 Spring, JPA, 기본 자바 지식들에 대한 지식이 부족하다고 생각하여 다시 공부하느라 프로젝트를 진행하지 못하고 있었다..
다시 으쌰으쌰 시작해보자!

💡 궁금했던 것들?
1. Entity, 임베디드 타입은 왜 기본생성자 protected여만 하는가?
2. entity dto vo에 대하여
3. ResponseEntity or DTO?
3. 도메인 모델 패턴, 트랜잭션 스크립트 패턴
4. entity 수정 (변경감지 or 병합)
5. API 권한관리
6. OSIV
7. BatchSize
10. Optional에 대하여
11. 등록시간, 업데이트시간, 등록자, 수정자는 어떨 때 넣어야될까?
12. 페이징
13. 자바 람다, 스트림
14. INDEX에 대하여

🤔 의문점

채팅을 구현하기 전에 의문점들이 몇개 있었다.

  1. 처음 채팅방을 접속하면 이전에 했던 채팅을 불러오는게 맞을까?
  2. 채팅을 할 때마다 DB에 INSERT문을 날려야 하는가?

🔍 해결 방법

  1. 이건 서비스마다 구현방법이 다를 것이다.
    -> 디스코드와 같은 경우는 이전 채팅을 불러오고 카카오톡같은 경우는 이전 채팅을 불러오지 않는다.
    -> 이번 프로젝트 같은 경우에서는 이전에 했던 채팅내용도 가져오는 것으로 한다! 단, 모든 채팅을 불러오는 것은 너무 많은 데이터이므로 Pagination을 통해 DB에서 불러오는 것으로 한다.
  2. 채팅 데이터 처리 질문
    이 글을 봤을 때 dynamoDB를 사용했을 때 채팅 하나하나 올때마다 insert해도 무리가 없다고 한다.
    -> 오픈 카카오톡 기술 공유방에 물어본 결과 채팅 하나하나 들어올 때마다 insert를 해도 무리는 없어보인다고 하셨다. (규모가 커짐에 따라서는 무수한 고민이 필요하겠지만)
    -> 결론적으로 일단은 매 채팅이 들어올때마다 insert문을 날리는 것으로 결정!

🤔🤔 의문점

다른 마이크로서비스들에서는 현재 로그인한 사용자 정보를 어떻게 얻어오지?
-> Spring Security는 user-service에서만 사용하고 있기 때문에 다른 마이크로서비스에서는 SecurityContextHolder에 저장된 Authentication값을 가져다 쓸 수 없다.

🔍🔍 해결 방법

클라이언트에서 보낸 헤더의 jwt의 subject를 가져와서 사용자 정보를 찾자!
-> 다른 방법이 생기면 수정하기로!

이번 프로젝트를 하면서 느낀점을 고려해야될게 진짜 많고 많고 많고 많다는 것이다....
아직 부족한 점이 많다.. 더 노력하자..

📃 요구사항 정리

채팅에 대한 요구사항을 정리하고 요구사항을 바탕으로 개발을 진행하고자 한다.

채팅

  1. 채팅
    • 채팅 참여자가 채팅시 모든 구독자(채팅 참여자)들에게 채팅 BroadCast
    • DB에 채팅 저장

채팅방

모임 참여자면 모든 채팅방 참여 가능

  1. 채팅방 생성
    • 아무나 생성 가능
  2. 채팅방 목록 가져오기
    • 모임에 해당하는 채팅방 목록 가져오기
  3. 채팅방 삭제
    • 해당 채팅방을 생성한 사람만 삭제 가능

ERD 설계

ERD 구조는 위와 같이 구성했다.

API 설계

코드 구현

ERD, API도 설계했겠다 Entity 코드를 작성해보겠다.

Enitty 구현

ChatRoom.java

package com.wcd.chattingservice.entity;

import jakarta.persistence.*;
import lombok.*;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long masterId;

    private String name;

    private Long clubId;

    @Builder
    public ChatRoom(Long masterId, String name, Long clubId) {
        this.masterId = masterId;
        this.name = name;
        this.clubId = clubId;
    }
}

Chat.java

package com.wcd.chattingservice.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Chat {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "chat_room_id")
    private ChatRoom chatRoom;

    private Long senderId;

    private String message;

    private LocalDateTime sendTime;

    @Builder
    public Chat(ChatRoom chatRoom, Long senderId, String message) {
        this.chatRoom =chatRoom;
        this.senderId = senderId;
        this.message = message;
        this.sendTime = LocalDateTime.now();
    }

}

당연한 코드겠지만 지금까지 프로젝트를 진행하면서 작성했던 코드와 차이가 있다!
1. Setter 제거

  • 사용한 의도를 쉽게 파악하기 어렵다.
  • 일관성을 유지하기 어렵다.

💡 그럼 setter 없이 어떻게 데이터를 수정할까?

setter의 경우 JPA의 Transaction 안에서 Entity 의 변경사항을 감지하여 Update 쿼리를 생성한다. 즉 setter 메소드는 update 기능을 수행한다.

여러 곳에서 Entity를 생성하여 setter를 통해 update를 수행한다면 복잡한 시스템일 경우 해당 update 쿼리의 출처를 파악하는 비용은 어마어마할 것이다.

  • 사용한 의도나 의미를 알 수 있는 메서드를 작성하자.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long masterId;

    private String name;

    private Long clubId;
    
    public void updateChatRoom(Long masterId, String name) {
    	this.masterId = masterId;
        this.name = name;
    }
}
chatRoom.updateChatRoom(10L, "채팅방 이름 수정");

위와 같이 메소드를 통해서 값을 업데이트해주면 setter 메소드를 작성하는 것 보다 행위의 의도를 한 눈에 알기 쉽다.

  • 생성자를 통해 값을 넣어 일관성을 유지하도록 하자. (feat. @Builder)

Entity의 일관성을 유지하기 위해 생성시점에 값을 넣는 방식으로 setter를 배제할 수 있다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long masterId;

    private String name;

    private Long clubId;
    
    @Builder
    public ChatRoom(Long id, Long masterId, String name, Long clubId) {
    	this.id = id;
        this.masterId = masterId;
        this.name = name;
        this.clubId = cludId;
    }
}
ChatRoom chatRoom = ChatRoom.builder()
				.id(1L)
                .masterId(1L)
                .name("채팅방 이름")
                .clubId(1L)
                .build();

setter를 배제하기 위해 여러 생성자를 작성할 수도 있는데, 위와 같이 lombok의 @Builder 애노테이션을 통해 많은 생성자를 사용할 필요 없이 setter의 사용을 줄일 수 있다.
또한 빌더 패턴을 통해 chatRoom 객체의 값을 세팅할 수 있다.

  1. 기본생성자 Protected로 생성
  • 기본 생성자의 접근제어를 protected로 설정해놓게 되면 무분별한 객체 생성에 대해 한번 더 체크할 수 있다.

    💡 Private를 쓰지 않는 이유?

  • JPA 표준 스펙에 디폴트 생상자가 있어야하므로 Private가 될 수 없음
  • JPA가 프록시 기술을 쓸 때, JPA hibernate가 객체를 강제로 만들어야 하는데 private로 만들면 다 막힘
  1. 단방향 설계로 먼저 설계하고 나중에 필요할 때 양방향으로 바꾸자!
  • 간결한 코드와 가독성
    : 양방향 연관관계에서는 연관된 양쪽 클래스 모두 관리해야 하기 때문에 코드 복잡성이 증가한다.

  • 메모리 사용 최적화
    : 양방향 연관관계에서는 서로 참조하고 있는 객체를 모두 유지해야 하므로 메모리 사용량이 증가한다. 반면 단방향 연관관계에서는 한 방향으로만 참조를 유지하므로 메모리 사용량이 상대적으로 줄 수 있다.

  • 성능
    : 단방향 연관관계에서는 필요한 객체만 로드하므로 성능이 향상된다.
    양방향 연관관게에서는 종종 불필요한 객체까지 로드해야 하는 상황이 생길 수 있다.

STOMP 설정

SpringConfig.java

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class SpringConfig implements WebSocketMessageBrokerConfigurer {
    private final StompHandler stompHandler; // jwt 인증

    // 웹소켓 configuration의 addHandler 메소드와 유사
    // cors, SockJS 설정 가능
    /*
       STOMP를 사용하면 웹소켓만 사용할 때와 다르게 하나의 연결주소마다 핸들러 클래스를 따로 구현할 필요없이
       Controller 방식으로 간편하게 사용할 수 있다.
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // stomp 접속 주소 url => /ws
        registry.addEndpoint("/ws") // 연결될 엔드포인트
                .setAllowedOriginPatterns("*")
                .withSockJS(); // SocketJS 를 연결한다는 설정
    }

    // STOMP에서 사용하는 메시지 브로커 설정
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // enableSimpleBroker: 내장 메시지 브로커를 사용하기 위한 메소드
        // 파라미터로 지정한 prefix가 붙은 메시지를 발행할 경우, 메시지 브로커가 이를 처리하게 된다.
        // 메시지를 구독하는 요청 url => 즉 메시지 받을 때
        registry.enableSimpleBroker("/topic");

        // 메세지 핸들러로 라우팅 되는 prefix를 파라미터로 지정할 수 있다.
        // 메시지 가공 처리가 필요한 경우, 가공 핸들러로 메시지를 라우팅 되도록하는 설정
        // 메시지를 발행하는 요청 url => 즉 메시지 보낼 때
        registry.setApplicationDestinationPrefixes("/app");
    }

//    @Override
//    public void configureClientInboundChannel(ChannelRegistration registration) {
//        registration.interceptors(stompHandler);
//    }
}

STOMP를 통해 채팅을 할 때 사용자 인증을 위한 로직 (보류)

StompHandler.java

package com.wcd.chattingservice.handler;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.security.Key;

@RequiredArgsConstructor
@Component
@Slf4j
public class StompHandler implements ChannelInterceptor {
    private Environment env;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if(accessor.getCommand() == StompCommand.CONNECT) {
            String jwtToken = accessor.getFirstNativeHeader("Authorization");
            if (!validateToken(jwtToken)) {
                throw new RuntimeException("Invalid JWT token");
            }
        }
        return message;
    }

    public boolean validateToken(String access_token) {
        try {
            Key secretKey = Keys.hmacShaKeyFor(env.getProperty("access_token.secret").getBytes(StandardCharsets.UTF_8));
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(access_token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

채팅간 사용자 인증 로직을 구현하려 했으나 일단 기본적인 코드 구현 뒤 인증/인가 작업은 마지막에 할 예정이다.

Repository 구현

CahtRepository.java

public interface ChatRepository extends JpaRepository<Chat, Long>, ChatRepositoryCustom {
}

ChatRepositoryCustom

public interface ChatRepositoryCustom {
    Page<ResponseChat> getChats(Pageable pageable);
}

ChatRepositoryImpl

public class ChatRepositoryImpl implements ChatRepositoryCustom {

    private JPAQueryFactory queryFactory;

    public ChatRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public Page<ResponseChat> getChats(Pageable pageable) {
        List<ResponseChat> content = queryFactory
                .select(new QResponseChat(
                        chat.id,
                        chatRoom.id.as("chatRoomId"),
                        chat.senderId,
                        chat.message,
                        chat.sendTime
                ))
                .from(chat)
                .leftJoin(chat.chatRoom, chatRoom)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Chat> countQuery = queryFactory
                .selectFrom(chat)
                .leftJoin(chat.chatRoom, chatRoom);

        return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetch().size());
    }
}

채팅 조회 같은 경우 모든 데이터를 한번에 불러오지 않고 Pagination을 통해 불러올 수 있도록 구현했다.

ChatRoomRepository.java

@Repository
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {

    List<ChatRoom> findByClubId(Long clubId);
}

Service 코드 구현

ChatService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class ChatService {
    private final ChatRepository chatRepository;
    private final ChatRoomRepository chatRoomRepository;

    @Transactional
    public void saveMessage(ChatDto chat) {
        ChatRoom chatRoom = chatRoomRepository.findById(chat.getChatRoomId())
                .orElseThrow(() -> new NoSuchElementException("ChatRoom not found with id: " + chat.getChatRoomId()));

        chatRepository.save(chat.toEntity(chatRoom));
    }

    public Page<ResponseChat> getChats(Pageable pageable) {
        return chatRepository.getChats(pageable);
    }
}

ChatRoomService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class ChatRoomService {
    private final ChatRoomRepository chatRoomRepository;

    @Transactional
    public Long createChatRoom(RequestChatRoom requestChatRoom) {
        ChatRoom result = chatRoomRepository.save(requestChatRoom.toEntity());
        return result.getId();
    }

    public ResponseChatRoom getChatRoom(Long clubId) {
        List<ChatRoom> chatRoomList = chatRoomRepository.findByClubId(clubId);

        return ResponseChatRoom.builder().chatRoomList(chatRoomList).build();
    }

    @Transactional
    public void deleteChatRoom(Long chatRoomId) {
        chatRoomRepository.deleteById(chatRoomId);
    }
}

DTO 코드 구현

ResponseChat.java

@Getter
@NoArgsConstructor
public class ResponseChat {
    private Long id;
    private Long chatRoomId; // 방 번호
    private Long senderId;
    private String message;
    private LocalDateTime sendTime;

    @QueryProjection
    public ResponseChat(Long id, Long chatRoomId, Long senderId, String message, LocalDateTime sendTime) {
        this.id = id;
        this.chatRoomId = chatRoomId;
        this.senderId = senderId;
        this.message = message;
        this.sendTime = sendTime;
    }
}

ResponseChat 같은 경우 Querydsl을 통한 페이지네이션 쿼리 사용시 select(프로젝션)를 바로 DTO로 가져올 것이기 때문에 @QueryProjection을 추가해줬다.

RequestChatRoom

@Getter
@NoArgsConstructor
public class RequestChatRoom {
    @NotBlank
    private Long masterId;

    @NotBlank
    private String name;

    @NotBlank
    private Long clubId;

    @Builder
    public RequestChatRoom(Long masterId, String name, Long clubId) {
        this.masterId = masterId;
        this.name = name;
        this.clubId = clubId;
    }

    public ChatRoom toEntity() {
        return ChatRoom.builder()
                .masterId(masterId)
                .name(name)
                .clubId(clubId)
                .build();
    }
}

ResponseChatRoom

@Getter
@NoArgsConstructor
public class ResponseChatRoom {

    List<ChatRoomDto> chatRoomDtoList;

    @Builder
    public ResponseChatRoom(List<ChatRoom> chatRoomList) {
        List<ChatRoomDto> chatRoomDtoList = chatRoomList.stream()
                .map(chatRoom -> ChatRoomDto.builder()
                        .chatRoomId(chatRoom.getId())
                        .masterId(chatRoom.getMasterId())
                        .name(chatRoom.getName())
                        .clubId(chatRoom.getClubId())
                        .build())
                .collect(Collectors.toList());

        this.chatRoomDtoList = chatRoomDtoList;
    }
}

ChatDto.java

@Getter
@NoArgsConstructor
public class ChatDto {

    private Long chatRoomId; // 방 번호
    private Long senderId;
    private String message;
    private LocalDateTime sendTime;

    public Chat toEntity(ChatRoom chatRoom) {
        return Chat.builder()
                .chatRoom(chatRoom)
                .senderId(senderId)
                .message(message)
                .build();
    }
}

ChatRoomDto.java

@Getter
@NoArgsConstructor
public class ChatRoomDto {
    private Long chatRoomId; // 채팅방 아이디

    private Long masterId;

    private String name;

    private Long clubId;

    @Builder
    public ChatRoomDto(Long chatRoomId, Long masterId, String name, Long clubId) {
        this.chatRoomId = chatRoomId;
        this.masterId = masterId;
        this.name = name;
        this.clubId = clubId;
    }
}

DTO 코드 같은 경우도 변화가 있다. 원래 @Data을 사용했었으나 @Setter는 최대한 지양하기로 하였다. 또한 DTO <-> Entity간 매핑을 ModelMapper를 사용했었으나, DTO에서 자바코드로 매핑을 시켜주는 것으로 변경했다.
아직 어떤게 최선의 방법인지는 잘 모르겠으나 확실히 이전 코드보다는 깔끔해졌다는게 느껴진다!

Controller 구현

ChatController.java

@RestController
@RequiredArgsConstructor
public class ChatController {
    // 메시지 브로커와 상호작용하여 WebSocket 메시지를 전송하는 데 사용
    private final SimpMessagingTemplate messagingTemplate;
    private final ChatService chatService;

    // /chat/send 엔드포인트로 들어오는 WebSocket 메시지를 처리한다.
    @MessageMapping("/chat/send")
    public void sendMessage(@Payload ChatDto chat) {
        // 채팅 저장
        chatService.saveMessage(chat);
        // 해당 채팅 메시지를 WebSocket 토픽(/topic/채팅방ID)에 전송하여 클라이언트에게 브로드캐스팅한다.
        messagingTemplate.convertAndSend("/topic/" + chat.getChatRoomId(), chat);
    }

    @GetMapping("/chat")
    public ResponseEntity<Page<ResponseChat>> getChats(Pageable pageable) {
        Page<ResponseChat> chatPage = chatService.getChats(pageable);

        return ResponseEntity.status(HttpStatus.OK).body(chatPage);
    }
}

ChatRoomController.java

@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/room")
public class ChatRoomController {

    private final ChatRoomService chatRoomService;
    private final Environment env;

    @PostMapping("/")
    public ResponseEntity<Long> createChatRoom(@RequestBody RequestChatRoom requestChatRoom) {
        Long chatRoomId = chatRoomService.createChatRoom(requestChatRoom);

        return ResponseEntity.status(HttpStatus.CREATED).body(chatRoomId);
    }

    @GetMapping("/{club_id}")
    public ResponseEntity<ResponseChatRoom> getChatRoom(@PathVariable("club_id") Long clubId) {
        ResponseChatRoom responseChatRoom = chatRoomService.getChatRoom(clubId);

        return ResponseEntity.status(HttpStatus.OK).body(responseChatRoom);
    }


    @DeleteMapping("/{chat_room_id}")
    public ResponseEntity<Void> deleteChatRoom(@PathVariable("chat_room_id") Long chatRoomId) {
        chatRoomService.deleteChatRoom(chatRoomId);

        return ResponseEntity.noContent().build();
    }
}

🚪 마무리

채팅서비스의 기초기반이 되는 코드는 구현이 완료됐다!

💡 나중에 추가할만한 것들

  • 현재 delete API인 경우 아무나 삭제가 가능하다.
    -> masterId인 사람만 삭제할 수 있도록 수정(인가)
  • 채팅시 특별한 사용자 인증 로직이 없다.
    -> jwt를 통한 사용자 인증 로직 추가

이제 화상채팅을 구현해보자....!

profile
기록을 통해 성장합니다.

0개의 댓글