Alert

์ด ๊ธ€์€ Claude Code์˜ ๋„์›€์„ ๋ฐ›์•„ ์ž‘์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค

TL;DR

  • @dataclass๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด๋Š” ํด๋ž˜์Šค์˜ boilerplate(__init__, __repr__, __eq__ ๋“ฑ)๋ฅผ ์ž๋™ ์ƒ์„ฑํ•ด์ฃผ๋Š” ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ
  • Python 3.7์—์„œ ๋„์ž… (PEP 557), โ€œ๊ธฐ๋ณธ๊ฐ’์ด ์žˆ๋Š” mutable namedtupleโ€๋กœ ์ดํ•ดํ•˜๋ฉด ์‰ฝ๋‹ค
  • ์„ค์ • ๊ฐ์ฒด, API ์‘๋‹ต ๋งคํ•‘, DTO ๋“ฑ ๊ฐ’์„ ๊ตฌ์กฐํ™”ํ•ด์„œ ๋‹ด๋Š” ๋ชจ๋“  ๊ณณ์—์„œ ์œ ์šฉํ•˜๋‹ค

Sources


1. ์™œ dataclass์ธ๊ฐ€

Python์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด๋Š” ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค๋ ค๋ฉด ๋ฐ˜๋ณต ์ฝ”๋“œ๊ฐ€ ๋งŽ๋‹ค.

์ผ๋ฐ˜ ํด๋ž˜์Šค๋กœ ์ž‘์„ฑํ•˜๋ฉด
class User:
    def __init__(self, name: str, age: int, email: str):
        self.name = name
        self.age = age
        self.email = email
 
    def __repr__(self):
        return f"User(name={self.name!r}, age={self.age!r}, email={self.email!r})"
 
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return (self.name, self.age, self.email) == (other.name, other.age, other.email)

ํ•„๋“œ๊ฐ€ 3๊ฐœ๋ฟ์ธ๋ฐ 20์ค„์ด๋‹ค. ํ•„๋“œ๊ฐ€ ๋Š˜์–ด๋‚  ๋•Œ๋งˆ๋‹ค __init__, __repr__, __eq__๋ฅผ ์ „๋ถ€ ์ˆ˜์ •ํ•ด์•ผ ํ•œ๋‹ค.

dataclass๋กœ ์ž‘์„ฑํ•˜๋ฉด
from dataclasses import dataclass
 
@dataclass
class User:
    name: str
    age: int
    email: str

์ด๊ฒŒ ์ „๋ถ€๋‹ค. __init__, __repr__, __eq__๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋œ๋‹ค.

u1 = User("alice", 30, "alice@example.com")
u2 = User("alice", 30, "alice@example.com")
 
print(u1)        # User(name='alice', age=30, email='alice@example.com')
print(u1 == u2)  # True

ํ•ต์‹ฌ

dataclass๋Š” ์ƒˆ๋กœ์šด ์ž๋ฃŒ๊ตฌ์กฐ๊ฐ€ ์•„๋‹ˆ๋‹ค. ์ผ๋ฐ˜ ํด๋ž˜์Šค์— boilerplate ๋ฉ”์„œ๋“œ๋ฅผ ์ž๋™์œผ๋กœ ๋ถ™์—ฌ์ฃผ๋Š” ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์ผ ๋ฟ์ด๋‹ค. ์ƒ์„ฑ๋œ ํด๋ž˜์Šค๋Š” ์ผ๋ฐ˜ ํด๋ž˜์Šค์™€ ์™„์ „ํžˆ ๋™์ผํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.


2. ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

์ž๋™ ์ƒ์„ฑ๋˜๋Š” ๋ฉ”์„œ๋“œ (boilerplate)

Python ํด๋ž˜์Šค์—๋Š” __๋กœ ๊ฐ์‹ธ์ง„ ํŠน์ˆ˜ ๋ฉ”์„œ๋“œ(dunder method)๊ฐ€ ์žˆ๋‹ค. ๊ฐ์ฒด๋ฅผ ์ถœ๋ ฅํ•˜๊ฑฐ๋‚˜, ๋น„๊ตํ•˜๊ฑฐ๋‚˜, ํ•ด์‹œ๊ฐ’์„ ๊ตฌํ•  ๋•Œ Python์ด ๋‚ด๋ถ€์ ์œผ๋กœ ํ˜ธ์ถœํ•˜๋Š” ๋ฉ”์„œ๋“œ๋‹ค. ์ด๊ฒƒ๋“ค์„ ์ง์ ‘ ์ •์˜ํ•˜์ง€ ์•Š์œผ๋ฉด object์—์„œ ์ƒ์†๋ฐ›์€ ๊ธฐ๋ณธ ๋™์ž‘์ด ์ ์šฉ๋˜๋Š”๋ฐ, ๋Œ€๋ถ€๋ถ„ ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค์—์„œ ์›ํ•˜๋Š” ๋™์ž‘๊ณผ ๋‹ค๋ฅด๋‹ค.

๋ฉ”์„œ๋“œ์—ญํ• ์ •์˜ ์•ˆ ํ–ˆ์„ ๋•Œ (์ผ๋ฐ˜ ํด๋ž˜์Šค)dataclass ์ž๋™ ์ƒ์„ฑ
__init__()๊ฐ์ฒด ์ƒ์„ฑ ์‹œ ์ดˆ๊ธฐํ™”์ธ์ž ์—†๋Š” ๋นˆ ์ƒ์„ฑ์žํ•„๋“œ๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์•„ self.ํ•„๋“œ = ๊ฐ’
__repr__()print(), ๋””๋ฒ„๊น… ์‹œ ์ถœ๋ ฅ<User object at 0x7f...> (๋ฉ”๋ชจ๋ฆฌ ์ฃผ์†Œ)User(name='alice', age=30)
__eq__()== ๋น„๊ต๊ฐ์ฒด identity ๋น„๊ต (is์™€ ๋™์ผ)ํ•„๋“œ ๊ฐ’ ๋น„๊ต
__hash__()dict ํ‚ค, set ์›์†Œ๋กœ ์‚ฌ์šฉํ•  ๋•Œid() ๊ธฐ๋ฐ˜ ํ•ด์‹œfrozen=True์ผ ๋•Œ ํ•„๋“œ ๊ธฐ๋ฐ˜ ํ•ด์‹œ
__lt__() ๋“ฑ<, <=, >, >= ๋น„๊ตTypeError (๋น„๊ต ๋ถˆ๊ฐ€)order=True์ผ ๋•Œ ํ•„๋“œ ํŠœํ”Œ ๋น„๊ต

๊ฐ€์žฅ ์ž์ฃผ ๊ฒช๋Š” ํ•จ์ •์€ __eq__๋‹ค.

# ์ผ๋ฐ˜ ํด๋ž˜์Šค โ€” ๊ฐ’์ด ๊ฐ™์•„๋„ ๋‹ค๋ฅธ ๊ฐ์ฒด๋ฉด False
class User:
    def __init__(self, name):
        self.name = name
 
print(User("alice") == User("alice"))  # False (๋ฉ”๋ชจ๋ฆฌ ์ฃผ์†Œ๊ฐ€ ๋‹ค๋ฅด๋‹ˆ๊นŒ)
# dataclass โ€” ๊ฐ’์ด ๊ฐ™์œผ๋ฉด True
@dataclass
class User:
    name: str
 
print(User("alice") == User("alice"))  # True (ํ•„๋“œ ๊ฐ’์„ ๋น„๊ตํ•˜๋‹ˆ๊นŒ)

__repr__๋„ ๋งˆ์ฐฌ๊ฐ€์ง€๋‹ค.

# ์ผ๋ฐ˜ ํด๋ž˜์Šค
class User:
    def __init__(self, name):
        self.name = name
 
print(User("alice"))  # <User object at 0x7f3b2c1d4a90> โ€” ๋””๋ฒ„๊น…ํ•  ๋•Œ ์“ธ๋ชจ์—†๋‹ค
# dataclass
@dataclass
class User:
    name: str
 
print(User("alice"))  # User(name='alice') โ€” ๋ฐ”๋กœ ๋‚ด์šฉ์ด ๋ณด์ธ๋‹ค
๊ธฐ๋ณธ๊ฐ’ ์ง€์ •
@dataclass
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False
c = Config()
print(c)  # Config(host='localhost', port=8080, debug=False)
 
c2 = Config(host="0.0.0.0", debug=True)
print(c2)  # Config(host='0.0.0.0', port=8080, debug=True)

์ผ๋ฐ˜ ํ•จ์ˆ˜์˜ ๊ธฐ๋ณธ ์ธ์ž์™€ ๊ฐ™์€ ๊ทœ์น™์ด ์ ์šฉ๋œ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์ด ์—†๋Š” ํ•„๋“œ๊ฐ€ ์žˆ๋Š” ํ•„๋“œ๋ณด๋‹ค ์•ž์— ์™€์•ผ ํ•œ๋‹ค.

# โŒ TypeError ๋ฐœ์ƒ
@dataclass
class Bad:
    x: int = 0
    y: int       # ๊ธฐ๋ณธ๊ฐ’ ์—†๋Š” ํ•„๋“œ๊ฐ€ ๋’ค์— ์˜ค๋ฉด ์•ˆ ๋จ
 
# โœ… ์˜ฌ๋ฐ”๋ฅธ ์ˆœ์„œ
@dataclass
class Good:
    y: int
    x: int = 0
๋ฉ”์„œ๋“œ ์ถ”๊ฐ€

dataclass๋ผ๊ณ  ํ•ด์„œ ๋ฉ”์„œ๋“œ๋ฅผ ๋ชป ๋„ฃ๋Š” ๊ฒŒ ์•„๋‹ˆ๋‹ค. ์ผ๋ฐ˜ ํด๋ž˜์Šค์™€ ๋™์ผํ•˜๊ฒŒ ๋ฉ”์„œ๋“œ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค.

@dataclass
class Rectangle:
    width: float
    height: float
 
    def area(self) -> float:
        return self.width * self.height
 
    def is_square(self) -> bool:
        return self.width == self.height
r = Rectangle(3.0, 4.0)
print(r.area())      # 12.0
print(r.is_square())  # False

3. ๊ธฐ์กด ๋ฐฉ์‹๊ณผ ๋น„๊ต

๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด๋Š” ๋ฐฉ๋ฒ•์€ ์—ฌ๋Ÿฌ ๊ฐ€์ง€๋‹ค. ๊ฐ๊ฐ ์–ธ์ œ ์ ํ•ฉํ•œ์ง€ ๋น„๊ตํ•œ๋‹ค.

dict
user = {"name": "alice", "age": 30, "email": "alice@example.com"}
NamedTuple
from typing import NamedTuple
 
class User(NamedTuple):
    name: str
    age: int
    email: str
dataclass
from dataclasses import dataclass
 
@dataclass
class User:
    name: str
    age: int
    email: str
๋น„๊ตํ‘œ
๊ธฐ์ค€dictNamedTupledataclass
ํƒ€์ž… ํžŒํŒ…X (๊ฐ’์— ๋Œ€ํ•ด ์—†์Œ)OO
์ž๋™ __repr__XOO
์ž๋™ __eq__O (๊ฐ’ ๋น„๊ต)OO
mutableOX (๋ถˆ๋ณ€)O (๊ธฐ๋ณธ) / X (frozen=True)
๊ธฐ๋ณธ๊ฐ’OOO
์†์„ฑ ์ ‘๊ทผd["name"]u.nameu.name
IDE ์ž๋™์™„์„ฑXOO
๋ฉ”์„œ๋“œ ์ถ”๊ฐ€X์ œํ•œ์ O
์ƒ์†X์ œํ•œ์ O
์–ธํŒฉXO (*u)X (๋ณ„๋„ ๊ตฌํ˜„ ํ•„์š”)

์–ธ์ œ ๋ญ˜ ์“ธ๊นŒ

  • dict โ†’ ์Šคํ‚ค๋งˆ๊ฐ€ ์œ ๋™์ ์ด๊ฑฐ๋‚˜ JSON์„ ์ž„์‹œ๋กœ ๋‹ค๋ฃฐ ๋•Œ
  • NamedTuple โ†’ ๋ถˆ๋ณ€์ด์–ด์•ผ ํ•˜๊ณ , ํŠœํ”Œ ์–ธํŒฉ์ด ํ•„์š”ํ•˜๋ฉฐ, ๊ฐ€๋ณ๊ฒŒ ์“ธ ๋•Œ
  • dataclass โ†’ ํ•„๋“œ๊ฐ€ ๋ช…ํ™•ํ•˜๊ณ , mutable์ด ํ•„์š”ํ•˜๊ฑฐ๋‚˜, ๋ฉ”์„œ๋“œ/์ƒ์†์ด ํ•„์š”ํ•  ๋•Œ
dataclass์˜ ์–ธํŒฉ(unpack)

NamedTuple์€ ๋‚ด๋ถ€๊ฐ€ tuple์ด๋ผ x, y = point๊ฐ€ ๋ฐ”๋กœ ๋˜์ง€๋งŒ, dataclass๋Š” ์ผ๋ฐ˜ ํด๋ž˜์Šค๋ผ์„œ __iter__๊ฐ€ ์—†์–ด ๊ธฐ๋ณธ์ ์œผ๋กœ ์–ธํŒฉ์ด ์•ˆ ๋œ๋‹ค.

from typing import NamedTuple
from dataclasses import dataclass, astuple, asdict
 
class NTPoint(NamedTuple):
    x: int
    y: int
 
@dataclass
class DCPoint:
    x: int
    y: int
 
x, y = NTPoint(1, 2)   # OK โ€” tuple์ด๋‹ˆ๊นŒ
x, y = DCPoint(1, 2)   # TypeError: cannot unpack non-iterable DCPoint object

์–ธํŒฉ์ด ํ•„์š”ํ•˜๋ฉด astuple()์„ ์“ฐ๊ฑฐ๋‚˜, __iter__๋ฅผ ์ง์ ‘ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค.

# ๋ฐฉ๋ฒ• 1: astuple()๋กœ ๋ณ€ํ™˜ ํ›„ ์–ธํŒฉ
from dataclasses import astuple
 
p = DCPoint(1, 2)
x, y = astuple(p)
 
# ๋ฐฉ๋ฒ• 2: __iter__ ๊ตฌํ˜„
@dataclass
class Point:
    x: int
    y: int
 
    def __iter__(self):
        return iter(astuple(self))
 
x, y = Point(1, 2)  # OK

๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ .x, .y๋กœ ์ ‘๊ทผํ•˜๋Š” ๊ฒŒ ๋ช…์‹œ์ ์ด๊ณ  ๊ถŒ์žฅ๋˜๋Š” ๋ฐฉ์‹์ด๋‹ค. ์–ธํŒฉ์€ ์ขŒํ‘œ์ฒ˜๋Ÿผ ์ˆœ์„œ๊ฐ€ ์ž๋ช…ํ•œ ๊ฒฝ์šฐ์—๋งŒ ์“ฐ๋Š” ๊ฒŒ ์ข‹๋‹ค.

NamedTuple์˜ ํ•จ์ •

NamedTuple์€ ๋‚ด๋ถ€์ ์œผ๋กœ tuple์ด๋ผ์„œ ๋‹ค๋ฅธ ํƒ€์ž…๋ผ๋ฆฌ๋„ ๊ฐ’๋งŒ ๊ฐ™์œผ๋ฉด ๋™์ผํ•˜๋‹ค๊ณ  ํŒ๋‹จํ•œ๋‹ค.

from typing import NamedTuple
 
class Point(NamedTuple):
    x: int
    y: int
 
class Pair(NamedTuple):
    first: int
    second: int
 
print(Point(1, 2) == Pair(1, 2))  # True โ€” ์˜๋„ํ•˜์ง€ ์•Š์€ ๋™์ž‘

dataclass๋Š” ํด๋ž˜์Šค ํƒ€์ž…๊นŒ์ง€ ๋น„๊ตํ•˜๋ฏ€๋กœ ์ด ๋ฌธ์ œ๊ฐ€ ์—†๋‹ค.

from dataclasses import dataclass
 
@dataclass
class Point:
    x: int
    y: int
 
@dataclass
class Pair:
    first: int
    second: int
 
print(Point(1, 2) == Pair(1, 2))  # False

4. field()์™€ ๊ธฐ๋ณธ๊ฐ’

field() ๊ธฐ๋ณธ ์‚ฌ์šฉ

field()๋ฅผ ํ†ตํ•ด ํ•„๋“œ๋ณ„๋กœ ์„ธ๋ฐ€ํ•œ ์ œ์–ด๊ฐ€ ๊ฐ€๋Šฅํ•˜๋‹ค.

from dataclasses import dataclass, field
 
@dataclass
class Product:
    name: str
    price: float
    tags: list[str] = field(default_factory=list)
    internal_id: str = field(repr=False, default="N/A")
    discount: float = field(compare=False, default=0.0)
p = Product("๋…ธํŠธ๋ถ", 1500000.0, ["์ „์ž๊ธฐ๊ธฐ", "์ปดํ“จํ„ฐ"])
print(p)  # Product(name='๋…ธํŠธ๋ถ', price=1500000.0, tags=['์ „์ž๊ธฐ๊ธฐ', '์ปดํ“จํ„ฐ'], discount=0.0)
# internal_id๋Š” repr=False๋ผ ์ถœ๋ ฅ์—์„œ ์ œ์™ธ๋œ๋‹ค
field() ์ฃผ์š” ์˜ต์…˜
์˜ต์…˜๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
defaultMISSINGํ•„๋“œ์˜ ๊ธฐ๋ณธ๊ฐ’
default_factoryMISSING๊ธฐ๋ณธ๊ฐ’์„ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜ (mutable ๊ธฐ๋ณธ๊ฐ’์— ํ•„์ˆ˜)
initTrue__init__ ํŒŒ๋ผ๋ฏธํ„ฐ์— ํฌํ•จํ• ์ง€
reprTrue__repr__ ์ถœ๋ ฅ์— ํฌํ•จํ• ์ง€
compareTrue__eq__ ๋“ฑ ๋น„๊ต์— ํฌํ•จํ• ์ง€
hashNone__hash__์— ํฌํ•จํ• ์ง€ (None์ด๋ฉด compare ๊ฐ’์„ ๋”ฐ๋ฆ„)
kw_onlyFalsekeyword-only ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์„ค์ •
metadataNone์ปค์Šคํ…€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋”•์…”๋„ˆ๋ฆฌ
๊ฐ€๋ณ€ ๊ธฐ๋ณธ๊ฐ’ ํ•จ์ •

Python์—์„œ mutable ๊ธฐ๋ณธ๊ฐ’์€ ๋ชจ๋“  ์ธ์Šคํ„ด์Šค๊ฐ€ ๊ฐ™์€ ๊ฐ์ฒด๋ฅผ ๊ณต์œ ํ•˜๋Š” ์œ ๋ช…ํ•œ ํ•จ์ •์ด๋‹ค. dataclass๋Š” ์ด๊ฑธ ์ปดํŒŒ์ผ ํƒ€์ž„์— ์žก์•„์ค€๋‹ค.

# โŒ dataclass๊ฐ€ TypeError๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค
@dataclass
class BadExample:
    items: list = []
# TypeError: mutable default <class 'list'> for field items is not allowed:
# use default_factory
# โœ… default_factory ์‚ฌ์šฉ
@dataclass
class GoodExample:
    items: list = field(default_factory=list)
    config: dict = field(default_factory=dict)
    scores: set = field(default_factory=set)
a = GoodExample()
b = GoodExample()
a.items.append(1)
print(a.items)  # [1]
print(b.items)  # [] โ€” ๊ฐ ์ธ์Šคํ„ด์Šค๊ฐ€ ๋…๋ฆฝ์ ์ธ ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ฐ€์ง„๋‹ค
๋ณต์žกํ•œ ๊ธฐ๋ณธ๊ฐ’

default_factory์—๋Š” ์–ด๋–ค callable์ด๋“  ๋„ฃ์„ ์ˆ˜ ์žˆ๋‹ค.

from dataclasses import dataclass, field
from datetime import datetime
 
@dataclass
class LogEntry:
    message: str
    level: str = "INFO"
    timestamp: datetime = field(default_factory=datetime.now)
    metadata: dict = field(default_factory=lambda: {"version": "1.0"})
log = LogEntry("์„œ๋ฒ„ ์‹œ์ž‘")
print(log)
# LogEntry(message='์„œ๋ฒ„ ์‹œ์ž‘', level='INFO', timestamp=datetime(...), metadata={'version': '1.0'})
init=False์™€ ๊ณ„์‚ฐ ํ•„๋“œ

init=False๋กœ ์ง€์ •ํ•˜๋ฉด ์ƒ์„ฑ์ž์—์„œ ๋ฐ›์ง€ ์•Š๊ณ , __post_init__์—์„œ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค.

@dataclass
class Circle:
    radius: float
    area: float = field(init=False)
 
    def __post_init__(self):
        self.area = 3.14159 * self.radius ** 2
c = Circle(5.0)
print(c)  # Circle(radius=5.0, area=78.53975)
metadata ํ™œ์šฉ

metadata๋Š” ์ง๋ ฌํ™” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋‚˜ ๊ฒ€์ฆ ๋กœ์ง์—์„œ ํ•„๋“œ ์ •๋ณด๋ฅผ ์ฐธ์กฐํ•  ๋•Œ ์œ ์šฉํ•˜๋‹ค.

from dataclasses import dataclass, field, fields
 
@dataclass
class APIResponse:
    status_code: int = field(metadata={"json_key": "statusCode"})
    body: str = field(metadata={"json_key": "responseBody"})
    headers: dict = field(default_factory=dict, metadata={"json_key": "headers"})
 
# ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์กฐํšŒ
for f in fields(APIResponse):
    print(f"{f.name} -> JSON key: {f.metadata.get('json_key')}")
# status_code -> JSON key: statusCode
# body -> JSON key: responseBody
# headers -> JSON key: headers

5. ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์˜ต์…˜

@dataclass ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์—๋Š” ํด๋ž˜์Šค ์ „์ฒด์˜ ๋™์ž‘์„ ์ œ์–ดํ•˜๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์žˆ๋‹ค.

@dataclass(init=True, repr=True, eq=True, order=False,
           frozen=False, slots=False, kw_only=False)
frozen โ€” ๋ถˆ๋ณ€ ๊ฐ์ฒด ๋งŒ๋“ค๊ธฐ

frozen=True๋กœ ์„ค์ •ํ•˜๋ฉด ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ ํ›„ ํ•„๋“œ ๊ฐ’์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†๋‹ค.

@dataclass(frozen=True)
class Coordinate:
    lat: float
    lng: float
c = Coordinate(37.5665, 126.9780)
c.lat = 0.0  # FrozenInstanceError ๋ฐœ์ƒ

frozen dataclass๋Š” __hash__๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋˜๋ฏ€๋กœ dict ํ‚ค๋‚˜ set ์›์†Œ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

locations = {
    Coordinate(37.5665, 126.9780): "์„œ์šธ",
    Coordinate(35.1796, 129.0756): "๋ถ€์‚ฐ",
}
print(locations[Coordinate(37.5665, 126.9780)])  # ์„œ์šธ
order โ€” ๋น„๊ต ์—ฐ์‚ฐ์ž ์ž๋™ ์ƒ์„ฑ

order=True๋กœ ์„ค์ •ํ•˜๋ฉด <, <=, >, >= ์—ฐ์‚ฐ์ž๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋œ๋‹ค. ํ•„๋“œ๋ฅผ ํŠœํ”Œ๋กœ ๋ณ€ํ™˜ํ•ด์„œ ์ˆœ์„œ๋Œ€๋กœ ๋น„๊ตํ•œ๋‹ค.

@dataclass(order=True)
class Version:
    major: int
    minor: int
    patch: int
versions = [Version(2, 1, 0), Version(1, 9, 5), Version(2, 0, 3)]
print(sorted(versions))
# [Version(major=1, minor=9, patch=5), Version(major=2, minor=0, patch=3), Version(major=2, minor=1, patch=0)]
 
print(Version(2, 0, 0) > Version(1, 9, 9))  # True
slots โ€” ๋ฉ”๋ชจ๋ฆฌ ์ตœ์ ํ™”

slots=True(Python 3.10+)๋กœ ์„ค์ •ํ•˜๋ฉด __slots__๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋œ๋‹ค. ์ด๊ฒŒ ์™œ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ ˆ์•ฝํ•˜๋Š”์ง€ ์ดํ•ดํ•˜๋ ค๋ฉด Python์ด ๊ฐ์ฒด์˜ ์†์„ฑ์„ ์ €์žฅํ•˜๋Š” ๋ฐฉ์‹์„ ์•Œ์•„์•ผ ํ•œ๋‹ค.

๊ธฐ๋ณธ ๋™์ž‘ โ€” __dict__ ๋ฐฉ์‹

์ผ๋ฐ˜ Python ๊ฐ์ฒด๋Š” ๋‚ด๋ถ€์— __dict__๋ผ๋Š” ๋”•์…”๋„ˆ๋ฆฌ๋ฅผ ๊ฐ–๊ณ  ์žˆ๋‹ค. ์†์„ฑ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ์ด ๋”•์…”๋„ˆ๋ฆฌ์— key-value๋กœ ์ €์žฅ๋œ๋‹ค.

@dataclass
class Point:
    x: float
    y: float
 
p = Point(1.0, 2.0)
print(p.__dict__)  # {'x': 1.0, 'y': 2.0}
 
# ๋”•์…”๋„ˆ๋ฆฌ๋ผ์„œ ์•„๋ฌด ์†์„ฑ์ด๋‚˜ ์ž์œ ๋กญ๊ฒŒ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค
p.z = 3.0
print(p.__dict__)  # {'x': 1.0, 'y': 2.0, 'z': 3.0}

๋”•์…”๋„ˆ๋ฆฌ๋Š” ์œ ์—ฐํ•˜์ง€๋งŒ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ํฌ๋‹ค. ํ•ด์‹œ ํ…Œ์ด๋ธ” ์ž์ฒด์˜ ๋ฉ”๋ชจ๋ฆฌ, ํ‚ค ๋ฌธ์ž์—ด ๊ฐ์ฒด, ๋™์  ๋ฆฌ์‚ฌ์ด์ง• ๋“ฑ์ด ์ธ์Šคํ„ด์Šค๋งˆ๋‹ค ๋ถ™๋Š”๋‹ค.

__slots__ ๋ฐฉ์‹

__slots__๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด __dict__๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋Œ€์‹  ์†์„ฑ์„ **๊ณ ์ •๋œ ์œ„์น˜์˜ ์Šฌ๋กฏ(๋ฐฐ์—ด)**์— ์ €์žฅํ•œ๋‹ค. C ๊ตฌ์กฐ์ฒด์˜ ํ•„๋“œ์ฒ˜๋Ÿผ ๊ฐ ์†์„ฑ์ด ๋ฏธ๋ฆฌ ์ •ํ•ด์ง„ ์˜คํ”„์…‹์— ์œ„์น˜ํ•˜๋Š” ๋ฐฉ์‹์ด๋‹ค.

@dataclass(slots=True)
class Point:
    x: float
    y: float
 
p = Point(1.0, 2.0)
print(p.__slots__)  # ('x', 'y')
 
# __dict__๊ฐ€ ์—†๋‹ค
hasattr(p, '__dict__')  # False
 
# ์„ ์–ธํ•˜์ง€ ์•Š์€ ์†์„ฑ์€ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์—†๋‹ค
p.z = 3.0  # AttributeError: 'Point' object has no attribute 'z'

์‹ค์ œ ๋ฉ”๋ชจ๋ฆฌ ์ฐจ์ด

import sys
from dataclasses import dataclass
 
@dataclass
class DictPoint:
    x: float
    y: float
 
@dataclass(slots=True)
class SlotPoint:
    x: float
    y: float
 
d = DictPoint(1.0, 2.0)
s = SlotPoint(1.0, 2.0)
 
print(sys.getsizeof(d) + sys.getsizeof(d.__dict__))  # 344 bytes
print(sys.getsizeof(s))                                # 48 bytes

์ธ์Šคํ„ด์Šค ํ•˜๋‚˜๋‹น ์ˆ˜์‹ญ๋ฐฑ ๋ฐ”์ดํŠธ ์ฐจ์ด๊ฐ€ ๋‚˜๋ฏ€๋กœ, 100๋งŒ ๊ฐœ๋ฅผ ๋งŒ๋“ค๋ฉด ์ˆ˜์‹ญ์ˆ˜๋ฐฑ MB ์ฐจ์ด๊ฐ€ ๋œ๋‹ค.

์ผ๋ฐ˜ (dict):  [๊ฐ์ฒด ํ—ค๋”] โ†’ [__dict__] โ†’ {ํ•ด์‹œ ํ…Œ์ด๋ธ”: "x"โ†’1.0, "y"โ†’2.0}
slots:        [๊ฐ์ฒด ํ—ค๋”] โ†’ [slot 0: 1.0] [slot 1: 2.0]

slots๋Š” ์–ธ์ œ ์“ธ๊นŒ

์ธ์Šคํ„ด์Šค๋ฅผ ์ˆ˜๋งŒ~์ˆ˜๋ฐฑ๋งŒ ๊ฐœ ์ƒ์„ฑํ•˜๋Š” ๊ฒฝ์šฐ(๋ฐ์ดํ„ฐ ํŒŒ์ดํ”„๋ผ์ธ, ์ด๋ฒคํŠธ ๋กœ๊ทธ ๋“ฑ)์— ๋ฉ”๋ชจ๋ฆฌ ์ฐจ์ด๊ฐ€ ์ฒด๊ฐ๋œ๋‹ค. ์†Œ๋Ÿ‰์ด๋ฉด ์ฒด๊ฐ ์—†๋‹ค.

slots์˜ ์ œ์•ฝ

  • ์„ ์–ธํ•˜์ง€ ์•Š์€ ์†์„ฑ์„ ๋™์ ์œผ๋กœ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์—†๋‹ค
  • ๋‹ค์ค‘ ์ƒ์† ์‹œ ๋ถ€๋ชจ ํด๋ž˜์Šค๋„ __slots__๋ฅผ ์ •์˜ํ•ด์•ผ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•œ๋‹ค
  • __dict__๊ฐ€ ์—†์œผ๋ฏ€๋กœ vars(obj) ๋Œ€์‹  dataclasses.fields()๋กœ ํ•„๋“œ๋ฅผ ์กฐํšŒํ•ด์•ผ ํ•œ๋‹ค
kw_only โ€” keyword-only ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ•์ œ

kw_only=True(Python 3.10+)๋กœ ์„ค์ •ํ•˜๋ฉด ๋ชจ๋“  ํ•„๋“œ๊ฐ€ keyword-only๊ฐ€ ๋œ๋‹ค. ํ•„๋“œ ์ˆ˜๊ฐ€ ๋งŽ์„ ๋•Œ ์‹ค์ˆ˜ ๋ฐฉ์ง€์— ์ข‹๋‹ค.

@dataclass(kw_only=True)
class DatabaseConfig:
    host: str
    port: int
    user: str
    password: str
    database: str
# โŒ TypeError โ€” ์œ„์น˜ ์ธ์ž๋กœ ์ „๋‹ฌ ๋ถˆ๊ฐ€
db = DatabaseConfig("localhost", 5432, "admin", "secret", "mydb")
 
# โœ… ๋ฐ˜๋“œ์‹œ keyword๋กœ ์ „๋‹ฌ
db = DatabaseConfig(host="localhost", port=5432, user="admin",
                    password="secret", database="mydb")

ํ•„๋“œ ๋‹จ์œ„๋กœ๋„ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ค. KW_ONLY ์„ผํ‹ฐ๋„ฌ์„ ์‚ฌ์šฉํ•˜๋ฉด ํ•ด๋‹น ์œ„์น˜ ์ดํ›„์˜ ํ•„๋“œ๋งŒ keyword-only๊ฐ€ ๋œ๋‹ค.

from dataclasses import dataclass, KW_ONLY
 
@dataclass
class Request:
    url: str              # ์œ„์น˜ ์ธ์ž OK
    method: str = "GET"   # ์œ„์น˜ ์ธ์ž OK
    _: KW_ONLY
    headers: dict = None  # keyword-only
    timeout: int = 30     # keyword-only
r = Request("https://api.example.com", "POST", headers={"Authorization": "Bearer ..."})

6. __post_init__๊ณผ InitVar

__post_init__ โ€” ์ดˆ๊ธฐํ™” ํ›„์ฒ˜๋ฆฌ

__init__์ด ์‹คํ–‰๋œ ์งํ›„ ์ž๋™์œผ๋กœ ํ˜ธ์ถœ๋œ๋‹ค. ํ•„๋“œ ๊ฐ’ ๊ฒ€์ฆ, ๊ณ„์‚ฐ ํ•„๋“œ ์ƒ์„ฑ ๋“ฑ์— ์‚ฌ์šฉํ•œ๋‹ค.

@dataclass
class Temperature:
    celsius: float
    fahrenheit: float = field(init=False)
    kelvin: float = field(init=False)
 
    def __post_init__(self):
        self.fahrenheit = self.celsius * 9 / 5 + 32
        self.kelvin = self.celsius + 273.15
t = Temperature(100)
print(t)
# Temperature(celsius=100, fahrenheit=212.0, kelvin=373.15)
๊ฐ’ ๊ฒ€์ฆ
@dataclass
class Age:
    value: int
 
    def __post_init__(self):
        if self.value < 0:
            raise ValueError(f"๋‚˜์ด๋Š” ์Œ์ˆ˜์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {self.value}")
        if self.value > 150:
            raise ValueError(f"๋น„ํ˜„์‹ค์ ์ธ ๋‚˜์ด์ž…๋‹ˆ๋‹ค: {self.value}")
Age(25)   # OK
Age(-1)   # ValueError: ๋‚˜์ด๋Š” ์Œ์ˆ˜์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: -1
๊ฐ’ ์ •๊ทœํ™”
@dataclass
class Tag:
    name: str
 
    def __post_init__(self):
        self.name = self.name.strip().lower()
t = Tag("  Python  ")
print(t)  # Tag(name='python')
InitVar โ€” ์ดˆ๊ธฐํ™” ์ „์šฉ ํŒŒ๋ผ๋ฏธํ„ฐ

InitVar๋กœ ์„ ์–ธํ•œ ํ•„๋“œ๋Š” __init__์—๋งŒ ์กด์žฌํ•˜๊ณ  ์ธ์Šคํ„ด์Šค ์†์„ฑ์œผ๋กœ๋Š” ์ €์žฅ๋˜์ง€ ์•Š๋Š”๋‹ค. __post_init__์˜ ์ธ์ž๋กœ ์ „๋‹ฌ๋œ๋‹ค.

from dataclasses import dataclass, field, InitVar
 
@dataclass
class User:
    name: str
    age: int
    birth_year: InitVar[int] = None
 
    def __post_init__(self, birth_year: int | None):
        if birth_year is not None:
            self.age = 2026 - birth_year
u1 = User("alice", age=30)
print(u1)  # User(name='alice', age=30)
 
u2 = User("bob", age=0, birth_year=1995)
print(u2)  # User(name='bob', age=31)
# birth_year๋Š” ์ธ์Šคํ„ด์Šค์— ์ €์žฅ๋˜์ง€ ์•Š๋Š”๋‹ค
์‹ค์ „ ์˜ˆ์‹œ โ€” DB ์กฐํšŒ ๊ฒฐ๊ณผ๋กœ ์ดˆ๊ธฐํ™”
from dataclasses import dataclass, field, InitVar
 
@dataclass
class Product:
    name: str
    price: float
    category: str = field(init=False, default="๋ฏธ๋ถ„๋ฅ˜")
    raw_data: InitVar[dict | None] = None
 
    def __post_init__(self, raw_data: dict | None):
        if raw_data:
            self.category = raw_data.get("category", "๋ฏธ๋ถ„๋ฅ˜")
            if "discount_rate" in raw_data:
                self.price *= (1 - raw_data["discount_rate"])
data = {"category": "์ „์ž๊ธฐ๊ธฐ", "discount_rate": 0.1}
p = Product("ํ‚ค๋ณด๋“œ", 100000, raw_data=data)
print(p)  # Product(name='ํ‚ค๋ณด๋“œ', price=90000.0, category='์ „์ž๊ธฐ๊ธฐ')

7. ์œ ํ‹ธ ํ•จ์ˆ˜

dataclasses ๋ชจ๋“ˆ์€ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ณ€ํ™˜ํ•˜๊ฑฐ๋‚˜ ์กฐํšŒํ•˜๋Š” ์œ ํ‹ธ ํ•จ์ˆ˜๋ฅผ ์ œ๊ณตํ•œ๋‹ค.

asdict() โ€” ๋”•์…”๋„ˆ๋ฆฌ ๋ณ€ํ™˜
from dataclasses import dataclass, asdict
 
@dataclass
class Address:
    city: str
    zipcode: str
 
@dataclass
class User:
    name: str
    age: int
    address: Address
u = User("alice", 30, Address("์„œ์šธ", "06000"))
 
print(asdict(u))
# {'name': 'alice', 'age': 30, 'address': {'city': '์„œ์šธ', 'zipcode': '06000'}}

์ค‘์ฒฉ๋œ dataclass๋„ ์žฌ๊ท€์ ์œผ๋กœ ๋ณ€ํ™˜๋œ๋‹ค. JSON ์ง๋ ฌํ™”ํ•  ๋•Œ ์œ ์šฉํ•˜๋‹ค.

import json
json.dumps(asdict(u), ensure_ascii=False)
# '{"name": "alice", "age": 30, "address": {"city": "์„œ์šธ", "zipcode": "06000"}}'
astuple() โ€” ํŠœํ”Œ ๋ณ€ํ™˜
from dataclasses import astuple
 
print(astuple(u))
# ('alice', 30, ('์„œ์šธ', '06000'))
replace() โ€” ์ผ๋ถ€ ํ•„๋“œ๋งŒ ๋ฐ”๊พผ ์ƒˆ ์ธ์Šคํ„ด์Šค

์›๋ณธ์„ ์ˆ˜์ •ํ•˜์ง€ ์•Š๊ณ  ๋ณต์‚ฌ๋ณธ์„ ๋งŒ๋“ ๋‹ค. frozen=True์ธ dataclass์—์„œ ํŠนํžˆ ์œ ์šฉํ•˜๋‹ค.

from dataclasses import replace
 
@dataclass(frozen=True)
class Config:
    host: str
    port: int
    debug: bool = False
prod = Config("api.example.com", 443)
dev = replace(prod, host="localhost", port=8080, debug=True)
 
print(prod)  # Config(host='api.example.com', port=443, debug=False)
print(dev)   # Config(host='localhost', port=8080, debug=True)
fields() โ€” ํ•„๋“œ ์ •๋ณด ์กฐํšŒ
from dataclasses import fields
 
for f in fields(User):
    print(f"name={f.name}, type={f.type}, default={f.default}")
# name=name, type=<class 'str'>, default=MISSING
# name=age, type=<class 'int'>, default=MISSING
# name=address, type=<class 'Address'>, default=MISSING

์ง๋ ฌํ™” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋‚˜ ORM์„ ์ง์ ‘ ๋งŒ๋“ค ๋•Œ ํ•„๋“œ ๋ฉ”ํƒ€์ •๋ณด๋ฅผ ๋™์ ์œผ๋กœ ์ฝ๋Š” ์šฉ๋„๋กœ ์“ด๋‹ค.


8. ์–ธ์ œ ์“ฐ๊ณ , ์–ธ์ œ ์•ˆ ์“ธ๊นŒ

dataclass๊ฐ€ ์ ํ•ฉํ•œ ๊ฒฝ์šฐ
  • ์„ค์ •/ํ™˜๊ฒฝ ๊ฐ์ฒด โ€” DB ์ ‘์† ์ •๋ณด, API ์„ค์ • ๋“ฑ ๊ตฌ์กฐํ™”๋œ ๊ฐ’
  • API ์‘๋‹ต/์š”์ฒญ ๋งคํ•‘ โ€” JSON ์Šคํ‚ค๋งˆ๊ฐ€ ๊ณ ์ •๋œ ๋ฐ์ดํ„ฐ
  • DTO (Data Transfer Object) โ€” ํ•จ์ˆ˜ ๊ฐ„, ๋ ˆ์ด์–ด ๊ฐ„ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • ๋„๋ฉ”์ธ ๋ชจ๋ธ โ€” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ํฌํ•จ๋œ ๊ฐ’ ๊ฐ์ฒด
  • ํ…Œ์ŠคํŠธ ํ”ฝ์Šค์ฒ˜ โ€” ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๊น”๋”ํ•˜๊ฒŒ ๊ตฌ์„ฑ
# ์„ค์ • ๊ฐ์ฒด
@dataclass(frozen=True)
class RedisConfig:
    host: str = "localhost"
    port: int = 6379
    db: int = 0
    password: str | None = None
 
# API ์‘๋‹ต ๋งคํ•‘
@dataclass
class PaginatedResponse:
    items: list[dict]
    total: int
    page: int
    per_page: int
    has_next: bool = field(init=False)
 
    def __post_init__(self):
        self.has_next = self.page * self.per_page < self.total
dataclass๊ฐ€ ์•„๋‹Œ ๊ฒŒ ๋‚˜์€ ๊ฒฝ์šฐ
์ƒํ™ฉ๋” ๋‚˜์€ ์„ ํƒ์ด์œ 
์Šคํ‚ค๋งˆ๊ฐ€ ์œ ๋™์ ์ธ JSONdictํ•„๋“œ๊ฐ€ ๋งค๋ฒˆ ๋‹ฌ๋ผ์ง€๋ฉด dataclass ์ •์˜๊ฐ€ ์˜๋ฏธ ์—†์Œ
๋ถˆ๋ณ€ + ํŠœํ”Œ ์–ธํŒฉ์ด ํ•„์š”NamedTuplex, y = point ํŒจํ„ด์„ ์ž์ฃผ ์“ธ ๋•Œ
๋ณต์žกํ•œ ๊ฒ€์ฆ/๋ณ€ํ™˜ ๋กœ์งpydantic ๋˜๋Š” attrsdataclass์˜ __post_init__๋งŒ์œผ๋กœ ๋ถ€์กฑํ•  ๋•Œ
๋‹จ์ˆœ 2~3๊ฐœ ๊ฐ’์„ ์ž„์‹œ๋กœ ๋ฌถ์„ ๋•Œtuple ๋˜๋Š” dictํด๋ž˜์Šค ์ •์˜ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ์˜คํžˆ๋ ค ํผ
ORM ๋ชจ๋ธSQLAlchemy Model ๋“ฑORM์€ ์ž์ฒด ๋ฉ”ํƒ€ํด๋ž˜์Šค ์‹œ์Šคํ…œ์ด ์žˆ์Œ

dataclass vs pydantic

pydantic์€ ๋Ÿฐํƒ€์ž„ ํƒ€์ž… ๊ฒ€์ฆ๊ณผ ์ž๋™ ๋ณ€ํ™˜์ด ํ•ต์‹ฌ์ด๋‹ค. age: int์— "30"(๋ฌธ์ž์—ด)์„ ๋„ฃ์œผ๋ฉด pydantic์€ ์ž๋™์œผ๋กœ int(30)์œผ๋กœ ๋ณ€ํ™˜ํ•˜์ง€๋งŒ, dataclass๋Š” ๊ทธ๋Œ€๋กœ "30"์ด ๋“ค์–ด๊ฐ„๋‹ค. API ์ž…๋ ฅ ๊ฒ€์ฆ์ด ํ•„์š”ํ•˜๋ฉด pydantic, ๋‚ด๋ถ€ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐํ™”๋งŒ ํ•„์š”ํ•˜๋ฉด dataclass๊ฐ€ ์ ํ•ฉํ•˜๋‹ค.

dataclass vs attrs

attrs(2015)๋Š” ์‚ฌ์‹ค dataclass(2017, PEP 557)๋ณด๋‹ค ๋จผ์ € ๋‚˜์™”๊ณ , dataclass๊ฐ€ attrs์—์„œ ์˜๊ฐ์„ ๋ฐ›์•„ ๋งŒ๋“ค์–ด์กŒ๋‹ค. attrs๋Š” validator, converter ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ๊ธฐ๋ณธ ์ œ๊ณตํ•ด์„œ __post_init__์œผ๋กœ ์ง์ ‘ ๊ตฌํ˜„ํ•ด์•ผ ํ•˜๋Š” ๊ฒ€์ฆ/๋ณ€ํ™˜์„ ์„ ์–ธ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.

from attrs import define, field
from attrs.validators import gt, instance_of
 
@define
class User:
    name: str = field(validator=instance_of(str))
    age: int = field(validator=gt(0))
 
User("alice", -1)  # ValueError โ€” validator๊ฐ€ ์ž๋™์œผ๋กœ ๊ฒ€์ฆ

dataclass๋Š” ํ‘œ์ค€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ผ ์„ค์น˜๊ฐ€ ํ•„์š” ์—†๊ณ  ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ์ถฉ๋ถ„ํ•˜๋‹ค. ํ•„๋“œ๋ณ„ ๊ฒ€์ฆ/๋ณ€ํ™˜์ด ๋ณต์žกํ•ด์ง€๋ฉด attrs๋ฅผ ๊ณ ๋ คํ•˜๋ฉด ๋œ๋‹ค.