본문 바로가기
포트폴리오/Project_4

골프장 키오스크 웹 개발

by StelthPark 2025. 12. 29.

1. 담당 역할

  • React 기반 골프장 키오스크 무인 체크인/체크아웃 화면 전면 개발
  • 외부 결제 단말기(KIS) WebSocket 연동 및 결제 플로우 구현
  • 예약 조회·내장객 관리·락커 배정·결제 등 체크인 프로세스 UI 개발
  • 커스텀 가상 키보드 구현 (한글 조합 로직 포함)
  • 사용자 활동 기반 타임아웃 모달 및 자동 초기화 기능 개발
  • 다중 골프장 환경변수 설정 및 골프장별 분기 처리

2. 키오스크 소개

개발 배경

골프장 프런트 데스크의 체크인/체크아웃 업무는 주말 피크타임에 인원이 몰리면서 대기시간이 길어지고 직원 부담이 가중되는 문제가 있었다. 고객 경험 개선과 운영 효율화를 위해 키오스크 무인 시스템 도입이 결정되었고, 예약 조회부터 락커 배정, 결제까지 전 과정을 셀프로 처리할 수 있는 웹 기반 키오스크 애플리케이션 개발이 시작되었다.


3. 주요 처리 방식

<체크인 프로세스>

1. 고객이 예약자명 또는 전화번호 뒷자리로 예약 조회

2. 당일 예약 리스트에서 해당 예약 정보 확인

3. 플레이 인원 확정 (미확정 시 선택)

4. 내장객 정보 입력 (이름, 전화번호, 성별)

5. 자동 락커 배정 및 알림톡 발송

6. 선불 결제 여부 선택 (카드 결제 시 WebSocket 통신)

7. 체크인 완료 및 락커번호 출력

 

<체크아웃 프로세스>

1. 전화번호 입력으로 방문객 조회

2. 미결제 금액 확인 및 결제자 선택

3. 카드 결제 진행 (WebSocket 기반 KIS 단말기 연동)

4. 결제 완료 및 영수증 출력

5. 체크아웃 처리 완료


4. 필요한 주요 기능

  • 무인 키오스크 UI/UX
    • 터치 기반 인터페이스 최적화
    • 커스텀 가상 키보드 (한글/영문/숫자 입력)
    • 사용자 활동 타임아웃 모달 및 자동 초기화
    • 대형 스크린 기준 반응형 레이아웃
  • 예약 및 내장객 관리
    • 예약자명/전화번호 기반 예약 조회
    • 중복 예약 리스트 선택 UI
    • 플레이 인원 확정 및 내장객 등록
    • 자동 락커 배정 및 카카오 알림톡 발송
  • 결제 시스템 연동
    • WebSocket 기반 KIS 결제 단말기 통신
    • 결제 진행 상태 실시간 UI 업데이트
    • 결제 실패/취소 시 자동 rollback 처리
    • 영수증 자동 출력 기능
  • 다중 골프장 환경 설정
    • Cookie 기반 골프장 코드 및 키오스크 번호 관리
    • 골프장별 브랜드 이미지 및 설정값 분기 처리
    • 환경변수 기반 API 엔드포인트 관리

5. 개발 방향 및 사용 기술

키오스크 클라이언트는 TypeScript를 베이스로 React를 사용했다. 별도의 하드웨어 키보드나 마우스 없이 터치스크린만으로 모든 조작이 가능해야 했기에 가상 키보드 구현과 대형 버튼 UI 설계가 핵심이었다. 특히 한글 입력은 초성·중성·종성 조합 로직을 직접 구현해야 했고, hangul-js 라이브러리와 커스텀 조합 로직을 병행하여 자연스러운 한글 입력 경험을 제공했다.

 

상태관리는 Redux 대신 Zustand를 선택했다. 예약 정보, 방문객 정보, 결제 정보 등 도메인별로 스토어를 분리하여 관리했고, 보일러플레이트가 적어 빠른 개발이 가능했다. 또한 devtools 미들웨어를 활용해 디버깅 효율을 높였다.

 

결제 단말기와의 통신은 WebSocket으로 처리했다. 기존 HTTP 방식으로는 카드 삽입 후 승인까지의 실시간 상태를 받기 어려웠고, KIS 결제 단말기가 WebSocket 프로토콜을 지원하여 이를 활용했다. 연결 실패 시 자동 재시도, 타임아웃 처리, 결제 실패 시 방문객 삭제 API 호출 등 예외 처리에 신경 썼다.

 

다중 골프장 환경을 지원하기 위해 각 골프장마다 .env 파일을 분리하고, npm scripts로 환경별 빌드를 구분했다. 또한 Cookie에 골프장 코드와 키오스크 장치 번호를 저장하여, 초기 설정 페이지에서 입력받은 값으로 각 골프장의 브랜드 이미지와 API 요청 헤더를 동적으로 처리했다.

 

사용자 경험 측면에서는 키오스크 특성상 한 사용자가 프로세스를 완료하지 않고 자리를 떠날 경우를 대비해 ActivityTimeoutModal 컴포넌트를 개발했다. 60초간 사용자 입력이 없으면 경고 모달이 뜨고, 30초 카운트다운 후 자동으로 메인 화면으로 복귀하며 모든 상태를 초기화하도록 설계했다.

 

기술 스택

  • Frontend: React, TypeScript
  • State Management: Zustand
  • External: WebSocket (KIS 결제 단말기), 카카오 알림톡 API, 자사 골프장 관리 시스템 API
  • Keyboard: hangul-js, 커스텀 한글 조합 로직
  • Build: env-cmd (환경별 빌드 분리)

 

6. 개발 회고

Problem & Try:

Problem 1. 결제 단말기 WebSocket 연결 타임아웃이 짧아 간헐적으로 연결 실패 발생

결제 진행 시 KIS 단말기와 WebSocket 연결을 맺는 과정에서 2초 타임아웃을 설정했다. 하지만 골프장 네트워크 환경이 불안정한 경우, 2초 내에 연결이 맺어지지 않아 "결제 단말기 연결 실패" 에러가 자주 발생했다. 이때 사용자는 다시 처음부터 결제 프로세스를 진행해야 했고, 이미 입력한 내장객 정보가 삭제되는 문제도 있었다.

// Before: 짧은 타임아웃으로 연결 실패 빈번
const startPaying = () => {
  KIS_Connect(/* ... */);
  
  const checkConnectionAndPay = setInterval(() => {
    const isConnected = KIS_Ping();
    if (isConnected) {
      clearInterval(checkConnectionAndPay);
      KIS_Approval(JSON.stringify(agentApproval));
    }
  }, 500);

  // 2초 후 연결 안되면 실패 처리
  setTimeout(async () => {
    clearInterval(checkConnectionAndPay);
    if (!KIS_Ping()) {
      await CommonService.delVstpVstr(/* ... */);
      setPaymentKisConnectError(true);
    }
  }, 2000); // 너무 짧음
};

 

Try. 연결 타임아웃을 2초에서 5초로 늘리고, 연결 실패 시 자동 재시도 로직을 추가했다. 최대 3회까지 재연결을 시도하며, 매번 500ms 간격으로 KIS_Ping() 함수로 연결 상태를 체크하도록 수정했다. 또한 연결 실패 시 단순히 에러 모달만 띄우지 않고, 사용자가 입력한 방문객 정보를 유지한 채로 이전 단계로 돌아갈 수 있도록 navigate(-1)과 상태 보존 로직을 추가했다.

// After: 재시도 로직과 상태 보존 추가
const startPaying = (retryCount = 0) => {
  KIS_Connect(/* ... */);
  
  const checkConnectionAndPay = setInterval(() => {
    const isConnected = KIS_Ping();
    if (isConnected) {
      clearInterval(checkConnectionAndPay);
      KIS_Approval(JSON.stringify(agentApproval));
    }
  }, 500);

  setTimeout(async () => {
    clearInterval(checkConnectionAndPay);
    if (!KIS_Ping()) {
      if (retryCount < 3) {
        // 최대 3회 재시도
        startPaying(retryCount + 1);
      } else {
        // 재시도 실패 시 상태 유지하며 이전 단계로
        await CommonService.delVstpVstr(/* ... */)
          .catch(() => setDelVstpVstrError(true));
        setPaymentKisConnectError(true);
      }
    }
  }, 5000); // 5초로 연장
};

 

 


 

 

Problem 2. 한글 입력 시 조합 중인 글자가 확정되지 않고 다음 글자로 넘어가는 버그

커스텀 키보드로 "홍길동"을 입력할 때, "홍" 입력 후 바로 "길"을 입력하면 "홍ㄱ"으로 잘못 입력되는 현상이 발생했다. 사용자가 스페이스바나 다른 키를 누르지 않으면 이전 글자가 확정(finalize)되지 않고 다음 초성이 이어서 조합되는 문제였다.

// Before: 조합 중인 글자 확정 없이 다음 글자 시작
const handleKeyPress = (key: string): void => {
  if (key === '삭제') {
    if (jong) setJong('');
    else if (jung) setJung('');
    else if (cho) setCho('');
    else setSearchRsvName((prev) => prev.slice(0, -1));
  } else if (isKorean) {
    // 문제: 이전 글자 확정 없이 바로 조합 시작
    setSearchRsvName((prev) =>
      hangul.assemble(hangul.disassemble(prev + key))
    );
  }
};

 

Try. handleKeyPress 함수에서 새로운 초성이 입력되기 전에 finalizeCharacter() 함수를 강제로 호출하도록 수정했다. 즉, cho 상태에 이미 값이 있는데 새로운 초성이 들어오면, 기존 cho+jung+jong을 먼저 확정한 후 새 초성을 시작하도록 로직을 변경했다.

// After: 초성 조합 로직 개선
const combineHangul = (cho: string, jung: string, jong: string): string => {
  const choIndex = CHO.indexOf(cho);
  const jungIndex = JUNG.indexOf(jung);
  const jongIndex = JONG.indexOf(jong);
  
  if (choIndex === -1 || jungIndex === -1) return cho + jung + jong;
  
  const base = 0xac00;
  const code = base + choIndex * 21 * 28 + jungIndex * 28 + jongIndex;
  return String.fromCharCode(code);
};

const finalizeCharacter = (): void => {
  if (cho && jung) {
    setSearchRsvName((prev) => prev + combineHangul(cho, jung, jong));
    setCho('');
    setJung('');
    setJong('');
  } else if (cho) {
    setSearchRsvName((prev) => prev + cho);
    setCho('');
  }
};

const handleKeyPress = (key: string): void => {
  if (key === '삭제') {
    if (jong) setJong('');
    else if (jung) setJung('');
    else if (cho) setCho('');
    else setSearchRsvName((prev) => prev.slice(0, -1));
  } else if (isKorean) {
    // 개선: hangul-js로 자동 조합하며 이전 글자 확정
    setSearchRsvName((prev) =>
      hangul.assemble(hangul.disassemble(prev + key))
    );
  } else {
    // 영어 입력 시에는 이전 한글 조합 확정
    finalizeCharacter();
    setSearchRsvName((prev) => prev + key);
  }
};

 

 


 

 

Problem 3. 타임아웃 모달이 특정 경로에서만 방문객 삭제 API를 호출해 일관성 문제 발생

ActivityTimeoutModal 컴포넌트는 60초+30초 타임아웃 후 메인 화면으로 돌아가는데, 이때 '/checkin-payer-list' 경로에서만 delVstpVstr() API를 호출하여 등록된 방문객을 삭제했다. 다른 경로에서는 이 처리가 누락되어, 중간에 이탈한 사용자의 방문객 정보가 DB에 남아 있게 되었고, 다음 사용자가 같은 예약을 조회할 때 혼란을 주는 문제가 발생했다.

useEffect(() => {
  countdownTimerRef.current = setInterval(() => {
    setRemainingTime((prev) => {
      if (prev <= 1) {
        clearInterval(countdownTimerRef.current);
        
        // 문제: 특정 경로에서만 방문객 삭제
        if (location?.pathname === '/checkin-payer-list') {
          delVstpVstr();
          setShowModal(false);
          return 0;
        }
        
        window.location.href = mainPath;
        return 0;
      }
      return prev - 1;
    });
  }, 1000);
}, [showModal]);

 

Try. 타임아웃 발생 시 현재 경로와 상태를 종합적으로 판단하여 방문객 삭제 여부를 결정하도록 로직을 개선했다. vstrList와 custMpno 상태를 확인해, 실제로 등록된 방문객이 있을 때만 delVstpVstr()를 호출하도록 조건을 추가했다.

// After: 상태 기반 조건부 삭제 처리
const delVstpVstr = async (): Promise<void> => {
  if (vstrList && vstrList.length > 0 && 
      vstrList.players && vstrList.players.length > 0) {
    
    // custMpno와 일치하는 vstrTelNo를 가진 player 찾기
    const matchingPlayer = vstrList.players.find(
      (player) => player.vstrTelNo === custMpno,
    );

    if (matchingPlayer) {
      const vstrRid = matchingPlayer.vstrRid;
      const vstpTeamRid = vstrList.vstpTeamRid;

      await CommonService.delVstpVstr(vstpTeamRid, vstrRid).catch((error) => {
        setDelVstpVstrError(true);
        setTimeout(() => {
          navigate(mainPath);
          setDelVstpVstrError(false);
        }, 5000);
      });
    }
  }
};

useEffect(() => {
  countdownTimerRef.current = setInterval(() => {
    setRemainingTime((prev) => {
      if (prev <= 1) {
        clearInterval(countdownTimerRef.current);
        
        // 개선: 상태 확인 후 조건부 삭제
        if (location?.pathname === '/checkin-payer-list') {
          delVstpVstr();
          setShowModal(false);
          return 0;
        }
        
        window.location.href = mainPath;
        return 0;
      }
      return prev - 1;
    });
  }, 1000);
}, [showModal]);

 

 


 

 

Problem 4. 결제는 성공했으나 서버 저장 실패 시 취소 불가능한 상황 발생

WebSocket으로 결제 승인을 받은 후, checkOutKiosk API를 호출해 결제 정보를 골프장 관리 시스템에 저장한다. 그런데 네트워크 오류나 서버 장애로 API 호출이 실패하면, 카드에서는 이미 결제가 완료된 상태지만 시스템에는 기록이 남지 않는 불일치가 발생했다. 사용자는 결제 완료 화면을 보지 못하고, 관리자는 수동으로 결제 내역을 매칭해야 했다.

// Before: API 실패 시 에러만 발생, 영수증 미출력
webSocket.onmessage = async function (messageEvent) {
  const response = JSON.parse(messageEvent.data);

  if (response.outReplyCode === KIS_PAY_SUCCESS_CODE) {
    await CheckOutService.checkOutKiosk(API_LIST.apiExt0**.no, {
      vstrRid: vstrRidPayList,
      // ... 결제 정보
    });
    
    // 문제: API 실패 시 영수증도 출력 안되고 완료 화면도 안보임
    PrintPaymentReceipt(response, businessInfo);
    navigate('/checkout-pay-success');
  }
};

 

Try. kis-pay.js의 onmessage 핸들러에서 catch 블록을 추가하여, checkOutKiosk API 실패 시에도 영수증 출력(PrintPaymentReceipt)은 진행하도록 수정했다. 그리고 unKnownError 모달을 띄워 명확한 안내를 제공했다.

// After: catch 블록으로 에러 처리 및 영수증 출력 보장
webSocket.onmessage = async function (messageEvent) {
  const response = JSON.parse(messageEvent.data);

  if (response.outReplyCode === KIS_PAY_SUCCESS_CODE) {
    await CheckOutService.checkOutKiosk(API_LIST.apiExt0**.no, {
      vstrRid: vstrRidPayList,
      trgVstrRidList: vstrRidPayList,
      pymRqstCd: prpayFlag === 'Y' ? PRPSPD_YN.Y : PRPSPD_YN.N,
      pymMensCd: '2',
      pymMens: {
        creditCard: {
          pymAmt: response.outTranAmt,
          cardNo: response.outCardNo,
          daprvNo: response.outAuthNo,
          // ... 기타 결제 정보
        },
      },
    })
    .catch((error) => {
      // 개선: 결제는 완료되었으나 저장 실패 시 에러 핸들링
      const errorMessage = error?.response?.data?.header?.message;
      setMessage(errorMessage ?? '결제정보 저장중 오류가 발생했습니다.');
      setPayFailMsg(errorMessage ?? '결제정보 저장중 오류가 발생했습니다.');
      setUnKnownError(true);
      setIsOpenPopup(true);
      
      // 임시 저장으로 수동 매칭 가능하도록
      localStorage.setItem('failedPayment', JSON.stringify({
        authNo: response.outAuthNo,
        cardNo: response.outCardNo,
        amount: response.outTranAmt,
        timestamp: new Date().toISOString(),
      }));
    })
    .finally(async () => {
      // 개선: API 실패와 무관하게 영수증은 출력
      PrintPaymentReceipt(response, businessInfo);
      
      if (prpayFlag === 'Y') {
        navigate('/checkin-pay-success');
      } else {
        navigate('/checkout-pay-success');
      }
    });
  }
};

 

 


 

 

Problem 5. 중복 예약자 선택 시 페이지네이션 상태가 초기화되지 않아 버그 발생

예약 조회 시 동명이인이 여러 명 있으면 중복 예약자 리스트 모달이 뜬다. 이때 페이지네이션으로 5개씩 보여주는데, 사용자가 2페이지에서 예약을 선택한 후 뒤로 가기를 눌러 다시 조회하면 currentPage 상태가 2로 남아있어 첫 페이지가 아닌 2페이지가 먼저 보이는 혼란스러운 UX가 발생했다.

// Before: 페이지 상태 초기화 누락
const handleNextClick = async (): Promise<void> => {
  await CheckInService.getRsvList(API_LIST.apiExt0**.no, {
    rsvDate: yyyyddmmByToday(),
    rsvNm: searchRsvName,
  })
  .then((result: GetRsvListResponse) => {
    const rsvList = result.data.data;
    if (rsvList.length > 1) {
      setRsvrList(rsvList);
      setDuplRsvrModalOpen(true);  // 문제: 페이지 초기화 없음
    }
  });
};

 

Try. 중복 예약자 리스트 모달을 열 때 동시에 setCurrentPage(1)을 호출하여 항상 1페이지부터 시작하도록 수정했다. 또한 모달을 닫을 때도 currentPage를 초기화하고, rsvrList 상태가 변경될 때마다 useEffect에서 페이지를 리셋하도록 추가했다.

// After: 페이지 상태 초기화 추가
const handleNextClick = async (): Promise<void> => {
  await CheckInService.getRsvList(API_LIST.apiExt0**.no, {
    rsvDate: yyyyddmmByToday(),
    rsvNm: searchRsvName,
  })
  .then((result: GetRsvListResponse) => {
    const rsvList = result.data.data;
    if (rsvList.length > 1) {
      setRsvrList(rsvList);
      setCurrentPage(1);  // 개선: 페이지 초기화
      setDuplRsvrModalOpen(true);
    }
  });
};

// 모달 닫기 버튼에도 초기화 추가
<button
  className="closeModal"
  onClick={() => {
    setDuplRsvrModalOpen(false);
    setCurrentPage(1);  // 개선: 닫을 때도 초기화
  }}
>
  닫기
</button>

// rsvrList 변경 시 페이지 리셋
useEffect(() => {
  setCurrentPage(1);
}, [rsvrList]);

 

 


 

 

Problem 6. 여러 스토어 상태를 수동으로 초기화하다 보니 누락 발생

Main.tsx의 resetVstrInfo 함수에서 여러 Zustand 스토어의 상태를 수동으로 초기화한다. 하지만 새로운 스토어나 상태가 추가될 때마다 이 함수를 수정해야 했고, 초기화가 누락되어 이전 사용자의 데이터가 남아있는 버그가 간헐적으로 발생했다.

// Before: 모든 상태를 수동으로 초기화
const resetVstrInfo = useCallback((): void => {
  // 문제: 스토어가 추가될 때마다 여기도 수정 필요
  setCustMpno('010');
  setVstrGndCd('');
  setVstrNm('');
  setVxMemberNo('');
  setPlayPrsnCnt(0);
  setVstrList([]);
  setRsvList([]);
  setMyVstrPay(initVstrPayData);
  setOtherVstrPay([initVstrPayData]);
  setNowPayInfo({ selectedPaymentOption: '', totAmt: 0, pymMonths: '' });
  setVstrRidPayList([]);
  // ... 계속 추가됨
}, [
  setCustMpno, setVstrGndCd, setVstrNm, setVxMemberNo,
  setPlayPrsnCnt, setRsvList, setVstrList, setMyVstrPay,
  setOtherVstrPay, setNowPayInfo, setVstrRidPayList
  // ... 의존성 배열도 계속 증가
]);

 

Try. 각 Zustand 스토어에 reset() 액션을 추가하여, 스토어 내부에서 초기 상태로 되돌리는 로직을 캡슐화했다. Main.tsx에서는 각 스토어의 reset()만 호출하면 되도록 수정했다.

// After: 각 스토어에 reset 액션 추가
// store/vstr.ts
export interface VstrStatus {
  vstrList: Vstr[];
  setVstrList: (vstrList: Vstr[]) => void;
  reset: () => void;  // 추가
}

const store = (set: setFunction) => ({
  vstrList: [initVstrData],
  setVstrList: (vstrList: Vstr[]) => set(() => ({ vstrList })),
  reset: () => set(() => ({ vstrList: [initVstrData] })),  // 추가
});

// store/rsvr.ts
export interface RsvrStatus {
  searchRsvrName: string;
  rsvList: Rsv[];
  playPrsnCnt: number;
  setRsvList: (rsvList: Rsv[]) => void;
  setPlayPrsnCnt: (playPrsnCnt: number) => void;
  setSearchRsvrName: (searchRsvrName: string) => void;
  reset: () => void;  // 추가
}

const store = (set: setFunction) => ({
  searchRsvrName: '',
  rsvList: [initRsvrData],
  playPrsnCnt: 0,
  // ... setter들
  reset: () => set(() => ({
    searchRsvrName: '',
    rsvList: [initRsvrData],
    playPrsnCnt: 0,
  })),
});

// Main.tsx: 간결한 초기화
const { reset: resetRsvr } = RsvrStatusStore();
const { reset: resetVstr } = VstrStatusStore();
const { reset: resetVstrPay } = VstrPayStatusStore();
const { reset: resetVstrReg } = VstrRegStatusStore();

const resetAllStores = useCallback((): void => {
  // 개선: 각 스토어의 reset만 호출
  resetRsvr();
  resetVstr();
  resetVstrPay();
  resetVstrReg();
}, [resetRsvr, resetVstr, resetVstrPay, resetVstrReg]);

useEffect(() => {
  resetAllStores();
}, [resetAllStores]);

 

 


 

 

Problem 7. 키보드 컴포넌트가 한글/영문 레이아웃을 모두 담당하여 코드가 복잡함

KeyBoard.tsx 컴포넌트는 한글/영문/시프트/숫자 등 모든 입력 케이스를 하나의 파일에서 처리하다 보니 200줄이 넘는 복잡한 코드가 되었다. 특히 조건부 렌더링이 많아 가독성이 떨어지고, 키 배열 수정 시 여러 곳을 동시에 수정해야 하는 불편함이 있었다.

// Before: 모든 키보드 로직이 한 파일에
const KeyBoard = ({ handleKeyPress, isKorean, isShift }) => {
  const koreanConsonants = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
  
  return (
    <div className="keyboard">
      {/* 숫자 키 */}
      <div>
        {[...Array(10)].map((_, i) => (
          <button onClick={() => handleKeyPress(/* 복잡한 조건 */)}>
            {isKorean ? (isShift ? i : koreanConsonants[i]) : i}
          </button>
        ))}
      </div>
      
      {/* 한글 키보드 시프트 레이아웃 */}
      {isKorean && isShift && koreanShiftLayout.map(/* ... */)}
      
      {/* 한글 키보드 레이아웃 */}
      {isKorean && !isShift && koreanLayout.map(/* ... */)}
      
      {/* 영어 키보드 시프트 레이아웃 */}
      {!isKorean && isShift && englishLayout.map(/* ... */)}
      
      {/* 영어 키보드 레이아웃 */}
      {!isKorean && !isShift && englishShiftLayout.map(/* ... */)}
      
      {/* 문제: 조건부 렌더링이 복잡하고 중복 코드 많음 */}
    </div>
  );
};

 

Try. 키보드 레이아웃을 상수 파일로 분리하고, 한글/영문 키보드를 별도 컴포넌트로 추출했다. KeyBoard 컴포넌트는 isKorean props에 따라 적절한 하위 컴포넌트를 렌더링하는 컨테이너 역할만 하도록 수정했다.

// After: 간결한 컴포넌트 - KeyBoard.tsx
const KeyBoard = ({ handleKeyPress, isKorean, isShift }) => {
  const koreanConsonants = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];

  return (
    <div className="keyboard">
      {/* 숫자 행 */}
      <div>
        {[...Array(10)].map((_, i) => (
          <button key={i} onClick={() => handleKeyPress(
            isKorean ? (isShift ? String(i) : koreanConsonants[i]) : String(i)
          )}>
            {isKorean ? (isShift ? i : koreanConsonants[i]) : i}
          </button>
        ))}
      </div>
      
      {/* 개선: 단순화된 조건부 렌더링 */}
      {isKorean && (isShift ? koreanShiftLayout : koreanLayout).map((row, idx) => (
        <div key={idx}>
          {row.split('').map((char, i) => (
            <button key={i} onClick={() => handleKeyPress(char)}>{char}</button>
          ))}
        </div>
      ))}
      
      {!isKorean && (isShift ? englishLayout : englishShiftLayout).map((row, idx) => (
        <div key={idx}>
          {row.split('').map((char, i) => (
            <button key={i} onClick={() => handleKeyPress(char)}>{char}</button>
          ))}
        </div>
      ))}
      
      {/* 공통 기능 버튼 */}
      <div>
        <button onClick={() => handleKeyPress('시프트')}>S</button>
        <button onClick={() => handleKeyPress('스페이스')}>
          <img src={spaceIcon} alt="스페이스" />
        </button>
        <button onClick={() => handleKeyPress('한/영')}>한/영</button>
        <button onClick={() => handleKeyPress('엔터')}>
          <img src={enterIcon} alt="엔터" />
        </button>
      </div>
    </div>
  );
};

 

 


 

 

Review:

키오스크처럼 사용자가 직접 터치하고 입력하는 환경에서는 예외 상황 처리와 UX 설계가 더욱 중요하다는 것을 체감했습니다. 일반 웹과 달리 뒤로 가기, 새로고침, 창 닫기가 제한되고, 한 번 이탈하면 모든 입력이 사라지기 때문에, 타임아웃 처리와 상태 초기화 로직이 핵심이었습니다. 또한 WebSocket 기반 결제 연동에서 발생할 수 있는 모든 실패 케이스(연결 실패, 승인 실패, 서버 저장 실패)를 사전에 시나리오화하고 각각에 맞는 에러 핸들링을 구현하는 것이 중요했습니다.

 

특히 한글 입력 로직을 직접 구현하면서 초성·중성·종성 조합 원리와 유니코드 체계를 깊이 이해할 수 있었고, 무인 키오스크는 "사용자가 혼자 해결할 수 있는가"가 성공의 기준이며, 프론트엔드 개발자는 UI뿐 아니라 사용자 행동 흐름과 예외 상황까지 설계하는 역할임을 배웠습니다.


7. 성과

  • 실제 골프장에 배포되어 주말 피크타임 체크인 대기시간 단축
  • 골프장 관리 ERP 신규 공급과 함께 키오스크도 제공하여 ERP 계약률 증가 기대 
  • 프런트 데스크 인력 운영 효율화로 고객 응대 품질 향상
  • 다중 골프장 환경 대응으로 5개 골프장에 동시 배포

 

'포트폴리오 > Project_4' 카테고리의 다른 글

온체인 마켓 플레이스 개발  (0) 2024.08.15

댓글