이번에 이야기할 내용은 이전 포스팅 했던 Apache FTPClient을 이용하여 개발을 할 때 있었던 또 다른 이슈 내용과 경험이다.

FTP 연결을 이용하여 이미지를 보여주던 화면에서 사진이 자꾸 짤려서 보이는 것이었다. 파일을 가져오기 위한 코드 플로우는 다음과 같았다.

 

-> FTPClient 라이브러리의 메서드를 이용
-> InputStream 타입을 리턴
-> FTP 받은 이후 FTP 연결을 disconnect
-> InputStream 에서 byte[] 읽기
-> response 

 

과거에 Stream 에 대하여 공부할 때 Stream은 데이터의 흐름을 처리해주는 타입이라는 내용을 공부했었다. 위의 플로우로 코드를 짜두고 사내 FTP 서버에서 테스트시 아무 문제 없이 이미지가 잘 불러와졌다.
해당 테스트를 하면서 나는 이렇게 이해하였다. InputStream 타입은 데이터의 흐름을 처리해주는 객체다 보니 disconenct 되었어도 해당 타입 내부에 데이터 정보를 가지고 있다라는 생각이었다.

하지만 실제 운영서버 FTP 서버에서는 테스트에서와 다르게 사진이 잘라져서 나왔던 것이었다. 결국 Stream 이란 타입은 단지 흐름을 위한 통로 역할만 해주는 것이 맞았던 것이다.
코드 흐름을 아래와 같은 플로우로 변경하자 사진이 짤리던 현상은 해결되었다.

 

-> FTPClient 라이브러리의 메서드를 이용
-> InputStream 타입을 리턴
-> InputStream 에서 byte[] 읽기
-> FTP 받은 이후 FTP 연결을 disconnect
-> response 

 

 

해당 문제를 해결하면서 운영서버에서 사용하던 FTP 서버는 HDD라서 느릴수 있다는 이야기를 들었고, byte[]를 읽은 후 disconnect 하도록 하였더니 문제가 없이 잘 불러와졌다.
이러한 사실과 관련한 내용들을 찾아보면서 당시의 문제를 예상해보았다.
수정전 코드에서 흐름에서 disconnect 하는 메서드이후 byte를 읽었지만, I/O 쓰레드는 별개의 쓰레드였고, disconnect 실행 후 I/O 쓰레드가 연결이 끊기기전에 byte를 읽는 코드가 실행되어 연결이 끊어지기 전까지 데이터를 받았을 것이다.
사내 FTP 서버의 경우 운영서버에서 사용하던 FTP 서버보다 속도가 빨랐기에 연결이 끊기기전에 이미지 데이터를 모두 보내줄 수 있었지만 운영서버에서 사용하던 FTP 서버는 그러지 않았을 것이다. 따라서 수정한 코드는 모든 데이터를 확실하게 받고 disconnect 하게 되었기에 문제가 없었을 것이다.

Stream을 주고 받는 데이터 그 자체라고 착각하던 나였기에 해당 경험은 잘못 알고있던 부분에 대해서 제대로 이해하게 해주는 좋은 경험이 된 것 같다. 또한 쓰레드에 대한 내용들을 찾아보면서 실제 데이터의 흐름에 대해 더 생각해 볼 수 있는 기회가 되었다.

이 내용은 Spring AOP를 이용하여 API 요청정보를 DB에 저장하는 기능을 구현할 때 알게된 정보이다.

당시 구현하고자 했던 기능은 어노테이션을 이용한 Advisor 클래스를 등록한 이후 API Controller 단에 해당 어노테이션을 적용하여 Request 의 Body 그리고 결과값으로 return 되는 값을 DB History 테이블에 저장하는 기능이었다.

해당 기능을 Request Body를 DB에 저장하기 위해 Advisor 클래스 @Before에서 읽어야 했고 구현하였고, Controller 에서 또한 요청 정보를 확인하기 위해 Request Body를 읽어야 했다. 하지만 코드를 작성한 후 테스트한 결과 Body를 읽을 수 없다는 Exception을 만나게 되었다.

구글링을 한 결과 애초에 Request Body 는 한 번만 읽어진다는 사실을 확인하였다. 해당 부분을 해결하기 위해선 HttpRequest 객체 자체를 Wrapper 객체로 감싸서 HttpRequest 객체 자체를 새로 등록하는 형태로 해결하는 방법이 있다는 내용들이었다. Wrapper 객체에 읽었던 Body 내용을 캐싱 할 수 있게 저장해서 사용하는 방법이었다.

해당 방법으로 해결하고자 하였으나 이사님과 상의한 결과 Request 객체 자체를 새로 등록하는 방법은 시스템 전체에 변경을 주는 방법이다 보니 위험하다는 의견을 주셨다. 고민 끝에 결국 내가 택한 방법은 Request 자체에 Body 읽은 값을 다시 setParameter 하여 저장하는 방법이었고, 문제 없이 해당 기능 구현을 완료할 수 있었다.

관리자 페이지 대시보드 관련해서 스칼라 쿼리를 수정해서 성능향상했던 경험을 이야기 해보고자 한다

처음 SQL 문을 배울때는 그냥 모든게 서브쿼리라고 생각했지만 서브쿼리의 위치에 따라 그 종류는 3가지로 나눌 수 있다. 

 

1. 스칼라쿼리: Select 절에서 사용

2. 인라인뷰: From 절에서 사용

3. 단일 행, 다중 행, 다중 열 서브쿼리: 기타 조건절에서 사용

 

그 중 오늘 이야기할 경험은 스칼라 쿼리에 대한 것이다.

스칼라 쿼리는 아래와 같이 Select 절에서 사용되는 서브쿼리이다

SELECT (SELECT COUNT(*) FROM TABLE A WHERE A.MONTH = M.MONTH AND A.COLUMN = 'SOMETHING1')  <--
     , (SELECT COUNT(*) FROM TABLE A WHERE A.MONTH = M.MONTH AND A.COLUMN = 'SOMETHING2')  <-- 스칼라쿼리
     , (SELECT COUNT(*) FROM TABLE A WHERE A.MONTH = M.MONTH AND A.COLUMN = 'SOMETHING3')  <--
  FROM MONTH_DATA M;

 

나의 경험에 대한 이야기를 시작해보자면 그날은 운영서버에 배포가 있던 날이었다.

배포가 끝난 후 운영서버 이곳저곳을 확인하던 중 대시보드의 초기 로딩속도가 이상할정도로 느린것이 보였다.

해당 페이지 진입시 호출되는 API 쿼리를 확인한 결과 위의 쿼리와 비슷한 형태였다.

(DB 는 참고로 MYSQL을 사용중이었다.)

월별 특정 데이터에 대한 COUNT 내용을 각 스칼라 쿼리에서 뽑아 내는 상황이었다. 해당 쿼리를 따로 DB 툴을 사용해서 실행해본 결과 3.XXs의 실행 속도를 보이고 있었다. 최초 작성하였던 팀원은 데이터가 충분하지 않던 개발서버 기준 데이터로 확인하다 보니 쿼리의 속도에 대한 문제를 확인 못한것 같았다. 운영서버의 경우 테이블 A에 양이 꽤나 쌓였고 이부분에서 속도가 느려진것 같다는 생각이 들었다. 

 

여러개의 스칼라 쿼리가 이어져 있었기에 관련한 속도 최적화에 대한 내용들을 찾아보았다. 그 내용들을 대략 정리하자면 아래와 같다.

 

1. 스칼라 쿼리는 캐싱효과가 있어 동일한 값을 반환하는 상황에서는 성능 향상의 가능성이 있다.

2. 스칼라 서브쿼리가 조회하는 쿼리의 조건이 변경되어 캐싱되지 않으면 성능이 떨어질 가능성이 있다.

3. 스칼라 서브쿼리의 조회 데이터가 많다면 성능이 떨어질 수 있다.

 

당시 쿼리를 대략적으로 표현했지만 최종 그결과는 최근 7개월간의 결과를 보여주는 데이터 조회쿼리였기에 총 7 row만 조회되고 있었기에 캐싱 기능의 필요성은 낮았다. 또한 조회되는 row 마다 매번 다른값(월별)을 조회해야했기에 캐싱이 되지도 않았을 것이며, TABLE A 자체의 데이터가 쌓여있다보니 스칼라 서브쿼리 조회 자체에 시간이 많이 소요되었을 것이다. 따라서 스칼라 서브쿼리의 성능 장점은 살릴수 없었고 단점만 부각되는 상황이었다.

 

해당 쿼리를 최적화한 형태는 대략 아래와 같다. 

SELECT COUNT(CASE WHEN A.COLUMN = "SOMETHING1" THEN 1 END)
     , COUNT(CASE WHEN A.COLUMN = "SOMETHING2" THEN 1 END)
     , COUNT(CASE WHEN A.COLUMN = "SOMETHING3" THEN 1 END)
  FROM MONTH_DATA M
  JOIN M.MONTH = A.MONTH
 GROUP BY M.MONTH

 

 우선 TABLE A에 대한 부분을 스칼라 서브쿼리를 조인 서브쿼리로 변경하여 TABLE A 에 전체 테이블 조회의 횟수를 줄였다. 변경 전 쿼리의 경우 TABLE A 에 대한 전체조회를 각 스칼라쿼리가 실행될 때마다 했을 것이다. 그러나 조인을 통하여 그 횟수를 줄였다. 그리고 COUNT 함수의 경우 내부의 값이 NULL 이 아닌 ROW 의 개수를 COUNT 하기때문에 위와 같이 작성할 경우 조건에 해당하지 않은경우 NULL 이 되어 COUNT 되지 않는다는 사실을 이용하여 월별 데이터를 조회하였다.

 

위와 같이 쿼리를 변경한 이후 해당 데이터의 조회속도 DB툴 기준으로 0.03s~0.04s 로 향상되었다. 초기 쿼리가 3초대였던 것을 생각하면 거의 100배에 가까운 속도를 향상시켰다!

해당 경험을 통하여 스칼라 쿼리에 대한 정보들을 찾아보며 쿼리의 실행에 대한 많은 고찰을 할 수 있었다.

 

만일 스칼라쿼리를 쓰고있으며 전체 ROW수가 적다면 나와 같은  스칼라쿼리를 쓰지 않고 JOIN 문을 이용하여 속도향상을 시도해보길 추천한다. 반면에 전체실행쿼리의 ROW 수가 굉장히 많고 스칼라 쿼리 각각의 값이 고정되는 형태라면 전체쿼리를 실행하는동안 스칼라쿼리의 캐싱기능에 도움을 많이 받을 수 있을 것 생각된다. 그런 경우엔 스칼라쿼리를 적극 사용해야할 것이다.

 

 

프로젝트에서 소셜 로그인 / 간편 회원가입 부분을 맡아 개발하였을때의 경험이다.

소셜 가입관련하여 네이버/카카오 이렇게 두곳에 대한 API 를 연결하여 구현하였다.

회원가입을 위하여 회원정보의 이메일, 이름, 전화번호는 필수동의항목으로 해두었다. 테스트를 위하여 내 계정으로 확인하였다. 카카오 API 를 통해 확인한 정보는 +82 010-000-0000 과 같은 형식의 형태였다. 전화번호의 형태를 확인한 나는 공백과 '-' 부분을 이용하여 split 함수 등을 통하여 데이터를 원하는 형태로 가공하여 회원가입을 하는 형태로 코드를 짜두었다.

 

테스트상 문제는 없었고 실제 프로젝트 오픈 후 운영기간도 꽤나 지나고, 회원가입, 로그인쪽 관련한 기억이 내 머릿속에서 사라져 갈때 쯔음 내가 예상치 못한 3가지의 문제가 발생하여 나를 당황시켰다... 😥

 

 

1. 필수 동의항목에 대한 데이터가 없을 수가 있다...?

카카오 API 를 이용시 카카오 Developers 에서 애플리케이션을 만들고 API 에 대한 설정을 할 수 있는데

카카오계정(이메일), 이름, 카카오계정(전화번호)에 대하여 위와 같이 설정을 해두었다. 필수 동의 항목이니 해당 항목들에 대하여 정보를 다 제공받을수 있겠지? 라는 내 생각은 보기 좋게 빗나갔다... 

한 회원이 이름이 없이 회원가입을 해버린것이다... 카카오 회원정보에 이름없이 가입한 회원이 있었던것으로 생각된다..

값이 없는건 생각하지 상황이었기에 카카오 Developers 설정쪽을 확인한 결과

위 이미지와 같은 설정을 발견하여 이메일과 이름에 대하여 해당 설정을 추가해주었다. 전화번호 같은경우에는 해당 설정이 존재하지 않았다. 카카오 회원가입시 전화번호는 반드시 입력해서 데이터가 없는 경우가 없나? 라는 생각이 들었다.

 

 

2. 필수에 수집도 받을수 없는데 ... 없어? 

하지만 그런 내 예상은 보기좋게 빗나갔다. 이름이 없이 가입한 회원을 확인한 이후 API 로그 데이터를 확인해보던 중 심지어 전화번호가 없는 경우를 발견한 것이다. 해당회원은 회원테이블을 확인한 결과 해당 회원으로 예상되는 회원은 없었다... 아마도 전화번호 데이터 가공관련한 코드에서 오류로 인해 회원가입에 실패한것으로 예상된다.... ㅠㅠ

카카오에서는 전화번호와 더불어 'has_phone_number' 라는 값을 함께 리턴 해주고있었다.

필요하다고 생각되는 값들만 신경쓰고 리턴된 데이터를 자세히 확인하지 않은 내 불찰일지어다...

해당 키값을 확인하여 만약 전화번호 데이터가 없을시 해당 소셜계정으로는 가입할 수 없다는 안내문구를 주는 형태로 처리하였다.

 

심지어 네이버의 경우는 필수 값으로 설정하여도 " " 빈값으로 들어오기도 하였다...

 

 

3. 형식이 다를수가 있다...?

내 계정으로 테스트 한 경우 리턴된 전화번호의 형태를 확인했던 나는 당연히 +82 000-000-0000 의 형태로 모든 전화번호 데이터가 들어올줄 알았다. 그러나 그것은 오산이었다.... 몇몇 국가의 휴대전화번호 패턴의 경우 하이픈('-')이 없는 형태의 전화번호 형태였던 것이었다. 000 000 000 000 이런식으로 국가코드 3자리 이후 전화번호가 공백으로 띄워져있던 형태였다... 

 

 

외부 API를 사용할 경우 위와 같은 상황은 언제든 발생할 수 있는 것 같다. 특별히 협의해서 인터페이스를 정의한 상황이 아니고 공개된 API를 사용하다보니 API 문서에 설명이 누락된 부분에 대해서는 실제 데이터를 통해서 확인해야하며, 적절한 유효성 검사를 추가하여 검증하는 자세를 가져야 겠다라는 생각이 들었다.

 

프로젝트 진행중에 NAS서버와 FTP 로 연결하여 이미지 파일을 저장, 보여주는 화면을 개발했을때 생겼던 문제이다.

FTP 서버 연결을 위해 구글 검색 후 Apache Commons Net( FTPClient (Apache Commons Net 3.10.0 API) 라이브러리를 이용하였다.

 

해당 문서에 들어가면 다음과 같은 기본 Example 코드를 제공해준다.

    FTPClient ftp = new FTPClient();
    FTPClientConfig config = new FTPClientConfig();
    config.setXXX(YYY); // change required options
    // for example config.setServerTimeZoneId("Pacific/Pitcairn")
    ftp.configure(config );
    boolean error = false;
    try {
      int reply;
      String server = "ftp.example.com";
      ftp.connect(server);
      System.out.println("Connected to " + server + ".");
      System.out.print(ftp.getReplyString());

      // After connection attempt, you should check the reply code to verify
      // success.
      reply = ftp.getReplyCode();

      if (!FTPReply.isPositiveCompletion(reply)) {
        ftp.disconnect();
        System.err.println("FTP server refused connection.");
        System.exit(1);
      }
      ... // transfer files
      ftp.logout(); 
    } catch (IOException e) {
      error = true;
      e.printStackTrace();
    } finally {
      if (ftp.isConnected()) {
        try {
          ftp.disconnect();
        } catch (IOException ioe) {
          // do nothing
        }
      }
      System.exit(error ? 1 : 0);
    }

 

해당 코드와 구글에 다른 개발자들이 남긴 흔적들을 템플릿-메소드 패턴으로 개발을 하였다.

파일을 가져오는 메서드를 반복하여 여러 파일을 불러오려고 시도하였으나 disconnect를 하지 않은상황임에도 FTPConnectionClosedException 가 발생하였기에 한 번에 하나의 파일정보만 가져올수 있는것으로 판단하였다.

따라서 여러 이미지를 불러오기 위해 이미지 태그를 사용하여 이미지별 Get 요청을 받아 이미지들을 Response 하다보니 요청하는 형태로 하였다.

 

코드를 완성한 후 테스트를 하다보니 한 번의 FTP 요청에 1~2초 정도 걸리는 상황을 발견하였다.

몇몇 화면에서는 이미지를 다수 보여줘야하는 상황이었으며, 이미지가 전부 로딩되는데 상당한 시간이 소모되었다. 

 

개발 후 테스트할 초기에는 단건씩 연결해야하는 상황과 FTP 서버 자체의 속도 문제인가라는 생각에 어쩔수 없다라고 생각했다. 하지만 뭔가 해결 방법을 찾고 싶어 확인하는 중에 신기한 현상(?)을 발견하였다. 난 정확히 얼마나 걸리나 파일을 가져오는 메서드 부분에 시간 로그를 찍어보았는데 빠르게 되는것이었다. 

 

어...째서? 그렇다면 왜 어디서 느리지 ? 라는 궁금증과 함께 코드 사이사이 시간로그를 모두 찍어보며 테스트한 결과 위 Example 코드에서 logout 메서드를 실행하는 곳에서 상당한 지연시간이 있음을 확인하였다.

      ... // transfer files
      
      
      ftp.logout();  <--- 바로 이곳!
      
      
    } catch (IOException e) {

 

 

아니 어째서 로그아웃이...

이후 해당 로그아웃 함수를 주석처리 후 테스트를 실행한 결과 상당한 속도의 향상이 있었다. 

로그아웃을 하지 않으면 문제가 없나 테스트하기 위해 수십장의 이미지를 불러오는 화면을 만들고 새로고침을 통하여 반복 테스트 해본결과 별다른 문제는 발생하지 않았다. 아마도 finally 코드 부분에서 결국 disconnect 하기때문에 문제가 발생하지 않는것으로 예상된다. Document 에서도 해당 메서드(logout)에 대해 단순히 QUIT command를 전송한다고만 되어있으며 IOException이 발생할수 있다 정도로만 설명이 적혀있었다.

public boolean logout() throws IOException

Logout of the FTP server by sending the QUIT command.
Returns:
True if successfully completed, false if not.
Throws:
FTPConnectionClosedException - If the FTP server prematurely closes the connection as a result of the client being idle or some other reason causing the server to send FTP reply code 421. This exception may be caught either as an IOException or independently as itself.
IOException - If an I/O error occurs while either sending a command to the server or receiving a reply from the server.

 

코드 라인라인마다 시간로그를 찍어보는 무식한(?) 방법이지만 속도향상을 할 수 있어서 만족했다.

 

 

혹시 FTPClient 를 사용하다 느려져서 검색하다 오신분을 위한 

요약: logout() 를 주석처리해보세요...

운영서버 및 개발서버에서 겪었던 일이다.

테이블에 컬럼을 추가하고 테스트를 해야하는 상황이었다. 

운영서버 배포시 소스코드는 추가된 컬럼을 운영서버 테이블에 추가하지 않아 문제가 있던 경험이 있던지라

개발서버 DB, 운영서버 DB 모두 테이블에 컬럼을 추가해두었다.

 

이후 테스트를 위해 특정 데이터의 상태값을 DBeaver 툴을 이용해서 수정하였다.

그런데 아래와 같은 메세지와 함께 Update가 안되는 것이었다

Error Code: 1136. Column count doesn't match value count at row 1

 

해당 에러문을 들고 구글로 얼른 찾아가보았다...

그런데 잉? 

INSERT 문을 사용할 시 컬럼수와 Values 의 값이 맞지 않을때 나오는 에러라고 한다.

나는 데이터 수정을 했는데? 왠 INSERT ? 왜 수정이 안되지? 

DBeaver를 오래 켜두었을때 가끔 에러메세지를 이유없이 뿜어대던 경험이 있었기에 이놈의 DBeaver가 말썽을? 하는 생각이 들었다. 2시간 정도 끙끙대다가 컬럼 추가한것이 문제인가라는 생각에 추가한 테이블 컬럼을 삭제하자 Update 가 잘되었다.

 

퇴근하며 구글링을 하다 원인을 예측할 수 있었고, 다음날 확인한 결과 그 원인은 맞았다...

그리고 그 원인과 함께 운영서버의 데이터가 망가져 있었다 ! ㅠㅠ

 

원인은 바로 트리거 였다. 해당 테이블에 트리거가 걸려있었던 것이다.

트리거 내용은 해당 테이블이 A라 했을때 INSERT, UPDATE 가 일어날 경우 아래와 같은 INSERT 구문이 실행되는 것이었다.

INSERT INTO A_HISTORY
SELECT * FROM A

 

A 테이블에 대한 로그 테이블인 A_HISTORY 에 내역을 저장하기 위한 트리거 였고 * 을 이용하여 INSERT 하고있었다...

그런데 A 테이블에 내가 컬럼을 추가하여 A_HISTORY 테이블과 컬럼숫자가 맞지 않아 에러가 발생한 상황이었다.

슬프게도 운영서버 A 테이블에도 컬럼을 같이 추가하였었기에....

테이블을 되돌리기까지의 2시간동안 운영서버에서 해당 테이블을 UPDATE 하는 API 부분들이 롤백 되어버렸다.

다행이 많은 롤백이 일어나진 않았고, 한 건의 결제 롤백이 있어서 강제로 결제성공 API를 태워 데이터를 원상복구하였다.

 

트리거라는 존재에 대한 고려가 필요하다는 사실을 깨닫는 경험이었다...

운영서버 컬럼추가는 신중하게....

이전엔 컬럼 추가가 누락되어서 실행안되던 경험이 있어서 미리.. 열심히 추가했는데.. ㅠㅠ

이후엔 트리거도 고려한 후에 추가하여야 겠다!

 

한편으론 해당 트리거를 만들어둔 사람이 원망? 되었다 ㅋㅋ....

컬럼을 명시한 것이 아니라 * 을 사용함으로서 이러한 에러 발생의 가능성을 남겨둔것이 아닌가! 하는 생각...

하지만 또 한편으론 임의의 컬럼이 추가 되었을때 이렇게 에러가 발생함으로써 로그테이블에도 해당 컬럼을

추가해야함을 확인할 수 있기도 했다.

 

해당 형태와 같은 트리거를 만들때 컬럼을 명시하는 것이 좋을까 아니면 해당 트리거처럼 * 을 이용하는 것이 좋을까?
컬럼을 명시할 경우 내가 겪은 상황과 같은 문제는 발생하진 않지만 새로이 추가된 컬럼에 대한 히스토리 로그가 남지 않는 문제점이 있다. 반면에 * 을 이용하면 모든 컬럼에 대한 처리가 가능하며 내가 겪은 일과 같이 에러를 발생하면 에러 자체로 문제가 될수 있지만 한편으론 히스토리 테이블에도 컬럼 추가가 필요하단 사실을 깨닫게 할 수 있다.

 

개발에 있어서 방법론적인 부분들에 장단점이 있다라는 사실이 고민의 기로에 서게 만드는것 같다.

이 부분에 대한 정답이 무엇인지 결론을 내리진 못하겠지만

만일 내가 해당 형태의 트리거를 작성하게 된다면 팀원들과 잘 공유하고 인지시켜야 한다라는 생각이 들었다.

또한 테이블에 변경사항이 있을때는 DB 테이블 및 소스 코드뿐만 아니라 DB내의 로직들(프로시져, 트리거) 등을 고려를 잘해야겠다라는 교훈을 얻었다.

+ Recent posts