화면 아래에 문의 버튼을 추가해 달라는 요청을 받으면 새로운 입력창부터 만들기 쉽다. 하지만 홈페이지 안에 이미 등록 폼과 팝업, 입력 검증, 수신 설정이 있다면 버튼 하나 때문에 두 번째 접수 흐름이 생길 수 있다. 같은 문의를 어디에서 관리해야 하는지도 다시 나뉜다.

내가 정리한 공통 문의 기능은 기존 등록 동작을 먼저 찾고 연결하는 쪽으로 만들어졌다. 이름이 비슷한 아무 버튼을 대신 누르는 방식은 아니었다. 실제 열기 함수, 명시한 선택자, 기존 폼을 확인하고, 찾지 못했을 때 무엇을 할지도 별도로 정했다. 여기서는 특정 사이트의 연락처를 제외하고 그 연결 방법을 새 모듈로 설명한다.

방문자가 원하는 것은 새 폼이 아니라 다음 행동이다

문의 버튼의 역할은 방문자를 접수할 수 있는 곳으로 안내하는 것이다. 이미 올바르게 연결된 등록 흐름이 있다면 진입점을 더하는 것으로 충분할 수 있다. 새 폼은 입력 항목과 개인정보 동의, 오류 메시지, 수신자, 중복 접수까지 새로운 확인 범위를 만든다.

물론 기존 흐름이 목적에 맞지 않으면 바꿔야 한다. 상담 요청과 예약 접수는 입력과 결과가 다를 수 있다. 나는 기존 기능이 있다는 이유만으로 무조건 연결하지 않는다. 어떤 행동을 여는 버튼인지, 누구에게 어떤 정보가 전달되는지 먼저 확인한다. 재사용은 목적을 확인한 다음의 선택이다.

나는 연결 대상을 눈에 보이는 이름만으로 정하지 않았다

실제 소스에는 지정한 열기 함수와 버튼 선택자, 의미가 분명한 속성, 기존 폼을 순서대로 찾는 로직이 있다. 자동 탐지에서는 정확한 문구를 확인하고, 숨김·비활성 요소와 제출 버튼, 열린 팝업 안의 버튼, 새 문의바 자체를 대상에서 제외한다.

이런 조건이 필요한 이유는 이름이 같아도 행동이 다를 수 있기 때문이다. 관심을 표시하는 버튼과 이미 작성한 내용을 제출하는 버튼은 다른 단계다. 새 문의 버튼이 자신을 다시 클릭하면 연결이 반복되고, 제출 버튼을 대신 누르면 방문자의 의도와 다른 요청이 발생할 수 있다. 연결 대상은 명시적으로 지정하는 편이 점검하기 쉽다.

동작을 열었다는 것과 문의가 도착했다는 것은 다르다

함수를 호출하거나 기존 버튼을 클릭하는 것은 접수 흐름에 들어가는 행동이다. 폼을 작성했고 서버가 받아들였으며 수신함에 도착했는지는 별개의 결과다. 나는 연결 모듈이 성공을 반환했다는 이유로 메일이 발송됐다고 설명하지 않는다.

기존 서버를 재사용한다면 그 홈페이지의 수신 대상과 개인정보 안내가 맞는지 확인한다. 다른 홈페이지의 기본 연락처를 가져와 붙이는 것은 기능 재사용과 다르다. 이번 홈페이지의 공개 연락은 이메일 하나이며, 메일 작성 화면을 여는 경우에도 자동 발송이나 도착을 보장하는 표현을 쓰지 않는다.

연결 방식을 하나 정하고 실패를 분명하게 남긴다

다음 구현은 함수, 지정한 버튼, 지정한 폼 중 하나의 경로를 명시해 쓰는 작은 어댑터다. 실제 공통 원본의 더 넓은 자동 탐지를 모두 옮기지 않고, 무엇을 실행할지 읽기 쉬운 범위로 줄였다. 함수나 버튼을 실행하고 나면 다른 경로를 추가로 실행하지 않는다.

함수가 오류를 내거나 대상을 찾지 못하면 unavailable을 반환한다. 화면에 안내할 함수도 함께 받는다. 실패했을 때 아무 수신 주소로 바꾸거나 두 번째 폼을 열어 성공처럼 보이게 하지 않는다. 오류 뒤의 대체 동작은 고객의 목적과 실제 연결 상태를 확인해 따로 정한다.

코드와 구현 살펴보기

JavaScript · 기존 기능 하나에 연결하는 어댑터

export async function reuseExistingContact({
  openExisting, triggerSelector, formSelector,
  onUnavailable=()=>{}, doc=document
}={}) {
  const unavailable=()=>{onUnavailable('문의 연결을 확인해 주세요.');return 'unavailable';};
  const visible=node=>Boolean(node && !node.hidden && node.isConnected && node.getClientRects().length);
  if(typeof openExisting==='function') {
    try{return await openExisting()===false ? unavailable() : 'handled';}
    catch{return unavailable();}
  }
  if(triggerSelector) {
    let target;try{target=doc.querySelector(triggerSelector);}catch{return unavailable();}
    const type=target?.getAttribute('type')?.toLowerCase();
    const href=target?.getAttribute('href')||'';
    const unsafe=target?.disabled || target?.getAttribute('aria-disabled')==='true' ||
      target?.closest('[data-contact-adapter],dialog[open]') ||
      type==='submit' || type==='reset' ||
      (target?.tagName==='BUTTON' && !type && target.closest('form')) ||
      /^(?:tel:|mailto:|javascript:)/i.test(href);
    if(!visible(target) || unsafe || typeof target.click!=='function')return unavailable();
    target.click();return 'handled';
  }
  if(formSelector) {
    let form;try{form=doc.querySelector(formSelector);}catch{return unavailable();}
    if(!visible(form))return unavailable();
    form.scrollIntoView({block:'center',behavior:'auto'});
    form.querySelector('input:not([type="hidden"]):not([disabled]),select:not([disabled]),textarea:not([disabled])')?.focus({preventScroll:true});
    return 'focused';
  }
  return unavailable();
}

기존 함수와 폼에 각각 연결하는 예시

함수 경로를 쓰면 기존 프로그램이 팝업 열기를 담당한다. 반환 값은 그 함수가 처리했는지를 알려주는 약속이며, 화면이 열렸거나 접수가 끝났다는 자동 증거는 아니다. 그런 결과는 실제 흐름에서 확인한다.

폼 경로를 쓰면 해당 위치로 이동하고 숨김·비활성 입력을 제외한 첫 입력에 초점을 준다. 새 폼을 만들지 않고 기존 읽기 순서와 검증을 유지하는 방법이다. 다만 폼이 접혀 있거나 추가 초기화가 필요하면 그 상태를 열어주는 명시적 함수로 연결하는 쪽이 적합하다.

코드와 구현 살펴보기

서로 다른 연결 방식 중 하나를 선택

// 기존 팝업을 여는 함수가 있는 경우
await reuseExistingContact({
  openExisting: () => {
    SiteUI.openContact();
    return true;
  },
  onUnavailable: message => showStatus(message)
});

// 화면에 이미 있는 폼으로 안내하는 경우
await reuseExistingContact({
  formSelector: "[data-existing-contact-form]",
  onUnavailable: message => showStatus(message)
});

나는 정상 흐름과 막히는 흐름을 함께 본다

새로 작성한 연결 예시의 코드 조건은 검사할 수 있다. 그러나 기존 등록 UI의 실제 열기와 키보드 초점, 서버 접수, 수신 결과는 각각의 홈페이지에서 확인해야 한다. 이 글은 현재 모듈의 PC·모바일 실화면 검증이나 실제 메일 도착을 마쳤다는 보고가 아니다.

실패 흐름을 남기는 이유는 방문자가 버튼을 눌렀는데 아무 일도 일어나지 않는 상태를 피하기 위해서다. 대상을 못 찾으면 어떤 안내가 나오는지, 재시도하면 같은 행동만 한 번 수행하는지, 변경을 제거하면 기존 기능이 그대로 남는지를 확인한다.

  1. 기존 열기 함수나 버튼이 실제로 문의 목적에 맞는가.
  2. 한 번의 요청이 기존 동작을 한 번만 실행하는가.
  3. 숨김·비활성·제출 버튼을 잘못 대신 누르지 않는가.
  4. 새 버튼이 자기 자신을 다시 실행하지 않는가.
  5. 폼을 안내할 때 입력 가능한 곳에 초점이 가는가.
  6. 연결 실패를 방문자가 이해할 수 있게 알리는가.
  7. 실제 접수와 수신은 해당 홈페이지의 설정으로 따로 확인하는가.

나는 진입점과 접수 책임을 나누어 남긴다

버튼을 더 붙이면 접근은 쉬워질 수 있다. 그 대가로 같은 일을 하는 서로 다른 접수 시스템을 계속 늘릴 필요는 없다. 기존 기능을 확인하고 진입점만 연결하면 다음 수정의 범위를 작게 유지할 수 있다.

중요한 것은 자동으로 무언가를 찾는 기술 자체보다, 찾은 대상이 어떤 책임을 갖는지 설명하는 것이다. 나는 클릭을 연결하는 코드와 실제 고객의 문의를 처리하는 시스템을 구별해 남기려 한다.