[운영체제] 프로세스 동기화

배고픈메꾸리·2022년 3월 4일
0

운영체제

목록 보기
3/4

1. 프로세스 간 통신

프로세스끼리 작업을 하다 보면 서로 데이터를 주고받아야 할 일이 생긴다. 프로세스끼리 통신을 하는 경우 누가 먼저 작업할지, 작업이 언제 끝날지 등을 서로 알려주어야 하는데 이를 동기화라고 한다.

프로세스가 다른 프로세스와 데이터를 주고 받는 프로세스간 통신(IPC) 에는 같은 컴퓨터 내에 있는 프로세스 뿐 아니라 다른 컴퓨터에 있는 프로세스와의 통신도 포함된다.

  • 프로세스 내부 데이터 통신 : 하나의 프로세스 내에 여러 개의 스레드가 존재하는 경우 스레드는 전역 변수나 파일을 이용하여 데이터를 주고받는다.

  • 프로세스 간 데이터 통신 : 같은 컴퓨터에 있는 여러 프로세스끼리 통신하는경우, 공용 파일 또는 운영체제가 제공하는 파이프를 사용하여 통신한다.

  • 네트워크를 이용한 데이터 통신 : 여러 컴퓨터가 네트워크로 연결된 경우 소켓을 이용하여 데이터를 주고받는다. 다른 컴퓨터에 있는 함수를 호출하여 통신하는 원격 프로시저 호출(RPC)도 여기에 해당한다.

같은 컴퓨터에 있는 프로세스끼리도 소켓 통신을 할 수 있는데, IP주소로 127.0.0.1과 같은 루프백 주소를 사용하면 된다. 하지만 소켓을 사용하려면 많은 전처리가 필요하므로 소켓통신을 사용할 이유가 없다.

1-1) 프로세스 간 통신의 분류

프로세스 간 통신은 동시에 실행되는 프로세스끼리 데이터를 주고받는 작업을 의미한다. 통신은 데이터가 전송되는 방향에 따라 전이중(duplex) , 반이중(half-duplex) , 단방향(simplex) 통신으로 나뉜다.

[a] 통신 방향에 따른 분류

  • 전이중 통신 : 데이터를 동시에 양쪽방향으로 전송할 수 있는 구조로, 프로세스 간 통신에서는 소켓 통신이 양방향 통신에 해당한다.

  • 반이중 통신 : 데이터를 양쪽 방향으로 전송할 수 있지만 동시에 전송하지는 못하는 구조이다.

  • 단방향 통신 : 한쪽 방향으로만 데이터를 전송할 수 있는 구조로, 전역 변수파이프가 단방향 통신에 해당한다.
    (전역 변수가 1개이면 한쪽 방향으로만 통신이 가능하며, 양방향 통신을 구현하려면 전역 변수 2개가 필요하다.)

[b] 통신 구현 방식에 따른 분류

전역 변수를 사용하는 통신 방식의 가장 큰 문제는 데이터를 보냈는지 상대편에서 알 수 없다는 것이다. 상태 변화를 살펴보기 위해 반복문을 무한정 기다리는 것을 바쁜 대기(busy waiting) 이라고 한다. 바쁜 대기는 자원이 낭비되므로 좋지 않은 프로그램의 전형적인 예이다.

바쁜 대기 문제를 해결하기 위해서는 데이터가 도착했음을 알려주는 동기화를 사용한다. 프로세스간 통신은 동기화 기능이 있느냐 없느냐에 따라 동기화 통신(blocking communication)비동기화 통신(non-blocking communication)으로 구분된다.

  • 동기화 통신 : 데이터를 받는 쪽은 데이터가 도착할 때까지 자동으로 대기 상태에 머물러 있다.
  • 대기가 없는 통신 : 데이터를 받는 쪽은 바쁜 대기를 사용하여 데이터가 도착했는지 여부를 직접 확인한다.

전역 변수와 파일을 이용한 통신은 비동기화 통신의 대표적인 예이다. 비동기화 통신은 통신 오버헤드는 적지만 바쁜 대기처럼 사용자가 직접 처리해야 하는 작업이 많다

분류 방식 종류
통신 방향에 따른 분류 전이중 통신 일반적 통신, 소켓
반이중 통신 무전기
단방향 통신 전역 변수, 파일 , 파이프
통신 구현 방식에 따른 분류 동기화 통신 파이프, 소켓
비동기화 통신 전역 변수, 파일

1-2) 프로세스 간 통신의 종류

프로세스 간 통신은 데이터를 주거나 받는 동작으로 이루어지며 이는 쓰기 연산과 읽기 연산으로 간소화할 수 있다.

예를 들어 전역변수 GV를 이용하여 send는 쓰기 연산으로, receive는 읽기 연산으로 변경한 것으로 연산만 바뀌었을 뿐 의미는 같다.

send -> write(GV,message)
receive -> read(GV, message)

프로세스 간 통신에서 가장 중요한 것은 프로세스의 동기화이다. 전역 변수, 파일, 파이프, 소켓을 이용한 통신에서 프로세스 동기화가 어떻게 이루어지는지 알아보자.

[a] 전역 변수를 이용한 통신

전역 변수를 이용한 통신은 공동으로 관리하는 메모리를 사용하여 데이터를 주고받는 것이다. 데이터를 보내는 쪽에서는 전역 변수나 파일에 값을 쓰고, 데이터를 받는 쪽에서는 전역 변수의 값을 읽는다. 전역 변수를 이용한 통신은 주로 직접적인 관련이 있는 프로세스간에 사용한다. 예를 들면 부모 프로세스가 전역 변수를 선언한 후 자식 프로세스를 생성하면 부모-자식 프로세스간 통신이 가능하다.

<전역 변수를 이용한 통신>

int R;
int L;

int main(){
	int pid;
    pid = fork();
    ...
}

위 그림은 부모-자식 관계의 두 프로세스가 양방향으로 통신하는 모습을 나타낸 것이다. 양방향으로 통신하기 위해 전역 변수 R과 L을 정의한 후 fork()를 사용하여 부모 프로세스 P와 자식 프로세스 C를 만들었다. 두 프로세스는 전역 변수에 읽기 또는 쓰기 연산을 한다.

하지만 이와 같은 통신 방식에는 동기화 문제가 있다. 프로세스 P가 전역 변수 R에 쓰기 연산을 한 후 프로세스 C가 그 데이터를 읽으려 할 때, 프로세스 C는 전역 변수 R을 주기적으로 확인해야 한다.

[b] 파일을 이용한 통신

프로세스가 입출력 관리 프로세스에 쓰기를 요구하면 데이터가 저장되고, 읽기를 요구하면 입출력 관리 프로세스로부터 데이터를 가져온다. 쓰기 연산은 하드디스크에 데이터를 전송하는 명령, 읽기 연산은 하드 디스크로 데이터를 가져오는 명령이라고 할 수 있으므로 파일 입출력도 통신이다.

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
	inf fd;
    char buf[5];
    
    fd = open("com.txt" , 0_RDWR); // 읽기 쓰기 모드로 파일 디스크립터 생성
    write(fd , "Test" , 5); // 하드디스크에 쓰기 (Test+null)
    read(fd , buf , 5); // 하드디스크에서 읽기
    close(fd);
    exit(0);
}

파일을 이용한 통신은 부모-자식 프로세스 간 통신에 많이 사용되며 운영체제가 프로세스 동기화를 제공하지 않는다. 그래서 프로세스가 알아서 동기화를 해야 하는데 보통 부모 프로세스가 wait() 함수를 이용하여 자식 프로세스의 작업이 끝날 때까지 기다렸다가 작업을 시작한다.

[c] 파이프를 이용한 통신

프로세스 동기화 문제를 해결하는 방법으로 파이프가 있다. 파이프는 운영체제가 제공하는 동기화 통신 방식으로, 파일 입출력 과 같이 파일 디스크립터를 얻고 작업을 한 후 마무리한다. 파이프도 마찬가지로 양방향 통신을 하기 위해서 파이프 2개가 필요하다.

파이프는 이름 없는 파이프이름 있는 파이프로 나뉜다.

  • 이름 없는 파이프 : 일반적으로 파이프라고 하면 가리키는 파이프. 부모-자식 등 서로 관련 있는 프로세스 간 통신에 사용된다.

  • 이름 있는 파이프 : FIFO라 불리는 특수 파일을 이용하며 서로 관련 없는 프로세스 간 통신에 사용됨

[d] 소켓을 이용한 통신

여러 컴퓨터에 있는 프로세스 끼리도 통신을 네트워킹이라고 한다. 네트워킹 상황에서의 통신은 원격 프로시저 호출이나 소켓을 이용한다. 프로시저 호출이 한 컴퓨터에 있는 함수를 호출하는 것이라면, 원격 프로시저 호출은 다른 컴퓨터에 있는 함수를 호출하는 것이다.

일반적으로 원격 프로시저 호출은 소켓을 이용하여 구현한다. 다른 컴퓨터에 있는 프로세스와 통신을 하기 위해서는 먼저 그 컴퓨터의 위치를 파악하고, 시스템 내 여러 프로세스 중 어떤 프로세스와 통신을 할지도 결정해야 한다. 이때 통신하고자 하는 프로세스는 소켓에 쓰기 연산을 하면 데이터가 전송되고, 읽기 연산을 하면 데이터를 받게 된다.

소켓은 프로세스 동기화를 지원하므로 데이터를 받는 쪽의 프로세스가 바쁜 대기를 하지 않아도 된다.


2. 공유 자원과 임계구역

공유 자원은 여러 프로세스가 공동으로 이용하는 변수, 메모리, 파일 등을 말한다. 공유 자원은 공동으로 이용되기 때문에 누가 언제 데이터를 읽거나 쓰느냐에 따라 결과가 달라진다.

위 그림을 보면 전역 변수 예금에 대해 두 프로세스가 입금을 한다. 두 프로세스 모두 예금 10만원을 동시에 읽은 후 다른 프로세스의 작업을 무시하고 덮어쓰는 바람에 총액이 맞지 않는 문제가 발생하였다.

이처럼 2개 이상의 프로세스가 공유 자원을 병행적으로 읽거나 쓰는 상황을 경쟁 조건(Race Condition)이 발생했다고 한다. 경쟁 조건이 발생하면 위의 그림처럼 공유 자원 접근 순서에 따라 실행 결과가 달라질 수 있다.

2-1) 임계구역

공유 자원 접근 순서에 따라 실행 결과가 달라지는 프로그램의 영역을 임계구역이라고 한다. 예를 들어 위의 그림처럼 예금을 믹서처럼 임계구역으로 지정하면 문제가 발생하지 않는다. 어떤 프로세스가 임계구역에 들어가면 다른 프로세스는 임계구역 밖에서 기다려야 하며 임계구역의 프로세스가 나와야 들어갈 수 있다.

2-2) 생산자-소비자 문제

생산자-소비자 문제에서는 생산자 프로세와 소비자 프로세스가 서로 독립적으로 작업을 한다. 생산자는 물건을 생산해서 버퍼에 넣고 소비자는 계속 버퍼에서 물건을 가져온다. 또한 버퍼가 비었는지 가득 찼는지 확인하기 위해 sum이라는 전역 변수를 사용하는데 sum에는 현재 버퍼에 있는 상품의 총 수가 결정된다.

위 그림은 생산자-소비자 문제의 코드와 작동 상태를 나타낸 것이다. 생산자는 수를 증가시켜가며 물건을 채우고 소비자는 생산자를 쫓아가며 물건을 소비한다. 현재 버퍼는 1에서 3까지 차 있고 SUM 값은 3이다.

이 때 생산자 코드와 소비자 코드가 다음과 같이 동시에 실행되면 문제가 발생한다. 생산자와 소비자가 전역 변수 sum에 접근하는 타이밍을 서로 맞추기 않았기 떄문이다.

  1. 생산자가 물건 하나를 buf 4에 저장했다. sum을 4로 바꿔야 하나 아직 바꾸지 못했다
  2. 소비자가 물건 하나를 소비했다. sum을 2로 바꿔야 하나 아직 바꾸지 못했다.
  3. 이 상태에서 생산자의 sum = sum +1; 과 소비자의 sum = sum-1;이 거의 동시에 실행되면 문제가 발생한다. 생산자와 소비자가 독립적이기 때문에 상대방이 sum을 바꾸려는 것을 모른 채 현재 상태인 sum=3을 읽어서 작업한다.

2-3) 임계구역 해결 조건

상호 배제

한 프로세스가 임계구역에 들어가면 다른 프로세스는 임계구역에 들어갈 수 없다. 이것이 지켜지지 않으면 임계구역을 설정한 의미가 없다.

한정 대기

어떤 프로세스도 무한 대기하지 않아야 한다. 즉 특정 프로세스가 임계구역에 진입하지 못하면 안 된다.

진행의 융통성

한 프로세스가 다른 프로세스의 진행을 방해해서는 안 된다.


3. 임계구역 해결 방법

임계구역 문제를 해결하는 단순한 방법은 잠금을 이용하는 것이다. 임계구역 문제를 해결하기 위한 세 가지 조건 상호 배제 , 한정 대기 , 진행의 융통성을 모두 만족하는 잠금, 잠금 해제 , 동기화 구현 방법을 알아보자

소프트웨어적으로 임계구역을 해결하는 방법을 살펴보자.

#include <stdio.h>

typedef enum {false , true} boolean;
extern boolean lock = false;
extern int balance;

main() {
	while(lock==true);
    lock = true;
    balance = balance + 10; // 임계구역
    lock = false;
}

C 언어에는 boolean 변수가 없어서 새로 정의하였다. 또한 잠금으로 사용할 전역 변수 lock과 임계구역에 사용할 예금 변수 balance도 정의했다.

3-1) 임계구역 해결 조건을 고려한 코드 설계

상호 배제 문제

프로세스 P1과 P2는 코드가 같으며 두 프로세스가 공유하는 변수인 lock의 초깃값은 false이다. 여기서 false는 잠금이 해제되었다는 의미이다.

프로세스 P1과 P2는 임계구역에 진입하기 전에 코드를 통해 임계구역에 잠금이 걸려 있는지 확인한다 (lock == true). 만약 잠겨있다면 무한 루프를 돌면서 잠금이 해제될때까지 기다린다. 임계구역에 진입할 경우 잠금을 걸고(lock = true) 작업을 하며, 작업을 마치면 다른 프로세스가 사용할 수 있도록 잠금을 해제한다.(lock = false)

하지만 이러한 코드도 가끔은 제대로 작동하지 않는다.

위 그림에서는 임계구역에 진입한 프로세스가 없다. 이런 상황에서 아래 순서대로 실행된다고 가정해보자.

  1. 프로세스 P1이 while문을 실행한다. 임계구역에 프로세스가 없기 때문에 무한 루프를 빠져나온다. 그런데 다음 문장을 실행하려는 순간 CPU시간을 다써서 타임아웃된다.
  2. 프로세스 P2는 while문을 실행한다. 아직 P1이 잠금을 걸지 않았기 때문에 lock은 여전히 false이며, P2는 임계구역에 진입할 수 있다.
  3. P1은 lock = true 문을 실행하여 잠금을 걸고 진입한다.
  4. P2도 lock = true 문을 실행하여 잠금을 걸고 진입한다. 결론적으로 두 프로세스가 임계구역에 진입한다.

또 다른 문제점은 잠금이 풀리기를 기다리려면 바쁜 대기를 한다는 것이다. 작업을 할 필요가 없음에도 무한 루프를 돌면서 자원을 낭비한다.

한정 대기 문제

상호 배제를 보장하지 못하는 위의 그림을 보완한 모습이다. 전역 변수로 lock1과 lock2 를 사용하고 초깃값은 둘 다 false이다.

프로세스 P1은 임계구역에 진입하기 전에 먼저 잠금을 설정하고 프로세스 P2가 잠금을 설정했는지 확인한다. 만약 잠금을 설정하지 않았다면 임계구역에 진입하여 작업을 마친 후 잠금을 해제한다.

하지만 이러한 방식에서는 두 프로세스가 모두 임계구역에 진입하지 못하는 무한 대기 상태가 일어날 수 있다.

  1. 프로세스 P1은 lock1=true 문을 실행한 후 자신의 CPU 시간을 다 써버림.
  2. 프로세스 P2도 lock2=true 문을 실행한 후 자신의 CPU 시간을 다 써버림
  3. 프로세스 P2가 lock2=true 문을 실행했기 떄문에 P1은 무한 루프에 빠짐
  4. 프로세스 P1이 lock1=true 문을 실행했기 때문에 P2는 무한 루프에 빠짐

P1과 P2 둘 다 무한 루프에 빠져서 임계구역에 진입하지 못한다. 이는 한정 대기 조건을 보장하지 못하는 상황으로 교착 상태라고 한다. 교착 상태란 프로세스가 살아 있으나 작업이 진행되지 못하는 상태를 말한다.

또한 프로세스가 3개 4개로 늘어나면 lock3 , lock4를 만드는 등 프로세스 갯수에 따라 검사해야하는 lock 변수도 많아져 비효율적이다.

진행의 융통성 문제

아래 그림은 임계구역 문제를 해결하기 위한 세 번째 코드이다. lock 값이 1이면 프로세스 P1이 임계구역을 사용한다는 뜻이고, lock 값이 2이면 프로세스 P2가 임계구역을 사용한다는 뜻이다.

공유 변수 lock의 값을 통해 다른 프로세스가 임계구역에 있는지 확인하고, 없으면 진입한다. 위 그림은 잠금을 확인하는 문장이 하나이므로 상호 배제와 한정 대기를 보장한다. 그러나 서로 번갈아서 실행된다는 문제가 있다. 한 프로세스가 두 번 연달아 임계구역에 진입하고 싶어도 그럴 수 없다.

이렇게 프로세스의 진행이 다른 프로세스로 인해 방해받는 현상을 경직된 동기화라고 한다. 즉 위의 그림은 진행의 융통성 조건을 보장하지 못한다.

하드웨어적인 해결 방법

임계구역 문제는 하드웨어적인 방법으로도 해결할 수 있다. 아래 그림에서 코드는 잠금이 걸렸는지 검사하는 while문과 검사한 후 잠금 설정을 하는 lock=true 문이 분리되어 중간에 타임아웃이 걸리면 문제가 발생한다. 이 경우 하드웨어적으로 두 명령어를 동시에 실행하면 임계구역 문제를 쉽게 해결할 수 있다.

임계구역을 하드웨어적으로 해결하는 방법은 편리하지만 바쁜 대기를 사용하여 검사하기 때문에 자원 낭비가 있다.

3-2) 피터슨 알고리즘

변수 turn은 두 프로세스가 동시에 lock을 설정하여 임계구역에 못 들어가는 상황에 대비하기 위한 장치이다. 두 프로세스가 모두 lock을 설정했더라도 turn을 사용하여 다른 프로세스에게 양보하도록 한다.

예를 들어, P1에서 작업이 필요하다고 판단하여 lock1 = true 문을 실행하였다. 본래였으면 직후에 타임아웃이 되어 P2에서도 lock을 걸고 결과적으로 두 프로세스 모두 임계구역에 들어가지 못하는 상황이 발생하였다. 피터슨 알고리즘은 turn이라는 변수를 사용하여 추가적으로 다른 작업이 필요한 프로세스가 있는지 탐색한다.

  1. P1의 lock1 = true 문이 실행
  2. P1 타임아웃
  3. P2의 lock2 = true 문이 실행
  4. P2 타임아웃
  5. P1의 turn = 2 실행
  6. P1 바쁜 대기 상태 (무한 루프)
  7. P2의 turn = 1 실행
  8. P2 바쁜 대기 상태 (무한 루프)
  9. turn = 1이므로 무한 루프 탈출
  10. P1 작업 수행
  11. P1의 lock1 = false 문 실행
  12. P2 작업 수행
  13. 종료

피터슨 알고리즘은 임계구역 해결의 세 가지 조건을 모두 만족하지만 2개의 프로세스만 사용 가능하다는 문제점이 있다. 여러 프로세스가 하나의 임계구역을 사용하려면 공유 변수를 추가해야 한다.

3-3) 데커 알고리즘

데커 알고리즘이 어떻게 작동하는지 프로세스 P1의 입장에서 살펴보자.

  1. P1이 잠금을 건다( lock1 = true )
  2. P2의 잠금이 걸렸는지 확인한다( while(lock2 == true) )
  3. P2도 잠금을 걸었다면 어떤 프로세스의 차례인지 확인한다( if(turn == 2) ). 만약 P1 차례라면(turn == 1) 작업을 진행하고 P2의 차례라면 P1은 잠금을 풀고 무한 대기한다.
  4. P2가 작업을 마치면 잠금을 걸고 작업을 진행한다.

3-4) 세마포어

세마포어란 임계구역에 진입하기 전에 스위치를 사용 중으로 놓고 임계구역으로 들어간다. 이후에 도착하는 프로세스는 앞의 프로세스가 작업을 마칠 때까지 기다린다. 프로세스가 작업을 마치면 세마포어는 다음 프로세스에 임계구역을 사용하라는 동기화 신호를 보낸다. 세마포어는 다른 알고리즘과 달리 임계구역이 잠겼는지 직접 점검하거나, 바쁜 대기를 하거나, 다른 프로세스에 동기화 메시지를 보낼 필요가 없다.

Semaphore(n);

P();
/* 임계구역 */
V();

위의 코드는 세마포어를 구현한 간단한 코드이다.

  • Semaphore(n) : 전역 변수 RS를 n으로 초기화한다. Rs는 현재 사용 가능한 자원의 수이다.
  • P() : 잠금을 수행하는 코드로, RS가 0보다 크면(사용 가능한 자원이 있으면) 1만큼 감소시키고 임계구역에 진입한다. 만약 RS가 0보다 작으면 0보다 커질 때까지 기다린다.
  • V() : 잠금 해제와 동기화를 같이 수행하는 코드로, RS 값을 1 증가시키고 세마포어에서 기다리는 프로세스에게 임계구역에 진입해도 좋다는 wake_up 신호를 보낸다.

세마포어에서 잠금이 해제되기를 기다리는 프로세스는 세마포어 큐에 저장되어 있다가 wake_up 신호를 받으면 큐에서 나와 임계구역에 진입한다. 따라서 바쁜 대기를 하는 프로세스가 없다. 그러나 세마포어의 P()나 V()내부 코드가 실행되는 도중에 다른 코드가 실행되면 상호 배제와 한정 대기 조건을 보장하지 못한다. 그러므로 P()와 V()의 내부 코드는 검사와 지정을 사용하여 분리 실행되지 않고 완전히 실행되게 해야한다.

위에서 봤던 그림을 세마포어로 해결해보자.

  1. 먼저 도착한 P1이 임계구역에 진입한다. 현재 RS는 1이므로 RS-1 후 임계구역에 진입한다.
  2. 나중에 도착한 P2는 RS값이 0이므로 P1이 임계구역을 빠져나올 때까지 세마포어 큐에서 기다린다.
  3. P1이 작업을 시작한다.
  4. P1은 V()를 실행하여 RS+1 후 wake_up 신호를 P2에 보낸다.
  5. wake_up 신호를 받은 P2가 작업을 시작한다.

세마포어는 공유 자원이 여러 개일 때도 사용할 수 있다.

  1. P1은 RS-1후 임계구역에 진입한다.
  2. P2도 RS-1후 임계구역에 진입한다.
  3. P3은 RS 값이 0이므로 다른 프로세스가 임계구역을 빠져나올 때까지 기다린다.
  4. P1이 작업을 마치고 V()를 실행하면 wake_up 신호가 P3에 전달된다.
  5. P3이 임계구역에 진입한다.

3-5) 모니터

세마포어의 가장 큰 문제는 사용자의 잘못된 사용으로 인해 임계구역이 보호받지 못한다는 것.

  1. 프로세스가 세마포어를 사용하지 않고 임계구역에 들어간 경우로 임계구역을 보호할 수 없음.
  2. P()를 두 번 사용하여 wake_up 신호가 발생하지 않은 경우이다. 프로세스 간의 동기화가 이루어지지 않아 세마포어 큐에서 대기하고 있는 프로세스들이 무한 대기에 빠진다.
  3. P()와 V()를 반대로 사용하여 상호 배제가 보장되지 않은 경우 임계구역을 보호할 수 없다.

공유 자원을 사용할 때 모든 프로세스가 세마포어 알고리즘을 따른다면 굳이 P()와 V()를 사용할 필요 없이 자동으로 처리하면 된다. 이를 실제로 구현한 것이 모니터이다. 모니터는 공유 자원을 내부적으로 숨기고 공유 자원에 접근하기 위한 인터페이스만 제공함으로써 자원을 보호하고 프로세스간 동기화를 시킨다.

profile
FE 개발자가 되자

2개의 댓글

comment-user-thumbnail
2022년 3월 6일

안녕하세요 개발 공부를 하고 있는 학생입니다. 제가 공부한 내용을 블로그에 올리는 중인데 혹시 이 글에 있는 사진을 제 블로그에 올려도 될까요?

1개의 답글