웹 브라우저에서 Kotlin로 알아보는 실시간 웹캠 기능 다루기

궁금하면 500원·2025년 5월 25일
0

JavaScript와 Kotlin/Spring Boot로 알아보는 실시간 스트림, 캡처, 음소거 기능 웹캠 다루기

안녕하세요! 웹 브라우저에서 웹캠을 제어하는 방법에 대해 쉽고 재미있게 알아보겠습니다.

사용자 동의하에 웹캠 영상과 음성을 가져와 화면에 표시하고, 사진을 찍거나 소리를 켜고 끌 수 있는 기능을 만들어볼 거예요.

프론트엔드는 HTML, CSS, 순수 JavaScript(ES6)로, 백엔드는 Kotlin과 Spring Boot, 그리고 파일 저장에는 MinIO 서버를 활용해볼 예정입니다.


1. 웹 브라우저에서 웹캠 제어하기 (HTML, CSS, JavaScript)

먼저 웹 브라우저에서 웹캠을 다루는 방법을 알아볼게요.
별도의 라이브러리 없이 웹 표준 기술인 MediaDevices API를 사용해 카메라와 마이크에 접근하고, HTML video 요소에 스트림을 연결하는 방식입니다.

1.1. HTML 구조 (index.html)

웹 페이지의 뼈대를 만듭니다. video 태그로 웹캠 영상을 표시하고, 버튼들을 만들어 기능을 제어할 거예요.
캡처된 이미지를 보여줄 img 태그와 canvas 태그도 포함됩니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>웹캠 데모</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <h1>웹캠 라이브 데모</h1>

    <div class="controls">
        <button id="startStreamBtn">스트림 시작</button>
        <button id="stopStreamBtn" disabled>스트림 중지</button>
        <button id="toggleMuteBtn" disabled>음소거</button>
        <button id="captureBtn" disabled>사진 찍기</button>
    </div>

    <div class="video-display">
        <video id="webcamFeed" autoplay playsinline></video>
        <canvas id="photoCanvas" style="display:none;"></canvas>
    </div>

    <div class="captured-photo-area">
        <h2>최근 촬영 사진</h2>
        <img id="capturedPhoto" alt="촬영된 사진" style="display:none;">
    </div>
    
    <script src="app.js"></script>
</body>
</html>

1.2. CSS 스타일링 (styles.css)

웹 페이지의 모습을 꾸며줍니다. 버튼, 비디오 영역 등에 간단한 스타일을 적용할 거예요.

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 30px;
    background-color: #f4f7f6;
    color: #333;
}

h1 {
    color: #2c3e50;
    margin-bottom: 30px;
}

.controls {
    display: flex;
    gap: 15px;
    margin-bottom: 30px;
}

button {
    padding: 12px 25px;
    font-size: 16px;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    transition: background-color 0.3s ease, transform 0.2s ease;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

button:hover:not(:disabled) {
    transform: translateY(-2px);
}

button:disabled {
    background-color: #cccccc;
    cursor: not-allowed;
    box-shadow: none;
}

#startStreamBtn {
    background-color: #28a745; /* Green */
    color: white;
}
#startStreamBtn:hover:not(:disabled) { background-color: #218838; }

#stopStreamBtn {
    background-color: #dc3545; /* Red */
    color: white;
}
#stopStreamBtn:hover:not(:disabled) { background-color: #c82333; }

#toggleMuteBtn {
    background-color: #ffc107; /* Yellow */
    color: white;
}
#toggleMuteBtn:hover:not(:disabled) { background-color: #e0a800; }

#captureBtn {
    background-color: #007bff; /* Blue */
    color: white;
}
#captureBtn:hover:not(:disabled) { background-color: #0069d9; }

.video-display {
    border: 3px solid #34495e;
    border-radius: 10px;
    overflow: hidden;
    margin-bottom: 25px;
    box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
}

#webcamFeed {
    display: block;
    width: 640px;
    height: 480px; /* 적절한 높이 설정 */
    background-color: #000;
    object-fit: cover; /* 비디오가 잘리지 않도록 채움 */
}

.captured-photo-area {
    text-align: center;
    width: 100%;
    max-width: 640px;
}

.captured-photo-area h2 {
    color: #4a69bd;
    margin-bottom: 15px;
}

#capturedPhoto {
    width: 100%;
    height: auto;
    border: 2px dashed #95a5a6;
    border-radius: 8px;
    background-color: #ecf0f1;
    padding: 5px;
    box-sizing: border-box;
}

1.3. JavaScript 로직 (app.js)

이제 웹캠을 제어하는 핵심 로직입니다.
navigator.mediaDevices.getUserMedia()를 사용해 미디어 스트림을 가져오고, HTMLMediaElement와 Canvas API를 활용하여 기능을 구현합니다.

// DOM 요소 참조
const startStreamBtn = document.getElementById('startStreamBtn');
const stopStreamBtn = document.getElementById('stopStreamBtn');
const toggleMuteBtn = document.getElementById('toggleMuteBtn');
const captureBtn = document.getElementById('captureBtn');
const webcamFeed = document.getElementById('webcamFeed');
const photoCanvas = document.getElementById('photoCanvas');
const capturedPhoto = document.getElementById('capturedPhoto');

// Canvas 2D 컨텍스트
const canvasContext = photoCanvas.getContext('2d');

// 미디어 스트림을 저장할 변수
let currentStream = null;

// --- 유틸리티 함수: 버튼 상태 업데이트 ---
const updateButtonStates = () => {
    const hasStream = currentStream !== null;
    startStreamBtn.disabled = hasStream;
    stopStreamBtn.disabled = !hasStream;
    toggleMuteBtn.disabled = !hasStream;
    captureBtn.disabled = !hasStream;

    // 음소거 버튼 텍스트 업데이트
    if (hasStream) {
        // audio track이 있을 경우에만 mute 상태를 확인
        const audioTracks = currentStream.getAudioTracks();
        if (audioTracks.length > 0) {
            toggleMuteBtn.textContent = audioTracks[0].enabled ? '음소거' : '음소거 해제';
        } else {
            // 오디오 트랙이 없으면 음소거 버튼 비활성화 또는 기본 텍스트 유지
            toggleMuteBtn.textContent = '음소거'; // 기본 텍스트
            toggleMuteBtn.disabled = true;
        }
    } else {
        toggleMuteBtn.textContent = '음소거'; // 스트림 없을 때
    }
};

// --- 스트림 시작 함수 ---
const startVideoStream = async () => {
    try {
        // 비디오(카메라)와 오디오(마이크) 권한 요청
        const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        currentStream = stream;
        webcamFeed.srcObject = stream; // <video> 요소에 스트림 연결

        // 웹캠이 로드되면 자동으로 재생
        await webcamFeed.play();
        console.log("웹캠 스트림 시작 성공");

        updateButtonStates(); // 버튼 상태 업데이트
        webcamFeed.muted = false; // 기본적으로 음소거 해제 (사용자 경험 고려)
        toggleMuteBtn.textContent = '음소거'; // 버튼 텍스트 초기화
    } catch (error) {
        console.error("웹캠 스트림 시작 실패:", error);
        alert("웹캠 접근 권한이 필요합니다.");
        currentStream = null; // 실패 시 스트림 초기화
        updateButtonStates();
    }
};

// --- 스트림 중지 함수 ---
const stopVideoStream = () => {
    if (currentStream) {
        currentStream.getTracks().forEach(track => track.stop()); // 모든 트랙 중지
        webcamFeed.srcObject = null; // <video> 요소에서 스트림 연결 해제
        currentStream = null; // 스트림 변수 초기화
        console.log("웹캠 스트림 중지");

        // 캡처된 이미지 초기화 및 숨기기
        capturedPhoto.src = '';
        capturedPhoto.style.display = 'none';
    }
    updateButtonStates(); // 버튼 상태 업데이트
};

// --- 음소거 토글 함수 ---
const toggleMute = () => {
    if (currentStream) {
        // 오디오 트랙을 찾아 enabled 속성 토글
        const audioTracks = currentStream.getAudioTracks();
        if (audioTracks.length > 0) {
            audioTracks[0].enabled = !audioTracks[0].enabled;
            webcamFeed.muted = !audioTracks[0].enabled; // <video> 요소의 muted도 동기화 (선택 사항)
            console.log("오디오 상태:", audioTracks[0].enabled ? "활성화" : "비활성화");
        } else {
            console.warn("오디오 트랙을 찾을 수 없습니다.");
        }
    }
    updateButtonStates(); // 버튼 텍스트 업데이트
};


// --- 사진 캡처 함수 ---
const capturePhoto = () => {
    if (webcamFeed.srcObject) {
        // canvas 크기를 비디오와 동일하게 설정
        photoCanvas.width = webcamFeed.videoWidth;
        photoCanvas.height = webcamFeed.videoHeight;

        // canvas에 비디오의 현재 프레임을 그립니다.
        canvasContext.drawImage(webcamFeed, 0, 0, photoCanvas.width, photoCanvas.height);

        // canvas 내용을 PNG 이미지 데이터 URL로 변환
        const imageDataURL = photoCanvas.toDataURL('image/png');

        // 캡처된 이미지를 <img> 태그에 표시
        capturedPhoto.src = imageDataURL;
        capturedPhoto.style.display = 'block';
        console.log("사진 캡처 성공!");

        // TODO: 여기서 백엔드(Spring Boot)로 이미지 데이터를 전송하는 로직을 추가할 수 있습니다.
        // sendImageToBackend(imageDataURL);
    } else {
        alert("웹캠 스트림이 활성화되어 있지 않습니다.");
    }
};

// --- 이벤트 리스너 연결 ---
startStreamBtn.addEventListener('click', startVideoStream);
stopStreamBtn.addEventListener('click', stopVideoStream);
toggleMuteBtn.addEventListener('click', toggleMute);
captureBtn.addEventListener('click', capturePhoto);

// 초기 버튼 상태 설정
document.addEventListener('DOMContentLoaded', updateButtonStates);

// 백엔드로 이미지 전송 예시 (추후 구현)
async function sendImageToBackend(imageData) {
    try {
        const response = await fetch('/api/upload-image', { // 백엔드 엔드포인트
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ image: imageData }), // Base64 인코딩된 이미지 데이터
        });

        if (response.ok) {
            const result = await response.json();
            console.log("이미지 업로드 성공:", result);
            alert("사진이 서버에 업로드되었습니다!");
        } else {
            console.error("이미지 업로드 실패:", response.statusText);
            alert("사진 업로드 중 오류가 발생했습니다.");
        }
    } catch (error) {
        console.error("이미지 업로드 중 네트워크 오류:", error);
        alert("네트워크 오류로 사진 업로드에 실패했습니다.");
    }
}

프론트엔드 정리

  • navigator.mediaDevices.getUserMedia({ video: true, audio: true }): 웹 브라우저에 카메라와 마이크 접근 권한을 요청하고 미디어 스트림을 가져옵니다.

- webcamFeed.srcObject = stream: 가져온 미디어 스트림을 video 요소에 연결하여 실시간 영상을 표시합니다.

  • stream.getTracks().forEach(track => track.stop()): 스트림을 중지할 때 모든 미디어 트랙(비디오, 오디오)을 중지하여 카메라/마이크 접근을 해제합니다.

  • audioTracks[0].enabled = !audioTracks[0].enabled: 오디오 트랙의 enabled 속성을 토글하여 소리를 켜고 끕니다.

  • canvasContext.drawImage(webcamFeed, ...): video 요소의 현재 프레임을 canvas에 그립니다.

  • photoCanvas.toDataURL('image/png'): 캔버스 내용을 PNG 이미지 데이터(Base64 인코딩된 문자열)로 변환하여 img 태그에 표시하거나 서버로 전송할 수 있습니다.

2. Kotlin/Spring Boot와 MinIO로 이미지 저장하기

이제 프론트엔드에서 캡처한 이미지를 서버로 전송하고, MinIO 객체 스토리지에 저장하는 백엔드 애플리케이션을 만들어볼 차례입니다.

Kotlin기반의 Spring Boot를 사용합니다.

2.1. 프로젝트 설정 (build.gradle.kts)

Spring Boot 프로젝트를 생성하고 build.gradle.kts에 필요한 의존성을 추가합니다.

// build.gradle.kts

plugins {
    kotlin("jvm") version "1.9.20" // Kotlin 버전
    kotlin("plugin.spring") version "1.9.20" // Spring 플러그인
    id("org.springframework.boot") version "3.2.0" // Spring Boot 버전
    id("io.spring.dependency-management") version "1.1.4" // 의존성 관리
}

group = "com.sleekydz86"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_17 // Java 버전 (Kotlin과 호환)
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot Web (REST API)
    implementation("org.springframework.boot:spring-boot-starter-web")
    // Kotlin JSON 직렬화/역직렬화 (Jackson)
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    // Kotlin 리플렉션
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    // MinIO 클라이언트 라이브러리
    implementation("io.minio:minio:8.5.8") // 최신 안정 버전 확인 후 사용
    // Spring Boot Test
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"
        jvmTarget = "17"
    }
}

tasks.named("test") {
    useJUnitPlatform()
}

2.2. MinIO 설정 (application.properties)

MinIO 서버의 연결 정보를 설정합니다.
MinIO 서버를 로컬이나 클라우드에 미리 설치 및 실행해야 합니다.

# Spring Boot Web Port
server.port=8080

# MinIO Configuration
minio.url=http://localhost:9000 # MinIO 서버 URL
minio.access-key=minioadmin # MinIO Access Key
minio.secret-key=minioadmin # MinIO Secret Key
minio.bucket-name=webcam-photos # 이미지를 저장할 MinIO 버킷 이름 (미리 생성 권장)

MinIO 설치 및 실행

docker run -p 9000:9000 -p 9001:9001 \
  --name minio \
  -e "MINIO_ROOT_USER=minioadmin" \
  -e "MINIO_ROOT_PASSWORD=minioadmin" \
  quay.io/minio/minio server /data --console-address ":9001"

위 명령어로 MinIO 서버를 실행한 후, 브라우저에서 http://localhost:9001에 접속하여 minioadmin/minioadmin으로 로그인 후 webcam-photos 버킷을 미리 생성해두세요.

2.3. MinIO 서비스 클래스 (MinioService.kt)

MinIO와 상호작용하는 로직을 담당하는 서비스 클래스입니다.

package com.sleekydz86.global.service

import io.minio.BucketExistsArgs
import io.minio.MakeBucketArgs
import io.minio.MinioClient
import io.minio.PutObjectArgs
import io.minio.errors.ErrorResponseException
import io.minio.errors.InsufficientDataException
import io.minio.errors.InternalException
import io.minio.errors.InvalidResponseException
import io.minio.errors.ServerException
import io.minio.errors.XmlParserException
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.util.*

@Service
class MinioService(
    @Value("\${minio.url}") private val minioUrl: String,
    @Value("\${minio.access-key}") private val minioAccessKey: String,
    @Value("\${minio.secret-key}") private val minioSecretKey: String,
    @Value("\${minio.bucket-name}") private val minioBucketName: String
) {

    private val minioClient: MinioClient = MinioClient.builder()
        .endpoint(minioUrl)
        .credentials(minioAccessKey, minioSecretKey)
        .build()

    // 버킷이 없으면 생성
    init {
        try {
            val found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(minioBucketName).build())
            if (!found) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(minioBucketName).build())
                println("MinIO 버킷 '$minioBucketName'이(가) 생성되었습니다.")
            } else {
                println("MinIO 버킷 '$minioBucketName'이(가) 이미 존재합니다.")
            }
        } catch (e: Exception) {
            println("MinIO 버킷 확인/생성 중 오류 발생: ${e.message}")
            e.printStackTrace()
        }
    }

    /**
     * Base64 인코딩된 이미지 데이터를 MinIO에 업로드합니다.
     * @param base64Image Base64 인코딩된 이미지 문자열 (예: "data:image/png;base64,iVBORw0...")
     * @param fileName 저장할 파일 이름 (확장자 포함)
     * @return 업로드된 파일의 MinIO URL 또는 null
     */
    fun uploadBase64Image(base64Image: String, fileName: String): String? {
        val parts = base64Image.split(",")
        if (parts.size < 2) {
            println("유효하지 않은 Base64 이미지 형식입니다.")
            return null
        }
        val mimeType = parts[0].substringAfter("data:").substringBefore(";")
        val base64Data = parts[1]

        return try {
            val imageBytes = Base64.getDecoder().decode(base64Data)
            val inputStream = ByteArrayInputStream(imageBytes)

            val objectName = "${UUID.randomUUID()}-${fileName}" // 고유한 파일명 생성

            minioClient.putObject(
                PutObjectArgs.builder()
                    .bucket(minioBucketName)
                    .`object`(objectName)
                    .stream(inputStream, imageBytes.size.toLong(), -1)
                    .contentType(mimeType)
                    .build()
            )
            println("MinIO에 '$objectName' 파일이 업로드되었습니다.")
            // MinIO는 기본적으로 객체 접근을 위한 URL을 직접 반환하지 않습니다.
            // 여기서는 공개 접근 가능한 경우의 URL 형식을 반환합니다.
            // MinIO 설정에 따라 URL이 다를 수 있습니다.
            "$minioUrl/$minioBucketName/$objectName"
        } catch (e: Exception) { // 다양한 MinIO 예외 처리
            println("MinIO 업로드 중 오류 발생: ${e.message}")
            e.printStackTrace()
            null
        }
    }
}

2.4. 데이터 전송 객체 (DTO) (ImageUploadRequest.kt)

프론트엔드에서 보낼 이미지 데이터를 담을 클래스입니다.

package com.sleekydz86.global.dto

data class ImageUploadRequest(
    val image: String // Base64 인코딩된 이미지 데이터
)

2.5. REST 컨트롤러 (WebcamController.kt)

프론트엔드와 통신하여 이미지를 업로드하는 엔드포인트를 제공합니다.

package com.sleekydz86.global.controller

import com.sleekydz86.global.dto.ImageUploadRequest
import com.sleekydz86.global.service.MinioService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

@RestController
@RequestMapping("/api")
class WebcamController(private val minioService: MinioService) {

    @PostMapping("/upload-image")
    fun uploadImage(@RequestBody request: ImageUploadRequest): ResponseEntity<Map<String, String>> {
        // 파일 이름을 위한 타임스탬프 생성
        val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
        val fileName = "webcam-capture-$timestamp.png" // PNG 형식으로 가정

        val imageUrl = minioService.uploadBase64Image(request.image, fileName)

        return if (imageUrl != null) {
            ResponseEntity.ok(mapOf("message" to "이미지 업로드 성공", "url" to imageUrl))
        } else {
            ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(mapOf("message" to "이미지 업로드 실패"))
        }
    }
}

2.6. CORS 설정 (WebConfig.kt)

프론트엔드(예: http://localhost:8080 또는 파일 시스템)와 백엔드(http://localhost:8080)가 다른 도메인/포트에서 실행될 수 있으므로 CORS(Cross-Origin Resource Sharing) 설정을 추가합니다

package com.sleekydz86.global.config

import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig : WebMvcConfigurer {

    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("/api/**") // /api 경로로 들어오는 모든 요청에 대해 CORS 허용
            .allowedOrigins("http://localhost:8080", "null") // 프론트엔드 주소 허용 (파일 직접 열 경우 "null"도 추가)
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 허용할 HTTP 메서드
            .allowedHeaders("*") // 모든 헤더 허용
            .allowCredentials(true) // 자격 증명 허용 (쿠키, HTTP 인증 등)
    }
}
  • 주의: allowedOrigins에 null을 추가하는 것은 로컬에서 index.html 파일을 직접 열 때(파일 프로토콜) 필요할 수 있습니다.
    실제 배포 환경에서는 보안상 특정 도메인만 명시해야 합니다.

백엔드 정리

  • Spring Boot Web: RESTful API를 쉽게 구축할 수 있게 해줍니다.
  • MinIO Client: MinIO 서버와 통신하여 객체(이미지)를 업로드합니다.
  • @Value: application.properties에서 설정값을 읽어옵니다.
  • Base64 디코딩: 프론트엔드에서 전송된 Base64 인코딩된 이미지 데이터를 바이너리 데이터로 변환합니다.
  • MinioClient.putObject(): 이미지 데이터를 MinIO 버킷에 저장합니다.
  • @CrossOrigin 또는 WebMvcConfigurer: 프론트엔드와 백엔드 간의 CORS 문제를 해결합니다.

프로젝트 실행 및 테스트

1. MinIO 서버 실행: 위 Docker 명령어를 사용하여 MinIO 서버를 실행하고 webcam-photos 버킷을 생성합니다.

  1. Spring Boot 백엔드 실행: IntelliJ IDEA 또는 터미널에서 WebcamDemoApplication.kt (Spring Boot 메인 애플리케이션 파일)를 실행합니다.

  2. 프론트엔드 실행: index.html, styles.css, app.js 파일을 같은 폴더에 두고 index.html을 웹 브라우저로 엽니다.

  3. 웹캠 제어

  • "스트림 시작" 버튼 클릭 -> 웹캠 영상이 나타나고 마이크 접근 권한을 요청합니다.
  • "음소거" 버튼 클릭 -> 소리가 켜지고 꺼집니다.
  • "사진 찍기" 버튼 클릭 -> 현재 웹캠 화면이 아래에 표시됩니다.
  • (선택 사항) app.js의 sendImageToBackend(imageDataURL) 주석을 해제하고, "사진 찍기"
    버튼을 누르면 이미지가 백엔드를 통해 MinIO에 저장되는 것을 확인할 수 있습니다.
    MinIO 콘솔에서 업로드된 파일을 직접 볼 수 있습니다.

결론

이렇게 HTML, CSS, JavaScript를 이용하여 웹 브라우저에서 웹캠을 제어하는 방법을 학습하고, Kotlin/Spring Boot와 MinIO를 활용하여 캡처한 이미지를 서버에 저장하는 기능까지 구현해 보았습니다.

웹캠 스트리밍, 이미지 캡처, 그리고 음소거 기능까지 구현해 보면서 웹 기술의 강력함과 유연성을 다시 한번 느꼈습니다.

특히 프론트엔드와 백엔드를 연동하여 실제 데이터를 처리하는 과정은 매우 흥미로웠습니다.

가장 인상 깊었던 점은 복잡해 보이는 웹캠 제어 기능이 navigator.mediaDevices.getUserMedia()와 같은 웹 표준 API만으로도 이렇게 쉽게 구현될 수 있다는 것이었습니다.
별도의 복잡한 라이브러리 없이도 브라우저가 제공하는 강력한 기능을 활용할 수 있다는 점은 웹 개발의 큰 장점인 것 같습니다.
HTMLMediaElement의 srcObject나 muted 속성, 그리고 Canvas API를 활용한 이미지 처리 방식은 웹 브라우저가 단순한 정보 소비 도구를 넘어선 강력한 애플리케이션 플랫폼임을 다시 한번 깨닫게 해주었습니다.

프론트엔드에서 캡처한 이미지를 Kotlin 기반의 Spring Boot 백엔드로 전송하고, MinIO에 저장하는 과정은 웹 애플리케이션의 전체적인 흐름을 이해하는 데 큰 도움이 되었습니다.
사용자의 입력을 받아 서버에서 처리하고 저장하는 일반적인 웹 서비스의 아키텍처를 직접 경험해 볼 수 있었죠.
특히 MinIO 같은 객체 스토리지를 활용하면 대용량 파일 관리도 효율적으로 할 수 있다는 점을 실감했습니다.

개발 과정에서 CORS(교차 출원 자원 공유) 문제는 늘 마주하는 도전이지만, 이번 프로젝트에서도 프론트엔드와 백엔드가 다른 포트에서 실행되면서 CORS 설정을 통해 이를 해결하는 과정을 거쳤습니다.
또한, getUserMedia 호출 시 사용자 권한 요청, 비동기 처리(async/await), 그리고 버튼 상태 관리 등 세부적인 부분들을 신경 써야 했습니다.
이런 작은 문제들을 해결해나가면서 견고한 애플리케이션을 만드는 능력을 키울 수 있었습니다.

이번에 구현한 기능들은 웹캠을 활용하는 다양한 서비스의 기반이 될 수 있습니다.
예를 들어, 실시간 화상 회의 시스템, 온라인 교육 플랫폼의 시험 감독 시스템, 심지어 간단한 웹 기반 사진 편집 도구 등으로 확장될 수 있겠다는 생각이 들었습니다.
이처럼 기본적인 기능들을 조합하여 무궁무진한 아이디어를 실현할 수 있다는 점이 개발의 매력인 것 같습니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글