분산 시스템 환경에서의 슬랙봇 앙몬드 개발기

분산 시스템 환경에서의 슬랙봇 앙몬드 개발기

요약: 사내 슬랙봇 ‘앙몬드’는 분산 시스템 환경에서 운영되며, 이 과정에서 발생한 동시성 문제와 탭 고정 문제를 해결하기 위해 데이터베이스 트랜잭션, Lock, Redis 등을 활용했습니다. 이러한 경험을 통해 프론트엔드 개발자로서 서버 측 동작 원리와 분산 시스템 특성을 이해하고, 전체 시스템을 고려한 개발의 중요성을 깨닫게 되었습니다. 앙몬드 개발 및 운영 경험은 분산 시스템 환경에 익숙하지 않은 개발자들에게 도움이 될 것입니다.

💡 리뷰어 한줄평

lika.anne 분산 시스템 환경이 장애물로 다가왔지만, 이를 딛고 서비스를 만든 경험을 담아낸 멋진 글입니다. 이슈를 기능으로 바꾼 꿀팁도 얻어가세요!

cdragon.cd FE 개발자라서 분산 시스템에 관심을 안 가지신다고요? 이 글을 보면 생각이 달라질 거예요. 카카오페이 FE 개발자들의 분산 시스템 이야기를 공유드려요.

시작하며

안녕하세요, 마이데이터클랜에서 FE 개발을 하고 있는 가디입니다. 앙몬드는 “카카오페이 크루 모두가 쉽게 기술 공유를 할 수 있는 그날”을 꿈꾸며 탄생한 슬랙봇입니다. 1편에서 레츠가 사내 사이드 프로젝트로 앙몬드 탄생 배경부터 앙몬드의 주요 기능 그리고 슬랙봇 개발 꿀팁까지 소개해주셨는데요. 이번 글에서는 사내 프로젝트로 시작했던 앙몬드 슬랙봇을 분산 시스템 환경에서 운영하며 마주했던 예기치 못했던 문제들이를 해결했던 경험에 대해 공유드리고자 합니다.

왜 사내 프로젝트를 분산 시스템 환경에서?

‘사내 프로젝트를 왜 분산 시스템 환경에서 운영하지?’ 궁금해하는 분들이 계실 것 같아요. 질문에 답하기 위해 카카오페이 개발 환경을 가볍게 살펴보겠습니다.

카카오페이의 분산 시스템 환경

카카오페이는 서비스 안정성을 높이고, 장애 영향도를 최소화하기 위해 기본적으로 분산 시스템 환경에서 서비스를 운영하고 있습니다. 서버 이중화 구성을 위해 쿠버네티스(k8s) 서비스 기능 중 하나인 리전 클러스터(Regional Cluster)1를 활용합니다. 가산, 판교 두 지역에 클러스터를 구축해 두어 애플리케이션 파드2를 분산하여 배포할 수 있는 환경이 구축되어 있습니다. 배포 환경이 분산 시스템 기반으로 세팅되어 있기 때문에, 사내 프로젝트더라도 쿠버네티스(k8s) 기반 서비스의 환경 구성을 신청하면 가산, 판교 두 지역에 각각 하나의 애플리케이션 파드가 구동되어 운영됩니다.

카카오페이는 분산 시스템 환경을 통해 이러한 이점을 얻고 있습니다.

  1. 고가용성 (High Availability): 한 지역에 문제가 발생해도 다른 지역에서 서비스 운영을 이어갈 수 있어 장애로 인한 서비스 중단을 최소화할 수 있습니다.
  2. 부하 분산 (Load Balancing): 여러 지역에 트래픽을 분산시켜 특정 지역의 트래픽 과부하를 방지합니다.
  3. 재해 복구 (Disaster Recovery): 자연재해와 같은 예기치 못한 상황에서 다른 지역이 백업 역할을 해줄 수 있습니다.
    그러나 분산 시스템 환경을 잘 이해하지 못한 채 서비스 로직을 구현한다면 예기치 못한 문제를 마주할 수 있습니다.

🙇🏻‍♀️ 이어지는 글에서는 이해의 편의를 위해 쿠버네티스(k8s) “파드”를 “서버”로 표현했습니다.

문제 1. 왜 같은 알림이 두 번 오지?

🤔 문제 상황

아직 다 작성하지 못한 오늘 배운 내용(TIL)이 있는 금요일 오후 7시, 앙몬드는 유저에게 해당 TIL이 자정에 삭제된다는 알림 메시지를 발송합니다. 한 주간 슬랙 기술 공유 채널에 공유된 글들을 정리해 매주 월요일 9시에 포스팅해 주는 기능 또한 앙몬드의 역할입니다. 이처럼 주기적으로 앙몬드가 수행해야 하는 작업들크론잡으로 등록해 두어 자동화했습니다.

크론잡은 지정된 시간에 정확히 한 번만 수행되어야 하는데, 같은 알림이 간헐적으로 두 번 발송되는 문제가 있었습니다. 이는 앙몬드가 운영되고 있는 가산, 판교 2대의 서버에서 크론잡을 동시에 처리하여 발생한 문제였습니다.

알림이 중복 발송되는 모습

😲 해결 방법

크론잡 로직에 트랜잭션SELECT FOR UPDATE LOCK을 적용하여 동시성 문제를 해결해보고자 했습니다.

크론잡 로직

크론잡 로직은 크게 4단계로 나누어 살펴볼 수 있습니다.

  1. 크론잡 상태값 읽어오기
    • 크론잡 상태는 lastStartedAt(마지막으로 크론잡이 실행된 시간), enabled(크론잡 활성화 여부) 필드값으로 관리됩니다.
  2. 크론잡 실행 가능 상태 판단하기
    • 크론잡이 활성화된 상태이고 마지막 크론잡 실행 후 1분이 지났을 때, 크론잡을 실행 가능한 상태로 판단합니다.
const checkIsExecutable = ({ cronjob, now }) => {
  // 이미 다른 서버에서 1분 이내에 실행된 크론잡은 실행하지 않는다.
  const isJustStarted =
    !!cronjob.lastStartedAt &&
    dayjs(now).diff(cronjob.lastStartedAt, 'minute') < 1;

  return cronjob.enabled && !isJustStarted;
};
  1. 크론잡 상태 업데이트하기
    • 크론잡을 실행하기 전 크론잡의 lastStartedAt 필드값을 현재 서버 시간으로 업데이트해 줍니다.
scheduleInfo.lastStartedAt = dayjs().toISOString();
  1. 크론잡 실행하기
    • 크론잡에 매핑되어 있는 스케줄 로직을 실행합니다.

트랜잭션 적용

크론잡 로직에 포함된 데이터베이스 갱신 작업과 크론잡에 매칭된 스케줄 로직을 실행하는 작업, 이 두 작업은 모두 성공하거나 실패해야만 데이터 일관성을 보장할 수 있습니다. 트랜잭션을 적용하여 두 작업을 하나의 작업 단위로 처리해 모든 작업을 성공적으로 완료(=COMMIT)하거나 롤백(=ROLLBACK)할 수 있도록 했습니다.

트랜잭션은 TypeORM의 QueryRunner를 이용해 손쉽게 관리할 수 있습니다.

const executeJobWithLock = async ({ name, func, now, dataSource }) => {
  try {
    // [1] queryRunner 생성
    const queryRunner = dataSource.createQueryRunner();

    // [2] 트랜잭션 시작
    await queryRunner.connect();
    await queryRunner.startTransaction();

    const cronjob = await queryRunner.manager.findOne(Schedule, {
      where: { name },
      lock: { mode: 'pessimistic_write' },
    });

    // [3] 크론잡이 실행 가능한 상태가 아니라면, 트랜잭션 커밋
    if (!checkIsExecutable({ cronjob, now })) {
      return await queryRunner.commitTransaction();
    }

    // [4] 크론잡 상태 업데이트
    scheduleInfo.lastStartedAt = dayjs().toISOString();
    await queryRunner.manager.save(scheduleInfo);

    // [5] 크론잡에 매핑된 스케줄 로직 실행
    func(now);

    // [6] 크론잡 상태 업데이트 후, 트랜잭션 커밋
    await queryRunner.commitTransaction();
  } catch (error) {
    // [7] 에러 발생 시 트랜잭션 롤백
    await queryRunner.rollbackTransaction();
  } finally {
    // [8] queryRunner 해제
    await queryRunner.release();
  }
};
  • 크론잡이 실행 가능한 상태가 아니라면 더 이상 트랜잭션을 지속할 이유가 없기에 바로 트랜잭션을 커밋하고 작업을 종료합니다. 트랜잭션이 실행되는 동안 데이터베이스는 Lock, 메모리, CPU 등의 자원을 지속적으로 소비하기 때문에 조기에 트랜잭션을 커밋하면 불필요한 리소스 소모를 줄일 수 있습니다. [3]
  • 크론잡의 상태를 업데이트 후 성공적으로 크론잡에 매핑된 스케줄 로직을 실행한 이후에도 트랜잭션을 커밋합니다. 커밋이 완료된 이후에만 트랜잭션 내에서 처리된 작업 내용이 외부에 공유될 수 있기 때문에 바로 커밋해 주는 것이 매우 중요합니다. [6]
  • 트랜잭션 도중 에러가 발생한다면, 롤백으로 트랜잭션 내 수행된 모든 변경 사항을 취소하고 데이터베이스를 트랜잭션 시작 이전 상태로 되돌립니다. [7]

SELECT FOR UPDATE LOCK 적용

트랜잭션을 통해 크론잡 로직을 하나의 작업 단위로 묶었지만 여러 트랜잭션이 동일한 데이터를 동시에 읽고 업데이트한다면 여전히 동시성 문제는 남아있게 됩니다.
동시성 이슈는 SELECT FOR UPDATE LOCK을 적용해 해결했습니다. 트랜잭션 내부에서 데이터를 읽어올 때 SELECT FOR UPDATE LOCK을 걸어 다른 트랜잭션이 데이터에 접근하지 못하도록 막아둘 수 있습니다. 이를 통해 트랜잭션 간의 Race Condition을 방지할 수 있을 뿐만 아니라 데이터베이스에서 업데이트 작업의 안전성도 보장할 수 있습니다.

const cronjob = await queryRunner.manager.findOne(Schedule, {
  where: { name },
  lock: { mode: 'pessimistic_write' },
});

트랜잭션으로 크론잡 로직을 단일 작업 단위로 묶고, SELECT FOR UPDATE LOCK을 적용해 데이터베이스의 동시성을 관리할 수 있게 되면서 앙몬드 사용자들은 알림을 한 번씩만 받게 되었답니다.

알림 중복 발송 문제가 해결된 모습

⚠️ 대규모 서비스에서는 DB LOCK은 지양해야 합니다.
DB 테이블에 LOCK을 걸어두어 동시성을 제어하는 행위는 DB 성능 저하를 초래하기 때문에 대규모 서비스에서는 지양해야 할 행위입니다. 앙몬드는 사내 소규모 개발자를 대상으로 한 슬랙봇 서비스이기 때문에 별도 자원을 추가로 도입하지 않고 DB LOCK을 활용해 실용적으로 데이터 일관성을 유지하고 동시성 문제를 예방하고자 하였습니다.

문제 2. 왜 탭 고정이 안되지?

🤔 문제 상황

앙몬드 홈에는 “오늘 배운 것”, “내 보관함” 버튼이 탭 역할을 하고 있습니다. 우리가 탭에 대해 기대하는 바는 클릭한 탭이 고정되는 것이죠. 그런데 분명 유저는 “내 보관함” 탭을 눌렀지만 갑자기 “오늘 배운 것” 탭으로 넘어가는 문제가 발생했습니다.

서버 로직 상에서 “오늘 배운 것” 탭이 기본 탭으로 되어있고, 유저 클릭 이벤트 발생 시 유저의 홈 탭을 유저가 클릭한 탭으로 지정해 주었습니다. 즉, 서버 인메모리에 유저별 탭 정보를 저장하고 있었습니다. 그러나, Client-Side Rendering(CSR) 환경 안에서 사고하는 것이 너무 익숙했던 나머지 유저별 탭 정보를 서버에 저장해 두고 쓰는 것에 대한 문제점을 단번에 파악하지 못했습니다.

CSR 환경에서는 유저가 클릭했던 탭 정보가 유저 기기에 고유한 상태값으로 관리됩니다. 반면 분산 시스템 환경에서는 유저 요청이 여러 서버에 분산되어 처리되기 때문에, 서버 간 탭 정보 동기화가 어렵습니다. 이로 인해 유저가 다른 서버로 이동할 때 탭 정보가 유지되지 않아 탭 고정 기능에 문제가 발생했음을 뒤늦게 발견했습니다.

유저가 “내 보관함” 탭을 클릭한 모습

“내 보관함” 탭을 클릭한 후 “오늘 배운 것” 탭으로 넘어가는 모습

😲 해결 방법

Redis를 도입해 유저별 탭 정보를 중앙 집중식 상태로 관리하여 문제를 해결하고자 했습니다.

Redis 도입

어떤 서버로 요청이 가는지와 관계없이 일관된 유저별 홈 탭 정보를 가져오기 위해서는 모든 서버가 동시에 접근할 수 있는 빠르고 신뢰할 수 있는 중앙 집중형 데이터 저장소가 필요했습니다. 이를 위해 Redis를 도입하게 되었습니다.

데이터베이스를 사용한 상태 관리 방식 대신 Redis를 사용했던 이유는 다음과 같습니다.

  1. 빠른 읽기/쓰기 속도: 메모리 기반의 빠른 데이터 접근 속도로 유저별 탭 상태와 같이 빈번한 읽기/쓰기 작업을 빠르게 처리할 수 있습니다.
  2. 데이터 일관성: 트랜잭션 기능을 통해 모든 서버가 동일한 유저별 탭 정보를 읽을 수 있습니다.
  3. 캐싱 기능: 자주 접근하는 데이터를 캐싱하여 데이터베이스 부하를 줄이고 성능을 극대화할 수 있습니다.
    유저별 홈 탭 정보를 Redis에서 관리함으로써 서버는 Stateless해질 수 있게 되었습니다. Redis 도입 후, 탭이 고정되지 않는 문제도 말끔히 해결될 수 있었습니다.

유저가 클릭한 탭이 잘 고정되는 모습

❓ 왜 동시성 문제에는 Redis를 도입하지 않았지?
동시성 문제를 마주했을 때는 Redis까지 도입할 계획이 없어서 위에 소개한 대로 데이터베이스 LOCK으로 문제를 해결했어요. 다만, 탭 고정 이슈를 발견했을 때는 Redis 도입이 이루어진 상태라 Redis를 이용해 문제를 해결할 수 있었습니다 🎉

마치며

프론트엔드 개발자는 주로 사용자 인터페이스 개발에 집중하다 보니 서버 문제를 직접 경험하고 해결할 기회가 적습니다.

사내 사이드 프로젝트로 분산 시스템 환경에서 앙몬드 서비스를 운영하며 데이터베이스 트랜잭션과 락 개념에 대해 공부하고 서버 간 중앙 집중식 상태 관리에 대해 고민해 볼 수 있었습니다. 이 경험은 서버 동작 원리와 분산 시스템 특성을 이해하는 데 큰 도움이 됐습니다.

특히, 중복 알림 발송 문제와 탭 고정 문제를 해결하며 서버 측의 동시성 제어와 상태 관리가 얼마나 중요한지 깨달을 수 있는 시간이었어요. 기존에는 프론트엔드 개발자로서 client-side에서 사고하는 방식에 익숙했지만, 앙몬드를 분산 시스템 환경에서 운영한 경험을 통해 server-side에서 일어나는 일에 더 많은 관심을 가지게 되었습니다. 앞으로는 프론트엔드, 백엔드 양쪽 모두를 고려하여 전체적인 시스템의 성능과 안전성을 고려하여 개발할 수 있는 개발자로 성장해나가고 싶습니다.

제 글이 분산 시스템 환경에 아직 익숙하지 않은 분들에게 조금이나마 도움이 됐기를 바라며 글을 마치겠습니다.
감사합니다 🙂

Footnotes

  1. 클러스터 (Cluster)란 컨테이너화된 애플리케이션을 실행하는 노드3들의 집합입니다. Multi-Region Deployment에서는 각 지역에 클러스터를 배치합니다.

  2. 파드 (Pod)란 쿠버네티스의 기본 배포 단위로, 하나 이상의 컨테이너를 포함합니다. Multi-Region Deployment에서는 각 지역에 파드를 배치합니다.

  3. 노드 (Node)란 클러스터 내에서 애플리케이션을 실행하는 물리적 또는 가상 서버입니다.

gady.garden
gady.garden

카카오페이 자산관리 서비스의 FE 개발을 하고 있는 가디입니다. 코드의 내부 동작 원리에 관심이 많으며 기술 공유를 좋아합니다.

태그