프로젝트를 진행하면서 처음으로 MongoDB 도입을 하면서 학습한 내용을 정리하고 고민한 내용을 정리를 하였습니다.
처음으로 MongoDB를 사용하며 처음 접하는 내용이 많아 학습해야 되는 부분이 많다고 느끼게 되었습니다.
이번 프로젝트에서 MongoDB를 도입하는 이유는 CStudy 프로젝트를 진행하면서 일반 문제를 풀었을 때 데이터를 저장을 했습니다.
많은 데이터가 있는 모델링에 유용하지 않다고 생각하여 이번에 MongoDB를 배우면서 유연한 Modeling이 가능한 장점을 사용하면 문제를 해결할 수 있다고 생각하여 도입을 하였습니다.
member_question 테이블은 문제를 풀었을 때 question_success에 선택한 번호를 삽입하고 question_fail에 0을 넣습니다. 반대로 실패를 하였을 때 question_fail에 오답인 번호를 삽입하고 question_success에 0을 넣습니다.
MongoDB를 도입한 이유는 오답노트를 만들었는데 MySQL을 선택하면 다음과 같은 조건이 필요합니다.
@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);
}
}
모델링 하면서 생각한 부분
- 회원을 조회하면 성공, 실패에 대한 데이터를 한번에 조회가 가능하게
- 오답노트를 만든다면 성공을 한다면 성공의 선택이 번호가 되기 때문에 failNumber에 0을 삽입한다.
- 실패를 할 경우 오답인 선택을 failNumber에 삽입하고 successNumber에 해당 문제에 대한 정답을 처리한다.
- 한명의 회원이 여러 개의 문제가 생성이 된다.
@Document
는 스프링 데이터 MongoDB에서 사용되는 어노테이션이다. MongoDB의 컬렉션에 저장하는 문서임을 나타내며 클래스에 이 어노테이션을 추가함으로써 Spring Data MongoDB는 해당 클래스의 인스턴스를 MongoDB에 저장하고 조회하는 작업을 쉽게 할 수 있습니다.
RDB와 다르게 JSON 타입으로 데이터를 저장을 합니다. 하지만 실제로 데이터는 BSON
형식의 문서로 저장합니다.
ObjectId("64dce391b13d587c5a21516e")
오브젝트 아이디로 형태로 저장이 된다.4바이트: 타임스탬프(timestamp)
5바이트: 머신 아이디(machine identifier)
3바이트: 프로세스 아이디(process identifier)
4바이트: 랜덤 값(random value)
UTC
기준으로 저장합니다.ObjectId
의 생성 순서를 유지하면서도 시간 순서로 문서를 검색이 가능합니다.UTC (햡정 시계시)
- UTC란 국제적인 표준 시간의 기준이다. 이것을 사용하는 이유는 모든 나라마다 시간이 다르기 때문에 UTC를 기준으로 설정을 합니다.
- 이때 1970년 1월 1일 00:00:00 UTC부터 몇 초 경과했는지를 스칼라 실수로 나타낸다.
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
data:
mongodb:
host: ${host}
port: 27017
uri: ${uri}
username: ${userName}
password: ${password}
@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");
}