input에 들어온 값을 대문자나 소문자로 바꾸는 코드는 toUpperCase() 한 줄이다. 어려운 건 그 한 줄이 사용자 손에 닿았을 때다. 입력하는 사람 입장에서 “이러면 좀 더 편하겠다” 싶은 지점을 떠올리고, 그게 다른 동작을 망가뜨리지 않는지까지 따져봐야 한다.

최근 만든 기능에서 이런 순간이 있었다. 별것 아닌 요구사항이었는데 구현하다 보니 걸리는 데가 두 군데 나왔다. JavaScript로 화면을 만드는 한 비슷한 고민은 또 온다고 보고, 그때 무엇을 따졌는지와 최종 코드를 남겨 둔다.

select 값이 입력 규칙을 정한다

화면에는 select box 하나와 text input 하나가 있다. select에서 무엇을 고르느냐에 따라 input에 들어갈 값의 대소문자가 정해진다.

  • select에서 A를 고르면 input에 이미 들어 있는 값과 앞으로 입력할 값이 모두 소문자가 된다.

  • select에서 B를 고르면 같은 방식으로 모두 대문자가 된다.

  • input에 긴 값이 들어 있는 상태에서 커서를 중간으로 옮겨 입력해도, 앞뒤 값은 그대로 두고 새로 입력한 글자에만 규칙이 적용된다.

앞의 두 줄은 금방 끝났고, 문제는 세 번째 줄에서 나왔다.

화면은 바뀌었는데 값은 그대로였다

처음 만난 현상은 화면에는 대소문자가 제대로 바뀌어 보이는데 실제 데이터는 바뀌지 않는 것이었다. 이 화면은 Vue.js를 쓰고 있었고, input이 v-model="method_info"로 묶여 있었다.

v-model은 input의 input 이벤트를 듣고 있다가 그 값을 데이터에 옮겨 적는다. 사용자가 글자를 칠 때는 이벤트가 발생하니 데이터가 따라오지만, 코드에서 input.value에 변환한 값을 대입하는 건 이벤트를 일으키지 않는다. DOM에는 변환된 값이, 데이터에는 변환 전 값이 남는다. Vue를 쓰는 화면에서 input 값을 코드로 바꿀 때는 v-model에 묶인 프로퍼티도 직접 바꿔 줘야 한다. 최종 코드에서 input.value = inputValue 다음 줄에 data.method_info = inputValue가 붙어 있는 이유다.

값을 통째로 바꾸면 커서는 끝으로 간다

두 번째 현상은 커서였다. select를 바꾸거나 타이핑할 때마다 커서가 맨 끝으로 강제로 이동했다. 긴 값의 중간을 고치려던 사용자에게는 한 글자 칠 때마다 커서가 도망가는 셈이다.

원인은 변환 방식에 있다. toLowerCase()나 toUpperCase()로 만든 문자열을 value에 다시 넣으면 input의 값이 전체 단위로 교체되고, 그 순간 원래 커서 위치를 잃어버린다.

해결에는 setSelectionRange를 썼다. 원래는 특정 구간의 글자를 마우스로 드래그한 것처럼 선택할 때 쓰는 함수인데, 시작과 끝을 같은 위치로 주면 선택 없이 커서만 그 자리에 놓인다. keyup 이벤트가 발생한 시점의 selectionStart, selectionEnd를 먼저 저장해 두고, 문자열을 치환한 직후 setSelectionRange(start, end)로 커서를 되돌렸다. 대소문자 변환은 글자 수를 바꾸지 않으니 저장한 위치가 치환 뒤에도 그대로 유효하다.

이벤트 네 개로 묶은 handleInput

위 요구사항과 두 문제를 모두 반영한 코드다. 공통 함수 handleInput은 select, input 선택자와 데이터 객체를 받아 이벤트 네 개를 건다.

// common.js
function handleInput(selectBox, inputBox, data, fieldPath) {
    selectBox = document.querySelector(selectBox);
    inputBox = document.querySelector(inputBox);
    if (!isEmpty(fieldPath)) {
        data =  fieldPath.reduce((target, key) => target[key], data);
    }

    function convertByChange() {
        if (selectBox.value == 'gkId') {
            data.method_info = data.method_info.trim().toLowerCase();
        } else if (selectBox.value == 'parentTicketId' || selectBox.value == 'ticketId') {
            data.method_info = data.method_info.trim().toUpperCase();
        }
    }

    function convertByKeyup(event) {
        if( !['gkId', 'parentTicketId', 'ticketId'].includes(selectBox.value) ) return;
        let input = event.target;
        let start = input.selectionStart;
        let end = input.selectionEnd;
        let inputValue = input.value;

        if (selectBox.value == 'gkId') {
            inputValue = inputValue.toLowerCase();
        } else if (selectBox.value == 'parentTicketId' || selectBox.value == 'ticketId') {
            inputValue = inputValue.toUpperCase();
        }

        input.value = inputValue;
        data.method_info = inputValue;
        input.setSelectionRange(start, end);
    }

    function convertInputString(event) {
        let input = event.target;
        let inputValue = input.value;
        inputValue = inputValue.replace(/(\s+)/g, ',').replace(/,+/g, ',').replace(/(^,)|(,$)/g, '').split(',');
        if(inputValue.length > 30) {
            alert("한 번에 여러 개 조회 시 최대 30개까지 검색할 수 있습니다.");
            inputValue = inputValue.slice(0,30);
        }
        inputValue = inputValue.join(', ');
        input.value = inputValue;
        data.method_info = inputValue;
    }

    function convertByEnter(event){
        if(event.keyCode == 13){
            convertInputString(event);
        }
    }

    selectBox.addEventListener('change', convertByChange);
    inputBox.addEventListener('keyup', convertByKeyup);
    inputBox.addEventListener('blur', convertInputString);
    inputBox.addEventListener('keydown', convertByEnter);
}

이벤트별로 하는 일을 나누면 이렇다.

이벤트함수하는 일
select changeconvertByChange이미 입력된 값을 앞뒤 공백을 지우고 소문자(gkId) 또는 대문자(parentTicketId, ticketId)로 바꾼다
input keyupconvertByKeyup커서 위치를 저장하고, 값을 변환해 DOM과 data.method_info에 함께 넣은 뒤 커서를 되돌린다
input blurconvertInputString공백을 쉼표로 바꿔 a, b, c 형태로 정리하고, 30개를 넘으면 알림 후 잘라낸다
input keydown (Enter)convertByEnterEnter를 누르면 blur와 같은 정리를 한다

select 변경은 데이터(data.method_info)만 바꾸고 input 표시는 Vue가 다시 그리게 두었다. 커서 복원은 타이핑을 처리하는 convertByKeyup에만 들어 있다.

화면 쪽에서는 초기화 함수에서 한 번 호출한다. 세 번째 인자로 Vue 인스턴스(this)를 넘기므로 data.method_info가 곧 v-model에 묶인 method_info다.

// Input핸들링대상화면.js
화면초기화함수 : function() {
	handleInput('#method_id','#method_info', this);
}
<!-- input핸들링대상화면.jsp -->
<div class="input_select col6">
    <select name="" v-model="method_id" id="method_id">
        <option v-for="method in methodIdList" :value="method.comnCd">
            {{ method.comnCdNm }}
        </option>
    </select>
</div>
<input type="text" name="" @keyup.enter="searchVocRequestedList(1)" v-model="method_info" id="method_info" class="col4" value="">

select 변경과 중간 입력에서의 동작

select에서 고른 option에 따라 입력값의 대소문자가 정해지고, option을 바꾸면 이미 입력된 값도 따라 바뀐다.

긴 값의 중간에 커서를 두고 입력해도 새 글자에만 규칙이 적용되고 커서는 제자리에 남는다.

지금 다시 본다면 Enter 판별에 쓴 event.keyCode는 MDN에서 deprecated로 분류하니 event.key === 'Enter'로 바꿔 두는 편이 낫겠다.