Web Framework & Library/React

[React+Android Studio] 모바일 앱으로 카메라 사용하기

노호호 2025. 2. 4. 17:16

※ React Native가 아닌 React.js로 모바일 앱을 구현하는 과정을 담았습니다.

학교에서 리액트+안드로이드 스튜디오 웹뷰를 활용해 안드로이드 앱을 만들 일이 생겼다. 나는 여기서는 프론트엔드를 담당했다.

이 글에서 다룰 요구사항을 간단히 적어보면 아래와 같다.

  • (기능적) 카메라로 QR 코드를 스캔하여 해당하는 URL로 리다이렉트되게 해야 함
  • (비기능적) 모바일 브라우저, 자체 모바일 애플리케이션에서 모두 실행 가능해야 함

처음에는 이게 리액트 네이티브를 안 쓰고 가능한 걸까 싶었는데,
react-webcam이 기본적으로 모바일 카메라도 지원을 해줬기 때문에 크롬 모바일 앱에서 정상적으로 실행이 되는 걸 확인할 수 있었고, jsqr이라는 라이브러리를 통해 카메라에 잡힌 QR 코드를 인식하게 하는 것이 가능했다. (react-qr-reader라는 라이브러리도 있었는데 React 18에서는 지원되지 않았다.)


앱 포팅같은 경우 웹뷰만으로는 동작하지 않았고, 안드로이드 스튜디오에서 추가로 권한 설정 등을 하니 정상적으로 작동되었다. (에뮬레이터 및 갤럭시 공기계로 확인)


1. Camera 컴포넌트 작성

QR 코드 스캔의 흐름은 대략적으로 아래와 같다.


1. 웹캠 스트림을 화면에 표시함
2. 0.5초마다 scanQRCode를 실행시켜, 웹캠으로부터 캡처한 이미지를 바탕으로 QR 코드를 인식하려고 시도함
3. 2에서 QR 코드를 발견하면 해당 데이터를 상태에 저장하고, 데이터에 적힌 URL로 리다이렉트시킴

우선 npm install react-webcam jsqr을 통해 라이브러리를 설치하고, 아래와 같이 컴포넌트를 작성했다.

import React, { useRef, useState, useEffect } from 'react';
import Webcam from 'react-webcam';
import jsQR from 'jsqr';
import '../styles/WebcamComponent.css';

const WebcamComponent = ({ onCapture }) => {
  const webcamRef = useRef(null);
  const [qrData, setQrData] = useState(null);

  const videoConstraints = {
    facingMode: { exact: "environment" }
  };

  useEffect(() => {
    const scanQrCode = () => {
      if (webcamRef.current) {
        const video = webcamRef.current.video;
        if (video.readyState === video.HAVE_ENOUGH_DATA) {
          const canvas = document.createElement('canvas');
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          const context = canvas.getContext('2d');
          context.drawImage(video, 0, 0, canvas.width, canvas.height);
          const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
          const code = jsQR(imageData.data, imageData.width, imageData.height);
          if (code) {
            setQrData(code.data);
            window.location.href = code.data; // QR 코드 데이터로 리다이렉트
          }
        }
      }
    };

    const interval = setInterval(scanQrCode, 500); // 0.5초마다 QR 코드 스캔 시도
    return () => clearInterval(interval);
  }, [webcamRef]);

  return (
    <div className="webcam-container">
      <Webcam
        audio={false}
        ref={webcamRef}
        screenshotFormat="image/jpeg"
        videoConstraints={videoConstraints}
        className="webcam"
      />
      {qrData && <div className="qr-data">QR Code Data: {qrData}</div>}
    </div>
  );
};

export default WebcamComponent;

자세히 살펴보면,

const WebcamComponent = ({ onCapture }) => {
  const webcamRef = useRef(null);
  const [qrData, setQrData] = useState(null);

  const videoConstraints = {
    facingMode: { exact: "environment" }
  };

우선 레퍼런스 및 State 지정해주고, 후면 카메라가 기본이 되게 설정한다.

useEffect(() => {
  const scanQrCode = () => {
    if (webcamRef.current) {
      const video = webcamRef.current.video;
      if (video.readyState === video.HAVE_ENOUGH_DATA) {
        const canvas = document.createElement('canvas');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        const context = canvas.getContext('2d');
        context.drawImage(video, 0, 0, canvas.width, canvas.height);
        const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
        const code = jsQR(imageData.data, imageData.width, imageData.height);
        if (code) {
          setQrData(code.data);
          window.location.href = code.data;
        }
      }
    }
  };

  const interval = setInterval(scanQrCode, 500);
  return () => clearInterval(interval);
}, [webcamRef]);

scanQrCode를 통해 카메라 내용을 캡처하고, 캔버스로 복사한다.
QR 코드가 발견되면 setQrData를 업데이트하고 window.location.href를 통해 이동한다.
이러한 scanQrCode는 0.5초마다 실행한다.

return (
  <div className="webcam-container">
    <Webcam
      audio={false}
      ref={webcamRef}
      screenshotFormat="image/jpeg"
      videoConstraints={videoConstraints}
      className="webcam"
    />
    {qrData && <div className="qr-data">QR Code Data: {qrData}</div>}
  </div>
);

오디오는 안 쓸 거니 false로 지정하고, 스크린샷의 포맷은 jpeg로 한다. videoConstraints로 후면 카메라를 사용하겠다고 다시 정의한다.

아이폰 크롬 브라우저 앱 기준으로 정상 처리된다.


2. 안드로이드 스튜디오 설정

안드로이드 스튜디오에서는 웹뷰 URL만 달랑 등록해놓으면 실행이 안 되고, 추가 설정을 해줘야 한다.

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />

    <uses-feature android:name="android.hardware.camera" android:required="false" />
    <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApplication"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.MyApplication">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

여기서 아래 부분을 통해 카메라 권한 설정을 해주었다.

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />

<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />

MainActivity.kt

package com.example.myapplication

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.myapplication.ui.theme.MyApplicationTheme

class MainActivity : ComponentActivity() {
    private val REQUEST_CAMERA_PERMISSION = 1

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                WebViewExample()
            }
        }

        // 권한 요청
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
                ActivityCompat.requestPermissions(
                    this,
                    arrayOf(Manifest.permission.CAMERA),
                    REQUEST_CAMERA_PERMISSION
                )
            }
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CAMERA_PERMISSION) {
            if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
                // 권한이 승인된 경우 카메라 기능을 사용할 수 있음
            } else {
                // 
            }
        }
    }
}

@Composable
fun WebViewExample() {
    AndroidView(
        factory = { context ->
            WebView(context).apply {
                webViewClient = WebViewClient()
                webChromeClient = object : WebChromeClient() {
                    override fun onPermissionRequest(request: PermissionRequest) {
                        // 권한 요청 처리
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                            if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
                                != PackageManager.PERMISSION_GRANTED) {
                                ActivityCompat.requestPermissions(
                                    context as ComponentActivity,
                                    arrayOf(Manifest.permission.CAMERA),
                                    1
                                )
                            } else {
                                request.grant(request.resources)
                            }
                        } else {
                            request.grant(request.resources)
                        }
                    }
                }
                settings.apply {
                    javaScriptEnabled = true
                    domStorageEnabled = true // 로컬 스토리지 사용 허용
                    useWideViewPort = true
                    loadWithOverviewMode = true
                    setSupportZoom(true)
                    builtInZoomControls = true
                    displayZoomControls = false
                    allowFileAccess = true // 파일 액세스 허용
                    javaScriptCanOpenWindowsAutomatically = true // 자바스크립트 팝업 허용
                }
                loadUrl("https://main--coupon-moa.netlify.app/")
                setInitialScale(1)
            }
        },
        modifier = Modifier.fillMaxSize()
    )
}

@Preview(showBackground = true)
@Composable
fun WebViewPreview() {
    MyApplicationTheme {
        WebViewExample()
    }
}

MainActivity도 이에 맞게 수정을 해주어야 한다. 그냥 카메라 사용이 아니라 웹뷰를 통한 사용이므로, 앱 자체의 카메라 사용 허가도 해주어야 하고, 웹뷰에서도 카메라 사용을 설정해야 한다.

오늘의 결론

백엔드와 리액트와 안드로이드 스튜디오의 웹뷰 기능만 가지고도 웬만한 앱은 만들 수 있다.
자신이 웹 개발자인데 앱은 개발하고 싶고 러닝 커브는 늘리기 싫으면 그냥 안드로이드 스튜디오 웹뷰 기능과 반응형 디자인 등을 적극 활용하자