티스토리 뷰

안녕하세요. 이번 글에서는 지난 학기부터 개발해 온 판례 암기 애플리케이션 <메멘토>에 대해 소개하고, 어떻게 구현했는지 소개하도록 하겠습니다.

1️⃣ 프로젝트 소개

기획 배경

저희는 변리사 시험을 준비하는 고시생의 판례 암기를 도와주고자 모바일 앱을 개발했습니다. 고시생은 아래와 같은 판례를 수백 개씩 외워야 하는데요.

확인대상 발명에 특허발명의 특허청구범위에 기재된 구성 중 변경된 부분이 있는 경우에도 특허발명과 과제 해결 원리가 동일하고, 특허발명에서와 실질 적으로 동일한 작용효과를 나타내며, 그와 같이 변경하는 것이 그 발명이 속하는 기술분야에서 통상의 지식을 가진 사람이라면 누구나 쉽게 생각해 낼 수 있는 정도라면, 특별한 사정이 없는 한 확인대상 발명은 특허발명의 특허청구 범위에 기재된 구성과 균등한 것으로서 여전히 특허발명의 권리범위에 속한다고 보아야 한다.

 

이렇게 긴 문장을 암기하려면 많은 시간과 노력을 투자해야 합니다. 그러려면 자투리 시간을 적극 활용해야 하는데요. 고시생 45명을 대상으로 자투리 시간에 사용하는 암기 도구를 조사해 보니, 약 85%의 고시생이

 

  • 별도로 작성한 노트
  • 강사님 자료
  • 모바일 앱

을 사용한다고 답했습니다. 여기서 모바일 앱은 자료를 쉽게 수정할 수 있고, 노트처럼 잃어버리거나 쉽게 훼손되지 않는다는 특징이 있는데요. 저희는 기존 노트 자료(강사님 자료, 별도로 작성한 노트)에 이런 모바일 앱의 특성을 더한다면 효과적인 암기 수단을 만들 수 있겠다고 생각했습니다.

유사 사례 조사

고시생이 주로 사용하고 있는 모바일 앱으로는 코리노트sFlashcard Deluxe이 있다고 합니다. 플래시 카드 등으로 암기를 효과적으로 할 수 있는 서비스였지만, 고시생 설문 결과 두 서비스 모두 '판례를 직접 입력하는데 시간이 많이 소비된다'고 답했습니다. 또한, 기존 서비스는 보편적인 암기를 위한 것이기 때문에, 저희는 오직 판례를 위한 암기 애플리케이션을 만들어 보고 싶었습니다.

프로젝트 목적

그래서 저희는 프로젝트 목적을 다음과 같이 정했습니다.

 

  1. 판례 암기에 특화
  2. 사용자가 판례를 입력하는 시간 단축

판례 암기에 특화되려면 기존 고시생이 어떻게 판례를 공부하는지 알아야 했는데요. 조사 결과, 강사님이 지정해 주신 키워드를 중심으로 암기를 하고, 판례가 선고된 구체적인 상황을 보고 싶으면 교재에 있는 판례 번호를 케이스 노트(CaseNote)라는 사이트에 검색하여 찾아본다고 합니다. 또한, 검색된 판례에서 주로 외우는 건 '판결 요지'라고 합니다.

2️⃣ 주요 기능

위 목적을 달성하기 위해 저희가 개발한 기능을 소개하겠습니다.

1. 검색과 텍스트 추출로 간편하게 판례 입력

첫 번째는 OCR과 판례 API를 사용하여 사용자가 판례를 간편하게 저장할 수 있는 기능입니다. 사용자는 '암기장' 메뉴에서 자신이 저장한 판례 노트를 확인할 수 있는데요. 판례를 저장하는 방법은 크게 두 가지입니다.

 

  • 사진에서 텍스트를 추출하거나
  • '검색' 메뉴에서 판례를 검색하여 저장합니다.
사진에서 텍스트 추출하기

텍스트를 추출하는 OCR로는 NAVER CLOVA OCR을 사용했습니다. 사용해 본 결과, 필기와 디지털 글씨 모두 잘 인식하고, 사진이 세로로 되어 있어도 결과가 잘 나옵니다. 무엇보다 글씨 위에 형광펜으로 밑줄이나 표시를 해도 글씨만 인식하는 놀라운 기능을 가지고 있습니다!

 

판례를 검색하여 판결요지 가져오기

다음은 '검색' 메뉴에서 판례 내용이나 번호를 입력하여 판례를 가져오는 기능입니다. 검색 결과에서 판례를 클릭하면, 판례 본문 전체를 볼 수 있고, '저장' 버튼을 누르면 '판결 요지'만 가져올 수 있습니다. (판결 요지가 없으면 저장되지 않습니다)

2. 키워드 지정

고시생이 키워드를 중심으로 암기한다는 점을 고려하여, 판례에서 키워드를 추천해 주고 사용자가 키워드를 지정할 수 있도록 했습니다. 

키워드 추천에는 OpenAI의 gpt 모델을 사용했습니다. 사용자가 저장하려는 판례를 gpt에 보내면 gpt가 추천 키워드를 보내줍니다. 사용자는 추천받은 키워드를 토대로 자신이 원하는 키워드를 선택할 수 있습니다.

3. 퀴즈

게임 형식의 퀴즈

마지막으로 저장한 판례의 암기 상태를 확인할 수 있도록, 판례 제목을 보고 키워드를 입력하는 퀴즈를 만들었습니다. 사용자는 메멘토 캐릭터를 상대로, 지정한 키워드를 생각해서 순서대로 입력해야 합니다. 특정 시간이 지나면 메멘토가 먼저 정답을 맞혀버립니다. 퀴즈가 끝나고 나면 사용자가 획득한 키워드를 확인할 수 있습니다.

 

 

퀴즈 결과: 사용자가 획득한 단어 표시

3️⃣ 구현 과정

Flutter로 구현했고 전체 코드는 여기 있습니다. 아직 어색하게 동작하거나 불편한 점이 있어서 부랴부랴 마무리하고 있습니다.

키워드 선택

리디북스의 키워드 선택처럼 만들고 싶었는데요. Flutter에서 텍스트 선택 시 나오는 옵션이 '복사'랑 '모두 선택'만 있고 커스텀하지 못하는 거 같았습니다. 그래서 사용자가 드래그하는 순간 바로 키워드를 저장하도록 만들었는데요. 저세상 알고리즘으로 짰지만 우선 의도하는 대로 얼추 작동을 해서 더 이상 건드리지 못하고 있습니다 😂 그래도 키워드를 저장하는 과정을  정리해 보면,

 

  • 사용자가 길게 누르거나 드래그할 때 선택된 인덱스를 배열에 저장합니다.
  • 이때 이미 선택한 키워드를 선택하면 키워드가 삭제돼야 합니다.
  • 다른 키워드를 포함하여 드래그하면, 기존 키워드를 삭제하고 인덱스를 새로 지정합니다.

이런 과정을 거쳐 선택한 인덱스를 화면에 표시하려면, 인덱스를 텍스트로 변환해야 했습니다. 그래서 인덱스를 선택할 때마다 오름차순으로 정렬하고, 본문을 키워드와 키워드가 아닌 것을 순서대로 잘라서 배열에 저장했습니다. 이 배열을 위젯에 넘겨서 화면에 보여줍니다.

간략한 과정

문제는 사용자가 한 글자 선택할 때마다 이런 일이 발생하기 때문에, 매번 화면을 렌더링 하면 버벅거림 때문에 드래그 커서가 작동하지 않았습니다. 그래서 이전에 선택한 startIndex와 다음에 선택한 startIndex가 서로 다를 때만 화면을 렌더링 하도록 했습니다. 해당 코드는 다음과 같습니다.

import 'package:flutter/material.dart';
import 'package:memento_flutter/api/keyword_api.dart';
import 'package:memento_flutter/themes/custom_theme.dart';

class KeywordSelect extends StatefulWidget {
  List selectedIndex;
  final int noteId;
  final String content;

  KeywordSelect({
    required this.selectedIndex,
    required this.noteId,
    required this.content,
  });

  @override
  State<KeywordSelect> createState() => _KeywordSelectState();
}

class _KeywordSelectState extends State<KeywordSelect> {
  int prevStartIndex = -1;
  // 선택한 문자 TextStyle
  final highlightStyle =
      TextStyle(backgroundColor: Colors.yellow.withOpacity(0.5));

  /* 사용자가 키워드를 선택하면 index 저장 */
  void _onSelectionChanged(selection, cause) {
    final startIndex = selection.baseOffset;
    final endIndex = selection.extentOffset;

    if (cause == SelectionChangedCause.longPress ||
        cause == SelectionChangedCause.drag) {
      saveIndex(newStartIndex: startIndex, newEndIndex: endIndex, cause: cause);
      // 오름차순 정렬 (사용자가 순서대로 밑줄을 긋지 않을 경우에 대비)
      sortIndex(indexList: widget.selectedIndex);
    }

    // 드래그 중 화면 새로고침 횟수 줄이기
    if (prevStartIndex != startIndex || cause == SelectionChangedCause.tap) {
      setState(() {});
    }
    prevStartIndex = startIndex;
  }

  void saveIndex(
      {required int newStartIndex,
      required int newEndIndex,
      required SelectionChangedCause cause}) {
    var isSaveNew = true; // 인덱스 새로 저장
    final isReverse = newStartIndex >= newEndIndex;

    // 반대로 밑줄 그으면 종료
    if (isReverse) {
      return;
    }

    for (var i = 0; i < widget.selectedIndex.length; i++) {
      final start = widget.selectedIndex[i]["first"]; // 시작 인덱스
      final end = widget.selectedIndex[i]["last"]; // 끝 인덱스
      final pressAgain = (cause == SelectionChangedCause.longPress) &&
          newStartIndex >= start &&
          newEndIndex <= end;
      final isDragging = newStartIndex == start;
      final include = newStartIndex < start && newEndIndex > start;

      // 기존 밑줄을 다시 길게 누르면 삭제
      if (pressAgain) {
        widget.selectedIndex.removeAt(i);
        // 새로 인덱스 저장 X
        isSaveNew = false;
        break;
      }

      // 사용자가 밑줄 긋는 중이면
      if (isDragging) {
        // 기존 끝 인덱스 갱신
        widget.selectedIndex[i]["last"] = newEndIndex;
        isSaveNew = false;
      }

      // 기존 밑줄을 포함하면 삭제
      if (include) {
        widget.selectedIndex.removeAt(i);
        isSaveNew = false;
      }
    }

    /* 새 인덱스 저장 */
    if (isSaveNew) {
      widget.selectedIndex.add({
        "first": newStartIndex,
        "last": newEndIndex,
        "noteid": widget.noteId
      });
    }
  }

  /* 오름차순 정렬 */
  void sortIndex({required List indexList}) {
    indexList.sort((a, b) {
      if (a["first"] == b["first"]) {
        return a["last"].compareTo(b["last"]);
      }
      return a["first"].compareTo(b["first"]);
    });
  }

  List<InlineSpan> getSpanList() {
    final selectedText = KeywordAPI.sliceText(
        content: widget.content, selectedIndex: widget.selectedIndex);

    final result = selectedText
        .map((e) => TextSpan(
            text: e["text"],
            style: e["isKeyword"] ? highlightStyle : const TextStyle()))
        .toList();

    return result;
  }

  @override
  Widget build(BuildContext context) {
    return ...;
  }
}

퀴즈 게임

퀴즈를 만들 때는 다음과 같은 것을 고려했습니다.

 

  • 특정 시간(10초)마다 메멘토가 정답을 맞히고, 다음 문제를 가져온다.
  • 다음 문제를 가져올 때는 1.키워드만 가져오면 되는지, 2.다음 판례 제목과 키워드를 가져와야 하는지 판단하여 가져온다.
  • 사용자가 정답을 맞히면 시간을 초기화(10초)한다.
  • 메멘토와 사용자가 최근에 맞춘 키워드를 화면에 표시한다.
  • 퀴즈 결과를 표시하려면, 퀴즈를 진행하면서 메멘토와 사용자가 맞춘 키워드 전체를 저장해야 한다.

다행히 Dart에 Timer.periodic()이라는 함수를 사용하여 특정 시간마다 원하는 함수를 실행할 수 있었습니다. 고려 사항을 토대로 구현한 함수는 아래와 같습니다.

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:memento_flutter/config/constants.dart';
import 'package:memento_flutter/utility/storage.dart';

class QuizAPI {
  String mementoWord = "";
  String userWord = "";
  int currentQuizIndex = 0;
  int currentKeywordIndex = 0;
  List answerList = [];
  List quizList = [];
  
  // 퀴즈 문제 리스트 가져오기
  Future fetchQuizList() async {
    final accessToken = await Storage.getAccessToken();
    final response = await http.get(
      Uri.parse('${Constants.baseURL}/quiz/0'),
      headers: {"Authorization": "Bearer $accessToken"},
    );

    if (response.statusCode == 200) {
      quizList = jsonDecode(utf8.decode(response.bodyBytes));

      if (quizList.isNotEmpty) {
        // answerlist 초기화
        answerList = quizList
            .map((quiz) => {
                  "title": quiz["title"],
                  "keywords": quiz["keywords"]
                      .map((e) => {"text": e, "isAnswer": false})
                      .toList()
                })
            .toList();
      }

      return quizList;
    } else {
      print('Error code: ${response.statusCode}');
      throw Exception('퀴즈를 불러오지 못했습니다.');
    }
  }
  
  // 현재 문제의 정답 가져오기
  String getAnswer() {
    return quizList[currentQuizIndex]["keywords"][currentKeywordIndex];
  }

  // 현재 문제의 판례 제목 가져오기
  String getTitle() {
    return quizList[currentQuizIndex]["title"];
  }

  // 다음 키워드(맞춰야 할 문제) 가져오기
  String getKeyword() {
    int keywordLength = quizList[currentQuizIndex]["keywords"].length;
    int quizLength = quizList.length;

    // 해당 퀴즈에 아직 남은 키워드가 있다면 (키워드 인덱스를 벗어나지 않으면)
    if (currentKeywordIndex < keywordLength) {
      // 키워드 반환 후 키워드 인덱스 증가
      return quizList[currentQuizIndex]["keywords"][currentKeywordIndex++];
    }

    // 해당 퀴즈에서 남은 키워드가 없고
    // 퀴즈를 모두 풀지 않았으면
    if (currentQuizIndex < quizLength - 1) {
      // 키워드 인덱스 0으로 초기화
      currentKeywordIndex = 0;
      // 다음 퀴즈로 이동 (퀴즈 인덱스 증가)
      return quizList[++currentQuizIndex]["keywords"][currentKeywordIndex++];
    }

    // 퀴즈를 모두 풀었으면
    return "";
  }

  // 누가 정답을 맞췄는지 기록하기
  void setAnswer({required isUserAnswer}) {
    answerList[currentQuizIndex]["keywords"][currentKeywordIndex - 1]
        ["isUserAnswer"] = isUserAnswer;
  }

  // 전체 정답 리스트 가져오기 (퀴즈 결과)
  List getAnswerList() {
    return answerList;
  }
}

아직도 조금씩 계속 고치고 싶은 게 나와서 손을 놓지 못하고 있지만, 그래도 저번 학기에 처음 시작할 때 막막하던 것과 다르게 결과물이 완성되고 있는 게 보여서 안심이 되네요! 최종 발표 전까지 미흡한 부분을 후딱 끝내려고 합니다.

 

긴 글 읽어주셔서 감사합니다. 혹시 비슷한 기능을 구현하시다면 도움이 되셨으면 좋겠습니다. 아니면 제 저세상 알고리즘을 개선할 방법을 알려주세요 😇

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함