Jhipster와 Vue.js로 CRUD 게시판만들기 2편

Updated:

Jhipster Vue.js 개발 1편 다시 읽기

지난 Jhipster Vue.js 개발기 1편 Re-mind

지난 Jhipster를 활용한 Microservice Application의 프론트 개발 1편에서는 Jhipster의 프론트를 커스터마이징 하여 새로운 메뉴 버튼을 만들어보았습니다.

이번엔 해당 버튼과 기존에 개발해놓은 백엔드 서비스를 연결하여 실제로 버튼을 눌렀을 때 해당 기능이 동작하도록 해볼게요~!

book-rental 기능을 위한 모듈 개발

router의 index.ts에 추가한 내용처럼

    const BookRental = () => import('../cnaps/book-rental-service/book-rental.vue');

webApp -> app 에 cnaps라는 폴더를 생성합니다. (폴더의 이름은 자유롭게 지정하여도 됩니다.) 이제 이 cnaps 라는 폴더 하위에 각 기능들을 위한 폴더를 생성하고 해당 폴더들 안에 각 기능을 위한 모듈을 개발할 거에요.

Vue.js에서는 기본적으로 component.ts, service.ts, vue파일이 세트로 묶입니다. 여기에 각 페이지별로 뻗어가는 하위 기능을 위한 view는 component와 vue파일만 새로 생성합니다.

이제 book-rental이란 모듈을 개발할 예정인데, 이 모듈 내에는 우선적으로 도서 조회, 검색, 도서 세부정보 보기를 만들 것 입니다. 추후 대여/반납 기능을 추가할 예정이에요.

따라서 아래 이미지와 같이 폴더 및 패키지를 생성합니다.

우선 Vue.js의 component, service, vue파일은 jhipster에서 제공하는 entities의 기존 내용을 그대로 가져와 수정 및 추가하는 방향으로 개발하였습니다.

book-rental.vue작성

book-rental.vue의 전체 코드는 아래와 같습니다. (조회와 검색 기능만 있음)

<template>
    <div>
        <h2 id="page-heading">
            <span v-text="$t('global.menu.rentalpage')">Rental Page</span>
        </h2>
        <b-alert :show="dismissCountDown"
            dismissible
            :variant="alertType"
            @dismissed="dismissCountDown=0"
            @dismiss-count-down="countDownChanged">
            
        </b-alert>
        <br/>
        <div class="alert alert-warning" v-if="!isFetching && books && books.length === 0">
            <span>No books found</span>
        </div>
        <div class="input-group mb-3">
            <label>
                <input type="text" class="form-control" placeholder="Search by title"
                       v-model="title"/>
            </label>
            <div class="input-group-append">
                <button class="btn btn-outline-secondary" type="button"
                        @click="search(title)"
                >
                    Search
                </button>
            </div>
        </div>
        <div class="table-responsive" v-if="books && books.length > 0">
            <table class="table table-striped">
                <thead>
                <tr>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.title')">Title</span></th>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.description')">Description</span></th>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.classification')">Classification</span></th>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.author')">Author</span></th>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.publicationDate')">Publication Date</span></th>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.rented')">Rented</span></th>
                    <th><span v-text="$t('gatewayApp.bookCatalogBookCatalog.rentCnt')">Rental Count</span></th>
                    <th></th>
                </tr>
                </thead>
                <tbody>
                <tr v-for="book in books"
                    :key="book.title">
                    <td>
                        <router-link :to="{name: 'BookRentalView', params: {bookTitle: book.title}}"></router-link>
                    </td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td class="text-right">
                        <div class="btn-group">
                            <router-link :to="{name: 'BookRentalView', params: {bookTitle: book.title}}" tag="button" class="btn btn-info btn-sm details">
                                <font-awesome-icon icon="eye"></font-awesome-icon>
                                <span class="d-none d-md-inline" v-text="$t('entity.action.view')">View</span>
                            </router-link>

                        </div>
                    </td>
                </tr>
                </tbody>
            </table>
        </div>
        <div v-show="books && books.length > 0">
            <div class="row justify-content-center">
                <jhi-item-count :page="page" :total="queryCount" :itemsPerPage="itemsPerPage"></jhi-item-count>
            </div>
            <div class="row justify-content-center">
                <b-pagination size="md" :total-rows="totalItems" v-model="page" :per-page="itemsPerPage" :change="loadPage(page)"></b-pagination>
            </div>
        </div>
    </div>
</template>

<script lang="ts" src="./book-rental.component.ts">
</script>

윗부분부터 살펴보겠습니다.

  1. 페이지 이름 수정

     <h2 id="page-heading">
         <span v-text="$t('global.menu.rentalpage')">Rental Page</span>
     </h2>
    

    먼저, 페이지 이름을 메뉴에 등록한 이름으로 변경합니다. 여기서는 Rental Page로 변경하였습니다.

  2. 책이 없는 경우 경고 표시 & 검색어 입력 및 버튼 만들기

         <div class="alert alert-warning" v-if="!isFetching && books && books.length === 0">
             <span>No books found</span>
         </div>
         <div class="input-group mb-3">
             <label>
                 <input type="text" class="form-control" placeholder="Search by title"
                        v-model="title"/>
             </label>
             <div class="input-group-append">
                 <button class="btn btn-outline-secondary" type="button"
                         @click="search(title)"
                 >
                     Search
                 </button>
             </div>
         </div>
    

    v-if는 말 그래도 if문입니다. 만약 book catalog에 등록된 책이 없으면 No books found라는 에러 메세지가 뜨도록 하였습니다. 여기서 book catalog list가 바로 books라는 변수로 쓰였습니다.

    그 밑에는 입력 폼을 추가합니다. 여기서 v-model은 입력된 내용을 title이라는 변수에 넣는다는 것이며, 버튼을 누르면 @click="search(title)"이 실행되어 search(title)이라는 메소드가 실행된다.

    그럼 이 books라는 변수와 search(title)라는 메소드는 어디에 선언한 것일까?

    바로 component.ts파일에 선언된다. vue 파일의 전체 소스코드 맨 하단을 보면 <script>로 묶인 부분에 component.ts파일이 source라는 것이 명시되어있다.

book-rental-component.ts파일 수정

        import { mixins } from 'vue-class-component';

        import { Component, Vue, Inject } from 'vue-property-decorator';
        import Vue2Filters from 'vue2-filters';
        import { IRental } from '@/shared/model/rental/rental.model';
        import { IBookCatalog } from '@/shared/model/bookCatalog/book-catalog.model';
        import AlertMixin from '@/shared/alert/alert.mixin';

        import BookRentalService from './book-rental.service';

        @Component({
        mixins: [Vue2Filters.mixin],
        })
        export default class BookRental extends mixins(AlertMixin) {
        @Inject('bookRentalService') private bookRentalService: () => BookRentalService;
        private removeId: number = null;
        public itemsPerPage = 20;
        public queryCount: number = null;
        public page = 1;
        public previousPage = 1;
        public propOrder = 'id';
        public reverse = false;
        public totalItems = 0;
        public title = '';
        public rentals: IRental[] = [];
        public books: IBookCatalog[] = [];
        public isFetching = false;

        public mounted(): void {
            this.retrieveAllBooks();
        }

        public clear(): void {
            this.page = 1;
            this.retrieveAllBooks();
        }

        public retrieveAllBooks(): void {
            this.isFetching = true;

            const paginationQuery = {
            page: this.page - 1,
            size: this.itemsPerPage,
            sort: this.sort(),
            };
            this.bookRentalService()
            .retrieve(paginationQuery)
            .then(
                res => {
                this.books = res.data;
                this.totalItems = Number(res.headers['x-total-count']);
                this.queryCount = this.totalItems;
                this.isFetching = false;
                },
                err => {
                this.isFetching = false;
                }
            );
        }
        public sort(): Array<any> {
            const result = [this.propOrder + ',' + (this.reverse ? 'asc' : 'desc')];
            if (this.propOrder !== 'id') {
            result.push('id');
            }
            return result;
        }

        public loadPage(page: number): void {
            if (page !== this.previousPage) {
            this.previousPage = page;
            this.transition();
            }
        }

        public transition(): void {
            this.retrieveAllBooks();
        }

        public changeOrder(propOrder): void {
            this.propOrder = propOrder;
            this.reverse = !this.reverse;
            this.transition();
        }

        public closeDialog(): void {
            (<any>this.$refs.removeEntity).hide();
        }
        
        public search(title: String): void {
            let foundBook: IBookCatalog[] = [];
            this.bookRentalService()
            .findByTitle(title)
            .then(res => {
                foundBook.push(res);
                this.books = foundBook;
            });
        }
        }

위 소스 코드를 차근 차근 살펴봅시다. 먼저 윗부분부터 살펴보면 @Inject('bookRentalService') private bookRentalService: () => BookRentalService;라는 코드로 bookRentalService를 주입시킨다. 이 bookRentalService는 추후 설명할 예정이지만, 간단히 설명하자면 다른 마이크로 서비스의 REST API controller로 요청을 주고받는 곳이다.

    public title = '';
    public rentals: IRental[] = [];
    public books: IBookCatalog[] = [];

위처럼 vue파일에 쓰일 변수들을 초기화 해줍니다. title은 검색할 때 쓰이는 v-model 변수로 설명하였죠? rentals와 books는 Jhipster에서 rental과 bookCatalog 서비스와 연결한 후 생성한 client model과 연결시켜줍니다. 해당 모델들은 webApp -> app -> shared -> model에서 확인할 수 있습니다.

그 밑의 메소드들이 바로 vue의 템플릿에서 사용되는 메소드들입나다.

    public search(title: String): void {
            let foundBook: IBookCatalog[] = [];
            this.bookRentalService()
            .findByTitle(title)
            .then(res => {
                foundBook.push(res);
                this.books = foundBook;
            });
        }
        }

이부분이 바로 vue파일에서 선언한 search입니다.

위 코드에서 보면 bookRentalService를 호출하고 있는데, 이부분은 잠시 후 설명하겠습니다.

book-rental.vue

다시, book-rental.vue파일 코드를 살펴볼까요?

    <tr v-for="book in books"
                    :key="book.title">
                    <td>
                        <router-link :to="{name: 'BookRentalView', params: {bookTitle: book.title}}"></router-link>
                    </td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td class="text-right">
                        <div class="btn-group">
                            <router-link :to="{name: 'BookRentalView', params: {bookTitle: book.title}}" tag="button" class="btn btn-info btn-sm details">
                                <font-awesome-icon icon="eye"></font-awesome-icon>
                                <span class="d-none d-md-inline" v-text="$t('entity.action.view')">View</span>
                            </router-link>

                        </div>
                    </td>
                </tr>

우선 v-for은 for문으로 book catalog에서 가져온 도서들을 하나씩 돌아가며 하단 코드를 실행시킵니다. 일반적인 for문과 똑같은 형태임을 알 수 있습니다. 이때 key로 book.title인것을 볼 수 있는데, index를 의미합니다. (book.id로 수정할 예정입니다.)

첫번째로 눈에 띄는 것은 <router-link>인데요, 이것은 위에서 설명한대로 router의 index.ts에 추가하였던 path의 name과 연결되는 곳 입니다.

첫번째 router-link를 보면 ‘BookRentalView’에 연결되며, 전달되는 param은 bookTitle로 book.title이 입력됨을 알 수 있습니다. 두번째 router-link도 마찬가지입니다.

이부분을 통해 바로 vue에서 다른 vue파일로 param을 넘기는 방식을 확인할 수 있습니다.

BookRentalView는 BookRentalService를 설명한 뒤 간략하게 설명하도록하겠습니다.

book-rental-service.ts

앞서 component에서 bookRentalService를 주입시켰습니다. 이렇게 bookRentalService를 주입시키기 위해선 이 애플리케이션에 bookRentalService를 선언해야합니다.

서비스를 선언하는 부분은 webApp -> app -> main.ts에서 선언할 수 있습니다.


import BookRentalService from '@/cnaps/book-rental-service/book-rental.service';

...

new Vue({
  el: '#app',
  components: { App },
  template: '<App/>',
  router,
  provide: {
    ...
    bookRentalService: () => new BookRentalService(),
  },
  i18n,
  store,
});

이제 book-rental-service.ts를 살펴볼게요.

import axios from 'axios';

import buildPaginationQueryOpts from '@/shared/sort/sorts';

import { IRental } from '@/shared/model/rental/rental.model';
import { IBookCatalog } from '@/shared/model/bookCatalog/book-catalog.model';

const rentalApiUrl = 'services/rental/api/rentals';
const bookApiUrl = 'services/bookcatalog/api/book-catalogs';

export default class BookRentalService {
  public find(id: number): Promise<IBookCatalog> {
    return new Promise<IBookCatalog>((resolve, reject) => {
      axios
        .get(`${bookApiUrl}/${id}`)
        .then(res => {
          resolve(res.data);
        })
        .catch(err => {
          reject(err);
        });
    });
  }

  public retrieve(paginationQuery?: any): Promise<any> {
    return new Promise<any>((resolve, reject) => {
      axios
        .get(bookApiUrl + `?${buildPaginationQueryOpts(paginationQuery)}`)
        .then(res => {
          resolve(res);
        })
        .catch(err => {
          reject(err);
        });
    });
  }

  public findByTitle(title: String): Promise<IBookCatalog> {
    return new Promise<IBookCatalog>((resolve, reject) => {
      axios
        .get(`${bookApiUrl}/title/${title}`)
        .then(res => {
          resolve(res.data);
        })
        .catch(err => {
          reject(err);
        });
    });
  }
  
}

book-catalog-service.ts의 코드 또한 매우 직관적입니다.

const rentalApiUrl = 'services/rental/api/rentals';
const bookApiUrl = 'services/bookcatalog/api/book-catalogs';

이부분은 url을 선언하는 부분으로, 이 url을 통해 gateway에 등록된 마이크로 서비스의 REST API로 요청을 주고 받습니다.

앞서 component에서 생성한 search 메소드를 살펴보면

    public search(title: String): void {
            let foundBook: IBookCatalog[] = [];
            this.bookRentalService()
            .findByTitle(title)
            .then(res => {
                foundBook.push(res);
                this.books = foundBook;
            });
        }
        }

this.bookRentalService().findBytitle(title)로 검색을 위해 입력한 title을 bookRentalService의 findByTitle메소드로 보냅니다. 서비스에서 findByTitle()을 실행시키면 axios로 요청을 보내고 data를 받아 resolve합니다. 이 data를 다시 컴포넌트의 search 메소드가 받고, book-rental에서 사용하는 books Array에 넣어줍니다.

BookRentalView

BookRentalView는 도서의 상세 정보를 조회했을 때 나오는 페이지로, book-rental-details에 해당합니다. 이것 또한 새로운 페이지로 이동하는 것이기 때문에 우선 router에 등록해주어야합니다.

router의 index.ts에 아래와 같은 코드를 추가해준다.

{
      path: '/rent/:bookTitle/view',
      name: 'BookRentalView',
      component: BookRentalDetails,
      meta: { authorities: [Authority.USER]}
    }

path를 보면 /rent/:bookTitle/view로 중간에 bookTitle이 있는 것을 확인할 수 있니다. 이는 param을 bookTitle로 가져온다는 것입니다. (추후 bookId로 수정될 수 있어요.) 또한 BookRentalView라는 이름으로 vue파일에서 연결될 것입니다. book-rental.vue 코드에서 아래와 같이 확인할 수 있습니다.

<td>
    <router-link :to="{name: 'BookRentalView', params: {bookTitle: book.title}}"></router-link>
</td>

component 는 BookRentalDetails란 이름으로 아래와 같이 등록해줍니다.

const BookRentalDetails = () => import('../cnaps/book-rental-service/book-rental-details.vue');

component는 path와 마찬가지로 index.ts라는 동일한 파일 상단에 등록해줍니다.

이제 book-rental-details-component를 확인해볼까요?

book-rental-details-component.ts작성

import { Component, Inject, Vue } from 'vue-property-decorator';
import { IBookCatalog } from '@/shared/model/bookCatalog/book-catalog.model';
import BookRentalService from '@/cnaps/book-rental-service/book-rental.service';
@Component
export default class BookRentalDetails extends Vue {
  @Inject('bookRentalService') private bookRentalService: () => BookRentalService;
  public book: IBookCatalog = {};

  beforeRouteEnter(to, from, next) {
    next(vm => {
      if (to.params.bookTitle) {
        vm.retrieveBookRental(to.params.bookTitle);
      }
    });
  }

  public retrieveBookRental(bookTitle) {
    this.bookRentalService()
      .findByTitle(bookTitle)
      .then(res => {
        this.book = res;
      });
  }

  public previousState() {
    this.$router.go(-1);
  }
}
  • bookRentalService 주입 : book-rental-details 또한 BookRentalService 의 주입이 필요합니다. Book Rental과 연결되어있는 기능이기 때문입니다.
  • book : IBookCatalog= {} : Book Catalog의 front model을 가져와 선언합니다. book-rental-details.vue에서 book이라는 명칭을 사용해 bookCatalog를 가져오게됩니다.
  • beforeRouteEnter : vue의 기능 중 하나인 네비게이션 가드로, Vue Router로 특정 URL에 접근할 때 해당 URL의 접근을 막는 방법을 의미합니다. 주로 사용자 인증정보가 없으면 페이지에 접근을 못하게 하는데에 주로 쓰입니다.
    • 여기서는 다른 의미로 쓰였는데, 상세정보 조회 버튼을 눌렀을 때 해당 책에 대한 key정보(bookTitle)이 param으로 넘어오지 않으면 페이지 이동이 제한됩니다.
  • retrieveBookRental : beforeRouteEnter 함수의 코드를 보면 조건 통과 후 retrieveBookRental을 호출합니다. retrieveBookRental은 넘겨받은 bookTitle을 bookRentalService의 findByTitle을 실행시켜 book 정보를 가져옵니다.

book-rental-details.vue

<template>
    <div class="row justify-content-center">
        <div class="col-8">
            <div v-if="book">
                <h2 class="jh-entity-heading"><span v-text="$t('gatewayApp.bookCatalogBookCatalog.detail.header')">Book Details</span></h2>
                <dl class="row jh-entity-details">
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.title')">Title</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.description')">Description</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.author')">Author</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.publicationDate')">Publication Date</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.classification')">Classification</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.rented')">Rented</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                    <dt>
                        <span v-text="$t('gatewayApp.bookCatalogBookCatalog.rentCnt')">Rent Cnt</span>
                    </dt>
                    <dd>
                        <span></span>
                    </dd>
                </dl>
            </div>
        </div>
    </div>
</template>

<script lang="ts" src="./book-rental-details.component.ts">
</script>

bookRentalDetailsView에 연결된 vue 파일입니다. 상세 정보를 보여주는 만큼 별 기능이 없어 코드는 심플하죠? (주 코드 설명은 book-rental.vue에서 설명하였으므로 생략)

Vue.js 개발 간단 요약

비록 1편부터 지금 2편까지 조금은 길고 복잡하게 개발과정을 설명드렸지만, 짧게 한번 요약해보자면!

Vue.js에서 어떠한 페이지를 생성해 기능을 부여하는 개발 순서는 아래와 같습니다.

  1. 개발하고자 하는 모듈의 package(폴더)생성
  2. 모듈 폴더 내에 해당 기능을 위한 name-component.ts, name-service.ts, name.vue파일 생성 및 소스 개발
    1. component는 vue에서 사용되는 메소드 및 변수 선언
    2. service는 Microservices와의 REST API 통신을 위한 메소드 및 변수 선언
  3. component 및 service 를 전역 등록
  4. Path를 router에 등록
  5. navBar 또는 Home에서 이동할 수 있는 메뉴 추가

조금은 복잡해보이지만, 백문이 불여 코딩! 실제로 코딩해보고 동작하도록 만들어보면 위의 개발 패턴은 금방 익힐 수 있을 거예요~! 물론, Vue.js를 이렇게 쉽게 익힌데에는 Jhister를 활용하면서 이미 프론트의 기본 틀이 잡힌 코드를 재사용 할 수 있었던 이유가 있기도 합니다.

그러한 의미에서 Jhipster를 활용하면 백엔드 뿐만 아니라 프론트 엔드 개발도 해당 언어나 구조를 이해하고 있다면 정말 빠르게 퀄리티 있는 Microservice Application을 개발할 수 있을 것 같습니다 :)