🎈매일 쉽고 재밌게 퀴즈를 풀며 한국어를 학습하는 서비스.
프로젝트 정보
- 프로젝트: 한글 학습 퀴즈 서비스 - 한 (내일배움캠프 최종 프로젝트)
- 개발기간: 2023.06.05 - 2023.07.10 ( 35일 )
- 주요 기능: 소셜로그인, 실시간 배틀 기능, 십자말퍼즐
- 역할
- 부리더
- 실시간 배틀 및 배틀 로비 기능 개발
- 4종 퍼즐 개발
- 전반적인 프론트엔드 개발
- 깃허브 관리 담당자
- 기술스택:
- BE 프로그래밍 언어: Python 3.8.6
- BE 웹개발 프레임워크: Django 4.2
- FE 프로그래밍 언어: JavaScript
- 의존성 관리(Dependency Manager): poetry 1.4.2
- 데이터베이스(Database): PostgreSQL
- 라이브러리
- Django REST framework - 3.14.0
- simplejwt - 5.2.2
- selenium - 4.9.1
- channels - 4.0.0
- redis - 4.5.5
- kiwipiepy - 0.15.2
- jamo - 0.4.1
ERD
아키텍쳐
주요 코드 리뷰
01. 퀴즈 생성기
초기 계획 중 '퀴즈 유형을 여러 개 만들어서 랜덤으로 제공하자'가 나왔었다. 내가 프론트엔드 초반 구성을 마무리 짓고 퀴즈 생성 기능 개발을 넘겨받았을 때는 크롤링한 네이버 퀴즈와 한국어 사전 api 데이터까지 완성이 된 상태였다. 즉 재료가 준비되어 있었다.
우선 퀴즈 유형을 4개까지는 늘리고 싶었기 때문에 주어진 데이터로 어떤 식의 확장이 가능할까 생각하다가 다음의 네가지 유형을 생각했다.
그리고 이 네가지 유형을 실시간 배틀 기능에 제공할 때나 어느 특정 유형만을 풀고 싶을 때 출제되는 유형이 계속해서 바뀌어야 했으므로 QuizGenerator라는 클래스를 만들고 그 안에 각각의 퀴즈 유형을 생성하는 메소드를 만들었다. 이를 위한 코드의 일부는 다음과 같다.
# HAN/crawled_data/generators.py
class QuizGenerator:
"""무작위 퀴즈 만들기
QuizGenerator([문제유형:list])
문제 유형 순서
1: 이지선다형
2: 뜻풀이
3: 빈칸 채우기
4: 십자말 풀이
"""
def __init__(self, puzzle: list):
"""초기 설정
Args:
puzzle (list): 문제유형
Raises:
TypeError: 문제 유형에 int값 아닌 경우 에러 발생
"""
self._all_quiz_count = KrDictQuiz.objects.all().count()
# 매개변수 체크
if any(not isinstance(p, int) for p in puzzle):
raise TypeError("문제 유형 개수 중에 int값이 아닌 게 포함되어 있습니다.")
# 문제 유형[이지선다, 뜻풀이, 빈칸 채우기, 십자말 풀이 순]
self.puzzle_category = {
1: self.one_of_two,
2: self.meaning,
3: self.fill_in_the_blank,
4: self.crossword_puzzle,
}
# 문제 개수
self._counts = sum(puzzle)
self._puzzles = puzzle
def generator(self):
"""전체 문제 생성기
Returns:
puzzle_list(dict): 각 함수에서 문제 유형 개수만큼 문제들 리턴받아 딕셔너리로 저장해서 리턴
"""
puzzle_list = {}
# 각 퍼즐 개수
puzzle_list["counts"] = self._puzzles
# 퍼즐 결과
for i, c in enumerate(self._puzzles, start=1):
if c != 0:
puzzle_list[self.puzzle_category[i].__name__] = self.puzzle_category[i](
c
)
return puzzle_list
QuizGenerator의 파라미터에 네가지 유형이 출제되어야 하는 정수값을 각각 입력해 리스트로 넘겨주고, 생성된 인스턴스의 generator메소드를 호출하면 그 각각의 정수값에 맞게 문제유형들이 생성되는 구조다.
generator메소드는 넘겨받은 리스트를 검사해서 각 개수만큼 문제 유형을 생성하고 하나의 딕셔너리로 묶어 리턴시켜주는 메소드다. 보다시피 puzzle_list[self.puzzle_category[i].__name__]로 구성해서 puzzle_list의 각각의 key는 문제 유형의 이름이 들어갈 수 있도록 했고, 이 이름으로 어떤 문제 유형의 데이터인지 구별할 수 있도록 했다.
02. 빈칸 채우기 유형
빈칸 채우기 유형의 문제를 만드는 게 상당히 까다로웠다. 한글은 영어와 달리 정답 단어를 예문에서 빈칸으로 처리할 수 없었다. 예를 들어 아래처럼 정답이 '간사하다'일 때, 예문은 '간사한'이 들어가게 된다. 즉, 메인이 되는 형태소 뒤에 붙는 어미의 바리에이션이 너무 많았다.
문제를 마주 하고 처음 든 생각은 각 예문에서 -하다, -하는, -해서, -했던, -하며 등의 가능성이 있는 문자열을 전부 제거하고 메인이 되는 부분인 '간사'을 찾아서 그 부분만 예문에서 빈칸을 만들어 주면 되지 않을까? 하는 생각을 했지만, 그러기엔 너무 고려해야 할 바리에이션이 많았으며 각 예문마다 그러한 과정을 반복하는 건 효율적이지 않을 것이란 생각이 들었다. 또한 정답은 '간사하다'인데 유저들이 정답을 '간사'라고 오해할 여지가 있었다.
따라서 아예 정답 단어와 예문의 형태소를 분석해 똑같은 형태소의 똑같은 문자열이 있으면 빈칸으로 바꾸는 방법으로 진행했다. 이미 한글 형태소를 분석해 토크나이징하는 라이브러리는 Kkma, Komoran 등 이것저것 많이 나와 있었으나 그 중에 Kiwi라는 형태소 분석기 라이브러리를 사용했다.
def fill_in_the_blank(self, puzzle_count):
"""빈칸에 알맞은 말 맞추기 생성
Args:
puzzle_count (int): 배정된 퍼즐 수
Returns:
serializer.data : 시리얼라이즈된 데이터 리턴
"""
# 최대 예문 개수
_maximum_explain = 3
# 전체 설명문 개수 count
all_quiz_array = range(0, self._all_quiz_count + 1)
quiz = []
# 빈 시리얼라이즈 만들기
serializer = FillInTheBlankSerializer()
# 뽑은 샘플 중에 적합하지 않은 쿼리셋이 있을 경우 다시 뽑음
while len(quiz) < puzzle_count:
random_num = random.sample(all_quiz_array, k=puzzle_count)
# 샘플 중에 예문이 최대 예문 개수 미만인 경우 제외
quiz = (
KrDictQuizExplain.objects.filter(id__in=random_num)
.annotate(all_examples_sum=Count("dict_word__examples"))
.filter(all_examples_sum__gte=_maximum_explain)
)
serializer = FillInTheBlankSerializer(quiz, many=True)
# kiwi 라이브러리 이용 단어 구멍 뚫기
for k in range(puzzle_count):
right_answer = serializer.data[k]["dict_word"]["word"]
# 정답 토크나이징
right_answer_tokenizes = kiwi.tokenize(right_answer)
subtract_word = []
# 토크나이징한 정답 중에서 어미 제외하고 subtract_word로 다시 합치기
for rat in right_answer_tokenizes:
# 어미 제외
if rat.tag != "EF":
subtract_word.append(rat)
examples = serializer.data[k]["dict_word"]["examples"]
emptyed_examples = []
# 각 예제들 토크나이징
for example in examples:
tokenized_example = kiwi.tokenize(example["content"])
# 토크나이징한 예제 형태소 들 중에서 토크나이징한 정답에 포함되는 형태소들 O로 대체
for te in tokenized_example:
if te.form in [form.form for form in right_answer_tokenizes]:
example["content"] = (
example["content"][: te.start]
+ "O" * te.len
+ example["content"][te.start + te.len :]
)
emptyed_examples.append(example)
# 예제를 O으로 구멍 뚫은 예제로 대체
examples = emptyed_examples
# 예제 개수 제한
all_examples = serializer.data[k]["dict_word"]["examples"]
examples_list = []
checked_list = []
count = 0
_max_count = 100
# 성능 문제로 최대 100번으로 제한
while len(examples_list) < _maximum_explain and count < _max_count + 1:
ran_num = random.randint(0, len(all_examples) - 1)
# 랜덤으로 픽한 예제 중복되지 않도록 제한
if ran_num not in checked_list:
checked_list.append(ran_num)
picked_example = all_examples[ran_num]["content"]
# 랜덤으로 선택한 예제 중에 아직 examples_list에 포함되어 있지 않고 O을 포함하고 있는 예제를 추가
if picked_example not in examples_list and "O" in picked_example:
examples_list.append(picked_example)
count += 1
# 시리얼라이징한 데이터 중에 선택된 examples_list리스트로 대체
serializer.data[k]["dict_word"]["examples"] = examples_list
return serializer.data
이것저것 고려하느라 코드가 길어졌지만 전체적인 흐름은 _maximum_explain 변수로 각 문제마다 예문을 몇 개까지 출제할건지 정해주고, 랜덤으로 정답이 될 단어를 픽한다. 해당 단어의 예문 개수가 이미 _maximum_explain 미만인 경우는 제외한다. 그 이상일 경우는 랜덤으로 예문을 _maximum_explain만큼만 제한해 성능을 높이려고 했으나 추후에 기술할 문제로 인해 그 부분은 일단 제외했다. 어쨌거나 정답 단어가 선택이 되었으면 시리얼라이징하면서 정답 단어 DB와 정답 설명 DB를 결합시켜준다. 이제 kiwi라이브러리를 이용해 단어의 구멍을 뚫어주면 된다.
이제 kiwi 라이브러리를 어떻게 사용했는지 보자. 먼저 정답 단어의 형태소를 분석하고 그 중에 '-다(EF)'와 같은 종결 어미는 제외하도록 해 예문 마지막까지 빈칸이 되지 않도록 했다. 이제 다시 선택된 예문의 형태소를 각각 분석하면서 정답 단어와 같은 형태소가 있다면 O으로 대체하고 emptyed_examples 리스트에 추가하도록 했다.
이제 emptyed_examples을 의 모든 예문을 다시 체크한다. 정말 비효율적이지만 이렇게 다시 체크할 수 밖에 없었던 이유는, kiwi 라이브러리의 분석률이 생각보다 좋지 않았다. 정답 단어 형태소와 예문의 형태소가 분명 같은데도 불구하고 다르게 분석하는 경우가 많아서 O으로 대체되지 않는 경우가 꽤 있었다. 그래서 다시 한번 전체적으로 체크해서 O이 없는 예문은 다시 제외하도록 했다.
이제 시리얼라이징한 데이터에서 예문을 빈칸을 뚫은 예문으로 대체하고 리턴하면 문제 하나의 출제가 완료된다.
03. 십자말퍼즐
이 문제 유형은 고려해야 할 게 너무 많았으며 코드 자체도 너무 길다. 이 기능이 적당히 돌아가도록 개발하는 데까지 3일이 걸렸고 프로젝트 끝자락이 될 때까지 백엔드와 프론트엔드를 계속해서 보수했다. 고려해야 할 사항을 간단히 요약하면, 어떤 단어가 십자말퍼즐에 배치될 때 그 단어의 방향, 위치, 앞선 단어의 어떤 글자와 겹쳐야 하는지, 만약에 겹친다면 겹치는 단어는 그 위치에 배치가 가능한지 등등이다.
어쨌거나 십자말퍼즐을 어떻게 만들었는지 간단히 설명하면 이 문제 유형은 단독으로 CrossWordPuzzleGenerator라는 새로운 클래스를 만들어 주었다. 그 안에는 십자말퍼즐을 초기화하고 생성하는 generate메소드, 퍼즐 안에 단어를 추가하는 voca_to_puzzle메소드, 첫번째 배치 이후로 이미 배치된 단어의 어떤 한 글자와 겹치는 새로운 단어를 선정하는 find_words_starting_with메소드, 후술할 메소드들을 전체적으로 관리하며 선정된 단어를 삽입하는 try_place_word메소드, 그 삽입이 가능한지, 즉 십자말퍼즐 안에 배치 가능한지(이미 배치된 단어와 겹치거나 퍼즐 경계를 넘어가지 않는지) 체크하는 can_place_word 메소드, 실제로 단어를 삽입하는 place_word메소드로 구성되어 있다.
이 메소드들이 유기적으로 연결되어 십자말퍼즐을 만들어내므로 클래스가 될 수 밖에 없었다. 이 문제 유형은 프론트엔드 관점에서도 문제였는데, api로 문제 데이터를 넘겨받아 그 크기만큼의 행렬을 생성하고 각각의 셀에 id를 붙여주었다. 그리고 각 위치에 해당하는 단어의 글자를 삽입하고 글자는 감추고 어떤 셀을 클릭했을 때 연관된 단어의 셀들만 전체적으로 하이라이팅되고 이런 것들을 구현하기 위해 꽤 시간을 보냈다.
레포지토리
BE
https://github.com/Churnobyl/han
FE
https://github.com/Churnobyl/han_front
'프로그래밍 > 생각나는대로 프로젝트' 카테고리의 다른 글
[프로젝트] 08. 한글 학습 퀴즈 서비스 - 한 (Part 2. 웹소켓) (0) | 2023.07.20 |
---|---|
[프로젝트] 07. 가계부 SNS - 리치메이커 (0) | 2023.05.31 |
[프로젝트] 06. DRF 경매 서비스 - 20세기 박물관 (0) | 2023.05.16 |
[프로젝트] 05. Django SNS CaMu (0) | 2023.04.16 |
[프로젝트] 04. Django 무신사 재고 관리 시스템 (0) | 2023.04.10 |