빈 속성값과 속성창의 차이

이미지에 대체 설명문을 입력했는데 저장 후 사라지는 문제가 있었다.

처음에는 서버 저장 로직이나 데이터베이스 컬럼을 의심하기 쉽다.

“폼에서 값이 전송되지 않았나?”
“Controller가 alt 값을 누락했나?”
“Model의 allowedFields에 필드가 빠졌나?”

하지만 이번 문제의 진짜 원인은 서버가 아니었다.
WYSIWYG 에디터인 TinyMCE가 이미지의 빈 alt 속성을 특별하게 해석하고 있었던 것이 원인이었다.

서버 저장 로직은 정상적으로 동작하고 있었다

그림책 페이지의 대체 텍스트는 book_pages.alt_text 컬럼에 저장된다.

해당 컬럼은 최대 255자의 nullable 문자열로 추가되어 있었고, Model의 허용 필드에도 포함되어 있었다.

서버의 저장 흐름도 단순했다.

  1. 요청에서 alt_text를 받는다.

  2. 앞뒤 공백을 제거한다.

  3. 255자를 초과하면 거부한다.

  4. 빈 문자열이면 null로 변환한다.

  5. 값이 있으면 그대로 저장한다.

즉, 그림책 페이지의 alt 저장 API에는 특별히 값을 버리는 로직이 없었다.

게시판 에디터의 이미지 alt 역시 마찬가지였다.
서버는 전달받은 POST 값을 저장할 준비가 되어 있었다.

문제는 서버에 도착하기 전, 브라우저 안에서 이미 값이 사라지고 있었다.

문제의 출발점은 alt=""였다

HTML에서 다음 두 이미지는 겉보기에는 비슷하다.

<img src="/images/deco.png">
<img src="/images/deco.png" alt="">

하지만 접근성 관점에서는 의미가 다르다.

  • alt 속성이 없는 이미지: 아직 대체 설명이 정의되지 않은 상태

  • alt=""인 이미지: 스크린리더가 읽지 않아야 하는 장식용 이미지

TinyMCE는 이 차이를 알고 있었다.

이미지에 alt=""가 있으면 해당 이미지를 장식용 이미지로 판단한다. 이때 이미지 속성창을 열어 대체 설명문을 입력해도 TinyMCE가 기존의 장식용 상태를 유지하려고 입력값을 제거하거나 반영하지 않았다.

사용자 입장에서는 다음과 같은 일이 벌어진다.

  1. 에디터에 이미지가 있다.

  2. 이미지 속성창을 연다.

  3. 대체 설명문을 입력한다.

  4. 저장 버튼을 누른다.

  5. 다시 속성창을 열면 입력한 값이 사라져 있다.

서버 문제처럼 보이지만 실제로는 요청이 만들어지기 전 단계에서 발생한 문제였다.

빈 값과 속성창은 같은 상태가 아니었다

이번 디버깅에서 중요한 점은 “alt가 비어 있다”는 사실만으로는 충분하지 않다는 것이었다.

TinyMCE 내부에서는 다음 두 상태가 다르게 취급됐다.

<img src="image.jpg" alt="">
<img src="image.jpg">

첫 번째는 “장식용 이미지”로 해석될 수 있다.
두 번째는 사용자가 대체 설명문을 새로 입력할 수 있는 일반 이미지 상태에 가깝다.

따라서 속성창을 열기 직전에 빈 alt를 단순히 빈 문자열로 유지하면 안 됐다.

새로운 설명문을 입력하려는 상황에서는 빈 alt 속성 자체를 제거해야 했다.

첫 번째 수정: 명령 실행 직전에 빈 속성 제거

처음에는 이미지 속성창을 여는 TinyMCE 명령을 감시했다.

editor.on('BeforeExecCommand', event => {
    if (event.command !== 'mceImage') {
        return;
    }

    const image = editor.selection.getNode();

    if (image.nodeName === 'IMG' && image.getAttribute('alt') === '') {
        image.removeAttribute('alt');
        image.removeAttribute('role');
    }
});

의도는 명확했다.

  • 이미지 속성창을 열기 전에

  • alt=""를 제거하고

  • 장식용 이미지임을 나타내는 role도 제거한다.

이렇게 하면 TinyMCE가 해당 이미지를 장식용으로 고정하지 않고, 속성창의 대체 설명문 입력값을 정상적으로 반영할 수 있다.

하지만 이 수정만으로는 모든 경로를 해결하지 못했다.

속성창을 여는 방법이 두 가지였다

이미지 속성창은 항상 같은 방식으로 열리지 않았다.

메뉴나 명령을 통한 경로

이 경우에는 mceImage 명령이 실행됐다.

따라서 BeforeExecCommand 이벤트가 동작했다.

툴바 이미지 버튼을 통한 경로

문제는 툴바의 이미지 버튼이었다.

툴바 버튼은 mceImage 명령을 거치지 않고 이미지 속성창을 직접 열었다.
그래서 BeforeExecCommand에만 의존한 코드는 이 경로에서 실행되지 않았다.

결과적으로 다음과 같은 상태가 됐다.

  • 메뉴에서 열면 정상 저장

  • 툴바 버튼으로 열면 여전히 저장되지 않음

이런 종류의 버그는 이벤트를 잘못 선택한 문제가 아니라, 사용자 행동 경로를 하나만 가정한 데서 발생한다.

최종 수정: 이미지가 선택되는 시점에 처리

두 경로를 모두 포괄하려면 속성창을 여는 명령을 감시하는 대신, 이미지가 선택되는 시점을 감시해야 했다.

최종 수정은 ObjectSelected 이벤트를 사용했다.

editor.on('ObjectSelected', event => {
    const image = event.target;

    if (image.nodeName !== 'IMG' || image.getAttribute('alt') !== '') {
        return;
    }

    editor.undoManager.ignore(() => {
        image.removeAttribute('alt');
        image.removeAttribute('role');
    });
});

이제 처리 시점은 “속성창을 여는 순간”이 아니라 “사용자가 이미지 객체를 선택한 순간”이다.

따라서 메뉴, 툴바, 기타 편집 경로와 관계없이 동일한 상태 정리가 적용된다.

undoManager.ignore()를 사용한 것도 중요하다.
사용자가 이미지를 선택했을 뿐인데 내부 속성 제거가 실행 취소 기록으로 남으면 편집 경험이 어색해질 수 있기 때문이다.

진짜 원인은 저장 코드가 아니라 상태 해석이었다

이번 문제를 한 문장으로 정리하면 다음과 같다.

사용자가 입력한 alt가 저장되지 않은 것이 아니라, TinyMCE가 빈 alt=""를 장식용 이미지의 신호로 해석해 입력값을 저장하기 전에 제거하고 있었다.

따라서 해결 방법도 서버 Controller를 수정하는 것이 아니었다.

  • DB 컬럼을 다시 만들 필요가 없었다.

  • Model의 허용 필드를 바꿀 필요가 없었다.

  • 저장 API를 새로 만들 필요도 없었다.

  • 이미지 업로드 로직을 수정할 필요도 없었다.

문제가 발생한 레이어인 에디터 이벤트 처리만 수정하면 됐다.

디버깅하면서 얻은 교훈

1. 저장되지 않는다고 서버부터 의심하지 말 것

저장 문제는 다음 단계 중 어디에서든 발생할 수 있다.

사용자 입력
  ↓
에디터 내부 상태
  ↓
HTML 직렬화
  ↓
HTTP 요청
  ↓
Controller
  ↓
Model
  ↓
Database

이번 문제는 Controller와 Database가 아니라 에디터 내부 상태에서 발생했다.

문제를 좁히려면 각 단계의 값을 직접 확인해야 한다.

  • 속성창 입력 직후 DOM

  • 에디터가 생성한 HTML

  • 실제 Network 요청 payload

  • Controller가 받은 POST 값

  • DB에 저장된 값

이 과정을 거치면 “어디서 값이 사라졌는지”를 빠르게 특정할 수 있다.

2. 빈 문자열은 항상 단순한 빈 값이 아니다

alt=""는 단순히 값이 비어 있는 문자열이 아니다.
HTML과 접근성 규칙에서는 “이 이미지는 장식용”이라는 의미를 가질 수 있다.

따라서 빈 문자열을 다룰 때는 다음을 구분해야 한다.

  • 의도적으로 장식용으로 지정한 빈 값

  • 아직 설명을 입력하지 않은 초기 상태

  • 사용자가 설명을 새로 입력하려는 상태

같은 문자열이라도 문맥에 따라 의미가 달라진다.

3. UI 이벤트는 사용자 경로 전체를 확인해야 한다

첫 번째 수정은 논리적으로 맞았지만 사용자 경로를 모두 커버하지 못했다.

BeforeExecCommand는 한 경로에서는 정확했지만, 툴바 버튼 경로에서는 실행되지 않았다.

결국 중요한 것은 특정 명령이 실행되는지를 보는 것이 아니라, 사용자가 실제로 어떤 행동을 하는지 확인하는 것이다.

  • 메뉴에서 여는가?

  • 툴바 버튼으로 여는가?

  • 더블클릭으로 여는가?

  • 키보드 단축키가 있는가?

에디터와 같은 복잡한 UI에서는 동일한 기능에 여러 진입점이 존재한다.

결론

이번 alt 저장 문제의 진짜 원인은 다음 세 가지가 결합된 결과였다.

  1. 이미지에 alt=""가 있었다.

  2. TinyMCE가 이를 장식용 이미지로 해석했다.

  3. 속성창을 여는 경로가 여러 개였는데 한 이벤트만 감시했다.

최종적으로는 이미지가 선택되는 시점에 빈 alt와 role을 제거하도록 수정했고, 메뉴와 툴바 양쪽에서 대체 설명문을 입력할 수 있게 됐다.

웹 에디터에서 값이 저장되지 않을 때는 서버 코드만 보지 말고, 브라우저 내부의 DOM 상태와 에디터가 가진 의미 해석까지 확인해야 한다.

특히 alt="", role="presentation", aria-hidden="true"처럼 접근성을 위한 속성은 단순한 문자열이 아니라 UI 컴포넌트의 동작을 바꾸는 상태값일 수 있다.