티스토리 뷰

개발노트

EZ2AC 성과 사진 분석기

메시에 2019. 9. 19. 04:09

100일 앱에 이은 남자친구를 위한 (물론 내 개인적인 공부를 위한 것이기도 하다) 프로젝트 2탄.

 

이지투는 그 파란만장한 역사 (...) 덕에 현재 제대로 돌아간다고 말할 수 있는 아케이드 리듬게임 중에 거의 유일하게 네트워크 개인화 시스템을 지원하지 않는 게임이다. 그래서 기록을 저장하고 남들이랑 공유하기 위해서는 직접 사진이나 영상을 찍어서 남기는 방법밖에는 없다.

 

남자친구가 이지투를 주로 하는데, 핸드폰 사진함에 '이지투 성과' 라는 폴더를 만들어서 성과 사진을 거기 모아서 관리를 하고 있더라. 딱 봐도 불편해 보이는데, 예전에 플레이했던 곡 기록을 찾으려면 쭉 내려가면서 사진 썸네일을 대충 보고 이건가 하고 눌러봐야 한다. 특히 같은 곡의 기록을 갱신했을 경우엔 예전 사진을 지우는데, 이걸 하기 위해서 또 그 곡을 찾으러 쭉 내려봐야 한다. 그러는 걸 보고 좀 답답함을 느껴서 떠올려본 아이디어가 이거다.

 

이 포스팅에서는 이 앱을 만들면서 배웠던 내용들과 내가 생각한 구현 방법, 겪었던 여러가지 이슈들을 정리해 놓는다.

 

쉬운 이해를 위한 그림으로 실제 설계나 개념과 일치하지는 않음 (되는대로 구현부터 하고 그림을 나중에 그린데다 코드도 개판이라...)

 

1. 시스템 구조 및 사용 플랫폼

 

결론부터 얘기하면 클라이언트-서버 방식으로 동작하는 웹 애플리케이션으로 만들었다.

 

사실 처음엔 클라이언트 쪽을 이전의 100일 앱처럼 안드로이드 앱으로 만들려고 했다. 자바스크립트도 잘 모르는 나로서는 클라이언트 사이드 웹 프로그래밍보다는 그나마 최근에 몇 번 건드려봤던 안드로이드 쪽이 더 익숙해서 그쪽이 더 쉬울 줄 알았는... 데,

 

gradle에 Vision API 의존성 넣고, 갤러리에서 사진 불러오는 기능까지는 만들었다. 근데 서버로 사진 업로드하는 부분에서 막혔다. HTML POST 방식으로 문자열 같은걸 넣어서 보내는 건 종종 해봤지만, 파일을 전송해보는 건 만들어본 적이 없어서 잘 몰랐는데, 2번에서 다시 설명하겠지만 Multipart라는 타입으로 전송을 하게 된다. 그래서 AsyncHttpClient, Ion, Retrofit2, Apache HttpClient 등 이걸 지원해주는 여러 라이브러리를 가지고 시도해봤는데 죄다 실패했다. 서버로 파일이 가긴 가는데 (서버 쪽에서 헤더를 뜯어보면 파일 사이즈가 제대로 떴다) 뜯어보려고 했을 때 request.getParts() 부분에서 자꾸 EOFException, SocketTimeoutException 등이 떴다.

 

어디가 문제인지 계속 찾아보다가 안드로이드 말고 간단하게 HTML 폼으로 Multipart POST 요청을 전송해보니까 (서버쪽 코드는 그대로 두고) 제대로 됐다. 그래서 안드로이드를 포기하고 그냥 웹 프로그래밍 공부도 더 할 겸 웹으로 만들기로 했다... EOFException이 뜨는걸로 봐서 안드로이드에서 서버로 파일을 날리는 과정에서 헤더 끝부분 같은 곳에 뭐가 잘못 붙는게 아닐까 추측은 했는데, 해결은 끝내 못하고 넘어왔다.

 

그래서 웹으로 만들기로 하고 보니까 부스트코스 공부하면서 예제로 만들어놓은 Maven Project가 있었는데, 거기서 이것저것 테스트를 하다보니 그게 그대로 내 앱이 됐다... 그래서 프로젝트 이름이 webapiexam으로 되어 있다. (...) 부스트코스 하던걸 그대로 쓴만큼 거기서 배웠던 것까지 딱 사용했다. 즉 JSP랑 서블릿으로 만들었다. 스프링도 아니고 이게 대체 2019년에 하는 프로젝트가 맞는지 원.... 이라는 생각이 들지만 내가 최신 기술을 모르는데 어떡해?

 

서버는 내가 컴퓨터를 항상 켜놓고 있을 수는 없으니까 클라우드를 쓰기로 했다. EC2에다가 Ubuntu 18.04 가상 머신 1대를 만들었고, 거기다가 톰캣이랑 MySQL을 설치했다. JDK 버전은 내 컴퓨터에서 부스트코스 공부하면서 11로 맞춰놔서 11이다.

 

 

2. 서버로 사진 업로드하기

 

HTML POST 방식을 이용해서 파일을 전송하려면 Multipart라는 업로드 방식을 사용해야 하는데, 처음 써봐서 이걸 어떻게 쓰는지 파악하는데 시간이 좀 걸렸다. 대충 설명하면 파일은 그냥 문자열 같은 거 전송할 때하곤 다르게 용량이 크기 때문에, 네트워크상에서 'part' 라고 하는 단위로 잘라서 전송을 하는 것이다. 이 방식으로 파일을 전송하기 위한 HTML 폼 코드는 아래와 같다.

 

<form action="http://localhost:8080/webapiexam/roles" method="post" enctype="multipart/form-data">
파일 : <input type = "file" name="upload" />
<input type="submit" value="Upload" />
</form>

서버 쪽, 그러니까 서블릿에서는 request.getParameter... 가 아니라 request.getParts() 라는 메서드를 이용해서 이를 받을 수가 있다.

받았으면 서버에 이걸 실제로 파일 형태로 저장해야 하는데 Part라는 객체를 보면 getInputStream() 이라는 메서드가 있어서 InputStream / OutputStream을 이용한 파일 입출력으로 연결해줄 수 있다. 코드는 아래와 같다.

 

String imageFilePath = "파일 저장 경로";
for (Part part: request.getParts()) { 
  InputStream input = null; 
  ByteArrayOutputStream result = null; 
  PrintWriter writer = null; 

  try{ 
   input = part.getInputStream(); 
   result = new ByteArrayOutputStream(); 
   byte[] buffer = new byte[1024]; 
   int size=0; 
   OutputStream fos = new FileOutputStream(imageFilePath); 
  // 버퍼 크기 1024Byte만큼 InputStream으로부터 데이터를 불러와서 파일에 쓰는걸 반복한다
   while((size=input.read(buffer, 0, 1024))!=-1){ 
      System.out.println("size : " + size); 
      result.write(buffer, 0, size ); 
      fos.write(buffer, 0, size); 
   } 

fos.close();
 // 뒤에 catch문, finally-try-catch문이 더 있지만 생략
}

3. 사진 인식과 파싱

 

구글 Vision API를 사용해서 사진을 인식하는 것까지는 이 포스팅에서 이미 설명한 것과 동일하므로 생략하고, 여기서는 읽은 텍스트를 파싱해서 DB에 넣을 정보를 뽑아낸 방법에 대해 써본다.

 

일단 분석할 대상인 이지투 성과 사진이 어떻게 생겼는지 보자. 참고로 게임의 버전은 타임 트래블러 (TT), 5K STANDARD 모드 기준이다. 남자친구가 주로 TT로 5K를 플레이하는 유저이기 때문에 이걸 기준으로 분석을 한 것인데, NT나 FINAL도 인터페이스가 거의 판박이기 때문에 (테스트를 많이 해보진 않았지만) 별 문제없을 것으로 보인다. NT 이전의 버전은 당연히 안된다. (애초에 NT 이전의 버전은 BERA를 빼면 보기도 힘들고, BERA는 리절트 화면에 곡 제목이나 난이도같은 정보가 안 나오기 때문에 분석할 수가 없다)

 

이 사진에서 뽑아내고 싶은 정보들은 이런 것들이다.

 

- 곡 제목: Don't Say Anything

- 레벨: 13

- 난이도: HD

- 정확도: 97.9%

- 점수: 699791

 

세부 판정 (중앙에 나오는 TOTAL NOTES부터 KOOL, COOL, ... MAX COMBO의 수) 도 일단 뽑아내기는 했는데, 상대적으로 중요도는 떨어지는 정보이면서 인식률은 미묘해서 사용자에게 보여주는 정보로 쓰지는 않기로 했다.

 

그리고 구글 Vision API의 getAnnotationsList().get(0).getDescription() 메서드가 던져준 결과는 아래와 같다.

 

Text : STAGE CLEAR STANDARD FINAL STAGE 1ST PLACE 13 RANK Don't Say Anything HD MIX LV Dont Say Amything 2382 TOTAL NOTES 2285 KOOL JUD. 0095 COOL 0001 GOOD 0000 MISS 0001 FAIL 097.9% RATE SP cOM r ouT 2243 0699791 MAX COMBO SCORE USED EFFECTOR CREDIT(S) 0 0/4

몇 가지 정보들은 화면상의 다른 텍스트들과 다른 특별한 형식을 가지고 있어서, 정규표현식 (Regular Expression) 을 이용해서 찾아낼 수 있었다. 자바에서 특정한 String이 정규표현식이랑 일치하는지 알아보려면 Pattern이랑 Matcher라는 클래스를 이용하면 되는데 예를 들면 이렇다.

 

Pattern p1 = Pattern.compile(정규표현식);
Matcher m1 = p1.matcher(string);
if (m1.matches())
  ...

 

- RATE: Pattern.compile("^\\S+(%)$");

텍스트 전체를 통틀어 %라는 문자는 한번밖에 나오지 않으므로 %라는 문자 앞에 붙어있는 문자들을 인식하면 된다. 곡 제목에 %가 들어가는 곡은 아마... 이지투엔 없는걸로 알고 있다. 오투잼이나 펌프였으면 망했다

 

- SCORE: Pattern.compile("^\\w[0-9][0-9][0-9][0-9][0-9][0-9]$");

7자리 숫자가 나오는 부분은 SCORE 표시 부분이 유일하다. 7자리 숫자로 된 곡은... 없겠지? 200억 후속곡으로 200만 같은게 나오지 않는 이상...

 

- 레벨: Pattern.compile("^[0-9][0-9]$");

정말 다행히도, 두자리 숫자 역시 레벨 부분을 제외하면 나오지 않는다. 근데 이 글을 쓰면서야 생각한건데 레벨이 한자리 수면 어떡하지? 일단 포스팅부터 쓰고 나중에 생각해봐야겠다. 나나 걔나 한자리 수 레벨은 잘 안하니까.

 

- 난이도: if (text.equals("NM") || text.equals("HD") || text.equals("SHD") || text.equals("EX")) ...

얘는 숫자가 아니라 문자인데, 그나마 4가지로 딱 정해져있어서 (NM/HD/SHD/EX) 그냥 해당 문자열로 찾아도 됐다. 정확하게 일치하는 경우만 찾기 때문에 곡 제목 중간에 쟤네들이 껴 있어도 상관 없다.

 

문제는 곡 제목인데... 위의 다른 정보들과 달리 딱히 정해진 형식이 없다. 항상 텍스트가 출력되는 순서가 똑같다면 앞뒤로 나오는 텍스트를 봐서 위치를 잡으면 되는데 (예를 들어 위 예시에서는 'RANK' 랑 'HD MIX' 사이에 곡 제목이 떴으니 RANK라는 문자열이 나오면 그 다음부터 곡 제목이라고 인식) 그렇지가 않다. 예를 들어서 다른 사진을 인식했을 땐 이런 결과가 나왔다. 빨간색이 곡 제목이다.

 

STAGE CLEAR FINAL STAGE STANDARD FUTURE NIGHT 14 RANK ST PLACE SHD MIX LV ...

왜 이런 결과가 나올까? 구글 Vision API의 저 메서드가 텍스트를 오른쪽 위에서부터 왼쪽 아래로 내려가면서 탐색을 하는 것으로 보이는데, y축 좌표가 비슷하면 사진의 미묘한 각도 차이에 따라서 먼저 나오는 텍스트가 그때그때 달라지는 것이다. 사진을 보면 곡 제목 (Don't Say Anything) 이랑 레벨, 'RANK' 라는 글자, '1ST PLACE' 라는 글자가 같은 줄에 있다. 그래서 어떤 경우엔 곡 제목보다 'RANK' 가 앞에 뜨고, 어떤 경우엔 뒤에 뜨고, 어떤 경우엔 'RANK' 랑 곡 제목 사이에 레벨이 껴들어가고 그러는 것이다.

 

내가 생각해낸 방법은 곡 제목이랑 비슷한 y축 위치에 있고 몇 번 테스트해본 결과 곡 제목 근처에 잘 뜨는 다른 단어들을 stopword라고 해서 필터링 처리해버리는 것이다. 그리고 곡 제목보단 확실하게 위에 나오는 단어를 기준삼아 그 단어를 찾았을 때부터 '곡 제목 탐색 모드' 로 진입하고, 곡 제목보다 확실하게 밑에 나오는 단어를 찾으면 '곡 제목 탐색 모드' 를 끝내는 것이다. '곡 제목 탐색 모드' 인 동안 찾아낸 단어들은 곡 제목으로 인식하는데, 미리 정해둔 stopword는 제외한다. 코드는 이렇다.

 

String[] stopwords = new String[] {"STAGE", "FINAL", "PLACE", "RANK", "1ST", "ST", "2ND", "ND", "3RD", "RD", "4TH", "5TH", "TH", "LV", "NM", "HD", "SHD", "EX"}; // 곡 제목 근처에 뜰 수 있는 단어들의 후보

if (text.equals("STANDARD")) {  // 'STANDARD' 라는 단어는 확실히 제목보다 위에 나온다
  title_search_flag = true;  // 'STANDARD' 를 찾으면 그때부터 곡 제목을 찾기 시작
  continue;
}

if (title_search_flag) {      
     if (Arrays.asList(stopwords).contains(text)) // stopwords에 포함된 단어를 찾으면 곡 제목으로 생각 X
        continue; 
     else if (text.equals("MIX")){ // 'MIX' 는 확실히 제목보다 밑에 나오므로, 'MIX' 를 찾으면 곡 제목 탐색 중단
       title_search_flag = false; 
       continue; 
     } 
     else 
       song_title.add(text);
 }

이렇게 해주니까 곡 제목은 그럭저럭 괜찮게 인식하는 것 같았다... 영어나 숫자인 경우에만. 곡 제목이 한글인 곡들이 간혹 있는데 이런걸 넣으니까 와장창! 

 

한글은 어떻게 인식할까? 구글 Vision API에 대해서 얼핏 알아봤을 땐 여러 언어에 대한 인식을 지원한다고 했던 것 같았는데... 다시 한번 검색해보니까 LanguageHint라는 옵션이 있었다. 그러니까 기본적으로도 AI가 여러 언어를 알아서 인식하려고 노력하긴 하는데, LanguageHint라고 해서 특정 언어를 던져주면 그 언어에 맞게 최적화를 해준다는 얘기. 내부적으로 구현이 어떻게 되어있는건지 쪼금 궁금해지긴 했는데, 여튼 가져다 쓰는건 어렵지 않다. 다만 주의할 점이 있다면 이 addLanguageHints() 라는 메서드를 쓰려면 그냥 Image가 아니라 ImageContext라는 객체를 이용해야 한다는 것이다. 

 

ByteString imgBytes = ByteString.readFrom(new FileInputStream(imageFilePath));
ImageContext img_ = ImageContext.newBuilder().addLanguageHints("ko").build();
Image img = Image.newBuilder().setContent(imgBytes).build();
Feature feat = Feature.newBuilder().setType(Type.TEXT_DETECTION).build();
AnnotateImageRequest req = AnnotateImageRequest.newBuilder().addFeatures(feat).setImage(img).setImageContext(img_).build(); requests.add(req);

그럭저럭 인식하는 것 같다. 띄어쓰기를 너무 좋아하는 것 같긴 하지만.... (마지막의 '빛바랜 영혼' 의 경우 게임상에서 원래 제목은 띄어쓰기 없이 '빛바랜영혼' 이다) 뭐 상대적으로 사소한 문제다.

 

 

4. 데이터베이스에 넣기

 

로컬 환경에서 테스트했을 땐 DB에 입력하는 작업은 간단했다. 부스트코스 JDBC 포스팅에 써놓은 대로 했다.

 

records는 테이블 이름이고, username, date, title... 등은 3번 과정을 거쳐 파싱해낸 결과물 또는 자체적으로 생성한 값이다. date는 Calendar 클래스를 이용해서 오늘 날짜를 뽑아서 넣었고, file_name은 지정된 경로 + 날짜 및 시간을 이용해서 알아서 생성한다.

 

주의할 점은... 위 예시의 'Don't Say Anything' 처럼 곡 제목에 작은따옴표 (') 가 들어가는 경우 replace("'", "''") 를 써서 이스케이프를 해줘야 했다. 이걸 안해주면 저 곡 제목의 작은따옴표를 SQL에서 문자열을 표시하는 작은따옴표로 착각해서 에러를 발생시키므로 유의한다.

 

String insert_query = "INSERT INTO records VALUES('" + username + "', '" + date + "', '" + title + "'," + level + ",'" + diff + "'," + notes + "," + kool + "," + cool + "," + good + "," + miss + "," + fail + "," + rate + "," + score + "," + combo + ",'" + file_name + "');";
st.execute(insert_query);

 

5. 화면에 띄워주기

 

DB에 기록이 저장됐으면 이제 그걸 보기좋게 페이지에 띄워주면 된다.

 

- 보통 스마트폰으로 기록을 확인할 것이라고 생각하기 때문에, 모바일도 쉽게 지원하도록 반응형 웹으로 만들기 위해서 부트스트랩을 사용했다. 물론 반응형이 필요해서 그런 것뿐 아니라 부트스트랩 사이트의 예제에서 템플릿도 적당히 긁어왔다.... 사실 난 CSS를 여전히 잘 모른다.

 

- index 파일을 jsp로 만들고 거기다가 DB에서 데이터를 가져오는 자바 코드를 쑤셔넣었다. 사실 이게 바람직한 설계는 당연히 아니지만 부트스트랩 쓰겠다고 옛날에 만들었던 페이지에서 소스를 가져오는 과정에서 어쩌다보니까 그렇게 됐다. (...) 좀 더 자세히는 JDBC API를 쓰는 직접적인 코드는 DAO 클래스로 분리해두긴 했는데 그걸 호출해서 값을 가져온 다음 정리하고 for문을 돌려서 HTML Element를 생성하는 코드를 박아놨다.

DB 테이블의 레코드 수만큼 기록을 표시하는 <a> ... </a> 태그를 만들어야 했는데 for문, 그러니까 자바 코드 중간에 HTML을 어떻게 넣을까? 찾아보니까 이런 식으로 할 수 있더라. 스크립트릿 영역 (<% ... %>) 이 중간에 HTML이나 다른걸로 끊어져 있어도 계속 이어서 실행이 된다는 점을 이용한 것이다.

 

<%
for (int i=0; i<list.size(); i++){ // list.size() 는 DB 테이블에서 갖고온 기록 수
 ...
 // (갖고온 기록들을 적당한 문자열로 가공하는 코드)
 ...
%>
  <a href="#" class="list-group-item ... // 기록 1개를 표시하는 부분에 해당하는 HTML 코드
    <h5 class="mb-1"><%=title %> ... // 제목 표시
    ... // 생략 (레벨, 난이도, 날짜, 정확도, 랭크, 점수 표시)
  </a>
<% } %>

이러면 for문이 도는 횟수만큼 저 HTML 코드가 찍힌다는 얘기다.

 

- 앞에서 생략했던 기록을 표시하기 위한 부분은 collapse라는 기능을 사용한다. 이게 뭐냐면 숨겨져 있다가 버튼을 클릭하면 밑에 슥 하고 뜨는... 그런건데, 여기서는 서버에 저장해놓은 기록 사진을 숨겨놨다가 클릭하면 띄우도록 만들었다. 사진을 볼 수 있게 만든 이유는 3번 과정에서 문자 인식을 제대로 못해서 기록이 이상하게 저장된 경우에도 원래 성과를 확인할 수 있게 하고, 세부 판정도 볼 수 있게 하기 위함이다.

 

왼쪽처럼 있다가, 기록을 클릭하면 오른쪽처럼 숨겨져있던 사진이 뜨게 만든 것.

<a href="#" class="list-group-item list-group-item-action flex-column align-items-start" type="button" data-toggle="collapse" data-target="#multiCollapse<%=path%>" aria-expanded="false" aria-controls="multiCollapse<%=path%>">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1"><%=title %> (<%=diff_level %>)</h5>
<small class="text-muted"><%=date %></small>
</div>
<p class="mb-1"><%=rate %>% / <%=rank %></p>
<small class="text-muted"><%=score %></small>
<div class="collapse multi-collapse" id="multiCollapse<%=path%>"> <img src="<%=full_path%>" class="img-fluid"> </div>
</a>

다른 부분들은 잘 모르겠고 (아마 디자인적인 요소겠지) 중요한건 빨간색으로 표시한 부분이다. 버튼에서 지정한 data-target이랑 사진을 담을 div 태그의 id가 같아야 서로 매칭이 돼서 해당 부분을 연다. 그러니까 각 기록마다 고유한 ID를 가져야 하는데, 사진 파일의 이름을 넣는 걸로 해결했다. 

> div 태그의 multi-collapse 클래스: 버튼을 여러개 클릭해도 다 열리는게 아니라 저들 중에 1개만 열리도록 함

> img 태그의 img-fluid 클래스: 화면의 크기가 작아져도 사진이 잘리지 않고 화면비율에 맞춰 줄어들게 함

 

- 한글 제목인 곡을 테스트해보기 전까지 몰랐는데, 한글 제목인 곡이 올라가는 순간 화면에서 깨져서 나오는 문제가 있었다. DB에는 한글로 제대로 들어갔기 때문에 Vision API나 MySQL쪽 문제는 아니고 JSP 파일의 문제인 것 같았는데...

처음 생각했던 건 HTML 파일 맨 위에 <meta charset="UTF-8"> 이렇게 넣어주는... 그쪽 설정에 문제가 있는거라 생각했는데 분명 UTF-8로 제대로 되어있다. 그럼 왜?

> 다음과 같은 JSP 지시자를 사용해서 해결했다.

 

<%@ page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8" %>

JSP 영역이랑 HTML 영역이랑 인코딩은 별개인가보다. 생각해보니 내가 설계를 뭐같이 해서 JSP 파일 하나에 HTML이랑 자바 코드를 같이 쑤셔넣어놔서 그렇지 JSP는 서버 사이드고 HTML은 클라이언트 사이드니까 그럴 법도 하다.

 

 

6. 정렬 기능

 

그냥 DB에 올린 순서대로만 기록을 볼 수 있다면 불편하기 그지없으니 정렬 기능은 필수다.

 

서버로부터 새로운 정보를 받아올 필요가 없으니 클라이언트 쪽에서 해결가능한, 그러니까 자바스크립트가 나설 차례다. 사실 내가 자바스크립트에 너무 자신이 없어서 그냥 서버로 파라미터 보내서 그 값에 따라서 서버쪽에서 정렬한 다음 다시 던져주게 만들까 생각도 했었는데 역시 너무 비효율적이고, 마침 부스트코스 진행하다가 DOM API에 대한 내용이 나와서 참고를 했다.

 

곡 제목, 레벨, 정확도, 점수, 날짜 5가지 기준으로 정렬할 수 있게 했으며 버튼을 눌러서 함수를 호출한다.

<a class="dropdown-item" href="#" onClick="sort_elements('title')">곡 제목 순으로 정렬</a>

(티스토리의 코드블럭이라는 기능을 이제서야 알아서 지금부턴 이거 쓴다....)

 

sort_elements() 함수를 자바스크립트로 만들어야 하는데, 과정을 한번 생각해보자.

 

- HTML 코드에서 '기록 1개' 에 해당하는 덩어리를 다 찾는다. -> querySelector

- 찾은 덩어리들끼리 그 안에 있는 특정한 내용 (title, level, rate...) 을 비교해서 정렬을 시킨다. -> sort 함수

- 기존의 HTML을 삭제하고 정렬된 덩어리들로 새로 채워넣는다. -> innerHTML/outerHTML setter

 

먼저 '기록 1개' 들을 찾는 부분인데, 만들어놓은 HTML 소스를 보니까 각 기록을 감싸는 제일 높은 레벨의 태그는 <a> 였다. 근데 그냥 <a> 를 찾게 시키자니 <a> 태그는 다른데서도 많이 쓰이는 거라서, 뭔가 추가적인 처리를 해줘야 할 것 같았다. 내가 선택한 방법은 기록들이 뜨는 부분 전체를 <div class='record_list'> 로 한번 감싸주고,

var all_records = document.querySelectorAll(".record-list a") // .은 클래스라는 뜻

이렇게 찾았다. 처음에 querySelectorAll이 아니라 그냥 querySelector를 썼다가 헷갈려서 삽질을 좀 했는데 여러 개를 한번에 찾고 싶을땐 All을 써야 한다는 점을 잊지 말자.

 

그 다음으로 정렬인데, 정렬은 워낙 자주 쓰이는 기능이라 어지간한 언어에는 다 이미 구현된 뭔가가 있을거라 생각했고 역시나 있었다. 중요한건 선택 파라미터인 compareFunction이라는 건데, 파라미터를 안 넣고 그냥 sort() 만 쓰면 문자의 유니코드 값 순으로 정렬하지만, compareFunction이라는 함수를 파라미터로 던져주면 그 함수 안에서 뭔가를 한 다음 아래 예시와 같이 리턴한 값의 부호에 따라 위치를 바꿀지 말지를 (정렬하면서) 결정한다.

function compare(a, b) {
  if (a is less than b by some ordering criterion) {
    return -1;
  }
  if (a is greater than b by the ordering criterion) {
    return 1;
  }
  // a must be equal to b
  return 0;
}

생각해보면 그동안 프로그래밍을 해보면서 단순히 정수가 들어가는 배열 뿐 아니라 어떤 객체가 들어가는 배열을 특정한 규칙에 따라 정렬하기 위해 함수를 만들어야 하는 상황은 많이 겪어봤다. querySelector로 가져온 HTML 노드도 객체라고 생각하면 그거랑 똑같다.

 

다만 더 필요했던 지식은 2가지가 있었는데, 먼저 저 sort() 함수는 배열에 대해 제공되는 메서드인데 querySelectorAll() 로 가져온 all_records가 배열이 아니었다는 점이다. typeof() 로 찍어보니까 object라고 나오더라. sort() 를 써먹으려면 타입을 배열로 바꿔줘야 해서 이런 코드가 필요했다.

	var records_arr = [];
	for (var i in all_records) {
	    if (all_records[i].nodeType == 1) {
	        records_arr.push(all_records[i]);
	    }
	}

 

두번째로 정렬을 하기 위해 필요한 값이 어디에 들어있는지 찾아내야 한다. 여기서는 크롬 개발자 도구의 도움을 받았다. 사실 자바스크립트에서 console.log() 라고 찍은게 이클립스엔 안 뜨길래 어디서 찾아야 하나? 하다가 개발자 도구를 띄워보니 거기 다 뜨고 있더라. 

 

기록 1개에 해당하는 덩어리의 childNodes를 찍어본 결과다. 여기서 각 번호를 눌러보면 'innerHTML' 이나 'innerText' 항목에서 내가 넣은 값들 (title, level...) 을 찾을 수 있다.

 

레벨 순으로 정렬한다고 하자. level은 어디에 있냐면 1번 childNode의 innerText를 보면...

 

innerHTML: "↵ <h5 class="mb-1">Lovely Day - Remaster (EX / 13)</h5>↵ <small class="text-muted">2019-09-02</small>↵ "
innerText: "↵ Lovely Day - Remaster (EX / 13)↵ 2019-09-02↵

여기서 13이라는 숫자를 뽑아내면 된다. 내가 만든 HTML 구조상 레벨은 무조건 '/' 랑 ')' 사이에 저렇게 뜨게 되어있으므로, split() 함수를 잘 쓰면 될 것 같다. 아래는 레벨에 따라 정렬하는 코드이다.

	else if (type === 'level'){
		records_arr.sort(function (a, b){
			var level_a_str = a.childNodes[1].innerText.split('/ ');
			var level_b_str = b.childNodes[1].innerText.split('/ ');
			level_a_str = level_a_str[1].split(')');
			level_b_str = level_b_str[1].split(')');
			
			return level_b_str[0] - level_a_str[0];
		});
	}

b에서 a를 빼서 값을 리턴하면 내림차순이 되고, 반대로 빼면 오름차순이 된다.

 

이제 기존의 HTML을 없애고 정렬된 새로운 HTML 덩어리들로 페이지를 채우면 될텐데, 어떻게 할까? 다행히 곧바로 생각해낼 수 있었던건 부스트코스에서 최근에 'innerHTML, innerText 속성을 setter로도 사용할 수 있다' 라는 내용을 들었기 때문에 가능했다. <div class='record-list'> 영역의 innerHTML을 싹 비우고, records_arr[] 의 내용으로 채워주면 된다. 아래의 코드를 보자.

	var whole_zone = document.querySelector(".record-list")
	whole_zone.innerHTML = "";
	
	for (var i=0; i<records_arr.length; i++){
		whole_zone.innerHTML += records_arr[i].outerHTML;
	}

못봤던 outerHTML이라는 속성이 나온다. 처음엔 그런게 있는줄 몰라서 그냥 innerHTML이라 썼었는데, 그렇게 했더니 정렬 결과가 제대로 뜨긴 하는데 기록 사이의 구분선이 없어지고 간격이 이상해지는 문제가 있었다. 왜냐면 innerHTML이라는 속성은 말 그대로 해당 노드의 '안쪽' 에 있는 내용만을 말하기 때문에 그 노드를 감싸고 있는 태그 자신은 빠져버린다. querySelectorAll() 로 불러와서 배열에 넣은, 즉 records_arr[i] 한 덩어리는 '<a> ... </a>' 인데, 여기서 innerHTML은 <a>, </a> 를 제외한 나머지 '...' 부분이라는 소리다. 그래서 innerHTML을 쓰면 a 태그가 증발해버렸는데, a 태그까지 같이 안고갈 수 있는 속성이 outerHTML이다.

 

레벨 말고 다른 기준에 따른 정렬 방법도 크게 다르진 않다. 날짜의 경우 구성요소가 연-월-일 3개라서 if문을 세번이나 중첩시켜야 했는데 좀 더 좋은 방법이 있을 것 같기도 하구...

 

 

7. 서버로 앱 배포 & 서버 환경에 맞추기

 

로컬에선 문제 없었는데, 서버로 배포했더니 추가로 생겼던 문제들에 대해서 메모해둔다. 일단 로컬에서 만든 앱을 서버로 deploy하는 방법은 이 포스팅에 써놓은 대로 했다.

 

- JDK 버전 문제

 

서버로 앱을 배포하고 처음 테스트해봤을 때 바로 마주친 문제. 내 컴퓨터엔 JDK 11이 깔려있어서 로컬에선 그 환경으로 작업을 했는데, 서버에 깔려있는 JDK는 1.8 버전이라서 그것 때문에 에러가 떴다.

리눅스 쪽에선 openjdk라는 걸 기본적으로 쓰는 모양이더라. 설치 방법은 다음과 같다. 우분투의 apt-get에 기본적으로 없어서 리포지토리를 추가해줘야 했다.

 

sudo add-apt-repository ppa:openjdk-r/ppa
sudo apt-get update
sudo apt-get install openjdk-11-jdk

몇번 시행착오를 겪었는데, 톰캣이랑 openjdk 1.8이 깔린 상태에서 openjdk 11을 추가로 깔았더니 JAVA_HOME 환경 변수가 분명 있는 것 같은데 못찾는다면서 톰캣이 자꾸 에러를 띄웠다. 여러번 반복해서 시도했는데, 기존의 JDK를 삭제하고 openjdk 11을 설치한 다음 톰캣을 설치했더니 됐던 것 같다. (며칠 됐다고 기억이 가물가물.... 좀 어쩌다보니 됐다 이런 느낌이다)

 

 

- Multipart Upload Directory 문제

 

Could not parse multipart servlet request ... (중략) ... The temporary upload location [...경로.../D:] is not valid

 

파일을 업로드하는 순간 등장했던 에러. 처음엔 이게 무슨 영문인지 몰라서 어리둥절했다가 일단 침착하게 메시지를 해석해보기로 했다.

 

에러 메시지에서 띄워준 저 경로로 직접 들어가보니까 'D:' 라는 디렉토리가 확실히 없었다. Multipart Upload를 할 때 각 part들을 임시로 저장해놓는 폴더를 말하는 것 같은데, 왜 저런 경로로 지정이 됐지? 잘 몰랐지만 일단 혹시나 해서 'D:' 라는 디렉토리를 하나 만들어줬다 (mkdir). 그랬더니 permission 에러가 떠서, D: 에다 대고 chmod 777 (모든 권한 허용) 를 쳐줬더니 에러가 사라졌다.

 

근데 앱을 수정하고 다시 deploy할 때마다 저 폴더가 사라져서 (로컬쪽 프로젝트엔 저게 없으니까) 다시 만들어주는 귀찮은 작업을 했는데, 나중에 알고보니 내 코드에 문제가 있었다. 바로 안드로이드랑 연동하겠다고 이것저것 시도해보다가 넣었던 Annotation 한줄이 화근이었는데...

 

@MultipartConfig(maxFileSize=1024*1024*10, location="D:/")

내가 서블릿 클래스 바로 위에 이런 Annotation을 써놨더라. 안드로이드랑 연동하려고 이것저것 시도해봤을 때 누가 저런걸 넣으면 동작할거라고 써놓은걸 보고 넣어놨던건데, 결국 안드로이드는 포기하고 나서 잊고 있었다. 로컬에선 문제없이 동작해서 저 location 설정이 상관없었는데, 리눅스엔 D:/ 라는 경로가 없으니까 문제가 발생했던 것이다. 저 location 부분을 삭제했더니 깔끔하게 동작했다.

 

 

- GOOGLE_APPLICATION_CREDENTIAL 환경 변수 문제

 

로컬 환경에선 이클립스를 썼기 때문에, Vision API 테스트 포스팅에 써놨듯 이클립스의 Run Configuration > Environment 항목에 환경 변수를 넣어주면 됐다. 근데 서버쪽엔 이클립스가 없으니, 어떻게 환경 변수를 세팅해줄까?

 

여기서도 삽질을 꽤 오래 했다. 그냥 시스템 환경 변수로 만들어줘도 된다고 하는데, 내가 리눅스에 익숙하지 않다보니 그걸 제대로 못했다. 어떤 사람은 etc/profile에 넣으라 그러고, 어떤 사람은 bash_profile인가 그런데 넣으라 그러고 다 제각각이었고 하는 것마다 안됐다. 리눅스 배포판이랑 버전마다 환경변수 넣는 파일이 다른 것 같기도 하고 그렇더라.

여튼 결론적으로 성공한 방법은 /usr/share/tomcat8/bin 폴더 (톰캣 실행과 관련된 .sh 파일들이 모여있는 곳) 에다가 setenv.sh 라는 파일을 만들고,

 

EXPORT GOOGLE_APPLICATION_CREDENTIALS=(경로)/secret.json // 경로를 따옴표로 감싸지 않았음

이렇게 한줄 써준 다음 톰캣을 재시작했더니 됐다. 리눅스 시스템 자체가 아니라 톰캣에 환경 변수를 던져준 것이다.

 

저 경로를 따옴표로 감쌌더니 빨갛게 떴는데, 따옴표를 쓰면 안되는 것 같았다. 이전까지 썼던 다른 방법들에서 내가 경로에 따옴표를 넣어서 안됐나 싶기두? 일단 이 포스팅 다 쓰고 나서 다시 다른 방법도 테스트해보자. 리눅스 환경변수에 대해서는 꼭 짚고 넘어가야 할 것 같다.

 

 

- MySQL 접속이 안되는 문제

 

서버 쪽에도 똑같이 MySQL을 설치하고, 로컬 쪽이랑 동일한 root 계정과 비밀번호를 사용했는데 서버 쪽에선 이상하게 Access Denied 오류가 자꾸 떴다. grant all privileges 어쩌구를 root에 먹여도 안 됐다. 사실 root한테 DB 다룰 권한이 없다는게 이상하다. StackOverflow를 뒤져봐도 "그건 니가 비밀번호 잘못 친거임" 이라는 답변이 대부분이었지만 콘솔에서는 잘 접속되고 JDBC로만 접속이 안됐다.

 

결국 왜 root로 접속을 못하는건지 알아내진 못했지만 해결은 했다. root 말고 다른 계정을 만들어서 걔한테 권한을 준 다음 걔로 접속하니까 됐다.

 

create user 'newuser'@'localhost' identified by '비밀번호';
grant all privileges on DB이름.* to 'newuser'@'localhost';
flush privileges;
Class.forName("com.mysql.jdbc.Driver");
String db_url = "jdbc:mysql://localhost/DB이름";
Connection con = DriverManager.getConnection(db_url, id, pwd);

 

- MySQL 인코딩 문제

 

로컬에서는 한글 윈도우에 깔린 MySQL이라서 상관이 없었는데, 서버쪽에 깔린 MySQL에서는 한글을 제대로 인식을 못하는 문제가 있었다. 이 문제는 그나마 인터넷에서 쉽게 답을 찾았다.

 

/etc/mysql/mysql.conf.d/mysqld.cnf 파일에 아래의 내용을 추가한다.

 

[mysqld]
collation-server = utf8_unicode_ci
character-set-server = utf8
skip-character-set-client-handshake // 이걸 안해주면 시스템의 기본 캐릭터셋으로 맞추려고 하는 듯

MySQL을 재시작한 후 'status' 라는 명령어를 입력해보면 MySQL의 버전이라던지 문자셋 같은 정보들이 쭉 뜨는데, 모든 charset이 다 utf8로 맞춰진 것을 확인할 수 있다. 이제 DB에 한글을 입력할 수 있다.

 

 

- 서버 시간 문제

 

사용자는 한국에 있는데, EC2 인스턴스는 북미 리전에 있어서 date가 미국 시간 기준으로 뜨는 문제. Calendar.getInstance() 로 객체를 가져올 때 시간대 설정을 할 수가 있다. 근데 Timezone 설정을 하기 위해서는 TimeZone이라는 별도의 객체가 또 필요하더라. 왜 Calendar 클래스 자체에서 그걸 지원안하고 TimeZone이라는 객체가 따로 필요한지 잘 모르겠지만... 여튼 코드는 한줄이면 된다.

 

Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Seoul"));

 

8. 고려해볼만한 추가 이슈 (2019/09/20)

 

- 다른 이지투 유저들도 좋아할 것 같기두 한데 퍼블릭으로 오픈할 수는 없을까?

 

문제는 이 앱이 클라우드를 이용해서 돌아가는 것이기 때문에 사용료를 내가 지불해야 한다는 것이다. 작은 EC2 머신 한대 요금 정도야 사용자() 만날 때마다 맛있는거 한번씩 얻어먹으면 그걸로 퉁칠 수 있으니까 괜찮은데, 많은 사람들이 사용하게 돼서 Vision API 요금까지 나오면 (Vision API는 Request 건수에 따라 요금이 책정된다) 내가 곤란하니까... 그렇다고 펀딩 같은걸 받자니 그럴만큼 이게 대단하거나 퀄리티 있는 앱도 아니고.

 

Vision API를 안 쓰고 TensorFlow 같은걸로 직접 사진 인식 AI를 구현하면 되지 않을까 생각도 해봤는데 장기적으로는 고려해볼 수 있겠다만 당장 AI 쪽을 더 공부하기는 좀 벅차다. 그리고 아래의 문제도 있다.

 

- 앱 자체의 한계점

 

오락실이라는 환경상 사실 화면에 불빛 같은 방해요소가 많아서 사진을 깔끔하게 찍기가 쉽지 않다. 이 앱 하나 쓰겠다고 리절트 나올때마다 사진을 애써서 깔끔하게 찍으려고 노력한다는 것도 웃기고... 게다가 사용자가 LCD 모니터 기기만 하는 유저라서 그나마 다행이지, 아직까지 오락실 이지투 기기의 대부분을 차지하는 CRT 기기에서는 더더욱 선명하게 사진을 찍어내기가 힘들다. 그래서 이 앱을 퍼블릭으로 오픈한다 하더라도 많은 유저들에게는 인식률이 올해 꼴데 야구 성적 수준으로 나올 것이다.

이건 사진 인식 기술 자체의 문제라서 내가 어떻게 하는데 한계가 있다.

 

- 인식률을 높이기 위한 다른 방법?

 

> RATE나 SCORE가 잘못 인식됐을 경우, TOTAL NOTES와 세부 판정들이라도 제대로 인식됐을 수도 있으니까 그 정보들을 이용해서 RATE, SCORE를 역계산하는 메커니즘을 도입한다: 이건 해볼만한 방법 같다.

 

> 곡들의 정보에 대한 DB를 미리 구축해놓고 인식된 텍스트랑 DB를 대조해서 정확한지 한번 더 체크한다: DB를 직접 구축하는 건 너무 귀찮다. ez2db.com이라는 사이트가 있긴 한데, 그 사이트 관리자 허락 없이 거기 있는 정보를 크롤링해가도 되나? 잘 모르겠다.

 

- 앱 설계나 구현상의 문제점

 

> JSP 파일에 자바 코드가 들어가는 게 좋은 설계는 아니라고 하지만 일단 박아넣어놨다. 부스트코스에서 배웠던 대로 로직은 다 서블릿으로 옮기고, EL이랑 JSTL만으로 출력하는 부분을 재구성할 수 있는지 생각해 보자.

 

> 서버로 앱을 재배포할 때마다 사진이 다 날아간다. 로컬에 (이클립스에) 존재하는 내 프로젝트 상에는 서버 쪽에 올려놨던 사진들이 존재하지 않고 앱을 재배포할 때마다 로컬에 있는 프로젝트가 올라가기 때문이다. 해결 방법을 찾아보자.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/04   »
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
글 보관함