MongoDB 프로젝트 도입

Mugeon Kim·2023년 8월 16일
0

1. 서론


  • 프로젝트를 진행하면서 처음으로 MongoDB 도입을 하면서 학습한 내용을 정리하고 고민한 내용을 정리를 하였습니다.

  • 처음으로 MongoDB를 사용하며 처음 접하는 내용이 많아 학습해야 되는 부분이 많다고 느끼게 되었습니다.

  • 이번 프로젝트에서 MongoDB를 도입하는 이유는 CStudy 프로젝트를 진행하면서 일반 문제를 풀었을 때 데이터를 저장을 했습니다.

  • 많은 데이터가 있는 모델링에 유용하지 않다고 생각하여 이번에 MongoDB를 배우면서 유연한 Modeling이 가능한 장점을 사용하면 문제를 해결할 수 있다고 생각하여 도입을 하였습니다.

2. 본론


2-2. MongoDB

  • 이전 MongoDB 정리글

https://velog.io/@geon_km/MongoDB-%EC%B2%AB-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%8F%84%EC%9E%85%EA%B8%B0

  • member_question 테이블은 문제를 풀었을 때 question_success에 선택한 번호를 삽입하고 question_fail에 0을 넣습니다. 반대로 실패를 하였을 때 question_fail에 오답인 번호를 삽입하고 question_success에 0을 넣습니다.

  • MongoDB를 도입한 이유는 오답노트를 만들었는데 MySQL을 선택하면 다음과 같은 조건이 필요합니다.

  1. 회원 동등 비교 -> 2. 성공 실패 유무 확인 -> (정답 확인) 문제를 조인해서 정답을 조회를 해야된다.
  • member_question의 데이터가 제일 많을거라고 생각을 했습니다. 왜냐하면 100명의 회원이 100개씩 문제를 푼다면 가장 많은 데이터가 있는 장소는 member_question이라고 생각을 했습니다.

2-2-1. MongoDB DataModel

@Component
@Getter
@Document(collection = "reviewUser")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ReviewUser {
    @Id
    private String userName;

    private List<String> successQuestion;

    private List<String> failQuestion;

    @DBRef(lazy = true)
    private List<ReviewNote> reviewNotes = new ArrayList<>();

    @Builder
    public ReviewUser(
            String userName,
            List<String> successQuestion,
            List<String> failQuestion,
            List<ReviewNote> reviewNotes
    ) {
        this.userName = userName;
        this.successQuestion = successQuestion;
        this.failQuestion = failQuestion;
        this.reviewNotes = reviewNotes;
    }

}
@Component
@Getter
@Document(collection = "reviewNote")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ReviewNote {
    @Id
    private String id;

    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDateTime createdDate;

    private Long questionId;

    private int successChoiceNumber;
    private int failChoiceNumber;

    private boolean isAnswer;


    @Builder
    public ReviewNote(String id, LocalDateTime createdDate, Long questionId, int successChoiceNumber, boolean isAnswer) {
        this.id = id;
        this.createdDate = createdDate;
        this.questionId = questionId;
        this.successChoiceNumber = successChoiceNumber;
        this.isAnswer = isAnswer;
    }

    public ReviewNote(LocalDateTime createdDate, Long questionId, int failChoiceNumber, boolean isAnswer) {
        this.createdDate = createdDate;
        this.questionId = questionId;
        this.failChoiceNumber = failChoiceNumber;
        this.isAnswer = isAnswer;
    }

    public static ReviewNote createFailNote(LocalDateTime createdDate, Long questionId, boolean isAnswer, int failChoiceNumber) {
        return new ReviewNote(createdDate, questionId,failChoiceNumber,isAnswer);
    }

}
  • 유저와 문제를 1:N 관계를 만들어 한명의 회원이 여러가지 정답 및 오답을 바로 접근할 수 있게 모델링을 하였습니다.

모델링 하면서 생각한 부분

  • 회원을 조회하면 성공, 실패에 대한 데이터를 한번에 조회가 가능하게
  • 오답노트를 만든다면 성공을 한다면 성공의 선택이 번호가 되기 때문에 failNumber에 0을 삽입한다.
  • 실패를 할 경우 오답인 선택을 failNumber에 삽입하고 successNumber에 해당 문제에 대한 정답을 처리한다.
  • 한명의 회원이 여러 개의 문제가 생성이 된다.

  • 이렇게 처리를 하면 한명의 회원이 빠르게 성공, 실패의 문제를 접근할 수 있다. MongoDB의 배열을 이용해서 SuccessQuestion, failQuestion을 배열로 선언하고 reviewNote를 @Many로 설정하여 문제에 대한 Detail 정보를 담을 수 있습니다.

Document

  • @Document는 스프링 데이터 MongoDB에서 사용되는 어노테이션이다. MongoDB의 컬렉션에 저장하는 문서임을 나타내며 클래스에 이 어노테이션을 추가함으로써 Spring Data MongoDB는 해당 클래스의 인스턴스를 MongoDB에 저장하고 조회하는 작업을 쉽게 할 수 있습니다.

  • RDB와 다르게 JSON 타입으로 데이터를 저장을 합니다. 하지만 실제로 데이터는 BSON형식의 문서로 저장합니다.

2-2-2. MongoDB _id Object

  • MongoDB의 아이디를 살펴보면 ObjectId("64dce391b13d587c5a21516e") 오브젝트 아이디로 형태로 저장이 된다.
  • ObjectId는 MongoDB의 내부적인 기능을 사용하여 생성되면 12 바이트로 구성이 됩니다.

4바이트: 타임스탬프(timestamp)
5바이트: 머신 아이디(machine identifier)
3바이트: 프로세스 아이디(process identifier)
4바이트: 랜덤 값(random value)

타임스탬프(timestamp)

  • 생성된 시간을 의미를 합니다.
  • 초단위의 타임스탬프이며 UTC기준으로 저장합니다.
  • 이를 통해 `ObjectId의 생성 순서를 유지하면서도 시간 순서로 문서를 검색이 가능합니다.

머신 아이디(machine identifier)

  • 일반적으로 서버의 MAC 주소를 사용합니다.
  • 기계를 유일하게 식별합니다.
  • 만약에 Mac 주소가 불가능하면 Random값으로 채워진다.

프로세스 아이디(process identifier)

  • 동일한 머신 내에서 여러 프로세스를 구분하기 위해 사용한다.
  • 프로세스 식별자는 각 프로세스마다 유일하게 설정됩니다.

랜덤 값(random value)

  • 이 값은 같은 머신과 프로세스에서 동일한 시간에 ObjectId를 생성할 경우에도 유일성을 보장하기 위해 사용한다.
  • 무작위 값으로 충돌을 피하고 중복을 방지합니다.

UTC (햡정 시계시)

  • UTC란 국제적인 표준 시간의 기준이다. 이것을 사용하는 이유는 모든 나라마다 시간이 다르기 때문에 UTC를 기준으로 설정을 합니다.
  • 이때 1970년 1월 1일 00:00:00 UTC부터 몇 초 경과했는지를 스칼라 실수로 나타낸다.

2-2-3. MongoDB OneToMany

2-3. Spring MongoDB

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

.yml

  data:
    mongodb:
      host: ${host}
      port: 27017
      uri: ${uri}
      username: ${userName}
      password: ${password}

Service

@Slf4j
@Service
public class reviewServiceImpl implements ReviewService {


    private final ReviewUserRepository userRepository;
    private final ReviewNoteRepository reviewNoteRepository;
    private final MemberRepository memberRepository;

    public reviewServiceImpl(
            ReviewUserRepository userRepository,
            ReviewNoteRepository reviewNoteRepository,
            MemberRepository memberRepository
    ) {
        this.userRepository = userRepository;
        this.reviewNoteRepository = reviewNoteRepository;
        this.memberRepository = memberRepository;
    }

    @Override
    @Transactional
    public void createUserWhenSignupSaveMongodb(String userName) {
        userRepository.save(ReviewUser.builder()
                .userName(userName)
                .successQuestion(new LinkedList<>())
                .failQuestion(new LinkedList<>())
                .build());
    }

    @Override
    @Transactional
    public void solveQuestionWithValid(
            long questionId,
            int choiceNumber,
            boolean isAnswer,
            LoginUserDto loginUserDto,
            Integer choiceAnswerNumber
    ) {
        LocalDateTime now = LocalDateTime.now();

        Member member = memberRepository.findById(loginUserDto.getMemberId())
                .orElseThrow(() -> new NotFoundMemberId(loginUserDto.getMemberId()));

        ReviewUser byName = userRepository.findByUserName(member.getName())
                .orElseThrow(RuntimeException::new);

        log.info("reviewUser_Name : {}", byName.getUserName());

        if (isAnswer) {
            byName.getSuccessQuestion().add(String.valueOf(questionId));
        } else {
            byName.getFailQuestion().add(String.valueOf(questionId));
        }

        userRepository.save(byName);

        if (isAnswer) {
            ReviewNote successNote = ReviewNote.builder()
                    .questionId(questionId)
                    .successChoiceNumber(choiceNumber)
                    .createdDate(now)
                    .isAnswer(true)
                    .build();
            reviewNoteRepository.save(successNote);
            byName.getReviewNotes().add(successNote);
        } else {
            ReviewNote failNote = ReviewNote.builder()
                    .questionId(questionId)
                    .successChoiceNumber(choiceAnswerNumber)
                    .failChoiceNumber(choiceNumber)
                    .createdDate(now)
                    .isAnswer(false)
                    .build();
            reviewNoteRepository.save(failNote);
            byName.getReviewNotes().add(failNote);
        }

        userRepository.save(byName);
    }


}

  • 해당 코드는 createUserWhenSignupSaveMongodb는 회원가입을 하였을 때 회원의 데이터를 MongoDB에 저장을 합니다. 이때 처음에는 성공, 실패의 문제가 없기 때문에 기본 값으로 처리를 하였습니다.

  • 이후 solveQuestionWithValid는 문제를 풀었을 때 성공과 실패에 따라 데이터를 삽입을 합니다. 이때 고려할 부분은 오답노트 이기 때문에 성공을 처리를 하였을 때 정답을 처리하고 실패일 때 실패에 대한 데이터만 처리를 하였습니다.

MongoDB Repository / Mongo Template

  • MongoDB Repository
    • Spring Data MongoDB의 일부로 주로 기본적인 crud 작업을 위한 인터페이스를 정의하고 Spring Data MongoDB가 이를 구현하는 클래스를 생성을 합니다.
  • MongoTemplate
    • MongoDB에 대한 다양한 작업을 수행하기 위한 api로 crud작업 이외에 추가적인 작업이 가능합니다. (EX . 집계)
  • 위 2개를 비교를 하였을 때 Jparepository를 사용한 경험과 아직 복잡한 작업이 필요하지 않다고 판단하여 MongoDB Repository를 선택을 하였습니다.
@Override
    @Transactional
    public void choiceQuestion(LoginUserDto loginUserDto, Long questionId, ChoiceAnswerRequestDto choiceNumber) {

        log.warn("정답 info {}", questionRepository.findQuestionWithChoicesAndCategoryById(questionId)
                .orElseThrow(() -> new NotFoundQuestionId(questionId)).getChoices().stream()
                .filter(Choice::isAnswer)
                .map(Choice::getNumber)
                .findFirst().orElseThrow());

        questionRepository.findQuestionWithChoicesAndCategoryById(questionId)
                .orElseThrow(() -> new NotFoundQuestionId(questionId)).getChoices().stream()
                .filter(Choice::isAnswer)
                .forEach(choice -> {
                    if (choice.getNumber() == choiceNumber.getChoiceNumber()) {
                        memberQuestionService.findMemberAndMemberQuestionSuccess(
                                loginUserDto.getMemberId(),
                                questionId,
                                choiceNumber
                        );
                    } else {
                        memberQuestionService.findMemberAndMemberQuestionFail(
                                loginUserDto.getMemberId(),
                                questionId,
                                choiceNumber
                        );
                    }
                });

        questionRepository.findQuestionWithChoicesAndCategoryById(questionId)
                .orElseThrow(() -> new NotFoundQuestionId(questionId)).getChoices().stream()
                .filter(Choice::isAnswer)
                .forEach(choice -> {
                    if (choice.getNumber() == choiceNumber.getChoiceNumber()) {
                        reviewService.solveQuestionWithValid(
                                questionId,
                                choiceNumber.getChoiceNumber(),
                                true,
                                loginUserDto,
                                questionRepository.findQuestionWithChoicesAndCategoryById(questionId)
                                        .orElseThrow(() -> new NotFoundQuestionId(questionId)).getChoices().stream()
                                        .filter(Choice::isAnswer)
                                        .map(Choice::getNumber)
                                        .findFirst().orElseThrow());
                    } else {
                        reviewService.solveQuestionWithValid(
                                questionId,
                                choiceNumber.getChoiceNumber(),
                                false,
                                loginUserDto,
                                questionRepository.findQuestionWithChoicesAndCategoryById(questionId)
                                        .orElseThrow(() -> new NotFoundQuestionId(questionId)).getChoices().stream()
                                        .filter(Choice::isAnswer)
                                        .map(Choice::getNumber)
                                        .findFirst().orElseThrow()
                        );
                    }

                });
        redisPublisher.publish(ChannelTopic.of("ranking-invalidation"), "ranking");
    }
  • 해당 코드는 문제를 선택을 하는 로직을 처리합니다. 이때 문제를 선택하여 문제를 조회를 합니다. 이후 MySQL에 정답, 오답을 삽입를 합니다. 이후 MongoDB에 데이터를 삽입을 합니다.

  • 이후 문제를 풀었을 때 Redis Pub/Sub을 통하여 해당 랭킹 시스템의 캐싱 정합성을 맞추기 위해 메세지를 발행을 합니다.

3. 참고


https://velog.io/@hanblueblue/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B83-Spring-Data-MongoDB-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0

https://www.baeldung.com/?s=mongodb

https://ckddn9496.tistory.com/102

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글