Ssul's Blog

[Generative-AI]GPT-Transformer 개념잡기 본문

AI & ML/개념잡기

[Generative-AI]GPT-Transformer 개념잡기

Ssul 2023. 12. 27. 00:55

0. 들어가며...

GPT와 ChatGPT는 다른 아이.

- GPT는 우리가 상상할수 없을 정도의 많은 텍스트 데이터를 가지고 학습한 Pretrained-Model. 이 모델은 다음단어를 귀신같이 잘 예측함. I am a ____를 입력하면, boy:45%, girl:44%, .......처럼 모든 단어들의 등장확률을 예측. 그중 높은 %를 가지는 단어를 출력하는 모델

- ChatGPT는 GPT라는 Pretrained모델을 RLHF, PPO 등을 통 파인튜닝해서 만든 챗봇형 모델

- 이번 글에서는 GPT 모델을 만드는 개념을 알아볼 예정 = 다음 단어를 예측하는 모델

 

1. 데이터 정리

모델이 학습을 하기위해서는 라벨링된 데이터가 많이 필요한데, 텍스트 데이터를 하나하나 다 라벨링하면 비용과 시간이 엄청나게 발생함.

하지만, 다음단어를 예측하는 Task의 경우, 사람이 라벨링하기보다, 조금만 스킬을 발휘하면 자동으로 라벨링이 됨.

i am a boy라는 문장을 기준으로, [:-1], [1:]이렇게 적용하면,

[:-1] = i am a, [1:] = am a boy로 구성될수 있음.

이렇게 함으로써, 따로 사람의 라벨링 작업이 없이 input: "i am a", label:"am a boy"가 생성 가능하다.

 

위의 방법을 통해서, 무수히 많은 텍스트 데이터를 이렇게 변환하여, 데이터 셋을 구성 가능하게 되었다.

이렇게 학습하는 방법을 self-supervised learning이라고 하며, 이 방법을 통하여,

언어분야와 비전(이미지)분야에 Pretrained-Model이 탄생할수 있게 되었다.

(*이미지의 경우에도 고양이 사진 한장을 가지고, 뒤집고/90도돌리고/270도 돌리고/흑백으로 만들고 등 한장의 고양이 사진으로 수십장의 데이터로 만들수 있게 된다)

 

코드와 함께 Transformer모델에 입력한 데이터를 다듬어 보자(*코드출처 "실전 파이토치 딥러닝 프로젝트")

 

1-1. 데이터 preprocessing

# 구두점 앞에 공백을 채워서 별도의 '단어'로 취급합니다.
# 예: Hello, world! How are you? > Hello ,  world !  How are you ?
def pad_punctuation(s):
    # 구두점 & 줄바꿈 문자의 앞뒤에 띄어쓰기 추가
    s = re.sub(f"([{string.punctuation}, '\n'])", r" \1 ", s)
    # 연속된 공백 1개로 만들기
    s = re.sub(" +", " ", s)
    return s


text_data = [pad_punctuation(x) for x in filtered_data]

- filtered_data는 텍스트 데이터(문장들)

- 각 문장마다, 구두점 앞에 공백을 채워서 별도의 단어로 취급

- 데이터마다, 상황에 필요한 preprocessing 진행

 

 

1-2. 토크나이징 & 단어사전 구성

# 텐서플로 데이터셋으로 변환하기
text_ds = (
    tf.data.Dataset.from_tensor_slices(text_data)
    .batch(BATCH_SIZE)
    .shuffle(1000)
)


# TextVectorization 층 만들기 텍스트를 > 벡터로 (예: "안녕하세요" > [0,1,1,0])
# 소문자 바꾸고, 가장 자주등장하는 단어10,000개에 정수부여(나머지 무시), 시퀀스길이 제한
vectorize_layer = layers.TextVectorization(
    standardize="lower",
    max_tokens=VOCAB_SIZE,
    output_mode="int",
    output_sequence_length=MAX_LEN + 1,
)


# 훈련 세트에 이 층을 적용합니다.
vectorize_layer.adapt(text_ds)
# 단어의 토큰 리스트 생성. 자주 등장하는 순서대로 배치
vocab = vectorize_layer.get_vocabulary()

len(vocab) #10,000

- 언어모델 학습시, 단어사전이 필요함. 해당 사전을 기준으로, 문장에 등장하는 각 단어를 숫자로 변환하는 형태

- TextVectorization을 활용해서, 각 단어를 임베딩하는 layer만들기

- max_tokens=VOCAB_SIZE : VOCAB_SIZE를 10,000으로 설정, 데이터의 모든 문장에서 가장많이 등장하는 단어순으로 나열하고, 숫자를 부여

- output_sequence_length=MAX_LEN + 1 : 문장의 최대길이 80으로 설정했음

단어사전: 5위 'and'

- vocab = vectorize_layer.get_vocabulary(): 데이터를 기반으로 단어사전 제작

- len(vocab) : 10,000개의 단어로 사전구성

example의 원래 문장. 해당 문장을 정수로 변환하는 모습. 8위인 'wine'이 7로 표시

 

 

1-3. input-label구성(self-supervised learning)

- 위에서 이야기 했던, self-supervised learning을 위한 데이터 input-label 생성하기

# 레시피 텍스트와 한 단어만큼 이동된 동일한 텍스트로 훈련 세트를 만듭니다.
# input: "i love surfing with my" > label: "love surfing with my dog "
def prepare_inputs(text):
    # 차원추가, 텐서플로우에서 사용하려고
    text = tf.expand_dims(text, -1)
    # 문장 토큰화
    tokenized_sentences = vectorize_layer(text)
    # 맨 마지막 단어 제거
    x = tokenized_sentences[:, :-1]
    # 맨 앞단어 제거
    y = tokenized_sentences[:, 1:]
    return x, y


train_ds = text_ds.map(prepare_inputs)

- :-1 을 통해서, 제일 마지막 단어를 x에는 미포함

- 1: 을 통해서, 제일 앞단어를 y에는 미포함

- inputs=x, labels=y

# train_ds > [(입력,레이블),(입력,레이블),....] 4060개
# train_ds.take(1) > (입력, 레이블) 1개
# example_input_output = (입력, 레이블)
# example_input_output[0]은 입력 32개(1개배치)
# example_input_output[0][1]은 1개(첫번째배치의 첫번째 값)
# example_input_output[1]은 label 32개
example_input_output = train_ds.take(1).get_single_element()

- train_ds.take(1): train_ds한개를 가져오기 > 첫번째 배치(32개) 가져오기

- 한개의 배치는 (입력, 레이블)로 구성되어 있고, 각각 입력 32개, 레이블 32개임

 

- 입력의 1번째 데이터, 라벨의 1번째 데이터 출력해보기

- 입력은 7부터 시작. 라벨은 10부터 시작. 실제 데이터를 보면 label은 한개씩 앞으로 당겨진 모습 확인가능

(입력: i am a 라벨: am a boy) 

 

 

2. Positional Encoding

- 위에서 생성된 문장을 바로 모델에 학습하면 좋겠지만, Transformer모델은 기존의 RNN처럼 순서대로 입력하지 않음. 문장에 있는 단어 전체를 임베딩하여 한꺼번에 입력

- 이럴경우, 순서정보를 알수가 없음

(*the boy look at the dog 와 the dog look at the boy 두문장이, 순서 정보가 없다면, 크게 구분을 할수 없음)

- 이는 당연히 효율적인 학습이 이뤄지지 않음

- 위치정보를 단어임베딩에 결합하여, 최종 임베딩으로 사용하자

Positional encoding 더하는 예시

- 1에서 진행한 단어임베딩에, 동일한 크기의 벡터를 구성하여, 위치정보를 임베딩해서 더해준다.

(*위의 예시는 discrete하지만, 실제 모델에서는 continous한 positional encoding 사용하기도 함)

- 단어를 임베딩, 위치정보를 인코딩 > 두개의 인코딩을 합쳐서 한개의 인코딩으로 > 최종적으로 Transformer에 입력하는 임베딩 완성

Transformer입력 전, 포지셔널 인코딩을 더해서, 합쳐진 임베딩을 Transformer에 입력

 

# 예: x {"The": 1, "cat": 2, "sat": 3, "on": 4, "the": 5, "mat": 6} > [1,2,3,4,5,6]
class TokenAndPositionEmbedding(layers.Layer):
    def __init__(self, max_len, vocab_size, embed_dim):
        super(TokenAndPositionEmbedding, self).__init__()
        self.max_len = max_len
        # 단어들의 차원
        self.vocab_size = vocab_size
        # 임베딩 차원(잠채차원)
        self.embed_dim = embed_dim
        # 단어 임베딩 이해하기: vocab_size*embed_dim의 매트릭스 생성
        # 고유한 단어(정수)가 입력되면, 해당 단어에 해당하는 행을 가져오면, 그 행벡터가
        # 해당 단어가 임베딩된 것
        self.token_emb = layers.Embedding(
            input_dim=vocab_size, output_dim=embed_dim
        )
        # positional encoding
        self.pos_emb = layers.Embedding(input_dim=max_len, output_dim=embed_dim)

    def call(self, x):
        # 마지막 차원 > 시퀀스 길이 > 토큰 갯수
        maxlen = tf.shape(x)[-1]
        positions = tf.range(start=0, limit=maxlen, delta=1)
        positions = self.pos_emb(positions)
        x = self.token_emb(x)
        # 문장(단어들) + 위치들 > 최종 임베딩
        return x + positions

    def get_config(self):
        config = super().get_config()
        config.update(
            {
                "max_len": self.max_len,
                "vocab_size": self.vocab_size,
                "embed_dim": self.embed_dim,
            }
        )
        return config

- token_emb는 단어가 임베딩

- pos_emb는 위치가 임베딩

- x(token_emb) + positions(pos_emb)를 통해서 위치정보가 포함된 최종 임베딩 완성

 

3. Attention

- 본격적인 Transformer모델 부문. 모든 단어간 관심(관계)도를 학습하는 구조

- 위치정보를 포함한 최종 임베딩 입력(X1, X2)

- 해당 문장(단어들)을 입력받아서 q1, k1, v1 출력(*Wq, Wk, Wv는 학습가능한 매트릭스로 이 친구들을 잘 학습하는게 목적)

- 이렇게 도출된 q1, k1, v1으로 아래의 값을 계산한다

- q1과 모든 kn에 대해서 q1*kn을 구하여, 각각 sqrt(dk)로 나누고, softmax를 진행

- 해당 값을 다시 v1과 계산하여 최종적으로 z1을 도출(이는 해당 단어와 문장내의 모든 단어와의 상관관계(attention)을 고려한 느낌)

- 동일하게 모든 단어에 대해서 진행해서 z2, z3를 도출

- 각 단어에 따라, X1 > z1, X2 > z2, X3 > z3, ....이렇게 나올수 있음

- 또한 이 연산은 매트릭스 연산이 가능하여, 한꺼번에 계산이 가능

문장내 여러단어입력 > 여러단어 z도출

 

 

4. MSA(Multi-headed Self-Attention)

- 위의 작업을 한번만 하는 것이 아니라, 8개를 동시에 진행시켜서 학습가능한 Wq, Wk, Wv도 여러개를 학습

- 이 개념이 MSA임

총 8개의 QKV를 동시에 진행

- 이렇게 나온 Z를 concat하여, FFN를 통과하여 X > Z로 도출

X를 입력하고, Z를 출력

 

class TransformerBlock(layers.Layer):
    def __init__(self, num_heads, key_dim, embed_dim, ff_dim, dropout_rate=0.1):
        super(TransformerBlock, self).__init__()
        # 멀티해드 갯수 X에 대해서 몇개의 Z1,Z2..를 만들것인가?
        self.num_heads = num_heads
        # key의 임배딩 차원 Wk의 열차원
        self.key_dim = key_dim
        # Wq, Wv의 열차원
        self.embed_dim = embed_dim
        # 멀티해드를 통과시킬 신경망 노드갯수: Z1Z2Z3...
        self.ff_dim = ff_dim
        self.dropout_rate = dropout_rate
        self.attn = layers.MultiHeadAttention(
            num_heads, key_dim, output_shape=embed_dim
        )
        self.dropout_1 = layers.Dropout(self.dropout_rate)
        self.ln_1 = layers.LayerNormalization(epsilon=1e-6)
        # 멀티해드 입력 > ff_dim으로 출력
        self.ffn_1 = layers.Dense(self.ff_dim, activation="relu")
        # ff_dim입력 > embed_dim으로 출력
        self.ffn_2 = layers.Dense(self.embed_dim)
        self.dropout_2 = layers.Dropout(self.dropout_rate)
        self.ln_2 = layers.LayerNormalization(epsilon=1e-6)

    def call(self, inputs):
        input_shape = tf.shape(inputs)
        batch_size = input_shape[0]
        seq_len = input_shape[1]
        causal_mask = causal_attention_mask(
            batch_size, seq_len, seq_len, tf.bool
        )
        attention_output, attention_scores = self.attn(
            inputs,
            inputs,
            attention_mask=causal_mask,
            return_attention_scores=True,
        )
        attention_output = self.dropout_1(attention_output)
        out1 = self.ln_1(inputs + attention_output)
        ffn_1 = self.ffn_1(out1)
        ffn_2 = self.ffn_2(ffn_1)
        ffn_output = self.dropout_2(ffn_2)
        return (self.ln_2(out1 + ffn_output), attention_scores)

    def get_config(self):
        config = super().get_config()
        config.update(
            {
                "key_dim": self.key_dim,
                "embed_dim": self.embed_dim,
                "num_heads": self.num_heads,
                "ff_dim": self.ff_dim,
                "dropout_rate": self.dropout_rate,
            }
        )
        return config
# 문장이 배치단위로 입력
inputs = layers.Input(shape=(None,), dtype=tf.int32)
# 문장들의 사전을 만들고, 숫자로 임베딩, 위치임베딩 추가하여, 최종 입력 데이터
x = TokenAndPositionEmbedding(MAX_LEN, VOCAB_SIZE, EMBEDDING_DIM)(inputs)
# 단어 > X(숫자+위치정보) > TransformerBlock > Z
x, attention_scores = TransformerBlock(
    N_HEADS, KEY_DIM, EMBEDDING_DIM, FEED_FORWARD_DIM
)(x)
# Z를 입력하여 단어사전 크기로 출력 > Softmax적용하여, 단어별 등장확률 출력
outputs = layers.Dense(VOCAB_SIZE, activation="softmax")(x)
gpt = models.Model(inputs=inputs, outputs=[outputs, attention_scores])
gpt.compile("adam", loss=[losses.SparseCategoricalCrossentropy(), None])

- inputs는 문장들

- x = TokenAndPositionEmbedding(MAX_LEN, VOCAB_SIZE, EMBEDDING_DIM)(inputs): 1번에서 설명했던 과정으로, 문장을 입력받아서 사전을 생성. 각 문장을 숫자로 임베딩. 여기에 위치정보추가하여, 최종적으로 Transformer에 입력한 임베딩 단어값 도출

- Transformer에 입력되고, 트랜스포머는 위에서 설명한것처럼 X를 입력받아, Z1,Z2,Z3...를 만들고, 그것을 최종적으로 Z로 출력

- 최종적으로 받은 Z를 마지막 Layer에 입력하면, 단어사전만큼의 결과값과, 확률값이 발생

- 이는 우리가 보유하고 있는 단어중, 다음에 어떤 단어를 출력하면 좋을지 확률값을 알려주는 것

 

5. 다음단어 출력하기

outputs = layers.Dense(VOCAB_SIZE, activation="softmax")(x)

- 최종적으로 발생한 Z를 Linear 신경망을 통과. 해당 신경망은 input은 Z이고, 출력은 단어사전의 갯수(위 코드에서는 10,000)

- Z를 입력하면 10,000의 출력

- 10,000개의 출력을 Softmax > 각 단어별 등장 확률이 출력

- 가장 높은 확률의 값을 출력 = 다음단어 예측

- 생성형의 경우, 가장 높은 확률을 출력하지 않고, 분포로 제공하여, 다양한 결과 출력가능

# TextGenerator 체크포인트 만들기
class TextGenerator(callbacks.Callback):
    def __init__(self, index_to_word, top_k=10):
      # 상위 k개의 단어중 선택
      # vocab = index_to_word (사전) > 숫자: word
        self.index_to_word = index_to_word
        # word: 숫자
        self.word_to_index = {
            word: index for index, word in enumerate(index_to_word)
        }

    # 주어진 확률분포(probs)에서 단어 샘플링
    # probs=y[0][-1] 입력문장의 마지막 단어 X입력의 결과 = 다음나올단어들의 확률
    def sample_from(self, probs, temperature):
        # 온도가 높으면, 확률분포가 평탄화되어 더 다양한 단어가 선택됨
        # 온도가 낮으면, 확률분포가 극적으로 변화되어 확률이 높은단어가 자주 선택
        probs = probs ** (1 / temperature)
        # 조정된 확률을 정규화. 총합 1
        # probs = [0.1, 0.2, 0.7], temp=0.5 > [0.15, 0.25, 0.6]
        probs = probs / np.sum(probs)
        # 선택가능 옵션 3 = len(probs), 선택확률 p => 옵션3선택확률 70%> 60%로
        return np.random.choice(len(probs), p=probs), probs

    # 
    def generate(self, start_prompt, max_tokens, temperature):
        # 시장문장을 쪼개서, 각 단어를 사전의 숫자로 전환, 없는 단어이면 1
        start_tokens = [
            self.word_to_index.get(x, 1) for x in start_prompt.split()
        ]
        sample_token = None
        info = []
        # 텍스트 생성루프
        while len(start_tokens) < max_tokens and sample_token != 0:
            # 입력 문장(단어들)을 숫자로 임베딩
            x = np.array([start_tokens])
            # Transformer를 통해서 Z > 사전모든단어들의 %
            # y = (배치, 시퀀스 길이, 어휘사전 크기) > y[0][-1]=첫벗째 배치의, 입력문장의 마지막 X입력에 따른 결과 = [.....사전 단어들의 확률]
            y, att = self.model.predict(x, verbose=0)
            sample_token, probs = self.sample_from(y[0][-1], temperature)
            info.append(
                {
                    # 입력 문장
                    "prompt": start_prompt,
                    # 입력문장의 마지막 단어 입력시, 다음단어 확률분포
                    "word_probs": probs,
                    # att는 [배치, 헤드수, 쿼리 시퀀스 길이, 키 시퀀스 길이]
                    # -1: 입력시퀀스의 마지막 요소
                    # 입력 시퀀스의 마지막 토큰이 입력 시퀀스의 다른 토큰들에 얼마나 주목하는지를 나타내는 8x10의 행렬을 반환
                    "atts": att[0, :, -1, :],
                }
            )
            # 다음단어를 기존 문장에 더해주기
            start_tokens.append(sample_token)
            # 입력문장 업데이트
            start_prompt = start_prompt + " " + self.index_to_word[sample_token]
            # 루프 위로가서 max_token까지 또는 출력이 없을때까지 문장 생성
        # 완성된 문장 출력
        print(f"\n생성된 텍스트:\n{start_prompt}\n")
        return info

    def on_epoch_end(self, epoch, logs=None):
        self.generate("wine review", max_tokens=80, temperature=1.0)

- 위 클래스를 이해하면, 프롬프트 엔지니어링에서 나왔던, 다양성을 위해 top_k를 조정하고, temperature를 조정하는 개념을 이해 할 수 있다

- __init__는 사전을 입력받아서, 문장(단어들)을 입력받아 숫자로 바꾸고, 숫자를 입력받아 단어로 바꾸는 역할을 하는 함수가 있음

- sample_from함수는 다음 등장할 단어들의 확률 벡터와 온도를 입력받아서, 위에 주석처럼 작동하여, 확률에 따라 다음단어를 숫자를 반환하는 기능을 한다

- generate함수는 문장을 입력받아 > 숫자로 임베딩후 > 트랜스포머를 통과하여 > 다음단어 토큰을 주고, info에는 출력되는 단어가 집중하는 이전단어 정보를 저장

- 루프를 max_tokens 또는 sample_token이 0이 되면 출력을 멈춤

- 한마디로 일정한 문장을 입력하면 다음단어를 계속 출력하면서 다음문장을 완성하는 형태.

 

def print_probs(info, vocab, top_k=5):
    for i in info:
        highlighted_text = []
        for word, att_score in zip(
            i["prompt"].split(), np.mean(i["atts"], axis=0)
        ):
            highlighted_text.append(
                '<span style="background-color:rgba(135,206,250,'
                + str(att_score / max(np.mean(i["atts"], axis=0)))
                + ');">'
                + word
                + "</span>"
            )
        highlighted_text = " ".join(highlighted_text)
        display(HTML(highlighted_text))

        word_probs = i["word_probs"]
        p_sorted = np.sort(word_probs)[::-1][:top_k]
        i_sorted = np.argsort(word_probs)[::-1][:top_k]
        for p, i in zip(p_sorted, i_sorted):
            print(f"{vocab[i]}:   \t{np.round(100*p,2)}%")
        print("--------\n")
        

info = text_generator.generate(
    "wine review : us", max_tokens=80, temperature=1.0
)
info = text_generator.generate(
    "wine review : italy", max_tokens=80, temperature=0.5
)
info = text_generator.generate(
    "wine review : germany", max_tokens=80, temperature=0.5
)
print_probs(info, vocab)

- 완성된 모델에 문장을 입력하고, 다음단어들을 예측

- 첫번째는 다음단어 출력하고, 그단어가 집중하고 있는 것(germany가 집중하고 있는 것 진하게 표시)

- 두번째는 다음에 등장할 단어 확률 상위5개( ':'가 germany다음에 올 확률 100%)

- 세번째는 가장 높은 : 출력되고, 다음 확률 상위5개

- 여기서 top_k가 등장

- 그리고, 확률분포로 선택하기 때문에 꼭 1위가 선택되지 않은 경우도 발생(GPT처럼)

'a'가 1위지만, 실제 dusty가 선택

 

 

6. 기타 요소들

6-1. 배치노말

- 배치에 들어있는 데이터를 평균과 분산으로 정규화 시켜서 학습을 안정화 시키기(예: 총 32개의 배치데이터들간의 평균/분산으로 정규화 진행)

- 배치 정규화는 한 배치에 포함된 모든 샘플들에 대해 평균과 분산을 계산하여 정규화를 진행합니다. 이는 각 배치 내에서 데이터의 내부 분포를 일정하게 유지하여, 모델이 더 효과적으로 학습할 수 있도록 돕습니다. 이 방법은 특히 큰 배치 크기에서 잘 작동하며, 깊은 네트워크에서의 학습을 가속화하는 데 유용

(참고: layer normalization

- 한개의 데이터샘플(이미지1장, 문장1개) 내에서 정규화 하는것

- (예)자연어 처리 작업에서 한 '데이터 샘플'은 하나의 문장 또는 텍스트 조각입니다. 여기서 층 정규화는 해당 문장 내의 모든 단어(또는 토큰) 임베딩에 대해 평균과 분산을 계산하고, 각 단어의 임베딩을 정규화합니다.)

 

6-2. 코잘 마스킹

- gpt를 생각할때, 다음단어를 예측하는 모델, Transformer계산시, 아직 입력되지 않은 단어는 입력되면 안됨. 하지만 학습시 다음단어 데이터를 가지고 있음. 이를 학습시 모르게 하기 위하여 causal masking을 진행함

# n_dest는 목적의 시퀀스 길이, n_src는 소스의 시퀀스 길이
# 0: 마스킹x(사용), 1: 마스킹O(사용못함)
# [0,1,1,1]: I만고려, [0,0,1,1]: I love, [0,0,0,1]: I love you고려..
def causal_attention_mask(batch_size, n_dest, n_src, dtype):
    i = tf.range(n_dest)[:, None]
    j = tf.range(n_src)
    m = i >= j - n_src + n_dest
    mask = tf.cast(m, dtype)
    mask = tf.reshape(mask, [1, n_dest, n_src])
    mult = tf.concat(
        [tf.expand_dims(batch_size, -1), tf.constant([1, 1], dtype=tf.int32)], 0
    )
    return tf.tile(mask, mult)


np.transpose(causal_attention_mask(1, 10, 10, dtype=tf.int32)[0])

- 0은 마스킹이 안되어서 보이는 형태

- 1은 마스킹이 되어서 안보이는 형태

- i like a dog라는 문장이 있어도, 처음에는 i만 보이고, 나머지는 다 마스킹

- 다음 학습시 i like까지 보이는 형태

 

7. Transformer전체 연결

- 논문상에서는 트랜스포머가 1개가 아니라, 여러개로 구성. 또한 인코더/디코더로 구성

- 위에서 작업한 것은 디코더 부분만 활용한 GPT구조. 아래 이미지상에서 오른쪽만 사용

- 4번의 TransformerBlock에서 add, 정규화 등 포함되어 있음

논문에 있는 정석 이미지

(위에는 논문에 있는 이미지이며, 다양한 변형 트랜스포머들이 등장하고 있다)

 

 

더 많이 기록해야 할 내용이 있지만, 개념잡기의 글이니, 이정도 깊이로 정리 끝