컴퓨터 시스템에서 여러 프로세스가 동시에 실행될 때, 공유 자원에 대해 여러 프로세스에서 동시 접근이 가능하다면 데이터 불일치 등의 문제가 발생할 수 있습니다. 이 때 공유 자원에 대해 동시 접근이 발생 가능한 영역을 임계 영역(Critical section)이라 부르며, 이 임계 영역에서 비롯되는 문제들을 막으려면 다음과 같은 요구사항을 만족해야 합니다.
1. 상호 배제 (Mutual Exclusion)
: 한 번에 하나의 프로세스만이 임계 구역에 들어갈 수 있어야 합니다.
2. 진행 (Progress)
: 임계 구역에 들어가려는 프로세스가 없을 때, 어느 프로세스도 임계 구역에 들어갈 수 있어야 합니다.
3. 한정 대기 (Bounded Waiting)
: 각 프로세스는 임계 구역에 들어가기 위해 무한정 기다리지 않아야 합니다.
피터슨의 알고리즘(Peterson's Algorithm)은 위 요구사항들을 만족하는 고전적인 알고리즘으로, 두 프로세스 간의 상호 배제를 위해 만들어졌습니다. 1980년대에 게리 피터슨(Gary L. Peterson)에 의해 제안됐으며, 상호 배제 문제를 간단하고 효율적으로, 이해하기 쉽고 최소한의 변수만을 사용하여 구현할 수 있다는 장점이 있습니다.
피터슨의 알고리즘은 flag 배열과 turn 변수를 사용하여 두 프로세스가 임계 영역에 동시에 진입(즉 경쟁 상태 발생)하는 것을 방지합니다. flag 배열은 각 프로세스가 임계 구역에 들어가기를 원하는지를 표시하며, turn 변수는 임계 구역에 들어갈 차례인 프로세스를 나타내는데요. 이 간단한 구조 덕에 직관적이고 구현하기 쉬운 상호 배제 해결책으로 널리 알려져 있습니다.
피터슨의 알고리즘 동작 순서
피터슨의 알고리즘은 다음과 같은 순서로 각 작업이 순서대로 임계 영역에 들어가게 합니다.
1. 임계 구역 진입 요청
: 프로세스가 임계 구역(critical section)에 들어가고자 할 때, 자신의 flag를 true로 설정하여 진입 의사를 표시합니다.
2. 양보 설정
: 프로세스는 turn 변수를 상대방 프로세스의 번호로 설정하고, 이를 통해 상대방 프로세스에게 양보할 의사를 나타냅니다.
3. 대기 상태
: 프로세스는 상대방 프로세스의 flag가 true이고, turn이 상대방 프로세스의 번호인 동안 계속해서 기다립니다(상대방 프로세스가 임계 구역에 들어가고자 할 경우 상대방에게 우선권을 주기 위함)
4. 임계 구역 진입
: 상대방 프로세스가 임계 구역에 들어가고자 하지 않거나, turn이 자신의 번호로 설정될 때까지 기다린 후 임계 구역에 진입합니다.
5. 임계 구역 종료
: 임계 구역에서의 작업을 마치면, 자신의 flag를 false로 설정하여 임계 구역에서 나왔음을 알립니다.
6. 비임계 구역 작업
: 비임계 구역에서 다른 작업을 수행합니다.
코드로 나타내면 다음과 같습니다.
// 변수 선언
boolean flag[2] = {false, false};
int turn;
function process0() {
while (true) {
// 1. 임계 구역 진입 요청
flag[0] = true;
// 2. 양보 설정
turn = 1;
// 3. 대기 상태
while (flag[1] == true && turn == 1) {
// busy-wait
}
// 4. 임계 구역 진입
// 임계 구역에서 수행할 작업
print("Process 0 in critical section")
// 5. 임계 구역 종료
flag[0] = false;
// 6. 비임계 구역 작업
// 비임계 구역에서 수행할 작업
print("Process 0 in non-critical section")
}
}
function process1() {
while (true) {
// 1. 임계 구역 진입 요청
flag[1] = true;
// 2. 양보 설정
turn = 0;
// 3. 대기 상태
while (flag[0] == true && turn == 0) {
// busy-wait
}
// 4. 임계 구역 진입
// 임계 구역에서 수행할 작업
print("Process 1 in critical section")
// 5. 임계 구역 종료
flag[1] = false;
// 6. 비임계 구역 작업
// 비임계 구역에서 수행할 작업
print("Process 1 in non-critical section")
}
}
피터슨의 알고리즘 문제점
1. 두 개 프로세스에서만 적용 가능
: 피터슨의 알고리짐은 기본적으로 두 개의 프로세스 사이에서만 상호 배제를 보장합니다. 2개보다 많은 프로세스를 다룰 경우 알고리즘을 확장해야 하지만, 코드가 복잡해지고 효율성이 떨어질 수 있습니다.
2. Busy waiting
: 피터슨의 알고리즘은 다른 프로세스가 임계 영역에 들어가있는 동안 다른 프로세스는 무한 루프를 통해 대기(Busy waiting)하는데, 이는 CPU 자원의 낭비 및 효율성 저하의 원인이 됩니다. 또한 실시간 시스템인 경우, Busy waiting은 일종의 지연 현상을 초래할 수도 있습니다.
대규모 언어 모델(LLM, Large Language Model)을 기반 앱 구축을위한 오픈 소스 프레임워크를 말합니다. 원래 ChatGPT, LLama 등의 LLM을 활용한 애플리케이션을 구축하려면 다음과 같은 작업들을 해줘야 합니다.
1) 여러 LLM별로 인터페이스 별도 구성
2) LLM에게 했던 이전 대화 내용 기록을 별도로 저장 및 LLM에게 제공(컨텍스트를 맞춘다고도 합니다)
3) 각종 Data source로부터 데이터를 수집해 LLM을 튜닝 또는 LLM에게 질의하는 파이프라인을 직접 구성
4) 등등..
즉 랭체인은 이렇게 LLM 기반의 앱 개발을 좀 더 편하고 빠르게 해줄 수 있도록 하는 "프레임워크"라고 보면 됩니다.
랭체인 구성 요소
1) LLM 추상화
우리가 객체지향 프로그래밍 시간에 배웠던 추상화 개념과 일치합니다. 자동차를 운전할 때, 어떤 차를 운전하든 핸들을 왼쪽으로 꺾으면 차가 왼쪽으로 가고 핸들을 오른쪽으로 꺾으면 차가 오른쪽으로 간다는 것을 우리는 알고 있습니다. 핸들의 움직임에 따라 내부적으로 어떻게 동작하는지는 우리가 알 필요가 없죠. 어떤 차든간에 핸들의 방향에 따라 차가 나아간다는 것만 알면 됩니다.
마찬가지로 현재 세상에 나온 여러 LLM들은 서로 특성 등이 상이하기 때문에, 애플리케이션에서 사용할 LLM별로 특성 등을 파악하는 것은 매우 번거롭고 힘든 일입니다. 따라서 랭체인은 LLM 추상화를 통해 어떤 LLM을 사용하던 간에 동일한 인터페이스를 우리에게 제공해줌으로써, 우리는 내부적으로 어떤 일이 일어나는지 신경쓸 필요 없이 앱을 구축할 수 있게 해줍니다.
2) 프롬프트
프롬프트는 LLM에 전달하는 명령어를 말합니다. 특히 랭체인에는 "프롬프트 템플릿 클래스"라는 것이 있습니다. LLM에게 전달할 컨텍스트와 쿼리, 출력 형식 등을 직접 수동으로 작성할 필요 없이 자동으로 프롬프트의 구성을 구조화(템플릿화)해주는 것이라고 이해하면 됩니다.
3) 체인
LLM 기반 앱은 단순히 LLM에게 뭔가를 질의하고 끝나기보다는 여러 태스트들이 이어진 형태로 동작하는 경우도 많습니다. 예를 들어
1. 특정 데이터 검색
2. 검색된 데이터 요약
3. 요약된 데이터 기반으로 LLM에게 질의
체인은 이 일련의 Task들이 서로 연결될 수 있도록 해주는 것이라고 이해하면 됩니다.
4) 인덱스
LLM들은 특정 날짜까지만(ex: 2023년 4월 등)의 데이터를 학습했기 때문에 상황에 따라 잘못된 답변을 내놓을 수 있습니다(이를 할루시네이션이라고도 부릅니다). 이 때 LLM에게 외부의 데이터를 직접 학습시켜서 보다 정확한 답을 내놓을 수 있도록 증강시킬 수 있는데 이를 RAG(Retrieval-Augmented Generation)이라 부릅니다. 이 RAG를 위해서는 외부 데이터 소스에 접근할 수 있는 기능이 필요하며, 랭체인에서는 이를 지원합니다. 이때 랭체인에서 이 외부 데이터들을 쉽게 탐색할 수 있도록 구조화하는 모듈을 인덱스라고 부릅니다.
5) 메모리
사용자가 프롬프트로 LLM과 질의를 주고받는 동안, 그 대화의 내용(컨텍스트)을 기억해 향후 적용할 수 있어야 합니다. 기본적으로 LLM은 채팅으로 직접 이전 대화에서의 컨텍스트를 전달해야 하지만, 랭체인은 애플리케이션에 "메모리"를 추가해 직접 채팅으로 컨텍스트를 전달하지 않고도 LLM이 컨텍스트를 기억하도록 해준다고 이해하면 됩니다.
6) 에이전트
LLM과 하나 이상의 도구를 결합해, LLM을 통해 수행할 일련의 작업을 선택하는 것이라고 이해하면 됩니다.
작년 말 운좋게 금융권 인턴 취업에 성공하고, 올해 무사히 정직원 전환에 성공했습니다. 금융권에 재직 중이고, 돈을 벌기 시작하면서 금융과 경제에 대한 관심이 자연스럽게 많아지고 있는데요. 문득 출근길 지하철에서 금융 포스트들을 하나씩이라도 읽으면 좋겠다는 생각이 들었고, 매일 아침 출근길마다 읽기 좋은 금융 포스트들의 링크를 보내주는 서비스들을 만들면 어떨까라는 생각으로 이어졌습니다.
그와 동시에 실제로 이 서비스를 간단히 운영해보고 싶다는 생각이 들었고, 운영 과정에서 발생하는 비용을 최소화하는 걸 목표로 삼았는데요. 이 "출근길 금융 한 잔"이라는 간단한 서비스를 개발한 과정을 소개해드리려고 합니다.
1. 어떤 플랫폼의 포스트들을 보낼까?
첫 번째로는 어떤 글들을 보낼 것인가를 고민했습니다. 아무래도 스스로를 위한 서비스인만큼 제가 잘 읽힌다고 생각되는 플랫폼들을 선정했는데요. 고민 끝에, 카카오뱅크와 토스뱅크에서 운영하는 플랫폼의 글들을 보내주기로 결정했습니다.
처음에는 크롤링을 통해 포스트들을 감싸고 있는 <ul>태그나 <ol태그>를 찾은 뒤, 그 안에 있는 목록들을 가져오는 방법을 구상했습니다. 그러다가 문득 "카카오뱅크나 토스뱅크도 처음에 블로그에 들어갈 때 특정 API를 통해서 포스트 목록들을 가져오지 않을까?"라는 생각이 들더라구요. 개발자 도구 등을 사용해서 그 API를 제가 딸수만 있다면 크롤링보다 훨씬 적은 공수로 포스트들을 가져와서 활용할 수 있겠다는 생각에 바로 개발자 도구를 켰습니다.
Network 탭을 까본 결과, 카카오뱅크와 토스뱅크가 사용하는 API들을 어렵지 않게(?) 따올 수 있었습니다. 그러면 그 API의 응답값을 토대로 특정 포스트의 no값이나 key값을 딸수가 있었는데요, 카카오뱅크와 토스뱅크의 블로그들은 그 값들을 이용해서 다음과 같은 URL로 접근하면 해당 글들을 볼 수 있는 것까지 살펴볼 수 있었습니다.
카카오뱅크 : https://brunch.co.kr/@kakaobank/{no}
토스뱅크 : https://blog.toss.im/article/{key}
3. 포스트들의 링크를 어떤 매체로 전달할까?
포스트들의 링크를 얻는 방법을 알았으니, 이 링크를 어떤 매체로 보낼지가 고민됐습니다. 가장 대중적인 매체인 "카카오톡"으로 보내보자는 생각이 들었고, 바로 채널을 개설했습니다.
하지만, 알람톡을 보내려면 일반 채널이 아닌 비즈니스 채널로 만들어야 한다는 것을 알게 됐습니다. 비즈니스 채널 개설을 위해선 사업자 등록증이 필요한데, 이걸 발급받고 이것저것 하는 것은 공수가 많이 들 것 같다는 생각이 들었습니다.
결국 카카오톡 채널을 통한 발송은 포기하고, 다른 방법을 알아봤습니다. 눈에 들어온 방법은 SMS 전송이었는데요, 다음과 같이 SMS 전송을 지원하는 4개의 플랫폼들을 비교했습니다.
가비아
CoolSMS
AWS SNS
네이버 클라우드 Notification
비용
최소 충전 20,500원 (건당 20.5원)
최소 충전 10,000원 (건당 20원)
건당 약 30원 (24.05.18 기준)
사업자만 가능
API 지원
O
O
O
기타
발신번호 등록 필요
발신번호 등록 필요
발신번호 등록 필요 X
SMS 발송에 필요한 발신번호는 "아톡" 등의 플랫폼에서 발급받을 수 있지만 월 2,000원이라는 비용이 발생합니다. 하지만 저는 이 서비스를 어차피 저(아니면 주변 사람들 몇 명만 더해서)만 사용할 것이기 때문에 발신번호는 제 010번호를 쓰면 되므로, 발신번호 필요 여부는 그리 큰 문제가 되지 않겠다는 생각이 들었습니다.
그러면 비용만 비교하면 됐었는데요. 최소 충전 비용이 있다보니 1년도 채 안 지나서 서비스 운영을 종료할 예정이라면 AWS SNS를 쓰는게 이득이지만, 앵간하면 이 서비스는 제가 계속 쓸 것 같다는 생각이 들었습니다. 금융 관련 글은 읽으면 읽을수록 플러스지 마이너스는 안 될 거라고 생각하거든요. 결국, 건당 비용이 가장 낮은 CoolSMS를 사용하기로 결정했습니다.
그렇다면, 실제로 CoolSMS를 통해 SMS를 보낼 수 있다는 걸 검증해야겠죠. 제공되는 API 문서를 통해 간단히 테스트했고, 제가 원하는 내용을 보낼 수 있음을 다음과 같이 검증할 수 있었습니다.
※ 참고 : 빠른 속도를 위해 Python을 사용해서 개발 및 테스트했습니다
4. 상세 로직은 어떻게 구성할까?
API를 통해 SMS를 보낼 수 있다는 것도 검증했으니, 이제 실질적으로 어떤 과정을 통해 포스트들의 링크를 SMS로 전달할지를 구상할 차례인데요. 우선 다음 3가지의 데이터는 가지고 있어야 한다고 생각했습니다.
1) SMS를 받을 사람(구독자들)의 휴대폰 번호
2) 메시지 내용 템플릿
3) 가장 최근에 보낸 포스트의 정보(카카오뱅크는 no, 토스뱅크는 key)
이를 바탕으로, 초기에는 다음과 같은 로직을 구상했습니다.
2번 과정에서 마지막(가장 최근)에 보낸 포스트들의 정보를 가져오고, 이를 토대로 어떤 포스트들을 보내야할지를 4번 과정에서 추린 다음 SMS를 전송하는 로직입니다. 근데 이렇게 되면, 네트워크 문제 등으로 6번 과정(SMS 전송)에 실패해도 이미 실행된 5번 과정에 의해 전송된 포스트 정보가 갱신된다는 문제가 있었습니다. 즉 특정 포스트는 건너 뛰게 되는 현상이 발생할 수 있었죠. 따라서 5번과 6번의 순서를 바꿔서, 다음과 같은 로직으로 구성했습니다.
5. 어떤 DB를 사용할까?
이제 데이터들을 담을 DB를 골라야 했습니다. 클라우드 서비스들을 알아봤는데요. 제 AWS 계정은 프리 티어를 사용할 수 있는 시기가 이미 지났기 때문에 AWS가 아닌 다른 서비스들을 찾아봤고, 서치를 통해Supabase라는 BaaS(Backend as a Service)를 알게 됐습니다. 완전 무료형 서비스는 아니고 500MB만큼의 DB 용량을 무료로 사용 가능한데, 어차피 저는 DB에 insert가 아닌 update만 해줄 거라 용량제한을 넘길 일은 없고, 끽해야 하루에 한 번 read/write를 하기 때문에 이 녀석을 쓰는게 딱이겠다는 생각이 들었습니다.
역시나 테스트를 해봐야겠죠? Supabase 콘솔에서 테이블들을 세팅해주고, 다음 코드들을 통해 select(휴대폰 번호 테이블에서 번호들 가져오기)와 update(가장 최근에 보낸 포스트 정보 갱신)가 정상 수행되는 걸 확인했습니다.
# SMS를 받을 사람들의 핸드폰 번호 가져오기
subscriber_phone_numbers = supabase.table(os.environ.get("PHONE_NUMBER"))\
.select("*").execute().data
print(subscriber_phone_numbers)
# [
# {"phone_number": "010XXXXXXXX},
# {"phone_number": "010XXXXXXXX},
# {"phone_number": "010XXXXXXXX}
# ]
# 가장 최근에 보낸 포스트의 정보 갱신(토스뱅크)
supabase.table(os.environ.get("TOSS_BANK_POST"))\
.update({"toss_last_send_key": key}).eq("id", id).execute()
6. 서버는 어디에 띄울까?
코드 작성은 끝났고, 이제 가장 중요한 단계죠. 서버를 어디에 띄울지 고민했습니다. 아무래도 매일 아침에만 코드가 실행되면 되게 때문에 서버리스를 도입하는게 궁극적인 목적인 "비용 절감"에 가장 적합하다고 생각했는데요. 그렇다고 처음부터 서버리스만 고려하기 보다는 EC2에 호스팅하는 방법을 포함해서 다음과 같이 4가지 방법을 조사했습니다.
AWS EC2 + Scheduler
AWS Lambda + EventBridge
Supabase Edge Fuction + pg_cron
스크립트 페이지 호스팅 + cron_job.org
비용
1,586원/월 (스팟 인스턴스, t3.nano 기준)
3.55원/월 (0.5GB 메모리, 10초 실행 기준)
무료
무료
비고
1) EC2를 Public Subnet에 두면 보안 취약
2) EC2를 Private Subnet에 두면 NAT Gateway 비용 추가 발생
TypeScript로만 작성 가능
다른 사람이 해당 페이지를 호출함으로써 스크립트 실행 가능
1) AWS EC2 + Scheduler
EC2 상에 서버를 띄우고, 자체적으로 스케쥴러(스프링의 @Scheduled 등)를 설정해 일마다 수행하는 방법입니다. EC2는 사용한 만큼 지불하는 On-demand 옵션 외에도 할인된 금액으로 선납입을 하는 예약 인스턴스, 가장 저렴한 금액으로 사용 가능하나 서비스가 중단될 가능성이 있는 스팟 인스턴스 등의 유형이 있는데요. 본 서비스는 서비스가 중단되도 큰 영향이 없기 때문에(다음날 수행되면 어차피 전날 안 보내진 것도 일괄 전송되므로) 스팟 인스턴스를 사용해도 무방하다고 판단했습니다.
24년 6월 4일 기준으로, t3.nano를 사용할 때 시간당 0.0016 달러를 내는데요. 24를 곱하고 30을 곱하면, 한달에 1.152달러 = 1,586원이 발생됩니다.
※ 참고 : On-demand의 경우, 이 케이스에선 4배 정도 더 비쌈
그리고 EC2는 최초 생성 시 Public Subnet에 둘 건지 Private Subnet에 둘 건지를 정해야 합니다. 해당 EC2는 외부로부터의 선접근이 없으므로 Private Subnet에 두는 게 보안상 안전하나, 카뱅 & 토뱅으로부터 최신 포스트 목록을 가져오는 과정, supabase에 접근해 데이터를 가져오는 과정 등에서 외부와의 통신이 필요하므로 NAT 게이트웨이 등을 별도 설치해야 하고, 이는 추가 비용을 야기합니다. 그렇다고 Public Subnet에 두자니, 외부로부터의 접근에 노출될 우려가 있습니다. 따라서 EC2와 Scheduler 조합을 선택할 경우, 이 부분을 좀 더 고려해서 선택해야 할 필요가 있었습니다.
2) AWS Lambda + EventBridge
AWS의 서버리스 서비스인 Lambda Function을 등록하고, EventBridge를 통해 일마다 Lambda를 호출하는 방법입니다. 제 로컬을 기준으로 1회 실행 시 3.2초 정도가 수행됐는데요, 아무래도 보수적으로 1회 실행에 10초가 걸린다고 잡고 512MB짜리 메모리를 쓴다고 가정했습니다.
일마다 실행하므로 한 달에 30번만 실행되기 때문에, 30 x 10(10초) x 0.5(512MB = 0.5GB) x 0.0000166667 = 0.0025달러 = 3.5원 정도의 비용이 한 달마다 발생되게 됩니다.
그리고 EventBridge에 대한 비용도 고려해야 하는데요.
100만건의 스케줄러 호출 당 1.15달러의 비용이 월마다 발생하는데요. 우리는 월 30번만 호출하니 0.0000345달러 = 약 0.05원의 비용이 달마다 발생합니다.
즉, 정리하면 AWS Lambda + EventBridge 사용 시, 3.55원의 비용이 월마다 발생합니다.
3) Supabase Edge Function + pg_cron
Lambda와 같은 서버리스 서비스인 Supabase Edge Function을 등록하고, pg_cron이라는 Supabase Extension을 통해 일마다 Edge Function을 호출하는 방법입니다. 24년 6월 4일을 기준으로 Edge Function은 TypeScript만으로만 작성가능하고, HTTP 요청을 통해 Edge Function을 호출하는 방식으로 동작합니다. 따라서 Edge Function과 + pg_cron을 선택하는 경우 HTTP 요청에 대한 별도의 인증을 구성하지 않는다면 제 3자의 Edge Function 호출로 SMS 발송이 야기되어 과금이 발생할 수 있으니 꼭 인증을 구성할 필요가 있고, TypeScript로 다시 코드를 작성해야 하는 공수가 발생함을 고려해야 합니다.
4) 스크립트 페이지 호스팅 + cron_job.org
우비나 닷홈 등의 무료 호스팅 서비스를 이용해 스크립트 페이지를 만들고, cron_job.org라는 무료 스케쥴러 사이트를 활용해 일마다 스크립트 페이지로 HTTP Request를 보내게 하는 방법입니다. 완전 무료로 사용 가능하지만, 호스팅된 스크립트 페이지를 다른 사람도 호출할 수가 있는데요. 이 경우 제가 원치 않는 타이밍에 SMS가 발송되고, 이것들이 모두 과금으로 이어질 수 있다는 치명적인 단점이 있습니다.
정리하면, EC2 사용은 월 1,500원 이상의 비용이 발생하니 고려 대상에서 제외했고, 스크립트 페이지 호스팅 & cron_job.org 사용은 제 3자의 스크립트 페이지를 요청으로 인한 과금 폭탄이 발생 가능해 제외했습니다. AWS Lambda와 Supabase Edge Function이 남았는데, Lambda 사용 시 발생하는 월 3원 가량의 비용은 사실상 없는 수준이라고 생각했고 Supabase Edge Function은 제가 사용해본 적 없는 TypeScript로 코드를 재구성해야 하는 점 등의 공수가 걸렸습니다. 따라서 최종적으로 Lambda와 EventBridge를 사용하는 것으로 결정하게 됐습니다.
7. AWS Lambda 함수 생성 & EventBridge 설정 (with. 트러블 슈팅..)
Lambda 함수 생성시, 파이썬 버전은 로컬에서와 동일하게 3.11버전으로 설정해줬습니다.
그 뒤 함수에서 사용될 환경변수들을 세팅해줬습니다.
그리고 Lambda 함수가 사용할 외부 라이브러리들을 세팅해줘야 했는데요. "레이어"를 통해 이것이 가능하고, 쉽게 설명하면 제가 세팅해준 레이어 위에서 Lambda함수가 돌아가는 개념입니다.(주로 이렇게 외부 라이브러리들을 미리 세팅할 용도로 활용한다고 합니다) 로컬에서 쓴 라이브러리들을 pip freeze >를 통해 requirements.txt로 빼준 뒤, pip install -t를 통해서 해당 라이브러리들을 따로 제가 지정한 폴더에 설치한 후 해당 폴더를 압축해서 레이어 생성에 활용할 수 있었습니다.
그 다음, 이렇게 생성한 레이어를 아까 만들어둔 Lambda 함수에 추가해줬습니다.
그리고 만들어둔 코드를 Lambda 코드에 복붙한 뒤 테스트 실행을 눌렀는데, 다음과 같이 pydantic_core라는 모듈을 찾을 수 없다는 오류를 만났습니다.
Response
{
"errorMessage": "Unable to import module 'lambda_function': No module named 'pydantic_core._pydantic_core'",
"errorType": "Runtime.ImportModuleError",
"requestId": "Blah Blah ~",
"stackTrace": []
}
요약하면 Lambda는 특정 아키텍쳐에 맞는 패키지들을 필요로 하는데, 제가 레이어로 세팅해준 라이브러리들이 Lambda가 구동되는 아키텍쳐와 맞지 않아서 발생되는 문제였습니다. 실제로 제 로컬에서 requirements.txt에 적힌 라이브러리들에 대해 pip install을 해보면,
이렇게 특정 라이브러리들이 제 로컬 머신의 아키텍쳐(macos)에 맞는 버전으로 설치되는 걸 볼 수 있었습니다.(pip install을 할 때 라이브러리들이 머신의 아키텍쳐 버전에 맞춰 설치되는 걸 처음 알았네요... 모든 라이브러리가 이렇게 아키텍쳐별로 버전을 다르게 제공하는 건 아닙니다) 하지만 저는 Lambda함수를 생성할 때 아키텍쳐를 x86_64로 설정했으니 오류가 발생하는 거였죠.
따라서 다음 명령어를 통해 라이브러리들을 x86_64 아키텍쳐에 맞는 버전으로 설치하게끔 하고, 이걸 다시 압축해서 새로 레이어를 세팅해줬습니다.
이제 EventBridge를 통해 생성한 Lambda함수를 스케줄링할 차례입니다. 이건 매우 간단했는데요. 생성된 Lambda 함수 콘솔에서 트리거 추가를 통해 설정해줄 수 있었습니다.
해당 cron 표현식은 월-금요일 매일 12시에 실행한다는 의미입니다. 다만 UTC가 기준이기 때문에, 한국 시각으로는 21시에 실행됩니다(KST가 UTC보다 9시간 앞서 있기 때문). 따라서 저렇게 트리거를 추가한뒤, 실제로 21시에 람다가 실행되는지를 봤습니다. 그 결과,
실제로 21시에 Lambda가 수행되어, SMS가 정상 전송된 것을 확인할 수 있었습니다!
이제 매일 아침 08시에 람다가 실행되도록 EventBridge 규칙의 cron 표현식을 다음과 같이 변경해주고 마무리 지었습니다.
cron(0 23 ? * SUN-THU *)
마무리...
되게 간단한 서비스라고 생각했는데, 이것저것 고민하는 과정들이 많았습니다.. 만들었다 하기도 뭐하고 운영이라 하기도 뭐하지만, 그래도 생각해보니 제가 만든 서비스를 이렇게 운영 레벨로 옮겨온 건 처음이더라구요. 옛날에 이두희님이 코딩으로 자기가 만들고 싶은 것들을 뚝딱뚝딱 만드는 걸 보며 되게 멋있다고 생각했는데, 저도 제가 나한테 필요하겠다 싶은 서비스를 이렇게 중간에 멈추지 않고 처음 목표대로 실제 운영 단계까지 오는데 성공해서 굉장히 뿌듯...>< 합니다. 우선 저랑 여자친구한테만 이 글들이 가게 하고, 나중에 친구들 몇 명 해서 추가할 것 같네요. 일련의 과정들을 정리하고 공유하고 싶어 글을 이렇게 써봤는데, 난잡한 글 끝까지 봐주셔서 감사합니다.
-f : awk program(awk 명령 스크립트) 이 있는 경로를 지정한다 (즉, 'pattern { action }' 부분은 -f가 없을 때 실행될 'awk program'에 해당
-v : awk program에서 사용될 특정 변수값들을 지정한다
pattern과 action은 둘 다 생략이 가능한 부분이다. pattern이 생략되면 모든 레코드가 적용되고, action이 생략되면 default action인 print가 수행된다.
# pattern을 생략하는 경우
$ awk '{ print }' test.txt # test.txt의 모든 레코드를 출력함
seq_num name age score
1 kim 27 100
2 cho 26 99
3 park 24 94
# action을 생략하는 경우
$ awk '$4 < 100' ./file.txt # test.txt에서 4번째 필드값이 100보다 작은 레코드들 출력함
2 cho 26 99
3 park 24 94
pattern
앞서 봤듯, awk에서 특정 조건에 따라 작업을 처리하게 하는 구성 요소. 크게 세 가지 유형으로 나눌 수 있으며, 이들을 조합하거나 단독으로 사용하여 데이터를 필터링하고 조작할 수 있다.
1. 관계식 패턴
$ awk '관계식패턴 {action}' FILE
관계식(비교 연산)을 사용하여 조건에 맞는 라인을 선택하게 한 뒤 action을 수행한다. 필드 값이 특정 조건을 만족하는지 여부를 평가할 때 등에 사용한다.
# ex 1
$ awk '$4 == 100 {print $2}' test.txt
kim
# ex 2
$ awk '$4 < 100' ./file.txt
2 cho 26 99
3 park 24 94
2. 정규표현식 패턴
$ awk '/패턴/ {action}' FILE
파일에서 pattern(정규표현식을 활용한)이 포함된 모든 라인을 찾아 해당 라인에 대해 action을 수행한다. 정규표현식은 /들로 둘러싸야 한다.
# ex 1 : k가 들어있는 레코드만 프린트
$ awk '/k/ {print}' test.txt
1 kim 27 100
3 park 24 94
3. BEGIN & AND 패턴
$ awk 'BEGIN {action1} pattern {action2} END {action3}' FILE
특수 패턴으로, BEGIN은 입력 데이터로부터 첫 번째 레코드를 처리하기 전 자신에게 지정된 action을 실행하고, END는 모든 레코드를 처리한 다음 자신에게 지정된 action을 실행한다.
# ex 1
$ awk 'BEGIN {print "Start Processing"} {print $1} END {print "End Processing"}' test.txt
Start Processing
seq_num
1
2
3
End Processing
※ 패턴 범위 지정
$ awk 'start_pattern,end_pattern {action}' FILE
start패턴이 처음 나타난 레코드부터 end패턴이 마지막으로 나타난 라인까지의 모든 레코드들에 대해 action을 수행한다. 로그 파일의 특정 섹션을 추출할 때 유용하다.
action
특정 조건(pattern)에 일치하는 레코드에 대해 수행할 작업을 정의하는 부분이다. 중괄호 {} 안에 작성되며, 여러 가지 연산과 명령을 포함할 수 있다. 일반적으로 데이터 처리와 조작을 위해 사용한다.
편의를 위해, pattern은 생략하고 action들의 유형을 설명한다.
1. 출력
$ awk 'pattern {print}' FILE
패턴을 만족하는 레코드들을 출력한다.
# ex 1: 4번째 값이 200보다 작은 레코드들을 출력
$ awk '$4 < 200 {print}' test.txt
1 kim 27 100
2 cho 26 99
3 park 24 94
2. 변수 할당
$ awk 'pattern {sum = sum + 1}' FILE
pattern을 만족하는 레코드들에 대해 변수를 할당하는 동작을 한다.
# ex 1 : 4번째 필드값이 200이하인 모든 레코드의 4번째 필드값을 합산 후 출력
$ awk 'BEGIN {sum_score = 0} $4 < 200 {sum_score = sum_score + $4} END {print sum_score}' test.txt
293
3. 조건문 사용
$ awk 'pattern {if(조건문) action }' FILE
pattern을 만족한 레코드들에 대해 조건문이 true로 평가되면 action을 수행한다. 참고로 else등도 활용 가능하다.
# ex 1: k가 포함된 레코드들에 대해 4번째 필드가 100이하면 해당 레코드를 출력한다
$ awk '/k/ {if($4 < 100) print $0}' test.txt
3 park 24 94
4. 반복문(for, while) 사용
$ awk 'pattern {반복문 action}' FILE
pattern을 만족한 레코드들에 대해 반복문을 돌며 action을 수행한다.
# ex 1 : 4번째 필드가 100보다 작은 레코드들에 대해, 1 ~ NF까지 루프를 돌며
# 현재 레코드 번호와 필드값을 출력
$ awk '$4 < 100 {for(i=1; i<=NF; i++) print NR, $i}' test.txt
3 2
3 cho
3 26
3 99
4 3
4 park
4 24
4 94
참고로, NR은 현재 레코드 번호(1부터 시작)를 의미하며 NF는 현재 레코드에 있는 필드값 수를 의미하는 것으로 내장 변수다.
또한 파이썬 등과 마찬가지로 break나 continue의 사용이 가능하다
이 외에도, next라는 action을 통해 다음 레코드로 넘기는 것도 가능하고(일종의 continue), exit를 통해 실행 중이던 awk를 종료시키는 것도 가능하다. 또한 sub, cos같은 다양한 내장 함수도 가지고 있다. 본 글에서처럼 파일에 작성된 값들에 대해 awk를 사용함으로써 데이터 변환 등이 가능하지만, vmstat이나 netstat등의 커맨드 결과를 파이프(|)를 통해 awk의 input으로 줌으로써 모니터링 관리 등에도 활용 가능하다.
SQL 쿼리 내에서 또다른 SELECT절을 사용하는 문법을 쓸 때, 내부에 포함되는 SELECT절을 서브쿼리(Subquery)라 부른다. SELECT 절에서 사용되는 서브쿼리를 스칼라 서브쿼리(Scala Subquery), FROM절에서 사용되는 서브쿼리를 인라인뷰(Inline View), WHERE절에서 사용되는 서브쿼리를 중첩 서브쿼리(Nested Subquery)라고 부른다.
Scala Subquery란
SELECT절에서 사용되는 서브쿼리로, 반드시 하나의 record(행)을 리턴해야하며 그 행도 하나의 컬럼값만 가지고 있어야 한다. (즉 단일행, 단일 컬럼을 반환해야 한다)
예제로, 다음과 같은 두 개의 테이블이 있다고 하자. DEPARTMENTS는 회사에 있는 부서들의 부서id와 부서명을, EMPLOYEES는 임직원들의 이름과 소속 부서id 등을 가진다.
이 때, 임직원들의 이름과 그들이 속한 부서의 이름을 함께 조회하고 싶다고 하고, 스칼라 서브쿼리를 사용해본다고 하자. 이때, 스칼라 서브쿼리의 FROM절에서 DEPARTMENTS를 참조하는가, EMPLOYEES를 참조하는가를 참조하는가를 주의해야 한다.
스칼라 서브쿼리의 FROM절에서 DEPARTMENTS를 참조할 경우의 쿼리와 결과는 다음과 같다.
SELECT EMP_NAME, (
SELECT DEPT_NAME
FROM DEPARTMENTS d
WHERE d.DEPT_ID = e.DEPT_ID)
FROM
EMPLOYEES e;
스칼라 서브쿼리의 FROM절에서 EMPLOYEES를 참조할 경우의 쿼리와 결과는 다음과 같다.
SELECT (
SELECT EMP_NAME
FROM EMPLOYEES e
WHERE d.DEPT_ID = e.DEPT_ID
), DEPT_NAME
FROM
DEPARTMENTS d;
서브쿼리의 FROM절에서 DEPARTMENTS를 참조하는 경우, d.DEPT_ID = e.DEPT_ID를 만족하는 레코드가 하나만 나오기 때문에 문제가 되지 않는다. 하지만 서브쿼리의 FROM절에서 EMPLOYEES를 참조하는 경우, d.DEPT_ID = e.DEPT_ID를 만족하는 레코드가 다수가 나오기 때문에 문제가 되고, 위와 같은 오류가 생기는 것이다.
즉, 스칼라 서브쿼리는 반드시 하나의 레코드와 하나의 컬럼값을 리턴해야 한다.
스칼라 서브쿼리의 성능 측면에서의 문제점 & LEFT OUTER JOIN으로 변환
쿼리는 FROM(과 JOIN) → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 순으로 실행된다. 즉 특정 테이블에서 특정 조건에 맞는 레코들들을 모두 뽑아오고, 해당 레코드들에 대해 select절에서 특정 컬럼들을 뽑아내는 식으로 동작한다. 스칼라 서브쿼리는 건수만큼 반복해서 수행된다. 데이터가 많아질수록 성능 저하의 주범이 될 수 있는 것이다.
따라서, LEFT OUTER JOIN과 Inline View를 활용하는 식으로 쿼리를 바꿔 성능을 개선시킬 수 있다. 다음 쿼리를 살펴보자.
SELECT DEPT_NAME, (
SELECT COUNT(*)
FROM EMPLOYEES e
WHERE e.DEPT_ID = d.DEPT_ID
) NUMS
FROM DEPARTMENTS d;
부서별로 부서명과 소속 인원들의 이름을 보는, 스칼라 서브쿼리를 쓰는 쿼리다. 이를 LEFT OUTER JOIN과 Inline View을 활용하는 형태로 다음과 같이 바꿔쓸 수 있다.
SELECT DEPT_NAME, c.NUMS
FROM DEPARTMENTS d
LEFT OUTER JOIN (
SELECT e.DEPT_ID, COUNT(*) AS NUMS
FROM EMPLOYEES e
GROUP BY e.DEPT_ID
) c
ON d.DEPT_ID = c.DEPT_ID;
기존에는 해당 서브쿼리가 건수만큼 반복돼서 수행됐지만, 이렇게하면 한 번만 수행되고 끝난다.
하지만 늘상 스칼라 서브쿼리를 LEFT OUTER JOIN으로 바꾼다고 성능 향상이 되는 건 아니다(그러면 스칼라 서브쿼리 아무도 안 쓰지..). 상황별로 스칼라 서브쿼리를 쓰는게 이득일 때도 있다. 다만 스칼라 서브쿼리를 쓸 때는 속도를 고려하는 습관을 가지자.