한자 사전 DB
국립국어원 우리말샘 OpenAPI를 연결해 한자 학습 데이터를 만들어 봅니다.
이번 프로젝트에서는 단순히 한자를 화면에 출력하는 것을 넘어, 실제 공개 데이터를 이용해 한자 학습용 데이터베이스를 만들어 봅니다.
데이터는 크게 두 가지 출처를 활용합니다.
Unicode에서 제공하는 Unihan 자료를 이용하여 한자별 총 획수(kTotalStrokes)를 가져옵니다.
우리말샘 검색 API를 이용해 한자어의 표제어, 한자, 뜻풀이, 품사 등의 정보를 검색합니다.
- 텍스트 파일 읽기
- 문자열 분리와 데이터 정제
- Unicode 코드 포인트 처리
- JSON 파일 저장
- HTTP GET 요청
- JSON·XML 데이터 파싱
- dataclass를 이용한 데이터 구조화
- threading을 이용한 API 비동기 처리
- Tkinter GUI 프로그램 제작
첫 번째 데이터는 Unicode Consortium에서 제공하는 Unihan Database입니다.
Unihan 자료에는 CJK 통합 한자와 관련된 다양한 속성이 들어 있으며, 이번 실습에서는 그중 kTotalStrokes 값을 이용합니다.
다운로드한 Unihan.zip을 Python 프로그램과 같은 위치에 압축 해제하고 폴더 이름을 Unihan으로 지정하면 예제 코드를 그대로 실행하기 편리합니다.
Unihan 텍스트 파일의 데이터는 일반적으로 탭(Tab)으로 구분됩니다.
U+4E00 kTotalStrokes 1 U+4E01 kTotalStrokes 2 U+4E03 kTotalStrokes 2
첫 번째 값은 Unicode 코드 포인트, 두 번째는 속성 이름, 세 번째는 실제 값입니다.
| 항목 | 예 | 설명 |
|---|---|---|
| 코드 포인트 | U+4E00 | 한자를 나타내는 Unicode 번호 |
| 필드 | kTotalStrokes | 한자의 총 획수 |
| 값 | 1 | 실제 획수 |
이제 Unihan 폴더 안의 모든 TXT 파일을 검색하여 kTotalStrokes 데이터만 추출해 보겠습니다.
from __future__ import annotations
import argparse
import json
from pathlib import Path
FIELD_NAME = "kTotalStrokes"
def extract_total_strokes(unihan_dir: Path) -> dict[str, int]:
"""폴더 안의 모든 Unihan txt 파일에서 kTotalStrokes 값을 읽는다."""
if not unihan_dir.is_dir():
raise FileNotFoundError(
f"Unihan 폴더를 찾을 수 없습니다: {unihan_dir}"
)
strokes: dict[str, int] = {}
text_files = sorted(unihan_dir.glob("*.txt"))
if not text_files:
raise FileNotFoundError(
f"txt 파일이 없습니다: {unihan_dir}"
)
for text_file in text_files:
with text_file.open(
"r",
encoding="utf-8"
) as source:
for line_number, raw_line in enumerate(
source,
start=1
):
line = raw_line.rstrip("\r\n")
if not line or line.startswith("#"):
continue
parts = line.split("\t", 2)
if len(parts) != 3:
continue
if parts[1] != FIELD_NAME:
continue
code_point, _, value = parts
try:
character = chr(
int(
code_point.removeprefix("U+"),
16
)
)
stroke_count = int(value)
except ValueError as error:
raise ValueError(
f"잘못된 데이터: "
f"{text_file}:{line_number}: {line}"
) from error
old_value = strokes.get(character)
if (
old_value is not None
and old_value != stroke_count
):
raise ValueError(
f"획수가 서로 다릅니다: "
f"{code_point} "
f"({old_value}, {stroke_count})"
)
strokes[character] = stroke_count
if not strokes:
raise ValueError(
f"{FIELD_NAME} 데이터를 찾지 못했습니다: "
f"{unihan_dir}"
)
return strokes
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Unihan 자료에서 한자별 총 획수를 "
"추출해 JSON으로 저장합니다."
)
)
parser.add_argument(
"--input",
type=Path,
default=(
Path(__file__).resolve().parent
/ "Unihan"
),
help="Unihan txt 폴더",
)
parser.add_argument(
"--output",
type=Path,
default=(
Path(__file__).resolve().parent
/ "hanja_strokes.json"
),
help="출력 JSON 경로",
)
args = parser.parse_args()
strokes = extract_total_strokes(args.input)
args.output.parent.mkdir(
parents=True,
exist_ok=True
)
with args.output.open(
"w",
encoding="utf-8",
newline="\n"
) as output:
json.dump(
strokes,
output,
ensure_ascii=False,
indent=2
)
output.write("\n")
print(
f"완료: 한자 {len(strokes):,}자의 획수를 "
f"{args.output}에 저장했습니다."
)
if __name__ == "__main__":
main()
text_files = sorted(unihan_dir.glob("*.txt"))
glob("*.txt")를 이용해 Unihan 폴더 안에 있는 모든 TXT 파일을 찾습니다.
parts = line.split("\t", 2)
한 줄을 탭 문자를 기준으로 나누어 코드 포인트, 필드명, 값을 각각 얻습니다.
character = chr(
int(code_point.removeprefix("U+"), 16)
)
예를 들어 U+4E00에서 U+를 제거하고 16진수를 정수로 바꾼 다음 chr() 함수로 실제 문자 一을 얻습니다.
json.dump(
strokes,
output,
ensure_ascii=False,
indent=2
)
ensure_ascii=False를 사용하면 한자가 Unicode 이스케이프 문자열이 아니라 실제 문자로 저장됩니다.
python extract_hanja_strokes.py
입력 폴더와 출력 파일을 직접 지정할 수도 있습니다.
python extract_hanja_strokes.py --input Unihan --output hanja_strokes.json
"一": 1,
"丁": 2,
"七": 2
}
JSON은 Python에서 쉽게 읽을 수 있고 웹 프로그램이나 GUI 프로그램에서도 활용하기 편리합니다. 한 번 Unihan 자료를 JSON으로 변환해 두면 프로그램을 실행할 때마다 원본 TXT 파일 전체를 다시 분석할 필요가 없습니다.
획수 정보만으로는 한자 학습 프로그램을 만들기 어렵습니다. 한자어의 표제어와 뜻풀이 같은 사전 정보도 필요합니다.
이때 활용할 수 있는 것이 국립국어원 우리말샘 OpenAPI입니다.
- 인증키를 이용한 OpenAPI 요청
- GET 방식으로 검색 API 호출
- req_type=json으로 JSON 응답 요청
- num과 start를 이용한 페이지 처리
- 한자어 검색 결과를 WordCard 객체로 변환
블로그나 GitHub에 프로그램을 공개할 때는 실제 인증키를 Python 소스에 직접 작성하지 않는 것이 좋습니다.
아래 예제에서는 OPENDICT_API_KEY 환경 변수에서 인증키를 읽도록 구성합니다.
import json
import os
import urllib.error
import urllib.parse
import urllib.request
API_KEY = os.environ.get(
"OPENDICT_API_KEY",
""
)
SEARCH_URL = (
"https://opendict.korean.go.kr/api/search"
)
PAGE_SIZE = 100
MAX_START = 1000
인증키는 환경 변수에서 가져오고, 검색 주소는 SEARCH_URL 상수에 저장합니다.
def request_search(params):
query = urllib.parse.urlencode(params)
request = urllib.request.Request(
f"{SEARCH_URL}?{query}",
headers={
"User-Agent": "hanja-card-game-open"
},
)
try:
with urllib.request.urlopen(
request,
timeout=25
) as response:
raw = response.read().decode("utf-8")
except urllib.error.URLError as error:
raise RuntimeError(
"우리말샘에 연결하지 못했습니다."
) from error
return raw
urllib.parse.urlencode()는 Python 딕셔너리를 URL의 쿼리 문자열 형식으로 변환합니다.
params = {
"key": API_KEY,
"q": "중",
"req_type": "json",
"part": "word",
"sort": "dict",
"advanced": "y",
"target": "1",
"method": "include",
"type1": "word",
"type2": "chinese",
"type3": "general",
"letter_s": "1",
"letter_e": "8",
"num": "100",
"start": "1",
}
| 항목 | 역할 |
|---|---|
| key | OpenAPI 인증키 |
| q | 검색할 단어 |
| req_type | 응답 형식 지정 |
| num | 한 페이지에서 요청할 결과 개수 |
| start | 검색 결과 시작 위치 |
| type2 | 한자어 검색 조건에 활용 |
API에서 받은 데이터를 프로그램 전체에서 편리하게 사용하기 위해 dataclass로 카드 구조를 정의할 수 있습니다.
from dataclasses import dataclass
@dataclass
class WordCard:
word: str
hanja: str
definition: str
pos: str
category: str
link: str
@property
def front(self) -> str:
return self.hanja or self.word
@property
def meaning(self) -> str:
return self.definition
하나의 WordCard 객체에는 표제어, 한자, 뜻풀이, 품사, 분류, 사전 링크를 저장할 수 있습니다.
def extract_hanja(text: str) -> str:
chars = [
char
for char in text
if (
"\u3400" <= char <= "\u9fff"
or "\uf900" <= char <= "\ufaff"
)
]
return "".join(chars)
문자열에 한글과 한자가 함께 들어 있는 경우 Unicode 범위를 검사해 한자 문자만 추출합니다.
↓
한자 추출
↓
WordCard 객체 생성
↓
카드 게임의 학습 데이터로 사용
def unique_cards(cards):
seen = set()
unique = []
for card in cards:
key = (
card.word,
card.hanja,
card.definition
)
if key in seen:
continue
seen.add(key)
unique.append(card)
return unique
API 검색 결과에는 같은 표제어나 뜻풀이가 여러 번 나타날 수 있으므로 표제어·한자·뜻풀이를 조합한 키를 이용해 중복을 제거합니다.
우리말샘에서 가져온 데이터를 Tkinter와 연결하면 간단한 한자 낱말카드 학습 프로그램으로 확장할 수 있습니다.
카드 앞면에는 한자 또는 표제어를 표시하고, 카드를 클릭하면 뜻풀이를 확인하도록 구성할 수 있습니다.
현재 카드의 뜻이나 표제어를 정답으로 지정한 뒤 다른 카드에서 오답 후보를 가져와 4지선다 문제를 만들 수 있습니다.
사용자가 검색창에 중, 학, 한, 국, 中 등의 검색어를 입력하면 OpenAPI에서 관련 한자어를 가져와 새로운 카드 묶음을 구성할 수 있습니다.
import random
def pick_choices(
correct: str,
pool: list[str],
count: int = 4
) -> list[str]:
options = [correct]
candidates = [
item
for item in pool
if item and item != correct
]
random.shuffle(candidates)
for item in candidates:
if item not in options:
options.append(item)
if len(options) == count:
break
random.shuffle(options)
return options
정답 하나를 먼저 넣고 나머지 카드에서 오답을 선택한 뒤 random.shuffle()로 보기 순서를 섞습니다.
worker = threading.Thread(
target=self._search_worker,
args=(text,),
daemon=True
)
worker.start()
네트워크 API 요청은 응답을 기다리는 시간이 필요합니다. Tkinter의 메인 스레드에서 직접 긴 네트워크 요청을 실행하면 프로그램 화면이 잠시 멈춘 것처럼 보일 수 있습니다.
따라서 API 검색은 별도의 threading.Thread에서 실행하고, 화면 변경은 Tkinter의 after()를 이용해 메인 스레드에서 처리하는 구조가 적합합니다.
python hanja_card_game_open.py
OpenAPI를 사용하지 않고 로컬 데이터를 이용하는 카드 게임이 있다면 다음과 같이 별도로 실행할 수 있습니다.
python hanja_card_game.py
hanja_project/ │ ├── Unihan/ │ ├── Unihan_DictionaryIndices.txt │ ├── Unihan_DictionaryLikeData.txt │ └── ... │ ├── extract_hanja_strokes.py ├── hanja_strokes.json ├── hanja_card_game_open.py └── hanja_card_game.py
처음부터 GUI 전체를 만들려고 하기보다 Unihan 데이터 추출 → JSON 확인 → OpenAPI 호출 → WordCard 생성 → Tkinter 연결 순서로 하나씩 완성하는 것이 좋습니다.
- Unicode Unihan 자료에서 한자의 다양한 속성을 얻을 수 있다.
- kTotalStrokes 필드를 이용하면 한자별 총 획수를 추출할 수 있다.
- Unicode 코드 포인트는 int()와 chr()를 이용해 실제 문자로 변환할 수 있다.
- 추출한 데이터는 JSON으로 저장하면 다른 Python 프로그램에서 활용하기 편리하다.
- 국립국어원 우리말샘 OpenAPI를 이용해 한자어와 뜻풀이를 검색할 수 있다.
- API 응답은 WordCard와 같은 객체로 변환하면 프로그램 관리가 쉬워진다.
- 중복 데이터를 제거한 뒤 Tkinter 카드 게임의 학습 자료로 활용할 수 있다.
- GUI에서 네트워크 요청을 처리할 때는 별도 스레드를 활용할 수 있다.
- API 인증키는 공개 소스코드에 직접 넣지 않고 환경 변수로 관리하는 것이 좋다.