동시성이란 무엇인가?
동시성은 종종 동시성을 구현하는 실질적인 방법들과 혼동된다. 어떤 프로그래머들은 동시성과 병렬 프로세싱을 동의어로 생각한다. 먼저 동시성에 관해 적절하게 정의해야 한다.
가장 중요한 것이지만 동시성은 병렬성 (parallelism)과 다르다.
동시성은 애플리케이션 구현과 관련된 것도 아니다. 동시성은 프로그램, 알고리즘 도는 문제의 속성이며, 병렬성은 동시에 발생하는 문제에 관한 접근 방식의 하나이다
레슬리 램포트(Leslie Lamport)가 1976년 발표한 논문인 <Time, Clocks and the Ordering of Events in Distributed System>에서는 동시성의 개념을 다음과 같이 정의 한다
$$ "어떤 두 이벤트가 서로 영향을 미칠 수 없을 때, 이 두 이벤트는 동시성을 갖는다" $$
위 정의에서 이벤트에 프로그램, 알고리즘 또는 문제를 대입해 생각해보자, 이들이 순서에 의존하지 않는 완전한 또는 부분적인 컴포넌트로 분해된다면 동시성을 가진다고 말할 수 있다. 이들은 각각 독립적으로 처리되며, 처리 순서가 최종 결과에 영향을 주지 않는다.
이것은 이들이 동시 (simultaneously) 또는 병렬로 처리될 수 있음을 의미한다. 이런 방식으로 정보를 처리하는 것이 병렬 프로세싱이다. 하지만 반드시 그래야하는 것이 아니다
멀티코어 프로세스나 컴퓨팅 클러스터 활용시 선호되는 분산 방식으로 작업을 처리하는 것은 동시성 문제를 해결하는 과정에서 만들어진 자연스러운 결과다. 그렇다고 이것이 효율적으로 동시성을 다루는 유일한 방법은 아니다. 동기 방식이 아닌 병렬적으로 실행하지 않는 방법들을 이용해서 동시성 문제에 접근할 수도 있다. 어떤 문제가 동시성을 가진다는 것은 이를 보다 효율적으로 처리할 수 있는 특별한 방법들을 사용할 기회라는 것을 의미한다.
우리는 어던 문제를 만나면, 습과적으로 전통적인 방법들을 이용해 (즉, 단계적 수행을 통해) 그 문제를 해결하려 한다. 우리들 대부분이 정보를 생각하고 처리하는 방식은 이와 유사하다. 한순간에 한 가지일만 수행하는 동기 알고리즘(synchronous algorithm)을 단계별로 이용하는 것이다. 그러나 이런 사고 방식은 대규모 문제를 해결한거나 많은 사용자 또는 소프트웨어 에이전트의 요구를 동시에 만족시켜야 하는 상황에는 적합하지 않다.
- 작업(job)을 처리하는 시간이 단일 프로세싱 유닛(단일 머신, CPU 코어 등)의 성능에 따라 제한될 때
- 프로그램이 이전 입력에 대한 처리를 완료할 때까지 새로운 입력을 받거나 처리할 수 없을 때
이와 같은 문제들은 일반적인 애플리케이션에서의 세 가지 시나리오와 이어지며, 병행 프로세싱을 이용하면 사용자의 요구를 만족시킬 수 있다.
프로세싱 분산 (processing distribution) : 문제의 규모가 너무 커서 이를 납득할 수 있는 시간 내에 (한정된 리소스를 이용해) 처리하는 유일한 방법은 해당 작업을 병렬로 처리할 수 있는 여러 프로세싱 유닛으로 분산해서 실행하는 것뿐이다
애플리케이션 응답성 (application responsiveness) : 애플리케이션은 이전 입력에 대한 처리를 완료하지 않았더라도 응답성(새로운 입력의 수용)을 유지해야 한다
백그라운드 프로세싱 (background processing) : 모든 태스크를 동기 방식으로 실행할 필요는 없다. 특정한 실행 결과에 즉각 접근하지 않아도 된다면 그 실행은 지연시키는 것이 합당할 수 있다
프로세싱 분산 시나리오는 병렬 프로세싱과 직접 연결되며 일반적으로 멀티스레딩과 멀티프로세싱 모델로 해결된다. 애플리케이션 응답성 시나리오는 반드시 병렬 프로세싱이 필요하지 않으며, 실질적인 문제의 세부 사항에 따라 다르다. 애플리케이션 응답성 문제는 애플리케이션이 여러 클라리언트 (사용자 또는 소프트웨어 에이전트)를 독립적으로 (각 에이전트에 대한 대응의 성공 여부와 관계없이) 처리해야 하는 경우를 다룬다.
흥미로운 것은 이 문제들이 배타적이지 않다는 것이다. 종종 애플리케이션 응답성을 유지하면서 동시에 모든 입력을 단일 프로세싱 유닛으로만 처리할 수 없을 것이다. 따라서 다르게 보이는 (겉으로 보기에는 동시성에 관한 대안이거나 충돌하는 접근 방식으로 보이는) 것들을 동시에 사용하게 된다.
파이썬은 동시성을 다루는 여러 방법들을 제공하며 주요한 방법들은 다음과 같다
- 멀티스레딩(multithreading) : 부모 프로세스의 메모리 콘텍스트를 공유하는 여러 처리 스레드들을 실행한다. 가장 유명하고 오래된 동시성 모델이며 입출력(I/O)이 많이 수행되거나 사용자 인터페이스 응답성을 유지해야 하는 애플리케이션에서 효과적으로 작동한다. 매우 경량이지만 사용 시 많은 주의가 필요하고 메모리 안전 리스크를 내포한다.
- 멀티프로세싱(multiprocessing) : 여러 독립 프로세스를 실행해 분산된 환경에서 작동하도록 한다. 동작 자체는 스레드와 유사하지만 공유 메모리 콘텍스트에 의존하지 않는다. 파이썬의 특성상 CPU를 많이 사용(CPU-intensive)하는 애플리케이션 보다 적합하다. 멀티스레딩보다 무거우며 프로세스 간 통신 패턴(inter-process communication pattern)을 구현해 프로세스들이 조화롭게 동작하도록 해야 한다
- 비동기 프로그래밍 (asynchronous programming) : 여러 협력적 태스크들은 단일 애플리케이션 프로세스에서 실행한다. 협력적 태스크들은 스레드처럼 작동하지만 태스크 전환은 운영체제 커널이 아니라 애플리케이션 자체에서 촉진한다. I/O 바운드(I/O bound) 애플리케이션, 특히 동시다발적인 네트워크 커넥션을 다루는 프로그램에 적합한다. 비동기 프로그래밍의 단점은 전용 비동기 라이브러리를 사용해야 한다는 것이다
멀티스레딩
스레드 (thread)는 실행 중인 스레드 (thread of excution)를 줄여서 부르는 말이다. 프로그래머는 작업(work)을 스레드로 나눠 동시에 실행할 수 있다. 스레드들은 부모 프로세스와 연결되어 있으며 같은 메모리 콘텍스트를 공유하기 때문에 쉽게 통신한다. 스레드 실행은 OS 커널에서 관장한다.
멀티 스레딩은 멀티프로세스(multiprocessor)와 멀티코어 머신(multicore machine) 덕분에 얻게 되는 이점이 있다. 각 스레드는 별도 CPU 코어에서 실행되므로 프로그램은 더욱 빠르게 실행된다. 파이썬은 멀티코어 CPU에서의 멀티스레딩을 통해 얻을 수 있는 성능상 이점에 일부 제한이 있다.
파이썬을 이용해 새로운 실행 스레드를 시작시키는 가장 간단한 방법은 threading.Thread() 클래스를 사용하는 것이다
from threading import Thread
def my_function():
print("printing from thread")
if __name__ == "__main__":
thread = Thread(target=my_function)
thread.start()
thread.join()
my_function() 함수는 새 스레드에서 실행할 함수이다. 이 함수는 Thread 클래스 생성자에 키워드 인수로 전달한다. 이 클래스의 인스턴스는 애플리케이션을 캡슐화하고 제어한다.
새로운 Thread 클래스 인스턴스를 만들었다고 곧바로 새로운 스레드가 시작되지 않는다. 스레드를 시작하려면 start() 매서드를 호출해야 한다. 새로운 스레드가 시작되면 대상 함수가 종료될 때까지 메인 스레드 곁에서 실행된다. 위 코드는 join() 매서드를 이용해 추가적인 스레드가 종료될까지 명시적으로 기다린다.
join() 매서드는 블로킹 동작(blocking operation)이다. 다시 말해 스레드는 실제로 아무 일도 하지 않으며 (CPU 시간을 소비하지 않는다), 특정한 이벤트가 발생하는 것을 기다릴 뿐이다.
모든 스레드는 같은 메모리 콘텍스트를 공유한다. 그러므로 스레드들이 동일한 데이터 구조에 접근하는지 조심해야 한다. 두 개의 병렬적인 스레드들이 같은 변수를 어떠한 보조 장치 없이 업데이트하면, 스레드 실행의 미묘한 시점 변화에 따라 최종 결과가 예기치 못하게 바뀔 수 도 있다.
파이썬의 스레드 처리 방식
파이썬은 다중 커널 레벨 스레드 (Kernel-level thread)를 사용해 모든 인터프리터 레벨 스레드 (interpreter-level thread)를 실행한다. 커널 레벨 스레드는 OS 커널에 의해 운영 및 스케줄링된다. CPython은 OS별 시스템 콜을 이용해 스레드를 생성하고 조인하며, 스레드 실행 시점과 스레드링 실행할 CPU 코어에 대한 완전한 통제를 갖지 않는다. 이 책임은 온전히 시스템 커널에게 일임한다. 시스템 커널은 우선도가 더 높은 스레들ㄹ 실행하기 위해 실행 중인 스레드를 언제든 선점할 수 있다.
안타깝게도 파이썬(CPython 인터프리터) 언어의 표준 구현은 많은 콘텍스트에서 스레드의 효용을 저하시키는 중요한 한계가 있다. 파이썬 객체에 접근하는 모든 동작은 하나의 글로벌 록에 의해 직렬화 (serialize) 된다. 왜냐하면 많은 인터프리터 내부 구조가 스레드-세이프하지 않으며 보호되어야 하기 때문이다. 모든 동작에서 록이 필요한 것은 아니며 스레드가 록을 해제해야만 하는 특정한 상황이 존재한다.
병렬 프로세싱 콘텍스트에서 무언가 직렬화되어 있다는 것은 동작을 순차적으로 수행하는 것(한 동작이 종료되면 그 다음 동작을 수행하는 것)을 의미한다. 동시성 프로그램에서 의도되지 않은 직렬화는 일반적으로 우리가 원하지 않는 것이다.
이 CPython 인터프리터 매커니즘을 글로벌 인터프리터 록(global interpeter lock, GIL)이라 한다. CPython는 GIL을 유지할 것이라고 가정하는 것이 안전하므로 GIL을 어떻게 다룰 것인지 학습하는 것이 좋다. GIL이 모든 파이썬 언어 구현에 존재하지는 않는다. GIL은 CPython, Stackless python, Pypy에 관한 제약사항이다.
파이썬에서의 멀티스레딩의 핵심은 무엇인가? 스레드가 순수한 파이썬 코드만 포함하며, 어떤 I/O 조작 (소켓을 통한 통신 등)도 하지 않는다면 스레드를 사용한다 해도 프로그램의 속도를 높일 만한 여지는 거의 없다. 왜냐하면 GIL이 모든 스레드의 실행을 글로벌하게 직렬화할 것이기 때문이다. 하지만 GIL은 파이썬 객체를 보호하는 것에만 초점을 맞춘다는 점을 기억해야 한다. 실질적으로 GIL은 소켓 호출과 같은 많은 블로킹 시스템 콜에 해체(release)된다. 또한 Python/C API 기능을 사용하지 않는 확장 영역에서도 해체될 수 있다. 즉 다중 스레드는 I/O 조작을 하거나 특별히 다듬어진 C 확장 코드를 완병하게 병렬 실행할 수 있음을 의미한다.
멀티스레딩을 이용하면 프로그램이 외부 리소스를 기다리는 시간을 효율적으로 활용할 수 있다. GIL을 해체한(CPython 내부적으로 발생) 대기 중 스레드는 준비 (standby) 상태로 기다리다가 결과가 돌아오면 깨어난다(wake up). 마지막으로 프로그램이 응답 인터페이스를 제공해야 하는 경우, 심지어 OS가 시분할을 이용해야 하는 싱클 코어 환경의 경우에도 멀티스레딩을 해답이 될 수 있다. 멀티 스레딩을 이용하면 프로그램은 소위 백그라운드에서 무거운 계산 작업을 하는 동안에도 사용자와 쉽게 상호작용할 수 있다.
언제 멀티스레딩을 사용해야 하는가?
- 애플리케이션 응답성 : 새로운 입력을 받고 이전 입력에 대한 처리를 마치지 못했더라도 주어진 시간 안에 응답해야 하는 애플리케이션
- 여러 사용자가 사용하는 애플리케이션 및 네트워크 통신 : 여러 사용자들의 입력을 동시에 받고 네트워크를 통해 사용자들과 자주 커뮤니케이션하는 애플리케이션, GIL이 해체된 CPython의 부분들을 활용해 록에 의한 영향을 줄일 수 있음을 의미한다.
- 작업 위임(work delegation) 및 백드라운드 프로세싱 : 외부 애플리케이션이나 서비스를 이용해 무거운 작업의 많은 부분을 수행하는 애플리케이션, 사용자가 작성한 코드는 이런 리소스들에 대한 게이트웨이 역할을 하는 경우
멀티프로세싱
스레드를 완전하고 안전하게 다루려면 동기 접근 방식에 비해 막대한 양의 코드가 필요하다. 스레드 풀과 통신 큐를 만들고, 스레드의 예외를 우하게 처리하고, 비율 제한 기능을 제공해 스레드 안정성까지 고려해야 한다. 몇몇 외부 라이브러리로부터 단 하나의 함수를 병렬적으로 실행하기 위한 것만으로 수십 줄의 코드를 추가해야 한다. 그리고 해당 라이브러리의 스레드-세이트 여부를 외부 패키지 개발자의 약속에 의해서만 온전히 의존해야 한다. 그저 I/O 바운드 태스크를 위해 실제 적용할 수 있는 해법치고는 그 비용이 높게 느껴진다.
이런 병렬성을 확보할 수 있는 대안적인 접근 방식으로 멀티프로세싱 (multiprocessing)이 있다. GIL을 이용해 서로를 제한하지 않는 분리된 파이썬 프로세스들을 이용하면 리소스를 보다 효율화할 수 있다. 이는 실제로 CPU를 많이 사용하는 태스크를 수행하는 멀티코어 프로세서에서 실행되는 애플리케이션에서 특히 중요하다. 현재 멀티프로세싱은 (CPython 인터프리터를 사용하는) 파이썬 개발자들이 사용할 수 있는 유일한 내장 동시성 기법이며, 이를 이용하면 모든 상황에서 다중 프로세서 코어의 이점을 활용할 수 있다.
스레드가 아닌 다중 프로세스를 이용하는 것의 또 다른 장점으로는 메모리 콘텍스트를 공유하지 않는 점을 들 수 있다. 그래서 애플리케이션에서 데이터가 오염되거나 데드룩 또는 레이스 컨디션이 잘 발생하지 않는다. 메모리 콘텍스트 공유하지 않는다는 것은 분리된 프로세스들 사이에 데이터를 전달하기 위한 추가 노력이 필요하다는 의미지만. 신뢰할 수 있는 프로세스 간 통신을 구현하는 좋은 방법들은 많다.
파이썬에서는 마치 스레드처럼 프로세스 사이에 통신을 하게 해주는 몇 가지 프리미티브를 제공한다.
어떤 프로그래밍 언어든 새로운 프로세스들을 시작하는 가장 기본적인 방법은 특정한 시점에서 프로그램을 포크(fork)하는 것이다. POSIX와 POSIX 유사 시스템(UNIX, macOS 등)에서 fork는 새로운 자식 프로세시를 생성하는 시스템 콜이다.
파이썬에서는 os.fork() 함수를 이용해 이를 실행한다.
import os
pid_list = []
def main():
pid_list.append(os.getpid())
child_pid = os.fork()
if child_pid == 0:
pid_list.append(os.getpid())
print()
print("CHLD: hey, I am the child process")
print("CHLD: all the pids I know %s" % pid_list)
else:
pid_list.append(os.getpid())
print()
print("PRNT: hey, I am the parent ")
print("PRNT: the child pid is %d" % child_pid)
print("PRNT: all the pids I know %s" % pid_list)
if __name__ == "__main__":
main()
os.fork()는 새로운 프로세스를 낳는다(spawn). 두 프로세스는 동일한 메모리 상태를 가지고 있지만 fork()가 호울되는 순간 메모리는 분리된다. os.fork()는 정숫값을 반환한다. 반환값이 0이면 현재 프로세스가 자식 프로세스임을 알 수 있다. 부모 프로세스는 그 자식 프로세는 ID(PID) 번호를 받는다.
내장 multiprocessing 모듈
multiprocessing 모듈은 프로세스들을 마치 스레드인 것처럼 다룰 수 있는 간편한 방법을 제공한다. 이 모듈에서 제공하는 Process 클래스 Thread 클래스와 유사하며 플랫폼과 관계없이 다음과 같이 이용할 수 있다.
from multiprocessing import Process
import os
def work(identifier):
print(f'Hey, I am the process ' f'{identifier}, pid: {os.getpid()}')
def main():
processes = [Process(target=work, args=(number,)) for number in range(5)]
for process in processes:
process.start()
while processes:
processes.pop().join()
if __name__ == "__main__":
main()
Process 클래스는 start(), join() 매서드를 제공하며, 이들은 Thread 클래스와 유사하다. start() 매서드는 새로운 프로세스를 낳고, join() 매서드는 자식 프로세스가 종료될 때까지 대기한다.
프로세스가 생성되면 메모리가 포드된다(POSIX와 POSIX 유사 시스템의 경우). 또한 메모리 상태도 복사되며, Process 클래스는 추가적인 args 인수를 생성자에 제공하며 이를 통해 데이터도 함께 전달된다.
프로세스들은 기본적으로 메모리를 공유하지 않으므로, 통신을 위해서는 추가 작업이 필요하다. multiprocessing 모듈은 프로세스 간 통신을 수월하게 하기 위해 다음과 같은 몇 가지 방법을 제공한다
- multiprocessing.Queue 클래스 이용 : 이 클래스는 스레드 간 통신에서 사용했던 queue.Queue와 기능 적으로 동일
- multiprocessing.Pipe 이용 : 소켓과 유사한 양방향 통신 채널
- multiprocessing.sharedctype 모듈 이용 : 이 모듈을 이요하면 프로세스 사이에 공유되는 전용 메모리 폴에 임의의 C타입(ctype 모듈로부터)을 만들 수 있다
프로세스 풀 이용하기
스레드 대신 다중 프로세스를 이용할 때는 몇 가지 오버헤드가 존재한다. 프로세스들은 각기 독립된 메모리 콘텍스트를 갖기 때문에 대부분 메모리 사용이 증가한다. 즉 자식 프로세스의 수를 제한하지 않으면 멀티스레드 애플리케이션에서 스레드 수를 제한하지 않을 때보다 더 큰 문제가 될 수 있다.
OS가 fork() 시스템 콜을 copy-on-write(COW) 구문으로 지원하다면, 새로운 프로세스들을 시작할 때의 메모리 오버헤드는 현저하게 감소한다. COW는 OS로 하여금 동일한 메모리 페이지의 중복을 제거하고 프로세스 중 하나가 메모리 페이지를 수정하고자 할 때만 복사하게 한다. 에를 들어 리눅스 COW 구문과 함께 fork() 시스템 콜을 제공하지만 원도우는 그렇지 않다. 또한 COW를 이용하면 오랫동안 실행되는 프로세스들을 제거하는 이점도 얻을 수 없다.
멀티 프로세싱에 의존하는 애플리케이션에서의 리소스 사용을 통제하는 최고의 패턴은 스레드를 기술한 것과 같은 방식으로 프로세스 풀을 만드는 것이다.
multiprocessing 모듈의 가장 강력한 점은 모듈이 즉시 사용할 수있는 Pool 클래스를 제공한다는 점이다. 이 클래스는 다중 프로세스 워커를 관리하는 복잡함을 모두 처리해준다. 이 구현은 필요한 보일러프레이트 코드 양과 양방향 통신에 관련된 문제들을 대폭 줄여준다. 또한 join() 메서드를 직접 이용할 필요도 없다. Pool는 콘텍스트 관리자 (context manager(with 문장과 함께 사용)로 사용될 수 있기 때문이다.
import time
from multiprocessing import Pool
import requests
SYMBOLS = ("USD", "EUR", "PLN", "NOK", "CZK")
BASES = ("USD", "EUR", "PLN", "NOK", "CZK")
POOL_SIZE = 4
def fetch_rates(base):
response = requests.get(f"https://api.vatcomply.com/rates?base={base}")
response.raise_for_status()
rates = response.json()["rates"]
# note: same currency exchanges to itself 1:1
rates[base] = 1.0
return base, rates
def present_result(base, rates):
rates_line = ", ".join([f"{rates[symbol]:7.03} {symbol}" for symbol in SYMBOLS])
print(f"1 {base} = {rates_line}")
def main():
with Pool(POOL_SIZE) as pool:
results = pool.map(fetch_rates, BASES)
for result in results:
present_result(*result)
if __name__ == "__main__":
started = time.time()
main()
elapsed = time.time() - started
print()
print("time elapsed: {:.2f}s".format(elapsed))
다음과 같이 코드에서 볼 수 있듯이 워커 폴을 다루기 단순해 졌으며, 작업 큐는 물로 start()/ join() 메서드를 유지보수 하지 않아도 되기 때문이다. 수정된 코드는 유지보수는 물론 문제가 발생했을 때 디버깅하기도 쉽다. 사실 명시적으로 멀티프로세싱을 다루는 부분은 def main() 함수 뿐이다.
multiprocessing.dummy를 멀티스레딩 인터페이스 이용하기
multiprocessing 모듈의 고수준 추상화(Pool 클래스 같은)는 threading 모듈이 제공하는 간단한 도구들보다 훨씬 큰 장점을 제공한다. 그렇지만 멀티스레딩보다 멀티프로세싱이 항상 더 좋은 것은 아니다. 스레드를 사용하는 것이 프로세스를 사용하는 것보다 나은 경우도 많은데, 주로 낮은 지연이나 높은 리소스 효율성이 필요할 때이다. 또한 프로세스 대신 스레드를 사용하고자 할때마다 multiprocessing 모듈이 제공하는 유용한 추상화를 모두 포기할 필요도 없다. multiprocessing.dummy 모듈은 멀티프로세싱 API를 복제하지만 새로운 프로세를 포크하거나 생성하는 대신 다중 스레드를 이용한다.
import time
from multiprocessing.pool import Pool as ProcessPool, ThreadPool
import requests
SYMBOLS = ("USD", "EUR", "PLN", "NOK", "CZK")
BASES = ("USD", "EUR", "PLN", "NOK", "CZK")
POOL_SIZE = 4
def fetch_rates(base):
response = requests.get(f"https://api.vatcomply.com/rates?base={base}")
response.raise_for_status()
rates = response.json()["rates"]
# note: same currency exchanges to itself 1:1
rates[base] = 1.0
return base, rates
def present_result(base, rates):
rates_line = ", ".join([f"{rates[symbol]:7.03} {symbol}" for symbol in SYMBOLS])
print(f"1 {base} = {rates_line}")
def main(use_threads=False):
if use_threads:
pool_cls = ThreadPool
else:
pool_cls = ProcessPool
with pool_cls(POOL_SIZE) as pool:
results = pool.map(fetch_rates, BASES)
for result in results:
present_result(*result)
if __name__ == "__main__":
started = time.time()
main()
elapsed = time.time() - started
print()
print("time elapsed: {:.2f}s".format(elapsed))
위와 같이 사용하면 이전에 작성햇던 main() 함수를 사용자가 사용할 프로세싱 백엔드(프로세스 또는 스레드)를 선택할 수 있도록 통제를 넘겨줄 수 있다.
multiprocessing 모듈 관점에서는 멀티프로세싱, 멀티스레딩은 많은 공통점을 가진 것으로 본다. 두 방법은 모두 OS에 의존해 동시성을 촉진한다. 이들은 유사한 형태로 운영되며 종종 유사한 추상화를 통해 통신 또는 메모리 안정성을 보장한다.
'programming > python' 카테고리의 다른 글
[PY] 객체지향 - 상속 (inheritance) (0) | 2022.11.24 |
---|---|
[PY] 파이썬의 global과 nonlocal (0) | 2022.11.23 |
[PY] 파이썬 진수 변환 (0) | 2022.11.22 |
[PY] 머신러닝과 부동소수점 (0) | 2022.11.21 |
[알고리즘] 정렬(Sort) 정리 (0) | 2022.10.11 |
댓글