도입 계기

현재 지난 부트캠프 때 미니 프로젝트로 진행한 캠핑온탑   프로젝트를 리팩토링하고 있다.

이번 스프린트 때 구매자와 판매자 간의 실시간 일대일 채팅을 구현하려고 한다.
현재 MYSQL 데이터베이스만 사용하고 있는데
웹소켓, STOMP 등을 사용해서 실시간 채팅을 할 수 있게 구현한다고 하더라도
MYSQL 데이터베이스를 그대로 사용하는 것이 적절한 것인가에 대한 의문이 들었다.

실시간성이 가장 중요한데, 채팅을 주고 받는 과정에서 SQL 문이 계속 실행된다면
서버에 큰 부담을 줄 수 있기 때문이다.

또한 MYSQL, 나아가 SQL은 실시간성 보다는 데이터의 안정성을 더 중요시하기 때문에
속도의 측면에서도 당연히 빠르지 않을 것이라는 생각이 들었다.

게다가 채팅 기능을 포함한 이번 스프린트가 마무리되면,
최종적으로 테스트한 후 수정된 버전을 배포한 후에 MSA리팩토링을 할 것이기 때문에
어차피 데이터베이스를 분리해야했다.

그래서 JSON 형태로 데이터를 빠르게 저장하고 조회할 수 있는 NOSQL 데이터베이스를 적용하기로 했다.



NOSQL 비교

여러 NOSQL 데이터베이스가 있지만,
내가 가장 중요하게 생각한 것은 무료 클라우드 데이터베이스였다.

지금 AWSEC2에 배포를 해서 서비스를 운영하고 있는데,
프리티어 계정이기 때문에 EC2, RDS를 각각 1대씩 사용하고 있다.

그러면 채팅 데이터베이스를 NOSQL로 해야하는데,
새롭게 데이터베이스를 AWS에서 생성할 수도 없고,
EC2에는 이미 REDISjar, 그리고 Nginx가 실행되고 있기에 더 이상 프로그램을 실행시킬 수 없었다.

그래서 처음에는 Google FirebaseRealtime Database를 사용하려고 했었다.
왜냐하면 학부 시절 진행한 캡스톤 프로젝트에서 PHP로 실시간 채팅을 구현한 적이 있었는데,
그 때, FirebaseRealtime Database를 사용했었기 때문이다.
어느 정도의 성능을 보이는지 직접 확인을 했었기 때문에 의심할 여지가 없었다.

그래서 이번에도 사용한 경험이 있는 Firebase 데이터베이스를 사용하려고 했다.

하지만 이미 검증된, 그리고 잘 알고 있는 방식도 좋지만,
최근에 정말 많이 사용되고 있는 MongoDB를 사용하고 싶었다.

취업 공고를 찾아보다 보면 우대사항MongoDB를 사용해 본 경험이 있는
개발자를 찾는다는 공고를 어렵지 않게 볼 수 있다.
즉, 현업에서 MongoDB를 많이 사용한다는 뜻이므로 취준하고 있는 주니어 개발자의 입장에서
다루어보고 싶은 의지가 샘솟았다.

이전부터 꼭 한 번 사용해봐야겠다는 생각을 했었지만, 마땅한 기회가 없었다.

하지만 이번에 MongoDB를 도입하여 채팅 기능을 구현하면서
NOSQL과 관련한 공부를 하고, 경험을 쌓을 좋은 기회라고 생각하여
최종적으로 MongoDB를 도입하기로 결정했다.

게다가 찾아보니 클라우드로 된 MongoDB Atlas가 있어서
무료 계정으로 가입하면 512MB까지 무료로 사용할 수 있었다.

내가 생각한 2가지조건을 모두 충족했기 때문에

  • 클라우드
  • 무료

바로 연동에 돌입했다.



MongoDB Atlas 가입 및 설정

먼저 MongoDB Atlas에 접속해 회원가입 후 Cluster를 만들어준다.
평생 무료?인 M0을 선택한다.

Drivers 클릭하면 Java가 나오는데 클릭하고 기본 설정대로 생성한다.


Cluster를 생성했으므로 이제 데이터베이스를 생성한다.

Browse Collections 버튼을 클릭하고

Create Database를 클릭하여 데이터베이스를 생성한다.


SpringBoot 연동

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

이제 데이터베이스를 생성했으니까 SpringBoot에 연동시키기 위해
pom.xmldependency를 추가한다.


spring:
  data:
    mongodb:
      uri: ${MONGODB_URI}

이제 application.yml에 위 설정을 추가해주고
데이터베이스를 생성할 때 받은 URI를 붙여넣기를 하면 된다.

단, ? 앞까지만 복사해서 붙여넣기를 해야 한다.

mongodb+srv://{MONGO_ID}:{MONGODB_PW}@{MONGODB_CLUSTER_URL}/{MONGODB_NAME}?retryWrites=true&w=majority&appName={PROJECT_NAME}

이 과정에서 한참 에러가 발생했다.

만약 비밀번호에 특수문자가 있다면 (ex.> @, :, ...)
아래 사이트에서 인코딩해서 그 값으로 넣어주어야 한다.

👉 encoding 하러 가기

근데 사실 생각해보면 URI이니까 당연히 특수문자가 들어가면 안되는 거였는데, 전혀 생각지도 못했다. 🤣


그리고 SpringBoot 애플리케이션에서 MySQLMongoDB를 동시에 사용하고자 할 때는
각각의 데이터베이스 관련 설정, 리포지토리, 엔티티를 분리하여 관리해야 한다.

이를 위해 패키지 구조를 적절히 조직하고,
Spring Data JPASpring Data MongoDB의 설정을 명확히 분리하는 것이 중요하다.


com.challenge.chat
├── config
├── domain
│   ├── mysql
│   │   ├── model
│   │   └── repository
│   └── mongodb
│       ├── model
│       └── repository
└── service

패키지 구조를 분리하지 않았다면,
위와 같은 구조로 먼저 MySQLMongoDB를 위한 도메인리포지토리를 분리하는 것이 좋다.


나는 추후 MSA로 전환하는 작업을 염두해 두었기 때문에 위와 같이 구성했었는데,
모두 아래와 같은 구조로 전환했다.


그리고 SpringApplicationannotation을 추가해야 한다.

기존에는 MYSQL 데이터베이스 한 개만 사용했었지만
이제 MongoDB까지 2개의 데이터베이스를 사용하는 것이기 때문에
Bean 의존성을 주입하는 과정에서 충돌이 발생하지 않도록 어노테이션을 통해 명확히 설정해주어야 한다.

그래서 @EnableJpaRepositories@EnableMongoRepositories 어노테이션을 사용하여
각 리포지토리 기술이 올바른 패키지를 바라보도록 설정했다.

@SpringBootApplication
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.example.yes.domain.mysql")  
// MySQL repositories
@EnableMongoRepositories(basePackages = "com.example.yes.domain.mongodb.chat")  
// MongoDB repositories
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(CampingOnTopApplication.class, args);
    }
}

모든 설정이 정상적으로 되어 애플리케이션을 실행하면 아래 화면처럼 connection이 열리고 모니터링도 활성화되면서 최종적으로 MongoDB Atlas 데이터베이스와 연동이 완료되었다.



1차 ver

우선 실시간성, 예외 처리, 관련 데이터의 존재 여부 확인 등의 여러 처리들을 모두 제외하고
채팅이 정상적으로 MongoDB저장되는지 확인하기 위해 간단한 버전으로 구현해 테스트를 진행했다.

  • 실시간 채팅 x
  • STOMP 사용 x
  • 단순 저장, 조회 테스트 O
@Configuration
public class DateTimeConfig {

    @Bean
    public DateTimeProvider auditingDateTimeProvider() {
        return () -> Optional.of(LocalDateTime.now(ZoneId.of("Asia/Seoul")));
    }
}

현재 대한민국 서울의 시간으로 날짜를 저장하기 위한 날짜 Config 클래스.


@Configuration
@EnableMongoAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
public class MongoConfig {
}

위에서 만든 날짜 저장 ConfigMongoDB에 반영하기 위한 MongoDB Config 클래스.

@Document(collection = "chats")
@Getter
@Setter
@Data
@Builder
public class Chat {
    @Id
    private String id;

    private String senderId;
    private String senderNickname;
    private String recipientId;
    private String recipientNickname;
    private String roomId;
    private String message;

    @CreatedDate
    private LocalDateTime date;

    public ChatDto toDto() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss");
        return ChatDto.builder()
                .senderId(getSenderId())
                .senderNickname(getSenderNickname())
                .recipientId(getRecipientId())
                .recipientNickname(getRecipientNickname())
                .message(getMessage())
                .date(getDate().format(formatter))
                .build();
    }
}

채팅을 저장할 클래스.
NOSQL이므로 Document 개념을 사용한다 (SQL의 Entity 개념)


@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatDto {
    private String senderId;
    private String senderNickname;
    private String recipientId;
    private String recipientNickname;
    private String message;
    private String date;
}

채팅을 저장하고 조회할 때 사용할 DTO 클래스


public interface ChatRepository extends MongoRepository<Chat, String> {
    List<Chat> findByRoomId(String roomId);
}

JpaRepository가 아니라 MongoRepository 사용


@Service
@RequiredArgsConstructor
public class ChatService {
    private final ChatRepository chatRepository;

    public ChatDto saveChat(ChatDto chatDto) {
        String roomId = UUID.randomUUID().toString();

        Chat chat = Chat.builder()
                .senderId(chatDto.getSenderId())
                .senderNickname(chatDto.getSenderNickname())
                .recipientId(chatDto.getRecipientId())
                .recipientNickname(chatDto.getRecipientNickname())
                .roomId(roomId)
                .message(chatDto.getMessage())
                .build();

        chat = chatRepository.save(chat);

        return chat.toDto();
    }

    public List<ChatDto> getChatsByRoomId(String roomId) {
        List<Chat> chats = chatRepository.findByRoomId(roomId);
        List<ChatDto> chatDtos = new ArrayList<>();
        for (Chat chat : chats) {
            chatDtos.add(chat.toDto());
        }

        return chatDtos;
    }
}

기본적으로 저장, 조회만 우선 가능한지 확인하기 위한 간단한 버전의 Service 클래스.
채팅방마다 고유의 ID값을 가지고 있어야 하기 때문에 UUID로 설정했다.


@RestController
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatController {
    private final ChatService chatService;

    @PostMapping("/send")
    public ChatDto sendChat(@RequestBody ChatDto chatDto) {
        return chatService.saveChat(chatDto);
    }

    @GetMapping("/room/{roomId}")
    public List<ChatDto> getChatsByRoomId(@PathVariable String roomId) {
        return chatService.getChatsByRoomId(roomId);
    }
}

마지막으로 Rest API로 사용하기 위해 클라이언트로부터 응답을 받아 처리할 Controller 클래스



📮 테스트

포스트맨으로 가볍게 테스트를 진행했다.

DTO에 맞게 JSON Body를 구성해 채팅 전송 요청을 POST 통신으로 보내어

위와 같이 정상적으로 MongoDB에 저장된 모습을 확인했다.

그리고 저장된 채팅 데이터를 조회하기 위해 고유의 roomId로 조회를 시도했고
위 결과와 같이 정상적으로 저장된 데이터가 정확히 조회가 된 모습을 확인했다.



지금까지 한 작업은 서두에 언급했듯이
SpringBoot 백엔드 서버와 MongoDB Atlas 클라우드 데이터베이스를 연결하여
동작 여부를 테스트한 것이다.


나는 이번 스프린트에서 실시간으로 일대일 채팅을 할 수 있는 기능을 구현하는 것이 목표이다.
위 구성으로는 실시간으로 메시지를 교환하는 기능을 직접 제공하지는 않아 실시간성을 보장할 수 없다.

실시간 채팅 기능을 구현하기 위해서는 WebSocket과 같은 프로토콜을 사용하여
클라이언트와 서버 간의 지속적인 연결을 유지하고 메시지를 실시간으로 교환할 수 있어야 한다.


STOMP를 사용하면 클라이언트와 서버 간에 메시지 기반 통신을 쉽게 구현할 수 있으며,
SpringBoot에서는 spring-boot-starter-websocket 라이브러리를 통해 STOMP를 지원한다.


따라서 다음 포스팅에서 웹소켓,STOMP 등의 설정을 추가하여
실시간 채팅 을 어떻게 구현했는지 소개할 예정이다.



profile
Web Developer

1개의 댓글

comment-user-thumbnail
2024년 12월 11일

감사합니다 잘 배웠습니다

답글 달기