1. 시도 이유

기존 챗봇은 아래와 같은 문제를 가지고 있습니다.

  1. 정적인 대답, 이는 구글이나 네이버와 같은 검색 엔진과 차별점이 존재하지 않는다.
  2. 문맥 파악 불가, “내가 기저질환이 ~~ 한 편인데, 유방암이 발병할 가능성이 높을까?”와 같은 문맥을 파악한 응답이 불가능하다.

GPT의 경우, 문맥을 파악한 동적인 대답이 가능하지만 아래와 같은 문제가 있습니다.

  1. 2021년 이전의 데이터셋을 바탕으로 개발되었기 때문에 최신 정보에 접근이 불가능하다.
  2. 인터넷에 있는 정보를 사용하였기 때문에 신뢰성이 떨어진다.
  3. 존재하지도 않는 정보를 생성하는 Hallucination 이 발생한다.

즉, 위 두 챗봇은 “검증된 정보로 사용자를 고려해 적절한 답변을 제시한다.”라는 목표에 도달하기 어렵습니다. 이를 해결하기 위해서는 검증된 데이터를 확보하고 그 데이터와 사용자 정보를 입력받아 답변을 생성하는 기술이 필요합니다.

2. 목표

따라서, 이번 개발의 목표와 결과 예시는 다음과 같습니다.

  1. 검증된 데이터만으로 답변을 생성하는 기능
  2. 데이터와 사용자 정보를 입력받아 적절한 답변을 생성하는 기능
  3. 답변에 필요한 정보가 존재하지 않는 경우 이를 알리는 기능
  4. 답변에 쓰인 데이터의 출처를 명시하는 기능

결과 예시

사용자 질문: "저는 당뇨병이 있는데, 살 찌면 항상 혈당이 높아지는데 이유가 무엇인가요?"

봇보답: 살이 찌면 체내 지방이 증가함에 따라 인슐린 저항성이 증가하고, 인슐린의 효과가 감소합니다. 이로 인해 혈당이 상승할 수 있습니다. 따라서, 당뇨병 환자는 체중을 유지하거나 감량하여 혈당을 조절하는 것이 중요합니다.

이 답변은 검증된 의료 데이터를 바탕으로 생성되었으며, 출처는 의료 전문가가 작성한 논문입니다. 해당 논문의 출처는 다음과 같습니다: [1]

감사합니다.

[1] 이명숙, 이동호, 이은정, 박창수, "당뇨병 환자에서 비만이 인슐린 저항성과 혈당 수준에 미치는 영향," 대한내분비학회지, 2005.

3. 방법

목표를 구현하기 위해선 아래 기능이 필요합니다.

  1. 사용자 질문을 받아 비슷한 정보를 검색하는 기능
  2. 검증된 정보와 출처를 데이터베이스에 저장하는 기능
  3. 사용자 질문, 정보 그리고 출처를 바탕으로 자연스러운 응답을 생성하는 기능

기능은 비교적 간단하다. 그러나 높은 효과를 보기 위해선 제약조건이 존재합니다.

예를 들어서, 검증정보가 기하급수적으로 증가해 정보 검색을 하는데 수 십분이 걸리게 되는 경우 서비스로 제공할 수 없습니다. 또한, 사용자 질문과 무관한 정보가 검색되는 경우 잘못된 응답이 생성됩니다. 이를 해결하기 위해 의미론적 텍스트 임베딩 기술과 벡터 데이터베이스가 필요합니다.

3.1 텍스트 임베딩

텍스트 임베딩을 영어, 한글과 같은 자연어를 컴퓨터가 이해하기 쉽도록 숫자열 형태로 변환하는 것을 말합니다. 이 떄, 단순히 형태를 바탕으로 임베딩을 진행할 경우 문제가 발생합니다. "사랑", "애정", "모정", "애착" 등의 단어는 유사한 단어이지만, 단순히 형태를 보면 차이가 존재해 [1,1,1…] , [20,9,11…] 등과 같이 임베딩을 진행할 경우 거리가 생길 수 있습니다. 따라서 의미를 충분히 고려한 임베딩 기술이 필요하다. 이번 구현에는 Open AI의 text-embedding-ada-002 를 사용하도록 하겠습니다.

3.2 벡터 데이터베이스

텍스트 임베딩이 잘 수행되었다면, 다음은 사용자 질문과 유사항 정보를 빠르게 찾는 것이 문제입니다. 임베딩 결과는 매우 긴 숫자열이며, 정보가 10만 줄이기만 해도, 유사성을 일일이 분석한다면 시간이 매우 오래 걸립니다. 이를 해결하기 위해선 벡터(숫자열) 정보를 저장, 조회, 삭제, 수정 그리고 동시 처리 작업을 원할하게 진행할 수 있는 벡터 데이터베이스를 도입하면됩니다. 여기에서는 데이터베이스를 쉽게 생성, 관리 할 수 있는 Pinecone 벡터 데이터베이스를 사용해보겠습니다.

4. 구현

4.1 텍스트 임베딩

4.1 텍스트 임베딩

Open AI의 text-embedding-ada-002 API를 사용하여 예시 텍스트를 임베딩해보겠습니다.

import openai
openai.api_key = "YOUR_API_KEY"

def text_embedding(text):
    response = openai.Completion.create(
        engine="text-davinci-002",
        prompt= f"embed text: {text}",
        temperature=0,
        max_tokens=256,
        top_p=1,
        frequency_penalty=0,
        presence_penalty=0
    )
    embedding = response.choices[0].text
    return embedding

위 함수를 사용하여 텍스트를 임베딩할 수 있습니다. 이 때, YOUR_API_KEY에는 Open AI API 키를 입력해주세요.

text = "당뇨병 환자에서 체중 증가와 혈당 수치의 상관관계는?"
embed = text_embedding(text)
print(embed)

위와 같이 입력하면 아래와 같이 임베딩된 결과를 확인할 수 있습니다.

[-0.461, 0.137, 0.186, 0.065, 0.238,.....]

4.1 데이터베이스 생성

https://docs.pinecone.io/docs 사이트를 보면 쉽게 데이터베이스를 생성하고 파이썬 코드로 접속할 수 있는 방법을 알 수 있습니다.

Pinecone 데이터베이스를 사용하여 벡터를 업로드하는 방법은 다음과 같습니다.

import pinecone

# Pinecone API key를 설정합니다.
pinecone.init(api_key="YOUR_API_KEY")

# 데이터베이스를 생성합니다.
pinecone.create_index(index_name="YOUR_INDEX_NAME", dimension=YOUR_DIMENSION)

# 임베딩 벡터를 리스트로 생성합니다.
vectors = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]

# 벡터를 데이터베이스에 업로드합니다.
pinecone.insert(index_name="YOUR_INDEX_NAME", data=vectors)

# Pinecone API key를 삭제합니다.
pinecone.deinit()

위 코드에서 YOUR_API_KEY와 YOUR_INDEX_NAME, YOUR_DIMENSION은 사용자가 설정해야 하는 값입니다. YOUR_API_KEY는 Pinecone 계정의 API 키를 입력해야 하며, YOUR_INDEX_NAME은 생성하고자 하는 데이터베이스의 이름을 입력하면 됩니다. YOUR_DIMENSION은 임베딩 벡터의 차원을 나타냅니다.

또한, vectors 리스트에는 업로드할 벡터를 포함시킬 수 있습니다. 벡터는 모두 같은 차원을 가져야 하며, 리스트 내의 각 벡터는 하나의 데이터 포인트를 나타내야 합니다.

4.2 유사한 데이터를 조회

먼저, 데이터베이스에 업로드된 벡터를 사용하여 입력된 질문과 유사한 벡터를 찾는 방법을 알아보겠습니다. 이를 위해서는 pinecone.query() 함수를 사용할 수 있습니다.

import pinecone

# Pinecone API key를 설정합니다.
pinecone.init(api_key="YOUR_API_KEY")

# 데이터베이스 이름을 설정합니다.
index_name = "YOUR_INDEX_NAME"

# 입력된 질문을 임베딩합니다.
question_embedding = text_embedding("당뇨병 환자에서 체중 증가와 혈당 수치의 상관관계는?")

# Pinecone 데이터베이스에서 유사한 벡터를 검색합니다.
result = pinecone.query(index_name=index_name, query=question_embedding, top_k=3)

# 검색된 벡터를 출력합니다.
print(result)

# Pinecone API key를 삭제합니다.
pinecone.deinit()

위 코드에서 YOUR_API_KEY와 YOUR_INDEX_NAME은 사용자가 설정한 값입니다. YOUR_API_KEY는 Pinecone 계정의 API 키를 입력해야 하며, YOUR_INDEX_NAME은 생성한 데이터베이스의 이름을 입력하면 됩니다.

pinecone.query() 함수에서 index_name은 조회할 데이터베이스의 이름입니다. query는 입력된 질문을 임베딩한 벡터입니다. top_k는 조회할 벡터의 수를 나타냅니다. 위 코드에서는 3개의 벡터가 반환됩니다.

위 코드를 실행하면, 입력된 질문과 유사한 벡터 3개가 출력됩니다. 이 벡터는 질문과 유사한 정보를 포함하고 있으므로, 이를 바탕으로 자연스러운 응답을 생성할 수 있습니다.

4.3 ChatGPT에게 답변 생성 요청

이제, 가져온 정보와 입력된 질문을 바탕으로 ChatGPT에게 질문 생성을 요청합니다.

아래는 챗봇 시스템 명령어와 요청 코드입니다.

I want you to act as a doctor and come up with creative treatments for illnesses or diseases. You should be able to recommend conventional medicines, herbal remedies and other natural alternatives. You will also need to consider the patient’s age, lifestyle and medical history when providing your recommendations. 

You will given json file like below, "question" value is patient question message. And "search_result" value is searched result from reliable vector database with "question". You have to answer with "search_result". If all of search_result is not related to "question", you have to say "Sorry, there is no information about question". When search_result has not related to "question", you can ignore it. When you create response, you have to express reference with footnote format.

Below is example of request and response.

User : 
{
    "question": "유방암의 정의에 대해서 알려줘",
    "search_result": [
        {
            "query": "유방암의 정의에 대해서 알려줘",
            "results": [
                {
                    "text": "1. 유방암이란 유방 밖으로 퍼져 생명을 위협할 수 있는 악성 종양으로 유방에 있는 세포 중 어느 것이라도 암이 될 수 있습니다. 일반적으로 유관과 유엽에 존재하는 세포에서 기원합니다. 유방암의 발생률은 세계적으로 증가하고 있으며, 우리나라 여성에게 발생하는 전체암 중 2번째로 흔한 암입니다. 이에 따라 사망률도 증가하고 있지만, 2012년 기준 한국 유방암 연령표준",
                    "metadata": {
                        "source": "유방의학회 1페이지"
                    }
                }
            ]
        }
    ]
}

Response:
방암이란 유방 밖으로 퍼져 생명을 위협할 수 있는 악성 종양으로 유방에 있는 세포 중 어느 것이라도 암이 될 수 있습니다. 일반적으로 유관과 유엽에 존재하는 세포에서 기원합니다. 유방암의 발생률은 세계적으로 증가하고 있으며, 우리나라 여성에게 발생하는 전체암 중 2번째로 흔한 암입니다.[1]

[1] 유방의학회 1페이지
import openai
openai.api_key = "YOUR_API_KEY"

def generate_question(prompt, related_information):
		# prompt = "당뇨병 환자에서 체중 증가와 혈당 수치의 상관관계는?"
    # related_information = "살이 찌면 체내 지방이 증가함에 따라 인슐린 저항성이 증가하고, 인슐린의 효과가 감소합니다. 이로 인해 혈당이 상승할 수 있습니다. 따라서, 당뇨병 환자는 체중을 유지하거나 감량하여 혈당을 조절하는 것이 중요합니다.([1] 이명숙, 이동호, 이은정, 박창수, "당뇨병 환자에서 비만이 인슐린 저항성과 혈당 수준에 미치는 영향," 대한내분비학회지, 2005.)"
		system_command=
    response = oopenai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=[
            {"role": "system", "content": system_command},
            {"role": "user", "content": {"prompt" : prompt, "related_information" :related_information},
        ]
    )
    question = response.choices[0].text.strip()
    return question

위 코드에서 prompt 변수는 입력된 질문입니다. related_information 변수는 Pinecone 데이터베이스에서 가져온 연관 정보입니다. 이 정보를 바탕으로 ChatGPT에게 질문 생성을 요청합니다.

ChatGPT는 입력된 정보를 바탕으로 자연스러운 질문을 생성한 후 반환합니다. 이후, 이 질문을 사용자에게 전달하면 됩니다.

위 코드를 실행하면 아래와 같이 출력됩니다.

사용자 질문: "저는 당뇨병이 있는데, 살 찌면 항상 혈당이 높아지는데 이유가 무엇인가요?"

챗봇 : 살이 찌면 체내 지방이 증가함에 따라 인슐린 저항성이 증가하고, 인슐린의 효과가 감소합니다. 이로 인해 혈당이 상승할 수 있습니다. 따라서, 당뇨병 환자는 체중을 유지하거나 감량하여 혈당을 조절하는 것이 중요합니다.

이 답변은 검증된 의료 데이터를 바탕으로 생성되었으며, 출처는 의료 전문가가 작성한 논문입니다. 해당 논문의 출처는 다음과 같습니다: [1]

감사합니다.

[1] 이명숙, 이동호, 이은정, 박창수, "당뇨병 환자에서 비만이 인슐린 저항성과 혈당 수준에 미치는 영향," 대한내분비학회지, 2005.

이와 같이 출처를 명시한 자연스러운 질문을 생성할 수 있습니다.

5. 한계

데이터베이스를 이용해 검증된 정보를 바탕으로 답변을 생성하도록 할 수 있지만, 이 방법에는 몇 가지 한계가 있습니다. 첫째로, 데이터베이스에 업로드된 정보가 모든 상황에서 적절하지 않을 수 있습니다. 예를 들어, 새로운 질병이 발생했을 때, 이에 대한 정보가 데이터베이스에 없는 경우가 있을 수 있습니다. 둘째로, 데이터베이스에서 검색된 정보가 질문과 관련이 없는 경우가 있을 수 있습니다. 이 경우, 적절한 대답을 제공할 수 없습니다. 마지막으로, 데이터베이스에 업로드된 정보가 충분하지 않은 경우도 있습니다. 이 경우, 적절한 대답을 제공할 수 없습니다. 따라서, 데이터베이스를 이용해 검증된 정보를 바탕으로 답변을 생성하도록 할 때는 이러한 한계를 염두에 두어야 합니다.

ChatGPT를 이용해 검증된 정보를 기반으로 응답 생성 시도

1. 시도 이유

기존 챗봇은 아래와 같은 문제를 가지고 있다.

  1. 정적인 대답, 이는 구글이나 네이버와 같은 검색 엔진과 차별점이 존재하지 않는다.
  2. 문맥 파악 불가, “내가 기저질환이 ~~ 한 편인데, 유방암이 발병할 가능성이 높을까?”와 같은 문맥을 파악한 응답이 불가능하다.

GPT의 경우, 문맥을 파악한 동적인 대답이 가능하지만 아래와 같은 문제가 있다.

  1. 2021년 이전의 데이터셋을 바탕으로 개발되었기 때문에 최신 정보에 접근이 불가능하다.
  2. 인터넷에 있는 정보를 사용하였기 때문에 신뢰성이 떨어진다.
  3. 존재하지도 않는 정보를 생성하는 Hallucination 이 발생한다.

즉, 위 두 챗봇은 “검증된 정보로 사용자를 고려해 적절한 답변을 제시한다.”라는 목표에 도달하기 어렵다. 이를 해결하기 위해서는 검증된 데이터를 확보하고 그 데이터와 사용자 정보를 입력받아 답변을 생성하는 기술이 필요하다.

2. 목표

따라서, 이번 개발의 목표와 결과 예시는 다음과 같다.

  1. 검증된 데이터만으로 답변을 생성하는 기능
  2. 데이터와 사용자 정보를 입력받아 적절한 답변을 생성하는 기능
  3. 답변에 필요한 정보가 존재하지 않는 경우 이를 알리는 기능
  4. 답변에 쓰인 데이터의 출처를 명시하는 기능

결과 예시

사용자 질문: "저는 당뇨병이 있는데, 살 찌면 항상 혈당이 높아지는데 이유가 무엇인가요?"

봇보답: 살이 찌면 체내 지방이 증가함에 따라 인슐린 저항성이 증가하고, 인슐린의 효과가 감소합니다. 이로 인해 혈당이 상승할 수 있습니다. 따라서, 당뇨병 환자는 체중을 유지하거나 감량하여 혈당을 조절하는 것이 중요합니다.

이 답변은 검증된 의료 데이터를 바탕으로 생성되었으며, 출처는 의료 전문가가 작성한 논문입니다. 해당 논문의 출처는 다음과 같습니다: [1]

감사합니다.

[1] 이명숙, 이동호, 이은정, 박창수, "당뇨병 환자에서 비만이 인슐린 저항성과 혈당 수준에 미치는 영향," 대한내분비학회지, 2005.

3. 방법

목표를 구현하기 위해선 아래 기능이 필요하다.

  1. 사용자 질문을 받아 비슷한 정보를 검색하는 기능
  2. 검증된 정보와 출처를 데이터베이스에 저장하는 기능
  3. 사용자 질문, 정보 그리고 출처를 바탕으로 자연스러운 응답을 생성하는 기능

기능은 비교적 간단하다. 그러나 높은 효과를 보기 위해선 제약조건이 존재한다.

예를 들어서, 검증정보가 기하급수적으로 증가해 정보 검색을 하는데 수 십분이 걸리게 되는 경우 서비스로 제공할 수 없다. 또한, 사용자 질문과 무관한 정보가 검색되는 경우 잘못된 응답이 생성된다. 이를 해결하기 위해 의미론적 텍스트 임베딩 기술과 벡터 데이터베이스가 필요하다.

3.1 텍스트 임베딩

텍스트 임베딩을 영어, 한글과 같은 자연어를 컴퓨터가 이해하기 쉽도록 숫자열 형태로 변환하는 것을 말한다. 이 떄, 단순히 형태를 바탕으로 임베딩을 진행할 경우 문제가 발생한다. "사랑", "애정", "모정", "애착" 등의 단어는 유사한 단어이지만, 단순히 형태를 보면 차이가 존재해 [1,1,1…] , [20,9,11…] 등과 같이 임베딩을 진행할 경우 거리가 생길 수 있다. 따라서 의미를 충분히 고려한 임베딩 기술이 필요하다. 이번 구현에는 Open AI의 text-embedding-ada-002 를 사용하도록 하겠다.

3.2 벡터 데이터베이스

텍스트 임베딩이 잘 수행되었다면, 다음은 사용자 질문과 유사항 정보를 빠르게 찾는 것이 문제입니다. 임베딩 결과는 매우 긴 숫자열이며, 정보가 10만 줄이기만 해도, 유사성을 일일이 분석한다면 시간이 매우 오래 걸립니다. 이를 해결하기 위해선 벡터(숫자열) 정보를 저장, 조회, 삭제, 수정 그리고 동시 처리 작업을 원할하게 진행할 수 있는 벡터 데이터베이스를 도입하면됩니다. 여기에서는 데이터베이스를 쉽게 생성, 관리 할 수 있는 Pinecone 벡터 데이터베이스를 사용해보겠습니다.

4. 구현

4.1 텍스트 임베딩

4.1 텍스트 임베딩

Open AI의 text-embedding-ada-002 API를 사용하여 예시 텍스트를 임베딩해보겠습니다.

import openai
openai.api_key = "YOUR_API_KEY"

def text_embedding(text):
    response = openai.Completion.create(
        engine="text-davinci-002",
        prompt= f"embed text: {text}",
        temperature=0,
        max_tokens=256,
        top_p=1,
        frequency_penalty=0,
        presence_penalty=0
    )
    embedding = response.choices[0].text
    return embedding

위 함수를 사용하여 텍스트를 임베딩할 수 있습니다. 이 때, YOUR_API_KEY에는 Open AI API 키를 입력해주세요.

text = "당뇨병 환자에서 체중 증가와 혈당 수치의 상관관계는?"
embed = text_embedding(text)
print(embed)

위와 같이 입력하면 아래와 같이 임베딩된 결과를 확인할 수 있습니다.

[-0.461, 0.137, 0.186, 0.065, 0.238,.....]

4.1 데이터베이스 생성

https://docs.pinecone.io/docs 사이트를 보면 쉽게 데이터베이스를 생성하고 파이썬 코드로 접속할 수 있는 방법을 알 수 있습니다.

Pinecone 데이터베이스를 사용하여 벡터를 업로드하는 방법은 다음과 같습니다.

import pinecone

# Pinecone API key를 설정합니다.
pinecone.init(api_key="YOUR_API_KEY")

# 데이터베이스를 생성합니다.
pinecone.create_index(index_name="YOUR_INDEX_NAME", dimension=YOUR_DIMENSION)

# 임베딩 벡터를 리스트로 생성합니다.
vectors = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]

# 벡터를 데이터베이스에 업로드합니다.
pinecone.insert(index_name="YOUR_INDEX_NAME", data=vectors)

# Pinecone API key를 삭제합니다.
pinecone.deinit()

위 코드에서 YOUR_API_KEY와 YOUR_INDEX_NAME, YOUR_DIMENSION은 사용자가 설정해야 하는 값입니다. YOUR_API_KEY는 Pinecone 계정의 API 키를 입력해야 하며, YOUR_INDEX_NAME은 생성하고자 하는 데이터베이스의 이름을 입력하면 됩니다. YOUR_DIMENSION은 임베딩 벡터의 차원을 나타냅니다.

또한, vectors 리스트에는 업로드할 벡터를 포함시킬 수 있습니다. 벡터는 모두 같은 차원을 가져야 하며, 리스트 내의 각 벡터는 하나의 데이터 포인트를 나타내야 합니다.

4.2 유사한 데이터를 조회

먼저, 데이터베이스에 업로드된 벡터를 사용하여 입력된 질문과 유사한 벡터를 찾는 방법을 알아보겠습니다. 이를 위해서는 pinecone.query() 함수를 사용할 수 있습니다.

import pinecone

# Pinecone API key를 설정합니다.
pinecone.init(api_key="YOUR_API_KEY")

# 데이터베이스 이름을 설정합니다.
index_name = "YOUR_INDEX_NAME"

# 입력된 질문을 임베딩합니다.
question_embedding = text_embedding("당뇨병 환자에서 체중 증가와 혈당 수치의 상관관계는?")

# Pinecone 데이터베이스에서 유사한 벡터를 검색합니다.
result = pinecone.query(index_name=index_name, query=question_embedding, top_k=3)

# 검색된 벡터를 출력합니다.
print(result)

# Pinecone API key를 삭제합니다.
pinecone.deinit()

위 코드에서 YOUR_API_KEY와 YOUR_INDEX_NAME은 사용자가 설정한 값입니다. YOUR_API_KEY는 Pinecone 계정의 API 키를 입력해야 하며, YOUR_INDEX_NAME은 생성한 데이터베이스의 이름을 입력하면 됩니다.

pinecone.query() 함수에서 index_name은 조회할 데이터베이스의 이름입니다. query는 입력된 질문을 임베딩한 벡터입니다. top_k는 조회할 벡터의 수를 나타냅니다. 위 코드에서는 3개의 벡터가 반환됩니다.

위 코드를 실행하면, 입력된 질문과 유사한 벡터 3개가 출력됩니다. 이 벡터는 질문과 유사한 정보를 포함하고 있으므로, 이를 바탕으로 자연스러운 응답을 생성할 수 있습니다.

4.3 ChatGPT에게 답변 생성 요청

이제, 가져온 정보와 입력된 질문을 바탕으로 ChatGPT에게 질문 생성을 요청합니다.

아래는 챗봇 시스템 명령어와 요청 코드입니다.

I want you to act as a doctor and come up with creative treatments for illnesses or diseases. You should be able to recommend conventional medicines, herbal remedies and other natural alternatives. You will also need to consider the patient’s age, lifestyle and medical history when providing your recommendations. 

You will given json file like below, "question" value is patient question message. And "search_result" value is searched result from reliable vector database with "question". You have to answer with "search_result". If all of search_result is not related to "question", you have to say "Sorry, there is no information about question". When search_result has not related to "question", you can ignore it. When you create response, you have to express reference with footnote format.

Below is example of request and response.

User : 
{
    "question": "유방암의 정의에 대해서 알려줘",
    "search_result": [
        {
            "query": "유방암의 정의에 대해서 알려줘",
            "results": [
                {
                    "text": "1. 유방암이란 유방 밖으로 퍼져 생명을 위협할 수 있는 악성 종양으로 유방에 있는 세포 중 어느 것이라도 암이 될 수 있습니다. 일반적으로 유관과 유엽에 존재하는 세포에서 기원합니다. 유방암의 발생률은 세계적으로 증가하고 있으며, 우리나라 여성에게 발생하는 전체암 중 2번째로 흔한 암입니다. 이에 따라 사망률도 증가하고 있지만, 2012년 기준 한국 유방암 연령표준",
                    "metadata": {
                        "source": "유방의학회 1페이지"
                    }
                }
            ]
        }
    ]
}

Response:
방암이란 유방 밖으로 퍼져 생명을 위협할 수 있는 악성 종양으로 유방에 있는 세포 중 어느 것이라도 암이 될 수 있습니다. 일반적으로 유관과 유엽에 존재하는 세포에서 기원합니다. 유방암의 발생률은 세계적으로 증가하고 있으며, 우리나라 여성에게 발생하는 전체암 중 2번째로 흔한 암입니다.[1]

[1] 유방의학회 1페이지
import openai
openai.api_key = "YOUR_API_KEY"

def generate_question(prompt, related_information):
		# prompt = "당뇨병 환자에서 체중 증가와 혈당 수치의 상관관계는?"
    # related_information = "살이 찌면 체내 지방이 증가함에 따라 인슐린 저항성이 증가하고, 인슐린의 효과가 감소합니다. 이로 인해 혈당이 상승할 수 있습니다. 따라서, 당뇨병 환자는 체중을 유지하거나 감량하여 혈당을 조절하는 것이 중요합니다.([1] 이명숙, 이동호, 이은정, 박창수, "당뇨병 환자에서 비만이 인슐린 저항성과 혈당 수준에 미치는 영향," 대한내분비학회지, 2005.)"
		system_command=
    response = oopenai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=[
            {"role": "system", "content": system_command},
            {"role": "user", "content": {"prompt" : prompt, "related_information" :related_information},
        ]
    )
    question = response.choices[0].text.strip()
    return question

위 코드에서 prompt 변수는 입력된 질문입니다. related_information 변수는 Pinecone 데이터베이스에서 가져온 연관 정보입니다. 이 정보를 바탕으로 ChatGPT에게 질문 생성을 요청합니다.

ChatGPT는 입력된 정보를 바탕으로 자연스러운 질문을 생성한 후 반환합니다. 이후, 이 질문을 사용자에게 전달하면 됩니다.

위 코드를 실행하면 아래와 같이 출력됩니다.

사용자 질문: "저는 당뇨병이 있는데, 살 찌면 항상 혈당이 높아지는데 이유가 무엇인가요?"

챗봇 : 살이 찌면 체내 지방이 증가함에 따라 인슐린 저항성이 증가하고, 인슐린의 효과가 감소합니다. 이로 인해 혈당이 상승할 수 있습니다. 따라서, 당뇨병 환자는 체중을 유지하거나 감량하여 혈당을 조절하는 것이 중요합니다.

이 답변은 검증된 의료 데이터를 바탕으로 생성되었으며, 출처는 의료 전문가가 작성한 논문입니다. 해당 논문의 출처는 다음과 같습니다: [1]

감사합니다.

[1] 이명숙, 이동호, 이은정, 박창수, "당뇨병 환자에서 비만이 인슐린 저항성과 혈당 수준에 미치는 영향," 대한내분비학회지, 2005.

이와 같이 출처를 명시한 자연스러운 질문을 생성할 수 있습니다.

5. 한계

데이터베이스를 이용해 검증된 정보를 바탕으로 답변을 생성하도록 할 수 있지만, 이 방법에는 몇 가지 한계가 있습니다. 첫째로, 데이터베이스에 업로드된 정보가 모든 상황에서 적절하지 않을 수 있습니다. 예를 들어, 새로운 질병이 발생했을 때, 이에 대한 정보가 데이터베이스에 없는 경우가 있을 수 있습니다. 둘째로, 데이터베이스에서 검색된 정보가 질문과 관련이 없는 경우가 있을 수 있습니다. 이 경우, 적절한 대답을 제공할 수 없습니다. 마지막으로, 데이터베이스에 업로드된 정보가 충분하지 않은 경우도 있습니다. 이 경우, 적절한 대답을 제공할 수 없습니다. 따라서, 데이터베이스를 이용해 검증된 정보를 바탕으로 답변을 생성하도록 할 때는 이러한 한계를 염두에 두어야 합니다.

최근 서버 환경을 쿠버네틱스로 변경했기 때문에 지속적 배포 방법을 변경해야 합니다. 오늘은 Github Action으로 구현해 볼 것입니다.

쿠버네틱스에 배포하는 방법은 간단합니다. 원하는 배포사항을 YAML 파일로 작성해 kubectl로 등록해주면 됩니다. 이후에 Github Action에서 SSH로 접속해 YAML 파일을 전달하고 kubectl을 실행해줄 것입니다.

도커 빌드 및 레지스트리 저장

레포지토리명과 브랜치 이름을 바탕으로 이미지 이름을 정할 것입니다. Github Secret 에는 도커 허브 로그인에 사용할 DOCKER_USERNAME와 DOCKER_PASSWORD를 등록하면 됩니다.

스크립트는 다음과 같습니다.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: <https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle>

name: Spring/Kotlin Coverage Test on main

on:
  push:
    branches: [ "main","dev","qa" ]

permissions:
  contents: write

jobs:
  Deploy-docker-image:

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - name: Build
        run: ./gradlew build

      - name: Create Test Coverage Report
        run: ./gradlew jacocoTestReport

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3.1.1
        with:
          file: ./build/reports/jacoco/test/jacocoTestReport.xml

      - name: Unpack JAR
        run: |
          mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*.jar)

      - name: Set lowercase repository name without owner environment variable
        run: |
          echo "REPOSITORY_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]' | cut -d'/' -f2)" >> $GITHUB_ENV

      - name: Get lowercase branch name
        run: |
          echo "BRANCH_NAME=$(echo $GITHUB_REF_NAME | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV

      - name: Get docker image name
        run: |
          echo "IMAGE_NAME=${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:${{ env.BRANCH_NAME }}" >> $GITHUB_ENV

      - name: Build docker image
        run: |
          docker build -t ${{ env.IMAGE_NAME }} .
          docker tag ${{ env.IMAGE_NAME }} ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

      - name: Push docker image
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker push ${{ env.IMAGE_NAME }}
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

Plain JAR 생성은 억제해야 합니다. 코틀린의 경우 build.gradle.kts에 아래와 같이 입력합니다.

tasks.getByName<Jar>("jar") {
    enabled = false
}

자바인 경우 build.gradle에 아래와 같이 입력합니다.

jar{
	enabled = false
}

이렇게 하면 dev 브랜치에 새로운 커밋이 생성될 때마다 그 커밋을 기준으로 도커 이미지를 빌드하고 푸시합니다. 그럼 latest와 dev 두 개가 만들어져 있을 것입니다. 꼭 latest를 만들어야 하는 것은 아닙니다.

EC2에 SSH로 접속하기

이제 SSH에 접속해보겠습니다. ssh-action을 사용하면 상당히 쉽습니다.

접속할 때는 ec2를 생성할 때 적용한 .pem 키 파일을 사용할 예정입니다.

1. 깃헙 시크릿 등록하기

깃헙 시크릿에는 총 3개를 등록해야 합니다.

  1. 사용자 이름 (AWS_USERNAME)
  2. EC2 호스트 주소(AWS_HOST)
  3. 시크릿 키(AWS_SECRET_ACCESS_KEY)

사용자 이름은 기본적으로 ec2-user 입니다.

호스트의 경우 ip 주소 또는 이를 가리키는 도메인을 의미합니다.

시크릿 키는 .pem 파일 값입니다.

메모장 또는 cat 명령어로 .pem 파일 내부의 값을 출력해 복사 후 시크릿으로 등록합니다.

2. 배포 스크립트 작성

k8s에 배포를 위한 스크립트를 작성합니다.

서비스와 배포에 대한 명세를 deploy.yml에 작성합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy
  labels:
    app: spring-demo
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo
  template:
    metadata:
      labels:
        app: spring-demo
    spec:
      containers:
        - name: spring-demo
          image: 'lee01042000/beamworks-backend:dev'
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  type: NodePort
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30007

3. Github Action k8s 배포 스크립트 작성

이제 deploy.yaml을 서버에 전달해 kubectl로 적용하는 스크립트를 작성합니다.

scp, ssh 액션을 사용하면 쉽게 가능합니다.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: <https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle>

name: Spring/Kotlin Coverage Test on main

on:
  push:
    branches: [ "main","dev","qa" ]

permissions:
  contents: write

jobs:
  Deploy:

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - name: Build
        run: ./gradlew build

      - name: Create Test Coverage Report
        run: ./gradlew jacocoTestReport

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3.1.1
        with:
          file: ./build/reports/jacoco/test/jacocoTestReport.xml

      - name: Unpack JAR
        run: |
          mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*.jar)

      - name: Set lowercase repository name without owner environment variable
        run: |
          echo "REPOSITORY_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]' | cut -d'/' -f2)" >> $GITHUB_ENV

      - name: Get lowercase branch name
        run: |
          echo "BRANCH_NAME=$(echo $GITHUB_REF_NAME | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV

      - name: Get docker image name
        run: |
          echo "IMAGE_NAME=${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:${{ env.BRANCH_NAME }}" >> $GITHUB_ENV

      - name: Build docker image
        run: |
          docker build -t ${{ env.IMAGE_NAME }} .
          docker tag ${{ env.IMAGE_NAME }} ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

      - name: Push docker image
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker push ${{ env.IMAGE_NAME }}
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

      - name: Transport deploy.yaml to server
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.AWS_HOST }}
          username: ${{ secrets.AWS_USERNAME }}
          key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          overwrite: true
          source: ./deploy.yaml
          target: ~/

      - name: Connect to AWS EC2 with SSH
        uses: appleboy/ssh-action@v0.1.8
        with:
          host: ${{ secrets.AWS_HOST }}
          username: ${{ secrets.AWS_USERNAME }}
          key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          script: kubectl apply -f deploy.yaml

4. 배포 및 확인

지금까지 작성한 커밋을 dev 브랜치에 커밋하면 Github Action이 실행됩니다.

위와 같이 배포가 잘 이루어지면 서버에 가서 상태를 확인합니다.

아래처럼 새로운 파드가 돌아가면 성공입니다.

쿠버네틱스에 배포하는 방법은 간단합니다. 원하는 배포사항을 YAML 파일로 작성해 kubectl로 등록해주면 됩니다. 이후에 Github Action에서 SSH로 접속해 YAML 파일을 전달하고 kubectl을 실행해줄 것입니다.

도커 빌드 및 레지스트리 저장

레포지토리명과 브랜치 이름을 바탕으로 이미지 이름을 정할 것입니다. Github Secret 에는 도커 허브 로그인에 사용할 DOCKER_USERNAME와 DOCKER_PASSWORD를 등록하면 됩니다.

스크립트는 다음과 같습니다.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: <https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle>

name: Spring/Kotlin Coverage Test on main

on:
  push:
    branches: [ "main","dev","qa" ]

permissions:
  contents: write

jobs:
  Deploy-docker-image:

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - name: Build
        run: ./gradlew build

      - name: Create Test Coverage Report
        run: ./gradlew jacocoTestReport

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3.1.1
        with:
          file: ./build/reports/jacoco/test/jacocoTestReport.xml

      - name: Unpack JAR
        run: |
          mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*.jar)

      - name: Set lowercase repository name without owner environment variable
        run: |
          echo "REPOSITORY_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]' | cut -d'/' -f2)" >> $GITHUB_ENV

      - name: Get lowercase branch name
        run: |
          echo "BRANCH_NAME=$(echo $GITHUB_REF_NAME | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV

      - name: Get docker image name
        run: |
          echo "IMAGE_NAME=${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:${{ env.BRANCH_NAME }}" >> $GITHUB_ENV

      - name: Build docker image
        run: |
          docker build -t ${{ env.IMAGE_NAME }} .
          docker tag ${{ env.IMAGE_NAME }} ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

      - name: Push docker image
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker push ${{ env.IMAGE_NAME }}
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

Plain JAR 생성은 억제해야 합니다. 코틀린의 경우 build.gradle.kts에 아래와 같이 입력합니다.

tasks.getByName<Jar>("jar") {
    enabled = false
}

자바인 경우 build.gradle에 아래와 같이 입력합니다.

jar{
	enabled = false
}

이렇게 하면 dev 브랜치에 새로운 커밋이 생성될 때마다 그 커밋을 기준으로 도커 이미지를 빌드하고 푸시합니다. 그럼 latest와 dev 두 개가 만들어져 있을 것입니다. 꼭 latest를 만들어야 하는 것은 아닙니다.

EC2에 SSH로 접속하기

이제 SSH에 접속해보겠습니다. ssh-action을 사용하면 상당히 쉽습니다.

접속할 때는 ec2를 생성할 때 적용한 .pem 키 파일을 사용할 예정입니다.

1. 깃헙 시크릿 등록하기

깃헙 시크릿에는 총 3개를 등록해야 합니다.

  1. 사용자 이름 (AWS_USERNAME)
  2. EC2 호스트 주소(AWS_HOST)
  3. 시크릿 키(AWS_SECRET_ACCESS_KEY)

사용자 이름은 기본적으로 ec2-user 입니다.

호스트의 경우 ip 주소 또는 이를 가리키는 도메인을 의미합니다.

시크릿 키는 .pem 파일 값입니다.

메모장 또는 cat 명령어로 .pem 파일 내부의 값을 출력해 복사 후 시크릿으로 등록합니다.

2. 배포 스크립트 작성

k8s에 배포를 위한 스크립트를 작성합니다.

서비스와 배포에 대한 명세를 deploy.yml에 작성합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy
  labels:
    app: spring-demo
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo
  template:
    metadata:
      labels:
        app: spring-demo
    spec:
      containers:
        - name: spring-demo
          image: 'lee01042000/beamworks-backend:dev'
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  type: NodePort
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30007

3. Github Action k8s 배포 스크립트 작성

이제 deploy.yaml을 서버에 전달해 kubectl로 적용하는 스크립트를 작성합니다.

scp, ssh 액션을 사용하면 쉽게 가능합니다.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: <https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle>

name: Spring/Kotlin Coverage Test on main

on:
  push:
    branches: [ "main","dev","qa" ]

permissions:
  contents: write

jobs:
  Deploy:

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - name: Build
        run: ./gradlew build

      - name: Create Test Coverage Report
        run: ./gradlew jacocoTestReport

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3.1.1
        with:
          file: ./build/reports/jacoco/test/jacocoTestReport.xml

      - name: Unpack JAR
        run: |
          mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*.jar)

      - name: Set lowercase repository name without owner environment variable
        run: |
          echo "REPOSITORY_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]' | cut -d'/' -f2)" >> $GITHUB_ENV

      - name: Get lowercase branch name
        run: |
          echo "BRANCH_NAME=$(echo $GITHUB_REF_NAME | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV

      - name: Get docker image name
        run: |
          echo "IMAGE_NAME=${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:${{ env.BRANCH_NAME }}" >> $GITHUB_ENV

      - name: Build docker image
        run: |
          docker build -t ${{ env.IMAGE_NAME }} .
          docker tag ${{ env.IMAGE_NAME }} ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

      - name: Push docker image
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker push ${{ env.IMAGE_NAME }}
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY_NAME }}:latest

      - name: Transport deploy.yaml to server
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.AWS_HOST }}
          username: ${{ secrets.AWS_USERNAME }}
          key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          overwrite: true
          source: ./deploy.yaml
          target: ~/

      - name: Connect to AWS EC2 with SSH
        uses: appleboy/ssh-action@v0.1.8
        with:
          host: ${{ secrets.AWS_HOST }}
          username: ${{ secrets.AWS_USERNAME }}
          key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          script: kubectl apply -f deploy.yaml

4. 배포 및 확인

지금까지 작성한 커밋을 dev 브랜치에 커밋하면 Github Action이 실행됩니다.

위와 같이 배포가 잘 이루어지면 서버에 가서 상태를 확인합니다.

아래처럼 새로운 파드가 돌아가면 성공입니다.

이전 시간에는 미니큐브에서 스프링 프로젝트와 MySQL을 실행하고, 포트포워딩을 사용하여 외부에서 접속하는 방법을 배웠습니다. 그러나 이 방법으로는 하나의 서비스만 접속할 수 있습니다. 만약 여러 개의 서비스를 운영하고 있다면, 파이썬 기반의 플라스크나 장고, 자바 기반의 스프링, 그리고 자바스크립트 기반의 노드제이에스 등을 함께 사용하는 경우 라우팅이 필요합니다. 이번 시간에는 쿠버네티스에서 라우팅할 수 있는 방법을 배우겠습니다.

domain.com/spring → spring project

domain.com/flask → flask project

1. 샘플 프로젝트 만들기

  1. 스프링의 경우, 이전 시간에 만들었던 것을 그대로 활용할 예정입니다.
  2. 파이썬 프로젝트를 만들기 싫다면, 스킵하고 아래 flask-deploment.yaml 에 명시된 샘플 이미지를 그대로 사용할 수 있습니다.
  3. 플라스크의 경우, 다음과 같이 코드를 작성합니다.
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, Flask!'

if __name__ == '__main__':
    app.run(host="0.0.0.0",port=80, debug=True)

  1. Dockerfile 작성
# Use an official Python runtime as the base image
FROM python:3.9

WORKDIR /app

COPY . .

RUN pip install Flask

EXPOSE 80
# Run the app.py file
CMD ["python", "app.py"]

  1. 빌드 (ec2를 x86으로 생성해줬으니 맞춰서 이미지를 만들어줄것입니다. )
docker build -t lee01042000/flask-test --platform linux/amd64 .

  1. 실행
docker run -it -p 8080:80 lee01042000/flask-test

  1. 크롬에서 8080 포트로 테스트해보세요. 만약 "Hello, Flask!"가 잘 나오면 끝입니다!
  2. 쿠버네틱스가 이미지를 가져오게 하기 위해 도커 허브에 푸쉬합니다. 만약 로그인이 안되어 있다면 로그인을 하고 진행하시면 됩니다.
docker push lee01042000/flask-test

2. Python Deployment & Service 작성하기

이전 포스트에서 스프링 프로젝트를 실행한 것처럼, 서비스와 배포를 명시한 python-deploy.yaml을 작성할 것입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-deployment
  labels:
    app: flask
spec:
  replicas: 1
  selector:
    matchLabels:
      app: flask
  template:
    metadata:
      labels:
        app: flask
    spec:
      containers:
      - name: flask
        image: 'lee01042000/flask-test'
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: flask-service
spec:
  type: NodePort
  selector:
    app: flask
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 80
      nodePort: 30008

kubectl apply -f python-deploy.yaml 을 이용해 쿠버네틱스에 적용하면 배포와 서비스를 실행할 수 있습니다.

minikube ip 명령어를 사용하여 IP 주소를 찾은 다음, curl {minikubeip}:30008을 실행하여 "Hello, Flask!"가 출력되면 성공입니다.

2. Ingress로 라우팅 구현하기

쿠버네틱스에서는 ingress를 사용하여 서비스 라우팅이 가능합니다.

이렇게 ingress는 요청을 먼저 받아들여 적절한 서비스로 라우팅할 수 있습니다.

URL 디렉터리를 기반으로 하거나, 요청 헤더에 포함된 호스트명을 가지고도 라우팅이 가능합니다.

L7 스위치라고 생각하시면 이해하기 쉽습니다.

2.1 Nginx ingress controller 설치

ingress 룰을 적용하기 위해선 룰을 구현하는 ingress controller가 필요합니다. 공식 홈페이지에서는 다양한 ingress controller를 찾아볼 수 있지만, 이 문서에서는 nginx를 사용해보겠습니다.

https://kubernetes.io/ko/docs/tasks/access-application-cluster/ingress-minikube/

  1. NGINX 인그레스 컨트롤러를 활성화하기 위해 다음 명령어를 실행합니다.
  2. minikube addons enable ingress

2.2 ingress 룰 작성

참고로 nginx.ingress.kubernetes.io/rewrite-target 옵션을 설정하지 않으면 http://minikubeip/spring 으로 요청시 /spring이 url에 포함되서 포워딩이 되니 / 로 명시해야합니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - http:
      paths:
      - path: /spring
        pathType: Prefix
        backend:
          service:
            name: spring-service
            port:
              number: 8080
      - path: /flask
        pathType: Prefix
        backend:
          service:
            name: flask-service
            port:
              number: 80

  1. kubectl apply -f ingress.yaml 명령어로 적용합니다.
  2. curl {minikubeip}/flask & curl {minikubeip}/spring을 쳤을 때 사이 좋게 나오면 성공입니다!

3. 외부 접속 연결하기

아직은 ec2 내부에서만 접속이 가능합니다. 즉, client → server — x → ingress → service → pod인 상태입니다.

그럼 이제 서버포트랑 미니큐브 사이만 연결해주면 됩니다.

이를 포트포워딩이라고 합니다.

가장 친숙한 방법은 nginx를 사용하는 것입니다.

3.1 EC2에 Nginx를 설치

sudo amaxon-linux-extras install nginx1

3.2 EC2에 라우팅 정책 설정

minikube ip 가 192.168.49.2 인 경우 아래와 같이 server.location을 설정합니다.

# For more information on configuration, see:
#   * Official English Documentation: <http://nginx.org/en/docs/>
#   * Official Russian Documentation: <http://nginx.org/ru/docs/>

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;

events {
    worker_connections 1024;
}

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 4096;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    # Load modular configuration files from the /etc/nginx/conf.d directory.
    # See <http://nginx.org/en/docs/ngx_core_module.html#include>
    # for more information.
    include /etc/nginx/conf.d/*.conf;

    server {
        listen       80;
        listen       [::]:80;
        server_name  _;
        root         /usr/share/nginx/html;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        error_page 404 /404.html;
        location = /404.html {
        }

        error_page 500 502 503 504 /50x.html;
        location = /50x.html {
        }

	location / {
            proxy_pass         http://192.168.49.2/;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
        }
    }

# Settings for a TLS enabled server.
#
#    server {
#        listen       443 ssl http2;
#        listen       [::]:443 ssl http2;
#        server_name  _;
#        root         /usr/share/nginx/html;
#
#        ssl_certificate "/etc/pki/nginx/server.crt";
#        ssl_certificate_key "/etc/pki/nginx/private/server.key";
#        ssl_session_cache shared:SSL:1m;
#        ssl_session_timeout  10m;
#        ssl_ciphers PROFILE=SYSTEM;
#        ssl_prefer_server_ciphers on;
#
#        # Load configuration files for the default server block.
#        include /etc/nginx/default.d/*.conf;
#
#        error_page 404 /404.html;
#            location = /40x.html {
#        }
#
#        error_page 500 502 503 504 /50x.html;
#            location = /50x.html {
#        }
#    }

}

3.3 재설치 및 재실행

sudo systemctl restart nginx

4. 테스트

이제 EC2서버에 접속해보겠습니다.

아래와 같이 잘 출력되면 성공입니다!

 

이 글을 보는 사람은 도커와 쿠버네티스에 대한 기본적인 지식이 있다고 생각할 것입니다.

도커는 알겠는데 쿠버네티스는 처음이라면 아래 동영상을 보고 진행하시기 바랍니다.

https://www.youtube.com/watch?v=s_o8dwzRlu4&ab_channel=TechWorldwithNana

쿠버네티스 컨테이너에 외부에서 접속하는 과정은 일반적으로 다음과 같습니다.

외부 → nginx → minikube → ingress controller → service → pod

각각이 잘 동작하는지 확인하려면 거꾸로 구현해야 합니다.

여기서는 서비스와 파드를 구현해보고 외부에서 접속하는 과정은 ingress 대신 포트포워딩을 이용해 간단히 구현해볼 것입니다.

쿠버네티스에 스프링 프로젝트 배포하기

1. EC2에 Minicube 설치하기

1.1. EC2 생성하기

  • ARM 버전으로 진행하면 이슈가 많아서 x86으로 진행하는 것을 추천합니다.
  • 인스턴스는 미니큐브 조건 때문에 t2 미디엄을 사용합니다.
    • 프리티어 사용하면 미니큐브 설치 에러가 발생합니다.
  • SSH로 접속합니다.
  • 프리티어가 아니니까 주의하시기 바랍니다.

1.2. 도커 설치하기

  • 두 단계로 나누어서 설치할 것입니다.
  • docker info를 입력해서 잘 나오면 설치가 잘 된 것입니다.

1.3. 미니큐브 설치하기

  • 공식 홈페이지에서 환경에 맞춰서 설치할 것입니다.
  • minikube version을 입력해서 잘 나오면 설치가 잘 된 것입니다.

1.4. kubectl 설치하기

  • 환경에 맞춰서 설치할 것입니다.
  • kubectl version --short를 입력해서 잘 나오면 설치가 잘 된 것입니다.

2. 샘플 프로젝트 만들기

  • 테스트를 위해서는 Spring과 MySQL을 사용하는 샘플 프로젝트를 만들어야 합니다.
  • 이 부분을 뛰어넘고 싶은 분은 그냥 3번부터 시작하시면 됩니다. 제가 미리 만들어둔 프로젝트를 사용할 것입니다.
  • 참고로 제 프로젝트는 /으로 GET 요청을 받으면 HelloEntity라는 인스턴스를 만들어서 DB에 저장하고 결과를 반환해주는 단순한 것입니다.

3. 샘플 프로젝트 배포하기

3.1. ConfigMap 작성

https://kubernetes.io/docs/concepts/configuration/configmap/

vim config.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql-config
data:
  # property-like keys; each key maps to a simple value
  mysql-url : mysql-service

  • 환경 변수에 세팅해줄 URL 주소를 입력합니다.
  • 아래 커맨드를 입력하고 잘 되면 된 것입니다.
kubectl apply -f config.yaml

  • kubectl describe configMap mysql-config을 입력하면 들어가 있는 값이 나옵니다.
[ec2-user@ip-172-31-42-29 spring-test]$ kubectl describe configMap mysql-config
Name:         mysql-config
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
mysql-url:
----
mysql-service

BinaryData
====

Events:  <none>

3.2. Secret 작성

  • 아이디와 비밀번호 등 민감한 정보는 config에 그대로 노출되면 안 됩니다.
    • 파일에 그대로 저장하면 위험합니다.
  • Secret은 암호화된 상태로 저장하기 때문에 털려도 그나마 괜찮습니다.
  • 그러나 설정하지 않으면 기본적으로 암호화 상태로 저장되지 않습니다.
  • 그래서 쿠버네티스에 배포할 수 있는 권한을 가진 사람이 간접적으로 접근 가능합니다.
  • 암호화하려면 따로 설정이 필요합니다.
  • 하지만 우리는 테스트니까 하지 않을 것입니다.

https://kubernetes.io/docs/concepts/configuration/secret/#basic-authentication-secret

  • 아래 커맨드를 사용하시기 바랍니다.
echo -n mysql-user | base64 # bXlzcWwtdXNlcg==
  • stringData 옵션을 사용하면 data 옵션을 대체할 수 있습니다. 하지만 특수문자를 포함한 문자열은 사용할 수 없습니다.
  • 저희는 stringData 옵션을 사용할 예정입니다.
  • 아래 커맨드를 입력하여 mysql-secret.yaml 파일을 적용합니다.
apiVersion: v1
kind: Secret
metadata:
  name: secret-basic-auth
type: kubernetes.io/basic-auth
stringData:
  password: mysql-password

  • kubectl describe secret secret-basic-auth 를 입력하면 아래와 같은 결과가 나타납니다.
Name:         secret-basic-auth
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  kubernetes.io/basic-auth

Data
====
password:  14 bytes

3.3 MySQL Deployment & Service 작성하기

https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ 에서 예시를 확인할 수 있습니다.

apiVersion: apps/v1

kind: Deployment

metadata:

  name: nginx-deployment

  labels:

    app: nginx

spec:

  replicas: 3

  selector:

    matchLabels:

      app: nginx

  template:

    metadata:

      labels:

        app: nginx

    spec:

      containers:

      - name: nginx

        image: nginx:1.14.2

        ports:

        - containerPort: 80

아래와 같이 수정해봅시다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql-deploy
  labels:
    app: mysql
    tier: database
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
        - name: mysql
          image: 'mysql:8.0.26'
          ports:
            - containerPort: 3306
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
---
apiVersion: v1
kind: Service
metadata:
  name: mysql-service
spec:
  selector:
    app: mysql
  ports:
    - protocol: TCP
      port: 3306
      targetPort: 3306

이후 kubectl apply -f mysql-deployment.yaml 를 입력합니다.

kubectl get all 로 파드, 서비스, 배포 그리고 레플리카셋을 확인할 수 있습니다.

아래와 같이 파드가 잘 동작하면 성공입니다.

NAME                                READY   STATUS    RESTARTS   AGE
pod/mysql-deploy-847cd594ff-8hkkc   1/1     Running   0          5s

로그를 자세히 확인하려면 아래의 커맨드를 입력합니다.

kubectl logs -f pod/mysql-deploy-847cd594ff-8hkkc

아래와 같은 로그가 나타나면 준비가 완료된 상태입니다.

2023-02-22T04:59:55.553138Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.26'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

배시 쉘에서 직접 접속하려면 아래의 커맨드를 입력합니다.

kubectl exec -it pod/mysql-deploy-847cd594ff-8hkkc bash

패스워드는 아까 시크릿에서 명시한 mysql-password 입니다. exit 두 번 입력하여 콘솔로 나옵니다.

3.4 Spring 배포와 Service 작성하기

MySQL 배포와 형식이 비슷하기 때문에, 일단 아래와 같이 복사합니다.

cp mysql-deployment.yaml spring-deployment.yaml

그리고 수정을 좀 해줄 겁니다. 저희가 만든 테스트 프로젝트는 환경 변수가 3개이고, 사용자는 그냥 root를 넣고, 비밀번호는 시크릿, URL은 config를 참조할 겁니다.

  • MYSQL_USER
  • MYSQL_PWD
  • MYSQL_URL
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy # 이름을 바꿔줍니다.
  labels:
    app: spring-demo # 레이블을 바꿔줍니다.
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo # 당연히 바꿔줍니다.
  template:
    metadata:
      labels:
        app: spring-demo # 당연히 바꿔줍니다.
    spec:
      containers:
        - name: spring-demo # 당연히 바꿔줍니다.
          image: 'lee01042000/k8s-auto-deployment-test:main-2023-02-21T11-36-33'
          ports:
            - containerPort: 8080 # Spring은 8080 포트를 사용하므로 바꿔줍니다.
          env:
            - name: MYSQL_USER
              value: root
            - name: MYSQL_PWD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
            - name: MYSQL_URL
              valueFrom:
                configMapKeyRef:
                  name: mysql-config
                  key: mysql-url

서비스도 추가해 줄 겁니다. 나중에 외부에서 이 파드에 접속하려면 서비스가 있어야 합니다.

apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

정갈한 버전은 아래와 같습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy
  labels:
    app: spring-demo
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo
  template:
    metadata:
      labels:
        app: spring-demo
    spec:
      containers:
        - name: spring-demo
          image: 'lee01042000/k8s-auto-deployment-test:main-2023-02-21T11-36-33'
          ports:
            - containerPort: 8080
          env:
            - name: MYSQL_USER
              value: root
            - name: MYSQL_PWD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
            - name: MYSQL_URL
              valueFrom:
                configMapKeyRef:
                  name: mysql-config
                  key: mysql-url
---
apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

kubectl get all을 실행해 보세요. 아래와 같이 잘 나오면 성공입니다.

NAME                                     READY   STATUS    RESTARTS   AGE
pod/mysql-deploy-847cd594ff-8hkkc        1/1     Running   0          52m
pod/spring-demo-deploy-c9798b78d-bs5c7   1/1     Running   0          4s

로그를 찍어 보세요.

kubectl logs -f pod/spring-demo-deploy-c9798b78d-bs5c7

로그가 잘 찍히면 실행된 것입니다. 굿굿!

3. 요청해보기

모든 파드와 서비스는 미니큐브 안에 있으므로, 현재는 ec2 로컬에서 요청할 수 없습니다. 스프링 서비스를 미니큐브 바깥으로 연결해야 합니다. 이를 위해서는 로드밸런서나 노드포트를 이용해서 구현할 수 있습니다. 이전에 작성한 서비스를 수정해 봅시다.

apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  type: NodePort #스펙에 노드포트 추가
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30007 # 미니큐브 바깥으로 연결할 포트번호 미니큐프ip:30007로 서비스에 연결가능

기존 파일을 수정한 코드는 아래와 같습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy
  labels:
    app: spring-demo
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo
  template:
    metadata:
      labels:
        app: spring-demo
    spec:
      containers:
        - name: spring-demo
          image: 'lee01042000/k8s-auto-deployment-test:main-2023-02-21T11-36-33'
          ports:
            - containerPort: 8080
          env:
            - name: MYSQL_USER
              value: root
            - name: MYSQL_PWD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
            - name: MYSQL_URL
              valueFrom:
                configMapKeyRef:
                  name: mysql-config
                  key: mysql-url
---
apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  type: NodePort
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30007

명령어를 실행한 결과를 확인해 봅시다.

kubectl get svc

위 명령어를 실행한 결과가 아래와 같으면 성공입니다.

NAME             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes       ClusterIP   10.96.0.1       <none>        443/TCP          103m
mysql-service    ClusterIP   10.110.69.196   <none>        3306/TCP         7m19s
spring-service   NodePort    10.104.52.108   <none>        8080:30007/TCP   5m37s

이제 콘솔에서 minikube ip를 입력하면, 미니큐브에 할당된 로컬 아이피가 나옵니다. curl 192.168.49.2:30007을 입력하면, 아래와 같이 응답을 받을 수 있습니다.

HelloEntity(id=0, name=Hello)

데이터베이스에 데이터가 잘 저장되어 있는지 확인해 봅시다. MySQL에 접속하고 데이터베이스들을 확인합니다.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| k8s_test           |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

데이터베이스가 생성되어 있습니다. use k8s_test;로 데이터베이스를 선택하고, show tables;로 테이블을 확인합니다.

mysql> show tables;
+--------------------+
| Tables_in_k8s_test |
+--------------------+
| hello_entity       |
+--------------------+
1 row in set (0.00 sec)

테이블도 잘 생성되어 있습니다. select * from hello_entity;를 실행하면, 데이터가 잘 저장되어 있는 것을 확인할 수 있습니다.

mysql> select * from hello_entity;
+----+-------+
| id | name  |
+----+-------+
|  0 | Hello |
+----+-------+
1 row in set (0.00 sec)

4. 외부와 연결하기

이제 minikube ip 를 외부에서 들어오는 요청과 매핑해야 합니다.

포트포워딩 방법은 여러 가지가 있습니다. kubectl에서는 다음과 같은 명령어를 사용하여 포트포워딩이 가능합니다.

kubectl port-forward --address 0.0.0.0 service/spring-service 8080:8080

위와 같이 포워딩이 수행되면 다음과 같은 메시지가 출력됩니다.

Forwarding from 0.0.0.0:8080 -> 8080
Handling connection for 8080
Handling connection for 8080

이제 웹 페이지에서 EC2 인스턴스로 요청하면 결과를 잘 받을 수 있습니다.

쿠버네틱스 외부 연결

이 글을 보는 사람은 도커와 쿠버네티스에 대한 기본적인 지식이 있다고 생각할 것입니다.

도커는 알겠는데 쿠버네티스는 처음이라면 아래 동영상을 보고 진행하시기 바랍니다.

https://www.youtube.com/watch?v=s_o8dwzRlu4&ab_channel=TechWorldwithNana

쿠버네티스 컨테이너에 외부에서 접속하는 과정은 다음과 같습니다.

외부 → nginx → minikube → ingress controller → service → pod

각각이 잘 동작하는지 확인하려면 거꾸로 구현해야 합니다.

여기서는 서비스와 파드를 구현해 볼 것입니다.

쿠버네티스에 스프링 프로젝트 배포하기

1. EC2에 Minicube 설치하기

1.1. EC2 생성하기

  • ARM 버전으로 진행하면 이슈가 많아서 x86으로 진행하는 것을 추천합니다.
  • 인스턴스는 미니큐브 조건 때문에 t2 미디엄을 사용합니다.
    • 프리티어 사용하면 미니큐브 설치 에러가 발생합니다.
  • SSH로 접속합니다.
  • 프리티어가 아니니까 주의하시기 바랍니다.
    • 제가 회사 계정이라서 괜찮았습니다.

1.2. 도커 설치하기

  • 두 단계로 나누어서 설치할 것입니다.
  • docker info를 입력해서 잘 나오면 설치가 잘 된 것입니다.

1.3. 미니큐브 설치하기

  • 공식 홈페이지에서 환경에 맞춰서 설치할 것입니다.
  • minikube version을 입력해서 잘 나오면 설치가 잘 된 것입니다.

1.4. kubectl 설치하기

  • 환경에 맞춰서 설치할 것입니다.
  • kubectl version --short를 입력해서 잘 나오면 설치가 잘 된 것입니다.

2. 샘플 프로젝트 만들기

  • MySQL과 연결되는 샘플 프로젝트를 만들 것입니다.
  • 이 부분을 뛰어넘고 싶은 분은 그냥 3번부터 시작하시면 됩니다. 제가 미리 만들어둔 프로젝트를 사용할 것입니다.
  • 참고로 제 프로젝트는 /으로 GET 요청을 받으면 HelloEntity라는 인스턴스를 만들어서 DB에 저장하고 결과를 반환해주는 단순한 것입니다.
  • 코드 블라블라

3. 샘플 프로젝트 배포하기

3.1. ConfigMap 작성

https://kubernetes.io/docs/concepts/configuration/configmap/

vim config.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql-config
data:
  # property-like keys; each key maps to a simple value
  mysql-url : mysql-service

  • 환경 변수에 세팅해줄 URL 주소를 입력합니다.
  • 아래 커맨드를 입력하고 잘 되면 된 것입니다.
kubectl apply -f config.yaml

  • kubectl describe configMap mysql-config을 입력하면 들어가 있는 값이 나옵니다.
[ec2-user@ip-172-31-42-29 spring-test]$ kubectl describe configMap mysql-config
Name:         mysql-config
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
mysql-url:
----
mysql-service

BinaryData
====

Events:  <none>

3.2. Secret 작성

  • 아이디와 비밀번호 등 민감한 정보는 config에 그대로 노출되면 안 됩니다.
    • 파일에 그대로 저장하면 위험합니다.
  • Secret은 암호화된 상태로 저장하기 때문에 털려도 그나마 괜찮습니다.
  • 그러나 설정하지 않으면 암호화 상태로 저장되지 않습니다.
  • 그래서 쿠버네티스에 배포할 수 있는 권한을 가진 사람이 간접적으로 접근 가능합니다.
  • 암호화하려면 따로 설정이 필요합니다.
  • 하지만 우리는 테스트니까 하지 않을 것입니다.

https://kubernetes.io/docs/concepts/configuration/secret/#basic-authentication-secret

  • 아래 커맨드를 사용하시기 바랍니다.
echo -n mysql-user | base64 # bXlzcWwtdXNlcg==
  • stringData 옵션을 사용하면 data 옵션을 대체할 수 있습니다. 하지만 특수문자를 포함한 문자열은 사용할 수 없습니다.
  • 저희는 stringData 옵션을 사용할 예정입니다.
  • 아래 커맨드를 입력하여 mysql-secret.yaml 파일을 적용합니다.
apiVersion: v1
kind: Secret
metadata:
  name: secret-basic-auth
type: kubernetes.io/basic-auth
stringData:
  password: mysql-password

  • kubectl describe secret secret-basic-auth 를 입력하면 아래와 같은 결과가 나타납니다.
Name:         secret-basic-auth
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  kubernetes.io/basic-auth

Data
====
password:  14 bytes

3.3 MySQL Deployment & Service 작성하기

https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ 에서 예시를 확인할 수 있습니다.

apiVersion: apps/v1

kind: Deployment

metadata:

  name: nginx-deployment

  labels:

    app: nginx

spec:

  replicas: 3

  selector:

    matchLabels:

      app: nginx

  template:

    metadata:

      labels:

        app: nginx

    spec:

      containers:

      - name: nginx

        image: nginx:1.14.2

        ports:

        - containerPort: 80

아래와 같이 수정해봅시다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql-deploy
  labels:
    app: mysql
    tier: database
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
        - name: mysql
          image: 'mysql:8.0.26'
          ports:
            - containerPort: 3306
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
---
apiVersion: v1
kind: Service
metadata:
  name: mysql-service
spec:
  selector:
    app: mysql
  ports:
    - protocol: TCP
      port: 3306
      targetPort: 3306

이후 kubectl apply -f mysql-deployment.yaml 를 입력합니다.

kubectl get all 로 파드, 서비스, 배포 그리고 레플리카셋을 확인할 수 있습니다.

아래와 같이 파드가 잘 동작하면 성공입니다.

NAME                                READY   STATUS    RESTARTS   AGE
pod/mysql-deploy-847cd594ff-8hkkc   1/1     Running   0          5s

로그를 자세히 확인하려면 아래의 커맨드를 입력합니다.

kubectl logs -f pod/mysql-deploy-847cd594ff-8hkkc

아래와 같은 로그가 나타나면 준비가 완료된 상태입니다.

2023-02-22T04:59:55.553138Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.26'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

배시 쉘에서 직접 접속하려면 아래의 커맨드를 입력합니다.

kubectl exec -it pod/mysql-deploy-847cd594ff-8hkkc bash

패스워드는 아까 시크릿에서 명시한 mysql-password 입니다. exit 두 번 입력하여 콘솔로 나옵니다.

3.4 Spring 배포와 Service 작성하기

MySQL 배포와 형식이 비슷하기 때문에, 일단 아래와 같이 복사합니다.

cp mysql-deployment.yaml spring-deployment.yaml

그리고 수정을 좀 해줄 겁니다. 저희가 만든 테스트 프로젝트는 환경 변수가 3개이고, 사용자는 그냥 root를 넣고, 비밀번호는 시크릿, URL은 config를 참조할 겁니다.

  • MYSQL_USER
  • MYSQL_PWD
  • MYSQL_URL
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy # 이름을 바꿔줍니다.
  labels:
    app: spring-demo # 레이블을 바꿔줍니다.
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo # 당연히 바꿔줍니다.
  template:
    metadata:
      labels:
        app: spring-demo # 당연히 바꿔줍니다.
    spec:
      containers:
        - name: spring-demo # 당연히 바꿔줍니다.
          image: 'lee01042000/k8s-auto-deployment-test:main-2023-02-21T11-36-33'
          ports:
            - containerPort: 8080 # Spring은 8080 포트를 사용하므로 바꿔줍니다.
          env:
            - name: MYSQL_USER
              value: root
            - name: MYSQL_PWD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
            - name: MYSQL_URL
              valueFrom:
                configMapKeyRef:
                  name: mysql-config
                  key: mysql-url

서비스도 추가해 줄 겁니다. 나중에 외부에서 이 파드에 접속하려면 서비스가 있어야 합니다.

apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

정갈한 버전은 아래와 같습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy
  labels:
    app: spring-demo
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo
  template:
    metadata:
      labels:
        app: spring-demo
    spec:
      containers:
        - name: spring-demo
          image: 'lee01042000/k8s-auto-deployment-test:main-2023-02-21T11-36-33'
          ports:
            - containerPort: 8080
          env:
            - name: MYSQL_USER
              value: root
            - name: MYSQL_PWD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
            - name: MYSQL_URL
              valueFrom:
                configMapKeyRef:
                  name: mysql-config
                  key: mysql-url
---
apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

kubectl get all을 실행해 보세요. 아래와 같이 잘 나오면 성공입니다.

NAME                                     READY   STATUS    RESTARTS   AGE
pod/mysql-deploy-847cd594ff-8hkkc        1/1     Running   0          52m
pod/spring-demo-deploy-c9798b78d-bs5c7   1/1     Running   0          4s

로그를 찍어 보세요.

kubectl logs -f pod/spring-demo-deploy-c9798b78d-bs5c7

로그가 잘 찍히면 실행된 것입니다. 굿굿!

3. 요청해보기

모든 파드와 서비스는 미니큐브 안에 있으므로, 현재는 ec2 로컬에서 요청할 수 없습니다. 스프링 서비스를 미니큐브 바깥으로 연결해야 합니다. 이를 위해서는 로드밸런서나 노드포트를 이용해서 구현할 수 있습니다. 이전에 작성한 서비스를 수정해 봅시다.

apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  type: NodePort #스펙에 노드포트 추가
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30007 # 미니큐브 바깥으로 연결할 포트번호 미니큐프ip:30007로 서비스에 연결가능

기존 파일을 수정한 코드는 아래와 같습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-demo-deploy
  labels:
    app: spring-demo
    tier: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-demo
  template:
    metadata:
      labels:
        app: spring-demo
    spec:
      containers:
        - name: spring-demo
          image: 'lee01042000/k8s-auto-deployment-test:main-2023-02-21T11-36-33'
          ports:
            - containerPort: 8080
          env:
            - name: MYSQL_USER
              value: root
            - name: MYSQL_PWD
              valueFrom:
                secretKeyRef:
                  name: secret-basic-auth
                  key: password
            - name: MYSQL_URL
              valueFrom:
                configMapKeyRef:
                  name: mysql-config
                  key: mysql-url
---
apiVersion: v1
kind: Service
metadata:
  name: spring-service
spec:
  type: NodePort
  selector:
    app: spring-demo
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30007

명령어를 실행한 결과를 확인해 봅시다.

kubectl get svc

위 명령어를 실행한 결과가 아래와 같으면 성공입니다.

NAME             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes       ClusterIP   10.96.0.1       <none>        443/TCP          103m
mysql-service    ClusterIP   10.110.69.196   <none>        3306/TCP         7m19s
spring-service   NodePort    10.104.52.108   <none>        8080:30007/TCP   5m37s

이제 콘솔에서 minikube ip를 입력하면, 미니큐브에 할당된 로컬 아이피가 나옵니다. curl 192.168.49.2:30007을 입력하면, 아래와 같이 응답을 받을 수 있습니다.

HelloEntity(id=0, name=Hello)

데이터베이스에 데이터가 잘 저장되어 있는지 확인해 봅시다. MySQL에 접속하고 데이터베이스들을 확인합니다.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| k8s_test           |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

데이터베이스가 생성되어 있습니다. use k8s_test;로 데이터베이스를 선택하고, show tables;로 테이블을 확인합니다.

mysql> show tables;
+--------------------+
| Tables_in_k8s_test |
+--------------------+
| hello_entity       |
+--------------------+
1 row in set (0.00 sec)

테이블도 잘 생성되어 있습니다. select * from hello_entity;를 실행하면, 데이터가 잘 저장되어 있는 것을 확인할 수 있습니다.

mysql> select * from hello_entity;
+----+-------+
| id | name  |
+----+-------+
|  0 | Hello |
+----+-------+
1 row in set (0.00 sec)

4. 외부와 연결하기

이제 minikube ip 를 외부에서 들어오는 요청과 매핑해야 합니다.

포트포워딩 방법은 여러 가지가 있습니다. kubectl에서는 다음과 같은 명령어를 사용하여 포트포워딩이 가능합니다.

kubectl port-forward --address 0.0.0.0 service/spring-service 8080:8080

위와 같이 포워딩이 수행되면 다음과 같은 메시지가 출력됩니다.

Forwarding from 0.0.0.0:8080 -> 8080
Handling connection for 8080
Handling connection for 8080

이제 웹 페이지에서 EC2 인스턴스로 요청하면 결과를 잘 받을 수 있습니다.

들어가며

최근 자사 웹 플랫폼을 한창 만들고 있습니다.. 새로운 요구사항이 들어왔는데, 바로 음성인식으로 텍스트 데이터를 입력할 수 있으면 좋겠다는 내용이었습니다. 자사 플랫폼 중에서 전문가의 리뷰를 받는 부분이 있는데, 이 때 전문가 분들이 타이핑을 하기 어려워하는 분들이 많다는 이유였습니다. 한 번도 해보진 않았지만, 일단 해보겠다고 하고 난 뒤 구현에 성공했습니다. 여기서는 그 과정에서 대해서 말해보려고 합니다.

디자인

처음 생각한 디자인은 Mac 의 받아쓰기 기능과 같습니다. 이 기능을 그대로 활용하면 좋겠지만, 아쉽게도 전문가 분들이 대부분 윈도우를 사용하시고 윈도우 11 부터 한국어를 지원하기 때문에 사용하기가 어렵습니다. 어쨋든 기능 디자인은 아래처럼 간단합니다.

  • 마우스로 데이터를 입력할 부분을 클릭 해 커서를 활성화시킨다.
  • 다음으로 받아쓰기 기능을 키고 말을 한다.
  • 원할 때 받아쓰기 기능을 끈다.

구현

1. 음성 인식 기능

Web API에는 편리하게도 SpeechRecognition 기능이 존재합니다. 바로 음성 입력을 번역해주는 기능이죠. 다행히 한국어도 지원하기에 아래와 같이 해석된 문장을 입력받아 콜백을 적용할 수 있습니다. 브라우저 중에서는 이 기능을 지원하지 않는 경우도 있기에 아래와 같이 조건문을 설정했습니다. onresult 가 바로 콜백을 적용하는 문장입니다. 여기서 텍스트 영역이 선택된 경우 음성 인식 문장을 추가해주는 기능을 구현했습니다.

export const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)

if(recognition){
    recognition.continuous = true;
    recognition.interimResults = false;
    recognition.lang = 'ko-KR'
    recognition.onresult = (event) => {
        const focusedComponent = getFocusedComponent()
        const isTextAreaFocused = isTextArea(focusedComponent)
        let interimTranscript = "";

        for (let i = event.resultIndex; i < event.results.length; i++) {
            const transcript = event.results[i][0].transcript;
            console.log(transcript)
            if (event.results[i].isFinal) {
                interimTranscript=transcript
            } else {
                interimTranscript += transcript;
            }
        }
        if(isTextAreaFocused){
            focusedComponent.innerHTML += interimTranscript
        }

    }
}
else{
    console.log("SpeechRecognition is not supported")
}

const getFocusedComponent = ():HTMLElement => {
    return document.activeElement as HTMLElement
}

const isTextArea = (element:HTMLElement):boolean => {
    return element.tagName === 'TEXTAREA'
}

2. 음성인식을 ON/OFF를 설정할 수 있는 버튼

음성 인식 지원 유무를 다시 검사하고 만약 지원하지 않다면 비어있는 컴포넌트를 반환하도록 합니다. recognition.start()recognition.stop() 을 이용해 음성인식 유무를 활성화 할 수 있습니다.

import React from 'react'
import {Button} from "@mui/material";
import MicIcon from '@mui/icons-material/Mic';
import MicOffIcon from '@mui/icons-material/MicOff';
import {recognition} from "./SpeechRecognition";

const isSpeechRecognitionSupported = ():boolean => {
    return 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window;
}

const setSpeechRecognition= (isActivate : boolean) => {
    if(!recognition){
        throw new Error("SpeechRecognition is not supported")
    }
    if(isActivate){
        recognition.start()
    } else{
        recognition.stop()
    }

}
/**
 * 받아쓰기 기능을 활성화, 비활성화 할 수 있는 버튼입니다.
 * 브라우저가 음성인식을 사용하지 않는 경우 사용할 수 없습니다.
 * autoVisible 속성을 true로 설정하면 음성인식이 지원되지 않는 경우 버튼이 보이지 않습니다.
 * @param props
 * @constructor
 */
const DictationActivateButton=({autoVisible  = true})=>{
    const [isActivated, setIsActivated] = React.useState(false);
    const isSupported=isSpeechRecognitionSupported()

    if(!isSupported && autoVisible){
        return <></>
    }

    const onClick=()=> {
        if(isSupported){
            setSpeechRecognition(!isActivated)
            setIsActivated(!isActivated)
        }
        else{
            alert("음성인식을 지원하지 않는 브라우저입니다.")
        }
    }

    return (
        <Button onClick={onClick}>
            {isActivated ? <MicIcon/> : <MicOffIcon/>}
        </Button>
    )
}
export default DictationActivateButton

결과

텍스트 영역을 클릭하고 말을 하면 잘 인식하는 모습을 볼 수 있습니다!

들어가며

최근 Backen-GPT와 관련된 글(https://beoks.tistory.com/91)을 올렸습니다. 동시에 GPT는 현재 Backend에 적용하기에는 다양한 한계가 존재한다고 평가했는데, 그래도 응용할 수 있는 방법이 없을까 고민하다 MockAPI에는 적용할만하다고 생각해 만들어보게 되었습니다.

디자인

기존 MockAPI는 주로 모바일이나 프론트엔드 개발에서 아직 벡엔드가 구현되지 않았을 때 API와 관련된 프론트 기능을 구현할 때 사용되었습니다. 하지만 스캐폴딩 코드에 해당하기에 MockAPI를 구현하는데 시간을 많이 쓰는 건 올바르지 않습니다. 따라서 목적과 초기 데이터베이스 상태를 설정하면 바로 원하는 API를 구현할 수 있도록 최대한 간단한 디자인을 목표로 삼아 아래와 같은 인터페이스를 생각했습니다.

//대학교 학생 관리라는 목적과 초기 학생 데이터베이스를 정의한다.
const API_DESCRIPTION = "University Studenet Management"
const INIT_DB={students : [{name : "foo",email:"email@email.com",grade:4}]}
const studentMockAPI = createMockAPI(API_DESCRIPTION,INIT_DB);
// 바로 원하는 API를 호출한다.
console.log(await studentMockAPI.get("/student"));
// {message: "Successfully retrieved data",State:"success",data:{students:[{"name":"foo","email":"email@email.com","grade":4}]}}
console.log(await studentMockAPI.post("/student",{name:"bar",email:"bar@bar.com",grade:3}))
// {message:"Student added successfully.",State:"Success",data:{name:"bar",email:"bar@bar.com",grade:3}}

구현

GPT 요청 구현

GPT API에 요청하는 기능은 간단하고 공식문서에도 잘 설명되어 있어서 어렵지 않게 구현했습니다.

  • OpenAI API 키를 발급한다.
  • 환경변수에 키를 등록한다.
  • 아래와 같이 프롬프트(질문)을 요청으로 보내고 응답으로 받는 코드를 작성한다.
import { Configuration,OpenAIApi } from "openai";

if(!process.env.OPENAI_API_KEY){
    throw new Error("OPENAI_API_KEY is not set");
}

const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
  });

const openai = new OpenAIApi(configuration);

const requestOpenAI=(prompt : string): Promise<string| undefined>=>
    openai.createCompletion({
        model: "text-davinci-003",
        prompt: prompt,
      }).then((response) => {
        return response.data.choices[0].text;
      });

GPT 응답 테스트

효과적인 mockAPI를 위해선 GPT가 어떤 형식으로 대답하는지 분석해야합니다.

처음 작성한 프롬프트 형식은 다음과 같습니다.

const createRequestText=(api : string, data : object,database : object,apiDescription:string): string=>{
    return `
    ${apiDescription} API Call (indexes are zero-indexed) : ${api}\\n\\n
    RequestBody : ${JSON.stringify(data)}\\n\\n
    Database State : ${JSON.stringify(database)}\\n\\n
    Output the API response prefixed with 'Response:' and as json.
    Then output the new database state as json, prefixed with 'State:'.
    If the API call is only requesting data, then don't change the database state, 
    but base your 'API Response' off what's in the database.
    `
}

테스트를 위해서 아래와 같은 학생 관리 API 예제를 만들었습니다.

async function test(){
    const studentMockAPI = createMockAPI("University Studenet Management",{students : [{name : "foo",email:"email@email.com",grade:4}]});

    console.log(await studentMockAPI.get("/get_all_student"));
    console.log(await studentMockAPI.post("/add_student",{name:"bar",email:"bar@bar.com",grade:3}))
    console.log(await studentMockAPI.get("/get_all_student"));
    console.log(await studentMockAPI.delete("/delete_student(grade=4)"))
    console.log(await studentMockAPI.get("/get_all_student"));
    console.log(await studentMockAPI.put("/revise_student(grade=3)",{name : "barbar",email : "barbar@bar.com",grade:5}))
    console.log(await studentMockAPI.get("/get_all_student"));
}

위 테스트한 결과, GPT의 응답이 비일관적인 것을 확인했습니다. 따라서 응답에서 API 응답과 갱신된 데이터베이스를 파싱하는 과정에서 에러가 발생했습니다. 이를 해결하기 위해선 정확한 응답 형식을 규정해야했습니다.

Regex 조건 추가

이번엔 “Total output must look like this:'Response: (.)\nState:(.)' with compact json.” 라는 문장을 프롬프트에 추가했습니다. 그 결과, 응답을 API 응답과 데이터베이스로 파싱하는 코드는 잘 동작했습니다.

const createRequestText=(api : string, data : object,database : object,apiDescription:string): string=>{
    return `
    ${apiDescription} API Call (indexes are zero-indexed) : ${api}\\n\\n
    RequestBody : ${JSON.stringify(data)}\\n\\n
    Database State : ${JSON.stringify(database)}\\n\\n
    Output the API response prefixed with 'Response:' and as json.
    Then output the new database state as json, prefixed with 'State:'.
    Total output must look like this:'Response: (.*)\\nState:(.*)' with compact json.
    If the API call is only requesting data, then don't change the database state, 
    but base your 'API Response' off what's in the database.
    `
}

그러나 이번엔 응답이 비일관적이라는 문제가 생겼습니다. 어떤 응답은 메시지만, 다른 건 상태코드만 등 제각각인 형태는 MockAPI를 사용하는데 문제가 될 수 있습니다.

API 응답 일관화

이번엔 “The API response must look like this:'{message: "(.)",status:"{status code}",data:{(.)}}' with compact json. with compact json.” 라는 문장을 추가해 API 응답 형식이 일관되도록 해보았습니다.

그 결과, 아래와 같이 비교적 일관된 Json 결과들을 받을 수 있었습니다.

{"message":"Students retrieved successfully.","status":200,"data":[{"name":"foo","email":"email@email.com","grade":4}]}
{"message":"Student added","status":200,"data":{"name":"bar","email":"bar@bar.com","grade":3}}
{message: "All student retrieved successfully", status:"200", data: [{"name":"foo","email":"email@email.com","grade":4},{"name":"bar","email":"bar@bar.com","grade":3}]}
{message: "Student deleted successfully.",status: "200",data:{}}
{"message":"Fetched all student data successfully.","status":200,"data":[{"name":"bar","email":"bar@bar.com","grade":3}]}
{"message":"Student updated","status":200,"data":{"name":"barbar","email":"barbar@bar.com","grade":5}}
{"message": "Successfully retrieved all student records", "status": 200, "data": {"students": [{"name": "barbar", "email": "barbar@bar.com", "grade": 5}]}}

그러나 아직 부족합니다. 같은 get_all_student API에 대해서 서로 다른 메시지를 반환하고 있습니다. 좀 더 일관화하기 위한 방법이 필요합니다.

GPT 요청 파라미터 튜닝

GPT Completion 요청 시에는 프롬프트 뿐만 아니라 여러 설정값을 튜닝할 수 있습니다. 여기서 창의적인 답변 정도를 뜻하는 temperature 파라미터의 값을 0으로 설정하고 추가적으로 다른 파라미터도 설정후 요청을 해보았습니다.

const requestOpenAI=(prompt : string): Promise<string| undefined>=>
    openai.createCompletion({
        model: "text-davinci-003",
        temperature:0,
        max_tokens: 100,
        top_p:1,
        frequency_penalty:0,
        best_of:1,
        prompt: prompt,
      }).then((response) => {
        return response.data.choices[0].text;
      });

그 결과 더욱 일관적인 API 응답을 얻을 수 있었습니다. 이 정도면 MockAPI로 사용할 수 있을 것 같습니다.

{"message":"Successfully retrieved all students","status":200,"data":[{"name":"foo","email":"email@email.com","grade":4}]}
{"message":"Student added successfully","status":200,"data":{"name":"bar","email":"bar@bar.com","grade":3}}
{"message":"Successfully retrieved all students","status":200,"data":[{"name":"foo","email":"email@email.com","grade":4},{"name":"bar","email":"bar@bar.com","grade":3}]}
{"message":"Student deleted successfully","status":200,"data":{}}
{"message":"Successfully retrieved all students","status":200,"data":[{"name":"bar","email":"bar@bar.com","grade":3}]}
{"message":"Student successfully updated","status":200,"data":{"name":"barbar","email":"barbar@bar.com","grade":5}}
{"message":"Successfully retrieved all students","status":200,"data":[{"name":"barbar","email":"barbar@bar.com","grade":5}]}

응답 형식 설정

하지만 사용자에 따라서 다른 종류의 API 응답형식이 필요할 수 있습니다. 이를 위해 응답 형식을 설정할 수 있는

디자인

그래서 이번엔 새로운 조건을 설정할 수 있는 기능 디자인을 구상했습니다.

//대학교 학생 관리라는 목적과 초기 학생 데이터베이스를 정의한다.
const API_DESCRIPTION = "University Studenet Management"
const INIT_DB={students : [{name : "foo",email:"email@email.com",grade:4}]}
const RESPONSE_FORMAT = '{*status:"{status code}",data:{(.*)}}'
const ERROR_RESPONSE_FORMAT='{message : {(.)},status:"{stattus code}"}'
const studentMockAPI = createMockAPI(API_DESCRIPTION,INIT_DB)
	.setSuccessResponseFormat(CONDITION)
	.setFailureResponseFormat(ERROR_RESPONSE_FORMAT)

그 결과 아래와 같이 API 수행 성공과 실패에 따라서 다른 응답 형식을 설정할 수 있었습니다.

console.log(await studentMockAPI.delete("/student(grade=5)"));
//{"status":"404","message":"No student found with grade 5"}
console.log(await studentMockAPI.get("/students"));
//{"data":[{"name":"foo","email":"email@email.com","grade":4}], "status":200}

결론

  • GPT를 이용해서 생각보다 괜찮은 MockAPI를 만들 수 있다.
  • 그러나 GPT가 항상 일정한 응답을 하지 않으므로 에러가 가끔 발생할 수 있으므로 이 경우 직접 mock API를 만들어야 한다.
  • 일반적인 mock API 보다 속도가 느리다. 따라서, 규모가 큰 테스트 사용에는 적합하지 않으며 일부 유닛테스트 사용하는 것이 적합하다.
  • 저장할 수 있는 데이터 크기에 한계가 존재한다. backend-GPT 에서는 1KB 정도가 한계라고 한다.

Npm

https://www.npmjs.com/package/@beoks/gpt-mock-api

들어가며

최근 GeeksNews에서 GPT를 이용한 벡엔드 서버에 관한 기사를 봤습니다(https://news.hada.io/topic?id=8341). 벡엔드 개발자로서는 굉장히 흥미로운 주제이기 때문에 어떻게 구현되어있는지 알아보았습니다. 이 포스트에서는 그 결과와 한계에 대해서 설명해보고자 합니다.

Backend-GPT란?

기존 벡엔드 서버는 코드를 이용해서 구현했습니다. Backend-GPT는 코드를 이용한 구현은 버그, 리뷰 필요 그리고 우리가 원하는 비즈니스 로직이 아닌 우리가 말하는 것만 수행한다고 비판하는 것에서 시작합니다. 비즈니스 로직을 처리하기 위해선 인간의 지능이 적절한 형태라고 말합니다. 이 문제를 해결하기 위해 LLM(Large-Language Model, ex.GPT) 을 벡엔드 서버로 처리자로 구현한 것이 Backend-GPT입니다.

Backend-GPT의 구현

코드를 분석한 결과 구현은 매우 간단합니다. 초기 데이터베이스 상태를 표현하는 JSON과 전달된 API를 GPT 모델에 전달하고, 계산된 API 결과와 데이터베이스 결과를 받아옵니다. 그리고 다시 API가 요청이 오면 갱신된 데이터베이스 결과와 API를 다시 전달합니다. 코드를 자세히 알아보면 다음과 같습니다.(2023.1.30 기준, 로그와 같은 불필요한 코드는 임의로 삭제했습니다.)

import json
from flask import Flask
import ray
ray.init()
from flask_cors import CORS
import requests

@ray.remote
def gpt3(input):
    response = requests.post(
    "<https://dashboard.scale.com/spellbook/api/app/kw1n3er6>",
    json={
            "input": input
        },
    headers={"Authorization":"Basic cld6n7eoo0065sr1acbwczykv"}
    )
    return response.text

def dict_to_json(d):
    return d.__dict__

app = Flask(__name__)
CORS(app)
db = json.load(open('db.json','r'))

@app.route('//') --------------- 1.
def api(app_name, api_call):
    db = json.load(open('db.json','r'))--------------- 2.
    gpt3_input = f"""{db[app_name]["prompt"]}
API Call (indexes are zero-indexed):
{api_call}
Database State:
{db[app_name]["state"]}
Output the API response prefixed with 'API response:'. Then output the new database state as json, prefixed with 'New Database State:'. If the API call is only requesting data, then don't change the database state, but base your 'API Response' off what's in the database.
""" --------------- 3.
    completion = ray.get(gpt3.remote(gpt3_input)) --------------- 4.
    completion = json.loads(completion)["text"]

    future1 = gpt3.remote(f"{completion}\\n\\nAPI Response as valid json (as above, ignoring new database state): ")
    future2 = gpt3.remote(f"{completion}\\n\\nThe value of 'New Database State' above (as json):")
    response = json.loads(ray.get(future1).strip())["text"].strip()
    new_state = json.loads(json.loads(ray.get(future2).strip())["text"].strip())
    db[app_name]["state"] = new_state
--------------- 5.
    json.dump(db, open('db.json', 'w'), indent=4, default=dict_to_json) --------------- 6.
    return response --------------- 7.

if __name__ == "__main__":
    app.run()
  1. Flask로 HTTP API 인터페이스 구현
  2. API 요청이 오면 현재 데이터베이스 상태를 로드
  3. API와 데이터베이스 상태를 바탕으로 GPT에 질문할 문장을 생성
  4. GPT에 질문 전송
  5. 데이터 수신 및 파싱
  6. 데이터베이스 상태 업데이트
  7. 응답 전달

한계

위 코드를 보면 알겠지만, 데이터를 전달할 때마다 데이터베이스 상태를 전달합니다. GPT가 수신할 수 있는 문장 길이는 제한되어 있기 때문에, 레포지토리에는 1KB 내외의 데이터만 가능하다고 명시되어 있습니다. 여기서 제가 추가적으로 생각하는 한계를 포함하면 다음과 같습니다.

  • 매우 제한적인 데이터베이스 용량 ~ 1KB
  • GPT API 사용으로 인한 매우 제한적인 트래픽
  • 파일시스템 기반의 동시성 처리 문제
  • 매번 데이터베이스 전체 상태 전달과 GPT 계산으로 인한 낮은 속도
  • 높은 추상화로 인한 최적화 설정 한계
  • 동일한 결과 미보장(GPT가 지속적으로 학습을 할 경우)

응용

위 한계로 인해서 응용할 수 있는 조건은 다음과 같이 매우 제한적입니다.

  1. 1KB 이내의 데이터 용량만 다루는 경우
  2. 사용자가 매우 적은 경우(정확한 수치는 계산 필요)
  3. 동시성 처리가 필요없거나 동시 사용자가 한 명으로 보장된 경우
  4. 빠른 속도가 필요하지 않은 경우
  5. 결과의 동일성이 반드시 보장되지 않아도 되는 경우

평가

위 응용 조건으로 인해서 현재 Backend-GPT 를 현업에 도입하는 것은 매우 어려울 것으로 보입니다. 그러나 별도의 코드 작성없이 서버 기능을 구현한다는 점에서 높은 효과성을 가지고 있습니다. 개발과정에서 위 응용조건에 부합하는 경우는 MVP 개발이나 Mock API 정도가 있을 것 같습니다. 

Reference

들어가며

지난글 (https://beoks.tistory.com/86)에선 API를 이용해서 AWS Lex와 대화할 수 있는 기능을 구현해보았습니다. 이번에는 발화 의도(Intent)를 업로드할 수 있는 API를 개발해보려고 합니다. 지난 글과 상당히 유사한 부분이 많기 때문에 이전 글을 많이 인용할 예정입니다.

1. 역할 생성

AWS Lex API 를 관리하기 위한 역할과 정책을 정의합니다. 여러 서비스를 이용하는 경우 각 서비스 마다 목적에 따라서 권한을 나누어 관리하는 것이 좋습니다. 권한을 정의할 정책과 정책을 적용한 역할을 만들어봅시다.

  1. IAM Console(https://console.aws.amazon.com/iam/) 에 루트 사용자로 로그인합니다.
  2. 엑세스 관리 > 역할 부분에서 역할 만들기를 선택합니다.

  1. 엔티티 선택에서 Lambda를 선택하고 다음을 클릭합니다.

  1. 권한 추가 단계는 생략합니다.
  2. 역할 생성이후 별도의 인라인 권한을 명시할 것이기 때문입니다.
  3. 역할 이름을 명시한 후 역할 생성 버튼을 클릭합니다.
  4. IAM 콘솔 > 엑세스 관리 > 역할에서 생성했던 역할 이름을 검색합니다.
  5. 권한 추가 > 정책 연결을 클릭합니다. (인라인으로 별도의 정책을 선언해 연결 할 수도 있습니다.)

여기에서 필요한 권한을 정책으로 등록할 수 있습니다. 지금은 편의를 위해 이미 존재하는 AmazonLexFullAccess 권한을 추가하겠습니다. 권한 정책 검색에서 해당 권한을 추가하고 체크 후 정책연결을 클릭해주세요.
 

2. 람다 함수 생성

AWS Lex 와 통신하기 위한 람다 함수를 생성합시다. 여기에서 이전에 정의한 역할을 사용합니다.

  1. AWS Lambda Console에 접속해서 함수 생성을 클릭합니다.
  2. 함수 이름 등 기본정보를 입력합니다. 여기서는 NodeJS를 사용해보겠습니다.
  3. 기본 실행 역할 변경에서 이전에 생성했던 역할을 선택합니다.

3. 람다 함수 코드 업로드

이제 Lex를 다루기 위한 람다 함수 코드를 작성해보겠습니다. Lex V2 Models SDK (https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-lex-models-v2/index.html) 에서 Lex V2 를 관리하기 위한 API 를 확인할 수 있습니다. 

1. npm init을 통해 새로운 NodeJS 프로젝트를 생성합니다.

2. npm install @aws-sdk/client-lex-models-v2 를 입력해 필요한 패키지를 설치합니다.

3. 이 패키지를 이용해 질문을하면 간단하게 초기 응답을 실행하는 의도를 생성하는 코드를 index.js에 작성합니다. 이 때, 의도의 이름(intentName)은 영어 또는 숫자를 사용해야만합니다. 저는 별도의 아이디를 생성하고 이를 질문과 응답과 함께 API에 전달한다고 가정하고 아래와 같이 코드를 작성했습니다. ( API 요청 형식이 궁금하다면 아래 5.테스트를 참고해주세요)

const { LexModelsV2Client, CreateIntentCommand } = require("@aws-sdk/client-lex-models-v2");

exports.handler = async (event, context, callback) => {
	try {
		const client = new LexModelsV2Client({ region: 'us-east-1' });
		const qnAList=event
		let response
		for(let i=0;i<qnAList.length;i++){
			const question=qnAList[i].question
			const answer=qnAList[i].answer
			const dataId=(qnAList[i].id).toString()
			const initialResponseSetting ={
				initialResponse:{
					messageGroups:[
						{
							message:{
								plainTextMessage: {
									value:answer
								}
							}
						}
					]
				}
			}
			const input = {
				'botId':process.env.BOT_NAME,
				'botVersion': process.env.BOT_VERSION,
				'intentName': dataId,
				'sampleUtterances':[{
					utterance : question
				}],
				'initialResponseSetting':initialResponseSetting,
				'localeId' : "ko_KR"
			};
			const command = new CreateIntentCommand(input);
			response = await client.send(command);
		}
		callback(null,response.messages)
	} catch(e) {
		console.log(e);
		callback(e);
	}
};

4. 코드를 작성했다면, 람다에 업로드하기 위해 압축을 진행합니다.

zip -r project.zip . #For Mac user

4. 람다 함수 환경 변수 설정

코드를 보면 일부 파라미터가 process.env 로 정의된 것을 알 수 있습니다. 이는 환경변수를 참조한다는 뜻입니다. 따라서 이에 맞는 환경변수를 입력해야 합니다.

  1. 선택한 람다 함수에서 → 구성 → 환경변수에 진입합니다.

  1. BOT_NAME 은 연결하려는 봇의 아이디를 의미합니다. Lex Console 에서 봇을 선택하면 봇 ID를 확인할 수 있습니다.

3. BOT_VERSION은 봇의 특정 버전을 의미합니다. 의도를 업로드하고자 하는 봇의 버전을 선택합니다. 초안 버전의 경우 DRAFT를 입력하면 됩니다.

5. 테스트

이제 전체적인 람다 함수에서 테스트를 해볼 수 있습니다.

  1. 람다 함수 테스트 → 테스트 이벤트에서 새로운 이벤트를 생성합니다.실행 결과를 확인해 테스트가 잘 수행되는것을 확인할 수 있습니다.

6. API Gateway 연결

이제 람다 함수를 API Gateway 와 연결해 HTTPS 로 접근할 수 있도록 합시다.

  1. API Gateway 콘솔에서 REST API 생성을 클릭합니다.
  2. 리소스 → 작업 → 메서드 생성 → POST Method 를 생성합니다.
  3. 이전에 생성한 람다 함수와 연결합니다.
  4. 설정이 완료되면 메서드를 테스트 할 수 있습니다.

7. API 배포

API를 등록하고 테스트를 완료했다면 외부에서 접속하는 것을 허락하기 위해 배포를 진행할 수 있습니다.

  1. 리소스 → 작업 → 배포를 선택합니다.

  1. 적절한 스테이지를 설정합니다. 스테이지는 dev, test, QA, prod 등의 배포 단계를 의미합니다.)

  1. 스테이지 편집기에서 외부에서 API를 호출할 수 있는 URL을 흭득할 수 있습니다.

스테이지에 표시된 URL을 통해 요청을 수행하면 발화 의도를 업데이트하는 API 완성입니다!

기억하기

개발자로서 일을 하다 보면 여러 회의를 거치면서 제목과 같은 단어들이 오가는 것을 알 수 있다. 

 

하지만 같은 의미인데도 다른 뜻과 의도로 말하는 경우가 많다.

 

기획자 - (간략) 저희가 데이터를 수집하고 검증화를 하려고 하는데 일일이 하기에는 시간이 오래 걸릴 것 같아요, 그래서 자동화를 하는 시스템을 만들었으면 좋겠어요.

 

나 - (자동화..? 크롤링 같은 걸 말하는 건가? 일단 데이터 수집 방식에 대해서 여쭤봐야겠다.) 아 그럼 데이터는 어떻게 수집하시나요?

 

기획자 - 그것도 있고 저희가 책에 있는 내용을 수기로 입력하기도 해요. 그리고 이 데이터가 올바른지 검증하는 시스템을 자동화했으면 좋겠어요.

 

나 - (데이터 검증 기준이 있으니 그걸 코드로 구현해 달라고 하시는 건가?) 그럼 데이터가 올바른지 판단하는 기준이 있나요?

 

기획자 -  기준은 따로 없고, 관련 전문가 분이 자문을 해주실 거예요. 

 

나 - (어?) 그럼 전문가 분들이 데이터를 일일이 검증하시는데 여기서 어떤 걸 자동화하시길 원하는 거죠?

 

기획자 - 저희가 데이터를 입력하면 이걸 각 전문가분들이 바로바로 받아서 하실 수 있으면 좋겠어요.

 

나 - (단순화를 말씀하시는 건가..?)  

 

사실 나도 각 단어의 의미에 애매하다고 생각한 점이 있었기 때문에 대화를 하면서 나중에 각 단어에 대해서 다시 정리해 봐야겠다는 생각이 들었다.

 

공부하기

1. 추상화 

미술에서의 추상화(抽象畫)는 뽑을 추(), 코끼리 상(象, 모양을 뜻하기도 한다), 그림 화(畫)를 조합한 한자어다. 한글로 풀자면, 모양을 추출한 그림이라고 할 수 있다. 사전적인 의미론 사물에서 점, 선, 면 등 일부 요소를 추출해 표현한 것을 의미한다. 

개발자가 자주 쓰는 추상화도 이와 별반 다를 게 없다. 차이점은 사물 대신 컴퓨터 과학의 복잡한 요소를 고려한다는 점이다. 나는 좀 더 일반적으로 "논리에서 특징을 추출하는 작업"이라고 표현하고 싶다. 

 

2. 일반화 

사전적인 의미론 개별적이거나 특수한 것들을 일반적인 것으로 만드는 것을 의미한다. 

음... 좀 더 쉽게 풀어보자. 일반화를 뜻하는 generalization의 정의는 다음과 같다. "a written or spoken statement in which you say or write that something is true all of the time when it is only true some of the time:"

특정한 때에만 사실이었던 것들을 항상 사실이라고 표현하는 것을 의미한다. 음 좋아 충분히 쉬운 표현이다. 이러니까 근거 없는 일반화는 성급한 일반화의 오류를 초래한다고 하는구나.

 

3. 단순화

사전적인 의미론 복잡하지 않고 간단하게 되다. 또는 그렇게 만들다는 것을 의미한다.

  • 단순화한 공정.
  • 단순화한 체계.
  • 단순화한 형태.
  • 과정을 단순화하다.
  • 구조를 단순화하다.
  • 우리는 생산자와 직접 거래를 함으로써 농산물을 유통 과정을 단순화했다.
  • 기계에 익숙하지 않은 노년층을 위해 복잡한 기능을 단순화한 휴대 전화가 출시되었다.
  • 가: 이 화가의 작품들은 모두 쉽고 간단하게 그려진 것 같아요.
    나: 복잡한 형태를 단순화하여 표현한 것이 이 작품들의 가장 큰 특징이죠.

내 생각에 추상화와 매우 유사한 의미라고 생각한다. 위 예시 문장에서 단순화를 추상화로 바꾸어 보면 일단 어색하진 않다. 복잡한 것을 간단하게 만드는 방법은 대부분 추상화를 통해서 이루어지기 때문이다. 내가 생각한 다른 점이라면 추상화는 "방법"이며 단순화는 "목적"이라는 생각이 든다.

 

4. 자동화

사전적인 의미론 사람의 노동력에 의존하던 것을 기계화하고 프로세스를 표준화하는 과정에서 자동적으로 이루어지도록 하는 과정을 말한다. 오.. 지금까지 나온 개념 중에 가장 의미가 길다. 차근차근 알아보자.

 

기계화와 표준화를 통해서 사람의 손을 벗어나 작업을 자동으로 수행하는 것이라는 생각이 든다. 즉, 사람이 관여하지 않아도 알아서 수행되도록 만드는 과정이다. 

 

기계화와 표준화를 위해서는 어떤 것이 필요할까? 우선 '화'의 대상을 정확히 정의해야 할 것 같다. 올바를 변화를 주기 위해선 대상을 정확히 알아야 한다. 

 

정확히 알게 되면 추상화와 일반화가 필요하다.

조사한 자동화 대상은 아래와 같다.

목재를 두 손으로 든다. -> 트럭으로 옮긴다. -> 트럭 위에 있는 사람에게 전달한다. -> 트럭 위에 있는 사람은 두 손으로 받아 차곡차곡 적재한다. -> 트럭을 운전한다. -> ( 역순으로 목재를 하역한다.)

 

추상화를 하게 되면 "목재를 트럭에 적재한다 -> 운전한다. -> 목재를 하역한다." 3가지로 요약이 가능하다.

여기서 기계화의 대상을 정해보자. 운전은 기술적 난의도가 높으므로 적재와 하역을 위한 기계를 우선 만들어보자.

즉, 세부적인 작업에서 기계화를 위한 작업을 추출하기 위해 추상화가 필요하다.

 

기계화를 위해선 다음으로 대상 일반화가 필요하다.

목재의 특징에 따라서 기계화의 "방법"이 달라질 것이다. 

목재의 모양이 제각각이어서 각 모양에 맞는 기계화를 진행하려면 엄청난 시간이 걸리기 때문이다.

다행히 지금까지 들어온 목재를 분석하니 다음과 같은 정확한 일반화를 할 수 있다면 올바른 자동화를 달성하기 쉬울 것이다.

[목재가 3미터 이상 5미터 이하의 지름 1미터 이하인 통나무인 경우]

 

즉, 자동화는 내가 생각하기에 "작업을 정의하고 특징을 파악해 이를 대신할 수 있는 기계를 만드는 것"이라고 생각한다. 

 

회고하기

기억을 되짚어 보자.

 

단어의 뜻을 제대로 알았다면 아래와 같이 좀 더 명확한 대화를 할 수 있을 것 같다. 

 

기획자 - (간략) 저희가 데이터를 수집하고 검증화를 하려고 하는데 일일이 하기에는 시간이 오래 걸릴 것 같아요, 그래서 최대한 자동화를 하는 시스템을 만들었으면 좋겠어요.

 

나 - (자동화라면 어떤 작업을 기계화하고 싶은지 정의해야겠다.) 우선 데이터를 수집하고 검증하는 과정 전체에 대해서 설명해 주실 수 있나요?

 

기획자 - 데이터는 웹 크롤링을 하거나 저희가 직접 수기로 입력해요. 검증은 저장된 데이터를 전문가 분들이 직접 진행하실 예정이에요

 

나 - (작업에서 자동화 대상을 추상화해 보자) 이 과정에서 어떤 부분을 자동화하시길 원하시나요?

 

기획자 -  저희가 모은 데이터는 검수자가 결제를 위해 보고서로 만들어야 하는데 이 부분을 자동화하고 싶어요.

 

나 - (그럼 자동화 대상의 특징은 무엇일까?) 그럼 모은 데이터와 보고서의 형태는 어떤 식인가요?

 

기획자 - 웹 크롤러 데이터와 저희가 모은 데이터 모두 같은 엑셀 형식으로 저장돼요. 보고서에는 엑셀 중에서 키워드, 출처 그리고 데이터 본문이 있고 각 검수자의 이름과 결제 확인란이 존재해요. 

 

나 - (ㅇㅋ 이제 충분하다)  

 

개발자이기 전에 같은 회사원으로써 상대방을 이해하기 위한 노력은 결국 나에게 좋은 보상으로 돌아오지 않을까?

 

 

 

 

들어가며

오늘은 회사에서 새로운 프로젝트를 시작하기 위해 리액트 프로젝트를 자동으로 AWS에 배포하는 과정을 진행했고 마주쳤던 이슈에 대해서 정리해보고자 합니다. 이 포스트는 아래와 같이 단계별로 구성되어 있습니다. 

1. S3로 배포하기

2. CloudFront로 배포 강화하기 - 예정

3. Github Action으로 배포 자동화하기 - 예정

 

기존 배포 과정과 문제점

기존에는 AWS 서비스에 대해서 잘 몰라 Nginx를 이용해서 정적 파일을 제공하도록 설계하고 이를 EC2에 올려 배포했습니다. 여기서 문제점은 단순히 정적 파일을 제공하는데 EC2라는 자원은 상당히 비싸다는 것입니다. 웹 페이지의 경우 특정 시간에는 접속이 낮은 경우가 많은데 EC2로 배포를 진행하게 될 경우 시간당 과금이 발생하기 때문에 불필요한 과금이 지속되기 때문입니다. 또한 단순히 정적 파일을 제공하는데 연산에 필요한 자원들은 거의 필요가 없기에 EC2의 자원낭비가 심했습니다.

 

문제 정의

해결방법을 모색하기 위해 지금 프로젝트에 필요한 요소들을 우선순위 기준으로 정의했습니다.

1. 정적 파일을 HTTP 기반으로 제공가능한 기능

2. 부하 분산 기능

3. TLS를 적용 기능

4. API 요청의 경우 리버스 프록시 기능

 

솔루션 탐색

AWS에서 제공하는 서비스 중에 위를 만족하는 서비스는 다음과 같았습니다.

1. 정적 파일을 HTTP 기반으로 제공 가능한 기능 : S3

2. 부하 분산 기능 : CloudFront

3. TLS를 적용 기능 : CloudFront

4. API 요청의 경우 리버스 프록시 기능 : CloudFront

탐색 결과, 운이 좋게 대부분의 요구사항이 CloudFront에서 제공하는 것을 알게 되었습니다. 

 

S3에 프로젝트 배포

1. S3 버킷 생성

S3 콘솔로 접속 후, 왼쪽 메뉴에서 버킷을 선택하고 버킷 만들기를 클릭합니다.

버킷 이름은 원하는 서비스 이름을 입력합시다. 여기서는 react-test-project라고 입력하겠습니다.

객체 소유권의 경우 ACL 활성화를 선택합니다. 

모든 퍼블릭 액세스 차단을 해제하고 확인을 클릭합니다. 우리는 정적 웹 사이트 호스팅에 이를 사용할 것이기 때문에 퍼블릭 액세스를 허용해야 합니다. 나머지 옵션 설정은 생략하고 버킷 만들기를 클릭합니다.

2. 파일 업로드

버킷을 생성했다면 해당 버킷에 들어가봅시다. 비어있는 객체 칸을 처음으로 확인할 수 있습니다. 여기에 배포할 파일을 업로드할 수 있습니다. AWS CLI를 이용하면 쉽게 업로드할 수 있습니다. 콘솔을 이용해도 되지만, 우리는 개발자니까 한번 시도해봅시다. 초기 설정은 어려울 수 있어도 이 시간이 지나면 편안함이 찾아옵니다. 우선 아래 링크를 통해 각자 환경에 맞게 AWS CLI를 설치합니다.

https://docs.aws.amazon.com/ko_kr/cli/latest/userguide/getting-started-install.html

 

최신 버전의 AWS CLI 설치 또는 업데이트 - AWS Command Line Interface

이전 버전에서 업데이트하는 경우 unzip 명령을 실행하면 기존 파일을 덮어쓸지 묻는 메시지가 표시됩니다. 스크립트 자동화와 같은 경우에 이러한 프롬프트를 건너뛰려면 unzip에 대한 -u 업데이

docs.aws.amazon.com

 

이제 IAM을 이용해서 배포 권한만 가지고 있는 사용자 계정을 만들어 보겠습니다. 루트 계정을 사용해도 배포를 할 수 는 있지만, 루트 계정의 인증 정보가 다른 곳에 유출되면 굉장히 위험합니다. 따라서 목적에 따라 최소한의 권한을 가진 계정을 생성해서 사용하는 것이 좋습니다. AWS IAM Console에 접속해봅시다. 콘솔 -> 사용자 -> 사용자 추가를 통해서 새로운 사용자를 만들 수 있습니다.

리액트 프로젝트를 배포할 TestReactDeployer라는 이름을 입력하고 CLI를 사용므로 자격 증명 유형은 액세스 키를 선택합니다. 

다음으로 사용자에게 줄 권한을 선택해봅시다. 권한은 AmazonS3FullAccess 에는 배포에 필요한 권한이 모두 들어있습니다. 하지만 필요 없는 권한도 있습니다. 세부적으로 정말 필요한 권한만 설정하면 좋겠지만 이 포스트의 목적과는 거리가 멀어 생략하고 AmazonS3 FullAccess를 사용하겠습니다. (힌트를 드리자면 세부적인 권한을 선택하기 위해선 IAM 정책을 활용할 수 있습니다. )

마지막으로 액세스 유형, 권한 을 확인하고 사용자를 생성합니다.

 생성한 사용자의 엑세스 키와 비밀 액세스 키를 확인할 수 있습니다. AWS CLI에서 인증할 때 필요한 정보이니 CSV를 소중히 다운로드해줍시다. 

 

이제 AWS CLI를 이용해 인증을 진행해봅시다.

프로파일을 설정하면 여러 자격증명 및 설정을 따로 관리할 수 있습니다. 위와 같이 커맨드를 입력하고 CSV에 포함된 키를 각각 입력합니다. 나머지 옵션도 위와 같이 입력해줍니다.

이제 업로드할 정적 파일로 이동합시다. 저는 Vite를 사용하고 있기 때문에 빌드를 하면 dist 디렉터리에 정적 파일이 생성됩니다. 일반적으로 build 디렉토리에 정적파일이 생성되니 상황에 따라 적절하게 이동하면 됩니다.

정적 파일 디렉터리로 이동하고 위와 같은 커맨드를 입력합니다. s3://[버킷 주소]를 입력하고 사용할 자격증명은 이전에 저장한 프로파일로 설정합니다. 이렇게 하면 한 줄의 커맨드로 아래와 같이 한 번에 필요한 파일들을 배포할 수 있습니다. 

3. 정적 웹 사이트 호스팅

자 이제 이 버킷을 정적 웹 사이트 호스팅용으로 변경해봅시다. 

선택한 버킷에서 속성을 선택합니다.

스크롤을 내리면 맨 아래 속성에서 정적 웹 사이트 호스팅을 확인해볼 수 있습니다. 편집을 누르고 정적 웹 사이트 호스팅을 활성화합니다. 

위와 같이 호스팅 유형을 설정하고 인덱스 문서를 버킷에 업로드한 파일 중 기본 페이지 파일 이름으로 설정합니다. 대부분 index.html 입니다:) 이제 변경사항을 저장합니다.

다시 속성을 확인하면 위와 같이 엔드포인트 주소를 확인할 수 있습니다. 저 링크로 접속해서 원하는 프로젝트에 접속이 가능하다면 성공입니다! 

다음

위 설정만으로 웹 사이트를 배포할 수 있습니다. 하지만 조금 아쉽습니다. HTTP가 아니라는 점, 접속자 수가 급증하면 부하 분산이 불가능하다는 점, 여러 지역에서 접속할 경우 거리마다 정적 파일이 제공되는 시간이 다르다는 점 그리고 API 서버와 연결하기 어렵다는 점 등 실제 서비스에 적용하기에는 부족한 점이 많습니다. 이 부족한 점을 CloudFront로 해결해보겠습니다. 

 

'Web' 카테고리의 다른 글

CSS - Element가 배치되는 방법들  (0) 2020.01.18
Servlet - Hellow World 출력하기(intellij)  (0) 2020.01.18
Web - Event & EventListener  (0) 2020.01.18
Ajax 통신 정의, 필요성, 예제  (0) 2020.01.18
JSP(Java Server Page) - 정의, 문법  (0) 2020.01.18

AWS Lex를 Facebook, Slack 그리고 Twilio 같이 연결서비스를 제공하는 메시지 앱 이외의 서비스에 연결하고 싶은 경우 별도의 HTTP API 서버를 구현해야합니다. 여기에서는 그 과정에 대해서 서술하겠습니다.

1. 아키텍쳐

  1. 사용자는 메시징 앱을 통해서 텍스트를 입력합니다.
  2. 메시징 앱은 텍스트내용을 HTTP API 요청에 담아 AWS API Gateway에 전달합니다.
  3. AWS API Gateway는 요청 인증을 확인하고 이를 AWS Lambda에 전달합니다.
  4. AWS Lambda는 비즈니스 로직을 수행하고 Amazon Lex의 PostText API를 호출하여 통신을 수행합니다.
  5. 위 데이터 전달 과정을 역으로 수행하여 응답 결과를 사용자에게 전달합니다.

여기서는 MakeAppointment 예제 Lex 봇을 이미 생성했다고 가정하고 진행합니다.

2. 역할 생성

AWS Lex API 를 관리하기 위한 역할과 정책을 정의합니다. 여러 서비스를 이용하는 경우 각 서비스 마다 목적에 따라서 권한을 나누어 관리하는 것이 좋습니다. 권한을 정의할 정책과 정책을 적용한 역할을 만들어봅시다.

  1. IAM Console(https://console.aws.amazon.com/iam/) 에 루트 사용자로 로그인합니다.
  2. 엑세스 관리 > 역할 부분에서 역할 만들기를 선택합니다.

  1. 엔티티 선택에서 Lambda를 선택하고 다음을 클릭합니다.

  1. 권한 추가 단계는 생략합니다.
  2. 역할 생성이후 별도의 인라인 권한을 명시할 것이기 때문입니다.
  3. 역할 이름을 명시한 후 역할 생성 버튼을 클릭합니다.
  4. IAM 콘솔 > 엑세스 관리 > 역할에서 생성했던 역할 이름을 검색합니다.
  5. 권한 추가 > 정책 연결을 클릭합니다. (인라인으로 별도의 정책을 선언해 연결 할 수도 있습니다.)

  1. 여기에서 필요한 권한을 정책으로 등록할 수 있습니다. 지금은 편의를 위해 이미 존재하는 AmazonLexFullAccess 권한을 추가하겠습니다. 권한 정책 검색에서 해당 권한을 추가하고 체크 후 정책연결을 클릭해주세요.

3. 람다 함수 생성

AWS Lex 와 통신하기 위한 람다 함수를 생성합시다. 여기에서 이전에 정의한 역할을 사용합니다.

  1. AWS Lambda Console에 접속해서 함수 생성을 클릭합니다.
  2. 함수 이름 등 기본정보를 입력합니다. 여기서는 NodeJS를 사용해보겠습니다.
  3. 기본 실행 역할 변경에서 이전에 생성했던 역할을 선택합니다.

  1. 함수 생성을 클릭합니다.

4. 람다 함수 코드 업로드

이제 람다 함수에 사용할 API 코드를 작성해보겠습니다. 이 과정은 사용자 입력과 Lex 응답을 서로 전달하는 간단한 샘플 프로젝트로 진행됩니다.

  1. 샘플 프로젝트를 클론합니다.
    1. git clone https://github.com/BEOKS/AWS-Lex-Lambda-API-Server.git
  2. 프로젝트의 패키지를 설치합니다.
    1. cd AWS-Lex-Lambda-API-Server; npm install;
  3. 프로젝트를 압축합니다.
    1. zip -r project.zip . #For Mac user
  4. 이전에 생성한 람다 함수에서 코드 > 에서 업로드 > .zip 파일을 선택해 업로드합니다.
 

5. 람다 함수 환경 변수 설정

코드를 보면 일부 파라미터가 process.env 로 정의된 것을 알 수 있습니다. 이는 환경변수를 참조한다는 뜻입니다. 따라서 이에 맞는 환경변수를 입력해야 합니다.

  1. 선택한 람다 함수에서 → 구성 → 환경변수에 진입합니다.

  1. BOT_NAME 은 연결하려는 봇의 아이디를 의미합니다. Lex Console 에서 봇을 선택하면 봇 ID를 확인할 수 있습니다.

3. BOT_ALIAS은 특정 버전을 연결하는 봇의 별칭을 의미합니다. Lex Console → 배포 → 별칭에서 특정 별칭을 생성하거나 선택해 확인할 수 있습니다.

 

4. BOT_LOCALE 은 지원 언어를 의미합니다. https://docs.aws.amazon.com/lexv2/latest/dg/how-languages.html 에서 지원하는 언어 코드를 찾아 입력할 수 있습니다. 현재는 봇을 한국어로 설정했으므로 ko_KR을 입력합니다.

6. 테스트

이제 전체적인 람다 함수에서 테스트를 해볼 수 있습니다.

  1. 람다 함수 테스트 → 테스트 이벤트에서 새로운 이벤트를 생성합니다.

  1. 실행 결과를 확인해 테스트가 잘 수행되는것을 확인할 수 있습니다.

7. API Gateway 연결

이제 람다 함수를 API Gateway 와 연결해 HTTPS 로 접근할 수 있도록 합시다.

  1. API Gateway 콘솔에서 REST API 생성을 클릭합니다.
  2. 리소스 → 작업 → 메서드 생성 → POST Method 를 생성합니다.
  3. 이전에 생성한 람다 함수와 연결합니다.
  4. 설정이 완료되면 메서드를 테스트 할 수 있습니다.

8. API 배포

API를 등록하고 테스트를 완료했다면 외부에서 접속하는 것을 허락하기 위해 배포를 진행할 수 있습니다.

  1. 리소스 → 작업 → 배포를 선택합니다.

  1. 적절한 스테이지를 설정합니다. 스테이지는 dev, test, QA, prod 등의 배포 단계를 의미합니다.)

  1. 스테이지 편집기에서 외부에서 API를 호출할 수 있는 URL을 흭득할 수 있습니다.

8. 최종 API 테스트

Postman Agent 를 이용해서 API를 아래와 같이 호출하면 응답이 잘 전달되는 것을 확인 할 수 있습니다.

 

관련 문서

  1. https://aws.amazon.com/ko/blogs/machine-learning/integrate-your-amazon-lex-bot-with-any-messaging-service/
  2. https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-lex-runtime-service/index.html
  3. https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-lex-runtime-v2/index.html
  4. https://docs.aws.amazon.com/lexv2/latest/dg/how-languages.html

Table of contents

1. 들어가며

이번 포스트에서는 Spring Data Elasticsearch 를 가장 간단하게 사용하는 방법을 먼저 다루고
각 방법을 세부적으로 수정하는 방법과 Spring Data Elasticsearch 의 동작과정에 대해서 다루어 보겠습니다.

❕이 포스트는 Spring Data Elasticsearch 4.4.5 Reference Documentation 을 기준으로 작성되었습니다.

2. 간단한 사용방법

2.1 Elasticsearch 실행

Spring Data Elasticsearch 를 구성하기 전 연결할 Elasticsearch 를 실행하도록합니다.
버전 정보에 따르면 Spring Data Elasticsearch 4.4.5 버전을 위해선 Elasticsearch 7.17.6 버전을 설치해야합니다.
이미 설치되고 실행하고 있는 경우 이 과정을 생략해도 좋습니다.
Elasticsearch 는 설치 가이드를 통해서 여러 OS에 맞게 설치할 수 있습니다.
여기서는 도커를 이용해서 간단하게 실행해보겠습니다.

  1. Pull Docker Image
  2. docker pull docker.elastic.co/elasticsearch/elasticsearch:7.17.6
  3. Create Elasticsearch Container
  4. docker run --name elastic --net elastic -p 9200:9200 -p 9300:9300 -e discovery.type=single-node -it docker.elastic.co/elasticsearch/elasticsearch:7.17.6

패스워드나 HTTPS 를 위한 인증서 설정 등 다양한 설정이 추가적으로 가능하지만, 여기서는 최소한의 사용방법을 익히는 것이 목표이므로
생략하도록 하겠습니다.

2.2 Spring Data Elasticsearch 종속성 추가

접속할 Elasticsearch 를 실행시켰으니 Spring Boot 프로젝트에서 Spring Data Elasticsearch 종속성을 추가합니다.

//...
dependencies {
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.springframework.data:spring-data-elasticsearch:4.4.5")
    implementation("org.springframework.boot:spring-boot-starter-web")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
//...

2.3 Spring Data Elasticsearch 연결 설정

스프링 프로젝트가 Elasticsearch 와 연결하기 위한 구성을 설정합니다. 여기에서 기본적인 호스트와 포트 비밀번호 그리고 SSL 설정 등 다양한 설정이 가능합니다.

package com.example.springdataelasticsearchdemo

import org.elasticsearch.client.RestHighLevelClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.elasticsearch.client.ClientConfiguration
import org.springframework.data.elasticsearch.client.RestClients
import org.springframework.data.elasticsearch.config.AbstractElasticsearchConfiguration
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories


@Configuration
@EnableElasticsearchRepositories(basePackages = ["com.example.springdataelasticsearchdemo.Student"])
class ElasticSearchConfig : AbstractElasticsearchConfiguration() {
    @Bean
    override fun elasticsearchClient(): RestHighLevelClient {
        val clientConfiguration: ClientConfiguration = ClientConfiguration.builder()
            .connectedTo("localhost:9200")
            .build()
        return RestClients.create(clientConfiguration).rest()
    }
}

2.3 테스트 클래스 생성

테스트를 위해서 간단한 학생 정보 클래스를 생성합니다.

package Student

import org.springframework.data.annotation.Id
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.elasticsearch.annotations.Document
import java.time.LocalDate 

data class Person(
    val name:String="foo",
    val age: Int=10,
    val birthDate: LocalDate= LocalDate.now(),
)
@Document(indexName = "Student")
data class Student(
    @Id @ReadOnlyProperty
    val id:String,
    val personalInformation: Person=Person(),
    val department: String="Computer Science",
    val grade: Int=3
)

Document의 ID는 Elasticsearch 의 _id 와 연동됩니다.

2.4 테스트 레포지토리 생성

마지막으로 Spring Data Repository 인터페이스를 생성합니다. 다른 Spring Data 를 사용해보셨다면 아시겠지만 이 인터페이스를 참고하여 기본적인 쿼리를 요청할 수 있습니다.
여기까지가 Spring Data Elasticsearch 를 사용하기 위한 기본적인 구성입니다. 이 다음에는 Service 와 Controller 를 활용해서 학생 정보를 저장하는 HTTP API 를 만들어보겠습니다.

package com.example.springdataelasticsearchdemo.Student

import org.springframework.data.elasticsearch.repository.ElasticsearchRepository
import org.springframework.stereotype.Repository

@Repository
interface StudentRepository : ElasticsearchRepository<Student,String>{
}

2.5 Service 코드 생성

작성한 StudentRepository 를 이용해서 학생정보를 입력받아 저장하는 서비스 코드를 생성했습니다.

package com.example.springdataelasticsearchdemo.Student

import org.springframework.stereotype.Service

@Service
class StudentService(
    private val studentRepository: StudentRepository
) {
    fun save(student: Student){
        studentRepository.save(student)
    }
}

2.6 Controller 코드 생성

서비스 코드를 이용해서 학생 정보를 저장하는 POST API 를 생성했습니다.

package com.example.springdataelasticsearchdemo.Student

import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping

@Controller
@RequestMapping("/api/student")
class StudentController(
    private val studentService: StudentService
) {
    @PostMapping
    fun save(@RequestBody student: Student): ResponseEntity<String> {
        studentService.save(student)
        return ResponseEntity.ok("")
    }
}

2.7 HTTP API 테스트

이제 Postman 을 이용해서 테스트를 진행해보겠습니다. 아래와 같은 테스트 JSON 을 입력해서 POST 메서드로 요청을 전송합니다.

id의 경우 Elasticsearch 가 자동 생성하도록 하는 것이 좋습니다. 그러나 여기서는 조회 테스트를 편하게 하기 위해서 임의의 값을 입력했습니다.

 

전송을 성공했다면, Elasticsearch 에 직접 조회 API 를 전송해서 데이터를 확인해봅시다.

데이터가 잘 저장된 것을 확인할 수 있습니다. 다만 birthDate 의 경우 전송한 값과 다르게 저장되는데 이는 LocalDate 클래스를 사용하고 별다른 형식을 설정하지 않았기 때문입니다.
형식을 설정하는 방법은 아래에서 살펴보겠습니다.

2.8 마무리

지금까지 Spring Data Elasticsearch 를 가장 간단하게 사용하는 방법에 대해서 알아보았습니다. 이 과정에서 Elasticsearch 와 연결을 설정할 떄 비밀번호나 SSL 을 설정하는 법,
파일 저장 형식을 변경하는 법 등 자세한 세부사항과 동작과정에 대해서 궁금한 점이 생겼으리라 믿습니다. 이 방법들은 이 후의 포스트에서 더욱 자세히 알아보도록 하겠습니다.

3. 세부사항과 동작과정

세부사항과 동작과정은 하나의 포스트에 담기에 매우 방대하다고 생각하여 여러 포스트를 시리즈로 업로드할 예정입니다.

  1. Spring Data Elasticsearch Configuration 설정 - 작성중
  2. Spring Data Elasticsearch SSL/TLS 설정 - 예정
  3. Spring Data Elasticsearch Date 형식 설정 방법 - 예정
  4. Spring Data Elasticsearch Repository 의 동작 과정 이해하기 - 예정
  5. Custom Spring Data Elasticsearch Repository 생성하기 - 예정

Reference

  1. https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/
  2. https://www.elastic.co/guide/en/elasticsearch/reference/current/setup.html

웹 개발을 진행해본 사람은 한 번쯤 CORS에러를 마주쳤을 겁니다. 보통 다른 도메인에 정보를 요청할 때 발생하는데요. 이번 포스트에는 RFC 문서를 바탕으로 왜 이 에러가 발생하는지 알아보도록 하겠습니다. RFC 문서를 참고하여 최대한 올바른 정보를 토대로 작성했습니다.

1. SOP

첫 번째 이유는 아주 간단합니다. 브라우저는 기본적으로 SOP(Same-Origins Policy)를 준수하기 때문입니다. 그렇다면 SOP는 무엇일까요? 이 내용은 RFC 6454에 잘 명시되어 있습니다. 먼저 Origin이라는 용어의 의미부터 알아보겠습니다.

1.1. Origins

3.2. Origin
...
user agents group URIs together into protection domains called "origins". Roughly speaking, two URIs are part of the same origin (i.e., represent the same principal) if they have the same scheme, host, and port. (See Section 4 for full details.)

User-agent(ex. 브라우저)가 같은 보호 규칙과 도메인을 가지고 있는 URI들을 그룹화한 것이 "Origins"입니다. 구체적으로, 같은 스키마, 호스트 그리고 포트 주소를 가지는 경우 하나의 Origins이라고 말할 수 있습니다. 예를 들어, 아래의 URI들은 같은 Origins입니다. 도메인, 프로토콜 그리고 포트 주소가 같기 때문이죠.

http://example.com/
http://example.com:80/
http://example.com/path/file

하지만 아래의 URI들은 같은 Origins라고 할 수 없습니다. 도메인, 프로토콜 그리고 포트번호가 하나 이상  다르기 때문입니다.

http://example.com/
http://example.com:8080/
http://www.example.com/
https://example.com:80/
https://example.com/
http://example.org/
http://ietf.org/

1.2. Same-Origins Policy

SOP(Same-Origins Policy)는 처음 스크립트를 요청한 도메인과 같은 Origins을 가진 URI만 요청할 수 있다는 정책입니다. 만약 http://example.com/ 페이지에 접속해서 스크립트를 요청했다면 위에서 언급한 같은 Origins을 가진 URI만 요청할 수 있습니다. 

2. 왜 SOP가 필요할까?

2.1 좋은 서버와 나쁜 서버

1. Introduction
User agents interact with content created by a large number of authors. Although many of those authors are well-meaning, some authors might be malicious. To the extent that user agents undertake actions based on content they process, user agent implementors might wish to restrict the ability of malicious authors to disrupt the confidentiality or integrity of other content or servers.

As an example, consider an HTTP user agent that renders HTML content retrieved from various servers. If the user agent executes scripts contained in those documents, the user agent implementor might wish to prevent scripts retrieved from a malicious server from reading documents stored on an honest server, which might, for example, be behind a firewall.

사용자가 만약 여러개의 서버에서 스크립트를 받아서 실행한다고 생각해봅시다. 서버는 신뢰성이 높은 좋은 서버도 있겠지만 바이러스를 퍼트리거나 불법으로 데이터를 수집하려는 악의적인 서버가 존재할 수 있습니다. 만약 좋은 서버와 나쁜 서버의 스크립트를 브라우저에서 동시에 실행시키면 어떻게 될까요? 좋은 서버의 데이터나 사용자 정보가 나쁜 서버에 의해 악의적으로 사용되거나 아예 프로그램을 망쳐버릴 수도 있습니다. 그럼 각각의 URI에 서로 다른 보안 정책을 사용하는 건 어떨까요? 그렇다면 나쁜 서버의 기능을 방지하면서 서로 다른 도메인에 데이터를 요청할 수 있으니까요! 

3.2. Origin
In principle, user agents could treat every URI as a separate protection domain and require explicit consent for content retrieved from one URI to interact with another URI. Unfortunately, this design is cumbersome for developers because web applications often consist of a number of resources acting in concert.

아쉽지만, 이를 구현하는 것은 쉽지 않습니다. 웹 어플리케이션은 수많은 컴퓨팅 리소스들로 구성되어 있기 때문에 이를 URI 마다 제한하는 기능을 추가하는 것은 매우 복잡하기 때문입니다.

그래서, User-agent(ex. 브라우저)는 같은 신뢰성을 가진 서버에게만 스크립트를 요청하기로 결정했습니다. 이렇게 하면 다른 나쁜 서버의 스크립트가 실행되는 것을 원천적으로 차단할 수 있으니까요.

3. SOP의 예외

물론 SOP에도 예외가 존재합니다. 위 글을 자세히 읽어보셨다면 오직 스크립트만 같은 서버에 요청했다고 말씀드렸는데요, 그 말은 즉 이미지나 CSS 등의 데이터는 SOP의 대상이 아닙니다. 좀 더 일반적으로 말하면 권한(Authority)이 없는 데이터는 SOP의 대상이 아닙니다.

3.3. Authority
Although user agents group URIs into origins, not every resource in an origin carries the same authority (in the security sense of the word "authority", not in the [RFC3986] sense). For example, an image is passive content and, therefore, carries no authority, meaning the image has no access to the objects and resources available to its origin. By contrast, an HTML document carries the full authority of its origin, and scripts within (or imported into) the document can access every resource in its origin.

권한(Authority)은 리소스에 접근하는 기능이 있는 것을 의미합니다. 스크립트는 당연히 코드를 통해서 리소스에 접근하고 이를 조작, 전송할 수 있습니다. 하지만, 이미지나 CSS는 이러한 기능이 없기 때문에 권한이 없다고 할 수 있습니다.

4. 결론 및 요약

  1. 웹에는 신뢰성이 있는 서버와 악의적인 서버가 존재합니다.
  2. 신뢰성이 있는 서버와 악의적인 서버의 스크립트를 같이 요청하면 신뢰성이 있는 서버의 정보나 리소스가 악용될 수 있습니다.
  3. 서로 다른 Origin마다 보안 규칙을 적용할 수 있지만, 이를 구현하는 것은 매우 복잡한 일입니다.
  4. 그래서, 같은 신뢰성을 가진 서버에서만 스크립트를 요청할 수 있습니다. 
  5. 그러므로 서로 다른 Origin에 요청을 보낼 경우 교차 출처 리소스 공유 (Cross-origin resource sharing) 에러가 발생합니다.
  6. 하지만 이미지나 CSS 처럼 리소스 접근권한이 없는 경우는 다른 Origin에 요청이 가능합니다.

 

들어가며

아래 문제 설명은 풀이를 쉽게 이해할 수 있도록 프로그래머스의 내용을 그대로 인용했음을 밝힙니다. 정확한 자료는 아래 링크를 참고하시기 바랍니다.

https://school.programmers.co.kr/learn/courses/30/lessons/118670

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

문제 설명

[본 문제는 정확성과 효율성 테스트 각각 점수가 있는 문제입니다.]

당신은 행렬에 적용할 수 있는 두 가지 연산을 만들었습니다.

  • ShiftRow
    • 모든 행이 아래쪽으로 한 칸씩 밀려납니다. 즉, 모든 행에 대해서 i번째 행은 i+1번째 행이 됩니다. (마지막 행은 1번째 행이 됩니다.)
    • ShiftRow의 예 
      • 왼쪽 행렬이 초기 상태이고 오른쪽 행렬이 ShiftRow를 한 번 시행한 뒤의 행렬입니다.
      • 1번째 행에 있던 [1,2,3]이 2번째 행으로, 2번째 행에 있던 [4,5,6]이 3번째 행으로, 3번째 행에 있던 [7,8,9]가 1번째 행이 된 것을 확인할 수 있습니다.
  • Rotate
    • 행렬의 바깥쪽에 있는 원소들을 시계 방향으로 한 칸 회전시킵니다.
    • 행렬의 바깥쪽에 있는 원소들은 첫 행, 첫 열, 끝 행, 끝 열에 포함되는 원소들입니다.
    • 한 칸 회전시킨다는 것은 이 원소들이 시계 방향으로 한 칸씩 밀려난다는 것을 의미합니다. 즉, 다음 4개의 연산이 동시에 시행됩니다.
      • 첫 행에서 끝 열에 있는 원소를 제외한 첫 행의 모든 원소는 오른쪽으로 한 칸 이동합니다.
      • 끝 열에서 끝 행에 있는 원소를 제외한 끝 열의 모든 원소는 아래쪽으로 한 칸 이동합니다.
      • 끝 행에서 첫 열에 있는 원소를 제외한 끝 행의 모든 원소는 왼쪽으로 한 칸 이동합니다.
      • 첫 열에서 첫 행에 있는 원소를 제외한 첫 열의 모든 원소는 위쪽으로 한 칸 이동합니다.
    • Rotate의 예 
      • 왼쪽 행렬이 초기 상태이고 오른쪽 행렬이 Rotate를 한 번 시행한 뒤의 행렬입니다.
      • 바깥쪽에 있는 값들이 시계 방향으로 한 칸씩 이동한 것을 확인할 수 있습니다.

당신은 행렬에 연산을 여러 번 시행하려고 합니다.
행렬의 초기 상태를 담고 있는 2차원 정수 배열 rc, 시행할 연산을 순서대로 담고 있는 문자열 배열 operations가 매개변수로 주어졌을 때, 연산을 차례대로 시행한 후의 행렬 상태를 return 하도록 solution 함수를 완성해주세요.


제한사항
  • 2 ≤ rc의 행 길이(=행렬의 가로 길이) ≤ 50,000
    • rc의 모든 행의 길이는 동일합니다.
  • 2 ≤ rc의 열 길이(=행렬의 세로 길이) ≤ 50,000
    • rc의 모든 열의 길이는 동일합니다.
  • 4 ≤ rc의 행 길이 x rc의 열 길이 ≤ 100,000
  • rc[i][j]  i+1번째 행 j+1번째 열에 있는 원소를 나타냅니다.
    • 1 ≤ rc[i][j] ≤ 1,000,000
  • 1 ≤ operations의 길이 ≤ 100,000
    • operations의 원소는 "ShiftRow" 혹은 "Rotate"입니다.

정확성 테스트 케이스 제한 사항

  • 2 ≤ rc의 행 길이(=행렬의 가로 길이) ≤ 1,000
    • rc의 모든 행의 길이는 동일합니다.
  • 2 ≤ rc의 열 길이(=행렬의 세로 길이) ≤ 1,000
    • rc의 모든 열의 길이는 동일합니다.
  • 4 ≤ rc의 행 길이 x rc의 열 길이 ≤ 10,000
  • 1 ≤ operations의 길이 ≤ 100

효율성 테스트 케이스 제한 사항

  • 주어진 조건 외 추가 제한사항 없습니다.

풀이

1. 방향 잡기

이 문제의 핵심은 operation을 어떻게 수행할 것인가에 초점이 맞추어져 있습니다. "ShiftRow" 혹은 "Rotate" 는 가장 쉽게 행렬을 조작하여 문제를 푼다면 각각 O(rc의 행 길이 x rc의 열 길이), O(rc의 행 길이 + rc의 열 길이) 만큼의 시간이 소요됩니다. 따라서 전체 소요 시간은 O(rc의 행 길이 x rc의 열 길이 x operations의 길이)로 정확성 테스트의 경우 약 1,000,000의 숫자가 나와 쉽게 해결할 수 있습니다. 그러나 이 알고리즘을 효율성 테스트에 적용할 경우 10,000,000,000 이라는 터무니 없는 숫자 때문에 해결하기가 어렵습니다. 

 

operation의 길이는 불변이므로 우리는 "ShiftRow" 혹은 "Rotate"의 시간복잡도가 O(1) 또는 O(log(rc의 행 길이 x rc의 열 길이 ))으로 개선해야 할 것입니다.

2. Operation 개선

2.1 자료구조 변경

결론부터 말하면 "ShiftRow", "Rotate"을 O(1) 로 개선하는 방법이 있습니다.

첫 번째로, 기존의 Matrix를 left line, middle 그리고 right line으로 나눕니다. 그리고 각 라인은 dequeue로 변경합니다. middle의 내부에 있는 각 row 또한 dequeue로 변경합니다.

def splitLine(self, matrix):
    leftLine=deque()
    rightLine=deque()
    middles = deque()
    for row in range(len(matrix)):
        leftLine.append(matrix[row][0])
        rightLine.append(matrix[row][-1])
        middles.append(deque(matrix[row][1:-1]))
    return leftLine,rightLine,middles

이렇게 하면 ShiftRow와 Rotate를 간단하게 구현할 수 있습니다.

2.1 ShiftRow

먼저 ShiftRow가 입력된 경우 아래와 같이 모든 라인의 dequeue는 마지막 원소를 앞에 오도록 이동합니다. 

def shiftRow(self):
    self.leftLine.appendleft(self.leftLine.pop())
    self.rightLine.appendleft(self.rightLine.pop())
    self.middles.appendleft(self.middles.pop())

2.3 Rotate

Rotate는 아래 4과정으로 다시 표현할 수 있습니다.

1.  왼쪽 위 모서리의 원소가 오른쪽으로 원소들을 밀어냅니다.

2. 오른쪽 위 모서리의 원소가 아래쪽으로 원소들을 밀어냅니다.

3. 오른쪽 아래 모서리의 원소가 왼쪽으로 원소들을 밀어냅니다.

4. 왼쪽 아래 모서리의 원소가 위쪽으로 원소들을 밀어냅니다.

 

요점은 실제로 테두리에 있는 모든 원소들이 이동하는 것이 아니라, 모서리에 있는 각 원소들이 근처의 원소들을 밀어낸다는 것으로 표현하는 것입니다. 이것은 dequeue를 이용해서 가장 간단하게 아래처럼 구현할 수 있습니다. (사실 이 아이디어 때문에 dequeue를 사용하기로 결정했습니다.) 아래 함수를 보시면 알겠지만 위 4과정을 그대로 표현하고 있습니다.

def rotate(self):
    self.middles[0].appendleft(self.leftLine.popleft())
    self.rightLine.appendleft(self.middles[0].pop())
    self.middles[-1].append(self.rightLine.pop())
    self.leftLine.append(self.middles[-1].popleft())

2.4 변경된 자료구조 복구

지금까지 operation 효율을 위해 dequeue를 여러개 사용하여 matrix를 다루었습니다. 그러나 결괏값은 이중 배열 형식이기 때문에 아래와 같이 자료구조를 변경하는 코드를 추가합니다.

def getMatrix(self):
    ret=[]
    for idx in range(len(self.middles)):
        self.middles[idx].appendleft(self.leftLine[idx])
        self.middles[idx].append(self.rightLine[idx])
        ret.append(list(self.middles[idx]))
    return ret

 

전체 코드

from collections import deque


class Matrix:
    def __init__(self, matrix):
        self.leftLine, self.rightLine, self.middles = self.splitLine(matrix)
    def shiftRow(self):
        self.leftLine.appendleft(self.leftLine.pop())
        self.rightLine.appendleft(self.rightLine.pop())
        self.middles.appendleft(self.middles.pop())
    def rotate(self):
        self.middles[0].appendleft(self.leftLine.popleft())
        self.rightLine.appendleft(self.middles[0].pop())
        self.middles[-1].append(self.rightLine.pop())
        self.leftLine.append(self.middles[-1].popleft())

    def getMatrix(self):
        ret=[]
        for idx in range(len(self.middles)):
            self.middles[idx].appendleft(self.leftLine[idx])
            self.middles[idx].append(self.rightLine[idx])
            ret.append(list(self.middles[idx]))
        return ret
    def splitLine(self, matrix):
        leftLine=deque()
        rightLine=deque()
        middles = deque()
        for row in range(len(matrix)):
            leftLine.append(matrix[row][0])
            rightLine.append(matrix[row][-1])
            middles.append(deque(matrix[row][1:-1]))
        return leftLine,rightLine,middles

def solution(rc, operations):
    matrix = Matrix(rc)
    for o in operations:
        if o == 'ShiftRow':
            matrix.shiftRow()
        else:
            matrix.rotate()
    return matrix.getMatrix()

 

들어가며

스타트업에서 일한 지 벌써 몇 달이 지났다. 스타트업에서 웹 개발자로 살아가면서 여러 고난을 겪고 내가 해결한 방법과 탐구 과정에 대해서 정리해보려고 한다. 아직 많이 미숙한 글이지만 이 글이 다른 스타트업에서 힘겹지만 노력하는 개발자들에게 도움이 되었으면 좋겠다.

 

환경

나는 이제 막 시드 투자를 유치하는 스타트업에 웹 개발자로 참여하게 되었다. 이 시기의 스타트업은 실제로 사용자를 모으고 비즈니스 모델을 단단하게 구현하기 보단 투자자, 협업체 그리고 예비 사용자의 피드백을 받고 거기서 새로운 시도를 통해 어떤 가치를 찾을 수 있을지 탐구하는게 가장 큰 목표다. 즉, 시도와 보완이 계속해서 이루어진다. 마치 인공지능이 올바른 방법을 찾기 위해서 학습을 하고 평가를 반복하듯 보이지 않는 정답을 향해 한 걸음 또 한걸음 나아간다.

그렇기에 거의 매번 요구사항이 변경되는 경험을 얻을 수 있다. 정말로 심할때는 한 번 회의를 할 때마다 6개 이상의 기능이 변경되거나 처음에 정말로 변하지 않을 거라 계획했던 기능조차 변경된 경험도 했다. 예를 들어, 복합적인 환자 정보를 효과적으로 관리하는 시스템을 단순히 환자 이미지를 관리하는 시스템으로 바꿔달라는 소리를 들었을 땐 정말로 어안이 벙벙했다. 예전에 책에서 사용자도 정말로 자신이 뭘 원하는지 모른다는 글을 읽었는데 이를 실감하는 경험이었다. 

 

하지만 인력은 부족하다. 경영자의 입장에서는 "빠르게", "다양한" 시도를 원한다. 이를 위해선 그 만큼의 인적, 물적 자원이 필요하지만 스타트업에은 대기업에 비해서 이 자원이 턱없이 부족하다.  클린 애자일에서는 프로그래밍의 피할 수 없는 진리로 철십자가 언급된다. 철십자는 좋음,빠름,저렴함,완성 중 오직 3가지만 선택이 가능하다는 뜻이다. 스타트업은 여기서 완성과 저렴함을 무조건 선택할 수 밖에 없다. 자본은 부족하고 결과를 만들어야 하기 때문이다. 따라서 스타트업에서는 느리지만 좋은 결과를 만들 것이냐, 좋은 기능은 아니지만 빠르게 결과를 만들 것이냐 둘 중 하나의 선택이 강요된다.

문제

하지만 경영자는 "좋은"걸 "빠르게" 만들길 원한다. 물론 좋은 인력이 많으면 많을수록 이를 만족 할 수 있다. 그러나, 앞서 말했듯 스타트업은 자원이 부족하지 않은가? 여기까지 글을 읽은 사람들은 개발을 하기 전에 기획자가 요구사항을 검수하고 디자이너가 목업을 만들어 최소한의 필요성을 검증한 다음에 개발을 진행하는 스프린트를 적용하면 최대한 효율적으로 진행되지 않을까?라고 생각하지만 이상과 현실은 다르다. 경영자는 최소한의 자원을 투입하려고 한다. 그리고 아직 배포하지도 않을 프로젝트를 위해서 기획자나 디자이너는 필요하지 않다고 생각한다. 또한 반복적인 수정사항이 코드를 망가뜨리고 나중에 수정사항을 요청할 때마다 비용이 많이 드는 것을 이해하지 못한다. 

 

그리고 디자이너가 목업을 만드는 것도 한계가 존재한다. 웹 개발같은 경우 기능이 반응형, 사용자와 상호작용 그리고 동영상 등 동적인 경우가 많다. 이런 기능은 디자이너가 Figma, Adobe XD를 이용한다고 해도 구현하기가 어렵다. 따라서 예비 사용자나 협업체에서 올바른 피드백을 주기가 어렵다. 실제로 Figma를 이용해서 만든 데모를 통해 받은 피드백 보다 MVP를 통해 받은 피드백이 훨씬 많고 가치가 있었다. 그리고 초기 스타트업은 가치있는 피드백이 곧 비즈니스 모델로 직결되기 때문에 매우 중요하다. 

 

또한 개발 가능성과 예상 구현 기간을 추론하는데도 직접 개발을 진행하는 것은 매우 중요하다. 새로운 요구사항이 추가되면 항상 따라오는 질문이 있다. "언제까지 개발이 가능한가요?". 이미 많이 구현해본 기능이고 기존 코드에서 쉽게 변경할 수 있을 것 같다면 이 질문에 잘 대답할 수 있지만, 전혀 새로운 기능이거나 기존 코드에 어떻게 이식할지 감이 잡히지 않을 때는 곧바로 대답하기 어렵다. 하지만 경영자도 일정이 있기 때문에 예상 개발 기간은 매우 중요하다. 이 경우 목업을 따로 만들기 보다는 오히려 바로 구현을 시도해봄으로써 예상 기간을 빠르게 파악할 수 있다. 

 

즉, 이상적으로는 기획 - 디자인(프로토타입, 목업) - 개발 과정은 훌륭한 수단이지만 초기 스타트업에서는 이를 실행하기 위한 인력이 부족하고 빠르게 좋은 피드백을 받기 어려우며 개발 일정을 예상하기 어렵다. 

해결 방법

결국 초기 스타트업에서는 빠르게 기능을 구현하는 것이 중요하다. 여기서 웹 프로젝트 구조를 다시 돌아보자. 현재 내가 개발하고 있는 웹 프로젝트는 아래와 같이 3-Tier로 구성되어 있다. 그리고 나는 프론트와 벡엔드 모두 개발을 진행중인데 지속적으로 기능 요구사항을 받으면서 알게 된 것이 하나 있다. 사실 피드백을 위해서 서버나 데이터베이스의 기능은 그렇게 중요하지 않은 것이다. 대부분의 피드백은 클라이언트에서 뷰와 컴포넌트가 어떻게 구성되어 있고 클릭이나 드래그 등 입력시 어떻게 반응하는지가 중점이지 서버에 어떤 기능을 추가해 달라는 요구사항은 별로 존재하지 않았다. (만약 존재하고 이를 구현한다고 해도 다음 피드백의 퀄리티를 크게 높이지는 못한다.)

즉, 피드백을 받기위한 목업을 만들기 위해서 서버나 데이터베이스에서 개발이 필요하지 않다. 그래서 나는 개발과정에서 Demo 라는 스테이지를 만들어 빠르게 개발하는 방법을 도입했다.

Demo

웹 개발을 해본 사람이라면 웹 서비스는 크게 dev-stage-production 등 요구되는 환경에 따라 개발 설정이 달라지는 것을 알것이다. 나는 dev 이전에 Demo라는 스테이지를 만들었는데 이 단계는 오직 피드백을 위해서 클라이언트 데모를 만드는 단계다. 즉, 이 단계에서는 서버나 데이터페이스가 기능에 포함되지 않는다. 클라이언트에서 서버로 전달되는 요청은 모두 Fake 데이터를 전달한다. 코드로 살짝 표현하자면 아래와 같다.

// HTTP 요청 예시
HttpRequest.post('api/v2/~~')
	.then(response=> {...})
    .catch(error=>{...}
    
// HttpRequest.config.ts
config={
	request : 'api/v2/~~',
    response : {
    	status : 200,
        content-type : 'application/json',
        response : ...
    }
}

만약 yarn demo 커맨드를 이용하면 모든 HTTP 요청은 HttpRequest.config.ts에 정의된 Fake Response를 응답으로 받게된다. 하지만 yarn dev, yarn prod 커맨드를 이용해서 클라이언트가 실행되면 실제로 프록시로 설정한 서버에 요청이 전송되고 응답을 받도록 한다.

 

언듯보면 Jest를 이용해 코드를 테스트할 때 API를 Mocking하는 작업과 유사하다. 하지만 이 라이브러리는 테스트 코드가 아니라 실제로

전체적인 데모를 위해서 Fake Response를 전달하고 demo 과정에서 dev 과정으로 넘어갈 때 따로 코드를 수정할 필요가 없도록 하는 것이 목표다.

 

이를 이용하면 서버, 데이터베이스 개발 없이 빠르게 데모가 구현가능하고 결과적으로 빠르게 피드백을 받아 개발 사이클은 물론 경영자도  경영전략을 빠르게 수립할 수 있다. 현재 이와 관련된 라이브러리를 개발 중이고 업무에 적용할 예정이다. 의미있는 성과를 얻게 된다면 이 포스트에 업데이트 할 예정이다.

+ Recent posts