πŸ”” ν‘Έμ‹œ μ•Œλ¦Ό κ΅¬ν˜„ν•˜κΈ°

YeonnΒ·2025λ…„ 3μ›” 15일
0

κΈ°λŠ₯ κ΅¬ν˜„ν•˜κΈ°

λͺ©λ‘ 보기
9/10
post-thumbnail

🎈 ν‘Έμ‹œ μ•Œλ¦Όμ˜ ν•„μš”μ„±

μ‚¬μš©μžμ˜ μœ„μΉ˜λ₯Ό 기반 으둜 μ£Όλ³€ μ‚¬μš©μžμ™€ μ†Œν†΅ ν•  수 μžˆλ‹€λŠ” νŠΉμ§•μ„ κ°€μ§„ 캠핑핑을 λ§Œλ“€λ©΄μ„œ,
μ£Όλ³€ μ‚¬μš©μžμ™€ μ†Œν†΅μ„ μœ„ν•΄μ„œλŠ” μ‚¬μš©μžκ°€ μ›Ή 앱에 접속해 μžˆμ§€ μ•Šλ”λΌλ„
λ©”μ‹œμ§€μ— λŒ€ν•œ μ•Œλ¦Όμ„ 받을 수 μžˆμ–΄μ•Ό μ›ν™œν•œ μ†Œν†΅μ΄ κ°€λŠ₯ν•˜λ‹€κ³  생각이 λ“€μ—ˆλ‹€.
κ·Έλž˜μ„œ μ˜€ν”„λΌμΈ μ‹œμ—λ„ ν‘Έμ‹œ μ•Œλ¦Όμ„ 받을 수 있으면 μ’‹κ² λ‹€κ³  μƒκ°ν–ˆλ‹€.

뿐만 μ•„λ‹ˆλΌ, ν‘Έμ‹œ μ•Œλ¦Ό κΈ°λŠ₯을 ν™œμš©ν•˜κ²Œ 되면 μΆ”ν›„ μ—…λ°μ΄νŠΈλ‚˜ 이벀트 λ“±μ˜ μ†Œμ‹μ„ μ¦‰μ‹œ μ „λ‹¬ν•˜μ—¬
μ‚¬μš©μžμ—κ²Œ μ‚¬μš©μ„ μœ λ„ν•˜κ³  νŽΈμ˜μ„±μ„ ν–₯상할 수 μžˆμ„ 거라고 μƒκ°λ˜μ—ˆλ‹€.

❗️ ν‘Έμ‹œ μ•Œλ¦Ό κ΅¬ν˜„ν•˜κΈ°

πŸ“ μ„œλΉ„μŠ€ μ›Œμ»€ λ“±λ‘ν•˜κΈ°

PWA μ—μ„œ ν‘Έμ‹œ μ•Œλ¦Όμ„ μ‚¬μš©ν•˜λ €λ©΄ μ„œλΉ„μŠ€ μ›Œμ»€( Service Worker )λ₯Ό 등둝해야 ν•œλ‹€. μ„œλΉ„μŠ€ μ›Œμ»€λ₯Ό λ“±λ‘ν•˜λ©΄ λ°±κ·ΈλΌμš΄λ“œμ—μ„œ μ‹€ν–‰λ˜λ©° ν‘Έμ‹œ λ©”μ‹œμ§€λ₯Ό μ²˜λ¦¬ν•  수 μžˆλ‹€. μ˜€ν”„λΌμΈ ν™˜κ²½μ—μ„œλ„ 앱이 λ™μž‘ν•  수 μžˆλ„λ‘ μ„€μ •ν•˜κΈ° μœ„ν•΄μ„œ next-pwa 라이브러리 λ₯Ό ν™œμš©ν–ˆλ‹€.

πŸ“ƒ PWA κ΄€λ ¨ ν¬μŠ€νŒ…

  const registerServiceWorker = async () => {
    try {
      const registration =
        await navigator.serviceWorker.register('/service-worker.js');
      console.log('μ„œλΉ„μŠ€ μ›Œμ»€ 등둝 성곡:', registration);
    } catch (error) {
      console.error('μ„œλΉ„μŠ€ μ›Œμ»€ 등둝 μ‹€νŒ¨:', error);
    }
  };

μ„œλΉ„μŠ€ μ›Œμ»€λŠ” λ°±κ·ΈλΌμš΄λ“œμ—μ„œ μ‹€ν–‰λ˜λ©° ν‘Έμ‹œ λ©”μ‹œμ§€λ₯Ό μ²˜λ¦¬ν•œλ‹€. μ„œλΉ„μŠ€ μ›Œμ»€λ₯Ό λ“±λ‘ν•΄μ„œ ν‘Έμ‹œ μ•Œλ¦Όμ„ 받을 μ€€λΉ„λ₯Ό ν•œλ‹€.

πŸ“ ν‘Έμ‹œ μ•Œλ¦Ό κΆŒν•œ μš”μ²­

μ‚¬μš©μžμ—κ²Œ ν‘Έμ‹œ μ•Œλ¦Ό κΆŒν•œμ„ μš”μ²­ν•œλ‹€.

// app/layout
const requestPushPermission = async () => {
  askPushNotification();
};

// hooks/usePushNotification
import { usePwaStore } from '@/stores/pwaState';
import { userStore } from '@/stores/userState';
import { isPwa } from '@/utils/isPwa';
import { useCallback } from 'react';

export const usePushNotification = () => {
  // 첫 방문인지λ₯Ό ν™•μΈν•˜λŠ” state
  const { isVisited } = userStore();
  const { setIsPwaOpen, setClicked } = usePwaStore();
  const denyPermission = () => {
    setIsPwaOpen(false);
  };

  const askPushNotification = useCallback(async () => {
    // λͺ¨λ‹¬ νƒ€μž…μ„ noti둜 μ„€μ •
    setClicked('noti');

    // PWA ν™˜κ²½μ΄κ³  첫 방문이라면
    if (isPwa() && !isVisited) {
      // 'noti-default': μ•Œλ¦Ό κΆŒν•œ 섀정을 λ¬»λŠ” λͺ¨λ‹¬μ„ λ„μš΄λ‹€.
      setIsPwaOpen(true, 'noti-default');
    }
  }, [isVisited]);

  // μ•Œλ¦Ό κΆŒν•œ μ„€μ • λͺ¨λ‹¬μ—μ„œ μŠΉμΈμ„ λˆ„λ₯Ό 경우 싀행될 ν•¨μˆ˜
  const checkNotificationPermission = useCallback(async () => {
    setClicked('noti');

    // κΆŒν•œμ΄ 'default' 둜 μ„€μ •λ˜μ–΄ μžˆμ„ 경우
    if (Notification.permission === 'default') {
      // κΆŒν•œ 승인 μš”μ²­μ„ ν•œλ‹€.
      const permission = await Notification.requestPermission();

      // 승인된 경우 λͺ¨λ‹¬ λ‹«κΈ°
      if (permission === 'granted') {
        setIsPwaOpen(false);
      }
    } else {
      // κΆŒν•œ 섀정이 λΆˆκ°€λŠ₯ ν•  경우 '직접 μ„€μ •'에 λŒ€ν•œ μ•ˆλ‚΄ λͺ¨λ‹¬ λ„μš°κΈ°
      setIsPwaOpen(false);
      setIsPwaOpen(true, 'noti-unsupported');
    }
  }, [isVisited]);

  return {
    denyPermission,
    askPushNotification,
    checkNotificationPermission,
  };
};

πŸ“ ν‘Έμ‹œ μ•Œλ¦Όμ„ μœ„ν•΄ κ΅¬λ…ν•˜κΈ°

ν‘Έμ‹œ μ•Œλ¦Όμ„ λ°›κΈ° μœ„ν•΄μ„œλŠ” λΈŒλΌμš°μ €μ˜ pushManagerλ₯Ό μ‚¬μš©ν•˜μ—¬ μ„œλ²„μ— ν‘Έμ‹œ ꡬ독 정보λ₯Ό μ €μž₯ν•΄μ•Όν•œλ‹€.

// utils/registerPushNotification.ts
import { api } from '@utils/axios';
import { toast } from 'react-toastify';

const registerPushNotification = async () => {
  if (!('Notification' in window) || !('serviceWorker' in navigator)) {
    toast.warn('πŸ”” ν‘Έμ‹œ μ•Œλ¦Όμ΄ μ§€μ›λ˜μ§€ μ•ŠλŠ” ν™˜κ²½μž…λ‹ˆλ‹€');
    return;
  }

  if ('Notification' in window && 'serviceWorker' in navigator) {
    const permission = await Notification.requestPermission();

    if (permission === 'granted') {
      const registration = await navigator.serviceWorker.ready;
      const pushSubscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlB64ToUint8Array(
          process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
        ),
      });

      try {
        const res = await api.post('/user/subscribe', {
          endpoint: pushSubscription.endpoint,
          expirationTime: pushSubscription.expirationTime,
          keys: {
            p256dh: arrayBufferToBase64(pushSubscription.getKey('p256dh')),
            auth: arrayBufferToBase64(pushSubscription.getKey('auth')),
          },
        });
      } catch (error) {
        console.error(error);
      }
    }
  }
};

const arrayBufferToBase64 = (buffer: ArrayBuffer | null): string => {
  if (!buffer) {
    throw new Error('ArrayBuffer is null or undefined');
  }
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
};

const urlB64ToUint8Array = (base64String: string | undefined): Uint8Array => {
  if (!base64String) {
    throw new Error('Base64 string is required');
  }

  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; i++) {
    outputArray[i] = rawData.charCodeAt(i);
  }

  return outputArray;
};

export default registerPushNotification;

πŸ“ μ„œλΉ„μŠ€ μ›Œμ»€μ—μ„œ ν‘Έμ‹œ 이벀트 처리

이제 service-worker.jsμ—μ„œ ν‘Έμ‹œ 이벀트λ₯Ό μˆ˜μ‹ ν•˜μ—¬ μ•Œλ¦Όμ„ ν‘œμ‹œν•΄λ³΄μž !

// 'push' 이벀트 μ²˜λ¦¬ν•˜κΈ°
self.addEventListener('push', (event) => {
  // data JSON ν˜• λ³€ν™˜
  const data = JSON.parse(event.data.text());

  // ν‘Έμ‹œ μ•Œλ¦Ό 타이틀 뽑기
  const title = data.title;
  // ν‘Έμ‹œ μ•Œλ¦Ό λ‚΄μš©, μ•„μ΄μ½˜, 뱃지 λ“± μ„€μ •
  const options = {
    body: data.body,
    icon: './images/maskable_icon_x192.png',
    badge: 'images/maskable_icon_x128.png',
    data: data.roomId,
  };
});

// 'push μ•Œλ¦Ό 클릭' μ‹œ 이벀트 μ²˜λ¦¬ν•˜κΈ°
self.addEventListener('notificationclick', (event) => {
  // ν‘Έμ‹œ μ•Œλ¦Ό λ‹«κΈ°
  event.notification.close();

  const roomId = event.notification.data;

  event.waitUntil(
    // ν•΄λ‹Ήν•˜λŠ” 창이 μžˆλŠ”μ§€ 확인
    self.clients.matchAll({ type: 'window' }).then((clients) => {
      const client = clients.find((client) =>
        client.url.includes('campingping.com/list')
      );
	  // μžˆλ‹€λ©΄ ν•΄λ‹Ή 창으둜 포컀슀 μ΄λ™ν•˜κ³  μ±„νŒ… μ°½ μ—΄μ–΄μ£ΌκΈ°
      if (client) {
        client.postMessage({ type: 'OPEN_CHAT_MODAL', roomId });
        client.focus();
      // μ—†λ‹€λ©΄ μƒˆλ‘œ λ„μ›Œμ£ΌκΈ°
      } else {
        self.clients.openWindow(`/list`).then((newClient) => {
          if (newClient)
            newClient.postMessage({ type: 'OPEN_CHAT_MODAL', roomId });
        });
      }
    })
  );
});

✨ κ΅¬ν˜„ κ²°κ³Ό

이미 PWAκ°€ λ°±κ·ΈλΌμš΄λ“œμ— μ—΄λ €μžˆμ—ˆμ„ κ²½μš°μ—λŠ” ν•΄λ‹Ή μ±„νŒ…λ°©μœΌλ‘œ μ΄λ™κΉŒμ§€ 잘 λ˜λŠ”λ°,
μ™„μ „νžˆ 앱이 κΊΌμ Έμžˆμ—ˆλ˜ κ²½μš°μ—λŠ” 앱이 μΌœμ§€λŠ” 데 κΉŒμ§€λ§Œ 진행이 λœλ‹€... 😭
μ±„νŒ… 방을 μ—΄μ–΄μ£Όκ³  μ‹Άμ—ˆλŠ”λ° ν•΄λ‹Ή 뢀뢄이 κ΅¬ν˜„μ΄ 잘 μ•ˆλ˜μ–΄μ„œ... κ°œμ„ ν•΄λ‚˜κ°ˆ μ˜ˆμ •μ΄λ‹€.


ν‘Έμ‹œ μ•Œλ¦Όμ„ κ΅¬ν˜„ν•˜λ©΄μ„œ 제일 νž˜λ“€μ—ˆλ˜ 뢀뢄은 λ°”λ‘œ... iOS와 Android μ˜€λ‹€.........!

λ‚˜λŠ” 개인적으둜 λͺ¨λ“  κΈ°κΈ°λ₯Ό λ‹€ iOSλ₯Ό μ‚¬μš©μ€‘μ΄λΌμ„œ.. 사싀 PWA μ„€μΉ˜λΆ€ν„° λ‚œκ΄€μ΄ μžˆμ—ˆλ‹€.
λ§₯λΆμ—μ„œλŠ” μ„€μΉ˜κ°€ 잘 λ˜λŠ”λ° μ•„μ΄ν°μ—μ„œλŠ” μ„€μΉ˜κ°€ μ•ˆλ˜μ—ˆλ˜ 것!
κ·Έλž˜μ„œ μœˆλ„μš° ν…ŒμŠ€νŠΈλ„ ν•΄λ΄€λŠ”λ° μœˆλ„μš°λŠ” μ„€μΉ˜κ°€ μž˜λ˜λŠ” 것!!!

ꡬ글링을 톡해 iOSλŠ” 이벀트 λ―Έμ§€μ›μœΌλ‘œ 'ν™ˆ 화면에 μΆ”κ°€' λ²„νŠΌμ„ 톡해 κ°€λŠ₯ν•˜λ‹€λŠ” 것을 ν™•μΈν•˜κ³ ...
그것도 'safari'λ₯Ό ν†΅ν•΄μ„œλ§Œ...

μ•„.... μ™ μ§€ κ³ λ‚œμ΄ μžˆμ„ κ±° κ°™μ•„...! λΌλŠ” 생각이 λ“€μ—ˆλ‹€.

κ·Έλ ‡κ²Œ ν‘Έμ‹œ μ•Œλ¦Όμ„ κ΅¬ν˜„ν•˜λŠ”λ° 아무리해도 μ±„νŒ…λ„ μ–΄ν”Œμ„ μž μ‹œ λ‚΄λ €λ†“μ•˜λ‹€κ°€ 켜면 잘 μ•ˆμ˜€λŠ” κ±° κ°™κ³ ,
ν‘Έμ‹œ μ•Œλ¦Όλ„ 잘 μ•ˆμ˜€λŠ” κ±° κ°™κ³ , 그런데 λ§₯λΆμ—μ„œλŠ” 또 μ±„νŒ…μ€ 잘 되고 ν‘Έμ‹œ μ•Œλ¦Όμ€ μ§€μ›μ•ˆν•œλ‹€κ³  ν•˜κ³ ...
지원도 μ• λ§€ν•˜κ³  이유λ₯Ό μ•Œ 수 μ—†λŠ” 'λ‚΄ 아이폰 이상해 😭' 상황이 κ³„μ†λ˜μ—ˆλ‹€.
심지어 μΉœκ΅¬λž‘ λ‚˜λž€νžˆ μ•‰μ•„μ„œ ν…ŒμŠ€νŠΈ ν•˜λŠ”λ° 친ꡬ 아이폰은 되고 λ‚΄ 아이폰은 μ•ˆλ˜λŠ” κ²½μš°λ„ μžˆμ—ˆλ‹€...

κ·Έλ ‡κ²Œ 슀트레슀 λ°›λ‹€κ°€ μ„€λ§ˆ... ν•˜λŠ” 마음으둜 μœˆλ„μš° ν…ŒμŠ€νŠΈλ₯Ό ν–ˆλŠ”λ° μœˆλ„μš°λŠ” ν‘Έμ‹œμ•Œλ¦Όλ„ μ±„νŒ…λ„ μž˜λœλ‹€...
κ·Έλž˜μ„œ μ„€λ§ˆ... ν•˜λŠ” 마음으둜 κ°€μ‘±λ“€ν•œν…Œ μ•ˆμ“°λŠ” κ°€λŸ­μ‹œκ°€ μžˆλŠ” μ§€ λ¬Όμ–΄μ„œ λ°›μ•„μ™”λŠ”λ° ν‘Έμ‹œ μ•Œλ¦Όμ΄ λ„ˆλ¬΄ 잘 μ˜€λŠ”κ±° 😦

κ·Έλž˜μ„œ μ°Ύμ•„λ³΄λ‹ˆ...

πŸ“ iOS ν‘Έμ‹œ μ•Œλ¦Ό κ΅¬ν˜„ 문제 상황

  • λ°±κ·ΈλΌμš΄λ“œμ—μ„œ WebSocket μœ μ§€κ°€ μ•ˆλ¨: 일정 μ‹œκ°„ ν›„ μ—°κ²° ν•΄μ œ
  • Wi-Fi -> LTE μ „ν™˜ μ‹œ WebSocket λŠκΉ€
  • ν‘Έμ‹œ μ•Œλ¦Όμ΄ 'μ¦‰μ‹œ' 도착 ν•˜μ§€ μ•Šμ„ 수 있음: μ„œλ²„μ—μ„œ μΆ”κ°€ μ„€μ • ν•„μš”
  • ν‘Έμ‹œ ꡬ독 μžλ™ 만료 κ°€λŠ₯성이 μžˆμ–΄μ„œ 'μž¬λ“±λ‘' ν•„μš”
  • 배터리 μ ˆμ•½ λͺ¨λ“œμ—μ„œ ν‘Έμ‹œ μ•Œλ¦Ό 차단 κ°€λŠ₯μ„± λ•Œλ¬Έμ— μ„œλ²„μ—μ„œ μΆ”κ°€ μ„€μ • ν•„μš”

??????????????????????????????????????????????????????????????

ꡉμž₯히 κΉŒλ‹€λ‘œμš΄ μΉœκ΅¬μ˜€λ˜ 것이닀 !

iOSμ—μ„œλ„ 잘 λ™μž‘ν•˜κΈΈ 바라며 λͺ‡κ°€μ§€ μ°Ύμ•„μ„œ 더 μΆ”κ°€ν•œ 보완 μ½”λ“œλ“€...

πŸ₯² socket μœ μ§€κ°€ λŠμ–΄μ§ˆ 경우 μž¬μ—°κ²° ν•˜κΈ°

export const socket = io(CHAT_URL, {
  withCredentials: true,
  reconnection: true,
  reconnectionAttempts: 5,
  reconnectionDelay: 2000,
  timeout: 5000,
  transports: ['websocket', 'polling'],
});

πŸ₯² ꡬ독 μ‹€νŒ¨ μ‹œ μž¬μ‹œλ„ ν•˜κΈ°

 const initPushNotification = async () => {
      while (attempt < MAX_RETRIES) {
        try {
          await registerPushNotification();
          console.log(`Push Notification 등둝 성곡 (μ‹œλ„ ${attempt + 1})`);
          return;
        } catch (error) {
          attempt += 1;
          console.error(
            `Push Notification 등둝 μ‹€νŒ¨ (μ‹œλ„ ${attempt}):`,
            error
          );

          if (attempt >= MAX_RETRIES) {
            toast.error('ν‘Έμ‹œ μ•Œλ¦Ό 등둝에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.');
          } else {
            await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
          }
        }
      }
    };

    initPushNotification();
  }, [userState]);



κ·Έλž˜μ„œ 일단 μŠ¬ν”„κ³  νž˜λ“€μ—ˆλ˜ 마음과 ν•¨κ»˜ μ •λ¦¬ν•˜λŠ”

πŸ’‘ iOS와 Android 의 μ£Όμš” 차이점

κΈ°λŠ₯Android( Chrome, Edge λ“± )iOS( Safari )
PWA μ„€μΉ˜μžλ™ μ„€μΉ˜ κ°€λŠ₯( beforeinstallprompt 지원)직접 ν™ˆ ν™”λ©΄ μΆ”κ°€ ν•„μš”( μžλ™ μœ λ„ X )
λ°±κ·ΈλΌμš΄λ“œ λ™μž‘Background Sync API μ§€μ›λ°±κ·ΈλΌμš΄λ“œμ—μ„œ μž‘μ—… μ œν•œ
둜컬 μ €μž₯μ†ŒPersistent Storage API 지원저μž₯μ†Œ μžλ™ μ‚­μ œ κ°€λŠ₯
μ˜€ν”„λΌμΈ λͺ¨λ“œCache API ν™œμš© κ°€λŠ₯μ˜€ν”„λΌμΈ 캐싱 λΆˆμ™„μ „

πŸ‘€ λŠλ‚€ 점

λΈŒλΌμš°μ € λ³„λ‘œ μ‹œμŠ€ν…œ λ³„λ‘œ λ™μž‘ν•˜λŠ” 방식도 μ§€μ›ν•˜λŠ” μ΄λ²€νŠΈλ‚˜ 방식도 λ‹€λ₯Έ 점이 생각보닀 λ§Žμ•˜λ‹€.
μƒˆλ‘œμš΄ 것듀을 μ‹œλ„ν•˜λ‹€ λ³΄λ‹ˆ μ „ν˜€ μ˜ˆμƒν•˜μ§€ λͺ»ν•œ κ³³μ—μ„œ λ¬Έμ œλ“€μ΄ λ°œμƒν–ˆλŠ”λ°,
λ¬Όλ‘  μ œλŒ€λ‘œ κ΅¬ν˜„μ„ ν•˜μ§€ λͺ»ν•œ λΆ€λΆ„λ“€μ΄λ‚˜ 이해λ₯Ό λͺ»ν•œ 뢀뢄도 μžˆμ—ˆμ§€λ§Œ
κ΅¬ν˜„μ„ 해놓고도 μ§€μ›ν•˜μ§€ μ•ŠλŠ”λ‹€λŠ” 것을 λͺ°λΌμ„œ ν•œμ°Έ μ—‰λš±ν•œ κ²ƒλ§Œ κ΅¬κΈ€λ§ν•˜λ©΄μ„œ
μ™œ 같은 μ½”λ“œ 같은데 λ‚˜λ§Œ μ•ˆλ˜λŠ”κ±°μ§€.. ν•˜λŠ” μ‹œκ°„λ„ λ³΄λƒˆλ‹€.

λΈŒλΌμš°μ € κ°„μ˜ μ°¨μ΄λ‚˜ μ‹œμŠ€ν…œ κ°„μ˜ μ£Όμš” 차이점 같은 것듀도 κΌ­ 곡뢀해봐야겠닀.

그리고 iOS μ—μ„œ μΆ”κ°€μ μœΌλ‘œ ν•΄κ²°ν•  수 μžˆλŠ” 방법이 μžˆλŠ” μ§€ μ’€ 더 μ°Ύμ•„λ³Ό μ˜ˆμ •μ΄λ‹€ !


3/19일 test μΆ”κ°€ λ‚΄μš©

  • Androidμ—μ„œλ„ μ˜€ν”„λΌμΈμ΄ μ§€μ†λ˜λ©΄ ν‘Έμ‹œ μ•Œλ¦Όμ΄ μ˜€μ§€ μ•ŠλŠ”λ‹€ !
  • λ„€νŠΈμ›Œν¬ μ—°κ²° μ‹œ λˆ„λ½λœ λ©”μ‹œμ§€κ°€ 확인 κ°€λŠ₯ν•˜λ„λ‘ ν‘Έμ‹œ μ•Œλ¦Όμ΄ ν•œλ²ˆμ— μ˜¨λ‹€.

0개의 λŒ“κΈ€