티스토리 뷰

Frontend

기획전 a11y 개선 프로젝트

지마켓 이민하 2023. 1. 17. 13:11

Intro


안녕하세요. 사회복지학과 출신 개발자 Web Frontend팀의 이민하입니다.
저는 입사 4개월 차에 설레는 첫 업무를 받게 되었고 이내 운명을 느꼈습니다.
그것은 바로 제 전공과 관련된 a11y 개선 프로젝트였기 때문이죠.

 

지라 티켓을 보고 놀라는 장면
아니 이 티켓은..!?

 

a11y?


k8s 라는 말 들어보셨나요? 백엔드 개발자에게는 친숙한 Kubernetes의 줄임말입니다. 첫 글자 k와 마지막 글자 s 사이에 8개의 문자가 있어 k8s라고 표현합니다. 이를 숫자약어(Numeronym)라고 하는데요, 이 유래가 참 재미있습니다.
지금은 HP(Hewlett-Packard)사에 합병된 DEC(Digital Equipment Corporation) 사의 Jan Scherpenhuizen라는 직원이 입사하게 되었습니다.
전통적으로 이름을 이메일 계정으로 하는 것은 국룰이죠. 제 회사 메일도 minhalee@gmarket.com
입니다. 그러나 시스템 관리자는 해당 이름을 이메일 계정명으로 사용하기엔 너무 긴 나머지 그에게 S12n이라는 계정을 부여하였습니다. 이것이 사내에서 유머러스하게 받아들여지고 일반화된 것이 숫자약어의 시초라고 합니다. (와 재밌다)
개발자가 명명한 것이 시초다보니 유독 동종업계에서 많이 사용하는 것 같습니다. 트위터처럼 글자 수가 제한 되는 곳에서도 많이 보이고요. 그 외에도 프론트엔드 개발자에게 친숙한 i18n(Internationalization) 등의 다른 약어를 아래 표로 소개합니다.

약어 설명
k8s Kubernetes 쿠버네티스
i18n Internationalization 국제화
l10n Localization 지역화
m17n Multilingualization 다국어화
c14n Canonicalization 정규화
a11y    

 

그럼 여기서 여러분들께 퀴즈를 하나 내보겠습니다. a11y는 무엇의 약자일까요?

 

accessbility는 a11y
보면 볼수록 중독되는 지마켓 브랜드 컬러

맨 위 jira ticket에 OCR이 언급된 것만 봐도 눈치 채신 분들이 있을텐데요, 접근성을 뜻하는 Accessibility를 줄여서 a11y라고 합니다.
발음은 "A-one-one-Y", "A-eleven-Y", ally 등으로 하기도 하고 Accessibility라고 말하기도 합니다.
위에서 말씀드렸다시피 개발자들이 많이 사용하는 약어다보니 주로 웹 접근성 을 뜻합니다.

 

지마켓에서 웹 접근성은


웹 접근성(Web Accessibility)이란
장애인, 고령자 등이 웹 사이트에서 제공하는 정보에 비장애인과 동등하게 접근하고 이해할 수 있도록 보장하는 것입니다.

 

지마켓은 모든 콘텐츠에 웹 접근성을 제공하고자 합니다. 대표적으로 홈 메인 배너에서 소개하는 각종 기획전, 상품 상세 페이지에서의 정보 제공 등이 있습니다. 주로 img 태그 내에 alt(대체 텍스트) 속성을 활용하여 시각장애인 분들이 텍스트 리더기를 통해 접근할 수 있도록 합니다.

지마켓 선물하기 캡쳐 화면
지마켓 선물하기가 오픈!

 

iOS, mac의 Voice Over, Android의 TalkBack, Samsung의 Voice Assistant 등 각 플랫폼에서 지원하는 접근성 툴을 사용해서 해당 요소의 alt 속성을 읽을 수 있습니다.

이미 작년에 저희 지마켓에서는 지마켓 / 옥션 판매자를 위한 툴인 Editor 2.0을 개편하며 이를 소개하는 포스팅을 게시한 적이 있었죠. 오픈마켓 판매자를 위한 Editor 2.0으로 우리 마켓 웹 접근성 높이는 꿀 TIP !

 

그러나 수많은 판매자들이 판매 상품을 올리는 오픈 마켓에서는 상품 상세화면이 웹 접근성을 준수하기란 쉽지 않습니다. 기획전 또한 담당자가 직접 작성하다 보니 수정이 빈번해 놓치기 쉽습니다. 모든 판매자들 및 담당자가 웹 접근성에 대해 인지하고 준수하기 위한 노력이 선행되어야 하죠. 이를 해결하고자 지마켓에서는 오랫동안 고민을 해왔습니다. 그리고 머신러닝한 AI의 OCR 기술을 적용하여 이미지 내 텍스트를 분석해 대체텍스트 속성으로 넣어주는, TTS 형태의 기술적인 대처법을 생각해내게 됩니다.

 

하지만 OCR을 적용하면 어떨까? O! C! R!

그래서 제가 담당하는 영역 중 하나인 기획전에 OCR 적용을 하게 되었습니다.
OCR 기술에 대해 더 자세히 알고 싶다면 Element Intelligence팀 강남희 님께서 작성하신 지마켓 OCR 기술 소개 를 참고해 주세요!

 

기획전


1월 2일부터 설 빅세일 시작!

지마켓과 옥션에서 말하는 기획전은 주로 홈 메인 배너에 노출되는 영역으로, 정형화된 Template를 이용하여 모바일/PC 통합된 콘텐츠를 제공하는 서비스입니다. 시즌 이슈, 동일 카테고리의 주요 상품, 특정 브랜드의 제품 모음이나 할인 이벤트가 있을 때 기획전을 통해 좋은 고객 경험을 드리고자 노력하고 있습니다.
기획전의 시스템 아키텍처는 MSA 환경으로 이루어져 있고 오늘 소개할 기획전 Front영역은 Node.js의 Express 서버로 구성되어 있습니다.

 

기선을 제압하는 Flow


OCR API 요청 후 멘트 처리 FLOW CHART

모든 개발의 기본은 설계입니다. Flow Chart를 그려줍니다. 특히 접근성 프로젝트인 만큼 현재 진행되는 프로세스의 상태를 멘트로 안내하는 것이 중요하다고 생각하였습니다. 처리 흐름에 따라 변화하는 안내 멘트는 aria-live속성 을 이용하여 스크린 리더기가 즉각적으로 인식할 수 있도록 하였습니다. 멘트는 변경될 가능성을 항상 내포하므로 유지보수가 쉽도록 Object로 선언하였습니다.

const ocrTextArr = {
    start: '현재 이미지 인식 중입니다. 잠시만 기다려 주세요. 이미지 양에 따라 다소 시간이 소요될 수 있습니다.',
    empty: 'OCR 처리할 이미지가 없습니다.',
    success: 'OCR 대체텍스트 처리가 완료되었습니다. 이미지에 따라 정보의 정확도는 다소 떨어질 수 있음을 감안해 주시기 바랍니다.',
    error: 'OCR 대체텍스트 처리가 완료되었지만 서버부하로 인해 일부 처리가 누락되었습니다. 필요시 재시도 하여 주시기 바랍니다. 이미지에 따라 대체텍스트의 정확도는 떨어질 수 있음을 감안하여 주시기 바랍니다',
    errorAll: '죄송합니다. 서버부하로 인해 모든 이미지의 OCR 처리에 실패하였습니다. 잠시후 다시 시도해 주시기 바랍니다',
    working: '조금만 더 기다려 주세요. 이미 처리 중입니다.',
    already: '이미 이미지 대체텍스트 처리가 완료되었습니다. 새로고침 후 다시 시도해 주세요.',
    exist: '기획전 이미지 OCR 대체 텍스트 지원받기 사용 가능',
}

//접근성 안내 멘트 변경
function announceForAccessibility(status, progress) {
  const textForA11y = document.getElementById('text__for__a11y'); //aria-live 태그

  //상황 별 멘트 세팅
  // ...
  const outputText = ocrTextArr('success');

  setTimeout(() => { textForA11y.innerText(outputText) }, 200); //멘트 변경
}

 

해피 해킹


본격적으로 작업을 시작합니다. 우선 Template 내에 각종 구성 요소를 구분하여 OCR 기술이 적용될 작업 범위를 산정하였습니다.
텍스트 속성이 전혀 들어가지 않는 이미지로만 구성된 요소를 추려봤습니다. 아래의 컴포넌트가 그 대상입니다.

 

  1. 기획전 상단 배너
  2. 자유형 스토리 이미지
  3. 배너형 스토리 이미지

해당 글은 2, 3번째의 스토리 이미지들을 OCR 요청하는 것에 대한 샘플 코드를 작성해 보겠습니다. 코드 샘플은 많은 부분을 생략해서 작성하는 점을 이해 부탁드립니다.

자 이제 OCR 적용 테스트를 위한 더미 기획전을 생성해 줍니다. 생성한 기획전은 위의 텍스트를 넣은 이미지로만 구성하여 리더기로는 읽지 못하게 되어있습니다. 이미지는 제 반려묘 이리의 사진들입니다.

 

기획전의 Front 영역은 Handlebars라는 템플릿 엔진을 이용하여 작성되었습니다.
Node.js의 Express 서버에서 동적으로 html을 생성해 주며 ejs, jade와 함께 자주 사용되는 템플릿 중 하나입니다. 상단 배너와 스토리 컴포넌트의 핸들바 파일에서 OCR 적용 대상임을 명시하기 위해 image__ocr 이라는 이름의 클래스를 하나 추가해 줍니다.

<img src="{{@Urls.lazyloadDefaultImage}}" data-original="{{imageUrl}}" class="spot image__ocr{{@index}}" alt="{{altText}}" >

더불어 {{@index}} 속성을 붙여 중복된 컴포넌트는 자동으로 순번이 매겨지도록 하였습니다. ex) image__ocr0, image__ocr1, image__ocr2 ...
그 이유는 조금 더 아래에서 설명해 드리겠습니다. OCR 요청 대상 이미지를 배열로 return 하는 함수를 먼저 만들겠습니다.

//OCR 요청 할 이미지 존재하면 배열 리턴
function getStoryOcrImageArr() {   

    //image__ocr 클래스를 갖는 선택자들 
    const targetImages = document.querySelectorAll('[class*="image__ocr"]');

    const targetStorys = [...targetImages].filter(el => {
        //이미지(자유형)이면, 부모 요소중에 clone이 아닌 것만 ocr api 요청
        const isStoryType1 = el.closest('.story_type1 .item')?.parentNode.className.includes('cloned');
        //이미지(배너형)이면, 전부 ocr api 요청
        const isStoryType2 = el.closest('.story_type2');
        return isStoryType1 || isStoryType2;
    })

    //타겟 요소와 imageUrl을 반환
    const storyOcrImages = targetStorys.map(el => {
        const storyImgUrl = el.getAttribute('data-original');
        return {target: this, imageUrl: storyImgUrl};
    });

    return storyOcrImages;
}

image__ocr 라는 이름이 들어간 클래스를 가진 NodeList에서 Spread operator를 통해 배열에 담은 뒤 filter()를 이용해 스토리 타입을 분기시켜 주었습니다. 이 뒤에 OCR 적용 대상이 늘어난다면 targetStorys 변수 filter 조건만 추가해주면 되겠죠.

이렇게 스토리 타입을 구분해 작업한 이유가 있는데 자유형 이미지의 경우는 등록한 아이템이 4장을 넘어가면 Swiper 형태로 구현되어 스토리의 clone 아이템이 생겨버립니다. 따라서 clone이 아닌 대상을 api 요청 보내고 응답 온 결과를 동일한 클래스에 모두 심어주면 clone 아이템에도 대체텍스트가 들어갈 수 있겠죠!

자 이제 대상 이미지들을 가지고 OCR API 요청하는 함수를 만들어 보겠습니다.

//OCR request Promise
function getOCRPromise(target, imageUrl) {
  return new Promise((resolve, reject) => {
    //api 통신 위한 전처리 과정
    //...
    fetch('/api/ocr').then(res =>{
      //대상 요소에 대체 텍스트 삽입 및 후처리
      //...

      resolve(res);
    }).catch(res => reject(res));
  })
}

이왕이면 텍스트 변환이 얼마나 이루어졌는지 progress를 알면 더 좋겠죠?
또한 이미지 분석 요청과 응답은 비동기적으로 이루어지게 하고 최종적인 멘트 세팅을 위해 Promise.allSettled()를 사용하였습니다.

function ocrPromiseAllSettled(promises, progress_cb) {
    announceForAccessibility('start'); //안내멘트 시작   

    //요청건수 진행상황 표시
    let successedProm = 0;
    progress_cb(0);

    for (const p of promises) {
        p.then(() => {
            successedProm++;
            progress_cb(` (남은 요청 ${promises.length - successedProm}건)`);
        })
    }

    Promise.allSettled(promises)
        .then((res) => {
            if (res.every(r => r.status === 'rejected') && $('.ocr__success').length < 1)   //전부 실패고 성공한 이미지가 없다면
                return announceForAccessibility('errorAll');

            if (res.some(r => r.status === 'rejected'))  //하나라도 에러가 발생하면
                return announceForAccessibility('error');

            return announceForAccessibility('success'); //에러가 하나도 없으면
        })
        .catch(() => {
            announceForAccessibility('error');
        })
}

마지막으로 접근성 요청 버튼을 클릭하면 위의 이벤트가 일어날 수 있도록 합니다.

const ocrButton = document.getElementById('btn__for__a11y');

ocrButton.addEventListener('click', () => {
    //현재 호출 가능하다면
    //...

    let promises = [];

    for (const param of getStoryOcrImageArr()) 
      promises.push(getOCRPromise(param.target, param.imageUrl));

    //요청할 이미지가 없으면 종료
    //...

    ocrPromiseAllSettled(promises, p => { announceForAccessibility('start', p) });
});

이 외에도 멘트 처리에 대한 상태 관리, 중복 요청 예방 처리 등을 고민해서 녹였습니다.

서버단은 api를 요청할 때 이미지의 도메인을 검증하는 로직 정도를 두었습니다. 그리고 Exception이 발생했을 때 사내 에러 로그 수집기로 보내도록 합니다.

router.get("/api/ocr", async function (req, res, next) {
    const isDev = req.app.locals.CONFIG === 'development';
    const imgUrlHost = (new URL(req.query.imageUrl)).host;
    const isValidImageUrlDomain = isDev || getGmarketImageDomains.includes(imgUrlHost) //외부 image 도메인 여부 검증

    return isValidImageUrlDomain && new OCRApi()
        .getOCRText(req.query.imageUrl)
        .then(result => {
            if (result) {
                res.json(result);
                return result;
            }

            throw new Error(result);
        })
        .catch(err => {
            //에러 처리
            //...
            next(err)
        });
});

 

확인 들어갑니다


자 이제 숨어있는 'OCR 요청 버튼' 클릭해 테스트 해봅니다!

OCR 요청버튼 확인

Before

before

After

after

 

만약 에러 로그가 수집되면 아래와 같은 정보들을 담아 보냅니다.

에러로그 확인
해당 이미지가 gif 확장자라 요청에 실패한 케이스


어떤 이미지가 분석이 어려운 유형인지 정보들을 모아 지마켓 OCR 기술이 점점 더 발전할 수 있도록 합니다.

이상입니다! 긴 글 읽어주셔서 감사합니다. 우리 g5t는 최고입니다!


 

TL;DR

지마켓 / 옥션 기획전에 OCR 기술 적용을 통해 웹 접근성을 개선했습니다.

댓글