Skip to content

๐Ÿ†˜ WebPush ๊ตฌํ˜„

sunghwki edited this page Dec 4, 2024 · 4 revisions
๋ถ„์•ผ ์ž‘์„ฑ์ž ์ž‘์„ฑ์ผ
BE ๋ฌธ์„ค๋ฏผ, ๊น€์„ฑํ™˜ 24๋…„ 11์›” 25์ผ

์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์€ ์‚ฌ์šฉ์ž๊ฐ€ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ƒํƒœ์—์„œ๋„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์•Œ๋ฆผ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ๊ธฐ์ˆ ์ž…๋‹ˆ๋‹ค. ์ด ๋ฌธ์„œ์—์„œ๋Š” ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์ „์ฒด์ ์ธ ๊ตฌ์กฐ์™€ ์„œ๋ฒ„ ๋ฐ ํด๋ผ์ด์–ธํŠธ ์ธก ๊ตฌํ˜„ ๋ฐฉ๋ฒ•์„ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

๊ตฌ์กฐ

  1. ์‚ฌ์šฉ์ž: ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์ ‘์†ํ•˜์—ฌ ํ‘ธ์‹œ ์•Œ๋ฆผ ์ˆ˜์‹ ์— ๋™์˜ํ•ฉ๋‹ˆ๋‹ค.
  2. ํด๋ผ์ด์–ธํŠธ: ์„œ๋น„์Šค ์›Œ์ปค๋ฅผ ๋“ฑ๋กํ•˜๊ณ  ํ‘ธ์‹œ ๊ตฌ๋…์„ ์ƒ์„ฑํ•˜์—ฌ ์„œ๋ฒ„์— ์ „์†กํ•ฉ๋‹ˆ๋‹ค.
  3. ์„œ๋ฒ„: ๊ตฌ๋… ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๊ณ , ์•Œ๋ฆผ ์กฐ๊ฑด์ด ๋งŒ์กฑ๋˜๋ฉด web-push ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ‘ธ์‹œ ๋ฉ”์‹œ์ง€๋ฅผ ์ „์†กํ•ฉ๋‹ˆ๋‹ค.
  4. ํ‘ธ์‹œ ์„œ๋น„์Šค: ๋ธŒ๋ผ์šฐ์ € ๊ณต๊ธ‰์ž๊ฐ€ ์ œ๊ณตํ•˜๋Š” ํ‘ธ์‹œ ์„œ๋น„์Šค๋กœ, ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
  5. ์„œ๋น„์Šค ์›Œ์ปค: ํด๋ผ์ด์–ธํŠธ ์ธก์—์„œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋™์ž‘ํ•˜๋ฉฐ, ํ‘ธ์‹œ ๋ฉ”์‹œ์ง€๋ฅผ ์ˆ˜์‹ ํ•˜๊ณ  ์•Œ๋ฆผ์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

์„œ๋ฒ„

์„œ๋ฒ„ ์ธก์—์„œ๋Š” ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ๊ด€๋ฆฌํ•˜๊ณ  ์ „์†กํ•˜๋Š” ์—ญํ• ์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค.

์ฃผ์š” ๊ตฌ์„ฑ ์š”์†Œ

  • PushSubscription ์—”ํ‹ฐํ‹ฐ: ์‚ฌ์šฉ์ž์˜ ํ‘ธ์‹œ ๊ตฌ๋… ์ •๋ณด๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • PushService: web-push ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ์ „์†กํ•ฉ๋‹ˆ๋‹ค.
  • PushController: ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ๊ตฌ๋… ์ •๋ณด๋ฅผ ๋ฐ›์•„ ์ €์žฅํ•˜๊ณ  ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
  • AlarmService: ์•Œ๋ฆผ ์กฐ๊ฑด์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ๊ฒ€์‚ฌํ•˜๊ณ , ์กฐ๊ฑด์ด ๋งŒ์กฑ๋˜๋ฉด ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ์ „์†กํ•ฉ๋‹ˆ๋‹ค.
  • ์Šค์ผ€์ค„๋Ÿฌ: @nestjs/schedule ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์•Œ๋ฆผ ์กฐ๊ฑด์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค.

์„œ๋ฒ„ ๊ตฌํ˜„ ์ƒ์„ธ

  1. VAPID ํ‚ค ์ƒ์„ฑ ๋ฐ ์„ค์ •

    webPush.setVapidDetails(
      'mailto:[email protected]',
      process.env.VAPID_PUBLIC_KEY!,
      process.env.VAPID_PRIVATE_KEY!,
    );
    • ์—ฌ๊ธฐ์„œ VAPID๋Š” ์„œ๋ฒ„์—์„œ ์ •์˜ํ•˜๋Š” ํ‚ค๋กœ์จ [RFC 8292](https://datatracker.ietf.org/doc/html/rfc8292) ์ •์˜์— ๋งž์ถฐ์ ธ ์žˆ๋‹ค.
    • VAPID ํ‚ค์˜ ๊ฒฝ์šฐ web-push๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ด์šฉํ•ด ์‰ฝ๊ฒŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.
    const webPush = require('web-push');
    
    const vapidKeys = webPush.generateVAPIDKeys();
    
    console.log('Public Key:', vapidKeys.publicKey);
    console.log('Private Key:', vapidKeys.privateKey);
    /**
    Public Key: BNo....
    Private Key: OqOn...
    */
  2. PushSubscription ์—”ํ‹ฐํ‹ฐ ์ •์˜

    @Entity()
    export class PushSubscription {
      @PrimaryGeneratedColumn()
      id: number;
    
      @ManyToOne(() => User, (user) => user.subscriptions, { onDelete: 'CASCADE' })
      user: User;
    
      @Column({ type: 'text' })
      endpoint: string;
    
      @Column({ type: 'text' })
      p256dh: string;
    
      @Column({ type: 'text' })
      auth: string;
    }
  3. PushService ๊ตฌํ˜„

    @Injectable()
    export class PushService {
      constructor(
        @InjectRepository(PushSubscription)
        private readonly subscriptionRepository: Repository<PushSubscription>,
        private readonly dataSource: DataSource,
      ) {
        // VAPID ํ‚ค ์„ค์ •
      }
    
      async createSubscription(userId: number, subscriptionData: any): Promise<PushSubscription> {
        return await this.dataSource.transaction(async (manager) => {
          const user = new User();
          user.id = userId;
    
          const newSubscription = manager.create(PushSubscription, {
            user: user,
            endpoint: subscriptionData.endpoint,
            p256dh: subscriptionData.keys.p256dh,
            auth: subscriptionData.keys.auth,
          });
    
          return await manager.save(newSubscription);
        });
      }
    
      async sendPushNotification(subscription: PushSubscription, payload: object): Promise<void> {
        // ํ‘ธ์‹œ ์•Œ๋ฆผ ์ „์†ก ๋กœ์ง
      }
    }
  4. PushController ๊ตฌํ˜„

    @Controller('push')
    export class PushController {
      constructor(private readonly pushService: PushService) {}
    
      @UseGuards(AuthGuard('jwt'))
      @Post('subscribe')
      async subscribe(@Body() subscriptionData: any, @Req() request: Request) {
        const userId = (request.user as any).id;
    
        const newSubscription = await this.pushService.createSubscription(userId, subscriptionData);
    
        return { message: 'Subscription saved.', subscriptionId: newSubscription.id };
      }
    }
  5. AlarmService์—์„œ ์•Œ๋ฆผ ์กฐ๊ฑด ๊ฒ€์‚ฌ ๋ฐ ์ „์†ก

    @Injectable()
    export class AlarmService {
      // ...
    
      @Cron('*/5 * * * * *') // ๋งค 5์ดˆ๋งˆ๋‹ค ์‹คํ–‰ (ํ…Œ์ŠคํŠธ์šฉ)
      async checkAlarms() {
        const alarms = await this.alarmRepository.find({
          relations: ['user', 'stock', 'user.subscriptions'],
        });
    
        for (const alarm of alarms) {
          // ์•Œ๋ฆผ ์กฐ๊ฑด ๊ฒ€์‚ฌ ๋กœ์ง
          if (shouldNotify) {
            await this.sendPushNotification(alarm);
            // ์•Œ๋žŒ ์‚ญ์ œ ๋˜๋Š” ์ƒํƒœ ์—…๋ฐ์ดํŠธ
          }
        }
      }
    
      private async sendPushNotification(alarm: Alarm): Promise<void> {
        const payload = {
          title: '์ฃผ์‹ ์•Œ๋ฆผ',
          body: `${alarm.stock.name}: ์กฐ๊ฑด์ด ๋งŒ์กฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`,
        };
    
        for (const subscription of alarm.user.subscriptions) {
          await this.pushService.sendPushNotification(subscription, payload);
        }
      }
    }

ํด๋ผ์ด์–ธํŠธ

ํด๋ผ์ด์–ธํŠธ ์ธก์—์„œ๋Š” ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ์ˆ˜์‹ ํ•˜๊ธฐ ์œ„ํ•œ ์„ค์ •๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ–ฅ์ƒ์‹œํ‚ค๊ธฐ ์œ„ํ•œ ์„ค์น˜ ์•ˆ๋‚ด ๋“ฑ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ณตํ†ต ๊ตฌํ˜„ ์‚ฌํ•ญ

  • ์„œ๋น„์Šค ์›Œ์ปค ๋“ฑ๋ก

    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/service-worker.js')
        .then((registration) => {
          console.log('Service Worker registered:', registration);
        })
        .catch((error) => {
          console.error('Service Worker registration failed:', error);
        });
    }
  • ํ‘ธ์‹œ ๊ตฌ๋… ์ƒ์„ฑ ๋ฐ ์„œ๋ฒ„ ์ „์†ก

    async function subscribeUser() {
      const registration = await navigator.serviceWorker.ready;
    
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
      });
    
      await fetch('/push/subscribe', {
        method: 'POST',
        body: JSON.stringify(subscription),
        headers: {
          'Content-Type': 'application/json'
        }
      });
    }
  • ์„œ๋น„์Šค ์›Œ์ปค์—์„œ ํ‘ธ์‹œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ

    self.addEventListener('push', function(event) {
      const data = event.data.json();
    
      const options = {
        body: data.body,
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
      };
    
      event.waitUntil(
        self.registration.showNotification(data.title, options)
      );
    });

์›น

๋ฐ์Šคํฌํ†ฑ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ์ง€์›ํ•˜๋ฏ€๋กœ ํŠน๋ณ„ํ•œ ์ถ”๊ฐ€ ์ž‘์—… ์—†์ด ๊ณตํ†ต ๊ตฌํ˜„ ์‚ฌํ•ญ์„ ๋”ฐ๋ฅด๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์•ˆ๋“œ๋กœ์ด๋“œ ์›น

์•ˆ๋“œ๋กœ์ด๋“œ์˜ ๋ชจ๋ฐ”์ผ ๋ธŒ๋ผ์šฐ์ €์—์„œ๋Š” ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ๊ณผ PWA(Progressive Web App)๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

  • PWA ์„ค์น˜ ์•ˆ๋‚ด

    let deferredPrompt;
    
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault();
      deferredPrompt = e;
      document.getElementById('install-button').style.display = 'block';
    });
    
    document.getElementById('install-button').addEventListener('click', async () => {
      if (deferredPrompt) {
        deferredPrompt.prompt();
        const { outcome } = await deferredPrompt.userChoice;
        deferredPrompt = null;
      }
    });
  • ๋งค๋‹ˆํŽ˜์ŠคํŠธ ํŒŒ์ผ ์„ค์ •

    {
      "name": "Your App Name",
      "short_name": "App",
      "start_url": "/",
      "display": "standalone",
      "icons": [
        {
          "src": "/icons/icon-192x192.png",
          "sizes": "192x192",
          "type": "image/png"
        },
        {
          "src": "/icons/icon-512x512.png",
          "sizes": "512x512",
          "type": "image/png"
        }
      ]
    }
    

iOS ์›น

iOS์—์„œ๋Š” ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ ์ง€์›์— ์ œํ•œ์ด ์žˆ์œผ๋ฏ€๋กœ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ™ˆ ํ™”๋ฉด์— ์ถ”๊ฐ€ํ•˜๋„๋ก ์•ˆ๋‚ดํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • iOS ์„ค์น˜ ์•ˆ๋‚ด ๋ฐฐ๋„ˆ

    function isIos() {
      const userAgent = window.navigator.userAgent.toLowerCase();
      return /iphone|ipad|ipod/.test(userAgent);
    }
    
    function isInStandaloneMode() {
      return ('standalone' in window.navigator) && (window.navigator.standalone);
    }
    
    if (isIos() && !isInStandaloneMode()) {
      document.getElementById('ios-install-banner').style.display = 'block';
    }
    <div id="ios-install-banner" style="display: none;">
      <p>์ด ์›น ์•ฑ์„ ์„ค์น˜ํ•˜๋ ค๋ฉด Safari์—์„œ ๊ณต์œ  ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ํ›„ "ํ™ˆ ํ™”๋ฉด์— ์ถ”๊ฐ€"๋ฅผ ์„ ํƒํ•˜์„ธ์š”.</p>
    </div>
  • ํ™ˆ ํ™”๋ฉด์— ์ถ”๊ฐ€๋œ ํ›„ ํ‘ธ์‹œ ๊ตฌ๋… ์ƒ์„ฑ

    if (isInStandaloneMode()) {
      subscribeUser();
    }

์ฐธ๊ณ  ์‚ฌํ•ญ

  • HTTPS ์‚ฌ์šฉ: ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ๊ณผ ์„œ๋น„์Šค ์›Œ์ปค๋Š” HTTPS ํ™˜๊ฒฝ์—์„œ๋งŒ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.
  • ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ: ์ตœ์‹  ๋ธŒ๋ผ์šฐ์ €์—์„œ์˜ ์ง€์› ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ์—๋Ÿฌ ์ฒ˜๋ฆฌ: ํ‘ธ์‹œ ์•Œ๋ฆผ ์ „์†ก ์‹คํŒจ ์‹œ ๊ตฌ๋… ์ •๋ณด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๊ฑฐ๋‚˜ ์‚ญ์ œํ•˜๋Š” ๋กœ์ง์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„ : ์„ค์น˜ ์•ˆ๋‚ด ๋ฐ ๊ถŒํ•œ ์š”์ฒญ์„ ์ ์ ˆํ•œ ์‹œ์ ์— ์ œ๊ณตํ•˜์—ฌ ์‚ฌ์šฉ์ž ๋งŒ์กฑ๋„๋ฅผ ๋†’์ž…๋‹ˆ๋‹ค.
  • ๋ณด์•ˆ ๊ฐ•ํ™”: VAPID ํ‚ค ๊ด€๋ฆฌ ๋ฐ ์ธ์ฆ ์ ˆ์ฐจ๋ฅผ ์ฒ ์ €ํžˆ ํ•˜์—ฌ ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ•ฉ๋‹ˆ๋‹ค.

์ฐธ๊ณ  ์ž๋ฃŒ

https://stackoverflow.com/questions/40392257/what-is-vapid-and-why-is-it-useful

https://datatracker.ietf.org/doc/html/rfc8292

https://mozilla-services.github.io/WebPushDataTestPage/

๐Ÿœ ํŒ€ ๊ฐœ๋ฏธ

๐Ÿ›๏ธ ํŒ€ ๋ฌธํ™”

๊ฐœ๋ฐœ ์œ„ํ‚ค

FE

BE

Infra

๐Ÿ—ฃ๏ธ ๋ฐœํ‘œ

๐Ÿ“š ํšŒ์˜๋ก

๐Ÿ”ด ์ธํ„ฐ๋ฏธ์…˜
๐ŸŸ  1์ฃผ์ฐจ
๐ŸŸก 2์ฃผ์ฐจ
๐ŸŸข 3์ฃผ์ฐจ
๐Ÿ”ต 4์ฃผ์ฐจ
๐ŸŸฃ 5์ฃผ์ฐจ
๐ŸŸค 6์ฃผ์ฐจ

๐Ÿ’ญ ํšŒ๊ณ 

๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ ๋ฉ˜ํ† ๋ง

Clone this wiki locally