재우니의 블로그

Your new, lightweight, JavaScript framework.

 

 

 

Alpine.js를 활용한 종속형 Select Box 구현

 

이번 시간에는 Alpine.js를 사용하여 3개의 Select Box를 만들고, 첫 번째 Select Box의 값에 따라 두 번째 Select Box의 옵션이 바뀌고, 두 번째 Select Box의 값에 따라 세 번째 Select Box의 옵션이 바뀌는 기능을 구현해 보겠습니다. 또한, 첫 번째 Select Box의 값이 변경되면 두 번째와 세 번째 Select Box는 초기화되도록 만들어 보겠습니다.

 

 

핵심 개념:

 

  • Alpine.js: 간단하고 강력한 JavaScript 프레임워크로, HTML에 직접 JavaScript 동작을 추가할 수 있습니다.
  • x-data: Alpine.js 컴포넌트의 데이터를 정의합니다. 이 데이터는 HTML 요소와 상호작용할 수 있습니다.
  • x-model: HTML 요소의 값과 Alpine.js 데이터 속성을 양방향으로 바인딩합니다. Select Box에서 선택된 값이 자동으로 JavaScript 변수에 저장되고, JavaScript 변수의 값이 바뀌면 Select Box의 선택된 값도 바뀝니다.
  • x-for: JavaScript 배열의 각 항목에 대해 HTML 요소를 반복하여 생성합니다. Select Box의 옵션들을 동적으로 생성하는 데 사용됩니다.
  • x-on:change: 특정 HTML 이벤트(여기서는 change 이벤트, 즉 Select Box의 값이 변경될 때 발생)가 발생했을 때 실행할 JavaScript 함수를 정의합니다.

 

 

HTML 구조:

 

먼저 기본적인 HTML 구조를 만들어 보겠습니다. 3개의 <select> 태그를 감싸는 <div>를 만들고, Alpine.js를 사용할 것이므로 x-data 속성을 추가합니다.

 

실행 결과:

 

코드를 HTML 파일에 저장하고 웹 브라우저로 열면, 다음과 같은 동작을 확인할 수 있습니다.

 

  1. 첫 번째 Select Box에서 "과일"을 선택하면, 두 번째 Select Box에는 "사과", "바나나", "오렌지" 옵션이 나타납니다.
  2. 두 번째 Select Box에서 "사과"를 선택하면, 세 번째 Select Box에는 "빨간 사과", "푸른 사과" 옵션이 나타납니다.
  3. 다시 첫 번째 Select Box에서 "채소"를 선택하면, 두 번째와 세 번째 Select Box는 "선택하세요"로 초기화되고, 두 번째 Select Box에는 "당근", "브로콜리", "오이" 옵션이 나타납니다.

 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Alpine.js 종속형 Select Box</title>
    <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
    <div x-data="{
        categories: [
            { value: 'fruit', label: '과일' },
            { value: 'vegetable', label: '채소' },
            { value: 'drink', label: '음료' }
        ],
        subCategories: {
            'fruit': [
                { value: 'apple', label: '사과' },
                { value: 'banana', label: '바나나' },
                { value: 'orange', label: '오렌지' }
            ],
            'vegetable': [
                { value: 'carrot', label: '당근' },
                { value: 'broccoli', label: '브로콜리' },
                { value: 'cucumber', label: '오이' }
            ],
            'drink': [
                { value: 'water', label: '물' },
                { value: 'juice', label: '주스' },
                { value: 'soda', label: '탄산음료' }
            ]
        },
        items: {
            'apple': ['빨간 사과', '푸른 사과'],
            'banana': ['달콤한 바나나', '작은 바나나'],
            'orange': ['신선한 오렌지', '제주 오렌지'],
            'carrot': ['단단한 당근', '미니 당근'],
            'broccoli': ['유기농 브로콜리', '냉동 브로콜리'],
            'cucumber': ['아삭한 오이', '백오이'],
            'water': ['생수', '탄산수'],
            'juice': ['오렌지 주스', '사과 주스'],
            'soda': ['콜라', '사이다']
        },
        selectedCategory: '',
        selectedSubCategory: '',
        selectedItem: '',
        filteredSubCategories: [],
        filteredItems: []
    }">
        <div>
            <label for="category">카테고리:</label>
            <select id="category" x-model="selectedCategory" @change="selectedSubCategory = ''; selectedItem = ''; filteredSubCategories = subCategories[selectedCategory] || []; filteredItems = []">
                <option value="">선택하세요</option>
                <template x-for="category in categories" :key="category.value">
                    <option :value="category.value" x-text="category.label"></option>
                </template>
            </select>
        </div>

        <div style="margin-top: 10px;">
            <label for="subCategory">세부 카테고리:</label>
            <select id="subCategory" x-model="selectedSubCategory" @change="selectedItem = ''; filteredItems = items[selectedSubCategory] || []">
                <option value="">선택하세요</option>
                <template x-for="subCategory in filteredSubCategories" :key="subCategory.value">
                    <option :value="subCategory.value" x-text="subCategory.label"></option>
                </template>
            </select>
        </div>

        <div style="margin-top: 10px;">
            <label for="item">품목:</label>
            <select id="item" x-model="selectedItem">
                <option value="">선택하세요</option>
                <template x-for="item in filteredItems" :key="item">
                    <option :value="item" x-text="item"></option>
                </template>
            </select>
        </div>

        <div style="margin-top: 20px;">
            선택된 카테고리: <span x-text="selectedCategory"></span><br>
            선택된 세부 카테고리: <span x-text="selectedSubCategory"></span><br>
            선택된 품목: <span x-text="selectedItem"></span>
        </div>
    </div>
</body>
</html>

 

개발자를 위한 추가 설명:

 

  • 데이터의 구조화: categories, subCategories, items와 같이 데이터를 JavaScript 객체나 배열로 구조화하는 것은 웹 개발에서 매우 중요합니다. 이렇게 하면 데이터를 체계적으로 관리하고, 필요에 따라 쉽게 접근하고 사용할 수 있습니다.

  • 이벤트 핸들링: @change와 같이 특정 이벤트가 발생했을 때 원하는 동작을 실행하도록 하는 것은 사용자 인터랙션을 처리하는 기본적인 방법입니다.

  • 양방향 바인딩 (x-model): x-model을 사용하면 HTML 요소의 값과 JavaScript 변수를 연결하여, 한쪽이 변경되면 다른 쪽도 자동으로 업데이트됩니다. 이는 사용자 입력과 애플리케이션 상태를 동기화하는 데 매우 편리합니다.

  • 조건부 렌더링 (Implicit in x-for with filtered data): x-for를 사용하여 배열의 내용에 따라 HTML 요소를 동적으로 생성하는 것은 매우 강력한 기능입니다. 여기서는 filteredSubCategories와 filteredItems를 사용하여 조건에 맞는 옵션들만 보여지도록 구현했습니다.

 


 

웹 서비스를 호출하는 방식으로 변경해 보겠습니다. 이번에는 서버에서 데이터를 비동기적으로 가져와서 Select Box의 옵션을 채우게 됩니다.

 

핵심 변경 사항:

 

  1. 데이터 구조 변경: 더 이상 categories, subCategories, items를 미리 정의하지 않습니다. 대신 서버에서 받아온 데이터를 저장할 변수를 사용합니다.
  2. API 호출 함수: 각 Select Box의 데이터를 가져오는 JavaScript 함수를 만듭니다. Workspace API를 사용하여 서버에 요청을 보냅니다.
  3. 로딩 및 에러 처리: API 호출이 완료될 때까지 로딩 상태를 표시하고, 에러가 발생했을 경우 사용자에게 알립니다.
  4. x-init 활용: Alpine.js 컴포넌트가 초기화될 때 첫 번째 Select Box의 데이터를 가져오도록 x-init을 사용합니다.

 

 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Alpine.js 웹 서비스 호출 종속형 Select Box</title>
    <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
    <div x-data="{
        categories: [],
        subCategories: [],
        items: [],
        selectedCategory: '',
        selectedSubCategory: '',
        selectedItem: '',
        categoryLoading: false,
        subCategoryLoading: false,
        itemLoading: false,
        categoryError: null,
        subCategoryError: null,
        itemError: null,

        // 첫 번째 Select Box 데이터 가져오기
        loadCategories() {
            this.categoryLoading = true;
            this.categoryError = null;
            fetch('/api/categories') // 실제 API 엔드포인트로 변경해야 합니다.
                .then(response => {
                    if (!response.ok) {
                        throw new Error('카테고리 데이터를 불러오는 데 실패했습니다.');
                    }
                    return response.json();
                })
                .then(data => {
                    this.categories = data;
                    this.categoryLoading = false;
                })
                .catch(error => {
                    this.categoryError = error.message;
                    this.categoryLoading = false;
                });
        },

        // 두 번째 Select Box 데이터 가져오기
        loadSubCategories() {
            if (!this.selectedCategory) {
                this.subCategories = [];
                return;
            }
            this.subCategoryLoading = true;
            this.subCategoryError = null;
            this.subCategories = []; // 이전 데이터 초기화
            this.selectedSubCategory = ''; // 두 번째 Select Box 초기화
            this.selectedItem = ''; // 세 번째 Select Box 초기화
            this.items = []; // 세 번째 Select Box 데이터 초기화
            this.itemError = null;

            fetch(`/api/subcategories?category=${this.selectedCategory}`) // 실제 API 엔드포인트로 변경해야 합니다.
                .then(response => {
                    if (!response.ok) {
                        throw new Error('세부 카테고리 데이터를 불러오는 데 실패했습니다.');
                    }
                    return response.json();
                })
                .then(data => {
                    this.subCategories = data;
                    this.subCategoryLoading = false;
                })
                .catch(error => {
                    this.subCategoryError = error.message;
                    this.subCategoryLoading = false;
                });
        },

        // 세 번째 Select Box 데이터 가져오기
        loadItems() {
            if (!this.selectedSubCategory) {
                this.items = [];
                return;
            }
            this.itemLoading = true;
            this.itemError = null;
            this.items = []; // 이전 데이터 초기화
            this.selectedItem = ''; // 세 번째 Select Box 초기화

            fetch(`/api/items?subcategory=${this.selectedSubCategory}`) // 실제 API 엔드포인트로 변경해야 합니다.
                .then(response => {
                    if (!response.ok) {
                        throw new Error('품목 데이터를 불러오는 데 실패했습니다.');
                    }
                    return response.json();
                })
                .then(data => {
                    this.items = data;
                    this.itemLoading = false;
                })
                .catch(error => {
                    this.itemError = error.message;
                    this.itemLoading = false;
                });
        },

        // 컴포넌트 초기화 시 첫 번째 Select Box 데이터 로드
        init() {
            this.loadCategories();
        }
    }">
        <div>
            <label for="category">카테고리:</label>
            <select id="category" x-model="selectedCategory" @change="loadSubCategories()" :disabled="categoryLoading">
                <option value="">선택하세요</option>
                <template x-for="category in categories" :key="category.value">
                    <option :value="category.value" x-text="category.label"></option>
                </template>
            </select>
            <template x-if="categoryLoading">
                <span>로딩 중...</span>
            </template>
            <template x-if="categoryError">
                <span style="color: red;" x-text="categoryError"></span>
            </template>
        </div>

        <div style="margin-top: 10px;">
            <label for="subCategory">세부 카테고리:</label>
            <select id="subCategory" x-model="selectedSubCategory" @change="loadItems()" :disabled="subCategoryLoading || !selectedCategory">
                <option value="">선택하세요</option>
                <template x-for="subCategory in subCategories" :key="subCategory.value">
                    <option :value="subCategory.value" x-text="subCategory.label"></option>
                </template>
            </select>
            <template x-if="subCategoryLoading">
                <span>로딩 중...</span>
            </template>
            <template x-if="subCategoryError">
                <span style="color: red;" x-text="subCategoryError"></span>
            </template>
        </div>

        <div style="margin-top: 10px;">
            <label for="item">품목:</label>
            <select id="item" x-model="selectedItem" :disabled="itemLoading || !selectedSubCategory">
                <option value="">선택하세요</option>
                <template x-for="item in items" :key="item">
                    <option :value="item" x-text="item"></option>
                </template>
            </select>
            <template x-if="itemLoading">
                <span>로딩 중...</span>
            </template>
            <template x-if="itemError">
                <span style="color: red;" x-text="itemError"></span>
            </template>
        </div>

        <div style="margin-top: 20px;">
            선택된 카테고리: <span x-text="selectedCategory"></span><br>
            선택된 세부 카테고리: <span x-text="selectedSubCategory"></span><br>
            선택된 품목: <span x-text="selectedItem"></span>
        </div>
    </div>
</body>
</html>

 

 

코드 변경 상세 설명:

 

  1. 데이터 속성 추가:
    • categoryLoading, subCategoryLoading, itemLoading: API 호출 중임을 나타내는 불리언 값입니다.
    • categoryError, subCategoryError, itemError: API 호출 실패 시 에러 메시지를 저장하는 변수입니다.
  2. loadCategories() 함수:
    • this.categoryLoading = true;: 로딩 상태를 true로 설정합니다.
    • Workspace('/api/categories'): /api/categories 엔드포인트로 GET 요청을 보냅니다. 주의: 실제 웹 서비스의 API 엔드포인트로 변경해야 합니다.
    • .then(...): API 호출이 성공하면 응답을 처리합니다.
      • response.ok: HTTP 상태 코드가 200번대인지 확인합니다.
      • response.json(): 응답 데이터를 JSON 형식으로 파싱합니다.
      • this.categories = data;: 파싱된 카테고리 데이터를 categories 배열에 저장합니다.
      • this.categoryLoading = false;: 로딩 상태를 false로 설정합니다.
    • .catch(...): API 호출이 실패하면 에러를 처리합니다.
      • this.categoryError = error.message;: 에러 메시지를 categoryError 변수에 저장합니다.
      • this.categoryLoading = false;: 로딩 상태를 false로 설정합니다.
  3. loadSubCategories() 함수:
    • 첫 번째 Select Box의 값이 선택되지 않았으면 세부 카테고리 목록을 비우고 함수를 종료합니다.
    • 로딩 상태 및 에러를 초기화하고, 이전 선택 값과 데이터를 초기화합니다.
    • Workspace(/api/subcategories?category=${this.selectedCategory}): /api/subcategories 엔드포인트로 GET 요청을 보내면서, 선택된 카테고리 값을 쿼리 파라미터로 전달합니다. 주의: 실제 API 엔드포인트로 변경해야 합니다.
    • 응답 처리 방식은 loadCategories()와 유사합니다.
  4. loadItems() 함수:
    • 두 번째 Select Box의 값이 선택되지 않았으면 품목 목록을 비우고 함수를 종료합니다.
    • 로딩 상태 및 에러를 초기화하고, 이전 선택 값을 초기화합니다.
    • Workspace(/api/items?subcategory=${this.selectedSubCategory}): /api/items 엔드포인트로 GET 요청을 보내면서, 선택된 세부 카테고리 값을 쿼리 파라미터로 전달합니다. 주의: 실제 API 엔드포인트로 변경해야 합니다.
    • 응답 처리 방식은 loadCategories()와 유사합니다.
  5. init() 함수:
    • Alpine.js 컴포넌트가 초기화될 때 자동으로 실행되는 함수입니다. 여기서 loadCategories()를 호출하여 첫 번째 Select Box의 데이터를 가져옵니다.
  6. HTML 수정:
    • 첫 번째 Select Box의 @change 이벤트 핸들러를 loadSubCategories()로 변경합니다.
    • 두 번째 Select Box의 @change 이벤트 핸들러를 loadItems()로 변경합니다.
    • 각 Select Box에 :disabled 속성을 추가하여 데이터 로딩 중이거나 이전 Select Box가 선택되지 않았을 때 비활성화합니다.
    • 로딩 중 상태와 에러 메시지를 표시하는 부분을 추가했습니다. <template x-if="categoryLoading">...</template>와 같은 형태로 구현했습니다.

 

// /api/categories 응답 예시
[
    { "value": "fruit", "label": "과일" },
    { "value": "vegetable", "label": "채소" },
    { "value": "drink", "label": "음료" }
]

// /api/subcategories?category=fruit 응답 예시
[
    { "value": "apple", "label": "사과" },
    { "value": "banana", "label": "바나나" },
    { "value": "orange", "label": "오렌지" }
]

// /api/items?subcategory=apple 응답 예시
[
    "빨간 사과",
    "푸른 사과"
]

 

 

이제 이 HTML 파일을 웹 브라우저로 열면, 페이지가 로드될 때 /api/categories를 호출하여 첫 번째 Select Box의 옵션을 채웁니다. 첫 번째 Select Box의 값을 변경하면 /api/subcategories를 호출하여 두 번째 Select Box의 옵션을 채우고, 두 번째 Select Box의 값을 변경하면 /api/items를 호출하여 세 번째 Select Box의 옵션을 채우는 방식으로 동작하게 됩니다.