[AWS] 람다 함수의 비동기 처리

들어가며


AWS Lambda와 같은 서버리스 컴퓨팅 환경에서 비동기(Asynchronous) 처리를 다룰 때에는 함수 실행이 종료 되기 전에 프로그램이 먼저 종료되는 문제가 발생할 수 있다. 람다가 실행을 완료하고 종료될 때, 백그라운드에서 실행 중인 프로세스나 작업을 모두 종료시켜버리기 때문이다. 이러한 특성 때문에 비동기로 실행시킨 작업이 완료되기 전에 람다 함수가 먼저 종료된다. 비동기식으로 작성하여 운영중이던 챗봇 서비스를 클라우드로 마이그레이션할 때 직접 겪었던 문제를 소개하고, 람다 함수를 사용할 때 동기식 호출과 비동기식 호출의 차이와 장단점을 알아본다.

문제


람다 함수에는 수명주기(Lifecycle)가 있는데, 람다 함수는 요청이 발생했을 때 필요에 의해서만 이벤트를 처리하고 실행이 완료되면 응답을 반환하고 수명주기가 종료된다. 비동기 처리에 대한 완료 여부를 검사하는 절차가 수명주기에 없기 때문에 함수가 먼저 종료된다. 실제 코드를 구현하고 온프레미스 환경에서 정상적으로 사용하던 코드를 람다로 마이그레이션 하는 과정에서 발생하는 문제이다.
 
이전 글에서 파이썬으로 작성한 게임 알림 봇의 코드를 비동기식으로 처리하면 더 효율적으로 실행할 수 있겠다는 생각에 nodejs로 포팅하였고 각각의 웹 요청 및 DB 작업에 대해 함수 호출 후 메인 스레드가 대기하지 않고 종료하도록 하였다.

운영중인 봇에 대한 설명은 이전 포스팅 참고

2020.11.26 - [Side Project/Telegram Chatbot] - [Telegram Bot] lol api 이용한 게임 알림 챗봇 제작기

 

[Telegram Bot] lol api 이용한 게임 알림 챗봇 제작기

라이엇 게임즈는 공식 홈페이지에서 롤 api를 통해 게임 정보와 유저 정보를 제공한다. https://developer.riotgames.com/ Riot Developer Portal About the Riot Games API With this site we hope to provide the League of Legends devel

gomguk.tistory.com


기능을 간단하게 요약하면 다음과 같다.

 1. 현재 게임중인 사용자의 게임정보를 파싱해서 텔레그램 채팅방으로 전송해주는 챗봇 (전송 기능 비동기식 구현)
 2. 10분마다 스크립트가 실행되며, 게임중인 사용자가 있는 경우 데이터베이스에 정보를 기록한다. (데이터베이스 기록 비동기식 구현)
 3. 동일한 게임에 대해 여러 번 메시지가 전송되지 않도록 게임 ID가 중복되는 경우 메시지를 보내지 않는다.


기존 코드의 경우 챗봇 api 호출을 통한 메시지 전송과 데이터베이스 쿼리 처리를 비동기식으로 구현하였다. nodejs에서 비동기식으로 코드를 구현하려면, 'async/await' 또는 'Promise'와 '.then()'을 사용할 수 있다. 다음 코드는 Promise로 구현하였다.


const mysql = require('mysql');

function insertrow(data) {
    return new Promise(function (resolve, reject) {
        var searchsql = "select * from gameinfo where gameId=? and nickname=?";
        var searchparam = [data.gameId, data.nickname];
        var db = mysql.createConnection({
            host: '[DB접속정보]',
            user: '[DB 계정명]',
            password: '[DB 패스워드]',
            database: '[접속할 DB명]'
        });

        db.connect();

        db.query(searchsql, searchparam)
            .then((rows, fields) => {
                console.log(" << DB CONNECTION SUCCESS!! >>");
                if (rows.length >= 1) {
                    console.log("이미 게임중이었던 사람");
                    db.end();
                    reject("Duplicate_ROW");
                } else {
                    var sql = 'INSERT INTO gameinfo(gameId,nickname,champId,queueId,gamemode,gameTime)VALUES(?,?,?,?,?,?)';
                    var params = [data.gameId, data.nickname, data.championName, data.gameQueueConfigId, data.gameMode, data.gameStartTime];
                    return db.query(sql, params);
                }
            })
            .then((rows, fields) => {
                console.log(rows.insertId);
                bot.sendMessage(chatroomid,
                    "<" + data.nickname + ">\n" + data.gameMode + " " + data.gameQueueConfigId + "\n챔피언 >> " + data.championName);
                db.end();
                resolve(1);
            })
            .catch((err) => {
                console.log(err);
                db.end();
                reject(err);
            });
    });
}


Promise가 성공적으로 실행되었다면 .then() 메서드를 통해서 챗봇 메시지를 전송하게 되며, 예외가 발생한 경우 .catch() 메서드에서 예외처리한다. 온프레미스 환경에서 잘 동작하는 이 코드를 람다에 업로드하고 실행하면 다음과 같은 로그를 확인할 수 있다.



코드에서 발생할 수 있는 명시적인 에러가 없기 때문에 핸들러 함수가 정상적으로 실행되고 종료되는 로그를 확인할 수 있다. 하지만 데이터베이스에는 값이 입력되지 않았고, 챗봇 또한 메시지를 전송하지 않았다.

 

 문제해결

제시된 문제를 해결하기 위해서는 모든 함수 실행이 종료되기 전에는 람다가 종료되지 않게 코드를 작성하면 된다. 이것이 기존의 전통적인 람다 함수의 호출 방법이었던 "동기식(Synchronous) 호출"이다. 두 가지 호출 방식을 비교하면 다음과 같다.

동기식 호출
1. 작업이 순차적으로 실행
2. 한 작업이 시작되면, 해당 작업이 완료될 때까지 다음 작업은 시작되지 않음

비동기식 호출
1. 작업이 순차적으로 실행되지 않음
2. 한 작업이 시작되고, 종료를 대기하지 않고 다음 코드로 진행
3. 한 작업의 시작과 완료가 독립적으로 관리
4. 주로 콜백(callback), 프로미스(promise), async/awiat와 같은 메커니즘을 사용하여 처리됨

제시한 문제를 해결하기 위해서는 코드의 동기식 호출이 필요하다. 

두 가지 코드를 사용할 수 있다. 람다의 핸들러 함수 내에 동기식으로 처리하고자 하는 비동기 함수의 실행이 완료할 때까지 'await' 키워드를 사용하여 기다릴 수 있다.

exports.handler = async (event, context) => {
    
    for (var i in Users) {
        await insertrow(Users[i].name);
    }
  
    const response = {
        statusCode: 200,
        body: JSON.stringify('요청이 정상적으로 처리되었음'),
    };
    return response;
};


또다른 키워드인 'Promise.all' 메서드를 사용하여 함수 내의 모든 비동기 작업이 완료될때까지 대기하도록 할 수 있다.

async function insertrow(data) {
    // ...
}

exports.handler = async (event, context) => {
    await bot.sendMessage(chatroomid, "Test Message");
    try {
        await Promise.all(Users.map(user => insertrow(user.name)));
    } catch (error) {
        console.error(error);
    }
  
    const response = {
        statusCode: 200,
        body: JSON.stringify('요청이 정상적으로 처리되었음'),
    };
    return response;
};



메신저에서 정상적으로 수신되는 메시지



동기식 호출은 람다 함수 실행 명령(Lambda Invoke API)을 수행하는 즉시 함수가 실행되며, 코드의 실행이 완전히 종료된 후에 람다 함수도 종료된다. 이를 위해서는 프로미스나 콜백을 적절하게 처리하고, 람다 함수가 종료되지 않도록 한다. 당연히 데이터베이스 처리와 챗봇 메시지 전송이 완료할 때까지 함수 실행이 종료되지 않기 때문에 전체 실행 시간은 증가한다. 람다의 요금은 "실행횟수 * 메모리 용량 * 실행시간"으로 계산하므로 어느 것이 비용효율적인지 판단하여 적용해야 한다.

두 가지 방식 중 어느 쪽을 선택하는 지에 대해서는 프로그램이 처리할 작업의 유형과 복잡성에 따라 다르다. 비동기식 프로그래밍은 일반적으로 입출력 작업(I/O, 파일 시스템 접근, 네트워크 요청 등)이 많거나 시간이 오래 걸리는 작업에 더 적합하다. 프로그램이 하나의 작업 때문에 오랜 시간 대기(blocking)하지 않고 먼저 처리하여도 지장이 없는 작업들을 동시에 처리할 수 있다. 비동기식 프로그래밍은 이벤트 기반 프로그래밍, 멀티스레드 및 병렬 처리와도 많은 관련이 있다. 프로그램을 더 효율적으로 작동하고, 더 빠르게 응답할 수 있도록 한다. 하지만 비동기식 프로그래밍은 코드를 관리하기가 더 어렵고, 콜백 헬(callback hell)과 같은 문제가 발생할 수 있다.


마치며

AWS에서는 효율적인 자원 관리를 위해서 람다 함수는 실행될 때마다 새로운 실행 환경을 설정한다(cold start). 사용하지 않을 때 리소스를 절약할 수 있다는 장점이 있지만, DB 연결 등 초기화 작업이 실행 시점마다 반복적으로 발생하게 되고 이는 실행 시간 증가와 일관된 성능을 제공할 수 없다는 단점이 있다. 이번 코드에서는 단일 람다함수를 AWS 이벤트 브릿지(Event Bridge)의 스케쥴 호출(cron)로 실행시켰기 때문에 람다 함수의 트리거(trigger)는 콜드 스타트로 동작하였다. 핸들러 함수가 실행되기 이전에 초기화 함수가 실행되었고, 초기화 함수가 비동기 실행으로 등록되어 있었기 때문에 비동기 작업이 완료되지 않은 상태로 핸들러 함수가 실행된 것이다. 핸들러 함수가 실행이 완료되고 응답을 반환할 때까지 비동기 작업이 완료되지 않았기 때문에 람다 함수는 먼저 종료되고, 람다 함수가 종료되었기 때문에 해당 요청 및 컨테이너가 종료되면서 백그라운드에서 동작하던 비동기 함수의 실행 또한 종료되었다.

기존에 실행하던 로컬 환경에서는 메인 스레드가 종료되더라도 백그라운드에서 동작 중인 작업은 계속 실행되었고, 작업이 완료되는 순서대로 실행을 종료했기 때문에 정상적으로 코드가 동작했고 응답을 받을 수 있었다. 하지만 람다에서는 메인 프로세스가 종료되는 순간 모든 스레드를 종료하기 때문에 비동기로 실행한 함수들의 호출이 처리되기 전에 종료되었다. 온-프레미스 환경과 클라우드 환경의 실행 방법과 자원 관리에서 오는 차이였다.

작성한 함수가 정상적으로 실행되는지, 예외가 발생하는지에 대한 가시성을 확보하기 위해 Cloud Watch 등 로그 스트림을 구성하면 대부분의 문제는 해결할 수 있다. 함수 동작에 대한 예외 발생 시 로그를 남기도록 코드를 작성하고 Cloud Watch를 통해 Insight를 통해 분석하거나 로그 수집 및 분석을 통해 주기적으로 검토할 수 있다.
이번 오류는 별다른 에러 발생 없이 정상 코드를 반환하면서 람다함수만 종료되었기 때문에 원인을 찾기 어려웠다. 기술적인 문제가 발생했을 때, 기능의 동작 원리부터 파악해보는 것이 필요하다.

Ref: https://aws.amazon.com/ko/blogs/architecture/understanding-the-different-ways-to-invoke-lambda-functions/

반응형