본문 바로가기
Spring/WebSocket

[Spring WebSocket] MongoDB Collection 설계 With Auto-Incremented Sequence

by 진꿈청 2024. 11. 10.

2024.11.10 - [Spring/WebSocket] - [Spring WebSocket] STOMP를 활용한 채팅 서비스 토이 프로젝트

 

[Spring WebSocket] STOMP를 활용한 채팅 서비스 토이 프로젝트

`WebSocket`, `SockJS`, `STOMP`를 학습하고 직접 실습해보는 프로젝트를 구상을 했다.개념을 학습하는 것과 직접 코드로 구현하는 것은 완전히 다른 영역이기에 실습해보는 것이 중요하다고 생각한다.

hdbstn3055.tistory.com

 

이전 포스팅에서 설명한 것처럼 나는 `MongoDB`를 사용하기로 결정했다.

 

유저와 일반 채팅 저장에서도..

 

 

`MongoDB`는 관계형 데이터베이스와 같이 네이티브 조인을 지원하지 않는다.

 

물론, 집계 파이프라인에서 `LeftOuterJoin`을 수행하는 `$loockup` 연산자를 제공한다고는 한다.

(하지만, 전통적인 조인에 비해 효율성이 떨어진다.)

 

 

일반 채팅 메시지 `Collection`의 경우 `MongoDB`의 주종목이기에 `Collection` 구성이 어렵진 않았다.

 

하지만, 나의 토이 프로젝트에 구성에서 유저와 일반 채팅의 경우 JPA로 개념으로 설명하자면 다대다 관계를 갖는다.

`MongoDB`에서는 `@DbRef`를 사용해서 여러 연관관계를 맺을 수 있는 것 같던데 뭔가 그렇게까지 할 필요는 없어보였다.

 

아래에서 설명하겠지만 나는 그냥 서로(User, Normal(일반 채팅))의 `id` 값을 그냥 `Array` 형태로 저장하는 것으로 하였다.

 

왜냐?

 

엄청나게 복잡한 연관 관계로 얽혀있는 것도 아니며,

MongoDB는 엄청난 빈도로 읽기/쓰기 연산을 해도 좋은 `Performance`를 갖는 친구기 때문이다.

 

즉, `embedded` 방식보다는 `Reference` 방식에 가까운 설계라고 보면 된다.

 

 

STOMP Study의 Collection 구성

 

User

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Document(collection = "users")
public class User extends BaseModel {

    @Transient
    public static final String SEQUENCE_NAME = "users_sequence";

    @Id
    private Long userId;

    @Indexed(unique = true)
    private String loginId;

    @Field
    private String nickName;

    @Field
    private String profileImg;

    @Field
    private String name;

    @Field
    private String password;

    @Indexed(unique = true)
    private String userCode;

    @Field
    private Boolean isDeleted = Boolean.FALSE;

    @Field
    private RoleType roleType;

    @Field
    private List<Long> chatIds = new ArrayList<>();


    public static User from(UserCreateRequest request) {
        User user = new User();

        user.initUser(request);

        return  user;
    }

    private void initUser(UserCreateRequest request) {
        this.loginId = request.getLoginId();
        this.nickName = request.getNickName();
        this.name = request.getName();
        this.profileImg = request.getProfileImg();
        this.password = request.getPassword();
        this.roleType = RoleType.USER;
        this.setCreatedAt(LocalDateTime.now());
        this.setModifiedAt(LocalDateTime.now());
        this.userCode = RandomUtil.generateRandomCode(12);
    }

    public void modify(UserModifyRequest request) {
        this.nickName = request.getNickName();
        this.name = request.getName();
        this.profileImg = request.getProfileImg();
        this.setModifiedAt(LocalDateTime.now());
    }

    public void modifyPassword(String newPassword){
        this.password = newPassword;
    }

    public void delete(){
        this.isDeleted = Boolean.TRUE;
        this.setModifiedAt(LocalDateTime.now());
    }

    public void generateSequence(Long userId){
        this.userId = userId;
    }


    public void addChat(Normal normal) {
        this.getChatIds().add(normal.getNormalId());
        normal.getUserIds().add(this.getUserId());
    }
}

 

`SEQUENCE_NAME`은 아래에서 설명하겠지만 `MongoDB`에서
`GeneratedValue` 타입 중 하나인 `Identity` 전략을 모방하기 위한
`Auto-Incremented Sequence` 구성을 위해 사용되는 필드이다.

 

`@Transient` 어노테이션을 사용하면 DB에 저장되지 않는다.

 

코드를 보면 알 수 있듯이 `loginId`와 `userCode`에 `@Indexed(unique = true)`를 설정하였다.

 

 

이유는 로그인 아이디와 채팅방 초대를 위한 유저 코드는 고유해야 하기 때문이다.

 

특히, `userCode` 생성 시 RandomUtil을 사용했다. 중복이 우려될 수 있지만 아래 코드를 살펴보면 

가능한 조합의 수는 `70^12`로 충돌 가능성은 실질적으로 무시해도 될 수준이다.

 

public class RandomUtil {
    private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$";
    private static final String ALPHANUMERIC_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    private static final SecureRandom random = new SecureRandom();

    public static String generateRandomCode(int length) {
        StringBuilder builder = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            builder.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
        }
        return builder.toString();
    }

    public static String generateAlphaNumericRandomCode(int length) {
        StringBuilder builder = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            builder.append(ALPHANUMERIC_CHARACTERS.charAt(random.nextInt(ALPHANUMERIC_CHARACTERS.length())));
        }
        return builder.toString();
    }
}

 

 

그러나, 여기서 중요한 점은 `MongoDB`는 `Spring`에서 인덱스를 설정하는 것을 `MongoDB 3.0` 버전부터 막기로 했다.

 

 

2번째 설명을 보면 기본 설정으로 `Auto-index creation`이 `disable` 되었다고 한다.

 

컬렉션 라이프 사이클과 퍼포먼스에 영향을 주는 것을 막기 위함이라고 한다.

 

 

그래서 반드시 명시적으로 `enable` 해주어야 한다고 한다.

 

그래서 명시적으로 `enable` 해주기 위해서는 아래와 같이 `application.yml`을 설정하면 된다고 한다.

 

spring:
  data:
    mongodb:
      auto-index-creation: true

 

그래서 위와 같이 설정하면 놀랍게도 안된다.

 

 

그래서 그냥 직접 `MongoDB`에 접속해 인덱스 설정을 해주는 것으로 하였다.

# User의 userCode에 관한 유니크 인덱스 추가
db['users'].createIndex({userCode: 1}, {unique: true})

# User의 loginId에 관한 유니크 인덱스 추가
db['users'].createIndex({loginId: 1}, {unique: true})

 

 

Normal(일반 채팅)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Normal extends BaseModel {

    @Transient
    public static final String SEQUENCE_NAME = "normal_sequence";

    @Id
    private Long normalId;

    @Field
    private String normalChatName;

    @Field
    private List<Long> userIds = new ArrayList<>();

    @Field
    private Boolean isDeleted = Boolean.FALSE;

    public static Normal from(NormalCreateRequest request) {
        Normal normal = new Normal();

        normal.initNormal(request);

        return normal;
    }

    private void initNormal(NormalCreateRequest request){
        this.normalChatName = request.getNormalChatName();
        this.setCreatedAt(LocalDateTime.now());
        this.setModifiedAt(LocalDateTime.now());
    }


    public void modifyChatName(String normalChatName) {
        this.normalChatName = normalChatName;
        this.setModifiedAt(LocalDateTime.now());
    }

    public void generateSequence(Long normalId){
        this.normalId = normalId;
    }


    public void removeUser(User user) {
        this.userIds.remove(user.getUserId());
        user.getChatIds().remove(normalId);
    }
}

 

`Normal`의 경우 간단하다.

 

근데 여기서 중요한 점은 아까 설명한 것처럼 서로의 `Id`를 `List<Long>` 형태로 Reference 한다는 것을 집중해서 보면 된다.

 

그리고 Normal과 User 모두 `Soft delete`를 위해 `isDelete` 필드를 추가해주었다.

 

 

여기서 주의할 점은 `MongoDB`는 `Dirty Check`를 해서 자동으로 업데이트 쿼리를 날려주지는 않는다.

그래서, 그냥 값을 수정을 했다면 아래처럼 다시 저장해주면 된다.

 

@Override
    public NormalInfoResponse modify(NormalModifyRequest request) {
        Normal normal = validateNormal(request.getNormalId());

        normal.modifyChatName(request.getNormalChatName());

        return NormalInfoResponse.from(normalRepository.save(normal));
    }

 

 

 

NormalMessage

 

일반 채팅 메시지를 저장하기 위한 Collection이라고 보면 된다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Document(collection = "normal_messages")
public class NormalMessage extends BaseModel {

    @Transient
    public static final String SEQUENCE_NAME = "normal_messages_sequence";

    @Id
    private Long messageId;

    @Field
    private Long normalId;

    @Field
    private Long userId;

    @Field
    private String writer;

    @Field
    private String content;

    @Field
    private String profileImg;

    @Field
    private ChatType chatType;

    @Field
    private ActionType actionType;

    @Field
    private List<UploadFile> files;

    @Field
    private boolean isDeleted = Boolean.FALSE;

    public static NormalMessage from(NormalMessageCreateRequest request) {
        NormalMessage normalMessage = new NormalMessage();

        normalMessage.initNormalMessage(request);

        return normalMessage;
    }

    private void initNormalMessage(NormalMessageCreateRequest request) {
        this.normalId = request.getNormalId();
        this.userId = request.getUserId();
        this.profileImg = request.getProfileImg();
        this.writer = request.getWriter();
        this.content = request.getContent();
        this.chatType = ChatType.NORMAL;
        this.actionType = ActionType.SEND;
        this.files = request.getFiles();
        this.setCreatedAt(LocalDateTime.now());
        this.setModifiedAt(LocalDateTime.now());
    }

    public void generateSequence(Long messageId){
        this.messageId = messageId;
    }

    public void modify(String content) {
        this.content = content;
        this.actionType = ActionType.MODIFY;
        this.setModifiedAt(LocalDateTime.now());
    }

    public void delete() {
        this.isDeleted = Boolean.TRUE;
        this.setModifiedAt(LocalDateTime.now());
    }
}

 

 

ChatType

 

추후 경매 채팅도 추가되면 `NORMAL` 외에도 추가 될 것이다.

@Getter
@RequiredArgsConstructor
public enum ChatType {

    NORMAL("기본 채팅 메시지");

    private final String type;
}

 

 

ActionType

 

추후에 포스팅하겠지만 우리는 이벤트 브로커로 `Kafka`를 적용했다.

그때, 일반 채팅방을 구독중인 클라이언트들에게 메시지의 상태를 전달하기 위해 사용하는 `Enum` 타입이다.

@Getter
@RequiredArgsConstructor
public enum ActionType {

    SEND("전송"), MODIFY("수정"), DELETE("삭제"), TYPING("타이핑");

    private final String type;
}

 

 

`NormalMessage`는 `normalId`를 `Reference` 한다.

 

이는 일반 채팅방 조회시 채팅 목록을 편하게 가져오기 위함이다.

 

 

Auto-Incremented Sequence 구성

스프링부트에서 `MongoDB`를 사용할 때 `@GeneratedValue`를 사용할 수 없다.

즉, `AUTO_INCREMENT` 사용에 제약이 있다.

 

따라서, `JPA`와 `SQL DB`를 사용할 때와 같은 효과를 낼 수 있는 방법이 필요했다.

 

관련해서는 아래 사이트에서 확인이 가능하다.

 

https://www.baeldung.com/spring-boot-mongodb-auto-generated-field

 

 

요약하자면, 다른 컬렉션(테이블)에서 생성된 시퀀스를 저장할 컬렉션(테이블)을 만든다.

그리고 새로운 `Document(레코드)`를 생성할 때, 이 컬렉션을 사용하여 다음 값을  가져온다.

 

 

코드와 함께 알아보자.

 

코드 예시

 

AutoIncrementSequence

 

다른 컬렉션을 위한 `auto-incremented sequence`를 저장할 컬렉션을 생성한다.

@Getter
@Setter
@Document(collection = "auto_sequence")
public class AutoIncrementSequence {

    @Id
    private String id;
    private Long seq;
}

 

 

NormalMessage 활용 예

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Document(collection = "normal_messages")
public class NormalMessage extends BaseModel {

    @Transient
    public static final String SEQUENCE_NAME = "normal_messages_sequence";

    @Id
    private Long messageId;

    @Field
    private Long normalId;

    @Field
    private Long userId;

    @Field
    private String writer;

    @Field
    private String content;

    @Field
    private String profileImg;

    @Field
    private ChatType chatType;

    @Field
    private ActionType actionType;

    @Field
    private List<UploadFile> files;

    @Field
    private boolean isDeleted = Boolean.FALSE;

    ...
}

 

`SEQUENCE_NAME`에 정적 필드를 추가했는데 이 값은 곧,

컬렉션에 관한 자동 증가 시퀀스를 고유하게 참조하는 필드가 된다.

 

그리고 모델의 다른 필드(속성)과 함께 영속화되지 않도록 하기 위한 `@Transient` 어노테이션을 사용한다.

`@Transient` 어노테이션은 해당 필드를 데이터베이스에 저장하지 않도록 지정해준다.

(저장하거나 조회할 때 포함되지 않음)

 

 

SequenceGenerator

 

지금까지 필요한 컬렉션과 모델을 생성했다.

그렇다면 이젠 엔티티의 `id`로 사용할 `auto-incremented` 값을 생성하는 `Generator`를 만들어야 한다.

 

@Component
@RequiredArgsConstructor
public class SequenceGenerator {

    private final MongoOperations mongoOperations;

    /**
     * 1. "_id"가 seqName인 문서를 찾습니다.
     * 2. "seq" 값을 1 증가시킵니다.
     * 3. 새 값을 반환하고, 문서가 없으면 생성합니다. -> returnNew(true) - 업데이트 이후의 새로운 값 반환, upsert(true) - _id가 seqName인 문서가 존재하지 않으면 새 문서 생성, 이 경우 기본 시퀀스 1
     * 4. 결과를 AutoIncrementSequence 객체로 반환합니다.
     * 5. 시퀀스 값 또는 기본값 1을 반환합니다.
     */
    public Long generateSequence(String seqName) {
        AutoIncrementSequence counter = mongoOperations.findAndModify(query(where("_id").is(seqName)),
                new Update().inc("seq", 1), options().returnNew(true).upsert(true), AutoIncrementSequence.class);

        return !Objects.isNull(counter) ? counter.getSeq() : 1;
    }

}
  1. `_id`가 `seqName`인 문서를 찾는다.
  2. `seq` 값을 1 증가시킨다.
  3. 새 값을 반환하고, 문서가 없다면 생성한다.
    1. returnNew(true) - 업데이트 이후의 새로운 값 반환
    2. upsert(true) - `_id`가 `seqName`인 문서가 존재하지 않으면 새 문서 생성(이 경우 기본 시퀀스 1 설정)
  4. 결과를 `AutoIncrementSequence` 객체로 반환
  5. 시퀀스 값 또는 기본값 1을 반환

 

실제 사용

@Slf4j
@Service
@RequiredArgsConstructor
public class NormalMessageCommandServiceImpl implements NormalMessageCommandService {

    private final SequenceGenerator sequenceGenerator;
    private final NormalMessageRepository messageRepository;
    private final NormalRepository normalRepository;

    @Override
    @Transactional
    public void save(NormalMessageCreateRequest request) {
        validateUserInNormal(request.getNormalId(), request.getUserId());
        NormalMessage normalMessage = NormalMessage.from(request);

        normalMessage.generateSequence(sequenceGenerator.generateSequence(NormalMessage.SEQUENCE_NAME));

        NormalChatCreateEvent chatCreateEvent =
                NormalChatCreateEvent.from(messageRepository.save(normalMessage), UUIDUtil.generateUUID());

        Events.send(chatCreateEvent);
    }
    
    ...
}

 

 

NormalMessage

public void generateSequence(Long messageId){
    this.messageId = messageId;
}

 

 

MongoDB 저장

 

결론적으로, 우리는 `id` 필드에 관해 순차적으로 자동 증가 값을 생성하고,

`MongoDB`에서도 `SQL` 데이터베이스처럼 일반적인 자동 증가 `id` 생성을 구현할 수 있게 된다.

 

 

 

코드를 확인하고 싶다면?

https://github.com/HanYoonSoo/STOMP-Study

 

GitHub - HanYoonSoo/STOMP-Study

Contribute to HanYoonSoo/STOMP-Study development by creating an account on GitHub.

github.com