[운영체제] Chapter6 Synchronization Tools

강현주·2025년 3월 16일

copperating process

  • 시스템에서 실행되는 다른 프로세스에 영향을 미치거나 받을 수 있는 프로세스
  • 논리적인 주소 공간을 직접 공유하거나 공유 메모리 또는 메세지 전달을 통해서만 데이터를 공유하도록 허용될 수 있음. but, 공유 데이터에 동시 액세스는 데이터 불일치를 초래할 수 있음.

이 chapter에서는 논리적 주소 공간을 공유하는 cooperating(협력) process의 질서 있는 실행을 보장하여, 데이터 일관성을 유지하는 다양한 메커니즘에 대해 설명함.

6.1 Background

이 chapter에서는 동시 또는 병렬 실행이 여러 프로세스가 공유하는 데이터의 무결성과 관련된 문제에 어떻게 기여할 수 있는지 설명함.

chapter3에서 비동기적으로 실행되고 아마도 데이터를 공유하는 cooperating sequential process 또는 스레드로 구성된 시스템 모델을 개발함. 이 모델을 생산자-소비자 문제로 설명했는데, 이는 많은 운영체제 기능의 대표적인 패러다임임. 특히, 섹션 3.5에서 제한된 버퍼를 사용하여 프로세스가 메모리를 공유할 수 있도록 하는 방법을 설명함. 원래 솔루션은 버퍼에 동시에 최대 BUFFER_SIZE-1 개의 아이템만 허용함. 이 결함을 고치기 위해 알고리즘을 수정하고 싶다고 가정하면, 한 가지 가능성은 integer 변수 count를 추가하는 것임. count는 버퍼에 새 항목을 추가할 때마다 증가하고 한 항목을 제거할 때마다 감소함.

생산자 프로세스의 코드는 다음과 같이 수정할 수 있음.

while (true) {
  /* produce an item in next produced */
  
  while (count == BUFFER SIZE)
  	; /* do nothing */
    
  buffer[in] = next produced;
  in = (in + 1) % BUFFER SIZE;
  count++;
}

소비자 프로세스의 코드는 다음과 같이 수정할 수 있음.

while (true) {
  while (count == 0)
  	; /* do nothing */
  
  next consumed = buffer[out];
  out = (out + 1) % BUFFER SIZE;
  count--;
  
  /* consume the item in next consumed */
}

위에 표시된 생산자 소비자 루틴은 개별적으로는 정확하지만 동시에 실행하면 정확하게 작동하지 않을 수 있음.
ex) count=5 일 때, 생산자와 소비자 프로세스가 동시에 명령문 count++, count--를 실행
-> count는 4, 5, 또는 6이 될 수 있음(부정확).
-> 생산자 소비자가 별도로 실행한 올바른 결과는 count=5임.

count++의 기계어 구현

register1 = count
register1 = register1 + 1
count = register1

count--의 기계어 구현

register2 = count
register2 = register2 − 1
count = register2

(register1, register2는 로컬 CPU 레지스터 중 하나임.)

count++, count-- 동시 실행은 순차적 실행과 동일함. 그 실행은 이전에 제시된 lover-level의 문이 임의의 순서로 인터리빙된 것. 인터리빙 중 하나는 다음과 같음:

T0: producer execute register1 = count {register1 = 5}
T1: producer execute register1 = register1 + 1 {register1 = 6}
T2: consumer execute register2 = count {register2 = 5}
T3: consumer execute register2 = register2 − 1 {register2 = 4}
T4: producer execute count = register1 {count = 6}
T5: consumer execute count = register2 {count = 4}

count=4로 잘못된 상태임. 이는 4개의 버퍼가 가득 찼음을 나타내지만, 실제로는 5개의 버퍼가 가득 참. T4와 T5 명령문의 순서를 바꾸면 잘못된 상태인 count=6이 됨.

두 프로세스가 변수 count를 동시에 조작하도록 허용했기 때문에 이러한 잘못된 상태에 도달하게 됨. 여러 프로세스가 같은 데이터를 동시에 액세스하고 조작하고 실행 결과가 액세스가 발생하는 특정 순서에 따라 달라지는 상황을 race condition이라고 함. race condition을 방지하려면, 한 번에 하나의 프로세스만 변수 count를 조작할 수 있게 해야함. 보장하기 위해서, 프로세스는 어떤 방법으로든 동기화가 필요함.

이 같은 상황은 시스템의 다른 부분이 리소스를 조작할 때 운영체제에서 자주 발생함. 멀티스레드 애플리케이션에서는 여러 스레드가 서로 다른 처리 코어에서 병렬로 실행됨. 이런 활동으로 발생하는 변경들이 간섭받지 않기를 원함. 이 중요한 문제 때문에, 이 chpater의 많은 부분을 copperating process 간의 process synchronizationcoordination에 기울임.


6.4 Hardware Support for Synchronization

소프트웨어 기반 솔루션이 최신 컴퓨터 아키텍처에서 작동한다는 보장이 없음. 이 섹션에서는 critical-section(임계 구역) 문제를 해결하기 위한 세 가지 하드웨어 명령어를 제시함. 이런 기본 연산은 동기화 툴로 직접 사용할 수도 있고, 더 추상적인 동기화 메커니즘의 기포를 형성하는 데 사용할 수도 있음.

6.4.1 Memory Barriers

시스템이 명령어를 재정렬 할 수 있는 것은 신뢰할 수 없는 데이터 상태로 이어질 수 있음. 컴퓨터 아키텍처가 프로그램에 제공할 메모리 보증을 어떻게 결정하는지를 momory model이라고 함. 일반적으로 메모리 모델은 다음 두 가지 카테고리 중 하나임:

  1. Strongly ordered, 한 프로세서의 메모리에 수정이 있는 경우 다른 모든 프로세서가 즉시 볼 수 있음.
  2. Weakly ordered, 한 프로세서의 메모리의 수정사항이 다른 프로세서에 즉시 보이지 않음.

메모리 모델은 프로세스의 유형에 따라 다르므로, 커널 개발자는 공유 메모리 멀티프로세서에서 어떠한 가정도 할 수 없음 메모리 수정의 가시성에 관하여.
이 이슈를 해결하기 위해, 컴퓨터 아키텍처는 메모리의 변경 사항을 다른 모든 프로세서에 전파되도록 강요할 수 있는 명령어를 제공함. 이러한 명령어는 memory barriers(장벽) 또는 memory fences(울타리)라고 함. 메모리 배리어 명렁어가 수행될 때, 시스템은 모든 로드와 저장이 후속 로드 또는 저장 작업이 수행되기 전에 완료되도록 함. 따라서, 명령어가 재정렬된 경우에도, 메모리 배리어는 저장 작업이 메모리에서 완료되고, 이후 로드 또는 저장 작업이 수행되기 전에 다른 프로세서에서 볼 수 있게 함.

최근의 예제를 메모리 배리어를 사용하여 예상한 출력을 얻도록 함.
Thread 1에 메모리 배리어 작업을 추가하면

while (!flag)
	memory barrier();
print x;

flag의 값이 x값보다 먼저 로드되도록 보장함. 마찬가지로 Thread 2에서 수행된 할당 사이에 메모리 배리어를 배치하면

x = 100;
memory barrier();
flag = true;

x에 대한 할당이 flag에 대한 할당보다 먼저 발생하도록 보장함.

Peterson의 솔루션과 관련하여, 그림 6.4의 연산의 재정렬을 피하기 위해 entry section의 처음 두 할당문 사이에 메모리 배리어를 둘 수도 있음. 메모리 배리어는 매우 low-level 연산으로 간주되고 일반적으로 커널 개발자가 상호 배제를 보장하는 특정 코드를 작성할 때만 사용함.

6.4.2 Hardware Instructions

많은 현대 컴퓨터 시스템은 한 word의 content를 테스트하고 수정하거나, 두 워드의 content를 원자적으로 교환하는 특수 하드웨어 명령어를 제공함. 이 특수 명령어들은 critical-section 문제를 상대적으로 간단하게 해결하는 데 사용될 수 있음. 특정 머신에 대한 한 가지 명령어를 설명하는 대신, 이러한 유형의 명령어 뒤에 있는 주요 개념을 test_and_set()compare_and_swap() 명령어로 추상화함.

test_and_set() 명령어는 그림 6.5와 같이 정의할 수 있음.

이 명령어의 중요한 특징은 원자적으로 실행된다는 것임. 따라서, 두 개의 test_and_set() 명렁어가 동시에 실행되면 임의의 순서로 순차적으로 실행됨. 머신이 test_and_set()를 지원하는 경우, false로 초기화된 부울 변수 lock을 선언하여 상호배제를 구현할 수 있음. 프로세스 Pi의 구조는 그림 6.6에 나와 있음.

compare_and_swap() 명령어(CAS)는 test_and_set()와 마찬가지로 두 워드에서 원자적으로 작동하지만, 두 워드의 content를 바꾸는 것을 기반으로 하는 다른 메커니즘을 사용함.

CAS 명령어는 세 개의 피연산자에 대해 작동하며 그림 6.7에 정의되어 있음.

피연산자 값은 표현식 ⁕value == expected이 true인 경우에만 new_value으로 설정됨. 그럼에도 불구하고, CAS는 항상 변수 value의 원래 값을 반환함. 이 명령어의 주요한 특징은 원자적으로 실행된다는 것. 따라서, 두 개의 CAS 명령어가 동시에 실행되면 임의의 순서로 순차적으로 실행됨.

CAS를 사용한 Mutual exclusion는 다음과 같이 제공될 수 있음: 전역 변수 lock이 선언되고 0으로 초기화. compare_and_swap()을 호출하는 첫 번째 프로세스는 value을 1로 설정.

그런 다음 critical section으로 들어감. 원래 lock값이 예상 값 0과 같았기 때문. 이후 compare_and_swap() 호출은 성공하지 못함. lock이 현재 예상 값 0과 같지 않기 때문. 프로세스가 critical section을 종료하면 lock을 다시 0으로 설정하여 다른 프로세스가 임계 구역에 들어갈 수 있음. 프로세스 Pi의 구조는 그림 6.8에 나와 있음.

이 알고리즘은 mutual-exclusion 요구 사항을 충족하지만, bound-waiting 요구 사항은 충족하지 않음. 그림 6.9은 모든 임계 구역 요구사항을 충족하는 compare_and_swap() 명령어를 사용하는 또 다른 알고리즘임.

일반적인 데이터 구조는

boolean waiting[n];
Int lock;

waiting 배열의 요소는 false, lock은0으로 초기화됨. 상호 배제 요구 사항이 충족됨을 증명하기 위해 프로세스 Pi가 waiting[i] == false 또는 key == 0인 경우에만 임계 구역에 들어갈 수 있음. key값은 compare_and_swap() 이 실행되는 경우에만 0이 될 수 있음. compare_and_swap()을 실행하는 첫 번째 프로세스 key == 0을 찾는다. 다른 모든 프로세스는 기다려야 함. 변수 waiting[i]는 다른 프로세스가 임계 구역을 벗어나는 경우에만 false가될 수 있음. 하나의 waiting[i]만 false로설정되어 상호 배제 요구 사항이 유지됨.

progress 요구 사항이 충족되었는지 증명하기 위해, 상호 배제를 위해 제시된 인수가 여기에도 적용됨. 임계 구역을 종료하는 프로세스는 lock을 0으로 설정하거나 waiting[i]를 false로 설정하기 때문. 둘 다 임계 구역에 들어가기를 기다리는 프로세스가 진행할 수 있도록 함.

bounded-waiting 요구 사항이 충족됨을 증명하기 위해, 프로세스가 임계 구역을 벗어날 때, 순환 순서 (i+1, i+2, …, n-1, 0, …, i-1)로 waiting 배열을 스캔함. 이 순서에서 진입 구역에 있는 첫 번째 프로세스(waiting[j]==true)를 임계 구역에 진입할 다음 프로세스로 지정함. 따라서 임계 구역에 진입하기 위해 대기하는 모든 프로세스는 n-1턴 내에 진입함.

6.4.3 Atomic Variables

일반적으로, compare_and_swap() 명령어는 상호 배제를 제공하는 데 직접 사용되지 않음. 오히려, 임계 구역 문제를 해결하는 다른 도구를 구성하기 위한 기본 구성 요소로 사용됨. 그런 도구 중 하나는 정수 및 부울과 같은 기본 데이터 타입에 대한 원자 연산을 제공하는 원자 변수임. 원자 변수는 counter가 증가할 때처럼, 업데이트되는 동안 단일 변수에 데이터 경쟁이 발행할 수 있는 상황에서 상호 배제를 보장하는 데 사용할 수 있음.

원자 변수룰 지원하는 대부분의 시스탬은 원자 변수에 접근하고 이를 조작하기 위한 함수뿐 아니라 특수한 원자 데이터 타입도 제공함. 이러한 함수는 종종 compare_and_swap() 연산을 사용하여 구현됨. 예를 들어, 다음 원자 정수 sequence를증가시킴:

increment(&sequence);

여기서 increment() 함수는 CAS명령어를 사용하여 구현됨:

void increment(atomic_int *v){
	int temp;

	do {
		temp = *v;
	}
	while (temp != compare_and_swap(v, temp, temp+1));
}

원자 변수가 원자 업데이트를 제공하더라도, 모든 상황에서 경쟁 조건을 완전히 해결하지는 못함. 예를 들어, 섹션 6.1에서 설명한 제한된 버퍼 문제에서 count에원자 정수를 사용할 수 있음. 이렇게 하면 count에대한 업데이트가 원자적임. 그러나 생산자 소비자 프로세스에는 count의값에 따라 조건이 달라지는 while루프가 있음. 버퍼가 현재 비어 있고 두 소비자가 count>0을기다리며 루프를 돌고 있는 상황을 고려하면, 생산자가 버퍼에 한 항목을 입력한 경우 두 소비자 모두 while루프를 종료하고(count가 0이 아니기 때문), count 값이 1로만 설정되었더라도 소비를 계속할 수 있음.

원자 변수는 운영 체제와 동시 애플리케이션에서 일반적으로 사용되지만, 그 사용은 종종 카운터와 시퀀스 생성기와 같은 공유 데이터 단일 업데이트로 제한됨.


6.7 Monitors

세마포어는 프로세스 간 동기화를 위한 편리하고 효과적인 메커니즘을 제공하지만, 잘못 사용하면 감지하기 어려운 타이밍 오류가 발생할 수 있음. 이러한 오류는 특정 실행 시퀀스가 발생하는 경우에만 발생하고, 항상 발생하는 것은 아니기 때문.

불행하게도, 뮤텍스 락이나 세마포어를 사용할 때도 타이밍 오류가 발생할 수 있음. 이를 설명하기 위해 임계 구역 문제에 대한 세마포어 솔루션을 검토함. 모든 프로세스는 1로 초기화된 이진 세마포어 변수 mutex를 공유함. 각 프로세스는 임계 구역에 들어가기 전에 wait(mutex)를실행하고 그 후에 signal(mutex)를실행해야 함. 이 시퀀스를 따르지 않으면 두 프로세스가 동시에 임계 구역에 있을 수 있음. 다름으로, 발생할 수 있는 몇 가지 어려움을 나열함. 이러한 어려움은 단일 프로세스가 제대로 동작하지 않더라도 발생할 수 있음.

  • 프로그램이 세마포어 mutex에서 wait() 및 signal() 작업이 실행되는 순서를 바꾸어 다음과 같이 실행한다고 가정함:
signal(mutex);
…
critical section
…
wait(mutex);

이상황에서는 여러 프로세스가 임게 구역에서 동시에 실행되어 상호 배제 요구 사항을 위반할 수 있음. 이 오류는 여러 프로세스가 임계 구역에서 동시에 활성화되어 있는 경우에만 발견될 수 있음.

  • 프로그램이 signal(mutex)를 wait(mutex)로 대체한다고 가정
wait(mutex);
…
critical section
…
wait(mutex);

이 경우 프로세스는 세마포어를 사용할 수 없으므로 wait()에대한 두 번째 호출에서 영구적으로 차단됨.

  • 프로세스가 wait(mutex) 또는 signal(mutex) 또는 둘 다 생략한다고 가정하면, 상호 배제가 위반되거나 프로세스가 영구적으로 차단됨.

이러한 오류를 처리하는 한 가지 전략은 간단한 동기화 도구를 하이 레벨 언어 구성 요소로 통합하는 것. 이 섹션에서는 기본적인 하이 레벨 동기화 구성 요소인 모니터 유형을 설명함.

6.7.1 Monitor Usage

추상 데이터 타입 (abstract data type, ADT)은 ADT의 특정 구현과는 독립적인 해당 데이터에 대해 작동하는 함수 집합으로 데이터를 캡슐화함. 모니터 타입은 모니터 내에서 상호 배제가 제공되는 프로그래머가 정의한 작업 집합을 포함하는 ADT. 모니터 타입은 또한 모니터의 상태를 정의하는 값을 갖는 변수를 선언. 해당 변수에서 작동하는 변수 중. 모니터 유형의 구문은 그림 6.11에 나와 있으며, 모니터 유형의 표현은 다양한 프로세스에서 직접 사용할 수 없음. 따라서 모니터 내에 정의된 함수는 모니터와 그 형식 매개변수 내에서 로컬로 선언된 변수에만 액세스할 수 있음. 마찬가지로 모니터의 로컬 변수는 로컬 함수에서만 액세스 가늠.

모니터 구조는 모니터 내에서 한 번에 하나의 프로세스만 활성화되도록 보장함. 결과적으로 프로그래머는 동기화 제약 조건을 명시적으로 코딩할 필요가 없음(그림 6.12). 그러나 지금까지 정의된 모니터 구조는 일부 동기화 체계를 모델링하기에는 충분히 강력하지 않음. 이를 위해 추가 동기화 메커니즘을 정의해야 함. 이러한 메커니즘은 condition 구조에서 제공함. 맞춤의 동기화 체계를 작성해야하는 프로그래머는 condition 타입의 하나 이상의 변수를 정의할 수 있음:

condition x, y;

조선 변수에서 호출할 수 있는 유일한 작업은 wait() 및 signal()임. x.wait(); 이 작업을 호출하는 프로세스는 다른 프로세스가 호출할 때까지 일시 중단됨을 의미.

x.signal();

x.signal() 연산은 정확히 하나의 중단된 프로세스를 재개함. 중단된 프로세스가 없으면 signal() 연산은 효과가 없음. 즉, x의상태는 연산이 실행된 적이 없는 것과 같음(그림 6.13). 이 연산을 세마포어와 관련된 signal() 연산과 대조하면, 항상 세마포어의 상태에 영향을 줌.

x.signal() 연산이 프로세스 P에의해 호출될 때, 조건 x와 연관된 중단된 프로세스 Q가 존재한다고 가정하면, Q가 실행을 재개할 수 있다면, 신호를 보내는 프로세스 P는 기다려야 함. 그러나, 개념적으로 두 프로세스 모두 실행을 계속할 수 있음. 두 가지 가능성이 존재:

  1. Signal and wait. P는 Q가 모니터를 떠날 때까지 기다리거나 다른 condition을 기다림.
  2. Signal and continue. Q는 P가 모니터를 떠날 때까지 기다리거나 다른 condition을 기다림.

두 옵션을 채택하는 데는 합리적인 주장이 있음. 한편으로는, P가 이미 모니터에서 실행 중이었기 때문에, signal-and-continue 방법이 더 합리적으로 조임. 다른 한편으로는, 스레드 P가 계속 진행되도록 허용하면, Q가 재개될 때까지 Q를 기다리고 있던 논리적인 조건이 더 이상 유지되지 않을 수 있음. 이 두 가지 선택 사이에는 절충안이 있음: 스레드 P가 signal 작업을 실행하면, 즉시 모니터를 떠나고, Q가 즉시 재개됨.

6.7.2 Implementing a Monitor Using Semaphores

각 모니터에 대해 상호 배제를 보장하기 위해 바이너리 세마포어 mutex가 제공됨. 프로세스는 모니터에 들어가기 전에 wait(mutex)를 실행해야 하고 모니터를 나간 후에는 signal(mutex)를 실행해야 함.

구현에는 signal-and-wait 방법을 사용함. 신호 프로세스는 재개된 프로세스가 떠나거나 대기할 때까지 기다려야 하므로, 추가 바이너리 세마포어인 next가 도입되어 0으로 초기화됨. 신호 프로세스는 next를 사용하여 자신을 일시 중단할 수 있음. 정수 변수 next_count도 제공되어 next에서 일시 중단된 프로세스 수를 계산함. 따라서, 각 외부 함수 F는 다음으로 대체됨.

wait(mutex);
...
body of F
...
if (next count > 0)
	signal(next);
else
	signal(mutex);

모니터 내에서의 상호 배제가 보장됨.

각 조건에 대해 이진 세마포어 x_sem과 정수 변수 x_count를 도입하고, 둘 다 0으로 초기화됨. x.wait()는다음과 같이 구현할 수 있음.

x count++;
if (next count > 0)
	signal(next);
else
	signal(mutex);
wait(x sem);
x count--;

x.signal() 연산은 다음과 같이 구현될 수 있음.

if (x count > 0) {
  next count++;
  signal(x sem);
  wait(next);
  next count--;
}

6.7.3 Resuming Processes within a Monitor

여러 프로세스가 조건 x에서 일시 중단되고 어떤 프로세스가 x.signal() 연산을 실행하면, 중단된 프로세스 중 다음에 재개해야 할 프로세스는 어떻게 결정하나. 간단한 해결책 중 하나는 FCFS 순서를 사용하여 가장 오랫동안 대기한 프로세스가 먼저 재개되도록 하는 것. 그러나, 많은 상황에서 이러한 간단한 스케줄링 방식은 적절하지 않음. 어런 상황애서는, conditional-wait 구조를 사용할 수 있음. 이 구조는 x.wait(c)의 형식을 가짐. 여기서 c는 wait() 연산이 실행될 때 평가되는 정수 표현식임. 우선순위 넘버라고 하는 c의값은, 일시 중단된 프로세스의 이름과 함께 저장됨. x.signal()이 실행되면 우선순위 번호가 가장 작은 프로세스가 다음으로 재개됨.

이 새로운 메커니즘을 설명하기 위해 그림 6.14의 ResourceAllocator모니터를 고려하면,

이 모니터는 경쟁하는 프로세스 간에 단일 리소스 할당을 제어함. 각 프로세스는 이 리소스의 할당을 요청할 때 리소스를 사용할 최대 시간을 지정함. 모니터는 가장 짧은 시간 할당을 가진 프로세스에 리소스를 할당함. 해당 리소스에 액세스 해야하는 프로세스는 다음 순서를 따라야 함.

R.acquire(t);
...
access the resource;
...
R.release();

여기서 R은 ResourceAllocator 유형의 인스턴스임.

불행하게도, 모니터 개념은 이전 액세스 시퀀스가 준수될 것이라고 보장할 수 없음. 특히, 다음과 같은 문제가 발생할 수 있음:

  • 프로세스는 리소스 액세스 권한을 먼저 얻지 않고 리소스에 액세스할 수 있음
  • 프로세스는 리소스 액세스가 허용되고 리소스를 해제하지 않을 수 있음
  • 프로세스는 요청하지 않은 리소스를 해제하려고 시도할 수 있음
  • 프로세스는 동일한 리소스를 두 번 요청할 수 있음.

세마포어 사용에도 동일한 어려움이 발생하며, 이러한 어려움은 처음에 모니터 구조를 개발하도록 격려했던 어려움과 본질적으로 유사함. 컴파일러가 더 이상 우리를 도울 수 없는 상위 수준 프로그래머의 정의 연산의 올바른 사용에 대해 걱정해야 함.

현재 문제에 대한 한 가지 해결책은, ResourceAllocator 모니터 내에 리소스 액세스 작업을 포함하는 것임. 그러나 이 솔루션을 사용하면 스케줄링이 내장된 모니터 스케줄링 알고리즘에 따라 수행된다는 것을 의미함.

프로세스가 적절한 시퀀스를 준수하는지 확인하려면 ResourceAllocator 모니터와 해당 관리 리소스를 사용하는 모든 프로그램을 검사해야 함. 이 시스템의 정확성을 확립하려면 두 가지 조건을 확인해야 함. 첫 째, 사용자 프로세스는 항상 올바를 시퀀스로 모니터에서 호출해야 함. 둘 째, 비협조적인 프로세스가 모니터에서 제공하는 상호 배제 게이트웨이를 무시하고 액세스 프로토콜을 사용하지 않고, 공유 리소스에 직접 액세스하려고 하지 않는지 확인해야 함. 이 두가지 조건을 보장할 수 있는 경우에만 시간 종속 오류가 발생하지 않고 스케줄링 알고리즘이 헛되게하지 않을 것이라고 보장할 수 있음.

이 검사는 작고 정적인 시스템에서는 가능할 수 있지만, 대규모 시스템이나 동적 시스템에서는 합리적이지 않음. 이 액세스 제어 문제는 17장에 설명된 추가 메커니즘을 사용해야만 해결 가능.

0개의 댓글