이미지 에디터 개발 기록 - blur 기능 추가
이미지 에디터가 필요하다
최근에 이력서와 포트폴리오를 작성하며 민감정보로 판단될 수 있는 부분에 대해 모자이크를 할 필요가 생겼다. 이럴 때 주로 사용했던 툴이 PowerPoint였는데, 지금의 내게는 Powerpoint가 없었고, Google Slide는 PowerPoint에서 제공하는 이미지 블러 기능이 없었다. 그렇다고 온라인 이미지 에디터를 사용하자니 기능 하나 사용하는데 가입을 요구하는 게 너무 번거로웠다.
직접 만들어보자
그래서 직접 만들기로 했다. 온라인에서 쉽게 사용할 수 있는 나만의 이미지 에디터를 만들어보자는 생각으로 개발을 시작했다. 하지만 처음부터 포토샵처럼 엄청난 프로그램을 만들기보다는 현재 목적에 필요한 blur 기능만 우선 구현하기로 했다.
Claude Code를 사용하여 개발 계획을 구상했다. 우선은 blur 기능만 추가하고 첨부 가능한 이미지 크기는 최대 3,200만 픽셀 이하. 그리고 원본 이미지는 건드리지 않고 Canvas 2D 컨텍스트의 filter 속성에 CSS filter와 같은 문법인 blur()를 설정해 적용하기로 했다. 이미지 업로드는 jpeg, png, webp 만 허용하도록 했다. 내가 자주 사용하는 이미지 포맷이 이 세개이기 때문에 추후 대중화된 이미지 포맷이 나온다면 추후 추가하기로 했다.
그렇게 해서 첫번째 결과물이 나왔다.
첫번째 결과물

첫번째 결과물은 이미지를 업로드하고 블러 슬라이더를 통해 이미지 전체에 효과를 주는 방식이었다.
이 방식도 나쁘지는 않다. PPT 같은 곳에서 사용할 때는 원본 이미지와 블러 처리 한 이미지를 겹쳐서 원하는 부분에만 블러 효과가 나오도록 처리하면 되니까.
하지만 이미지에서 블러 해야하는 영역이 따로 떨어져있다면 떨어진 갯수만큼 이미지를 겹쳐야하기 때문에 비효율적이다. 따라서 브러시 기능을 추가하기로 했다.
두번째 결과물

브러시 기능을 추가하고 테스트해보니 부분적으로 blur 효과가 적용되는 것을 확인했다. 하지만 곧 문제를 발견했는데, blur 강도를 높이니 이미지 가장자리에 blur 효과가 제대로 적용되지 않음을 발견했다.
이미지 전체에 blur 효과를 주고 강도를 변경했을 때 다음과 같이 적용됨을 발견했다.
블러 강도: 7px
blur 강도: 50px
이미지 가운데는 blur 강도가 그대로 적용되지만 가장자리는 오히려 강도가 작을 때보다 이미지가 더 잘 보임을 확인 가능하다.
이렇게 된 원인을 알아보니 브라우저의 canvas가 사용하는 filter: blur() 의 특성 때문이었다. blur 커널은 주변 픽셀을 평균 내는 과정을 통해서 blur 값을 이미지에 적용한다. 그런데 계산 과정에서 캔버스 경계 바깥에는 아무것도 없다고 인식한다. 따라서 효과를 주고자 하는 픽셀 주변의 평균을 계산할 때 픽셀이 충분할 경우는 문제 없지만, 가장자리 픽셀에 평균을 계산할 경우 이 이미지 바깥의 값(0)이 포함되어 알파값이 낮아지게 되는 것이다. 따라서 blur 효과의 알파값이 낮아지게 되니 뒤에 있는 원본 이미지가 잘 보이게 되는 것이었다.
blur 강도가 낮을 때는 문제 없지만 강도가 높아지면 문제가 생기는 원인도 이 때문이었다. 강도가 높을수록 계산에 포함되는 이미지 바깥 영역이 많아지면서 알파값이 극단적으로 낮아지는 것이었다.
이를 해결하기 위해서 새로운 방법을 모색했다.
세번째 결과물
문제를 해결하기 위해 이미지의 여백을 확장하기로 했다. 각 방향에 blur 강도 값의 두배만큼 이미지 여백을 늘리고, 그곳을 가장자리 픽셀로 채운 뒤 blur를 걸고, 나중에 여백을 잘라내도록 했다.

이렇게 하니 blur 강도를 50px로 최대로 늘려도 효과가 제대로 적용됨을 확인할 수 있었다.
혹시 이 경우 연산량에 문제가 있지 않을까 싶었다.
performance.now를 이용하여 성능을 측정해봤는데, 처음에는 모든 강도에서 0ms가 나왔다. 이유를 알아보니 Canvas의 그리기 명령은 최적화를 위해 브라우저가 그리기 명령을 몰아두었다가 나중에 한꺼번에 처리하기 때문이라고 한다. Canvas가 그리기 명령을 픽셀로 전환하는 작업을 래스터화라고 하는데, 이 래스터화를 지연시켰다 몰아서 하기 때문에 처음 측정한 값이 모두 0ms가 나온 것이었다. 실제 그리기에 걸린 시간이 아니라 명령을 큐에 넣는데 걸린 시간이었던 것이다.
그래서 매 호출 직후 getImageData(0, 0, 1, 1)로 픽셀 하나를 읽도록 했다. 픽셀 값을 읽으려면 브라우저는 밀려 있던 명령을 전부 실행해 실제 픽셀을 만들어낼 수밖에 없기 때문에, 이 호출이 강제 동기화 역할을 한다. 이렇게 하고 나서야 실제 소요 시간이 측정되기 시작했다.
실험은 다음과 같이 진행했다.
측정 환경: Chrome 브라우저
이미지 크기: 2,000 X 1,500 (300만 픽셀)
측정 방법: performance.now()로 페인팅 함수를 감싸고, getImageData()로 래스터화 강제 후 소요시간 측정
blur 강도별로 18회 측정 (워밍업 초반 3회 제외 후 15회 측정값만 사용) 후 중앙값 집계
결과는 다음과 같았다.
순차 측정
| blur 강도 | 여백(margin) | 패딩 캔버스 픽셀 수 | 중앙값 소요시간 | 최소 | 최대 |
|---|---|---|---|---|---|
| 0px | 0 | 300만 | 1.0ms | 0.8ms | 1.1ms |
| 5px | 10 | 307만 (+2%) | 8.1ms | 7.1ms | 9.4ms |
| 10px | 20 | 314만 (+5%) | 6.6ms | 6.0ms | 6.9ms |
| 20px | 40 | 329만 (+10%) | 6.3ms | 5.7ms | 7.3ms |
| 30px | 60 | 343만 (+14%) | 6.2ms | 5.3ms | 7.2ms |
| 40px | 80 | 359만 (+20%) | 6.2ms | 5.6ms | 7.9ms |
| 50px | 100 | 374만 (+25%) | 4.5ms | 4.1ms | 5.5ms |
blur(0)의 경우, 값이 0일 경우에는 blur() 처리 과정 자체를 생략하도록 처리해 소요시간이 짧다. 그 외 blur(x)의 경우 반경이 커질 수록 오히려 소요시간이 감소하는 신기한 결과가 나왔다. 블러 반경이 커질 수록 소요시간도 비례해서 늘어날 것이라 생각했는데 결과는 예상과 달랐다.
이에 대해 좀 더 찾아보았다. 우선, 브라우저(정확히는 브라우저 엔진)마다 blur 효과를 렌더링하는 방식이 달라서 같은 강도를 주더라도 결과물이 달라질 수 있다는 것을 알게 되었다. 일단 크롬의 경우 filter: blur()를 가우시안 블러로 해석하지만, 별도의 방법으로 최적화한다고 한다. 예를 들어 매 픽셀에 가우시안 커널을 그대로 적용하지는 않고, 가로·세로를 나눠 계산하는 분리형 처리나, 가우시안에 가까운 결과를 내는 box blur 근사, 또는 큰 블러에서는 이미지를 줄여서 처리한 뒤 다시 키우는 다운샘플링 같은 방법으로 최적화한다고 한다.
Claude는 이 결과에 대해서 블러를 켜는 순간에는 비용이 붙지만 이후로는 반경이 클수록 이미지를 더 많이 축소해서 처리하는 다운샘플링 최적화를 통해 이런 결과가 나왔을 것이라 추측했다. 5px 구간이 유독 높게 나온 것은 측정 순서상 블러 경로를 처음 타는 케이스라 일회성 초기화 비용이 섞였을 가능성도 있어, 측정 순서를 바꿔 다시 재보면 더 분명해질 것이라고 했다.
그래서 이번엔 거꾸로 다시 측정했다. 그 결과
역순 측정
| blur 강도 | 여백(margin) | 패딩 캔버스 픽셀 수 | 중앙값 소요시간 | 최소 | 최대 |
|---|---|---|---|---|---|
| 50px | 100 | 374만 (+25%) | 5.7ms | 4.8ms | 5.9ms |
| 40px | 80 | 359만 (+20%) | 5.8ms | 4.8ms | 5.9ms |
| 30px | 60 | 343만 (+14%) | 5.7ms | 4.7ms | 6.8ms |
| 20px | 40 | 329만 (+10%) | 6.0ms | 4.8ms | 9.1ms |
| 10px | 20 | 314만 (+5%) | 6.2ms | 5.0ms | 6.5ms |
| 5px | 10 | 307만 (+2%) | 7.3ms | 7.1ms | 7.8ms |
| 0px | 0 | 300만 (+0%) | 0.9ms | 0.8ms | 1.0ms |
이번에도 5px에서 소요시간이 제일 길었다. 가장 나중에 측정된 값인데도 가장 오래 걸렸다. 좀 더 정확한 결과를 위해 이번에는 라운드로빈 식으로 측정을 수행 후 결과를 얻었다.
라운드로빈 측정
| blur 강도 | 여백(margin) | 패딩 캔버스 픽셀 수 | 중앙값 소요시간 | 최소 | 최대 |
|---|---|---|---|---|---|
| 0px | 0 | 300만 (+0%) | 1.0ms | 0.9ms | 1.1ms |
| 5px | 10 | 307만 (+2%) | 9.0ms | 7.5ms | 23.0ms |
| 10px | 20 | 314만 (+5%) | 7.4ms | 6.2ms | 22.9ms |
| 20px | 40 | 329만 (+10%) | 7.1ms | 5.9ms | 25.7ms |
| 30px | 60 | 343만 (+14%) | 7.3ms | 5.6ms | 27.3ms |
| 40px | 80 | 359만 (+20%) | 7.2ms | 5.8ms | 31.3ms |
| 50px | 100 | 374만 (+25%) | 7.2ms | 5.7ms | 33.0ms |
마찬가지로 5px에서 소요시간이 가장 길었다. 그 와중에 최대값은 갑자기 확 튀었고, 강도에 비례해 증가하는 경향이 나타났다. 이것은 왜 그런 것인지 잘 모르겠다...
일단 중앙값을 기준으로 보자면, blur 강도의 증가에도 소요시간에 유의미한 차이가 있다고 보기는 어려웠다. 다만, 5px은 측정 순서에 상관 없이 가장 긴 시간이 걸렸는데, 이를 통해 유추해보자면 앞서 찾아보았던 브라우저의 blur 처리 최적화 방식이 영향을 준 것이 아닌가 생각한다. 블러 반경이 커질수록 다운샘플링 방식으로 처리를 하기 때문에 실제 소요시간이 더 줄어든게 아닌가 유추한다.
또한, 이번 performance.now() 측정은 그리기 명령 발행, 강제로 완료시킨 래스터화, 그리고 getImageData()의 리드백 비용이 함께 포함되어 있기 때문에 순수 blur 연산 시간보다는 값이 높게 측정되었을 수 있다고 했다.
Claude
특히 리드백(Readback)은 GPU에 있는 픽셀 데이터를 CPU가 읽을 수 있는 메모리로 가져오는 작업이라, 그동안 CPU가 GPU 작업이 끝나기를 기다리게 됩니다. 이번에는 픽셀 하나만 읽었기 때문에 전송량 자체는 무의미한 수준이지만, 기다리는 시간은 그대로 측정값에 들어갑니다. 따라서 순수한 blur 연산 시간은 표에 적힌 값보다는 다소 적을 것으로 봐야 합니다.
그래도, blur(0)과 0이 아닌 blur(x)의 값을 비교했을 때, blur(x)에서 일정한 소요 시간이 발생하고, 이후 반경 증가에 따른 비용이 예상보다 원만하다는 결과를 얻었다. 이러한 결과는 앞서 조사했던 브라우저가 사용하는 분리형 처리, box blur 근사, 다운샘플링 등의 최적화 방식과 부합한다.
다만 이번 측정만으로는 실제 실행된 렌더링 경로나 최적화 방식을 확인할 수는 없다고 한다. 이를 검증하려면 브라우저 프로파일러에서 Raster, Paint, Composite 및 GPU 작업을 추가로 살펴봐야 한다고 하는데... 이 부분은 나중에 좀 더 알아보고 진행해보려 한다.
우선, 현재 구현된 최대 강도 50px 까지는 문제 없음을 확인했기에 현재는 이대로 두어도 괜찮을 것 같다.
