WebAssembly Browser App (์™„๋ฃŒ) - slog

WebAssembly Browser App์€ .NET 7์—์„œ wasm-experimental ์›Œํฌ๋กœ๋“œ๋ฅผ ์„ค์น˜ํ•˜๋ฉด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

WebAssembly Browser App ํ…œํ”Œ๋ฆฟ ํ”„๋กœ์ ํŠธ๋กœ ์ƒ์„ฑ๋œ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ๋ฅผ ๊ฐ„๋‹จํžˆ ๋ถ„์„ํ•˜๋ฉด์„œ ์ตœ์ข…์ ์œผ๋กœ OffscreenCanvas๋ฅผ ์ด์šฉํ•œ ๊ทธ๋ฆฌ๊ธฐ ๋ชจ๋“ˆ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ชฉ์ ์œผ๋กœ ์Šฌ๋กœ๊ทธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

์ข‹์•„์š” 2

index.html

<!DOCTYPE html>
<!--  Licensed to the .NET Foundation under one or more agreements. -->
<!-- The .NET Foundation licenses this file to you under the MIT license. -->
<html>

<head>
  <title>WebAssemblyBrowserApp</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="modulepreload" href="./main.js" />
  <link rel="modulepreload" href="./dotnet.js" />
</head>

<body>
  <!--<span id="out"></span>-->
  <script type='module' src="./main.js"></script>
</body>

</html>

์‹คํ–‰ ๊ฒฐ๊ณผ๋ฅผ ์ถœ๋ ฅ ํ•  out span ํƒœ๊ทธ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. WebAssembly Browser App ๊ด€๋ จ ์ดˆ๊ธฐํ™” ๋ฐ ์‹œ์ž‘์€ main.js์—์„œ ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ข‹์•„์š” 1

main.js

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { dotnet } from './dotnet.js'

const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);

const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
    .withDiagnosticTracing(false)
    .withApplicationArgumentsFromQuery()
    .create();

//setModuleImports("main.js", {
//    window: {
//        location: {
//            href: () => globalThis.window.location.href
//        }
//    }
//});

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
//const text = exports.MyClass.Greeting();
//console.log(text);

/*document.getElementById("out").innerHTML = `${text}`;*/
await runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);

dotnet.js์—์„œ ์ œ๊ณตํ•˜๋Š”

  • setModuleImports : .NET ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก import
  • getAssemblyExports : .NET์˜ ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก export
  • getConfig : ์„ค์ • ๊ด€๋ จ
  • runMainAndExit : .NET ๋ฉ”์ธ ๋ฉ”์†Œ๋“œ ์‹คํ–‰

์˜ ๊ธฐ๋Šฅ์„ ์ด์šฉํ•˜์—ฌ ๊ฐ€์žฅ ๊ฐ„๋‹จํ•œ ๊ธฐ๋ณธ ๊ตฌ์„ฑ์ด ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

์ข‹์•„์š” 1

Program.cs

using System;

Console.WriteLine("Hello, Browser!");

//public partial class MyClass
//{
//    [JSExport]
//    internal static string Greeting()
//    {
//        var text = $"Hello, World! Greetings from {GetHRef()}";
//        Console.WriteLine(text);
//        return text;
//    }

//    [JSImport("window.location.href", "main.js")]
//    internal static partial string GetHRef();
//}

.NET ์ชฝ ๋ฉ”์ธ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. ๋ฉ”์ธ ํ•จ์ˆ˜๋Š” ์‹คํ–‰๋˜์–ด ์ดˆ๊ธฐํ™” ํ›„ ๋ฐ˜ํ™˜๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
main.js์˜ runMainAndExit()์— ์˜ํ•ด ํ˜ธ์ถœ๋˜๋ฉฐ ๋„˜๊ฒจ์ง„ ์ธ์ž๋Š” args์— ๋‹ด๊น๋‹ˆ๋‹ค.

์ข‹์•„์š” 2

OffscreenCanvas

Canvas์˜ ๊ทธ๋ฆฌ๊ธฐ ์„ฑ๋Šฅ์„ ๋” ๋†’์ด๊ธฐ ์œ„ํ•ด ๋ฉ”์ธ ์Šค๋ ˆ๋“œ๊ฐ€ ์•„๋‹Œ ์ž‘์—… ์Šค๋ ˆ๋“œ์—์„œ ๊ทธ๋ฆฌ๊ธฐ๊ฐ€ ๊ฐ€๋Šฅํ•œ OffscreenCanvas ๊ธฐ๋Šฅ์ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•œ๊ธ€ ์ž๋ฃŒ๋กœ ์•„๋ž˜์˜ ๊ธ€๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

Canvas์—์„œ OffscreenCanvas๋ฅผ ํš๋“ ํ•œ ํ›„ ์ž‘์—…์ž ์Šค๋ ˆ๋“œ์—์„œ 2d ์ปจํ…์ŠคํŠธ๋ฅผ ์–ป์–ด์„œ ๊ทธ๋ฆฌ๊ธฐ๋ฅผ ํ•˜๋Š” ์‹์ž…๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ๋˜๋ฉด ๊ทธ๋ฆฌ๊ธฐ ๋Ÿ‰์ด ๋งŽ์•„์ง„๋‹ค ํ•˜๋”๋ผ๋„ ์›น๋ธŒ๋ผ์šฐ์ €์˜ ๋™์ž‘์„ฑ์„ ๋ฐฉํ•ดํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์ข‹์•„์š” 2

ํ•œ๊ณ„

HTML5 Canvas ๊ฐ์ฒด ๋ฐ Context์— ๋Œ€ํ•œ .NET ๋ž˜ํผ ํด๋ž˜์Šค๋ฅผ ์ œ๊ณตํ•ด์ฃผ์ง„ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ๊ธฐ๋Šฅ ํ•˜๋‚˜ํ•˜๋‚˜ setModuleImports()๋กœ ๋…ธ์ถœํ•ด์ค˜์•ผ๋งŒ ํ•ฉ๋‹ˆ๋‹ค.

์ข‹์•„์š” 2

์Šค๋ ˆ๋“œ ์‚ฌ์šฉ

.NET์œผ๋กœ ์ž‘์—…์ž ์Šค๋ ˆ๋“œ๋ฅผ ์–ด๋–ป๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์„๊นŒ์š”? wasm-experimental์€ ์›น ์ž‘์—…์ž๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์Šค๋ ˆ๋“œ๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ๋ฉ€ํ‹ฐ ์Šค๋ ˆ๋“œ๋ฅผ ํ™œ์„ฑํ™” ํ•˜๋ ค๋ฉด ํ”„๋กœ์ ํŠธ ์„ค์ •์— ๋‹ค์Œ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

<PropertyGroup>
...
  <WasmEnableThreads>true</WasmEnableThreads>
</PropertyGroup>

์ด์ œ ์ผ๋ฐ˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์Šค๋ ˆ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ ์ฒ˜๋Ÿผ ์Šค๋ ˆ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

new Thread(SecondThread).Start();
Console.WriteLine($"Hello, Browser from the main thread {Thread.CurrentThread.ManagedThreadId}");

static void SecondThread()
{
    Console.WriteLine($"Hello from Thread {Thread.CurrentThread.ManagedThreadId}");
    for (int i = 0; i < 5; ++i)
    {
        Console.WriteLine($"Ping {i}");
        Thread.Sleep(1000);
    }
}

์ฝ˜์†”์— ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์—์„œ ์ฐจ๋‹จ๋˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ํ•˜๋‹ค๋Š” ๊ฒฝ๊ณ  ๋ฉ”์‹œ์ง€๊ฐ€ ๋ฐœ์ƒํ•˜์ง€๋งŒ ์‹ค์ œ๋กœ๋Š” ์ฐจ๋‹จ ์—†์ด ๋ฐ”๋กœ init finished๊ฐ€ ์ถœ๋ ฅ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

image

์ข‹์•„์š” 1

import ๋ฐ export, JSMarshalAs ํŠน์„ฑ

import ๋ฐ export๋Š” JSMarshalAs ํŠน์„ฑ์„ ํ†ตํ•ด ์ž๋™์œผ๋กœ ๋งˆ์ƒฌ๋ง ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ์†Œ์Šค ์ƒ์„ฑ๊ธฐ๋ฅผ ์ด์šฉํ•˜๋ฏ€๋กœ ํด๋ž˜์Šค ๋ฐ ๋ฉ”์„œ๋“œ์— partial ํ‚ค์›Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • import (๊ฐ€์ ธ์˜ค๊ธฐ)
    [JSImport("window.location.href", "main.js")]
    internal static partial string GetHRef();
  • export (๋‚ด๋ณด๋‚ด๊ธฐ)
    [JSExport]
    internal static string Greeting()
    {
        var text = $"Hello, World! Greetings from {GetHRef()}";
        Console.WriteLine(text);
        return text;
//    }

bool, byte, int, double(float) ๋ฐ string, ๋ฐฐ์—ด์€ JSMarshalAs ํŠน์„ฑ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•„๋„ ์ธ์‹ํ•˜๊ณ  ๋งˆ์ƒฌ๋งํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋‚˜ ushort, long(ulong), callback ํ•จ์ˆ˜(Action, Func) ๋“ฑ์€ JsMarshalAs ํŠน์„ฑ์„ ์ •ํ™•ํžˆ ์ž˜ ํ‘œํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

JSMarshalAsAttribute<TType>

JSMarshalAs ํŠน์„ฑ์€ ์ œ๋„ค๋ฆญ ์ธ์ž๋กœ JSType์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.

public sealed class JSMarshalAsAttribute<T> : Attribute where T : JSType

๋ฏธ๋ฆฌ ์ •์˜๋œ JSType ํƒ€์ž…์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

    public sealed class Void : JSType {}
    public sealed class Discard : JSType {}
    public sealed class Boolean : JSType {}
    public sealed class Number : JSType {}
    public sealed class BigInt : JSType {}
    public sealed class Date : JSType {}
    public sealed class String : JSType {}
    public sealed class Object : JSType {}
    public sealed class Error : JSType {}
    public sealed class MemoryView : JSType {}
    public sealed class Array<T> : JSType where T : JSType {}
    public sealed class Promise<T> : JSType where T : JSType {}
    public sealed class Function : JSType {}
    public sealed class Function<T> : JSType where T : JSType {}
    public sealed class Function<T1, T2> : JSType where T1 : JSType where T2 : JSType {}
    public sealed class Function<T1, T2, T3> : JSType where T1 : JSType where T2 : JSType where T3 :  JSType {}
    public sealed class Function<T1, T2, T3, T4> : JSType where T1 : JSType where T2 : JSType where T3 : JSType where T4 : JSType {}
    public sealed class Any : JSType {}

๋‹ค์Œ์Œ ๋ฐฐ์—ด์˜ ๊ฒฝ์šฐ ์˜ˆ์‹œ์ž…๋‹ˆ๋‹ค.

        [JSImport("canvas.drawLineBrush", "main.js")]
        internal static partial void DrawLine(
          double x1,
          double y1,
          double x2,
          double y2,
          double strokeWidth,
          string color
           [JSMarshalAs<JSType.Array<JSType.Number>>] double[] lineDashes);

๋‹ค์Œ์€ ์ฝœ๋ฒกํ•จ์ˆ˜์˜ ์˜ˆ์‹œ ์ž…๋‹ˆ๋‹ค.

    [JSImport("requestAnimationFrame", "main.js")]
    internal static partial void requestAnimationFrame(
        [JSMarshalAs<JSType.Function>] Action callback);
์ข‹์•„์š” 1

canvas ์‚ฌ์šฉ ์˜ˆ์‹œ

๋Œ€๋žต์ ์ธ ๋ชจ์Šต์ž…๋‹ˆ๋‹ค.

| main.js

setModuleImports("main.js", {
    canvas: {
        clear: (color) => {
            context.fillStyle = color;
            context.fillRect(0, 0, canvas.width, canvas.height);
        },
        setOpacity: (value) => {
            context.globalAlpha = value;
        },
        getOpacity: () => {
            return context.globalAlpha;
        },
        drawLine: (x1, y1, x2, y2, color) => {
            context.strokeStyle = color;
            context.beginPath();
            context.moveTo(x1, y1);
            context.lineTo(x2, y2);
            context.stroke();
        },
        drawLineBrush: (x1, y1, x2, y2, strokeWidth, color, lineDashes) => {
            context.lineWidth = strokeWidth;
            context.strokeStyle = color;
            context.setLineDash(lineDashes);
            context.beginPath();
            context.moveTo(x1, y1);
            context.lineTo(x2, y2);
            context.stroke();
        }
    }
    //    window: {
    //        location: {
    //            href: () => globalThis.window.location.href
    //        }
    //    }
});

| cs

    internal static partial class JSImport
    {
        [JSImport("canvas.clear", "main.js")]
        internal static partial void Clear(string color);

        [JSImport("canvas.setOpacity", "main.js")]
        internal static partial void SetOpacity(double value);

        [JSImport("canvas.getOpacity", "main.js")]
        internal static partial double GetOpacity();

        [JSImport("canvas.drawLine", "main.js")]
        internal static partial void DrawLine(double x1, double y1, double x2, double y2, string color);

        [JSImport("canvas.drawLineBrush", "main.js")]
        internal static partial void DrawLine(double x1, double y1, double x2, double y2, double strokeWidth, string color, [JSMarshalAs<JSType.Array<JSType.Number>>] double[] lineDashes);

        internal static string ToRgbString(Color color) => $"rgba({color.R}, {color.G}, {color.B}, {color.A})";
    }

๊ตฌํ˜„ ์ค‘โ€ฆ

์ด์ œ ์ด๋Ÿฐ ์ฝ”๋“œ๋กœ

var ds = new HtmlCanvasDrawningSession();


ds.Clear(new Color(0xAAAAAA));

//ds.Opacity = 1.0f;

for (var i = 0; i < 800; i += 10)
    ds.DrawLine(i, 0, i + 100, 100, new Color(0xff0000));


var brush = new CanvasBrush(new Color(0x00ff00), 5, new[] { 5d, 15d });

ds.DrawLine(200, 200, 300, 300, brush);

ds.DrawRectangle(300, 300, 150, 200, brush);

ds.DrawRectangle(400, 400, 200, 150, new Color(0x0000ff));

ds.FillRectangle(500, 500, 150, 100, new Color(0xff0000));

ds.DrawRoundedRectangle(100, 300, 100, 150, 10, 10, new Color(0xff0000));

ds.DrawRoundedRectangle(100, 500, 100, 150, 10, 10, brush);

ds.FillRoundedRectangle(300, 300, 100, 150, 10, 10, new Color(0x0000ff));

ds.FillRoundedRectangle(300, 500, 100, 150, 10, 10, brush);

ds.FillCircle(700, 300, 40, new Color(0x00ff00));

ds.DrawCircle(800, 300, 40, brush);

ds.FillArc(700, 400, 40, 0, 180, new Color(0x00ff00));

ds.DrawArc(800, 400, 40, 0, 180, brush);

ds.DrawText("Test Text! ํ•œ๊ธ€!", 300, 150, new Color(0xFF0000), new CanvasTextFormat
{
    FontSize = 24,
});

ds.DrawText("Test Text! ํ•œ๊ธ€!", 500, 150, 120, 120, new Color(0xFF0000), new CanvasTextFormat
{
    FontSize = 24,
});


var size = ds.MeasureTextSize("Test Text! ํ•œ๊ธ€!", new CanvasTextFormat
{
    FontSize = 24,
});

ds.DrawRectangle(500, 150, size.Width, size.Height, new Color(0x00FF00));

Console.WriteLine($"MeasureTextSize => {size.Width}, {size.Height}");

๋‹ค์Œ์˜ ํ™”๋ฉด์„ ์›นํŽ˜์ด์ง€๋กœ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ข‹์•„์š” 1

์ธํ„ฐํŽ˜์ด์Šค์— ๋งž์ถฐ์„œ Canvas๋กœ ๊ทธ๋ฆฌ๊ธฐ ๊ธฐ๋Šฅ์„ ์ž˜ ๊ตฌํ˜„ํ–ˆ๋‹ค๋ฉด ์ธํ„ฐํŽ˜์ด์Šค ๊ธฐ์ค€์œผ๋กœ ๊ธฐ์กด์— ์ž˜ ๋งŒ๋“  ๊ธฐ๋Šฅ์„ ์ด์šฉํ•ด ์›น๋ธŒ๋ผ์šฐ์ €์—์„œ๋„ ๋™์ž‘์„ฑ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ข‹์•„์š” 4

๋น„๋‹จ Canvas ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ SVG๋กœ๋„ ์ธํ„ฐํŽ˜์ด์Šค๋งŒ ์ฐฉ์‹คํžˆ ๋งž์ถฐ์ค€๋‹ค๋ฉด SVG๋กœ์˜ ์ „ํ™˜๋„ ๋ฌธ์ œ ์—†์Šต๋‹ˆ๋‹ค.

image

SVG๋Š” ์ ์ ˆํ•˜๊ฒŒ SvgElement โ†’ SvgContainer โ†’ SvgRoot, SvgGroup ์œผ๋กœ ํ™•์žฅ ๊ตฌํ˜„ํ•˜๊ณ 
SvgElement๋Š” ๋‹ค์Œ ์ฒ˜๋Ÿผ ์ตœ์ข… SVG XML์„ ์ƒ์„ฑํ•˜๋„๋ก ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.

| SvgElement

internal abstract class SvgElement
{
    public abstract string Tag { get; }
    public SvgElement? Content { get; set; }
    public Color? StrokeColor { get; set; }
    public double StrokeWidth { get; set; } = 1;
    public double[]? StrokeDashArray { get; set; }
    public Color? FillColor { get; set; }


    protected virtual void AddProperties(StringBuilder sb)
    {
        if (StrokeColor is not null)
            AddProperty(sb, "stroke", StrokeColor?.ToRgbaString());

        AddProperty(sb, "stroke-width", StrokeWidth);

        if (StrokeDashArray is not null)
        {
            AddProperty(sb, "stroke-dasharray", string.Join(' ', StrokeDashArray));
        }

        if (FillColor is not null)
            AddProperty(sb, "fill", FillColor?.ToRgbaString());
    }

    protected virtual bool HaveStyles() => false;

    protected virtual void AddStyles(StringBuilder sb)
    {
    }

    protected static void AddStyle(StringBuilder sb, string styleName, object? value)
    {
        if (value is null)
            return;

        sb.Append(styleName);
        sb.Append(':');
        sb.Append(value.ToString());
        sb.Append(';');
    }

    protected virtual void AddContent(StringBuilder sb)
    {
    }

    protected static void AddProperty(StringBuilder sb, string propertyName, object? value)
    {
        if (value is null)
            return;

        sb.Append(' ');
        sb.Append(propertyName);
        sb.Append('=');
        sb.Append($"\"{value}\"");
    }

    public override string ToString()
    {
        var sb = new StringBuilder();
        ToString(sb);
        return sb.ToString();
    }

    public virtual void ToString(StringBuilder sb)
    {
        sb.Append('<');
        sb.Append(Tag);
        AddProperties(sb);
        if (HaveStyles() is true)
        {
            sb.Append("style=\"=");
            AddStyles(sb);
            sb.Append('"');
        }
        sb.Append('>');

        AddContent(sb);

        sb.Append("</");
        sb.Append(Tag);
        sb.Append('>');
    }
}

์ด์ œ ์ด ๊ทœ์น™์— ๋งž๊ฒŒ SVG ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ํด๋ž˜์Šค๋กœ ๊ตฌ์„ฑํ•˜๋ฉด ๋˜๋Š” ๊ฒƒ์ด์ง€์š”.

์ข‹์•„์š” 1

์ฐธ๊ณ ๋กœ ํŒŒ์›Œํฌ์ธํŠธ์— image/svg+xml๋กœ ํด๋ฆฝ๋ณด๋“œ๋กœ SVG๋ฅผ ๋„ฃ์–ด๋‘๋ฉด ๋ถ™์—ฌ๋„ฃ๊ธฐ ํ•  ๋•Œ ๋ฒกํ„ฐ ํ˜•ํƒœ๋„ ํŒŒ์›Œํฌ์ธํŠธ๋กœ ๋ถ™์—ฌ๋„ฃ๊ธฐ ๋˜๋Š”๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์‹ค์ œ๋กœ ํŒŒ์›Œํฌ์ธํŠธ์˜ ๊ทธ๋ฆฌ๊ธฐ ๊ฐœ์ฒด๋ฅผ ์„ ํƒ ํด๋ฆฝ๋ณด๋“œ๋กœ ๋ณต์‚ฌํ•  ๋•Œ๋„ image/svg+xml ํ˜•ํƒœ๋ฅผ ์ œ๊ณตํ•ด์ค˜์„œ SVG๋ฅผ ํ•ด์„ํ•  ์ˆ˜ ์žˆ๋Š” ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์ด๋ผ๋ฉด ํŒŒ์›Œํฌ์ธํŠธ ๊ทธ๋ฆฌ๊ธฐ ๊ฐœ์ฒด๋ฅผ ์ด๋ฏธ์ง€๊ฐ€ ์•„๋‹Œ ๋ฒกํ„ฐ ๋‹จ์œ„๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ข‹์•„์š” 1