Search
Duplicate
🍵

잉티 프로젝트 상세보기

프로젝트 개요

ChatGPT로 영어를 공부하며 생기는 불편함을 해소하고, 학습 효율을 높여주기 위한 웹 어플리케이션을 만드는 프로젝트입니다. 다양한 자연어 처리 API를 활용하여 사용자가 영어를 효과적으로 학습할 수 있도록 도와줍니다. 본 프로젝트는 기존 지피티처 프로젝트를 리팩터링하였습니다.
프로젝트명
진행 기간
참여 인원
URL
: 잉티(engT)
: 2023.05.24 ~ 2023.06.27 (1개월 4일)
: 개인

프로젝트 제작 동기

최근 자연어 AI 모델인 ChatGPT를 이용해 영어공부하는 방법이 매우 각광받고 있습니다. 하지만 ChatGPT를 이용해 영어공부를 할 경우 아래처럼 여러 문제점들이 발생합니다.
상황극을 하자고 했는데 GPT 혼자 모든 대본을 줄줄 외워버림
GPT와 대화는 자연스럽게 이어가면서도 내 영어 문장에 대한 피드백은 또 따로 받고싶을 때, GPT에게 요청하는 것이 매우 번잡하고 주의력을 떨어뜨림
피드백을 달라고 정확히 요청하기도 쉽지 않음
음성으로 대화를 주고받을 수 없음
이전 학습 내역들을 저장하고 복습하기 어려움
이런 문제점을 해결하고자 자연어 처리 API를 통해 학습을 보조해주는 애플리케이션을 제작하였습니다.

사용 기술

백엔드
Spring Boot 3.0.6
Java 17 (OpenJDK, JakartaEE)
Apache Tomcat
Thymeleaf
Gradle
프론트엔드
HTML5 / CSS
Javascript ES6+
Bootstrap 5.3
DB
MySQL
Spring Data JPA
Spring Data Redis
QueryDSL
AWS RDS
형상관리
Git, Github
Sourcetree
IDE
IntelliJ IDEA Ultimate
외부 API
OpenAI Whisper API(Speech To Text)
OpenAI ChatGPT 3.5
Microsoft Azure Speech Pronunciation Assessment API
Microsoft Azure Speech TTS(Text To Speech) API
Google OAuth 2.0 API
사용 라이브러리
opusRecorder.js
opusMediaRecorder.js
sweetalert2.js

주요 기능

학습 기능 - 글쓰기 연습

사용자가 주제를 직접 입력하거나, 카테고리를 선택하면 AI가 랜덤 주제를 추천해줍니다.
사용자 작문에 대해 문장별로 상세히 피드백을 해줍니다.

학습 기능 - 말하기 연습

사용자가 주제를 직접 입력하거나, 카테고리를 선택하면 AI가 랜덤 주제를 추천해줍니다.
사용자 작문에 대해 문장별로 상세히 피드백을 해줍니다.

학습 기능 - 회화 연습

사용자가 주제를 직접 입력하거나, 카테고리를 선택하면 AI가 랜덤 주제를 추천해줍니다.
AI가 먼저 이야기를 시작할 쪽을 선택하여 먼저 말을 걸거나 사용자에게 대화를 시작할 문장을 추천해줍니다.
대화 진행과 동시에 실시간으로 피드백을 받을 수 있습니다.
한국어로 이야기를 해도 자동으로 번역이 되어 대화를 이어나갈 수 있습니다.

복습하기

학습 이력들을 조회할 수 있습니다.

발음 평가

문장별로 발음을 평가받을 수 있습니다.

리팩터링 회고

본 프로젝트는 지피티처 프로젝트를 배포하기 위해 핵심 기능만 추려 리팩터링 과정을 거쳤습니다.
리팩터링을 통해 기존 프로젝트 대비 아래와 같은 부분을 개선하였습니다.

AWS 배포

기존 로컬 환경에서 사용하던 애플리케이션을 AWS에 배포하였습니다.
EC2 Linux Ubuntu 인스턴스를 사용했습니다.
HTTPS 설정, 서버 분산, 무중단 배포 등을 위해 Application Load Balancer를 사용했습니다.
RDS(MySQL)를 사용해 DB 서버를 구축했습니다.

자바스크립트 모듈화

기존 파일 분리정도만 시행했던 자바스크립트를 기능별로 모듈화하여 재사용성 및 유지보수성을 높였습니다.
사용이 빈번한 유틸리티 파일은 base layout 파일에 글로벌 네임스페이스로 등록해 개발 생산성을 높였습니다.
알림창, ajax 등 라이브러리 또는 API를 사용하는 경우, 추상화를 통하여 추후 구현체가 변경되더라도 변경 지점을 한 파일로 한정시켰습니다.

API 호출 병렬 처리

회화 연습의 경우 대화 응답과 유저의 문장에 대한 피드백을 별도로 진행하는데, 이때 병렬처리를 위하여 ExecutorService를 사용하여 응답 속도를 높였습니다.
ExecutorService executor = Executors.newFixedThreadPool(2); List<Callable<String>> apiCalls = new ArrayList<>(); apiCalls.add(() -> lmc.chat(correctionRequestMessages, 33)); apiCalls.add(() -> lmc.chat(chatRequestMessages)); List<Future<String>> futureResponses; try { futureResponses = executor.invokeAll(apiCalls); } catch (InterruptedException e) { log.error("An error occurred while invoking methods with ExecutorService.", e); throw new IllegalStateException(e); } String correctionResult; try { correctionResult = futureResponses.get(0).get(); } catch (Exception e) { log.error("Parsing the correction result for a dialogue sentence failed.", e); throw new ApiFailException(e); } String aiSentence; try { aiSentence = JsonPath.parse(futureResponses.get(1).get()).read("response", String.class); } catch (Exception e) { log.error("Parsing the response result for a dialogue sentence failed.", e); throw new ApiFailException(e); }
Java
복사

API 인터페이스 추상화

API 클래스 추상화하여 추후 사용 기술이 변경되더라도 변경점을 구현체 한 곳으로 한정되도록 하였습니다.
아래는 ChatGPT 클라이언트 클래스를 추상화한 인터페이스입니다.
public interface LanguageModelClient { /** * 유저 프롬프트를 통해 언어모델의 응답 수신. * randomFactor 50%. * * @param prompt 유저 프롬프트. * @return 언어모델의 응답. */ String chat(String prompt); /** * 유저 프롬프트를 통해 언어모델의 응답 수신. * * @param prompt 유저 프롬프트. * @param randomFactor 응답의 랜덤성을 결정하는 파라미터. 0~100 범위 입력. 100에 가까울수록 답변이 랜덤해짐. * @return 언어모델의 응답. */ String chat(String prompt, int randomFactor); /** * 과거 유저 프롬프트 및 언어모델 응답을 포함한 프롬프트 입력을 통해 언어모델의 답변 수신. * randomFactor 50%. * * @param messages 과거 유저 프롬프트 및 언어모델 응답을 포함한 프롬프트. 0번 인덱스는 시스템 프롬프트, 홀수 인덱스는 유저 프롬프트, 짝수 인덱스는 언어모델의 응답. * @return 언어모델의 응답. * @throws ApiFailException API 응답 수신 실패 시 발생. */ String chat(List<String> messages); /** * 과거 유저 프롬프트 및 언어모델 응답을 포함한 프롬프트 입력을 통해 언어모델의 답변 수신. * * @param messages 과거 유저 프롬프트 및 언어모델 응답을 포함한 프롬프트. 0번 인덱스는 시스템 프롬프트, 홀수 인덱스는 유저 프롬프트, 짝수 인덱스는 언어모델의 응답. * @param randomFactor 응답의 랜덤성을 결정하는 파라미터. 0~100 범위 입력. 100에 가까울수록 답변이 랜덤해짐. * @return 언어모델의 응답. * @throws ApiFailException API 응답 수신 실패 시 발생. */ String chat(List<String> messages, int randomFactor); }
Java
복사
이 외에 STT, TTS, 발음평가 API도 같은 방식으로 추상화를 했습니다.

인터셉터 인가(Authorization) 처리 공통화

기존 코드는 각 컨트롤러마다 특정 자원에 대해 유저가 권한을 가지고 있는 지 일일이 확인하였기 때문에 코드중복, 유지보수의 어려움 등 여러 문제가 존재했습니다.
스프링 인터셉터에서 아래와 같은 방법으로 공통된 인가 처리를 구현했습니다.
인가가 필요한 자원에 접근하기 위해서는 해당 자원의 id가 파라미터로 넘어가는 점에서 착안했습니다.
학습(learning)이라면 learningId, 문장(sentence)은 sentenceId 등이 됩니다.
URI 쿼리 파라미터, Post body 쿼리 파라미터, PathVariable, 직접 제작한 Json 파라미터 등에 해당 자원의 id가 포함되어있을 경우 인가 검증을 진행합니다.
@RequiredArgsConstructor public class AuthorizationInterceptor implements HandlerInterceptor { private final AuthorizationService authorizationService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Authentication authentication = (Authentication) request.getAttribute("authentication"); Map<String, String[]> parameterMap = request.getParameterMap(); for (String paramKey : AuthorizationService.RESOURCE_PARAM_KEYS) { String[] resourceIds = parameterMap.get(paramKey); if (resourceIds != null && resourceIds.length > 0) { authorizationService.validate(paramKey, resourceIds, authentication); } } Map<String, String> pathVariables = (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); if (pathVariables != null) { for (String paramKey : AuthorizationService.RESOURCE_PARAM_KEYS) { String resourceId = pathVariables.get(paramKey); if (StringUtils.hasText(resourceId)) { authorizationService.validate(paramKey, resourceId, authentication); } } } if (authentication != null) { request.setAttribute("premiumType", authentication.getValidPremiumType()); } return true; } }
Java
복사
AuthorizationService에서는 파라미터 키/값, 인증(Authentication) 정보를 받아 검증 작업을 진행합니다.
@Component @RequiredArgsConstructor @Transactional(readOnly = true) @Slf4j public class AuthorizationService { private final LearningRepository learningRepository; private final SentenceRepository sentenceRepository; private final CollectionSentenceRepository collectionSentenceRepository; private final CollectionLearningRepository collectionLearningRepository; public static final String[] RESOURCE_PARAM_KEYS = new String[]{"learningId", "sentenceId", "collectionSentenceId", "collectionLearningId"}; public void validate(String paramKey, String paramValue, Authentication authentication) { validate(paramKey, new String[]{paramValue}, authentication); } public void validate(String paramKey, String[] paramValues, Authentication authentication) { List<Long> ids = Arrays.stream(paramValues) .filter(StringUtils::hasText) .map(Long::valueOf) .toList(); if (ids.size() == 0) { return; } try { paramKey = paramKey.substring(0, 1).toUpperCase() + paramKey.substring(1); Method method = this.getClass().getMethod("validate" + paramKey, List.class, Long.class); boolean isValid = (boolean) method.invoke(this, ids, authentication.getMemberId()); if (!isValid) { throw new UnauthorizedException("허가되지 않은 접근입니다."); } } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { throw new IllegalStateException(e); } } public boolean validateLearningId(List<Long> ids, Long memberId) { return learningRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } public boolean validateSentenceId(List<Long> ids, Long memberId) { return sentenceRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } public boolean validateCollectionSentenceId(List<Long> ids, Long memberId) { return collectionSentenceRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } public boolean validateCollectionLearningId(List<Long> ids, Long memberId) { return collectionLearningRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } }
Java
복사
이때 자바 리플렉션을 이용하여 검증용 메서드를 호출하고, 검증용 메서드는 DB에서 권한이 없는 리소스가 하나라도 있는 지 조회하여 인가 여부를 boolean으로 반환합니다.
검증 결과 false 반환 시 커스텀으로 만든 예외인 UnauthorizedException를 던지고, exceptionResolver에서 해당 예외를 받아 에러처리를 진행합니다.

@JsonParam 어노테이션 제작

JSON 포맷으로 요청을 받을 경우 해당 JSON 형태에 맞는 객체를 일일이 만들어야 하는 점이 불편하여 컨트롤러에서 @RequestParam과 같은 @JsonParam 어노테이션을 제작하여 간단히 파라미터를 받을 수 있도록 했습니다.
@RequiredArgsConstructor public class JsonArgumentResolver implements HandlerMethodArgumentResolver { private final AuthorizationService authorizationService; @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(JsonParam.class); } @Override public Object resolveArgument( MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) throws Exception { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); String jsonBody = (String) request.getAttribute("JSON_REQUEST_BODY"); if (jsonBody == null) { jsonBody = IOUtils.toString(request.getInputStream()); request.setAttribute("JSON_REQUEST_BODY", jsonBody); } String key = parameter.getParameterAnnotation(JsonParam.class).value(); // @JsonParam value 속성이 없을 경우 controller의 파라미터 변수명으로 key 지정 if (!StringUtils.hasText(key)) { key = parameter.getParameterName(); } Object value = JsonPath.parse(jsonBody).read(key, parameter.getParameterType()); validateAuthorization(key, value, request); return value; } private void validateAuthorization(String key, Object value, HttpServletRequest request) { if (Arrays.asList(AuthorizationService.RESOURCE_PARAM_KEYS).contains(key)) { String resourceId = String.valueOf(value); Authentication authentication = (Authentication) request.getAttribute("authentication"); authorizationService.validate(key, resourceId, authentication); } } }
Java
복사
아래와 같이 사용됩니다
@ResponseBody @PostMapping("/collection/learning/new") public CollectionLearningListDtoResult<CollectionLearningListDto> addCollection( @JsonParam String collectionLearningName, @RequestAttribute Authentication authentication ) { ... }
Java
복사

Session에서 JWT로

AWS에 배포할 프로젝트이기 때문에 서버를 분산해서 이용해도 문제가 없도록 JWT를 도입했습니다.
JWT 쿠키에 secure 설정을 하여 XSS 공격을 예방했습니다.
CSRF 공격 등 보안 취약점이 남아있지만, 사이트에서 민감한 개인정보를 수집하지 않고 있고, 해커가 탈취한 JWT를 통해 얻을 수 있는 이득이 거의 없다고 판단하여(마음껏 영어공부하기) JWT를 도입하였습니다.
다만 다음부터는 대칭키를 이용해 JWT의 body도 별도로 해싱하는 것이 좋겠다는 생각이 들었습니다.

스프링 부트, Spring Data JPA, QueryDSL 도입

기존 순수 스프링에서 스프링 부트를 도입했습니다.
기존 MyBatis에서 Spring Data JPA, QueryDSL을 도입하여 생산성을 높였습니다.

페이지 전환 시 Ajax 적용

페이지 전환 요청 시 외부 API를 호출한 후 결과물을 가지고 전환해야하는 경우가 많았습니다.
이 경우 외부 API 호출이 실패할 수 있기 때문에 페이지 이동 버튼을 모두 Ajax로 처리하여 실패 시 오류 페이지 대신 알림창이 나타나도록 했습니다.

모바일 최적화(반응형 디자인, 미디어 입출력)

부트스트랩을 통해 반응형 디자인을 구현하여 모바일 환경에서도 이용할 수 있도록 하였습니다.
그 외 기기 형태 및 브라우저별로 지원하는 포맷이 상이한 미디어 입출력을 라이브러리를 이용해 해결하였습니다.
OpusMediaRecorder.js (.webm audio for STT API)
OpusRecorder.js (.ogg audio for 발음평가 API)

Redis DB 활용

기존 이메일 인증에만 사용하던 Redis를 이용하여 주제 선택 데이터를 전시하는데 사용했습니다.
먼저 데이터를 텍스트 파일에 저장한 후 서버가 시작될 때 Redis DB로 퍼올리도록 하여 주제 선택화면 조회 시 Redis DB를 사용하도록 했습니다.
Redis DB로 읽어들일 텍스트 파일
@Slf4j @Component @Profile({"dev", "prod"}) @RequiredArgsConstructor public class BaseDataInit { private final TopicWritingService topicWritingService; private final TopicDialogueService topicDialogueService; @EventListener(ApplicationReadyEvent.class) public void initTopicDialogue() throws IOException { log.info("init topic dialogue base data"); topicDialogueService.deleteAll(); ClassPathResource classPathResource = new ClassPathResource("data/topic_dialogue.txt"); InputStream is = classPathResource.getInputStream(); BufferedReader br = new BufferedReader(new InputStreamReader(is)); List<TopicDialogue> list = new ArrayList<>(); br.lines().forEach(line -> { if (!StringUtils.hasText(line)) { return; } String[] topics = line.split(","); String topicCategoryKorean = topics[0]; String topicKorean = topics[1]; TopicDialogue topicDialogue = TopicDialogue.builder() .topicCategoryKorean(topicCategoryKorean) .topicKorean(topicKorean) .build(); list.add(topicDialogue); }); topicDialogueService.addAll(list); } ... }
Java
복사
초기화 부분

기타

외부 API 호출 시 사용한 HttpURLConnection을 WebClient로 변경
TTS API를 Naver Clova에서 Microsoft Azure Speech로 변경
DTO를 JSON으로 반환하는 컨트롤러에서 반환형을 Result 클래스로 래핑해서 반환
DB를 Oracle에서 MySQL로 변경
설정파일을 Spring profile로 분리(dev, test, prod)
기능 추가
글쓰기, 회화 주제 추천
성우 성별 및 억양 선택 기능
문장 교정 부분 하이라이팅
다크모드
보관함 기능
기타 ChatGPT 프롬프트 및 로직 개선