웹 개발 프로젝트, 특히 관리자 페이지나 복잡한 사용자 입력 폼을 구현하다 보면 여러 개의 드롭다운 메뉴가 서로 상호작용해야 하는 경우를 자주 마주하게 됩니다. 예를 들어, 동일한 사용자 목록에서 '주 담당자'와 '부 담당자'를 선택해야 한다고 가정해 보겠습니다. 이때 '주 담당자'로 '김철수'를 선택했다면, '부 담당자' 목록에서는 '김철수'를 다시 선택할 수 없도록 비활성화 처리하는 것이 사용자 경험(UX) 측면에서 매우 중요합니다. 이는 데이터의 정합성을 보장하고 사용자의 실수를 원천적으로 방지하는 효과적인 방법입니다.
이러한 기능을 구현하기 위해 많은 개발자들이 강력한 드롭다운 라이브러리인 Select2를 사용합니다. Select2는 기본 HTML <select>
요소에 검색, 태깅, 무한 스크롤, 원격 데이터 소스 연동 등 다채로운 기능을 추가해주어 현대적인 웹 애플리케이션 개발에 필수적인 도구로 자리 잡았습니다.
하지만 여러 개의 Select2 인스턴스를 연동하여 한쪽의 선택이 다른 쪽에 영향을 미치도록 만드는 작업은 생각보다 간단하지 않을 수 있습니다. 많은 개발자들이 처음에는 Select2를 초기화할 때 사용한 원본 데이터 배열(Array)이나 객체(Object)의 속성을 변경하면 Select2의 옵션도 자동으로 갱신될 것이라 기대합니다. 예를 들어, 데이터 소스 객체에 disabled: true
속성을 동적으로 추가하는 방식입니다. 안타깝게도, Select2는 일단 초기화되고 나면 원본 데이터 소스의 변경 사항을 자동으로 감지하여 자신의 DOM을 갱신하지 않습니다. 이로 인해 많은 혼란과 시행착오가 발생하곤 합니다.
이 글에서는 바로 이 문제, 즉 하나의 Select2에서 특정 항목을 선택했을 때, 다른 Select2 인스턴스에 있는 동일한 항목을 동적으로 비활성화하는 가장 효과적이고 안정적인 방법을 심도 있게 다룰 것입니다. 잘못된 접근 방식부터 시작하여, 올바른 이벤트 리스너의 선택, 그리고 실제 적용 가능한 전체 코드 예제까지 상세하게 안내하여 여러분의 프로젝트에 즉시 적용할 수 있도록 돕겠습니다.
1. 문제 정의: 왜 이 기능은 까다로운가?
본격적인 해결책을 논의하기에 앞서, 이 문제가 왜 일반적인 DOM 조작과 다른지, 그리고 어떤 함정이 도사리고 있는지 명확히 이해하는 것이 중요합니다.
사용자 시나리오 구체화
- 상품 옵션 선택: 한 상품에 '색상1'과 '색상2'를 선택하는 옵션이 있다고 가정합니다. '색상1'로 '빨강'을 선택하면, '색상2' 목록에서는 '빨강'을 선택할 수 없어야 합니다.
- 역할 배정 시스템: 여러 사용자에게 '1순위 책임자', '2순위 책임자', '3순위 책임자'를 배정해야 합니다. '1순위'로 A를 배정했다면, '2순위'와 '3순위'에서는 A가 선택 불가능해야 합니다.
- 출발지와 도착지 선택: 항공권 예약 시스템에서 출발지와 도착지를 선택할 때, 출발지로 '인천(ICN)'을 선택했다면 도착지 목록에서 '인천(ICN)'은 의미가 없으므로 비활성화하거나 숨기는 것이 좋습니다.
이 모든 시나리오는 동일한 데이터 소스를 공유하는 여러 선택지에서 중복 선택을 방지해야 한다는 공통점을 가집니다.
흔히 저지르는 실수와 잘못된 접근법
문제에 직면한 개발자들이 흔히 시도하지만 실패하는 방법들은 다음과 같습니다.
잘못된 접근 1: 원본 데이터 소스 객체 수정
가장 직관적인 생각은 Select2를 초기화할 때 사용했던 자바스크립트 배열을 수정하는 것입니다.
// 1. 초기 데이터
const myData = [
{ id: 'A', text: '옵션 A' },
{ id: 'B', text: '옵션 B' },
{ id: 'C', text: '옵션 C' }
];
// 2. Select2 초기화
$('#select1, #select2').select2({
data: myData
});
// 3. 첫 번째 Select2에서 'A'를 선택한 후, 데이터를 수정하려는 시도
$('#select1').on('change', function() {
const selectedId = $(this).val(); // 'A'
// 원본 데이터에서 해당 ID를 가진 객체를 찾아 disabled 속성을 추가한다
const targetItem = myData.find(item => item.id === selectedId);
if (targetItem) {
targetItem.disabled = true;
}
// 과연 #select2가 이 변경사항을 알 수 있을까?
// -> 정답: 알 수 없다.
});
이 코드가 동작하지 않는 이유는 명확합니다. Select2는 select2()
가 호출되는 시점에 data
옵션을 기반으로 필요한 HTML 태그들을 한 번 생성하고 DOM에 삽입합니다. 그 이후, 자바스크립트 변수
myData
의 내용이 바뀌더라도 이미 생성된 DOM 요소들과의 연결고리는 끊어진 상태입니다. Select2는 이 변수를 지속적으로 감시(watch)하지 않으므로, UI에는 아무런 변화가 일어나지 않습니다.
잘못된 접근 2: 'destroy' 후 재초기화
일부 개발자들은 Select2를 완전히 파괴하고 변경된 데이터로 다시 생성하는 방법을 선택하기도 합니다.
$('#select1').on('change', function() {
const selectedId = $(this).val();
// 다른 모든 select2 인스턴스에 대해
$('select').not(this).each(function() {
// 기존 데이터를 복사하고, 선택된 옵션을 비활성화 처리
let newData = myData.map(item => ({
...item,
disabled: item.id === selectedId
}));
// Select2를 파괴!
$(this).select2('destroy');
// 새로운 데이터로 다시 초기화!
$(this).select2({ data: newData });
});
});
이 방법은 기술적으로는 동작합니다. 하지만 심각한 단점을 가지고 있습니다.
- 성능 저하:
destroy
와 재초기화 과정은 내부적으로 많은 연산을 수행하며, 특히 옵션이 많은 경우 눈에 띄는 성능 저하를 유발합니다. - 사용자 경험 저해: Select2가 파괴되었다가 다시 렌더링되는 과정에서 화면이 순간적으로 깜빡이거나(flicker) 스타일이 잠시 깨지는 현상이 발생할 수 있습니다. 이는 사용자에게 불안정하다는 인상을 줍니다.
- 상태 유지의 어려움: 만약 다른 Select2가 이미 어떤 값을 선택하고 있었다면, 재초기화 과정에서 그 선택 상태가 날아갈 수 있습니다. 이를 유지하려면 추가적인 코드가 필요해져 복잡도가 급격히 증가합니다.
따라서 이 방법은 기능 구현에만 급급한 임시방편일 뿐, 안정적이고 전문적인 해결책이라고 보기 어렵습니다.
2. 올바른 해결책: 이벤트 리스너와 DOM 직접 조작의 조화
가장 효율적이고 올바른 방법은 Select2가 생성한 실제 HTML <option>
요소의 disabled
속성을 직접 제어하는 것입니다. Select2는 내부적으로 표준 <select>
와 태그 구조를 유지하고 있기 때문에, jQuery를 사용해 이 DOM 요소들을 조작하면 Select2의 UI에도 즉시 반영됩니다.
핵심은 '언제' 이 조작을 실행할 것인가입니다. 이를 위해 Select2가 제공하는 전용 이벤트를 활용해야 합니다.
핵심 포인트 1: `change` 이벤트 대신 `select2:select` 이벤트를 사용하라
jQuery를 사용해 본 개발자라면 드롭다운의 값 변경을 감지할 때 자연스럽게 .on('change', function() { ... })
를 떠올릴 것입니다. 하지만 Select2와 함께 사용할 때는 이 방법이 문제를 일으킬 수 있습니다.
Select2는 자체적인 이벤트 시스템을 가지고 있으며, 값 선택 시 select2:select
이벤트를 발생시킵니다. 표준 change
이벤트도 연달아 발생하기는 하지만, select2:select
이벤트는 Select2가 선택과 관련된 모든 내부 처리를 완료하고, 선택된 데이터 정보를 포함한 추가적인 파라미터를 제공한다는 장점이 있습니다.
일부 구형 버전이나 특정 상황에서 change
이벤트를 사용하면 Select2의 내부 상태가 아직 완전히 정리되지 않은 시점에 핸들러가 실행되어 'Cannot read property '...' of null'
과 같은 예기치 않은 오류를 만날 수 있습니다. 따라서 Select2의 고유한 액션을 감지할 때는 반드시 Select2가 제공하는 네임스페이스 이벤트(select2:action
)를 사용하는 것이 안전하고 권장됩니다.
핵심 포인트 2: 이전 선택값을 기억하여 복원하기
단순히 새로 선택된 값을 다른 드롭다운에서 비활성화하는 것만으로는 부족합니다. 더 나은 UX를 위해서는 사용자가 선택을 변경했을 때, 이전에 선택했던 값은 다시 활성화시켜 주어야 합니다.
예를 들어, select1
에서 처음에는 'A'를 선택했습니다. 그러면 select2
에서는 'A'가 비활성화됩니다. 이후 사용자가 마음을 바꿔 select1
에서 'B'를 선택했습니다. 이때 'B'가 select2
에서 비활성화되는 것은 당연하지만, 동시에 이전에 비활성화했던 'A'는 다시 선택 가능하도록 활성화시켜주어야 합니다.
이를 위해 우리는 각 Select2의 '변경 전' 값을 저장해 둘 변수가 필요합니다.
3. 단계별 구현: 실제 코드 작성하기
이제 이론을 바탕으로 실제 동작하는 코드를 단계별로 작성해 보겠습니다.
1단계: 기본 HTML 구조 및 라이브러리 로드
먼저 jQuery와 Select2 라이브러리를 로드하고, 두 개의 <select>
요소를 포함하는 기본 HTML 문서를 준비합니다. CDN을 사용하는 것이 가장 간편합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Select2 연동 예제</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<style>
body { font-family: sans-serif; padding: 20px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
.select-control { width: 300px; }
</style>
</head>
<body>
<h1>담당자 배정</h1>
<div class="form-group">
<label for="primary-manager">주 담당자</label>
<select id="primary-manager" class="manager-select select-control">
<option value="">선택하세요</option>
<option value="user1">김철수</option>
<option value="user2">이영희</option>
<option value="user3">박민준</option>
<option value="user4">최지우</option>
</select>
</div>
<div class="form-group">
<label for="secondary-manager">부 담당자</label>
<select id="secondary-manager" class="manager-select select-control">
<option value="">선택하세요</option>
<option value="user1">김철수</option>
<option value="user2">이영희</option>
<option value="user3">박민준</option>
<option value="user4">최지우</option>
</select>
</div>
</body>
</html>
두 개의 <select>
는 동일한 manager-select
클래스를 가지고 있어 jQuery로 한 번에 선택하기 용이합니다.
2단계: Select2 초기화 및 이전 값 저장을 위한 준비
자바스크립트 영역에서 Select2를 초기화하고, 이전 값을 추적하기 위한 변수를 선언합니다.
$(document).ready(function() {
// 모든 manager-select 클래스에 Select2 적용
$('.manager-select').select2();
// 각 select 요소의 이전 선택값을 저장하기 위한 객체
let previousValues = {
'primary-manager': null,
'secondary-manager': null
};
// 포커스가 갈 때 현재 값을 previousValues에 저장
$('.manager-select').on('select2:opening', function (e) {
const selectId = $(this).attr('id');
previousValues[selectId] = $(this).val();
});
// ... 여기에 메인 로직이 들어갑니다 ...
});
여기서는 select2:opening
이벤트를 활용했습니다. 이 이벤트는 사용자가 드롭다운을 열려고 할 때 발생합니다. 바로 이 시점에 현재 선택된 값을 previousValues
객체에 저장해두면, 나중에 select2:select
이벤트가 발생했을 때 '변경 전 값'으로 사용할 수 있습니다. `focus` 이벤트를 사용해도 비슷하게 구현할 수 있습니다.
3단계: 메인 로직 작성 (`select2:select` 이벤트 핸들러)
이제 가장 중요한 부분인 이벤트 핸들러 로직을 작성합니다.
$('.manager-select').on('select2:select', function(e) {
const currentSelect = $(this);
const selectId = currentSelect.attr('id');
const newValue = currentSelect.val(); // 새로 선택된 값
const oldValue = previousValues[selectId]; // 이전에 선택됐었던 값 (select2:opening에서 저장)
// 다른 모든 manager-select를 순회
$('.manager-select').not(currentSelect).each(function() {
const otherSelect = $(this);
// 1. 새로 선택된 값(newValue)에 해당하는 옵션을 비활성화
if (newValue) {
otherSelect.find('option[value="' + newValue + '"]').prop('disabled', true);
}
// 2. 이전에 선택됐던 값(oldValue)에 해당하는 옵션은 다시 활성화
if (oldValue) {
otherSelect.find('option[value="' + oldValue + '"]').prop('disabled', false);
}
});
// 모든 select2 인스턴스를 강제로 재렌더링하여 변경사항(disabled 속성)을 UI에 반영
$('.manager-select').trigger('change');
});
코드 해설:
-
이벤트 핸들링:
$('.manager-select').on('select2:select', function(e) { ... })
모든
manager-select
클래스를 가진 요소에select2:select
이벤트 리스너를 바인딩합니다. 어떤 드롭다운에서든 선택이 일어나면 이 함수가 실행됩니다. -
변수 선언:
currentSelect
,selectId
,newValue
,oldValue
이벤트가 발생한 자기 자신(
this
), 그 요소의 ID, 새로 선택된 값, 그리고previousValues
객체에서 가져온 이전 값을 각각 변수에 담아 가독성을 높입니다. -
타겟 루프:
$('.manager-select').not(currentSelect).each(function() { ... })
모든
manager-select
중에서 현재 이벤트가 발생한 요소를 제외(.not(currentSelect)
)한 나머지 드롭다운들을 대상으로 코드를 실행합니다. -
비활성화 로직:
otherSelect.find('option[value="' + newValue + '"]').prop('disabled', true);
다른 드롭다운(
otherSelect
) 내부에서, 새로 선택된 값(newValue
)과 동일한value
속성을 가진을 찾아
disabled
속성을true
로 설정합니다. 여기서attr
대신prop
을 사용하는 것이 boolean 속성을 다룰 때 더 권장됩니다. -
활성화 로직:
otherSelect.find('option[value="' + oldValue + '"]').prop('disabled', false);
이전 값(
oldValue
)이 존재한다면, 그 값에 해당하는을 찾아
disabled
속성을false
로 설정하여 다시 선택 가능하게 만듭니다. -
UI 갱신 트리거:
$('.manager-select').trigger('change');
의
disabled
속성을 자바스크립트로 변경한 후, Select2가 이 변경 사항을 인지하고 자신의 UI(드롭다운 목록)를 다시 그리도록 알려주어야 합니다. 이 때 표준change
이벤트를 강제로 발생시키는 것이 가장 간단하고 효과적인 방법 중 하나입니다. 이것이 없으면 비활성화 상태가 다음 번에 드롭다운을 열 때까지 반영되지 않을 수 있습니다.
4. 심화 과정 및 추가 고려사항
위의 코드는 기본적인 시나리오를 완벽하게 처리합니다. 하지만 실제 프로젝트에서는 더 복잡한 요구사항이 있을 수 있습니다.
'선택 해제' 시나리오 처리하기 (`allowClear` 옵션)
Select2는 allowClear: true
옵션을 통해 사용자가 선택을 해제할 수 있는 'x' 버튼을 제공합니다. 사용자가 선택을 해제했을 때도 비활성화되었던 옵션이 다른 곳에서 다시 활성화되어야 합니다. 이를 위해서는 select2:unselect
이벤트를 처리해야 합니다.
// Select2 초기화 시 allowClear 옵션 추가
$('.manager-select').select2({
allowClear: true,
placeholder: '선택하세요'
});
// ... (select2:opening, select2:select 핸들러는 동일)
// 선택 해제 시 이벤트 핸들러 추가
$('.manager-select').on('select2:unselect', function (e) {
const unselectedVal = e.params.data.id; // 선택 해제된 값
if (unselectedVal) {
// 다른 모든 select에서 해당 값을 다시 활성화
$('.manager-select').not(this)
.find('option[value="' + unselectedVal + '"]')
.prop('disabled', false);
$('.manager-select').trigger('change');
}
});
select2:unselect
이벤트 핸들러는 e.params.data.id
를 통해 해제된 옵션의 값을 얻을 수 있습니다. 이 값을 가지고 다른 모든 드롭다운에서 해당 옵션을 찾아 disabled
속성을 제거해주면 됩니다.
페이지 로드 시 초기값 처리하기
만약 페이지가 로드될 때 이미 특정 담당자가 선택된 상태로 폼이 그려져야 한다면(예: 수정 페이지), 페이지 로드 직후에 한 번 비활성화 로직을 실행해주어야 합니다.
function syncDisabledOptions() {
let allSelectedValues = [];
// 현재 모든 select에서 선택된 값들을 수집
$('.manager-select').each(function() {
const selectedVal = $(this).val();
if (selectedVal) {
allSelectedValues.push(selectedVal);
}
});
// 모든 select를 다시 순회하며 옵션 상태 업데이트
$('.manager-select').each(function() {
const currentSelect = $(this);
const currentSelectVal = currentSelect.val();
currentSelect.find('option').each(function() {
const option = $(this);
const optionVal = option.val();
// 다른 곳에서 선택된 값이고, 빈 값이 아니며, 현재 select의 선택값이 아니라면 비활성화
if (optionVal && allSelectedValues.includes(optionVal) && optionVal !== currentSelectVal) {
option.prop('disabled', true);
} else {
option.prop('disabled', false);
}
});
});
$('.manager-select').trigger('change');
}
$(document).ready(function() {
$('.manager-select').select2({ /* ... 옵션 ... */ });
// 페이지 로드 시 초기 동기화 실행
syncDisabledOptions();
// 이벤트 핸들러에서도 이 함수를 호출하여 코드를 재사용할 수 있습니다.
// (더 복잡해지므로 위의 개별 핸들러 방식이 더 직관적일 수 있음)
});
syncDisabledOptions
와 같은 재사용 가능한 함수를 만들어, 페이지 로드 시점에 한 번 호출하고, 각 이벤트 핸들러 내부에서도 이 함수를 호출하도록 리팩토링할 수 있습니다. 이렇게 하면 초기 상태와 사용자 인터랙션 후의 상태를 모두 일관성 있게 관리할 수 있습니다.
최종 정리 및 완성 코드
지금까지 논의된 모든 내용을 종합하여, 가장 안정적이고 확장 가능한 최종 코드를 아래에 제시합니다. 이 코드는 다음의 기능을 모두 포함합니다.
- 두 개 이상의 Select2 연동
- 선택 시 다른 드롭다운의 동일 옵션 비활성화
- 선택 변경 시 이전 옵션 다시 활성화
- 선택 해제(`allowClear:true`) 시 옵션 다시 활성화
- 페이지 로드 시 초기값에 대한 상태 동기화
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Select2 연동 최종 예제</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<style>
body { font-family: sans-serif; padding: 20px; max-width: 600px; margin: auto; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
.select-control { width: 100%; }
</style>
</head>
<body>
<h1>프로젝트 팀원 배정</h1>
<p>각 역할에 중복되지 않는 팀원을 배정하세요.</p>
<div class="form-group">
<label for="role-pm">프로젝트 매니저(PM)</label>
<select id="role-pm" class="team-select select-control">
<option value="">팀원 선택</option>
<option value="user1" selected>김철수</option>
<option value="user2">이영희</option>
<option value="user3">박민준</option>
<option value="user4">최지우</option>
<option value="user5">정다혜</option>
</select>
</div<
<div class="form-group">
<label for="role-dev">메인 개발자</label>
<select id="role-dev" class="team-select select-control">
<option value="">팀원 선택</option>
<option value="user1">김철수</option>
<option value="user2">이영희</option>
<option value="user3">박민준</option>
<option value="user4">최지우</option>
<option value="user5">정다혜</option>
</select>
</div>
<div class="form-group">
<label for="role-design">디자이너</label>
<select id="role-design" class="team-select select-control">
<option value="">팀원 선택</option>
<option value="user1">김철수</option>
<option value="user2">이영희</option>
<option value="user3">박민준</option>
<option value="user4">최지우</option>
<option value="user5">정다혜</option>
</select>
</div>
<script>
$(document).ready(function() {
// 모든 team-select에 Select2 적용
$('.team-select').select2({
placeholder: '팀원을 선택하세요',
allowClear: true
});
// 모든 Select2의 상태를 동기화하는 중앙 함수
function syncSelects() {
const selectedValues = [];
// 현재 선택된 모든 값들을 수집 (빈 값 제외)
$('.team-select').each(function() {
if ($(this).val()) {
selectedValues.push($(this).val());
}
});
$('.team-select').each(function() {
const currentSelect = $(this);
const currentValue = currentSelect.val();
// 현재 select의 모든 옵션을 순회
currentSelect.find('option').each(function() {
const option = $(this);
const optionValue = option.val();
// 옵션 값이 있고, 다른 select에서 이미 선택된 값이며, 현재 select의 값과 다른 경우
if (optionValue && selectedValues.includes(optionValue) && optionValue !== currentValue) {
option.prop('disabled', true);
} else {
option.prop('disabled', false);
}
});
});
// 변경사항 UI 반영을 위해 트리거.
// '.select2' 클래스로 필터링하면 더 정확하게 Select2 인스턴스에만 영향을 줄 수 있음
$('.team-select.select2-hidden-accessible').trigger('change');
}
// 값 변경(선택, 해제) 시 동기화 함수 호출
$('.team-select').on('change.select2', function() {
syncSelects();
});
// 페이지 로드 시 최초 동기화 실행
syncSelects();
});
</script>
</body>
</html>
이 최종 코드에서는 개별 이벤트(`select2:select`, `select2:unselect`)를 모두 처리하는 대신, 상태 변경을 나타내는 포괄적인 `change.select2` 이벤트를 사용하고, 모든 로직을 syncSelects()
라는 하나의 함수로 통합하여 구조를 더욱 단순하고 명확하게 만들었습니다. change.select2
이벤트는 사용자가 값을 선택하거나 해제할 때 모두 발생하므로, 코드가 훨씬 간결해집니다. 이 방식은 3개 이상의 Select2가 연동되는 경우에도 매우 효과적이며 유지보수가 용이합니다.
결론적으로, Select2의 연동형 옵션 비활성화 기능은 라이브러리의 내부 데이터 구조를 직접 건드리기보다는, Select2가 생성한 표준 DOM 요소를 제어하고, Select2 전용 이벤트를 활용하여 그 시점을 포착하는 것이 핵심입니다. 이 원리를 이해하고 적용한다면, 어떤 복잡한 폼 시나리오에서도 사용자 친화적이고 안정적인 UI를 구축할 수 있을 것입니다.
0 개의 댓글:
Post a Comment