소스 :
https://github.com/braverokmc79/pwa-ex-11
기본적으로 nodejs 설치 및 vue 와 Vue CLI 설치 되어 있어야 한다.
nodejs 설치
https://nodejs.org/ko/download/
nodejs 설치 후
vue 설치
npm install vue
명령을 사용하여 설치한 버전 번호를 확인합니다(vue --version).
Vue CLI를 설치하려면 npm을 사용합니다. 업그레이드하려면 -g 플래그를 사용하여 전역으로 설치해야 합니다(vue upgrade --next).
1 npm install -g @vue/cli
프로젝트 결과 화면



1. vue add vuetify 설치
2. npm install firebase vuefire 설치
3. 매니페스트 작성
public/manifest.json
{
"name": "카메라 갤러리",
"short_name": "카메라 갤러리",
"icons": [
{
"src": "./img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff"
}
4. 파이어베이스 프로젝트 만들기
https://console.firebase.google.com/
1) firebase.google.com 에 접속해서 pwa-camera 라는 이름으로 프로젝트 만들기
2) 파이어베이스 프로젝트 설정 화면에서 웹앱에서 파이어베이스 추가하기 (닉네임 pwa-camera 로 등록)
3) 파이어베이스 SDK 추가에서 databaseURL, storageBucket 값을 복사해서 기록해 두기
4) Realtime Database 만들기 -> 테스트 모드로 시작
5. 파이어베이스 스토리지 및 DB 연동 파일 생성하기
1) src/datasources/firebase.js
// 파이어베이스 앱 객체 모듈 가져오기
import firebase from 'firebase/compat/app'
// 파이어베이스 패키지 모듈 가져오기
import 'firebase/compat/database';
import 'firebase/compat/storage';
// 파이어베이스 DB를 초기화
const oFirebase = firebase.initializeApp({
// 파이어베이스 콘솔에서 복사하여 붙여넣기
databaseURL: "https://pwa-camera.firebaseio.com",
storageBucket: "pwa-camera.appspot.com",
});
// 파이어베이스 DB객체 연결
const oDB = oFirebase.database();
// 파이어베이스 DB객체 중에서 pictures 항목을 다른 곳에서 사용하도록 공개
export const oPicturesinDB = oDB.ref('pictures');
// 파이어베이스 스토리지 객체 공개
export const oStorage = oFirebase.storage();
2)src/main.js 에 다음을 추가
//뷰파이어 노드 모듈 가져와서 Vue에 연결
import { rtdbPlugin } from "vuefire";
Vue.use(rtdbPlugin);
import Vue from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
import vuetify from "./plugins/vuetify";
//뷰파이어 노드 모듈 가져와서 Vue에 연결
import { rtdbPlugin } from "vuefire";
Vue.use(rtdbPlugin);
Vue.config.productionTip = false;
new Vue({
router,
vuetify,
render: (h) => h(App),
}).$mount("#app");
3) DB 연동 테스트 확인
App.vue 파일에 firebase.js 파일 임포트 후 확인
<script>
// 파이어베이스 DB 객체 가져옴
import { oPicturesinDB } from '@/datasources/firebase'
console.log("oPicturesinDB : " ,oPicturesinDB);
export default {
name: 'App',
data: () => ({
//
}),
};
</script>

6. 앱 실행 화면 만들기
1) public/index.html 수정
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!-- 상태 표시줄 테마 색상을 흰색으로 변경 -->
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>카메라 갤러리</title>
<!-- 구글 머티리얼 디자인 아이콘 추가-->
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>
<body>
<noscript>
<strong>We're sorry but ex11 doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
2)datasources/picture-data.js 파일 추가
export default {
aPictures: [{
'id': 1,
'url': 'https://farm1.staticflickr.com/654/22663129542_e3df218c90_b.jpg',
'title': '청춘플랫폼_우수부스투표',
'info': '졸업작품 전시회에 출품한 작품 중에서 방문자들이 직접 우수 전시 부스를 투표로 선정하는 모습입니다.'
},
{
'id': 2,
'url': 'https://farm1.staticflickr.com/739/22663128752_7b347e01a9_b.jpg',
'title': '전시장모습',
'info': '전시가 열렸던 예술의 전당 전시실 안의 모습입니다. '
},
{
'id': 3,
'url': 'https://farm1.staticflickr.com/674/22055529403_aeeb5fc371_b.jpg',
'title': 'pc실습실',
'info': '윈도우OS 환경에서 다양한 멀티미디어디자인 S/W를 실습하는 공간입니다.'
},
{
'id': 4,
'url': 'https://farm1.staticflickr.com/706/22650669476_d8bf33c153_b.jpg',
'title': '졸업작품전시회 주제',
'info': '청춘 플랫폼이라는 주제로 젊은 열정이 펼쳐지는 무대가 마련되었습니다.'
},
{
'id': 5,
'url': 'https://farm1.staticflickr.com/677/22055530223_91e3f73227_b.jpg',
'title': '명함디자인',
'info': '자신의 미래 모습을 담은 명함을 직접 디자인하고 인쇄 제작하여 전시한 모습입니다.'
},
{
'id': 6,
'url': 'https://farm1.staticflickr.com/777/22650669326_e180cd84ca_b.jpg',
'title': '전시_방명록',
'info': '전시장을 방문한 관객들의 다양한 소감을 방명록의 기록으로 남겨졌습니다.'
},
{
'id': 7,
'url': 'https://farm1.staticflickr.com/759/22687855771_92dc8b3245_b.jpg',
'title': '매킨토시 실습실',
'info': '맥OS 환경에서 멀티미디어디자인 관련 S/W를 사용하여 작업하기 위한 맥 실습실입니다.'
},
{
'id': 8,
'url': 'https://farm6.staticflickr.com/5646/22053918354_ee031eae46_b.jpg',
'title': '캘리그래피_디자인',
'info': '학생들이 직접 디자인한 아날로그 정서의 캘리그래피 작품입니다.'
},
{
'id': 9,
'url': 'https://farm1.staticflickr.com/762/22687856291_963df9b90a_b.jpg',
'title': '실습실복도',
'info': '전공존에 따라 색상이 그룹으로 연결된 실습실 복도 모습입니다.'
},
{
'id': 10,
'url': 'https://farm1.staticflickr.com/766/22055529593_c162dc3143_b.jpg',
'title': '인터뷰_촬영',
'info': '졸업작품을 준비한 학생들의 과정을 인터뷰의 기록으로 남기는 모습입니다. '
},
{
'id': 11,
'url': 'https://farm6.staticflickr.com/5775/22053918014_f0e0873b7c_b.jpg',
'title': '전시_메모',
'info': '관객들이 전시를 보면서 느낀 점을 간단한 메모의 글로 남긴 흔적입니다.'
},
{
'id': 12,
'url': 'https://farm6.staticflickr.com/5672/22055530083_8f633d57f3_b.jpg',
'title': '종이컵_캘리그래피',
'info': '종이컵을 캔버스 삼아 학생들의 자유로운 생각을 캘리그래피의 작품으로 제작한 종이컵 아트워크입니다.'
}
]
}
3)src/router/index.js 라우터 추가 및 변경
[4페이지]
· 홈(home_page)
· 세부 정보(info_page)
· 포스트(post_page)
· 카메라 촬영(camera_page)
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
const routes = [
{
path: "/",
name: "home_page",
component: () => import("@/components/home_page.vue"),
},
{
path: "/camera",
name: "camera_page",
component: () => import("@/components/camera_page.vue"),
},
{
path: "/info",
name: "info_page",
component: () => import("@/components/info_page.vue"),
},
{
path: "/post",
name: "post_page",
component: () => import("@/components/post_page.vue"),
},
];
const router = new VueRouter({
routes,
mode: "history",
});
export default router;
4) App.vue
<template>
<v-app>
<v-app-bar app color="red" dark fixed>
<!-- 홈 화면이 아닌 경우 돌아가기 버튼 표시 -->
<v-btn icon v-if="$route.name !== 'home_page'" @click="$router.go(-1) ">
<v-icon>arrow_back</v-icon>
</v-btn>
<v-toolbar-title>카메라 갤러리</v-toolbar-title>
<v-spacer></v-spacer>
<!-- 홈 화면에서만 촬영아이콘 표시-->
<v-btn icon v-if="$route.name=='home_page'" @click="$router.push('/camera')">
<v-icon>camera_alt</v-icon>
</v-btn>
</v-app-bar>
<!-- 라우터에 등록했던 홈, 카메라 촬영, 상세 정보, 포스트 페이지가 표시 -->
<v-main>
<router-view />
</v-main>
</v-app>
</template>
<script>
export default {
name: 'App'
}
</script>
7. 컴포넌트 작성하기
1) 홈컴포넌트 작성하기
home_page.vue
<template>
<v-container>
<v-row>
<v-col cols="12" sm="6" md="4" lg="3" xl="2"
v-for="item in this.oPictures" :key="item.key"
>
<v-card class="py-2 px-2">
<!-- 사진을 읽어서 표시하고 세부 페이지로 이동하도록 링크 설정 -->
<v-img :src="item.url" height="200px" class="pointer" @click="fnDisPlayInfo(item['.key'])">
</v-img>
<!-- 사진의 제목과 내용을 바인딩하여 표시 -->
<v-card-title>
<h1 class="title grey--text text-darken-3 mb-3">{{ item.title }}</h1>
<p class="body-1 grey--text">{{ item.info }}</p>
</v-card-title>
</v-card>
</v-col>
<v-col cols="12" class="text-cener">
<!-- 만약에 업로된 이미지가 없으면 안내 문구 표시 -->
<p v-if="!this.oPictures.length">
사진이 없습니다. 추가해 주세요!
</p>
</v-col>
<!-- 오른쪽 하단에 포스트 추가 버튼 표시 -->
<v-btn @click="$router.push('/post')" color="red" dark fixed bottom right fab>
<v-icon>add</v-icon>
</v-btn>
</v-row>
</v-container>
</template>
<script>
//파이어베이스 DB 객체 가져옴
import {oPicturesinDB} from "@/datasources/firebase";
export default {
name: 'App',
data(){
return{
oPictures:[] //사진 데이터 저장 변수
}
},
//파이어베이스와 연결된 뷰파이어 oPictures 객체 준비
firebase:{oPictures:oPicturesinDB},
methods:{
//라우터를 이용해서 세부 페이지로 이동할 때 사진의 ID 전달
fnDisPlayInfo(pID){
this.$router.push({
name:"info_page",
params:{p_id:pID}
})
}
}
};
</script>
<style>
/* 마우스 커서가 손 모양이 되도록 설정 */
.pointer{
cursor: pointer;
}
.text-cener{
text-align: center;
}
</style>
2) 세부정보 컴포넌트 작성하기
info_page.vue
<template>
<v-container>
<v-row>
<v-col cols="12">
<v-card class="py-3 px-3">
<!-- contain 어트리뷰트를 사용해서 세부 사진을 컨테이너 크기에 맞도록 자동으로 조절하여 표시 -->
<v-img height="450px" contain :src="this.itemPic.url"
></v-img>
<!-- 제목이 있는 경우만 하단에 제목과 내용 표시 -->
<v-card-text v-if="this.itemPic.title">
<h1 class="headline mt-1 txt-center">{{ this.itemPic.title }}</h1>
<p class="body-1 mt-1 text-center">{{ this.itemPic.info }}</p>
</v-card-text>
<v-col cols="12" class="mt-3 text-center">
<!-- 현재 사진을 삭제하는 버튼 처리 -->
<v-btn color="grey" fab dark @click="fnDeleteItem()">
<v-icon>delete</v-icon>
</v-btn>
</v-col>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
//파이어베이스에서 DB와 스토리지 객체 가져옴
import {oStorage, oPicturesinDB} from "@/datasources/firebase";
export default {
name: 'App',
//파이어베이스와 연결된 뷰파이어 oPictures 객체 준비
firebase:{oPictures:oPicturesinDB},
data(){
return{
oPictures:[], //사진 데이터 저장 변수
itemPic:null, //검색 결과 항목을 저장할 객체 변수
}
},
created(){
//라우터의 매개변수로 전달된 항목 ID값 읽기
const itemID =this.$route.params.p_id;
//find 검색 기능으로 파이어베이스에서 해당 ID 항목 검색 및 저장
this.itemPic =this.oPictures.find(item=> item['.key']===itemID);
},
methods: {
fnDeleteItem() {
// 파이어베이스 DB의 사진 항목 삭제
oPicturesinDB.child(this.itemPic['.key']).remove()
// 스토리지에 이미지가 존재할 경우(카메라 사용)만 삭제
if (this.itemPic['filename']) oStorage.ref('images').child(this.itemPic['filename']).delete()
// 홈화면으로 이동
this.$router.push('/');
}
},
};
</script>
3) 포스트 컴포넌트 작성하기
post_page.vue
<template>
<v-container>
<!-- 첫 번째 행에는 사진 표시 -->
<v-row mt-4>
<v-col offset="1" cols="10">
<v-card>
<v-img height="200px" :src="this.sPicUrl"></v-img>
</v-card>
</v-col>
</v-row>
<!-- 두 번째 행에는 기본 제목을 표시하고 수정할 수 있도록 함 -->
<v-row mt-5>
<v-col offset="2" cols="8">
<v-text-field name="title" label="사진 제목" v-model="sTitle" autofocus></v-text-field>
</v-col>
</v-row>
<!-- 세번째 행에는 기본 내용을 표시하고 수정할 수 있도록 함 -->
<v-row>
<v-col offset="2" cols="8">
<!-- 3줄로 편집 제한-->
<v-text-field name="info" label="사진 설명" v-model="sInfo" multi-line rows="3"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" class="text-center">
<v-btn color="orange" darak large @click="fnSubmitPost()">업로드</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
//JSON 파일로부터 이미지 정보 가져옴
import oPictureData from '@/datasources/picture-data';
//파이어베이스에서 DB 객체 가져옴.
import {oPicturesinDB} from '@/datasources/firebase';
export default {
//파이어베이스와 연결된
firebase : {
oPictures:oPicturesinDB
},
name: 'App',
data(){
return{
oPictures:[], //사진 데이터 저장 변수
//초깃값으로 JSON 파일의 이미지 배열 저장
aPictures:oPictureData.aPictures,
sTitle:'',
sInfo:'',
sPicUrl:''
}
}
,
mounted() {
//JSON 파일에서 사진 정보를 랜덤으로 읽어 와서 사진과 포스트 글 준비
let nIndex=Math.floor(Math.random()*12);
const itemPic=this.aPictures[nIndex];
this.sTitle=itemPic.title;
this.sInfo=itemPic.info;
this.sPicUrl=itemPic.url;
},
methods: {
fnSubmitPost(){
//DB에 저장하고 홈 화면으로 이동
oPicturesinDB.push({
'url':this.sPicUrl,
'title':this.sTitle,
'info':this.sInfo
}).then(this.$router.push('/'))
}
},
};
</script>
4) 카메라 촬영 컴포넌트 작성하기
파이어베이스에서 storage 사용 설정을 한다.
다음화면은 storage 사용 설정 후 동영상 업로드 목록 화면

카메라 영상 표시하기
카메라 영상을 실시간으로 표시하려면 video 엘리먼트를 사용하면 된다.
그리고 video 엘리먼트에 담긴 영상을 갤러리에 전달하려면 ref 어트리뷰트를 지정해야 한다.
camera_page.vue
<template>
<v-container>
<v-row>
<v-col cols="12" class="text-center">
<!-- 카메라 영상 부분을 표시 -->
<video ref="rVideo" class="style_video"></video>
</v-col>
<v-col cols="12" class="mt-5 text-center">
<!-- 만약에 업로드된 이미지가 없으면 안내 문구 표시-->
<p>현재 iOS는 지원하지 않습니다.</p>
</v-col>
</v-row>
<div class="text-center my-3">
<!-- 카메라 캡처 버튼을 영상 하단 중앙에 위치 -->
<v-btn v-if="!this.bIsWait" color="red" fab dark bottom @click="fnCameraCapture( )">
<v-icon>camera</v-icon>
</v-btn>
<v-progress-circular v-if="this.bIsWait" :size="50" indeterminate color="grey"></v-progress-circular>
</div>
</v-container>
</template>
<script>
// 파이어베이스에서 DB와 스토리지 객체 가져옴
import {
oStorage,
oPicturesinDB
} from '@/datasources/firebase'
export default {
// 파이어베이스와 연결된 뷰파이어 oPictures 객체 준비
firebase: {
oPictures: oPicturesinDB
},
data() {
return {
oPictures: [], // 사진 데이터 저장 변수
oVideoStream: null, // 카메라 영상 스트림을 저장할 객체변수
bIsWait: false
}
},
mounted() {
// Web API를 통해서 사용자 카메라의 접근(영상 only)을 요청함
navigator.mediaDevices.getUserMedia({
video: true
}).then(pVideoStream => {
// 카메라 영상 스트림 정보를 oVideoStream에 저장함
this.oVideoStream = pVideoStream
// 카메라 영상 스트림 정보를 video 엘리먼트에 표시함
this.$refs.rVideo.srcObject = pVideoStream
this.$refs.rVideo.play()
}).catch(function (err) {
console.log(err)
})
},
destroyed() {
// 현재 화면을 종료할 경우 현재 재생되는 영상 트랙을 찾아 종료시킴
const oTrack = this.oVideoStream.getTracks()
oTrack.map(pTrack => pTrack.stop())
},
methods: {
fnCameraCapture() {
this.bIsWait = true
// 현재 재생되는 트랙을 찾아 스틸이미지로 캡처함
const oVideoTrack = this.oVideoStream.getVideoTracks()[0]
let oCapturedImage = new window.ImageCapture(oVideoTrack)
const options = {
imageHeight: 359,
imageWidth: 640,
fillLightMode: 'off'
};
const self = this
// 캡처된 이미지를 파이어베이스 DB와 스토리지에 저장함
return oCapturedImage.takePhoto(options).then(pImageData => {
// 영상 정지
const oTrack = self.oVideoStream.getTracks()
oTrack.map(pTrack => pTrack.stop())
console.log('캡처: ' + pImageData.type + ', ' + pImageData.size + '바이트');
// 저장할 이미지 파일이름으로 사용할 ID 준비
const nID = new Date().toISOString()
//pImageData 서버에 저장 처리 하면 된다. 여기서는
// 파이어베이스 스토리지에 이미지 파일 저장
let uploadTask = oStorage.ref('images').child('pic' + nID).put(pImageData);
uploadTask.on('state_changed', function (snapshot) {
// state_changed 이벤트를 통해서 얼만큼의 바이트가 업로드 중인지 콘솔에 표시
let progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
console.log('업로드: ' + progress + '% 완료', snapshot.state);
}, function (error) {
console.log(error)
// 오류 발생 시 콘솔에 표시
}, function () {
// 성공적으로 업로드 완료 후 파이어베이스 DB에 정보 저장
uploadTask.snapshot.ref.getDownloadURL().then(function (downloadURL) {
console.log('업로드URL:', downloadURL);
oPicturesinDB.push({
'url': downloadURL,
'title': '',
'info': '',
'filename': 'pic' + nID
})
.then(self.$router.push('/')) // 저장 후 홈 화면으로 이동
});
});
})
}
}
}
</script>
<style>
/* 카메라 영상의 너비값을 브라우저 너비에 맞춤 */
.style_video {
width: 100%
}
</style>
8.워크박스로 서비스 워커에서 캐시 관리하기
카메라 사진 갤러리 앱 만들기 예제에서 카메라로 직접 찍은 사진은 기본 캐시가 되지 않으므로 InjectMainfest 모드를 통해 캐시해야 한다.
지금까지는 캐시를 관리할 때 GenerateSW 모드를 사용하며 프리캐시와 런타임 캐시를 쉽고 빠르게 설정값으로 관리할 수 있었다. 하지만 프로젝트 성
격상 서비스 워커에서 직접코드를 넣어야 할 경우가 발생하면 GenerateSW 모드 대신에 InjectManifest 모드를 사용해야 한다. 이렇게 하면
서비스 워커에 자신의 코드를 넣을 수 있으며 프리캐시와 런타임 캐시를 직접 관리할 수 있다.
기존
vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: [
'vuetify'
]
})
InjectManifest 플러그인 모드는 vue.config.js 파일을 루트 폴더에 생성한 후에 다음 내용을 입력
다음과 같이 변경한다.
vue.config.js
module.exports = {
pwa: {
// 서비스워커를 코드로 수정하기 위해 InjectManifest 모드 사용
workboxPluginMode: 'InjectManifest',
workboxOptions: {
swSrc: "./src/service-worker.js"
}
}
}
src 폴더에 service-worker.js 를 추가
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js"
); // <-- workbox 최신번전 CDN 추가
//워크박스는 캐시를 수행할 때 진행 상황을 콘솔 창에 표시해 줍니다.
//개발하면서 이러한 메시지를 볼 수 있으면 편리한데 디버그 모드일 때만 가능합니다.
//배포를 위해 더는 디버그가 필요없다면 프로덕션 모드로 변경하면 됩니다.
// Workbox를 디버그모드로 설정
// workbox.setConfig({
// debug: true,
// });
// 배포용 프로덕션 모드
workbox.setConfig({
debug: false,
});
// Vue-Cli에서 기본으로 제공하는 프리캐시 설정을 Workbox에 적용
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); // <-- self.__WB_MANIFEST로 변경
// 촬영된 이미지 캐시
workbox.routing.registerRoute(
new RegExp(
"https://firebasestorage.googleapis.com/v0/b/pwa-camera.appspot.com/.*"
),
new workbox.strategies.StaleWhileRevalidate({
cacheName: "camera-images",
plugins: [
new workbox.expiration.ExpirationPlugin({
// <-- ExpirationPlugin로 대문자 'E' 변경
maxEntries: 60, //이미지를 총 60개만 캐시하도록 지정
maxAgeSeconds: 365 * 24 * 60 * 60, // 1년 지정
}),
],
})
);
워크박스는 캐시를 수행할 때 진행 상황을 콘솔 창에 표시해 줍니다.
개발하면서 이러한 메시지를 볼 수 있으면 편리한데 디버그 모드일 때만 가능합니다.
배포를 위해 더는 디버그가 필요없다면 프로덕션 모드로 변경하면 됩니다.
// Workbox를 디버그모드로 설정
workbox.setConfig({
debug: true,
});
// 배포용 프로덕션 모드
workbox.setConfig({
debug: false,
});
workbox.routing.registerRoute( //캐시 경로 //캐시 전략 )
9.빌드 및 firebase 서버에 배포하기
다음을 참조
https://macaronics.net/m04/vue/view/2048
출처 :
Do it! 프로그레시브 웹앱 만들기
반응형 웹 개발부터 하이브리드 앱 배포까지 PWA 완전 정복!
김응석 저자(글)
이지스퍼블리싱 · 2020년 08월 06일
8.5(7개의 리뷰)
도움돼요(50%의 구매자)
https://product.kyobobook.co.kr/detail/S000001817978
















댓글 ( 4)
댓글 남기기