프로젝트 개요
ChatGPT로 영어를 공부하며 생기는 불편함을 해소하고, 학습 효율을 높여주기 위한 웹 어플리케이션을 만드는 프로젝트입니다. 다양한 자연어 처리 API를 활용하여 사용자가 영어를 효과적으로 학습할 수 있도록 도와줍니다. 본 프로젝트는 기존 지피티처 프로젝트를 리팩터링하였습니다.
•
프로젝트명
•
진행 기간
•
참여 인원
•
URL
프로젝트 제작 동기
최근 자연어 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 프롬프트 및 로직 개선