관련 지식
#javascript #node #async #promise

이번엔 이전에 만들었던 ‘sharp 패키지로 이미지 자동 분할(async/await)’ 소스의 성능을 개선해볼 것입니다. 일반적으로 소스를 개선하는것은 알고리즘 작업이 되겠지만 이번에는 알고리즘의 변경 없이 함수의 사용법만 바꿔서 성능을 향상 시켜 보겠습니다.

원인 찾기

느린 원인은 비교적 명확합니다.

이전 코드)

  1. for(let i = 0; i < imgHeight - blankHeight; i += blankHeight) {
  2. let isBlank = await checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight});
  3. if(isBlank) { //현재 위치가 여백 라인이라면
  4. let extractHeight = i - cropInfo.prevTop;
  5. if(!cropInfo.mode && extractHeight > 200) { //200픽셀보다 커야 의미있는 이미지
  6. cropInfo.mode = true;
  7. let option = {left:0, top:cropInfo.prevTop, width:imgWidth, height: extractHeight};
  8. console.log(option);
  9. image.extract(option).clone().toFile(i + ".jpg");
  10. }
  11. cropInfo.prevTop = i;
  12. }
  13. else
  14. cropInfo.mode = false;
  15. console.log(i);
  16. }

위 소스를 대충 정리하면 아래와 같이 되겠습니다.

  1. 이미지의 일부가 여백인지 확인
  2. 일부가 여백이면 이미지로 저장
  3. 1~2 반복

그런데 해당 샘플에서 이미지로 저장하는 경우는 겨우 6회에 지나지 않습니다. 하지만 여백인지 확인을 하는 횟수는 700회가 넘습니다. 즉 대부분이 처리 시간이 여백인지 체크하는 부분에서 발생한다는 것입니다.

그런데 여백인지 체크하는 기능을 생각해 봅시다. 여백인지 체크하는 기능은 위에서 부터 아래로 체크를 하거나 아래에서 부터 위로 체크하더라도 결과가 달라지지 않습니다.

예1)

  1. let isBlank1 = await checkBlankPart({left:blankLeft, top:0, width:blankWidth, height:blankHeight});
  2. let isBlank2 = await checkBlankPart({left:blankLeft, top:10, width:blankWidth, height:blankHeight});
  3. let isBlank3 = await checkBlankPart({left:blankLeft, top:20, width:blankWidth, height:blankHeight});

예2)

  1. let isBlank2 = await checkBlankPart({left:blankLeft, top:10, width:blankWidth, height:blankHeight});
  2. let isBlank3 = await checkBlankPart({left:blankLeft, top:20, width:blankWidth, height:blankHeight});
  3. let isBlank1 = await checkBlankPart({left:blankLeft, top:0, width:blankWidth, height:blankHeight});

예1 과 예2 의 값은 완전히 동일합니다. checkBlankPart()의 호출 순서가 결과에 영향을 주지 않기 때문입니다. 그렇다면 한 가지 결론을 낼수 있습니다. checkBlankPart() 를 하나씩 순서대로 실행시키지 않고 한번에 모두 실행시켜도 된다는 것!
순차적으로 실해할 경우 1+1=2 가 되지만 병렬로 실행할 경우 1+1은 2보다는 확실히 작을 것입니다.

소스의 수정

checkBlankPart() 함수를 호출하고 종료될때까지 기다리지 않기 위해 비동기 형태 호출로 바꿀 것입니다.

  1. //변경전
  2. let isBlank = await checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight});
  3. //변경후
  4. let isBlank = checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight});

그러나 for 루프 안에서 비동기 함수의 호출은 언제나 원치 않는 결과를 보여줍니다. 따라서 소스는 아래처럼 비동기 논리와 동기 논리 부분으로 분리할 것입니다.

  1. for 루프에선 checkBlankPart() 함수를 배열에 저장하며 실행(비동기)
  2. 배열에 저장된 함수의 실행이 모두 끝나면 결과 값을 비교하여 이미지 crop 저장

2번 항목에서 말하는 ‘모든 함수 실행이 종료’ 시점을 알기 위해 Promise 객체의 all() 함수를 사용할 것입니다.

  1. //변경전
  2. let isBlank = await checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight});
  3. //변경후
  4. let fnList = [];
  5. for(let i = 0; i < imgHeight - blankHeight; i += blankHeight) {
  6. fnList.push(checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight}));
  7. console.log(i);
  8. }

먼저 checkBlankPart() 를 비동기로 호출(await 삭제) 하며 fnList 배열에 넣고 있습니다. 간단하죠?

이번엔 모든 함수의 실행이 종료되었을 때를 알기 위해 어떻게 수정하는지 보겠습니다.

  1. Promise.all(fnList).then(function(values) {
  2. });

매우 간단합니다. Promise.all() 함수를 한줄 쓰는것 만으로 모든 함수가 종료 되었을때 그 결과값을 변수로 받을수 있습니다. 물론 결과값인 values 변수는 배열 객체입니다.
결과값을 기준으로 이미지를 crop & save 하면 동일한 기능이 완성됩니다.

정리

제 PC의 경우 이전 버전의 소스가 약 60~70초 정도 걸렸는데 이번에 개선된 코드에선 약 20~25초 정도면 실행이 끝났습니다.

소스코드 실행 전)

평소cpu

이전버전 소스코드 실행 중)

이전버전 cpu

개선된 소스코드 실행 중)

개선후 cpu

개선된 소스코드에서 더 많이 cpu를 사용하고 있음을 보실수 있습니다. 즉 비동기로 함수를 대량으로 호출하여 cpu가 매우 바빠졌다는 것이죠. 그에 반해 이전 버전은 cpu가 놀면서 일했나 보네요.

여전히 실제 서비스를 하기 위해서는 턱없이 느린 속도지만 알고리즘의 개선없이 아주 간단한 방법으로 1/3 시간으로 줄였습니다. 사실 이러한 경우는 잘못 코딩했던 경우입니다. 평소 알고리즘 적인 요소를 개선하기 전에 잘못 코딩된 부분이 없는지 확인해 보는것도 좋을듯 합니다.

최종 소스

  1. const sharp = require("sharp");
  2. let imagePath = "sharp.jpg";
  3. async function checkBlankPart(areaInfo) {
  4. let data = await image.extract(areaInfo).toBuffer();
  5. let info = await sharp(data).stats();
  6. let [rc, gc, bc] = info.channels;
  7. //RGB 컬러의 평균값을 구한다
  8. const r = Math.round(rc.mean),
  9. g = Math.round(gc.mean),
  10. b = Math.round(bc.mean);
  11. if(r >= 230 && g >= 230 && b >= 230) { //255의 90% 값
  12. return {top: areaInfo.top, isBlank: true};
  13. }
  14. return {top: areaInfo.top, isBlank: false};
  15. }
  16. let start = new Date().getTime();
  17. let image = sharp(imagePath);
  18. let cropInfo = {
  19. mode : false,
  20. prevTop : 0
  21. };
  22. image.metadata().then(async function(metadata) {
  23. let imgHeight = metadata.height; //이미지 높이
  24. let imgWidth = metadata.width; //이미지 넓이
  25. let blankWidth = imgWidth / 2; //여백인지 확인할 넓이
  26. let blankLeft = blankWidth / 2; //여백인지 확인할 위치
  27. const blankHeight = 5; //여백인지 확인할 높이
  28. let fnList = [];
  29. for(let i = 0; i < imgHeight - blankHeight; i += blankHeight) {
  30. fnList.push(checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight}));
  31. console.log(i);
  32. }
  33. Promise.all(fnList).then(function(values) {
  34. for(let j = 0, size = values.length; j < size; j++) {
  35. var ret = values[j];
  36. if(ret.isBlank) { //현재 위치가 여백 라인이라면
  37. let extractHeight = ret.top - cropInfo.prevTop;
  38. if(!cropInfo.mode && extractHeight > 200) { //200픽셀보다 커야 의미있는 이미지
  39. cropInfo.mode = true;
  40. let option = {left:0, top:cropInfo.prevTop, width:imgWidth, height: extractHeight};
  41. console.log(option);
  42. image.extract(option).clone().toFile(ret.top + ".jpg");
  43. }
  44. cropInfo.prevTop = ret.top;
  45. }
  46. else
  47. cropInfo.mode = false;
  48. }
  49. let end = new Date().getTime();
  50. console.log((end - start) / 1000);
  51. });
  52. });