ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 공공데이터를 활용한 의약품 검색 사이트 구축 #2 프론트엔드 with vue.js
    미니프로젝트 2021. 12. 28. 18:01
    반응형
    반응형

    공공데이터를 활용한 의약품 검색 사이트 구축

    1. 프로젝트 구성

    이번 의약품 검색 사이트 구축 프로젝트에 사용될 구성이다.

    - 프론트엔드(vue.js)

    - 백엔드(golang)

    - 배포서버(AWS EC2)

     

    백엔드에서 공공데이터 API를 요청하여 가공한 후 프론트엔드에서 백엔드를 통해 데이터를 확인할 수 있는 방식으로 설계를 할 것이다.


    2. 개발환경 사전 준비

    - 프론트엔드 프로젝트

     

    빠른 생산성 및 결과를 위해 자바스크립트 프레임워크인 vue.js로 구축하기로 한다.

    vue.js에 대한 설명은 다음 문서를 통해 확인할 수 있다.

    https://ko.wikipedia.org/wiki/Vue.js

     

    Vue.js - 위키백과, 우리 모두의 백과사전

    Vue.js(간단히 Vue, , 뷰/view)는 웹 애플리케이션의 사용자 인터페이스를 만들기 위해 사용하는 오픈 소스 프로그레시브 자바스크립트 프레임워크이다.[4] 다른 자바스크립트 라이브러리를 사용하

    ko.wikipedia.org

    구축에 필요한 패키지는 설치가 되어있다는 전제하에 프로젝트를 진행할 것이다.

     

    [개발환경]

    OS : Ubuntu
    LANGUAGE : Javascript Frontend Framework (vue.js)
    IDE : Visual Studio Code

    우리 프로젝트는 node와 vue가 설치되어 있다는 가정하에 진행하므로 설치에 관련된 것은 다른 블로그를 참고하길 추천한다. 그럼 설치되어 있는지 확인한 후 프로젝트를 생성한다.

    $ node --vesion
    v14.18.1
    $ vue --vesion
    @vue/cli 4.5.13

     

    node와 vue cli가 설치되어 있는 것을 확인한 후 vue 프로젝트를 생성한다.

    우선 터미널에서 프로젝트를 생성할 경로로 이동한 후 아래와 같이 명령어를 날린다.

    vue create project-frontend

    프로젝트 이름은 본인이 원하는 이름으로 생성한다. 우리는 이번 프로젝트를 project-frontend 으로 설정하려 한다.

    Vue CLI v4.5.13
    ┌───────────────────────────────────────────┐
    │											│
    │   New version available 4.5.13 → 4.5.15						│
    │     Run npm i -g @vue/cli to update!							│
    │											│
    └───────────────────────────────────────────┘
    
    ? Please pick a preset: (Use arrow keys)
      Default ([Vue 2] babel, eslint) 
      Default (Vue 3) ([Vue 3] babel, eslint) 
    ❯ Manually select features

    프로젝트를 생성하는 과정에 몇 가지 옵션을 선택하라는 내용이 나온다.

     

    우리는 이번 프로젝트에서 사용할 옵션만 간단히 설정하기로 한다. 방향키로 "Manually select features"를 선택하고 엔터키를 누른다.

    ? Check the features needed for your project: (Press <space> to select, <a> to t
    oggle all, <i> to invert selection)
    ❯◉ Choose Vue version
     ◉ Babel
     ◯ TypeScript
     ◯ Progressive Web App (PWA) Support
     ◉ Router
     ◉ Vuex
     ◯ CSS Pre-processors
     ◉ Linter / Formatter
     ◯ Unit Testing
     ◯ E2E Testing

    우리는 기본으로 설정된 부분에서 Router와 Vuex를 사용할 예정이므로 두 개를 스페이스바를 통해 추가 선택한 후 엔터를 눌러 다음으로 넘어간다.

    ? Choose a version of Vue.js that you want to start the project with 
    ❯ 2.x 
      3.x

    프로젝트의 Vue.js 버전을 선택하라고 나오는데 개인적으로 3.x로 진행할 때 종속성 오류가 제법 발생했기에 2.x를 선택하고 다음으로 진행한다.

    ? Use history mode for router? (Requires proper server setup for index fallback 
    in production) (Y/n) y

    먼저 히스토리 모드를 사용하기 위해 "y"를 입력한 후 엔터키를 누른다.

    히스토리 모드에 대한 자세한 내용은 다음 문서를 참고하시기 바랍니다.

    https://router.vuejs.org/kr/guide/essentials/history-mode.html#%EC%84%9C%EB%B2%84-%EC%84%A4%EC%A0%95-%EC%98%88%EC%A0%9C

     

    HTML5 히스토리 모드 | Vue Router

    HTML5 히스토리 모드 vue-router의 기본 모드는 hash mode 입니다. URL 해시를 사용하여 전체 URL을 시뮬레이트하므로 URL이 변경될 때 페이지가 다시 로드 되지 않습니다. 해시를 제거하기 위해 라우터의

    router.vuejs.org

    ? Pick a linter / formatter config: 
      ESLint with error prevention only 
      ESLint + Airbnb config 
      ESLint + Standard config 
    ❯ ESLint + Prettier

    lint formatter를 설정하기 위해 제시되는 4개의 내용 중에 우리는 ESLint + Prettier 를 선택한다.

     

    선택하는 이유는 자바스크립트 문법에 오류가 있거나 잘못된 코드 포맷으로 배포되는 것을 막기 위함이다. ESLint 는 잘못된 문법을 표시해주고, 코드 스타일 정리는 Prettier 가 담당하는 방식이다. 

    ? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i
    > to invert selection)
    ❯◉ Lint on save
     ◯ Lint and fix on commit

    다음 질문으로 파일을 저장할 때 lint 검사를 할 건지, 검사와 함께 자동으로 fix를 할 것인가에 대한 것인데 우리 프로젝트는 검사와 함께 자동으로 fix할 이유가 없기 때문에 lint 검사 정도만 하기 위해 "Lint on save"를 선택한 후 다음으로 넘어간다.

    ? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
      In dedicated config files 
    ❯ In package.json

    다음으로는 설정 값을 어디에서 관리할 것인가를 물어보는데 개발자의 입장에서는 package.json 파일로 관리하는 게 편할 것이다. 그러므로 "In Package.json" 를 선택하고 넘어간다.

    ? Save this as a preset for future projects? (y/N) n

    마지막 질문으로 위에서 선택한 개발환경을 저장할 것인가 대해 물어보는 것인데 우리는 연습을 목적으로 하는 프로젝트이기에 "n"을 선택하고 다음으로 넘어가면 프로젝트가 생성을 시작한다.

    🎉  Successfully created project project-frontend.
    👉  Get started with the following commands:
    
     $ cd project-frontend
     $ npm run serve

    성공적으로 생성됐다는 메시지가 출력되고 시작하기 위해 두 개의 명령어를 터미널에서 진행하라는 안내 멘트도 나온다. 따라 해 본다.

     INFO  Starting development server...
    98% after emitting CopyPlugin
    
     DONE  Compiled successfully in 0000ms 
    
    
      App running at:
      - Local:   http://localhost:8080/ 
      - Network: http://192.168.xx.x:8080/
    
      Note that the development build is not optimized.
      To create a production build, run npm run build.

    개발 서버가 시작된다는 메시지와 함께 컴파일 성공했다는 내용이 나오면 브라우저에 Local에 해당하는 주소를 복사해서 확인해본다.

    vue.js 앱의 기본페이지

    환영한다는 페이지가 나온다면 프로젝트 생성은 문제없이 진행된 것이다.

    다음으로 우리 프로젝트에서 사용할 디자인 프레임워크를 추가해보자. vuetify 라는 것인데 Vue.js를 위한 개발된 디자인 프레임워크이다. 자세한 내용은 아래에 문서에서 확인할 수 있다.

     

    https://vuetifyjs.com/en/introduction/why-vuetify/

     

    Why you should be using Vuetify

    Vuetify has an extremely active community, provides easy to use Material Design components and is consistently updat...

    vuetifyjs.com

    우리 프로젝트에 적용하기 위해 터미널로 돌아와 다음 명령어를 입력한다.

    vue add vuetify

    그럼 다음과 같이 vuetify 플러그인을 인스톨하고 옵션을 선택하는 질문이 나온다.

    📦  Installing vue-cli-plugin-vuetify...
    
    + vue-cli-plugin-vuetify@2.4.5
    added 10 packages from 11 contributors in 12.521s
    
    92 packages are looking for funding
      run `npm fund` for details
    
    ✔  Successfully installed plugin: vue-cli-plugin-vuetify
    
    ? Choose a preset: 
      Configure (advanced) 
    ❯ Default (recommended) 
      Vite Preview (Vuetify 3 + Vite) 
      Prototype (rapid development) 
      Vuetify 3 Preview (Vuetify 3)

    우리는 별다른 옵션을 사용하지 않을 것이므로 Default를 선택하고 다음으로 넘어간다.

    🚀  Invoking generator for vue-cli-plugin-vuetify...
    📦  Installing additional dependencies...
    
    added 15 packages from 10 contributors in 8.584s
    
    95 packages are looking for funding
      run `npm fund` for details
    
    ⚓  Running completion hooks...
    
    ✔  Successfully invoked generator for plugin: vue-cli-plugin-vuetify
     vuetify  Discord community: https://community.vuetifyjs.com
     vuetify  Github: https://github.com/vuetifyjs/vuetify
     vuetify  Support Vuetify: https://github.com/sponsors/johnleider

    성공적으로 플러그인이 추가되었다고 나오면 다음과 같이 개발 서버를 다시 실행해본다.

    npm run serve

    브라우저에서 http://localhost:8080/ 를 요청해본다.

    vuetify 가 적용된 화면

    vuetify가 성공적으로 적용된 화면으로 바뀐 것을 볼 수 있다.

    어느 정도 프로젝트를 개발한 준비가 된 것이기에 다음으로 넘어간다.


    3. 프로젝트 구조 확립

    처음 프로젝트를 생성하면 아래와 같은 구조로 되어있을 것이다.

    .project-frontend
    ├── node_modules
    ├── public
    │	 ├── favicon.ico
    │	 └── index.html
    ├── src
    │	 ├── assets
    │	 ├── components
    │	 ├── plugins
    │	 ├── router
    │	 ├── store
    │	 └── views
    ├── babel.config.js
    ├── package-lock.json
    ├── package.json
    ├── README.md
    └── vue.config.js

    프로젝트 생성 후 위와 같은 구조에 대한 설명은 아래의 유튜브 영상에 자세하게 나와있다. 뿐만 아니라 vue.js에 관한 사용법 등의 설명이 자세하게 나와있으므로 추천한다.

    https://youtu.be/G6rhxMuqnhU

    이제 우리가 추가해야 할 파일들을 정의한다.

     

    src/views 경로에 우리가 사용할 페이지를 4개 추가한다.

    - Main.vue

    - Search.vue

    - Contact.vue

    - Error.vue

     

    그리고 src/components 경로에 컴포넌트를 1개 추가한다.

    - Spinner.vue

     

    마지막으로 루트 경로에 환경변수로 지정할 파일을 1개 추가한다.

    - .env

     

    우리가 추가할 목록은 아래와 같다.

    .project-frontend
    ├── src
    │	 ├── components
    │	 │	└── Spinner.vue
    │	 └── views
    │	 	├── Main.vue
    │	 	├── Search.vue
    │	 	├── Contact.vue
    │	 	└── Error.vue
    └── .env

    마지막으로 백엔드와 API를 요청하기 위해 HTTP 비동기 통신 라이브러리 axios를 우리의 프로젝트에 설치한다.

    npm install --save axios

    이제 다음으로 넘어가 프로젝트 구조 확립에서 생성했던 파일을 작성하며 역할을 설명하고자 한다.


    4. 프로젝트 코드 작성

    > .env

    VUE_APP_APIURL=http://localhost:5000/api

    우리의 백엔드 서버 엔드포인트를 적어준다.

     

    백엔드서버 구축에 관한 내용은 지난 글에 나와있다.

    2021.12.27 - [미니프로젝트] - 공공데이터를 활용한 의약품 검색 사이트 구축 #1 백엔드 with Golang

     

    공공데이터를 활용한 의약품 검색 사이트 구축 #1 백엔드 with Golang

    공공데이터를 활용한 의약품 검색 사이트 구축 1. 프로젝트 구성 이번 의약품 검색 사이트 구축 프로젝트에 사용될 구성이다. - 백엔드(golang) - 프론트엔드(vue.js) - 배포서버(AWS EC2) 백

    jeong-dev-blog.tistory.com


    > src/router/index.js

    각 요청 패스에 해당하는 페이지를 보여주기 위해서 우선적으로 라우터를 설정해준다.

    // src/router/index.js
    import Vue from "vue";
    import VueRouter from "vue-router";
    import Main from "../views/Main.vue";
    import Search from "../views/Search.vue";
    import Contact from "../views/Contact.vue";
    import Error from "../views/Error.vue";
    
    Vue.use(VueRouter);
    
    const routes = [
      // 루트 URL 접근시 main 페이지로 리다이렉트를 줘서 기본페이지를 보여주는 역할을 한다.
      {
        path: "/",
        redirect: "/main",
      },
      // 메인페이지
      {
        path: "/main",
        name: "Main",
        component: Main,
      },
      // 검색리스트 결과페이지
      {
        path: "/search",
        name: "Search",
        component: Search,
      },
      // 문의페이지
      {
        path: "/contact",
        name: "Contact",
        component: Contact,
      },
      // 위의 URL 패스 이외에 접속은 에러페이지를 반환하기 위해 /error 로 리다이렉트
      {
        path: "*",
        redirect: "/error",
      },
      // 에러페이지
      {
        path: "/error",
        name: "Error",
        component: Error,
      }
    
    ];
    
    const router = new VueRouter({
      mode: "history",
      base: process.env.BASE_URL,
      routes,
    });
    
    export default router;

    라우팅을 정해놨어도 기본 페이지에서 라우팅을 설정해야 하므로 다음 파일을 수정한다.


    > App.vue

    // ./App.vue
    <template>
      <v-app id="inspire">
        <v-app-bar
          app
          color="white"
          flat
        >
          <v-tabs
            centered
            class="ml-n9"
            color="grey darken-1"
          >
            <v-tab
              v-for="link in links"
              :key="link.name"
              :to="link.to"
            >
              {{ link.name }}
            </v-tab>
          </v-tabs>
        </v-app-bar>
    
        <v-main class="grey lighten-3">
          <router-view/>
        </v-main>
      </v-app>
    </template>
    
    <script>
    export default {
      data: () => ({
        links: [
          { name: 'Main', to: '/main'},
          { name: 'Search', to: '/search'},
          { name: 'Contact', to: '/contact'},
        ],
      }),
    };
    </script>

    앱의 기본 네비게이션을 설정하고 해당하는 탭을 누를 경우 이동하는 설정을 해놓았다. 이제 각 페이지의 틀을 만들어보자.


    그전에 우리가 각 페이지에서 전역으로 사용할 변수들과 그 값들을 처리하고 상태를 변경시켜줄 함수를 정의하기 위해 vuex를 사용할 것이다. vuex에 대한 설명은 다음 문서를 확인한다.

    https://vuex.vuejs.org/kr/

     

    Vuex가 무엇인가요? | Vuex

    Vuex가 무엇인가요? Vuex는 Vue.js 애플리케이션에 대한 상태 관리 패턴 + 라이브러리 입니다. 애플리케이션의 모든 컴포넌트에 대한 중앙 집중식 저장소 역할을 하며 예측 가능한 방식으로 상태를

    vuex.vuejs.org

    vuex를 통한 상태를 관리하기 위해 우리가 설정할 파일은 store/index.js 파일이다.

    > src/store/index.js

    // src/store/index.js
    import Vue from "vue";
    import Vuex from "vuex";
    import axios from "axios";
    
    Vue.use(Vuex);
    
    export default new Vuex.Store({
      state: {
        isLoading: false, // 페이지의 로딩여부
        searchKeyword: "", // 라우터 이동을 해도 검색키워드를 유지하기 위한 변수
        radioSelected: "itemName", // 검색구분을 정한다. 디폴트로 의약품명을 선택했다.
        medicines: [], // 라우터 이동을 해도 검색결과를 유지하기 위한 리스트
        pageNo: 0, // 현재 페이지
        totalCount: 0, // 검색결과의 개수
        numOfRows: 10, // 한페이지에 보여질 검색결과 개수
        totalPages: 0, // totalCount와 numOfRows를 통해 총 페이지를 계산하기 위한 변수
        clientIp: "", // 현재 접속한 클라이언트의 아이피를 확인할 변수
      },
      mutations: {
        setSearchKeyword(state, data) {
          state.searchKeyword = data
        },
        setSearchRadio(state, data) {
          state.radioSelected = data
        },
        setPageNo(state, data) {
          state.pageNo = data
        },
        setSearchData(state, data) {
          state.pageNo = data.pageNo
          state.totalCount = data.totalCount
          state.medicines = data.items
          state.totalPages = Math.ceil( state.totalCount / state.numOfRows )
        },
        setClearData(state){
          state.pageNo = 0
          state.totalCount = 0
          state.totalPages = 0
          state.medicines = []
        },
        setLoading(state) {
          state.isLoading = true
        },
        setLoadingClose(state) {
          state.isLoading = false
        },
        setIp(state, data){
          state.clientIp = data
        },
      },
      actions: {
        // 클라이언트 아이피를 가져오는 외부API(부정 이용 클라이언트를 식별하기 위한 확장단계)
        getIp(context){
          axios
            .get("https://api.ipify.org?format=json")
            .then(res => {
              context.commit("setIp", res.data.ip)
            })
            .catch(err => {
              console.log(err)
            })
        },
        // 의약품 검색 결과 리스트 반환 API
        getMedicines(context, payload){
          context.commit("setLoading");
          var url = process.env.VUE_APP_APIURL + "/search?";
          if (payload.radioSelected == "itemName") {
            url += "itemName=" + payload.searchKeyword;
          } else if (payload.radioSelected == "entpName"){
            url += "entpName=" + payload.searchKeyword;
          } else {
            url += "efcyQesitm=" + payload.searchKeyword;
          }
    
          if (payload.pageNo == undefined) {
            url += "&pageNo=1";
            // do nothing
          } else {
            url += "&pageNo=" + payload.pageNo;
          }
          axios
            .get(url)
            .then(res => {
              if (res.data.body.totalCount == 0) {
                context.commit("setClearData")
              } else {
                context.commit("setSearchData", res.data.body)
                window.scrollTo(0, 0);
              }
              context.commit("setLoadingClose");
            })
            .catch(err => {
              context.commit("setLoadingClose");
              alert(err)
            })
        }
      },
      modules: {},
    });

    state에 전역으로 가질 변수를 지정하고 mutation을 통해 state의 상태를 변경할 수 있게 설정을 완료한 후 actions에서 axios를 통해 요청한 결과를 통해 mutation에 설정한 setter를 이용하는 느낌으로 설계를 한다.

     

    클라이언트의 IP를 확인할 수 있는 외부 API인 ipify를 사용한다.

    https://www.ipify.org/

     

    ipify - A Simple Public IP Address API

    API Usage Using ipify is ridiculously simple. You have three options. You can get your public IP directly (in plain text), you can get your public IP in JSON format, or you can get your public IP information in JSONP format (useful for Javascript developer

    www.ipify.org

    그리고 검색 결과 리스트를 받을 수 있는 getMedicines 함수를 정의했다.

    이제 정의한 내용을 사용하기 위해 각 페이지를 수정한다.

     

    > src/views/Main.vue

    우리의 프로젝트 접속하면 처음으로 보이는 화면이다.

    // src/views/Main.vue
    <template>
      <v-container fill-height fluid>
        <v-row class="justify-center align-center">
          <v-col cols="12" sm="4">
            <div>
              <h1>의약품 정보 검색</h1>
            </div>
            <v-radio-group v-model="radio">
              <template v-slot:label>
                <div>검색시 <strong>구분</strong>을 체크해주세요.</div>
              </template>
              <v-radio value="itemName">
                <template v-slot:label>
                  <div>의약품명</div>
                </template>
              </v-radio>
              <v-radio value="entpName">
                <template v-slot:label>
                  <div>업체명</div>
                </template>
              </v-radio>
              <v-radio value="efcyQesitm">
                <template v-slot:label>
                  <div>효능</div>
                </template>
              </v-radio>
            </v-radio-group>
            <v-text-field
              ref="search"
              v-model="search"
              label="키워드를 입력해주세요"
              append-icon="mdi-pill"
              @click:append="doSearch({ searchKeyword: search, radioSelected: radio })"
              @keypress.enter="doSearch({ searchKeyword: search, radioSelected: radio })"
            >
            </v-text-field>
            <div>
              <v-alert
                v-model="alert"
                type="error"
              >
                <strong>키워드</strong> 란이 비어있습니다.
              </v-alert>
            </div>
            <div>
              <h5>본 사이트는 식품의약품안전처의 의약품개요정보(e약은요) 공공데이터를 활용한 정보를 제공하고 있습니다.</h5>
            </div>
          </v-col>
        </v-row>
      </v-container>
    </template>
    
    <script>
    import { mapState } from 'vuex';
    
    export default {
      data() {
        return {
          alert : false,
        }
      },
      computed: {
        ...mapState(["searchKeyword", "radioSelected"]),
        search: {
          get() {
            return this.searchKeyword
          },
          set(val) {
            this.$store.commit("setSearchKeyword", val);
          }
        },
        radio: {
          get() {
            return this.radioSelected
          },
          set(val) {
            this.$store.commit("setSearchRadio", val);
          }
        }
      },
      watch: {
        search(oldVal, NewVal) {
          if (oldVal != NewVal) {
            this.$store.commit("setClearData");
          }
        },
        radio(oldVal, NewVal) {
          if (oldVal != NewVal) {
            this.$store.commit("setClearData");
          }
        },
      },
      methods: {
        // 검색 요청
        doSearch(val){
          if (val.radioSelected == "" || val.searchKeyword == "") {
            this.alert = true
            this.$refs.search.focus()
          } else {
            this.$store.dispatch("getMedicines", val);
            this.$router.push({ name: "Search" });
          }
        }
      }
    }
    </script>

     

    > src/views/Search.vue

    // src/views/Search.vue
    <template>
      <v-container>
        <v-card>
          <v-card-title>
            의약품 검색
            <v-spacer></v-spacer>
            <v-text-field
              ref="search"
              v-model="search"
              append-icon="mdi-magnify"
              label="키워드를 입력해주세요"
              @click:append="doSearch({ searchKeyword: search, radioSelected: radio })"
              @keypress.enter="doSearch({ searchKeyword: search, radioSelected: radio })"
              single-line
              hide-details
            ></v-text-field>
          </v-card-title>
          <div>
            <v-alert
              v-model="alert"
              type="error"
            >
              <strong>키워드</strong>가 비어있습니다.
            </v-alert>
          </div>
          <v-card-text>
            <v-select
              v-model="radio"
              :items="selectBox"
              label="검색구분"
            ></v-select>
          </v-card-text>
          <v-data-table
            :headers="headers"
            :items="medicines"
            hide-default-footer
            @click:row="getDetail"
            :disable-sort="$vuetify.breakpoint.smAndDown"
          >
            <template v-slot:item.efcyQesitm="{ item }">
              <div v-html="item.efcyQesitm"></div>
            </template>
          </v-data-table>
          <div class="text-center pt-2">
            <v-pagination
              v-model="pageNo"
              :length="totalPages"
              next-icon="mdi-menu-right"
              prev-icon="mdi-menu-left"
              @input="doSearch({searchKeyword: searchKeyword, radioSelected: radio, pageNo: pageNo})"
            ></v-pagination>
          </div>
        </v-card>
        <v-dialog
          v-model="dialog"
          max-width="800"
          scrollable
        >
          <v-card
            class="mx-auto"
            outlined
          >
           <v-card-title>
              의약품 상세내역
              <v-spacer></v-spacer>
              <v-icon @click="closeDialog">mdi-close</v-icon>
            </v-card-title>
            <v-divider></v-divider>
            <v-card-text class="pa-0">
              <v-list-item three-line>
                <v-list-item-content>
                  <div class="text-overline mb-4">
                    공개일자 : {{ drugInfo.openDe }}<br>
                    수정일자 : {{ drugInfo.updateDe }}<br>
                    품목기준코드 : {{ drugInfo.itemSeq }}
                  </div>
                  <v-list-item-title class="text-h5 mb-1 text-wrap">
                    <strong>{{ drugInfo.itemName }}</strong>
                  </v-list-item-title>
                  <v-list-item-subtitle>{{ drugInfo.entpName }}</v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[의약품 사진]</v-list-item-title>
                  <v-img
                    max-height="200"
                    max-width="300"
                    :src="drugInfo.itemImage"
                    eager
                  ></v-img>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[약의 효능]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.efcyQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[약의 효능]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.efcyQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[사용법]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.useMethodQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[사용하기 전 반드시 확인해야 할 사항]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.atpnWarnQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[사용시 주의사항]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.atpnQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[상호작용]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.intrcQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[부작용]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.seQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
              <v-divider></v-divider>
              <v-list-item>
                <v-list-item-content>
                  <v-list-item-title>[보관법]</v-list-item-title>
                  <v-list-item-subtitle v-html="drugInfo.depositMethodQesitm" class="text-wrap"></v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
            </v-card-text>
            <v-divider></v-divider>
            <v-card-actions>
              <v-btn
                outlined
                rounded
                text
                @click="closeDialog"
              >
                닫기
              </v-btn>
            </v-card-actions>
          </v-card>
        </v-dialog>
        <spinner :isLoading="isLoading"></spinner>
      </v-container>
      
    </template>
    
    <script>
    import axios from "axios";
    import Spinner from "../components/Spinner.vue";
    import { mapState } from "vuex";
    
      export default {
        components: {
          Spinner,
        },
        computed: {
          ...mapState(['isLoading','totalCount', 'totalPages', 'medicines', 'searchKeyword', 'radioSelected']),
          search: {
            get() {
              return this.searchKeyword;
            },
            set(val) {
              this.$store.commit("setSearchKeyword", val);
            },
          },
          pageNo : {
            get() {
              return this.$store.state.pageNo
            },
            set(val) {
              this.$store.commit("setPageNo", val)
            }
          },
          radio: {
            get() {
              return this.radioSelected;
            },
            set(val) {
              this.$store.commit("setSearchRadio", val);
            },
          }
        },
        watch: {
          search(oldVal, NewVal) {
            if (oldVal != NewVal) {
              this.$store.commit("setClearData");
            }
          },
          radio(oldVal, NewVal) {
            if (oldVal != NewVal) {
              this.$store.commit("setClearData");
            }
          },
        },
        data: () => ({
          dialog: false,
          selectBox: [
            { text: '업체명', value: 'entpName'},
            { text: '의약품명', value: 'itemName'},
            { text: '효능', value: 'efcyQesitm'},
          ],
          headers: [
            {
              text: '업체명',
              align: 'start',
              sortable: false,
              value: 'entpName',
            },
            { text: '품목기준코드', value: 'itemSeq' },
            { text: '제품명', value: 'itemName' },
            { text: '효능', value: 'efcyQesitm' },
          ],
          drugInfo : [],
          alert : false
        }),
        methods: {
          // 의약품 검색요청
          doSearch(val) {
            if (val.radioSelected == "" || val.searchKeyword == "") {
              this.alert = true
              this.$refs.search.focus()
            } else {
              this.alert = false
              this.$store.dispatch("getMedicines", val);
            }
          },
          // 의약품 상세정보 창 닫기
          closeDialog() {
            this.dialog = false
            this.drugInfo = []
          },
          // 의약품 상세정보 취득
          getDetail(arg){
            this.$store.commit("setLoading");
            var url = process.env.VUE_APP_APIURL + "/detail?itemSeq=" + arg.itemSeq;
            axios
              .get(url)
              .then(res => {
                this.drugInfo = res.data.body.items[0]
                this.dialog = true
                this.$store.commit("setLoadingClose");
              })
              .catch(err => {
                this.$store.commit("setLoadingClose");
                console.log(err)
                alert("정보를 불러오는 도중 에러가 발생했습니다.")
              })
          },
        },
      }
    </script>

    의약품의 검색 결과를 확인하고 결과를 토대로 상세페이지를 다이얼로그 창으로 띄우는 코드를 구현했다. 이 페이지에서 단일로 사용하는 기능이 있으므로 methods에 의약품의 상세정보를 요청하는 getDetail 함수를 구현했다.

     

    > src/views/Contact.vue

    여기에선 우리 프로젝트에서 버그나 잘못된 내용에 관해 이메일을 통해 문의를 할 수 있는 페이지를 설정한다.

    // src/views/Contact.vue
    <template>
      <v-container>
        <v-sheet
          color="white"
          elevation="1"
        >
          <v-snackbar
            v-model="snackBar"
            :timeout="3000"
            color="success"
            top
          >
            메일 전송에 성공했습니다.
            <template v-slot:action="{ attrs }">
              <v-btn
                color="white"
                text
                v-bind="attrs"
                @click="snackBar = false"
              >
                Close
              </v-btn>
            </template>
          </v-snackbar>
          <div>
            <h1>문의하기</h1>
          </div>
          <v-form
            ref="form"
            v-model="valid"
            lazy-validation
          >
            <v-container>
              <v-text-field
                v-model="name"
                :counter="10"
                :rules="nameRules"
                label="이름"
                required
              ></v-text-field>
    
              <v-text-field
                v-model="email"
                :rules="emailRules"
                label="이메일 주소"
                required
              ></v-text-field>
    
              <v-textarea
                v-model="content"
                counter
                label="문의 내용"
                type="email"
                :rules="contentRules"
              ></v-textarea>
    
              <v-btn
                class="ma-2"
                color="grey"
                dark
                @click="formReset"
              >
                내용 지우기
                <v-icon
                  dark
                  right
                >mdi-cached</v-icon>
              </v-btn>
              <v-btn
                class="ma-2"
                color="primary"
                dark
                @click="resetValidation"
              >
                보내기
                <v-icon
                  dark
                  right
                >mdi-email</v-icon>
              </v-btn>
            </v-container>
          </v-form>
        </v-sheet>
        <spinner :isLoading="isLoading"></spinner>
      </v-container>
    </template>
    
    <script>
    import axios from "axios";
    import Spinner from '../components/Spinner.vue';
    import { mapState } from "vuex";
    
      export default {
        components: {
          Spinner,
        },
        computed: {
          ...mapState(['isLoading'])
        },
        data: () => ({
          valid: true,
          name: '',
          nameRules: [
            v => !!v || '이름을 입력해주세요.',
            v => (v && v.length <= 10) || '이름은 최대 10자를 넘길 수 없습니다.',
          ],
          email: '',
          emailRules: [
            v => !!v || '이메일을 입력해주세요.',
            v => !!(v || '').match(/@/) || '이메일 형식에 맞지 않습니다.',
          ],
          content: '',
          contentRules: [
            v => !!v || '내용을 입력해주세요.',
            v => (v && v.length <= 2000) || '내용은 최대 2000글자를 넘길 수 없습니다.'
          ],
          snackBar: false,
        }),
    
        methods: {
          // 내용비우기
          formReset() {
            this.$refs.form.reset()
          },
          // 메일보내기
          resetValidation () {
            const validate = this.$refs.form.validate();
            if (validate) {
              this.$store.commit("setLoading");
              let params = {
                name : this.name,
                mailAddr : this.email,
                content : this.content,
                ipAddr : this.$store.state.clientIp
              }
              var url = process.env.VUE_APP_APIURL + "/contact";
              axios
                .post(url, params)
                .then(res => {
                  console.log(res)
                  this.$store.commit("setLoadingClose");
                  this.formReset()
                  this.snackBar = true
                })
                .catch(err => {
                  console.log(err)
                  this.$store.commit("setLoadingClose");
                  alert("전송중 에러가 발생했습니다.")
                })
            }
          },
        },
      }
    </script>

     각 입력 폼에 대한 validation 체크 기능과 통과 후 백엔드 서버의 메일 전송 API를 요청해 메일이 전송이 완료됐을 경우의 안내를 설계했다.

     

    > src/views/Error.vue

    에러 페이지는 단순히 라우팅 경로에 해당하지 않는 요청에 대해 보여줄 페이지이므로 간단하게 작성한다.

    // src/views/Error.vue
    <template>
      <v-container fill-height fluid>
        <div
         class="justify-center"
        >
          <h1>요청하신 페이지를 찾을 수 없습니다.</h1>
          <h3>관리자에게 문의해주세요.</h3>
        </div>
      </v-container>
    </template>

    > src/components/Spinner.vue

    우리는 스피너를 통해서 화면 렌더링이 완료될 때까지의 움직임을 원형의 로딩 프로그레스를 통해 처리하려고 한다. 오버레이의 값이 True일 경우만 화면에 표시되는 구조로 실제로 이 컴포넌트를 사용할 때에는 "isLoading"의 값을 변경함으로 나타내거나 사라지게 하는 로직을 구성할 수 있다.

    // src/components/Spinner.vue
    <template>
      <v-overlay :value="isLoading">
        <v-progress-circular
          indeterminate
          size="64"
        ></v-progress-circular>
      </v-overlay>
    </template>
    
    <script>
      export default {
        props: {
          isLoading: Boolean,
        }
      }
    </script>

    마지막으로 App.vue 에 클라이언트 IP를 요청하는 API를 사용하기 위해 아래의 코드를 추가한다.

    // src/App.vue
    <template>
      <v-app id="inspire">
        <v-app-bar
          app
          color="white"
          flat
        >
          <v-tabs
            centered
            class="ml-n9"
            color="grey darken-1"
          >
            <v-tab
              v-for="link in links"
              :key="link.name"
              :to="link.to"
            >
              {{ link.name }}
            </v-tab>
          </v-tabs>
        </v-app-bar>
    
        <v-main class="grey lighten-3">
          <router-view/>
        </v-main>
      </v-app>
    </template>
    
    <script>
    export default {
      created() {
    	// 추가된 부분으로 store/index.js에서 actions에 선언한 getIp함수를 사용한다.
        this.$store.dispatch('getIp')
      },
      data: () => ({
        links: [
          { name: 'Main', to: '/main'},
          { name: 'Search', to: '/search'},
          { name: 'Contact', to: '/contact'},
        ],
      }),
    };
    </script>

    이제 모든 준비는 끝났다. 백엔드 개발서버와 함께 프론트 개발서버도 기동 해보며 테스트를 해보자.


    5. 연동 테스트

    연동 테스트는 원래 단순하게 진행하면 안 되며 기능 단위로 진행해야 것이나 프로젝트가 개발방향을 파악하는 미니 프로젝트이므로 주요한 기능 위주로 테스트를 진행할 것이다.

     

    1장에서 구축한 백엔드 프로젝트 폴더 루트 경로의 터미널에서 백엔드 개발 서버를 기동 한다. 

    $ go run main.go
    백엔드 서버를 구동합니다.
    
       ____    __
      / __/___/ /  ___
     / _// __/ _ \/ _ \
    /___/\__/_//_/\___/ v4.6.1
    High performance, minimalist Go web framework
    https://echo.labstack.com
    ____________________________________O/_______
                                        O\
    ⇨ http server started on [::]:5000

    다음으로 우리가 이번 편에 작성한 프론트엔드 루트 경로의 터미널에서 아래의 명령어를 날린다. 

    npm run serve
    You may use special comments to disable some warnings.
    Use // eslint-disable-next-line to ignore the next line.
    Use /* eslint-disable */ to ignore all warnings in a file.
    
      App running at:
      - Local:   http://localhost:8080/ 
      - Network: http://192.168.xx.x:8080/
    
      Note that the development build is not optimized.
      To create a production build, run npm run build.

    프론트 앱이 구동 중이라는 메시지를 확인 후 웹 브라우저를 통해 웹페이지를 요청해보자.

    http://localhost:8080/

    메인페이지

    메인 페이지가 잘 열리는 것이 확인된다. 키워드에 "아스피린"을 쓰고 엔터키를 누르면 의약품명에 "아스피린"을 포함하는 검색 결과 리스트가 나와야 할 것이다.

    검색 결과 페이지

    검색 결과 리스트가 테이블 형태로 잘 나오는 것을 확인할 수 있다. 백엔드 서버와 현재까지는 잘되는 부분이다. 의약품의 상세페이지를 확인하기 위해 테이블에 결과 중 첫 번째 행을 클릭해본다.

    의약품 상세페이지

    의약품의 상세페이지를 다이얼로그 창으로 확인할 수 있다. 단일 데이터도 잘 가져오고 있다.

    다음으로 이메일 문의 기능을 테스트해본다.

    문의하기 페이지

    문의 내용을 작성한 후 보내기 버튼을 클릭해보자.

    문의하기 전송 결과

    메일 전송에 성공했습니다. 라는 문구의 알러트가 표시되며 수신 메일함에 메일이 도착했다.

    수신메일 결과


    6. 마무리

    우리는 vue.js를 통해 간단하게 웹페이지를 작성했으며 백엔드 API를 호출하여 검색 데이터를 화면에 뿌려주고 문의 메일을 보내는 과정을 진행해보았다. 프론트엔드를 간단하게 작성하는 것에 중점을 두었으며 이번 프로젝트를 3편에서 AWS EC2에서 배포하고자 한다. 현재 이 프로젝트는 배포가 된 상태이며 아래의 문서를 통해 코드 확인이 가능하다. 현재 클라우드 서버의 무료 기간이 종료되어 배포된 사이트에 접속할 수 없습니다.


    프론트엔드 프로젝트 코드

    https://github.com/JEONG-YUNHO01/project-frontend

     

    GitHub - JEONG-YUNHO01/project-frontend

    Contribute to JEONG-YUNHO01/project-frontend development by creating an account on GitHub.

    github.com

     

    사이트 주소

    https://medicineinfo.n-e.kr/

     

    의약품 검색

     

    medicineinfo.n-e.kr

     

    반응형

    댓글

Designed by Tistory.