웹 개발에 있어 네이티브 앱과 같은 기능을 구현하는 것은 항상 고민이 되고, 도전적인 부분이라고 생각합니다. 특히 기기의 카메라에 접근해야하는 기능이라면 더욱 그렇습니다. 이번 글에서는 저희 스파키 Frontend팀에서 스냅피 프로젝트에서의 카메라 사진 촬영 기능을 구현한 경험을 공유드리려합니다.
웹에서의 카메라 구현
스냅피 프로젝트는 사람들이 쉽게 사진을 찍고, 업로드 할 수 있는 모임 사진 서비스입니다. 때문에 설치를 하지 않아도 링크를 통해 쉽게 접속할 수 있어야한다는 점을 고려하여 Next.js를 활용한 웹 서비스로 개발되었습니다. 하지만 앱을 다운로드 받거나 바로가기를 만들 수 있다는 점에서 PWA(Progressive Web App) 형태로 개발하기로 결정했습니다.
프로젝트 초반에 가장 먼저 부딪힌 고민은 ‘PWA 웹앱에서 네이티브 앱과 같이 카메라 기능을 구현할 수 있을까?’ 였습니다. 저희는 아래와 같은 요구사항을 충족해야했습니다.
- 실시간으로 카메라 화면을 보여줄 수 있어야 한다.
- 카메라 화면에 미션 선택 버튼과 선택한 미션의 내용을 함께 표시해야한다.
사용자 기기의 카메라 촬영 기능을 직접 열어서 사진을 첨부하게 하는 방법도 고려했지만, 기획 상 미션 정보를 함께 표시해야했습니다. 따라서 Web API를 활용하여 직접 카메라 기능을 구현해보기로 결정했습니다.
MediaStream API 로 접근하기
먼저 카메라를 통해 실시간으로 보는 모습을 화면에 보여주어야 합니다. 이를 위해선 MediaStream API를 통해 카메라에 접근하고, 실시간 비디오 스트림을 얻어야합니다. 저희는 useCamera 훅을 만들어 관련 로직을 분리하였는데요.
import { useState, useRef, useEffect } from 'react'
function useCamera(setPhoto: (photo: string | null) => void) {
const [isCameraOpen, setIsCameraOpen] = useState(false)
const [isRearCamera, setIsRearCamera] = useState(true)
const videoRef = useRef<HTMLVideoElement>(null)
const openCamera = async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('미디어 장치가 지원되지 않는 브라우저입니다.')
return
}
setIsCameraOpen(true)
setPhoto(null)
const constraints = {
video: {
facingMode: isRearCamera ? 'environment' : 'user',
},
}
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints)
if (videoRef.current) {
videoRef.current.srcObject = stream
}
} catch (err) {
console.error('카메라를 열 수 없습니다:', err)
}
}
return {
isCameraOpen,
isRearCamera,
videoRef,
openCamera,
takePicture,
toggleCamera,
}
}
export default useCamera
이 훅은 카메라를 열고, 사진을 촬영하는데에 필요한 기능들을 담고 있습니다. facingMode 는 MediaTrackConstraints 객체의 속성 중 하나로, 비디오 입력 장치의 카메라 방향을 지정하는 데 사용됩니다. 이 속성은 user(전면 카메라), environment(후면 카메라), left(사용자 왼쪽을 향하는 카메라), right(사용자 오른쪽을 향하는 카메라) 와 같은 값들을 가질 수 있습니다. 스냅피는 보통 모바일 기기를 통해 이용하기 때문에 전면 카메라와 후면 카메라를 사용합니다.
Canvas API 를 통한 촬영 및 이미지 렌더링
사진 촬영은 결국 이 비디오 프레임의 프레임을 캡쳐하고, 이미지로 변환함으로써 수행할 수 있습니다. 이때 캡처한 프레임을 Canvas를 사용하여 그리고, 최종적으로 이미지 파일로 저장해야 합니다. 먼저 Canvas 요소를 생성하고 크기를 설정합니다.
const canvas = canvasRef.current
const video = videoRef.current
if (canvas && video) {
canvas.width = 360
canvas.height = 480
const context = canvas.getContext('2d')
// ...
}
여기서 canvasRef 는 useRef 훅을 사용하여 생성한 ref 입니다. Canvas의 크기를 360x480 픽셀로 설정했는데, 원하는 이미지 크기에 따라서 설정이 가능합니다. 이제 이 Canvas에 비디오 스트림의 프레임을 그려주어야겠죠. 이를 위해선 drawImage 메서드를 사용할 수 있습니다.
context?.drawImage(
video,
sx, sy, sWidth, sHeight,
0, 0, canvas.width, canvas.height
)
drawImage 메서드는 다양한 매개변수를 가지고 있는데요.
- video: 소스 이미지
- sx, sy: 소스 이미지에서 추출을 시작할 x, y 좌표
- sWidth, sHeight: 소스 이미지에서 추출할 영역의 너비와 높이
- 0, 0: Canvas에 그리기를 시작한 x, y 좌표
- canvas.width, canvas.height: Canvas에 그릴 이미지의 너비와 높이
위와 같은 매개변수들을 통해서 Canvas에 원하는 위치와 크기로 이미지를 그릴 수 있습니다. drawImage에 대한 자세한 정보는 MDN Web Docs에서 확인할 수 있습니다.
이제 Canvas에 그려진 내용을 이미지로 변환하면 됩니다. 이때 toDataURL 메서드를 사용하여 Canvas 의 현재 내용을 기반으로 데이터 URL 을 생성합니다. 이 URL 은 이미지 데이터를 base64로 인코딩한 문자열을 포함합니다.
const dataUrl = canvas.toDataURL('image/png')
첫 번째 인자로 ‘image/png’ 를 전달하여 png 형식의 이미지를 생성하도록 합니다. 두 번째 인자를 0에서 1사이의 값으로 지정하면 화질을 지정할 수도 있습니다. 현재는 png이기 때문에 이 속성은 무시됩니다. 이렇게 생성된 데이터 URL을 활용할 수 있게 되는데요. 찍은 이미지를 확인할 수 있는 페이지에서 Image 태그의 src 속성에 넣어 이미지를 표시하도록 했습니다. 아래는 살펴본 코드들을 합쳐놓은 takePicture 함수의 전체 코드입니다.
const takePicture = () => {
const canvas = canvasRef.current
const video = videoRef.current
if (canvas && video) {
canvas.width = 360
canvas.height = 480
const context = canvas.getContext('2d')
const { videoWidth, videoHeight } = video
const aspectRatio = videoWidth / videoHeight
const desiredAspectRatio = 360 / 480
let sx = 0
let sy = 0
let sWidth = videoWidth
let sHeight = videoHeight
if (aspectRatio > desiredAspectRatio) {
sHeight = videoHeight
sWidth = videoHeight * desiredAspectRatio
sx = (videoWidth - sWidth) / 2
} else {
sWidth = videoWidth
sHeight = videoWidth / desiredAspectRatio
sy = (videoHeight - sHeight) / 2
}
context?.drawImage(
video,
sx,
sy,
sWidth,
sHeight,
0,
0,
canvas.width,
canvas.height
)
const dataUrl = canvas.toDataURL('image/png')
setPhoto(dataUrl)
const stream = video.srcObject as MediaStream
stream.getTracks().forEach((track) => track.stop())
setIsCameraOpen(false)
}
}
base64 to File
위에서 다룬 이미지 데이터는 결국 base64 인코딩된 문자열 형태입니다. 하지만 이 데이터를 서버에 업로드할 때에는 JavaScript File 객체로 올려야합니다. 이를 위해서는 File 객체로 변환하는 과정이 필요하겠죠. 변환 과정은 다음과 같이 이루어집니다.
- base64 문자열 파싱
- 바이너리 데이터로 변환
- Blob 객체 생성
- File 객체 생성
base64 문자열 파싱
const [, data] = base64String.split(',')
const mimeString = base64String.split(',')[0].split(':')[1].split(';')[0]
base64 문자열에서 실제 데이터와 MIME 타입을 추출합니다. MIME 타입이란 Multipurpose Internet Mail Extensions으로, 파일의 형식을 나타내는 표준화된 방식입니다. MIME은 type/subtype의 구조를 가지고 있는데요. 예를 들어 image/jpeg, application/json와 같이 나타내던 것들이 MIME 타입이라고 할 수 있겠습니다. 이 MIME 타입은 브라우저나 서버가 데이터를 올바르게 해석하고 처리하는데 도움을 줍니다. 우리는 MIME 타입을 분리하여 순수한 이미지 데이터만 다루어야 합니다.
바이너리 데이터로 변환
이번에는 위에서 다루었던 데이터를 컴퓨터가 직접 처리할 수 있는 형태인 바이너리 데이터로 변환합니다.
const byteCharacters = atob(data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i += 1) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
atob 함수를 이용하여 base64 데이터를 바이너리 문자열로 변환하고, 다시 Uint8Array 형태의 바이트 배열로 변환합니다. Uint8Array라는 형태로 바꾸는 이유는 아래에서 다루겠지만, Blob 생성자는 ArrayBuffer, ArrayBufferView, Blob, DOMStirng 배열을 인자로 받기 때문입니다. Uint8Array는 ArrayBufferView 의 한 종류로 Blob 생성에 직접 사용할 수 있는 형식입니다. 따라서 base64 데이터를 Uint8Array 형태의 바이트 배열로 변환한 것입니다.
Blob 객체 생성
const blob = new Blob(byteArrays, { type: mimeString })
바이트 배열을 사용해 Blob 객체를 생성합니다. Blob 이란, 이미지, 사운드, 비디오와 같은 멀티미디어 데이터를 다루기 위한 객체입니다. 만약 원본 이미지의 비율을 수정하고 싶다면 createImageBitmap 함수를 이용하여 이미지를 크롭하고, 이 크롭된 이미지로 새로운 Blob을 만들 수 있습니다. createImageBitmap 함수는 Blob 객체를 통해 ImageBitmap 객체를 생성하는데요. createImageBitmap 함수는 비트맵 이미지를 나타내는 객체인 ImageBitmap를 반환하는 웹 API로, 자세한 정보는 MDN Web docs에서 확인할 수 있습니다. 원본 이미지의 비율을 수정할 필요가 없다면 createImageBitmap 을 사용하는 부분은 넘어가도 괜찮습니다.
File 객체 생성
return new File([croppedBlob], filename, { type: mimeString })
마지막으로 Blob으로 File 객체를 이용해 생성합니다. 이 File 객체에는 위에서 만든 Blob 객체와 파일명, 타입 정보를 포함시킵니다. 이렇게 생성된 File 객체는 서버로 직접 업로드할 수 있는 형태입니다.
위에서 살펴본 변환과정을 모두 포함하는 base64ToFile.ts 파일의 코드는 다음과 같습니다.
async function base64ToFile(
base64String: string,
filename: string,
): Promise<File> {
const [, data] = base64String.split(',')
const mimeString = base64String.split(',')[0].split(':')[1].split(';')[0]
const byteCharacters = atob(data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i += 1) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
const blob = new Blob(byteArrays, { type: mimeString })
const img = await createImageBitmap(blob)
const size = Math.min(img.width, img.height)
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
ctx.drawImage(
img,
(img.width - size) / 2,
(img.height - size) / 2,
size,
size,
0,
0,
size,
size,
)
const croppedBlob = await new Promise<Blob>((resolve) => {
canvas.toBlob((newBlob) => {
if (newBlob) resolve(newBlob)
}, mimeString)
})
return new File([croppedBlob], filename, { type: mimeString })
}
export default base64ToFile
로컬 개발 환경에서 주의해야했던 점
카메라 기능을 로컬 개발 환경에서 구현하면서 추가적인 단계가 필요했습니다. 카메라 접근 기능이 보안상의 이유로 HTTPS 프로토콜에서만 사용 가능하다는 점 때문이었습니다. 프로덕션 환경에서는 큰 문제가 되지 않지만, 보통 'http://localhost'와 같은 HTTP 프로토콜을 사용하는 로컬 개발 환경에서는 불편함을 초래했습니다.
문제를 해결하기 위해 처음에는 ngrok이라는 툴을 사용했습니다. ngrok은 로컬 개발 서버를 인터넷에 노출시켜주는 터널링 프로그램으로, 이를 통해 로컬에서 실행 중인 웹 애플리케이션에 HTTPS로 외부에서 접근할 수 있게 되었습니다. 이로써 로컬 개발 환경에서도 카메라 기능을 테스트할 수 있었습니다. 물론 이는 개발 과정에서만 필요한 단계였고, 실제 배포 환경에서는 HTTPS를 사용하므로 이러한 과정이 필요 없었습니다.
그러나 이후 백엔드에서 쿠키를 설정하는 데 문제가 발생하면서 더 나은 해결책을 찾아야 했습니다. Chrome의 'Schemeful same-site' 정책 업데이트로 인해 HTTP와 HTTPS를 사용하는 동일 도메인 사이트도 크로스 사이트로 취급되는 상황이 발생했기 때문입니다. 이에 따라 우리는 로컬 환경에서 mkcert 툴로 개발용 SSL 인증서를 생성한 후 직접 HTTPS를 설정하고, 도메인을 로컬 IP와 매핑하는 방식으로 전환하게 되었습니다. 이 방식은 실제 프로덕션 환경과 유사한 설정이어서 잠재적인 문제들을 미리 발견하고 해결할 수 있는 장점이 있었습니다.
맺음말
이렇게 스냅피 서비스에서 사진 촬영이 어떻게 이루어지는지, 이미지 처리가 어떻게 이루어지는지에 대해서 알아보았습니다. 참고로, 아직 사진의 낮은 화질에 대한 이슈가 남아있어 앞으로 개선해나갈 예정입니다.
많은 서비스와 사이드 프로젝트들이 앱 형태를 선호한다고 생각합니다. 유저들이 직접 스토어에서 서비스를 설치한다는 것은 큰 의미를 가지고, 앱에서 구현하기 더 편한 기능들이 많다는 이유 때문인 것 같습니다. 하지만 PWA 웹앱 서비스의 장점 또한 분명히 있고, 스냅피 프로젝트는 웹 만의 장점을 살릴 수 있는 서비스이기 때문에 웹 기술을 최대한 활용하는 것이 필요했습니다. 사진 촬영 기능은 그 중 하나였으며, 덕분에 웹 기술의 가능성을 확인할 수 있었다고 생각합니다.
아직 해결해야할 문제들이 남아있지만 ‘쉽고 편리하게’ 라는 모토를 가지고 스냅피 유저들에게 더 나은 경험을 제공할 수 있는 방법을 계속해서 고민하고 발전시켜 나갈 계획입니다. 앞으로의 스냅피의 남은 여정을 응원해주세요!