⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

이미지 분류와 객체 탐지 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 27. · 3 Views

이미지 분류와 객체 탐지 완벽 가이드

웹 브라우저에서 TensorFlow.js를 활용하여 이미지 분류와 객체 탐지를 구현하는 방법을 다룹니다. 실시간 웹캠 처리부터 바운딩 박스 시각화까지, 실무에 바로 적용할 수 있는 내용을 담았습니다.


목차

  1. 이미지_분류_구현
  2. 객체_탐지_모델_사용
  3. 이미지_전처리
  4. Canvas와_연동
  5. 실시간_웹캠_처리
  6. 결과_바운딩_박스_표시

1. 이미지 분류 구현

김개발 씨는 이번에 새로운 프로젝트를 맡게 되었습니다. 사용자가 업로드한 사진이 어떤 종류인지 자동으로 분류하는 기능을 만들어 달라는 요청이었습니다.

"이미지 분류라니, 인공지능 박사 학위가 있어야 하는 거 아닌가요?" 김개발 씨의 걱정스러운 질문에 박시니어 씨가 웃으며 대답했습니다. "TensorFlow.js를 쓰면 브라우저에서도 쉽게 할 수 있어요."

이미지 분류는 주어진 이미지가 어떤 카테고리에 속하는지 판별하는 기술입니다. 마치 도서관 사서가 새로 들어온 책을 보고 "이건 소설이고, 저건 과학 서적이네요"라고 분류하는 것과 같습니다.

TensorFlow.js의 MobileNet 모델을 사용하면 별도의 학습 없이도 1000가지 이상의 객체를 분류할 수 있습니다.

다음 코드를 살펴봅시다.

// TensorFlow.js와 MobileNet 모델 로드
async function classifyImage(imageElement) {
  // 사전 학습된 MobileNet 모델을 불러옵니다
  const model = await mobilenet.load();

  // 이미지를 분석하여 예측 결과를 얻습니다
  const predictions = await model.classify(imageElement);

  // 상위 3개 예측 결과를 반환합니다
  predictions.forEach(pred => {
    console.log(`${pred.className}: ${(pred.probability * 100).toFixed(2)}%`);
  });

  return predictions;
}

// 사용 예시
const img = document.getElementById('myImage');
classifyImage(img);

김개발 씨는 입사 6개월 차 주니어 개발자입니다. 이번에 받은 과제는 쇼핑몰에 업로드되는 상품 이미지를 자동으로 분류하는 기능을 만드는 것이었습니다.

처음에는 막막했지만, 박시니어 씨의 도움으로 TensorFlow.js를 알게 되었습니다. "TensorFlow.js가 뭔가요?" 김개발 씨가 물었습니다.

박시니어 씨가 설명을 시작했습니다. "구글에서 만든 머신러닝 라이브러리인데, 특별한 점은 웹 브라우저에서 바로 실행된다는 거예요.

파이썬이나 별도의 서버 없이도 클라이언트에서 인공지능을 돌릴 수 있죠." 그렇다면 이미지 분류란 정확히 무엇일까요? 쉽게 비유하자면, 이미지 분류는 마치 숙련된 감정사가 물건을 보고 "이건 18세기 도자기입니다"라고 판별하는 것과 같습니다.

컴퓨터가 이미지의 특징을 분석하여 "이 사진에는 고양이가 있습니다" 또는 "이것은 커피 컵입니다"라고 판단하는 것입니다. MobileNet은 구글에서 만든 경량화된 이미지 분류 모델입니다.

이름에서 알 수 있듯이 모바일 환경에서도 빠르게 동작하도록 설계되었습니다. 덕분에 웹 브라우저에서도 무리 없이 실행할 수 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 mobilenet.load() 함수가 사전 학습된 모델을 불러옵니다.

이 모델은 이미 수백만 장의 이미지로 학습되어 있어서, 우리가 추가로 학습시킬 필요가 없습니다. 마치 이미 모든 책을 읽어본 사서를 고용하는 것과 같습니다.

다음으로 model.classify() 메서드가 핵심입니다. HTML의 이미지 요소를 전달하면, 모델이 그 이미지를 분석하여 예측 결과를 반환합니다.

결과는 배열 형태로, 각 항목에는 className(분류 이름)과 probability(확률)가 포함됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 중고 거래 플랫폼을 운영한다고 가정해봅시다. 판매자가 상품 사진을 업로드하면, 이미지 분류를 통해 자동으로 카테고리를 추천해줄 수 있습니다.

"이 사진은 의자로 보입니다. 가구 카테고리에 등록할까요?" 이런 식으로 사용자 경험을 크게 향상시킬 수 있습니다.

하지만 주의할 점도 있습니다. MobileNet은 일반적인 객체 1000가지를 분류하도록 학습되었습니다.

따라서 특정 도메인에 특화된 분류가 필요하다면, 커스텀 모델을 학습시켜야 할 수도 있습니다. 또한 첫 모델 로딩에는 네트워크 상태에 따라 몇 초가 걸릴 수 있으므로, 로딩 인디케이터를 보여주는 것이 좋습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 후, 김개발 씨는 자신감 있게 코드를 작성하기 시작했습니다.

"생각보다 어렵지 않네요!"

실전 팁

💡 - 모델 로딩은 한 번만 하고 재사용하세요. 매번 로딩하면 성능이 크게 저하됩니다.

  • topk 파라미터로 반환받을 예측 개수를 조절할 수 있습니다.

2. 객체 탐지 모델 사용

김개발 씨는 이미지 분류를 성공적으로 구현한 후, 새로운 요구사항을 받았습니다. "이번에는 사진 속에 여러 물체가 있을 때, 각각이 어디에 있는지도 알려줘야 해요." 팀장님의 말에 김개발 씨는 고개를 갸웃했습니다.

이미지 분류와 객체 탐지는 어떻게 다른 걸까요?

객체 탐지는 이미지 분류보다 한 단계 더 나아간 기술입니다. 단순히 "이 사진에 고양이가 있다"고 말하는 것이 아니라, "고양이가 왼쪽 상단에 있고, 강아지는 오른쪽 하단에 있다"고 정확한 위치까지 알려줍니다.

TensorFlow.js의 COCO-SSD 모델을 사용하면 80가지 종류의 객체를 탐지하고 위치를 파악할 수 있습니다.

다음 코드를 살펴봅시다.

// COCO-SSD 모델을 사용한 객체 탐지
async function detectObjects(imageElement) {
  // COCO-SSD 모델 로드
  const model = await cocoSsd.load();

  // 이미지에서 객체 탐지 실행
  const predictions = await model.detect(imageElement);

  // 탐지된 각 객체의 정보 출력
  predictions.forEach(pred => {
    const [x, y, width, height] = pred.bbox;
    console.log(`${pred.class}: ${(pred.score * 100).toFixed(1)}%`);
    console.log(`위치: (${x.toFixed(0)}, ${y.toFixed(0)}), 크기: ${width.toFixed(0)}x${height.toFixed(0)}`);
  });

  return predictions;
}

박시니어 씨가 김개발 씨 옆에 앉았습니다. "이미지 분류와 객체 탐지의 차이를 설명해줄게요." 쉽게 비유하자면, 이미지 분류는 마치 친구에게 "이 사진에 뭐가 찍혀 있어?"라고 물어서 "고양이"라는 대답을 듣는 것과 같습니다.

반면 객체 탐지는 "어디에 뭐가 있어?"라고 물어서 "왼쪽에 고양이가 있고, 오른쪽에는 화분이 있어"라는 대답을 듣는 것입니다. COCO-SSD는 마이크로소프트에서 공개한 COCO 데이터셋으로 학습된 모델입니다.

SSD는 Single Shot Detector의 약자로, 한 번의 분석으로 여러 객체를 동시에 탐지할 수 있다는 뜻입니다. 왜 객체 탐지가 필요할까요?

실제 서비스에서는 사진 속에 여러 물체가 함께 있는 경우가 대부분입니다. 예를 들어 자율주행 자동차는 도로 위의 차량, 보행자, 신호등을 모두 인식하고 각각의 위치를 파악해야 합니다.

단순히 "도로에 차가 있다"는 정보만으로는 충분하지 않습니다. 코드에서 주목할 부분은 bbox 속성입니다.

이것은 bounding box의 약자로, 탐지된 객체를 둘러싼 사각형의 좌표를 의미합니다. 배열의 형태는 **[x, y, width, height]**입니다.

x와 y는 사각형의 왼쪽 상단 꼭짓점 좌표이고, width와 height는 사각형의 크기입니다. score 속성은 모델이 얼마나 확신하는지를 나타내는 신뢰도입니다.

0에서 1 사이의 값으로, 1에 가까울수록 해당 객체가 맞을 확률이 높습니다. 실무에서는 신뢰도 임계값을 설정하여 너무 낮은 확률의 결과는 필터링하는 것이 일반적입니다.

예를 들어 50% 미만의 결과는 무시하도록 설정할 수 있습니다. 김개발 씨가 질문했습니다.

"MobileNet과 COCO-SSD 중에 뭘 써야 하나요?" 박시니어 씨가 대답했습니다. "용도에 따라 다릅니다.

단순히 사진이 어떤 종류인지만 알면 되면 MobileNet을, 위치까지 알아야 하면 COCO-SSD를 사용하세요."

실전 팁

💡 - COCO-SSD는 사람, 자동차, 동물 등 일상적인 80가지 객체를 인식합니다.

  • 신뢰도가 낮은 결과는 **predictions.filter(p => p.score > 0.5)**로 필터링하세요.

3. 이미지 전처리

김개발 씨가 만든 이미지 분류 기능이 이상하게 동작했습니다. 어떤 사진은 잘 인식되는데, 어떤 사진은 전혀 엉뚱한 결과가 나왔습니다.

박시니어 씨가 원인을 찾아보더니 말했습니다. "이미지 전처리를 안 해서 그래요.

모델에게 맞는 형태로 이미지를 가공해줘야 합니다."

이미지 전처리는 모델이 더 정확하게 분석할 수 있도록 원본 이미지를 가공하는 과정입니다. 마치 요리사가 재료를 손질하는 것처럼, 이미지도 모델에 입력하기 전에 크기 조정, 정규화, 색상 변환 등의 과정을 거칩니다.

제대로 된 전처리는 인식 정확도를 크게 향상시킵니다.

다음 코드를 살펴봅시다.

// TensorFlow.js를 사용한 이미지 전처리
function preprocessImage(imageElement) {
  return tf.tidy(() => {
    // 이미지를 텐서로 변환
    let tensor = tf.browser.fromPixels(imageElement);

    // 모델 입력 크기에 맞게 리사이즈 (224x224)
    tensor = tf.image.resizeBilinear(tensor, [224, 224]);

    // 픽셀값을 0~1 범위로 정규화
    tensor = tensor.div(255.0);

    // 배치 차원 추가 (모델 입력 형태: [batch, height, width, channels])
    tensor = tensor.expandDims(0);

    return tensor;
  });
}

왜 이미지 전처리가 필요할까요? 박시니어 씨가 비유를 들어 설명했습니다.

"외국어 번역기를 생각해보세요. 영어 번역기에 한글을 입력하면 제대로 동작하지 않겠죠?

마찬가지로 머신러닝 모델도 특정 형태의 입력을 기대합니다." MobileNet 모델은 224x224 크기의 이미지를 입력받도록 학습되었습니다. 따라서 다른 크기의 이미지를 그대로 넣으면 정확도가 떨어질 수 있습니다.

resizeBilinear 함수는 이미지를 부드럽게 확대하거나 축소해줍니다. 정규화는 또 다른 중요한 단계입니다.

원본 이미지의 픽셀값은 0부터 255 사이입니다. 하지만 신경망은 0부터 1 사이의 작은 값으로 계산할 때 더 안정적으로 동작합니다.

따라서 모든 픽셀값을 255로 나누어 정규화합니다. 코드에서 tf.tidy() 함수가 눈에 띕니다.

이것은 메모리 관리를 위한 함수입니다. 텐서 연산 과정에서 생성되는 중간 결과물들이 메모리에 쌓이는 것을 방지합니다.

마치 요리 후에 설거지를 하는 것과 같습니다. **expandDims(0)**은 배치 차원을 추가합니다.

대부분의 딥러닝 모델은 여러 이미지를 동시에 처리할 수 있도록 설계되었습니다. 따라서 단일 이미지를 처리할 때도 "1개짜리 배치"라는 형태로 입력해야 합니다.

김개발 씨가 깨달음을 얻었습니다. "아, 그래서 어떤 사진은 잘 되고 어떤 사진은 안 됐군요!" 박시니어 씨가 고개를 끄덕였습니다.

"맞아요. 특히 아주 작은 이미지나 아주 큰 이미지는 전처리 없이 그대로 넣으면 결과가 부정확해질 수 있어요." 주의할 점으로, 일부 모델은 -1에서 1 사이의 값을 기대하기도 합니다.

사용하는 모델의 문서를 꼭 확인하세요.

실전 팁

💡 - tf.tidy()로 메모리 누수를 방지하세요. 특히 반복 처리 시 필수입니다.

  • 모델마다 요구하는 입력 크기와 정규화 방식이 다르므로 문서를 확인하세요.

4. Canvas와 연동

객체 탐지는 성공적으로 구현되었지만, 결과를 어떻게 보여줄지가 고민이었습니다. 콘솔에 좌표만 출력되니 사용자 입장에서는 직관적이지 않았습니다.

"Canvas를 사용해서 원본 이미지 위에 결과를 그려보는 건 어때요?" 박시니어 씨의 제안에 김개발 씨는 눈이 빛났습니다.

Canvas는 웹 브라우저에서 그래픽을 그릴 수 있는 HTML 요소입니다. 객체 탐지 결과를 Canvas와 연동하면, 원본 이미지 위에 탐지된 객체의 위치를 시각적으로 표시할 수 있습니다.

이를 통해 사용자는 어떤 객체가 어디에서 인식되었는지 한눈에 파악할 수 있습니다.

다음 코드를 살펴봅시다.

// 이미지를 Canvas에 그리고 탐지 준비
function setupCanvas(imageElement, canvasElement) {
  const ctx = canvasElement.getContext('2d');

  // Canvas 크기를 이미지에 맞춤
  canvasElement.width = imageElement.naturalWidth;
  canvasElement.height = imageElement.naturalHeight;

  // 이미지를 Canvas에 그리기
  ctx.drawImage(imageElement, 0, 0);

  return ctx;
}

// Canvas에서 이미지 데이터 추출
function getImageFromCanvas(canvasElement) {
  return tf.browser.fromPixels(canvasElement);
}

Canvas는 웹 개발자에게 빈 도화지와 같습니다. 원하는 그림을 그릴 수 있고, 이미지를 불러와 편집할 수도 있습니다.

객체 탐지 결과를 시각화하기에 완벽한 도구입니다. 먼저 getContext('2d') 메서드로 2D 렌더링 컨텍스트를 얻습니다.

이 컨텍스트가 실제로 그림을 그리는 도구입니다. 마치 붓을 손에 쥐는 것과 같습니다.

Canvas의 크기 설정은 중요한 부분입니다. naturalWidthnaturalHeight는 이미지의 원본 크기입니다.

CSS로 이미지가 화면에 작게 보이더라도, 원본 크기로 Canvas를 설정해야 탐지 좌표가 정확하게 일치합니다. drawImage() 함수는 이미지를 Canvas에 그립니다.

첫 번째 인자는 그릴 이미지, 두 번째와 세 번째 인자는 그릴 위치(x, y)입니다. (0, 0)은 왼쪽 상단 꼭짓점을 의미합니다.

TensorFlow.js의 tf.browser.fromPixels() 함수는 Canvas의 픽셀 데이터를 텐서로 변환합니다. 이렇게 하면 Canvas에 그려진 이미지를 바로 모델에 입력할 수 있습니다.

김개발 씨가 질문했습니다. "이미지 요소를 직접 모델에 넣을 수 있는데, 왜 Canvas를 거쳐야 하나요?" 박시니어 씨가 대답했습니다.

"몇 가지 이유가 있어요. 첫째, 웹캠 영상은 Canvas를 통해 프레임을 캡처해야 합니다.

둘째, 결과를 시각화하려면 어차피 Canvas가 필요합니다. 셋째, 이미지를 편집하거나 필터를 적용할 때도 Canvas가 유용해요." 실무에서는 원본 이미지용 Canvas와 결과 표시용 Canvas를 분리하기도 합니다.

이렇게 하면 원본을 보존하면서 다양한 시각화를 실험할 수 있습니다.

실전 팁

💡 - Canvas 크기는 반드시 이미지 원본 크기에 맞추세요. 그렇지 않으면 좌표가 어긋납니다.

  • willReadFrequently 옵션을 설정하면 픽셀 읽기 성능이 향상됩니다.

5. 실시간 웹캠 처리

정적 이미지에서의 객체 탐지가 완성되자, 팀장님이 새로운 아이디어를 냈습니다. "실시간으로 웹캠 영상에서 객체를 탐지하면 더 인상적이지 않을까요?" 김개발 씨는 긴장되면서도 설레었습니다.

실시간 처리는 어떻게 구현하는 걸까요?

실시간 웹캠 처리는 카메라에서 들어오는 영상을 프레임 단위로 분석하는 기술입니다. 마치 CCTV 관제 시스템처럼, 매 순간 화면을 분석하여 즉각적인 피드백을 제공합니다.

requestAnimationFrame을 활용하면 부드러운 실시간 탐지가 가능합니다.

다음 코드를 살펴봅시다.

// 웹캠 스트림 시작
async function startWebcam(videoElement) {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: { width: 640, height: 480 }
  });
  videoElement.srcObject = stream;
  await videoElement.play();
}

// 실시간 객체 탐지 루프
async function detectLoop(videoElement, model, canvasElement) {
  const ctx = canvasElement.getContext('2d');

  async function detect() {
    ctx.drawImage(videoElement, 0, 0);
    const predictions = await model.detect(videoElement);
    drawBoundingBoxes(ctx, predictions);
    requestAnimationFrame(detect);
  }
  detect();
}

실시간 웹캠 처리는 세 단계로 이루어집니다. 웹캠 접근, 프레임 캡처, 그리고 반복 탐지입니다.

**navigator.mediaDevices.getUserMedia()**는 사용자의 카메라에 접근하는 웹 API입니다. 이 API를 호출하면 브라우저가 사용자에게 카메라 사용 권한을 요청합니다.

마치 앱이 "카메라에 접근해도 될까요?"라고 묻는 것과 같습니다. 반환된 stream을 video 요소의 srcObject에 연결하면, 웹캠 영상이 화면에 표시됩니다.

이제 video 요소는 실시간으로 업데이트되는 이미지 소스가 됩니다. 핵심은 requestAnimationFrame입니다.

이 함수는 브라우저에게 "다음 화면을 그리기 전에 이 함수를 실행해주세요"라고 요청합니다. 함수 내에서 다시 requestAnimationFrame을 호출하면, 끊임없이 반복되는 루프가 만들어집니다.

보통 초당 60번 정도 실행됩니다. 하지만 주의할 점이 있습니다.

객체 탐지는 시간이 걸리는 작업입니다. 매 프레임마다 탐지를 시도하면 이전 탐지가 끝나기도 전에 새로운 탐지가 시작될 수 있습니다.

박시니어 씨가 조언했습니다. "탐지 속도에 따라 프레임을 건너뛰는 로직을 추가하는 게 좋아요.

또는 탐지 완료 후에 다음 프레임을 요청하는 방식으로 구현할 수도 있습니다." 위 코드에서 detect 함수는 async 함수입니다. **await model.detect()**가 완료된 후에 다음 requestAnimationFrame이 호출되므로, 탐지가 겹치지 않습니다.

실무에서는 탐지 주기를 조절하기도 합니다. 예를 들어 100ms마다 한 번씩만 탐지하고, 나머지 시간에는 이전 결과를 표시하는 방식입니다.

실전 팁

💡 - 웹캠 사용 시 HTTPS가 필수입니다. localhost는 예외적으로 HTTP에서도 동작합니다.

  • 탐지 성능에 따라 비디오 해상도를 조절하세요. 해상도가 높을수록 정확하지만 느려집니다.
  • 모바일 기기에서는 facingMode: 'environment' 옵션으로 후면 카메라를 사용할 수 있습니다.

6. 결과 바운딩 박스 표시

모든 기능이 완성되어 가고 있었습니다. 이제 마지막 단계입니다.

탐지된 객체를 사용자가 직관적으로 알아볼 수 있도록, 화면에 바운딩 박스를 그려야 합니다. 박시니어 씨가 말했습니다.

"사각형만 그리면 밋밋하니까, 레이블도 함께 표시해봐요."

바운딩 박스는 탐지된 객체를 둘러싸는 사각형입니다. Canvas의 그리기 API를 활용하면 객체의 위치와 종류를 시각적으로 표현할 수 있습니다.

색상과 레이블을 적절히 활용하면 여러 객체도 한눈에 구분됩니다.

다음 코드를 살펴봅시다.

// 탐지 결과를 바운딩 박스로 시각화
function drawBoundingBoxes(ctx, predictions) {
  predictions.forEach(pred => {
    const [x, y, width, height] = pred.bbox;

    // 바운딩 박스 스타일 설정
    ctx.strokeStyle = '#00FF00';
    ctx.lineWidth = 3;
    ctx.strokeRect(x, y, width, height);

    // 레이블 배경 그리기
    ctx.fillStyle = '#00FF00';
    const label = `${pred.class} ${(pred.score * 100).toFixed(0)}%`;
    ctx.fillRect(x, y - 25, ctx.measureText(label).width + 10, 25);

    // 레이블 텍스트 그리기
    ctx.fillStyle = '#000000';
    ctx.font = '18px Arial';
    ctx.fillText(label, x + 5, y - 7);
  });
}

바운딩 박스 시각화는 객체 탐지의 꽃입니다. 아무리 정확한 탐지도 보여주지 못하면 의미가 없습니다.

Canvas의 strokeRect 함수로 사각형 테두리를 그립니다. strokeStyle은 테두리 색상, lineWidth는 테두리 두께입니다.

밝은 녹색(#00FF00)은 대부분의 배경에서 잘 보이는 색상입니다. 레이블을 표시할 때는 두 단계가 필요합니다.

먼저 fillRect로 배경 사각형을 그리고, 그 위에 fillText로 텍스트를 씁니다. 배경 없이 텍스트만 쓰면 이미지에 따라 글씨가 안 보일 수 있습니다.

ctx.measureText(label).width는 텍스트의 픽셀 너비를 계산합니다. 이것을 활용하면 텍스트 길이에 맞는 배경 사각형을 그릴 수 있습니다.

고정된 너비를 사용하면 짧은 레이블에는 배경이 너무 길고, 긴 레이블은 잘릴 수 있습니다. 김개발 씨가 완성된 결과물을 보며 뿌듯해했습니다.

웹캠에 손을 흔들자 "person 95%"라는 레이블이 따라다녔습니다. 커피잔을 들자 "cup 87%"도 인식되었습니다.

팀장님이 다가와 화면을 보더니 감탄했습니다. "오, 실시간으로 이렇게 잘 동작하다니!

김개발 씨 실력이 많이 늘었네요." 박시니어 씨가 마지막 조언을 덧붙였습니다. "실제 서비스에서는 객체 종류별로 다른 색상을 사용하면 더 좋아요.

또 너무 많은 객체가 탐지되면 화면이 복잡해지니까, 상위 N개만 표시하는 것도 고려해보세요." 이렇게 해서 김개발 씨의 이미지 분류와 객체 탐지 프로젝트가 완성되었습니다. TensorFlow.js 덕분에 복잡한 인공지능 기술도 웹 브라우저에서 손쉽게 구현할 수 있었습니다.

여러분도 이 기술을 활용하여 다양한 프로젝트에 도전해보세요.

실전 팁

💡 - 객체 종류별로 다른 색상을 지정하면 구분이 쉬워집니다.

  • 바운딩 박스가 너무 많으면 신뢰도 상위 5개만 표시하는 것도 좋은 방법입니다.
  • 반투명 배경(rgba)을 사용하면 더 세련된 시각화가 가능합니다.

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#JavaScript#TensorFlow.js#ImageClassification#ObjectDetection#ComputerVision#AI,ML,JavaScript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.