⭐️ 목적

백엔드 프로젝트를 새로 시작하면서, 또 다시 AWS EC2를 생성하게 되었다.

 

실수로 노출되면 한 순간 엄청난 과금이 이루어지는 정보들을 또 다시 관리해야했다.

 

서비스 코드 레포는 추후 퍼블릭으로 변경하여 포트폴리오 용도로 사용할 예정이기 때문에 중요한 설정 정보들을 관리해야했다.

 

.env에 환경변수로 관리하거나 .gitignore를 이용해서 yml 파일 자체를 안올리는 방법도 있지만,

두 방법 모두 새로 배포를 할 때마다 수동으로 추가해야 된다는 점에서 굉장히 귀찮다고 생각했다.

 

이러한 이유로 민감한 정보가 포함된 yml 파일들을 저장하는 레포를 따로 만들어서 Submodule로 연결하게 되었다.

 

 

✅ 서브모듈 연결

먼저 서브모듈로 활용할 레포를 생성해준다. (평소 레포 생성하는 것과 똑같이하면 된다)

 

서브모듈을 연결할 메인 레포를 클론한다.

 

로컬 레포 경로에서 다음 명령어를 입력해준다.

git submodule add ${URL} {원하는 디렉토리 명이 있을 경우 명시}

 

URL의 경우 서브모듈로 활용할 레포의 URL을 입력하면 된다.

 

서브모듈이 추가될 경우 서브모듈 매핑 정보가 담긴 .gitmodule이 생성된다. (이게 생성되지 않으면 어떤 일이 일어나는지는 뒤에 정리)

 

서브모듈이 연결될 경우 설정한 이름과 똑같은 디렉토리가 생성된다.

 

다른 레포의 파일들을 원래 디렉토리 경로로 설정 파일 사용하는 것처럼 똑같이 사용할 수 있게 된 것이다.

 

 

✅ 서브모듈 관리

서브모듈은 메인 레포에 포함된 디렉토리처럼 보이지만, 매핑만 된 것이지 여전히 별도의 레포이다.

 

따라서 서브모듈에 변경할 내용이 있거나, 업데이트할 내용이 있다면 따로 관리해주어야 한다.

 

서브모듈 업데이트

처음 레포를 clone한 경우에 서브모듈을 함께 clone하기 위해서 --recurse-submodules 플래그를 추가한다. 

git clone --recurse-submodules {URL}

# 또는

git submodule init
git submodule update

 

위의 과정은 한 번만 진행하면 된다.

 

이후 팀원이든 어떠한 이유로 서브모듈이 변경되었다면, 로컬에서 업데이트 해줘야 한다.

git submodule update --remote

 

서브모듈 커밋

보통 깃허브에 변경 내용을 push 할 때 다음과 같은 과정을 거친다.

git add .
git commit -m "적당한 커밋 메시지"
git push origin "브랜치명"

 

만약 서브모듈의 내용을 변경한 뒤, 본 프로젝트 디렉토리에서 위와 같이 push 하면,

[변경된 본 프로젝트] - [변경되기 전 서브모듈] 와 같은 형태로 push가 된다.

 

반드시 서브모듈 디렉토리에서 먼저 commit > push 후 본 프로젝트 디렉토리에서 push 해야한다.

다음과 같은 흐름이다.

cd "서브모듈 디렉토리"
git add .
git commit -m "서브모듈 적당한 커밋 메시지"
git push origin "서브모듈 브랜치명"

cd ..
git add .
git commit -m "적당한 커밋 메시지"
git push origin "브랜치명"

 

왜냐하면, 서브모듈을 커밋 식별자를 통해 연결하고 있기 때문이다.

 

깃허브의 모든 커밋에는 랜덤 문자열 형태의 식별자가 붙는다.

 

9b88be3라는 커밋 상태에서 변경 내용이 포함된 ae4c2f3이라는 새로운 커밋이 생성된 것을 의미한다.

 

따라서 서브모듈의 내용을 변경하더라도 커밋을 하지 않으면,

본 프로젝트에서는 변경 내용이 적용되지 않은 기존의 커밋과 연결되어 있기 때문에 이전 서브모듈과 연결된 상태로 깃허브에 push가 된다.

 

따라서 서브모듈 내에서도 커밋을 진행해서 변경 내용이 포함된 새로운 커밋을 만들어내고,

본 프로젝트에서 "연결된 커밋(식별자)이 변경된 내용"을 포함한 커밋을 push해야 하는 것이다. (쓰고 나니 읽기가 요상하다)

 

 

✅ 트러블슈팅

앞서 서브모듈을 추가하면 본 프로젝트 디렉토리에 매핑 정보가 적혀있는 .gitmoudules가 추가된다고 했다.

 

하지만 어떤 이유인진 모르겠지만 처음 서브모듈을 추가했을 때 해당 파일이 추가되지 않았고,

로컬에선 돌아갔기 때문에 신나서 push한 나는 팀원이 빈 디렉토리를 클론하는 모습을 지켜봐야했다.

 

왜 빈 디렉토리로 보이는 지 찾아다니다가 깃허브 페이지를 봤는데

 

위와 같이 서브모듈에 커밋 식별자가 포함되지 않아 클릭해도 서브모듈 레포로 넘어가지지 않았다.

 

그제서야 .gitmodules가 없다는 것을 알아차렸고, 매핑 정보가 없기 때문에 빈 깡통이 서브모듈로 연결되고 있는 것을 알게 되었다.

 

처음엔 서브모듈이 추가되지 않은 것인가 싶어서 add를 한번 더 진행했다.

❯ git submodule add {URL}
fatal: '어쩌구-config' already exists in the index

 

이미 추가된 서브모듈이라는 에러만 출력 됐다.

 

어쩔 수 없이 서브모듈을 제거하고자 git rm --cached business-card-config 를 입력했는데,

또 어떤 이유인지 모르겠지만 갑자기 .gitmoudules가 생성되어 제대로 동작되었다.

 

사실 서브모듈 연결을 끊고자 입력한 것이지만, 명령어를 잘못해서 git 인덱스에서 서브모듈을 제외하는 명령어를 입력해버렸다.(이러면 해당 디렉토리가 커밋 내용에서 제외된다)

 

그런데 보이지 않던 .gitmodules가 나타났고, 커밋은 여전히 잘되는 상황이 돼버렸다.

 

일단 사용하고다가 나중에 이유를 알게 된다면 내용을 추가하도록 하겠다.

'개발 메모' 카테고리의 다른 글

ELK stack + Kafka 적용 일지  (0) 2024.08.12

풀이 사이트: 프로그래머스 고득점 Kit! 

# 완전 탐색

📝 모음 사전 (lv 2) - https://school.programmers.co.kr/learn/courses/30/lessons/84512

* 체감 난이도 : ⭐️ - - - -

- 이건 그냥 수학 문제라 기록할 것도 없다.
- 완탐까지 갈 필요도 없이 그냥 경우의 수만 따지면 되는 의미 없는 문제라고 생각한다.
def solution(word):
    answer = 0
    dic = {"A":0, "E":1, "I":2, "O":3, "U":4}
    for i in range(len(word)):
        for j in range(5-i):
            answer += dic[word[i]] * 5 ** (j)
    return answer + len(word)

 

 

# 우선순위 큐

📝 디스크 컨트롤러 (lv 3) - https://school.programmers.co.kr/learn/courses/30/lessons/42627

* 체감 난이도 : 💀💀💀💀💀

- 아 풀다가 답답해서 반례 봐버렸다... 못 푼걸로 치도록 하겠다.
- 아이디어는 단순하게 가장 소요시간이 적은 작업을 가능한 빨리 시작하는 것이였다.
- 문제 제한이 최대 1000이 걸리는 작업 최대 500개가 제한이였기 때문에 500000 크기의 배열을 만들어 시작 시간 순서 상관 없이 작업이 언제 시작해서 언제 끝났는지를 저장하고자 했다.
- 이를 위해선 각 작업의 시작 가능한 시간을 찾고 해당 시간부터 소요 시간동안 어떠한 작업도 이루어지지 않았는지 확인을 해야했기 때문에 각 작업당 n^2의 시간이 걸리게 된다. (n개의 작업시 n^3)
- 그래서 다음 생각한 아이디어가 시간의 순서를 적용시키는 것이였다.
- 시간의 흐름상 앞에서부터 작업을 진행시킨다.
- 이때 최소의 작업 대기시간을 만들기 위해서 핵심 아이디어가 필요하다.
- '바로 시작가능한 밀린 작업 중 가장 소요시간이 짧은 작업'이다.
- 매 순간 짧은 작업은 선택 해야하는 이유는 예제에서도 드러나듯, 선행하는 작업의 소요시간이 그 시간에 시작 가능한 모든 작업에 적용되기 때문에 가장 짧은 작업부터 선택해야 오버헤드가 최소화 된다. 
- 그 뿐만 아니라, 작업을 하지 않는 시간을 없애는 것도 중요하다.
- 시작 시간에 상관없이 가장 소요시간이 짧은 것부터 선택했을 때 해당 작업이 현재 시간 이후에 시작 가능하면 그 사이 시간동안 어떠한 작업도 할 수 없다면 당연히 평균 대기시간이 늘어나게 될 것이다. (이러한 점에서 첫 번째 방법은 아예 잘못됐다고 볼 수 있다)
- 위의 조건을 모두 만족시키기 위해 작업들을 시작 시간 순으로 정렬하고, 짧은 소요시간을 우선으로하는 우선순위 큐를 사용하였다.
- 방식은 다음과 같다.
   1-1. 모든 작업을 완료할 때까지, 현재 바로 시작가능한 작업들을 우선순위 큐에 집어넣는다.
   1-2. 만약 현재 시작 가능한 작업이 없을 경우 가장 빨리 시작 가능한 작업을 기준으로 현재시간을 조정한다.
   2. 우선순위 큐 가장 첫 요소가 바로 위에서 말한 현재 시작 가능한 작업 중 소요시간이 가장 짧은 작업에 해당하기 때문에 빼내어 대기시간(현재시간 + 소요시간 - 시작가능 시간)을 저장하고 현재시간을 소요시간만큼 늘린다.
   3. 모든 대기시간을 더한 값에서 총 작업 개수를 나눠 평균 대기시간을 구한다.
- 반례를 본 이유가 1-2 때문이다.
- 첫번째 아이디어에서 두번째 아이디어로 코드를 변경할 때 뇌리에 남아있던 저 부분을 망각해버려서 작업이 절대 있을 수 없는 빈 시간이 생길 경우 더 이상 진행을 하지 않았다. (예제는 그런 일이 없어서 빼먹은걸 반례 보기 전까지 알 수가 없었다)
import heapq

def solution(jobs):
    answer = 0
    
    heap = []
    original = []
    
    for i in range(len(jobs)):
        heapq.heappush(original, (jobs[i][0], jobs[i][1]))
        
    current_time = original[0][0] + original[0][1]
    answer = current_time - original[0][0]
    heapq.heappop(original)
    while True:
        while len(original) != 0 and original[0][0] <= current_time :
            temp = heapq.heappop(original)
            heapq.heappush(heap, (temp[1], temp[0]))
            
        if len(heap) == 0:
            if len(original) == 0:
                break
            current_time = original[0][0]
            temp = heapq.heappop(original)
            heapq.heappush(heap, (temp[1], temp[0]))
            continue
            
        temp = heapq.heappop(heap)
        current_time += temp[0]
        answer += current_time - temp[1]

    answer //= len(jobs)
    return answer

풀이 사이트: 프로그래머스 고득점 Kit! 

# 완전 탐색

📝 피로도 (lv 2) - https://school.programmers.co.kr/learn/courses/30/lessons/87946

* 체감 난이도 : ⭐️⭐️⭐️ - -

- 체감 난이도가 별 3개나 되는 건 완전 탐색을 굉장히 오랜만에 풀어서이다.
- 현재 방문 가능한 모든 던전을 무작위 순서로 방문해보는 모든 경우의 수를 알아야했다.
- 재귀함수를 이용해서 방문체크를 하면서 현재 피로도 상태에서 중복되지 않는 던전을 모두 방문하는 방식으로 코드를 짰더니 백트래킹 형태가 되었다.
- 그래서 게시판에서 사람들의 코드를 확인해보니, 까맣게 잊고 있던 완전 탐색을 위해 백트래킹을 활용하기도 한다는 점이 떠올랐다.
- 풀고나니 백트래킹 느낌이라 이후로도 기억이 한 번에 나길 바라는 마음에서 3개로 채택했다.
def solution(k, dungeons):
    answer = -1
    visit = [0] * len(dungeons)
    answer = find(visit, dungeons, 0, k)
    return answer

def find(visit, dungeons, n, energe):
    temp = n
    temp = max(n, temp)
    for i in range(len(visit)):
        if visit[i] == 0 and energe >= dungeons[i][0]:
            visit[i] = 1
            temp = max(temp, find(visit, dungeons, n+1, energe-dungeons[i][1]))
            visit[i] = 0
    return temp

 

📝 전력망을 둘로 나누기 (lv 2) - https://school.programmers.co.kr/learn/courses/30/lessons/86971

* 체감 난이도 : ⭐️⭐️- - -

- 트리를 둘로 분리 되었을 때, 두 서브 트리의 차이가 가장 작은 엣지를 알기 위해선 모든 서브 트리의 크기를 알아야한다.
- 모든 서브트리의 크기를 알아내기 위해서, 재귀 함수와 방문체크를 이용했다.
- 재귀 함수를 통해 리프노드까지 내려간 후 다시 루트노드까지 올라오면서, 현재 노드의 모든 서브노드의 크기를 더하는 방식으로 구현했다.
- 진행 중 현재 노드가 루트인 서브 트리의 크기가 구해지면, 현재 노드를 기준으로 자른다고 가정했을 때 생성되는 두 서브 트리 크기의 차이를 계산해서 가장 값이 작은 결과를 answer로 채택했다.
temp_answer = 101

def solution(n, wires):
    answer = -1
    arr = [[0] * (n+1) for _ in range(n+1)]
    
    for i in range(len(wires)):
        arr[wires[i][0]][wires[i][1]] = 1
        arr[wires[i][1]][wires[i][0]] = 1
        
    visit = [0] * (n+1)
    
    visit[1] = 1
    find(arr, visit, 1, n)
    answer = temp_answer
    return answer

def find(arr, visit, n, target):
    temp = 1
    global csb
    
    for i in range(len(visit)):
        if visit[i] == 0 and arr[n][i] == 1:
            visit[i] = 1
            temp += find(arr, visit, i, target)
    
    temp_answer = min(temp_answer, abs(target - temp - temp))
    return temp

풀이 사이트: 프로그래머스 고득점 Kit! 

# 해시

📝 의상 (lv 2) - https://school.programmers.co.kr/learn/courses/30/lessons/42578

* 체감 난이도 : ⭐️ - - - -

- 해쉬의 매우 기본적인 문제였다.
- 옷의 종류가 정해져 있고, 중복 된 종류의 옷은 함께 입을 수 없기 때문에 구분해서 저장해야 했다.
- 그렇기 때문에 해쉬를 사용해야하며, Key는 옷의 종류로 하여 저장한다.
- 종류마다 옷들을 리스트 형태로 저장할 필요 없이 단순히 개수만 카운트하면 된다. (얼굴 : 2, 상의 : 3, 하의 : 1 ..)
- 이유는 옷의 경우의 수를 생각보면 알 수 있다.
- 단순하게 생각해봤을때 상의 3가지, 하의 2가지가 있으면 단순 조합수는 6가지이다(2x3).
- 하지만 모든 부위를 입을 필요는 없기 때문에 상의를 입지 않았을 때, 하의를 입지 않았을 때를 포함한 11가지가 답이다.
- 나는 맨살(옷을 입지 않음)을 하나의 옷 종류로 생각하여 상의 4가지, 하의 3가지로 생각했다.
- 하지만 여기서 단 하나의 예외 "하나의 옷은 입어야한다"에 해당하는 경우 1가지를 제외시켜야한다.
- 즉, 4 x 3 - 1  = 12라는 값을 구할 수 있다.
- 딕셔너리를 이용해 단순히 종류별 옷의 개수만 세고, 위의 공식을 이용해서 답을 구했다.
def solution(clothes):
    answer = 1
    
    dic = {}
    
    for i in range(len(clothes)):
        temp_key = clothes[i][1]
        
        if temp_key in dic:
            dic[temp_key] = dic[temp_key] + 1
        else:
            dic[temp_key] = 1
            
    for key in dic:
        answer *= dic[key] + 1
    
    answer -= 1
    
    return answer

 

📝 베스트 앨범 (lv 3) - https://school.programmers.co.kr/learn/courses/30/lessons/42579

* 체감 난이도 : ⭐️⭐️ - - -

- 이게 왜 lv 3인지 당최 모르겠고, 분류도 사실상 정렬 문제라고 생각한다.
- "총 재생 수를 기준으로 장르를 수록", "장르별 많이 재생된 노래 2가지를 수록 (재생수가 같을 경우 고유번호가 작은 순)"라는 2가지 기준을 위해 딕셔너리 두 가지에 장르별 총 재생수와 장르별 각 노래 정보 (고유번호, 재생 수)의 형태로 리스트에 저장했다.
- 풀이는 단순하게 총 재생수로 정렬한 후 두 번째 딕셔너리에서 우선순위가 높은 두 가지 곡의 정보를 뽑아내어 순서대로 answer에 저장하면 된다.
- 요건은 두 번째 딕셔너리에 담긴 각 장르별 노래 정보 리스트를 정렬하는 것이다.
- 나는 cmp_to_key를 지정하여 각 노래 정보 리스트를 정렬했다.
- 먼저 두 곡의 재생 수를 비교하고, 같을 경우 고유번호를 비교하여 더 작은 번호에 우선 순위를 주는 방식으로 설정했다.
- 이후 총 재생수를 기준으로 정렬된 순서에 따라 장르별 노래 2가지를 순서대로 answer에 저장하여 해결했다.
from functools import cmp_to_key

def solution(genres, plays):
    answer = []
    
    dic_count = {}
    dic_inform = {}
    
    for i in range(len(genres)):
        if genres[i] in dic_inform:
            dic_count[genres[i]] += plays[i]
            dic_inform[genres[i]].append([i, plays[i]])
        else:
            dic_count[genres[i]] = plays[i]
            dic_inform[genres[i]] = [[i, plays[i]]]
    sort_genres = sorted(dic_count.items(), key=lambda x: x[1], reverse=True)
    
    for i in range(len(sort_genres)):
        temp_genres = sort_genres[i][0]
        sorted_list = sorted(dic_inform[temp_genres], key=cmp_to_key(compare))
        for j in range(min(len(sorted_list), 2)):
            answer.append(sorted_list[j][0])
        
    return answer

def compare(x, y):
    if x[1] > y[1]:
        return -1
    elif x[1] < y[1]:
        return 1
    else:
        if x[0] < y[0]:
            return -1
        else:
            return 1

 

 

⭐️ 목적

현재 진행 중인 캡스톤 프로젝트에 핵심 기능 중 하나가 AI를 이용한 자동 답변 기능이다.
 
ChatGPT API를 활용하여 답변 생성을 하고자 했는데, AI 서버를 분리한 상태이기 때문에
스프링부트 프로젝트에 연결해서 자동 답변 기능을 구현해야 했다.
 
 

✅ 구현 과정 (간략)

# 연결 로직 구상

> 첫 번째 아이디어: 즉시 응답

단순하게, 질문글 작성을 요청할 경우 글의 내용을 AI 서버에 전송하여,
ChatGPT API를 이용하여 답변을 생성하고 해당 답변을 응답으로 받아 작성하는 방식이다.
 
하지만, 이런 방식으로 구현할 경우 챗 지피티의 답변 생성 시간만큼 질문글 생성에 딜레이가 생기게 되고,
응답 시간의 딜레이가 생긴다는 것은 서비스 측면에서 일어나선 안 될 일이기 때문에 개선이 필요했다.
 

> 두 번재 아이디어: 추후 응답

이 방법 또한 자동 답변 요청 시점은 질문글 작성 요청 순간으로 같다.
 
하지만 딜레이 발생을 최소화하기 위해 AI 서버측에 요청을 보내고 난 후 AI 서버에서 답변을 따로 생성을 하고,
답변이 생성 되고나면 서비스 서버쪽으로 답변글 작성 요청을 보내는 방식이다.
 
답변글 작성 API는 이미 구현해뒀기 때문에 이 API에 생성된 답변 내용을 담아 요청을 보내면
추가 API를 생성할 필요도 없고, 딜레이를 최소화하여 질문글을 작성할 수 있기 때문에 이 방식을 채택했다.
 
이 과정을 간단한 흐름도로 표현해 보면 다음과 같다.

 

# Feign Client 구현

AI 서버에 답변 생성 요청을 위해 이전에 디스코드 웹훅 연결 시 활용했던 Feign Client를 이용하여 구현했다.
 
클라이언트는 다음과 같이 구현했다.

@FeignClient(name = "${ai.bot.name}", url = "${ai.bot.endpoint}")
public interface AIBotClient {
    @PostMapping
    void sendAutoAnswerRequest(@RequestBody AutoAnswerRequest request);
}

 
 

✅ 트러블 슈팅

# AI가 답변글 작성 API를 어떻게 사용해야할까?

서비스에 jwt를 이용하여 특정 API를 요청할 시 권한에 필터링 되도록 설정해놨다.
 
AI 서버에서 답변글 작성을 위해선 이 점을 해결해야 했기 때문에 추가적인 고민이 필요했다.
 

> 첫 번째 구현: 질문을 요청한 계정 정보를 활용해서 엑세스 토큰 발급

현재 서비스에는 고등학생, 대학생 두 가지 회원 타입이 존재한다.
 
질문글은 고등학생 회원, 답변글은 대학생 회원만 작성이 가능하도록 구현해두었다.
 
즉, 답변글을 작성하기 위해선 대학생 타입의 엑세스 토큰과 함께 작성 API를 요청해야만 한다.
 
나는 구현 방식을 고민하다가 이 점을 망각한 채 어떻게 든 임시 엑세스 토큰을 발급할 방법만 찾아당겼고,
질문글 작성을 요청한 고등학생 계정 정보를 이용하여 엑세스 토큰을 생성하고 AI 서버로 질문 내용과 함께 전송하는 방식으로 구현했다.
 
나름 괜찮은 아이디어라고 생각한 채 신나서 PR 후 배포를 진행했고,
권한 없는 유저의 요청이라는 오류 메시지를 보게되었다.
 
 

> 두 번째 구현: AI용 계정을 따로 생성하여 활용

결국 임시 엑세스 토큰을 생성할 수 있는 대학생 타입의 계정이 필요하다고 판단했다.
(답변 작성시 나타나는 계정 문제도 있었다)
 
대학생 타입을 가진 AI 더미 계정을 따로 생성해야겠다는 결론을 내렸고,
어떤 방식으로 AI 계정을 데이터베이스에 저장해 둘지 고민해야했다.
 
처음에는 수동으로 RDS의 데이터베이스에 저장할까 했지만,
데이터베이스 초기화가 일어날 때마다 수동으로 데이터를 추가하는 것은 별로라는 생각이 들어 ApplicationRunner를 활용하기로 했다.
 
ApplicationRunner 인터페이스를 extend하고 run 메소드를 오버라이드하여,
원하는 코드를 적으면 해당 코드가 스프링부트 서버 시작 시 동작하도록 구현할 수 있다.
 

@Component
public class TestRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
    	// 원하는 동작
    }
}

 
이를 이용해서 서버를 시작할 때 멤버에 AI 계정이 존재하지 않으면,
이메일 인증 때문에 만들었던 팀 구글 계정을 사용해서 AI 계정을 생성하고 데이터베이스에 저장하도록 구현했다.
 
이제 질문글 작성시 AI 계정을 활용해서 엑세스 토큰을 생성하고 자동 답변 요청시 포함시키도록 로직을 변경했다.
 

# 자동 답변 글이 안 써진다

질문글을 생성하면 제대로 된 엑세스 토큰과 질문 내용을 포함시킨 자동 답변 요청이 AI 서버로 전달됐지만,
AI 서버에서 답변을 생성하고 답변글 작성 요청을 하면 존재하지 않는 질문글이라는 오류가 발생했다.
(답변글은 질문글에 달리는 것이기 때문에 자동 답변 생성 요청시 질문글의 id를 포함시켜 전송한다)
 
로직상에 문제는 보이지 않았고, 데이터 또한 원하는데로 정상적으로 전달됐지만 답변글 작성만 작동하지 않았다.
 
한참을 AI 파트와 상의한 끝에 AI 서버 구현 코드 문제라고 판단했고
코드를 전달받아 확인해 본 결과 답변 생성 요청을 받은 후 즉시 200 응답을 보낸 후 백그라운드에서 생성해야 했지만,
그 부분이 수정이 누락되어 문제가 발생한 것이었다.
 
응답을 즉시 전달하지 않고 답변 생성 후 답변글 작성 요청을 서비스로 한 후 응답을 보내주기 때문에
그 시간동안 서비스 측은 블록킹 되어 글 작성이 완료되지 않았고,
작성이 완료되지 않은 질문글 ID로 답변 생성을 요청하여 질문글이 존재하지 않는다는 오류가 발생한 것이었다.

 
 
문제의 부분을 쓰레드를 이용해서 답변 생성을 백그라운드로 하는 방식으로 리펙토링 했고,
원하는 동작을 성공적으로 완성시킬 수 있었다.
 
 

✅ 추후 개선점

# 질문글 작성시 자동 답변 요청을 하는 방식에는 문제가 많다

현재 AI측 답변글 생성 요청 전에 질문글 생성이 선행된다는 로직이 존재하지 않기 때문에
만약 ChatGPT 등의 AI들이 너무 일을 빨리하거나 서비스 서버측이 모종의 이유로 퍼포먼스가 떨어지는 경우,
이전과 같은 질문글을 찾지 못하는 문제가 발생하게 된다. (그럴 일은 거의 없을 것이라 생각하긴 한다)
 
현재 생각 중인 로직 변경 방식은 두 가지가 있다.
1. 임시 방식:
AI 서버 측 코드에 작성 요청에 대해 응답 코드가 온 경우에는 최대 x번의 리트라이를 하도록 구현한다.
2. 목표 방식:
현재 방식은 매 질문글 작성 때마다 챗 지피티 API를 호출하기 때문에 비용상의 문제가 발생할 것이다.
질문글 작성 후 특정 시간이 지나도 답변이 달리지 않는 질문글을 대상으로 자동 답변이 작성도록 구현한다.
 
목표긴 하지만 두 번째 방식대로 구현만 성공하면, 비용 문제와 글 작성 순서 문제 모두 해결할 수 있다.
현재는 임시 방식을 이용해서 글 작성 순서 문제를 해결하도록 할 것이다.
 
 

✅ 공부 내용

# 별거 아니긴 하지만 ApplicationRunner 선택 이유

스프링부트에서 서버 동작 시 실행할 코드를 지정하는 방법에는 CommandLineRunner와 ApplicationRunner가 있다.
 
두 인터페이스는 Runner 인터페이스를 상속하며 run 메소드를 사용하는 형태가 동일하지만,
CommandLineRunner는 스트링 타입의 가변 인자만 받을 수 있지만
ApplicationRunner는 다양한 타입의 가변 인자를 받을 수 있다는 차이점이 존재한다.
 
사실 이번 구현에서는 인자를 활용할 일이 없어서 두 인터페이스 모두 상관 없었지만,
이왕 사용하는거 좀 더 유틸성이 좋은 ApplicationRunner를 사용하게 되었다.
 
여담으로 ApplicationRunner의 경우 스프링부트 서버 실행 완료 후에 run 메소드를 호출하는데,
서버 실행 완료 시점은 SpringApplication.run() 실행이 완료 된 시점으로 판단한다고 한다.
(서버는 SpringApplication.run() 완료 후 트래픽을 받는 등의 동작이 가능하게 된다고 한다)
 

⭐️ 목적

이메일 전송 기능은 인증코드를 통한 인증을 위해서 필요해졌다.
 
플랫폼 정책상 랜덤으로 생성된 인증 코드를 이메일로 전송하여 검증하는 방식을 채택했기 때문에
프로젝트에 이메일 전송 기능을 구현해야만 했다.
 
 

✅ 구현 과정 (간략)

1. 구글 메일 설정

이메일 전송에 사용할 SMTP 서버를 설정해야한다.
 
나의 경우 프로젝트 전용 구글 계정을 새로 생성하여 구글 SMTP 서버를 사용했다.
 
구글 계정을 통해 SMTP 서버를 사용하기 위해선 별도의 설정이 필요하다.
 
구글 메일 설정에 들어가서 다음과 같이 설정한다.

 

2. 구글 메일 연동

구글 보안 상 연동을 위해선 2단계 인증이 된 계정의 앱 비밀번호를 설정하여 사용해야 한다.

 
먼저 스프링부트 build.gradle에 메일 전송을 위한 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-mail'

 
스프링부트 application.yml에 앱 비밀번호로 Config를 추가한다.

spring:
	mail:
    	host: smtp.gmail.com
    	port: 587  // 구글 SMTP 서버 기본 포트
    	username: 계정
    	password: 앱 비밀번호
    	properties:
      	mail:
        	smtp:
          	auth: true
          	timeout: 5000
          	starttls:
            	enable: true

 
위와 같이 작성할 경우 JavaMailSender 생성시 MailSenderAutoConfiguration로 인해 디폴트 값으로 설정된다.
 

2. 메일 전송

JavaMailSender를 이용할 경우 MimeMessage를 이용해 메일 내용 작성 후 전송한다.
(메시지 생성시 MimeMessageHelpler 이용)
 
수신자와 메일 내용을 지정하여 send 메소드를 이용하면 메일이 성공적으로 전송된다.

public String sendMail(String mail) {
        String code = createCode();
        try {
            MimeMessage message = javaMailSender.createMimeMessage();
            MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message, false, "UTF-8");
            mimeMessageHelper.setTo(mail);
            mimeMessageHelper.setSubject("이메일 인증");
            String body = "";
            body += "<h3>" + "이메일 인증을 위한 인증코드입니다." + "</h3>";
            body += "<div style='border: 2px solid black; padding: 10px; display: inline-block;'>" +
                    "<h1>" + code + "</h1>" +
                    "</div>";
            body += "<h3>" + "6자리 코드를 정확히 입력해주세요!" + "</h3>";
            mimeMessageHelper.setText(body, true);
            javaMailSender.send(message);
        } catch (MailException | MessagingException e) {
            throw new BusinessException(ErrorCode.EMAIL_SEND_FAILURE);
        }
        return code;
    }

 
실제 예시

 
 

✅ 고민한 부분

# 별거 아니긴 하지만 Mail Sender 인터페이스 선택

스프링부트에서 사용하는 Mail Sender 인터페이스에는 크게 두 가지가 있다.

  1. MailSender
  2. JavaMailSender (MailSender 확장)

이번 프로젝트에서 JavaMailSender를 이용했는데,
그 이유는 MimeMessage를 이용하기 때문이다.
 
대표적으로 스프링부트에서 사용하는 메시지 클래스로는 SimpleMailMessage, MimeMessage가 있는데,
MimeMessage는 텍스트뿐만 아니라 HTML 형식, 첨부 파일, 이미지 등의 복잡한 이메일을 보낼 수 있다.
 
서비스를 개발하기 때문에 메일을 이쁘게(?) 꾸밀 필요성이 있어서 MimeMessage를 이용하고자 했고,
그래서 JavaMailSender를 이용하게 되었다.
 
 

✅ 트러블 슈팅

# 앱 비밀번호 설정

처음에는 그냥 구글 계정 비밀번호로 설정했더니 메일이 보내지지 않았다.
 
그래서 검색을 해보니 앱 비밀번호가 필요하다 하여 이에 대해 알아보았다.
 
구글 공식 문서에는 다음과 같이 명시하고 있다.

앱 비밀번호란 보안 수준이 낮은 앱 또는 기기에 Google 계정에 대한 액세스 권한을 부여하는 16자리 비밀번호입니다. 앱 비밀번호는 2단계 인증이 사용 설정된 계정에서만 이용할 수 있습니다.

 
한 마디로, 스프링부트에 연동하게 될 경우 보안 수준이 낮아서
계정 비밀번호가 아닌 따로 앱 비밀번호를 설정해서 사용해야 한다는 것이다.
 
 

✅ 공부한 내용

# SMTP 서버란?

SMTP(Simple Mail Transfer Protocol)이란 인터넷을 통해 이메일 메시지를 보내고 받는 데 사용되는 통신 프로토콜을 말한다.
 
SMTP 서버는 발신 메일 서버라고 불리며, SMTP를 이용하여 메일을 전송(처리)하는 서버를 말한다. (오직 발신에 대해서만 관여한다.)
 
SMTP 작동 방식에 대해서도 알아보았다.
 
SMTP 서버는 SMTP 클라이언트로부터 이메일을 전달받아 수신자로 이메일을 전송한다. (전송 과정은 생략)
여기선 스프링부트 프로젝트가 SMTP 클라이언트, 구글 SMTP 서버가 SMTP 서버가 된다.
 
SMTP 클라이언트는 SMTP 서버와 연결을 시작하고,
수신자 세부 정보, 제목 및 본문이 포함된 이메일을 전송한다.
 
이후 SMTP 서버는 수신자 주소를 기반으로 적합한 다음 서버를 결정하여 전송한다.
 

# 구글 SMTP 보안 설정

위에서 언급한 application.yml에 작성한 SMTP 관련 설정 중 보안과 관련 된 것은 다음 부분이다.

	smtp:
          	auth: true
          	timeout: 5000
          	starttls:
                  	enable: true

 
auth: true이메일 송신 시 서버에서 사용자 인증(계정과 앱 비밀번호를 사용)을 요구하게 하는 것이다.
 
starttls 는 TLS를 사용하여 이메일 송신 전에 평문 통신을 암호화된 통신으로 업그레이드하여 송신자의 정보가 노출되지 않도록 한다.
 
여기서 TLS란 뭘까?

SSL: SSL는 웹사이트와 브라우저 사이(또는 두 서버 사이)에 전송되는 데이터를 암호화하여 인터넷 연결을 보호하기 위한 표준 기술
TLS: TLS는 정보를 암호화해서 송수신하는 프로토콜로 SSL의 향상되고 안전한 버전

 
정리해보자면, SMTP는 TLS를 이용해서 메일 전송 시 보안을 유지한다.
 

# 궁금해서 JavaMailSender 알아보기

난 단순히 application.yml에서 spring.mail 아래에 값을 지정 했을 뿐인데,
JavaMailSender 생성 시 알아서 설정값(호스트, 유저, 비밀번호 등)이 설정 되는지 궁금해져서 알아봤다.
 
답은 메일을 보내기 위해 의존성을 추가한 모듈인 spring-boot-starter-mail 에 있었다.
 
MailSenderAutoConfiguration 클래스는 spring-boot-starter-mail 모듈에 포함 돼 있는 클래스다.
 
해당 클래스는 자동 구성 클래스로 spring.mail에 있는 속성을 읽고, 이를 사용하여 JavaMailSender를 설정한다.

@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class MailSenderAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(JavaMailSender.class)
    public JavaMailSender javaMailSender(MailProperties properties) {
        JavaMailSenderImpl sender = new JavaMailSenderImpl();
        sender.setHost(properties.getHost());
        sender.setPort(properties.getPort());
        sender.setUsername(properties.getUsername());
        sender.setPassword(properties.getPassword());
        sender.setJavaMailProperties(asProperties(properties.getProperties()));
        return sender;
    }
}

 
즉 어떤 Bean 객체에서 JavaMailSender 주입 시 내부적으로 알아서 매핑 된다는 소리다.

⭐️ 목적

웹훅 연동의 필요성은 신고하기 기능 구현으로부터 나타났다.

 

사용자가 신고를 했을 경우 운영자가 직접 처리해야 했기 때문에
운영측을 위한 알림 기능이 필요했다.

 

알람 구현 방법은 두 가지가 있었다.

  1. Amazon Lambda
  2. Slack 또는 Discord 웹훅 연동

아무래도 아마존 람다의 경우 러닝커브가 굉장히 클 것이라는 팀원의 의견을 수용해서
웹훅을 연동하기로 결정했다.

 

슬랙과 디스코드 중에 결정했어야 했는데 프로젝트 소통 플랫폼이 디스코드였기 때문에
자연스럽게 디스코드 웹훅을 연동하게 되었다.

 

 

✅ 구현 과정 (간략)

1. 연동할 디스코드 채널 생성 및 설정

 

메시지를 받고 싶은 채널을 생성하고 웹후크 설정을 한다.

 

설정한 웹후크의 URL을 사용하여 스프링부트 프로젝트에 연동할 것이다.

 

2. 스프링부트 application.yml에 Config 추가

discord-report-bot:
  name: discord-report-client
  webhook-url: 웹 후크 URL 복붙

 

나는 신고하기 알림용을 쓸 것이기 때문에 이름을 임의로 저렇게 지었다.

 

3. 디스코드 HTTP Client 생성

디스코드에 메시지를 전송하기 위해선 HTTP POST 요청을 보내야 한다.

 

Feign Client를 이용하여 디스코드 클라이언트를 생성하고,

전송용 POST 요청 메소드를 작성하고 이를 이용하면 된다.

@FeignClient(name = "${discord-report-bot.name}", url = "${discord-report-bot.webhook-url}")
public interface DiscordClient {
    @PostMapping
    메시지 전송 함수 구현...
}

 

참고로 디스코드는 요청 본문에 JSON만을 허용한다.

{
	"content": "message"
}


바로 content에 본인이 원하는 메시지를 집어넣어 전송하면 된다.

(디스코드는 마크다운을 지원하기 때문에 기호에 따라 휘뚜루마뚜루 사용하면 된다)

 

실제 예시

 

 

 

✅ 고민한 부분

# HTTP Client 선택

클라이언트를 어떤 방식으로 생성할 지 여러 구현 코드들을 보면서 고민해봤다.

 

구현에서 보통 RestTemplate, Feign Client 이 두 가지를 사용하고 있었다.

 

둘 중 Feign Client를 선택한 이유는

  1. RestTemplate에 비해 코드가 간결하고 직관적이다 (= 코드짜기 쉽다)
  2. 요청 인터페이스가 매우 간단하다 (기존 스프링부트 어노테이션처럼 매핑)

이다.

 

 

✅ 트러블 슈팅

# Feign Client 활성화

단순히 @ForignClient로 클라이언트 정의하고 사용하면 되는줄 알았더니

직접 메인 어플리케이션 클래스에서 활성화 시켜줘야 한다.

@EnableFeignClients("com.gdyd.gdydsupport")
public class ExampleApplication {
    public static void main(String[] args) {
        SpringApplication.run(ExampleApplication.class, args);
    }
}

 

 

 

✅ 공부한 내용

# 웹훅

구현 목적은 스프링부트 프로젝트에 디스코드라는 추가 기능을 가진 서브 파티를 붙이는 것이다.

 

만약 프로젝트가 클라이언트의 관점에서 메시지를 보낼 경우, 디스코드의 상태에 따라 영향을 받게 되는

다시 말해 의존성이 생겨 주종관계가 역전되는 문제가 발생한다.

 

즉, 스프링부트 프로젝트가 서버, 디스코드가 클라이언트의 역할이 되어야만 한다.

 

하지만 클라이언트의 요청 -> 서버의 응답이 일반적인 흐름이기 때문에

디스코드를 통해 이벤트 알림을 받기 위해선, 끊임없는 요청을 날려야한다.

 

디스코드가 이벤트가 발생했는지 계속해서 프로젝트에게 질문을 해야한다는 것이다.

 

이런 경우 웹훅을 사용하면 된다.

 

웹훅 정의를 보면, 웹훅은 '웹페이지 또는 웹앱에서 발생하는 특정 행동(이벤트)들을 커스텀 Callback으로 변환해주는 방법'을 말한다.

(Callback: 특정 이벤트가 발생하거나 작업이 끝난 후, 미리 정의된 동작을 수행하도록 예약된 함수)

 

웹훅을 이용하게 되면 서버에서 이벤트가 발생할 경우 클라이언트를 호출하게 된다. (그래서 역방향 API라고 불린다)

 

여기서 서버가 클라이언트를 호출하기 위해 지정한 주소를 CallbackURL이라고 하며,

이번 케이스에서는 디스코드 웹후크 URL이 여기에 해당한다.

본 카테고리의 캡스톤 프로젝트를 스프링부트로 진행하면서 익힌 기술들에 대한 기록이다.

 

최근 구현하는 내용에 대한 기록과 함께 이전에 했던 내용들을 생각나는데로 작성해 볼 예정이다.

+ Recent posts