[day-14] 클로저, 데코레이터, args/kwargs, 이터레이터/제너레이터, nonlocal, 모듈, 예외처리

Joohyung Park·2024년 1월 17일
0

[모두연] 오름캠프

목록 보기
14/95
post-thumbnail

1/16일 과제와 연관된 실습

class Calc:
    pass

add = Calc()
add

# 더할 수 있는 함수의 기능이 지금 Calc에 없다.

add.oper = lambda x, y : x + y  
# oper라는 인스턴스를 만들고 인스턴스를 함수로 만들었다!
add.oper(10, 20)	# 30

클로저

클로징 되어야 하는 공간의 한 변수에 접근하는 것을 말한다

def outer_function(x):
    def inner_function(y):
        return x + y
    return inner_function

inner = outer_function(100)
inner(200) 	# 300
# inner 입장에서 100을 변경할 수 있는 방법이 없다. 
# 변수를 감추고 싶을때 사용
def make_counter():
    count = 0

    def counter():
        # global count # error 왜냐하면 글로벌(전역 영역에) 영역에 count가 없음
        nonlocal count  # 바로 밖에 있는 count를 가져오겠다!
        count += 1  # 이것만 쓰면 error. 지역변수 내에 count가 없기 때문에 
        return count

    return counter

counter_a = make_counter()
print(counter_a())  # 1
print(counter_a())  # 2

# 이렇게 함으로 순서는 항상 상승된다는 보장
# DB에서 게시물 번호같은 경우, 클로저 같은거 사용

함수 안에서 nonlocal 키워드로 변수 선언을 하면 그 함수 바로 밖에 있는 함수의 해당 변수를 가져온다.

count = 0
def counter():
    global count
    count += 1
    return count

counter_a = counter
print(counter_a())  # 1
count = 0	# 효과 없음
print(counter_a())  # 2
print(counter_a())  # 3

# 이렇게 함으로 순서는 항상 상승된다는 보장할 수 있습니다.
# DB에서 게시물 번호

global로 함수 내에서 변수를 선언하면 가장 바깥에 있는 해당 변수의 값을 참조한다.

클로저 아님

def calc(oper):
    def add(x, y):
        return x + y
    def sub(x, y):
        return x - y
    if oper == 'add':
        return add
    if oper == 'sub':
        return sub

add = calc('add')
add(10, 20)	# 30

위의 코드는 변수를 계속 참조하고 있지 않음으로 클로저가 아니다.

클로저 예시로 바꾼다면

def calc(oper, original):
    def add(x, y):
        return original + x + y
    def sub(x, y):
        return original - x - y
    if oper == 'add':
        return add
    if oper == 'sub':
        return sub

add = calc('add', 1000)
add(10, 20) # add 입장에서 1000을 바꿀 수 없다

sub = calc('sub', 1000)
sub(10, 20) # sub 입장에서 100을 바꿀 수 없다
# 970

original이라는 값이 계속 참조되고 있다.

짤막상식

# 아래와 같이 사용하면 문제가 발생할 수도 있다.
def 은행(원금):
    def 입금(입금금액):
        return 원금 + 입금금액
    return 입금

이호준통장_입금함수 = 은행(1000) # 1000만원을 초기에 입금
이호준통장_입금함수(100)   

# 문제점1: 입금 금액에 커스텀 인스턴스를 넣어서 __add__를 추가할 수 있는가?
# 문제점2: 출금은 안되는가?

강사님이 이더리움을 예시로 들어주었는데 이 가상화폐는 음수가 없고 양수만 있다는 특징이 있다. but 커스텀 인스턴스로 음수를 넣어버리면 나올 수 있는 최댓값이 나와서 이렇게 해킹당한 적이 있다고 한다. 코드 짤때 여러번 신경써야 될 것 같다.


데코레이터 - 별표 1개

분명한 목적성, 가독성, 명료함을 위하여 사용한다

def login(function):
    '''
    로그인을 확인하는 로직
    '''
    pass

@login
def 게시판읽기():
    pass

위의 코드는 로그인 한 사람만 게시판을 읽을 수 있도록 설계된 예시이다. 여기서 @login부분이 데코레이터이다.
다른 예시를 보자.

# 데코레이터는 함수가 호출되었을 때 실제 실행되는 함수
# 데코레이터의 return 함수가 실행되는 것

def simple_decorator(function):
    def wrapper():
        print("전")
        function()
        print("후")
    return wrapper

@simple_decorator   # hello()는 simple_decorator의 인자로 들어감
def hello():
    print("Hello, World!")

hello() # 데코레이터가 없는 상태에서는 simple_decorator(hello)() 와 같다.
# 파선아실 => 파라미터는 선언할 때, 아규먼트는 실행할 때 

위의 코드를 실행하면 "Hello, World"가 바로 출력될 것 같지만! @simple_decorator로 데코레이터를 선언해주었기에 이러한 hello 함수는 simple함수의 파라미터로 들어가게 된다. 이후, return wrapper 구문(이전에 함수도 반환이 된다고 했었다!) 으로 wrapper함수가 실행되며 따라서 출력은 "전", "Hello, World!", "후"가 나오게 된다.
위 코드는 아래와 같다.

def hello():
    print("Hello, World!")

print("전")
hello()
print("후")

데코레이터에 의해 hello()는 바로 실행되지 않고 wrapper()함수의 function()으로 실행된다.

매개변수가 있는 데코레이터

def simple_decorator(function):
    def wrapper(a, b): # point 1
        print('전')
        result = function(a, b) # point 2
        print('후')
        return result
    return wrapper

@simple_decorator
def hello(a, b):
    return a + b

hello(10, 20) # => simple_decorator(hello)(10, 20) => wrapper(10, 20)
# 매개변수는 보통 개수가 같게 설정, hello랑 wrapper의 예시 

위에서도 @simple_decorator로 simple함수를 먼저 실행하도록 하였고 파라미터로는 hello함수를 주었다.
return wrapper에 의해 wrapper함수가 실행되며 이번엔 function에 a와 b라는 값이 10과 20으로 존재하는데 이를 hello 함수의 반환값인 a+b로 계산하고 result에 저장한다. 출력은 제일 처음에 있는 '전' 그리고 '후'가 출력되며 마지막으로 return result에 의해 hello함수의 반환값인 30이 출력된다.

짤막상식

강사님은 이렇게 사용한다

# step0 : 필요성에 대한 인식

sum([1, 2, '3', 4, 5, '6']) 
# error! 그런데 이게 되게 하고 싶다?

sum(map(int, l))이렇게 할수도 있지만 보통

l = [1, 2, '3hello', 4, 5, 'l6l']

@전처리 # 이것이 함수 전이나 후에 무언가를 작업해 줄 수 있는 데코레이터이기 때문에 
sum(l)

이런식으로 명시해서 사용 한다고 한다.
이제 실제로 구현하는 방법을 각 스텝에 따라 보도록 하자.

# step1 : 골격을 만든다.

def data_pre(function):
    def wrapper():
        return None
    return wrapper

@data_pre
def mean(l):
    return sum(l) / len(l)

mean([1, 2, '3', 4, 5, '6'])    # data_pre(mean)()

나는 이러한 이상한 리스트의 평균을 구하고 싶다!

# step2 : 파라미터를 설정한다.
# 얻어가야할 포인트(데코레이터와는 관련 없음)
# map은 _len_가 없어서 len()이 안됨
# 포인트2: list 형변환은 부담이 있는 연산이니 주의를 해야 한다.

def data_pre(function):
    def wrapper(iter_obj):
        return list(map(int, iter_obj))
    return wrapper

@data_pre
def mean(l):
    return sum(l) / len(l)

mean([1, 2, '3', 4, 5, '6']) 
# data_pre(mean)(iter_obj) # iter_obj에 [1, 2, '3', 4, 5, '6']

# 이 3.5는 실제 mean 반환값인가? 실제로는 wrapper의 반환값이다.

위 코드의 결과값은 [1, 2, 3, 4, 5, 6]이다.
wrapper 함수의 파라미터인 iter_obj는 우리가 mean의 아규먼트로 넣었던 리스트이다. 이러한 리스트를 map함수를 이용해 원소를 int형으로 바꿔주었고 그걸 list로 형변환 하는 과정을 거쳤다.
원래 mean함수의 반환값은 출력되지 않은 모습이다.

# 진짜로 평균을 구하고 싶다면 이렇게
def data_pre(function):
    def wrapper(iter_obj):
        my_list = list(map(int, iter_obj))
        result = function(my_list)
        return result
    return wrapper

@data_pre
def mean(l):
    return sum(l) / len(l)

mean([1, 2, '3', 4, 5, '6']) 

리스트의 모든 원소를 절대값으로 더하는 예제를 보도록 하자.

data = [-1, 2, 3, 4, -5]  

def all_abs(f):
    def wrapper(iter_obj):
        return f([abs(i) for i in iter_obj])
    return wrapper

@all_abs
def _sum(l):
    return sum(l)

_sum(data)

위의 코드도 앞서 설명했던 것과 마찬가지로 바로 _sum함수가 실행되는 것이 아닌 all_abs함수가 먼저 실행된다. all함수의 파라미터로 _sum함수를 받았고, wrapper함수를 반환한다. 이후 wrapper함수의 파라미터는 data값이 들어가 있고
이 wrapper함수는 data값을 절대값 씌운 리스트를 _sum함수로 보내 더하게 된다.


lambda 함수

임시로 생성하는 함수. 재사용 하려면 def 사용하자

numbers = [1, 2, 3, 4, 5]
print(list(filter(lambda x: x > 3, numbers)))  # 출력: [4, 5]


# 재사용 여부에 따라 lambda를 사용할지 def 사용할지 판단하면 된다.
def f(x):
    return x > 3
numbers = [1, 2, 3, 4, 5]
print(list(filter(f, numbers)))  # 출력: [4, 5]

args, kwargs

기본적으로 패킹과 언패킹에 대해 알아둬야 한다.

# 패킹
10, 20, 30	# (10, 20, 30)

패킹은 하나로 묶는다고 생각하면 편하다.

# 언패킹
a, b, c = [10, 20, 30]
a

for i, j in [[10, 20], [30, 40]]:
    print(i * j)

언패킹은 패킹의 반대로, 풀어서 생각한다고 이해하면 된다.
이를 에스터리스크(*)를 통해서 구현 가능하다.

def print_args(*args):
    print(args) # 출력: (100, True, 'Licat')

print_args(100, True, 'Licat')

위의 코드에서 print함수의 파라미터로 3가지 값이 1개의 튜플로 묶인 모습을 볼 수 있다.
이러한 패킹 파라미터가 먼저 나오면 뒤에는 일반 파라미터가 올 수 없다는 것을 기억하자.

# 패킹하는 파라미터가 먼저나오면 뒤에있는 일반 파라미터는 사용하면 안됨
# error
def print_args( *args, a, b):
    print(args) # 출력: ('Licat', 'hello', 10)

print_args(100, True, 'Licat', 'hello', 10)

이러한 아규먼트와 더불어 키워드 아규먼트(kwargs)도 있는데 딕셔너리 형태로 아규먼트를 받는다. 딕셔너리 형태의 값을 넘기는데 에스터리스크(*)이 한개이면 key값만 넘어간다.

# 키워드 아규먼트 
def print_kwargs(a, **kwargs):
    print(a)
    print(kwargs)

print_kwargs(100, name='Licat', age='10')
# 100
# {'name': 'Licat', 'age': '10'}

아래의 기능은 파이썬과 소수의 언어만 가능하다. 대부분 인자의 순서 그대로 간다.

def f(a, b, c, d, e):
    print(a, b, c, d, e)

f(1, 2, e=3, d=4, c=5)  # 이렇게 보장해주는 언어가 별로 없다.

이터레이터와 제너레이터

# 이터레이터란, 값을 차례대로 꺼낼 수 있는 객체
# 시퀀스형 자료형이란 index가 있고 indexing, slicing이 가능한 자료형
# 제너레이터는 이터레이터를 만드는 함수

# dict는 이터레이터인가요? 네
for i in {'one': 1, 'two': 2}:
    print(i)

list(map(lambda x:x[0], {'one':1, 'two': 2}))

# dict는 시퀀스형 자료형인가요? 아뇨
# 이터레이터 클래스를 생성할 때에는 __iter__와 __next__를 거의 필수로 선언함
class MyIterator:
    def __init__(self, stop):
        self.current_value = 0  # 현재 값
        self.stop = stop  # 순회를 멈출 값

    def __iter__(self):
        return self

    def __next__(self):
        if self.current_value >= self.stop:
            raise StopIteration
        result = self.current_value
        self.current_value += 1
        return result

x = iter(MyIterator(5)) # for문의 시작
print(next(x))
print(next(x))
print(next(x))
print(next(x))
print(next(x))
print(next(x))

위의 반복은 raise에 의해 멈추게 된다.

# 제너레이터는 이터레이터를 생성해주는 함수로, yield 키워드를 사용하여 만듬
def my_generator():
    x = 10
    yield x
    x = 20
    yield x
    x = 30
    yield x
    x = 40
    yield x

for i in my_generator():
    print(i)

제너레이터는 yield 키워드로 만든다.
위의 코드에서 for문을 돌리게 되면 처음엔 10이 나오고 두번째는 처음 yield부분에서 시작하고 그 후엔 두번째 yield에서 함수가 시작하고... 쭉 가서 40까지 출력하게 된다.
예시를 하나 더 보자.

def my_generator():
    x = 0
    while True:
        yield x
        x += 2

list(zip('hello', my_generator()))
[('h', 0), ('e', 2), ('l', 4), ('l', 6), ('o', 8)]

위에서 zip함수로 한글자씩 튜플로 매칭이 되고 이를 리스트로 묶은 값을 출력하게 된다.
다음 예시를 보자.

# 예시2
def my_generator():
    l = ['짝', '홀']
    while True:
        yield l[t := False] # := 이 문법은 왈러스 연산자라고 3.8버젼에 등장했음
        yield l[t := True]

list(zip([0, 1, 2, 3, 4, 5, 6], my_generator()))
# [(0, '짝'), (1, '홀'), (2, '짝'), (3, '홀'), (4, '짝'), (5, '홀'), (6, '짝')]

왈러스 연산자라는 것도 있다고 알고만 넘어가자.
위의 예시의 쉬운 버전은 다음과 같다.

def my_generator():
    x = 0
    while True:
        if x == 0:
            yield '짝'
            x += 1
        else:
            yield '홀'
            x = 0

list(zip([0, 1, 2, 3, 4, 5, 6], my_generator()))
# 
[(0, '짝'), (1, '홀'), (2, '짝'), (3, '홀'), (4, '짝'), (5, '홀'), (6, '짝')]

처음에는 '짝', 그 다음에는 x==1이므로 '홀'이 되고 다시 '짝'이 되고.. 이 과정을 반복한다.
왈러스 연산자를 사용해 이런 코드도 가능하다.

x = 10
while x := x - 1:
    print(x)

짤막상식

리스트로 while문을 돌릴 수 있다.

l = [10, 20, 30]
while l:
    print(l.pop())

nonlocal

중첩 함수 내부에서 바깥 함수의 변수를 참조할 수 있게 하는 기능

# nonlocal은 주로 클로저(closure)에서 변수의 값을 변경하고자 할 때 사용합니다.
# 실무에서 이렇게 짜는 경우는 거의 없다!

a = 10
def f():
    a = 100
    print(f'f a: {a}')
    def ff():
        a = 1000 => 100
        print(f'ff a: {a}')
        def fff():
            nonlocal a # global a로 변경해보세요.
            a = 100
            print(f'fff a: {a}')
        fff()
        print(f'ff a: {a}')
    ff()
f()
print(f'global a: {a}')
f 
# a: 100
# ff a: 1000
# fff a: 100
# ff a: 100
# global a: 10

nonlocal로 변수를 선언하면 바로 위의 함수의 해당 변수값을 가져와 변경할 수 있다.
위의 예시에서는 네번째 ff의 a값이 100인걸 보면 알 수 있다. fff함수에서 global a로 선언하면 가장 최상단의 a의 값이 바뀐다.


모듈

클래스나 함수, 변수를 다른 파일(.py)에 작성하여 다른 파이썬 코드에서 재사용하도록 한 것

import info as q # info라는 이름 대신 q

q.name
q.age
q.hello()

info.py라는 파일안에 저 변수들이 다 존재하고, 그 것을 활용하는 예제이다.

# a > b > c > infotest.py 파일이 있을 경우
# name = 'hojun'만 infotest.py에 있습니다

import a.b.c.infotest as q

q.name

여러 폴더 안에 있는 경우는 위와 같이 점을 활용해 사용한다.
모듈과 라이브러리 중, 모듈이 더 작은 범위라고 보는 사람도 있긴 한데 강사님은 똑같다 보고 라이브러리와 프레임워크만 구분할 수 있으면 된다고 했다.
다음은 sys모듈에 관한 코드이다.

import sys

sys.path    # 모듈을 읽어오는 경로
sys.modules # 기본적으로 읽어온 모듈들

모듈을 표현하는 방식은 2가지가 존재한다.

# 1번 방식, 모듈이 별로 없으면 1번이 좋을 수도
from info import name, age, hello

print(name)
# 2번 방식
# 여러 모듈을 포함해야 하는 실무에서는 2번 선호

import info

info.name
info.age

서로 다른 모듈을 불러왔는데 변수명이 같다면 뒤에 선언한 변수로 덮어 쓴다.

from info import name, age, hello
from infotwo import name, age => 이걸로

짤막상식

  • 파이썬은 라이브러리, 프레임워크, 서드파티가 정말 많기에 직접 코드를 짜기전에 존재하는 요소인지 찾아보자
  • 라이브러리 : 코드에 라이브러리가 섞여 들어가는 코드. request, bs4등
  • 프레임워크 : 설계 도면이 정해져 있어 이 설계대로 코딩하는 경우. 레고 설계 도면처럼 완성품에 설계도면이 존재
  • 서드파티 : 프레임워크에 붙는 코드. Django에서는 Django 로그인, DRF, Django-cors 등
  • 경로에서 점을 붙이면 현재 경로

자주 사용되는 모듈

os모듈

import os

# Django에 3.x에서 os모듈이 빠졌다.
# os모듈 대신 Path라는 모듈이 들어왔다.
# os모듈은 너무 강력하다.
# os모듈에 경로지정

os.mkdir('licat') # licat이란 폴더 생성, 삭제는 os.rmdir()
os.getcwd() # 현재 경로 반환
os.open('a.txt', os.O_CREAT | os.O_WRONLY) # 파일 생성(os.O_CREAT: 필요한 경우 파일을 생성, os.O_WRONLY: 파일을 쓰기 전용 모드로 연다.)
os.rename('a.txt', 'b.txt') # a.txt파일을 b.txt파일로 변경
os.remove('b.txt')

datetime

import datetime

s = datetime.datetime(2023, 9, 19, 14, 10)
print(s)
print(s.year, s.month, s.day, s.hour, s.minute)

s = datetime.datetime(2023, 9, 18, 14, 10)
print(s.weekday()) # 월요일0, 화요일1, 수요일2 ... 일요일6

today = datetime.date.today()
days = datetime.timedelta(days=100)
today + days # 100일 후 시간

graduation_date = datetime.date(2023, 12, 29)
today = datetime.date.today()

print(graduation_date - today) # 졸업까지 남은 일자

json

import json

d = {
    'one': 1,
    'two': 2,
    'three': 3
}

s = json.dumps(d)
print(type(s)) # str
d = json.loads(s)
print(type(d)) # dict

json에서 주의할 점은 다음과 같다.

  • 앞에 변수명을 쓰지 않는다. (s = [{...}])
  • 안의 key값은 쌍 따옴표
  • dict key와 콜론은 붙여쓰고 콜론과 value는 한 칸 띄움
  • True대신 true
# (point4)주의! True는 안됩니다. true여야 합니다. 
[{
    "지역이름": "서울", # point2 json은 쌍 따옴표여야 합니다!
    "확진자수": 5607, # point3 dict key와 콜론은 붙여쓰고 콜론과 value는 한 칸 띄어씁니다.
    "격리해제수": 5050,
    "사망자수": 66,
    "십만명당발생율": 57.61,
    "지역별확진자비율": 22.53
},
{
    "지역이름": "부산",
    "확진자수": 491,
    "격리해제수": 423,
    "사망자수": 4,
    "십만명당발생율": 14.39,
    "지역별확진자비율": 1.97
},
{
    "지역이름": "대구",
    "확진자수": 7141,
    "격리해제수": 6933,
    "사망자수": 196,
    "십만명당발생율": 293.09,
    "지역별확진자비율": 28.69
}]

<무작위 데이터 만드는 사이트>
https://datagenerator.co.kr/

import json
# 파이썬 객체를 직렬화(json형식으로 만듬) 
s = json.dumps(data)
# 문자형 자료형을 파이썬 객체로 역직렬화(json파일을 딕셔너리 형태로 반환)
d = json.loads(s)

collections 모듈

import collections
# 알고리즘 문제에서 정말 많이 사용합니다.
# deque문제: 페이지 교체 알고리즘, 회전 초밥 등 다양한 문제에서 활용됩니다.

d = collections.deque([1, 2, 3, 4])
d.rotate(1) # 1번 오른쪽으로 쉬프트 합니다. 숫자를 2로 바꾸어 비교해보세요.
d # 출력: deque([4, 1, 2, 3])

d = collections.deque([1, 2, 3, 4])
d.rotate(2) # 1번 오른쪽으로 쉬프트 합니다. 숫자를 2로 바꾸어 비교해보세요.
d # 출력: deque([3, 4, 1, 2])
c = collections.Counter('hello world')
c	
# Counter({'h': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'w': 1, 'r': 1, 'd': 1})

counter라는 기능으로 글자의 빈도수를 셀수도 있다.
most_common 함수로 가장 많이 나온 글자순으로 보여주기도 한다.

c.most_common()
  # [('l', 3),
  #	('o', 2),
  #	('h', 1),
  #	('e', 1),
  #	(' ', 1),
  #	('w', 1),
  #	('r', 1),
  #	('d', 1)]

예외처리

오류가 나면 어떤 오류가 났는지 항상 정리하는 것을 추천!

실무에서는 1/0 등으로 코드 중간에 일부러 에러를 발생시키기도 한다고 한다.

print('hello')
try:
    s = 1/0
    print(s)
except:
    print('error가 발생되었습니다!')
print('world')

try~except

try:
    # s = 1/0
    l = []
    l.appnd(10)
    print(s)
except ZeroDivisionError:
    print('0으로 나누어졌습니다!')
except AttributeError:
    print('메서드 없어요!')

일단 try문을 실행하고 오류나면 except문 실행
raise 키워드로 오류를 만들어 낼 수도 있다.

raise ValueError('코드를 잘~~ 만들어주세요.')

연습문제

1-1. 데코레이터 문제: 1부터 100까지 더하는 함수를 만들고 이 함수에 데코레이터를 실행시켜 몇 초가 걸리는지 확인해주세요.

import time

def time_decorator(func):
    def wrapper(x):
        start_time = time.time()
        result = func(x)
        end_time = time.time()
        print(f"함수가 {end_time - start_time} 초가 걸렸습니다.")
        return result
    return wrapper

@time_decorator
def add_value(x):
    return sum([i for i in range(1,x+1)])

add_value(100)

1-2. 제너레이터 문제: 0부터 시작하여, 매 호출 시마다 2씩 증가하는 값을 반환하는 제너레이터 함수를 작성하세요. 100이 되면 멈춰야 합니다.

def gen():
    x = 0
    while x <= 100:
        yield x
        x += 2
        
for i in gen():
    print(i)

1-3. 예외처리 문제: 사용자로부터 두 개의 숫자를 입력받아 나눗셈을 수행하는 함수를 작성하세요. 이 때 0으로 나눌 때에는 예외처리를 해주세요.

def divide(x, y):
    try:
        return x / y
    except ZeroDivisionError:
        print("0이 아닌 값으로 나눠주세요")

divide(10, 0)

피드백

오늘은 진도를 많이 나간편인가? 월요일 화요일은 오전에 녹화강의만 들었어서 그런지 뇌에 과부하가 ㅋㅋㅋㅋ... 받아들이는데 힘들었다. 클래스도 힘들었지만 이거랑 비교하면 음 아무리 생각해도 클래스보단 나은가..? 잘모르겠다. 문제를 풀면서 다시 한번 정리하면서 보니까 그제서야 좀 이해가 되는 것 같다. 다른 사람들도 말이 많이 없던데 나만 어려운게 아닌 것 같다.

오늘! 맛집 추천 프로젝트 회의를 좀 해봤는데 의견이 조금씩 다르기도 하고 말로만 하다보니까 큰 그림이 잘 안그려져서 일단 각자 크롤링해와서 설명해주기로 하였다. 다음주 수요일까지인데 괜찮겠지..라고 생각해 본다.

profile
익숙해지기 위해 기록합니다

0개의 댓글