카드 문제를 풀면 그때의 카드 상태와 점수를 채점해서 카드 결과를 리턴하는 API를 설계해야 했다. 이 때 구현해야 하는 로직은 다음과 같다.
- 카드 ID와 사용자 정답을 입력 받는다 ->
- 카드를 조회한 후 채점한다 ->
- 조회한 카드의 정보와 채점한 정보를 바탕으로 DB에 데이터를 저장한다.
채점은 카드 타입에 따라 분류되고 서술형 카드의 경우 형태소 분리를 통해 키워드를 분리하여 채점을 시도한다. 형태소 분석의 경우, 외부 API로 분리하여 요청하는 방식으로 설계하기로 했다. 그 이유는 우선 해당 라이브러리가 python과 java8을 지원하고(내 프로젝트는 java11) 알고리즘 구현 자체를 application server와 분리하고 싶었다.
외부 API를 적용하기 위해서 우선 WebClient를 활용하고 유연하고 확장성있게 설계해야한다. 그 이유는 외부 API에서 한글 형태소를 분석해서 제공하거나 영어로 번역후 영어 형태소 분석을 통해 제공하는 경우도 있을 수 있기 때문이였다.
인터페이스를 사용하면 외부 API에서 가져오는 것들이 달라져도 유연하게 대처할 수 있다. 이는 SOLID 원칙 중 DIP와 관련이 깊다. 이를 활용하면 공개해야할 API와 구현 로직을 효과적으로 분리할 수 있고 변경사항이 생기는 경우 가져오는 타입은 그대로 활용하되 내부 구현체는 새로운 클래스로 만들어서 갈아 끼워주기만 하면 된다. 결과적으로 OCP도 쉽게 충족한다.
// package com.almondia.meca.cardhistory.domain.service;
public interface MorphemeAnalyzer<T extends NlpToken> {
Morphemes<T> analyze(String cardAnswer, String userAnswer);
}
해당 코드를 보면 인터페이스를 제네릭 타입으로 선언했다. 제네릭 타입으로 선언한 이유는 결과 타입의 유연성을 확보하고 싶었기 때문이다. 그러나 결과 타입은 기본적인 토큰타입에서 크게 벗어나지 않기 때문에 매개 변수 T는 <T extends NlpToken> 를 통해 NlpToken의 하위타입으로 제한해두었다.
인터페이스는 domain 패키지 내부에 service에서 했지만 구현체의 경우 외부 infra를 사용하므로 infra 패키지에서 구현한다. 구현체의 경우 다음과 같이 2가지 버전을 설계했다.
@Component("englishMorphemeAnalyzer")
@Primary
@RequiredArgsConstructor
public class EnglishMorphemeAnalyzer implements MorphemeAnalyzer<EngNlpToken> {
private final WebClient webClient;
private final Environment environment;
@Override
public Morphemes<EngNlpToken> analyze(String cardAnswer, String userAnswer) {
String requestUri = environment.getProperty("morpheme_uri.english");
if (requestUri == null) {
throw new IllegalArgumentException("morpheme_uri.english is null");
}
return webClient.put()
.uri(requestUri)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CommonRequest(cardAnswer, userAnswer))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Morphemes<EngNlpToken>>() {
})
.block();
}
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
private static class CommonRequest {
private String cardAnswer;
private String userAnswer;
}
}
@Component("koreanMorphemeAnalyzer")
@RequiredArgsConstructor
public class KoreanMorphemeAnalyzer implements MorphemeAnalyzer<KoNlpToken> {
private final WebClient webClient;
private final Environment environment;
@Override
public Morphemes<KoNlpToken> analyze(String cardAnswer, String userAnswer) {
String requestUri = environment.getProperty("morpheme_uri.korean");
if (requestUri == null) {
throw new IllegalArgumentException("morpheme_uri.korean is null");
}
return webClient.put()
.uri(requestUri)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CommonRequest(cardAnswer, userAnswer))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Morphemes<KoNlpToken>>() {
})
.block();
}
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
private static class CommonRequest {
private String cardAnswer;
private String userAnswer;
}
}
우선 동일한 인터페이스의 bean을 2개 등록하므로 컴포넌트를 구분하기위해 bean에 등록될 네임을 적어준다. 이는 @Qualifier를 이용한 방식과 결과적으로 같다. 그리고 기본적으로 사용할 컴포넌트에 @Primary를 써준다. 동일 인터페이스에 bean을 등록할 때 우선순위를 적용해두지 않으면 귀찮은 에러를 맛보게 될 것이다.
지금 보니 내부 중첩 클래스가 겹치는 부분이 있는데 이것을 package-private로 선언해서 해당 패키지에서만 활용할 수 있는 클래스로 분리해서 설계하면 외부에 노출을 피하면서 중복을 줄일 수 있을것 같긴 하다.
결과를 리턴할 클래스이다. 사용자 입력에 대해서 분석하고 점수를 리턴한다. 이 또한 인터페이스와 구현체를 분리한다. 그 이유는 채점기 자체가 채점 방식이 나중에 완전히 달라질 수 있는 경우가 생길 수 있기 때문이다.
public interface ScoringMachine {
Score giveScore(MorphemeAnalyzer<? extends NlpToken> morphemeAnalyzer, Card card, Answer userAnswer);
}
형태소 분석에 사용되는 MorphAnalyzer<T extends NlpToken> 타입은 매개 변수 인자로 주입한다. 그 이유는 ScoringMachine은 도메인 서비스로 따로 내부 컴포넌트를 두고 싶지 않았고, 필요한 메서드에서만 사용되기 때문에 굳이 생성자 주입을 할 필요가 없다고 생각했다.
구현체는 다음과 같다.
@Component
@Slf4j
public class DefaultScoringMachine implements ScoringMachine {
@Override
public Score giveScore(MorphemeAnalyzer<? extends NlpToken> morphemeAnalyzer, Card card, Answer userAnswer) {
if (userAnswer.getText().isBlank()) {
return new Score(0);
}
if (card.getCardType().equals(CardType.OX_QUIZ)) {
return scoringOxCard(((OxCard)card), userAnswer);
}
if (card.getCardType().equals(CardType.KEYWORD)) {
return scoringKeywordCard((KeywordCard)card, userAnswer);
}
if (card.getCardType().equals(CardType.MULTI_CHOICE)) {
return scoringMultiChoiceCard((MultiChoiceCard)card, userAnswer);
}
if (card.getCardType().equals(CardType.ESSAY)) {
return scoringEssayCard(morphemeAnalyzer, (EssayCard)card, userAnswer);
}
throw new IllegalArgumentException("지원하지 않는 카드 타입입니다.");
}
private Score scoringOxCard(OxCard card, Answer userAnswer) {
if (card.getAnswer().equals(userAnswer.getText())) {
return new Score(100);
}
return new Score(0);
}
private Score scoringKeywordCard(KeywordCard card, Answer userAnswer) {
KeywordAnswer keywordAnswer = card.getKeywordAnswer();
if (keywordAnswer.contains(userAnswer.getText())) {
return new Score(100);
}
return new Score(0);
}
private Score scoringMultiChoiceCard(MultiChoiceCard multiChoiceCard, Answer userAnswer) {
MultiChoiceAnswer multiChoiceAnswer = multiChoiceCard.getMultiChoiceAnswer();
if (multiChoiceAnswer.toString().equals(userAnswer.getText())) {
return new Score(100);
}
return new Score(0);
}
private Score scoringEssayCard(MorphemeAnalyzer<? extends NlpToken> morphemeAnalyzer, EssayCard essayCard,
Answer userAnswer) {
Morphemes<? extends NlpToken> morphemes = morphemeAnalyzer.analyze(essayCard.getAnswer(), userAnswer.getText());
List<String> cardAnswerMorpheme = morphemes.getCardAnswerMorpheme()
.stream()
.map(NlpToken::getMorph)
.map(String::toLowerCase)
.map(String::trim)
.collect(toList());
List<String> userAnswerMorpheme = morphemes.getUserAnswerMorpheme()
.stream()
.map(NlpToken::getMorph)
.map(String::toLowerCase)
.map(String::trim)
.collect(toList());
int totalSize = cardAnswerMorpheme.size();
int correctSize = 0;
for (String morph : userAnswerMorpheme) {
if (cardAnswerMorpheme.contains(morph)) {
correctSize++;
}
}
if (totalSize == 0) {
log.warn("EssayCard의 정답 형태소가 존재하지 않습니다. cardAnswerMorpheme: {}", cardAnswerMorpheme);
return new Score(0);
}
return new Score(correctSize * 100 / totalSize);
}
}
구현한 도메인 서비스를 application service layer에서 활용해보자.
도메인 서비스: 도메인의 복잡한 로직이지만 도메인 공유의 로직인 경우
application 서비스: MVC 패턴중 service layer (api 로직을 관리하는 매니저)
service layer에서는 도메인 로직이 들어갈 필요가 없다. 그리고 이미 우리는 모두 구현되 있으므로 연결작업만 수행해주면 된다. 코드는 다음과 같다.
@Service
@RequiredArgsConstructor
public class CardHistoryService {
private static final ScoringMachine scoringMachine = new DefaultScoringMachine();
private final CardHistoryRepository cardHistoryRepository;
private final CardRepository cardRepository;
private final MorphemeAnalyzer<? extends NlpToken> morphemeAnalyzer;
@Transactional
public Score saveCardHistory(CardHistoryRequestDto cardHistoryRequestDto, Id solvedMemberId) {
Card findCard = cardRepository.findByCardIdAndIsDeletedFalse(cardHistoryRequestDto.getCardId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카드입니다."));
Score score = scoringMachine.giveScore(morphemeAnalyzer, findCard, cardHistoryRequestDto.getUserAnswer());
CardHistory cardHistory = CardHistoryFactory.makeCardHistory(cardHistoryRequestDto, findCard, solvedMemberId,
score);
cardHistoryRepository.save(cardHistory);
return score;
}
// ~~~~~~~~
}
클래스를 분리해뒀기 때문에 코드만 봐도 service 로직을 쉽게 이해할 수 있다.
개인적으로 DefaultScoringMachine은 여러 책임을 가지고 있다. 하나의 메서드에서 4가지 타입을 모두 처리하는데 이 때문에 if 문이 굉장히 많이 사용된다. 이를 분리하면 조금 더 단일 책임을 가지게 하고 메서드의 길이와 private helper 메서드 갯수를 줄일 수 있다. 그러나 그렇게 되면 application service layer에서 if 문이 늘어나기 때문에 가독성이 떨어질 수 있다. 두 고민을 모두 만족할 만한 효과적인 방법이 있을까...