올해 6월?부터 시작한 사이드 프로젝트 동아리 활동이 마무리가 됐다.

개인 일정 상 마지막 발표회(이 동아리에서는 데모데이라 불렀음)에 참여하지 못한 건 아쉽게 됐으나, 어찌됐든 마무리되어 시원섭섭한 느낌. 끝나고 돌아보니 이런저런 생각이 많이 든다.

 

아무래도 왜 사이드 프로젝트 동아리에 지원했는가? 그 목적은 뭐였는가? 부터 생각하게 된다. 이유는 심플하게 "성장하고 싶어서"였다. 현재 주어진 회사 업무는 여러 직군과 협업하는 것이 아니었고 내가 할 수 있는 영역에 한계가 있었기 때문에, 회사 밖에서 내가 갈증을 느끼고 있는 영역에 대한 경험을 쌓고 싶었다. 동아리 활동이 시작된 직후, 회사에서 디자이너와 PM으로 일하고 있는 동기들을 찾아가 "함께 일하고 싶은 개발자"가 어떤 사람인지 물어가며 이번 동아리 활동을 그렇게 해야겠다고 다짐했던 일들도 생각난다.

 

동아리 활동을 하며 내가 몰랐던 영역도 꽤 만나서 즐거웠다. FE파트에서 코드래빗이란 툴을 사용해 PR 리뷰를 자동화하는 걸 보며 "와 이거 회사에서도 쓸 수 있음 좋겠다"했던 기억이 있다. (분명 내가 예전에 대외활동할 땐 소나큐브로 정적분석하던게 전부였는데..) BE파트에서 같이 하던 동료의 코드를 보며 "이렇게도 할 수 있군" 했던 기억도 있다.

 

아쉬웠던 것들도 당연히 떠오른다. "좀 더 적극적으로 개입하고 제시할걸"이란 생각이 떠오른다. 동아리 활동을 하며 막바지 즈음에, 같이 하던 디자이너분이 내게 "XX님은 기획 단계에서 막 적극적으로 하진 않으시더라구요? 다른 활동에서는 백엔드 개발자 분들이 기획 단계에서도 엄청 반발하고 하던데.." 라는 멘트를 했다. 단순히만 보면, 내가 했던 서비스는 매우 심플해서 기획이 변경됐다고 기존의 설계가 뒤엎어지는 경우는 없었기 때문에 백엔드 개발자 입장에서 반발해야 하는 상황은 없던 편이었다. 하지만 스스로 많은 생각이 들게 한 멘트는 "적극적으로 하지는 않더라" 라는 거였다.

 

사실 나는 이 동아리를 하면서 가지고 있던 제일 큰 마인드는 <일단 완성시키기>였다. 소프트웨어 마에XXX 시절, 팀원들의 찐텐 싸움으로 팀이 괴멸하게 되어 실제로 눈물을 훔쳤던 기억이 내 내면에 너무 강하게 남아있었기 때문에.. "일단 완성시키면 뭐라도 된다!"라는 생각을 가지고 있었다. 그래서 기획/디자인 단계에서 "~도 필요하다, ~대한 정책을 정의해야 한다"라는 코멘트를 하는 것 외에는 그렇게까지 깊게 관여하지 않았었고, 특정 기능을 반드시 도입하자고 강하게 주장하지도 않았었다. 빠르게 방향성을 잡고 완성시켜야 한다는 생각으로 어떻게보면 기획이라는 영역에서는 다소 수동적인 자세로 임했던 것 같다.

 

내가 취했던 관점이 잘못됐다라곤 생각하진 않는다. 시간이 촉박했던만큼 빠르게 방향성을 정해서 개발해야하는 상황이었다고 생각한다. 다만 사실 나도 내가 개발하고 싶던 기능(실시간 통신)이 있던 만큼 기획에서도 적극적으로 내가 하고 싶던 걸 어필했으면 어땠을까 싶다. 단순히 "개발자로서 기술적 성취를 얻고 싶은 마음"만 가지고 특정 기능을 반드시 넣어야 한다고 밀어붙이는 건 조금 지양해야겠지만, 사실 생각해보면 실시간 통신으로 우리 서비스에 가져올 수 있던 분명한 메리트가 있었고 이걸 토대로 설득을 해봤으면 어땠을까라는 생각이다. 물론 그때 내가 설득을 했어도 어떻게 됐을지 모르지만, 그렇게 "설득을 해보는 경험"을 이 동아리에서 별로 못 해본(어떻게 보면 내가 안 한 거지만) 것이 가장 큰 아쉬움이다.

 

백엔드 개발자로서 성장하는 방법에 대해 여러 가지를 시도해왔고 지금도 고민하고 있다. "잘 하는 개발자"가 어떤 사람인가에 대해서도 항상은 아니지만 알게 모르게 계속 고민하게 되는 것 같다. 여러 기술을 잘 알고 있는 개발자. 한 영역을 깊게 파고들어서 그 영역을 누구보다 잘 아는 개발자. 문제가 발생했을 때 어디를 봐야하는지 딱 딱 내놓을 수 있는 개발자 등등.. 이렇게 하드스킬 위주로 연마를 해야한다고 생각해왔지만, 요즘 드는 생각은 소프트 스킬이 중요하다는 것이다. 결국 엔지니어는 본인이 연마하고 익힌 기술들을 토대로 문제를 해결하는 사람이므로, 기술을 연마하는 것도 중요하겠지만 결국 "풀어야 할 문제를 정확히 식별"할 수 있어야 한다는 생각이다. 그렇기 위해선 커뮤니케이션을 잘 해야 하고, 팀원들을 잘 설득할 수 있어야 한다. "문제가 이거니까 이런이런 기술을 사용하면 ~하게 해결할 수 있어요!"라고 말할 수 있어야 한다. 물론 이렇게 말해도 그 상황에서는 오답일 수 있을 것이다. 하지만 배가 움직이려면 그 순간엔 잘못된 방향이어도 누군가는 결국 키를 잡아야 하듯이, 이렇게 내 생각을 밖으로 꺼내며 소통해야만 더 나은 해결책으로 귀결될 수 있다. 그렇기 때문에 참 뻔한 말이지만 잘 하는 개발자는 소프트 스킬이 좋은 개발자라고 주변에서 말하는게 아닐까라는 생각이다.

 

하지만 나는 내 생각을 밖으로 꺼내서 소통하는 것에 막 익숙하진 않은 것 같다. 회사에서도 내 생각을 밖으로 꺼내서 팀원들을 설득해야 하는 상황이 실제로 곧 생긴다. 앞으로 개발자로 살면서, 어느 회사가 됐든 소속 팀의 팀원들과 소통하면서 설득하는 일이 정말 많을 것이다. 비개발자 팀원들에게도 설득하는 일이 생길 수 있고, 당장 팀원들부터 설득하지 못한다면 내가 일하고 싶어하는 서비스회사들의 고객들을 설득하는 것은 더 힘들 수 있다.

 

그렇기 때문에, 이번 사이드 프로젝트에서 그런 "설득하는 경험"을 별로 못 해본 것 같아 참 아쉽다. 기술적인 의사결정이 아니더라도 어떤 형태로든 내 의견과 생각을 피력하고 설득할 수 있었던, 그런 지점들이 생각난다. 앞으로는 적극적으로 내 생각과 의견을 피력하는 태도를 가져야 할 것 같다.

 

 

 

 

 

제가 활동한 동아리는 젝트라는 동아리로, 관심있으신 분들은 지원해보셔도 될 것 같습니다.

https://ject.kr

 

 

 

 

 

 

'생각정리, 일상' 카테고리의 다른 글

간만의 사이드 프로젝트 중간 회고  (1) 2025.08.31

1. 트리거란?

RDB에서 특정 테이블에 이벤트(CREATE or UPDATE or DELETE)가 발생했을 때 수행할 작업들을 정의할 수 있는데, 이를 트리거라고 부릅니다. 즉 DB 테이블에 어떠한 변경이 일어날 때 이를 방아쇠(Trigger)삼아 미리 정의된 SQL 코드가 자동으로 실행되는 것으로 이해할 수 있습니다.

 

트리거는 실행 시점을 기준으로 크게 다음과 같이 2종류로 구분할 수 있습니다.

 

1) BEFORE 트리거

UPDATE, INSERT, DELETE 실행 전에 실행되는 트리거입니다. 애플리케이션으로부터 온 잘못된 요청을 거부하는 방식으로 활용 가능하며, UPDATE와 INSERT의 경우 실제 값이 수정/삭제되기 전 그 값을 가공할 수도 있습니다.

 

2) AFTER 트리거

UPDATE, INSERT, DELETE 실행 후에 실행되는 트리거입니다. 주로 변경된 데이터를 기반으로 다른 테이블을 업데이트하는 후속 조치를 남기는 식으로 많이 활용합니다.

 

참고) BEFORE 트리거든 AFTER 트리거든, 트리거에 정의된 작업들은 트리거를 실행시킨 작업과 같은 트랜잭션에서 수행됩니다. 즉 트리거가 실패하면 트리거를 실행시킨 작업도 실패합니다. (https://dev.mysql.com/doc/refman/8.0/en/trigger-syntax.html)

 

 

만약 트리거를 적용 범위 수준으로 구분하면 다음과 같이 2종류로 구분 가능합니다.

 

1) Row-level 트리거

이벤트가 발생된 행별로 수행되는 트리거입니다. 예를 들어 UPDATE절에 의해 10개의 행이 바뀌면, 이 트리거는 10번 실행됩니다.

 

2) Statement-level 트리거

변경된 행 수에 관련없이 조작되는 전체 문장(statement)단위로 실행되는 트리거입니다. 전체 배치 작업 전/후 하나의 로직만 실행하면 되는 경우 등에 사용할 수 있습니다. (MySQL의 경우 지원하지 않습니다)

 

참고) SQL Server나 Oracle의 경우 DDL에도 트리거를 만들 수 있습니다.

 

 

2. 트리거 생성 방법 (MySQL 기준)

트리거 생성은 CREATE TRIGGER를 통해 수행할 수 있고, 해당 테이블에 대한 Trigger 권한이 필요합니다. 세부적인 생성 문법은 다음과 같습니다.

 

-- 대괄호는 Optional을 의미합니다.

CREATE
    [DEFINER = user]
    TRIGGER [IF NOT EXISTS] trigger_name
    trigger_time trigger_event
    ON tbl_name FOR EACH ROW
    [trigger_order]
    trigger_body

-- 각 필드
-- 1. trigger_time: { BEFORE | AFTER }
-- 2. trigger_event: { INSERT | UPDATE | DELETE }
-- 3. trigger_order: { FOLLOWS | PRECEDES } other_trigger_name

 

  • DEFINER : 트리거 정의자를 어떤 계정으로 할 것인지 지정합니다. 트리거는 실행되면 트리거를 실행시킨 계정의 권한이 아니라 트리거를 정의한 사람의 권한으로 실행되는데, 그때의 트리거 정의자 계정을 지정하는 것으로 이해할 수 있습니다. 이 필드를 따로 작성하지 않으면 트리거를 생성한 사람이 자동으로 DEFINER가 됩니다. DEFINER로 지정되는 계정에는 해당 테이블(트리거가 붙는 테이블)에 대한 TRIGGER 권한이 필요하며, trigger_body에서 참조되는 테이블들에는 TRIGGER 권한은 필요없으나 적절한 DML 권한이 필요합니다.
  • trigger_name : 트리거의 이름으로, 같은 스키마 내에서 트리거 이름은 유일해야 합니다.
  • trigger_time : 트리거의 실행시점을 지정합니다.
  • trigger_event : 트리거를 발생시킬 이벤트 종류를 지정합니다.
  • trigger_order : 같은 테이블의 동일한 이벤트에 대한 트리거가 여러 개일때, 각각의 실행 순서를 지정합니다. FOLLOWS 사용시 지정한 트리거 실행 후에 실행되고, PRECEDES 사용시 지정한 트리거 실행 전에 실행됩니다. 참고로 MySQL 5.7버전부터만 동일 이벤트에 대한 복수의 트리거 정의가 가능합니다.
  • trigger_body : 실행할 작업을 정의합니다. BEGIN ... END를 작성하면 여러 작업을 정의할 수 있습니다.

 

trigger_body에서는 OLD와 NEW라는 키워드를 통해 변경 전후의 데이터에 접근할 수 있습니다.

 

  • OLD : UPDATE 또는 DELETE 문에서 변경/삭제 전의 기존 데이터를 가리킵니다. OLD.column_name을 통해 해당 데이터의 컬럼값에 접근할 수 있습니다.
  • NEW: INSERT 또는 UPDATE 문에서 새로 추가되거나 변경될 행의 데이터를 가리킵니다. NEW.column_name을 통해 해당 데이터의 컬럼값에 접근할 수 있습니다.

 

UPDATE 문에서는 OLD & NEW를 둘 다 접근할 수 있는데, 당연히 BEFORE 트리거에서는 NEW만 수정 가능합니다.

 

위 내용들을 토대로 트리거 생성 예제를 보면 다음과 같습니다. employees 테이블에 있는 직원 연봉(salary)가 변경될 때마다 변경내용을 salary_audit 테이블에 기록하는 AFTER 트리거입니다.

 

-- 트리거 생성도 세미콜론으로 끝나는데, trigger_body 내에서 사용되는 세미콜론이 트리거 종료로 인식되는걸 방지
DELIMITER $$

CREATE DEFINER = 'admin_user'@'localhost'
TRIGGER after_salary_update
AFTER UPDATE ON employees
FOR EACH ROW
BEGIN
    -- 연봉이 변경되었을 때만 로그를 남김
    IF OLD.salary <> NEW.salary THEN
        INSERT INTO salary_audit (emp_no, old_salary, new_salary, changed_by)
        VALUES (OLD.emp_no, OLD.salary, NEW.salary, USER());
    END IF;
END$$

DELIMITER ;

 

여기서 질문. USER()를 통해 변경을 실행한 DB 계정을 기록하는 건 알겠는데, 트리거를 실행시킨 DB 계정이 기록될까요 아니면 DEFINER로 정의된 DB계정이 기록될까요?

 

실제 예제를 통해 보겠습니다. 현재 employees 테이블에 다음과 같은 레코드가 있습니다.

이쁘게 이미지로 만들었습니다 ㅋㅋ

 

그리고 admin_user, app_user 계정을 만든 뒤 다음처럼 권한을 부여했습니다.

 

  • admin_user
    1. employees 테이블에 대한 SELECT, TRIGGER 권한
    2. salary_audit 테이블에 대한 INSERT 권한
  • app_user
    1. employees 테이블에 대한 SELECT, INSERT 권한

 

그리고 app_user 계정으로 접속하여 salary를 20000으로 바꿔주었습니다.

salary_audit 테이블에 적재된 결과는..

 

다음과 같이 트리거 정의자 계정이 아닌, 트리거를 실행시킨 DB 계정이 기록된 것을 볼 수 있었습니다.

사실 이 내용은 MySQL 공식 문서 내에서도 확인 가능합니다 (https://dev.mysql.com/doc/refman/8.0/en/create-trigger.html)

 

 

3. ⭐️ 어디에, 그리고 왜 트리거를 쓸까

우선 트리거는 특정 테이블에 이벤트가 발생됐을 때 수행하고 싶은 기능을 자동화하는 것으로 볼 수 있습니다. 애플리케이션에서 반복 작성해야 할 로직을 트리거를 쓰면 한 번만 구현하면 되고, 중요한 데이터에 대한 감사 로깅이 필요한 경우 트리거를 통해 별도의 로그 테이블에 자동으로 기록하게 하거나, 유저 테이블의 회원 상태가 '탈퇴'로 변경됐을 때 관련된 게시판 테이블의 게시물 상태들을 '비공개'로 변경하는 작업 등에 활용 가능합니다. 외부로의 이벤트/메시지 전파를 위해 같은 트랜잭션 내에서 비즈니스 테이블에 데이터를 연동하고 outbox 테이블에 발행할 이벤트를 적재하는 '트랜잭셔널 아웃박스 패턴'의 구현 방법으로 트리거를 채택할 수도 있습니다.

 

하지만 단순히 '자동화'를 넘어서 트리거의 핵심 역할을 DB 스스로가 데이터의 정합성과 일관성을 유지하도록 강제하는 것으로 바라볼 수 있습니다. 장애라고 하는 것은 트래픽으로 인해서 뿐만 아니라 여러 원인으로 인해 논리적으로 잘못된 형태의 데이터가 적재되면서도 발생할 수 있는데요. 물론 개발자가 애플리케이션 레벨에서 최대한 조심하며 개발할 수 있겠지만 결국 개발자 스스로도 휴먼 에러를 낼 수 있고(예상치 못하게 로직이 꼬인다든가.. 등) 이로 인해 잘못된 데이터가 들어올 수 있습니다. 물론 테이블에 CHECK 등의 제약 조건을 붙이며 최대한 데이터 무결성을 지킬 순 있겠으나, 'VIP 단계의 고객이 주문한 아이템들의 총액은 10,000원을 넘어야 한다' 등의 복잡한 비즈니스 제약들을 표현하고 지키기는 힘듭니다.

 

이럴 때 트리거를 사용해서 복잡한 데이터의 유효성 검사를 담당하게 할 수 있고, 단순 참조 무결성을 넘어서 복잡한 비즈니스 제약을 DB 스스로가 지키도록 강제할 수 있습니다. 또한 트리거 정의시 작성하는 DEFINER 계정에만 A 테이블에 대한 권한을 준다면, 해당 트리거 실행을 제외하고는 A 테이블에 데이터가 넣어질 수 없도록 아예 틀어막아 버릴 수도 있습니다. (개발자 실수 등이 생겨도 잘못된 데이터가 들어가는 일 자체가 안 생기게 됨)

 

그렇다고 트리거가 늘 좋냐!고 하면 당연히 아닙니다. 다음과 같은 단점들이 있습니다.

 

1) 성능 저하

애플리케이션에서 실행한 DB 작업 외에도 트리거를 통한 부가 작업이 수행되는 만큼, 성능 저하가 발생됩니다. 특히 MySQL은 Row-level 트리거이므로 1,000개의 행이 갱신되면 트리거가 1,000번 실행되므로.. 만약 트리거가 부착된 테이블의 변경이 잦다면 그만큼 성능 오버헤드도 비례해서 올라갑니다.

 

2) 불투명성

"A 테이블에 데이터를 넣으면 B 테이블에도 데이터를 추가한다" 라는 비즈니스 로직이 있다고 가정해보겠습니다. 이 비즈니스 로직이 애플리케이션 레벨에서 우리에게 친숙한 자바 코드 등으로 작성되어 있다면 처음 서비스를 접하는 개발자도 이런 로직이 있다고 쉽게 파악할 수 있습니다. 하지만 트리거를 통해 DB 레벨에서 이 비즈니스 로직이 구현되어 있다면, 개발자가 이런 비즈니스 로직이 있다고 인지하기도 어려울 뿐더러 문제 발생 시 디버깅도 어렵게 됩니다. 더 나아가 한 테이블에 부착된 트리거들이 FOLLOWS 등을 통해 여러 선후행 관계를 가진다면 골치가 더 아파지게 됩니다.

 

3) 작동 확인 방법의 번거로움

트리거는 작동을 확인하기 위해 명시적으로 실행해볼 수 있는 방법이 없어서, 잘 동작하는지 확인하려면 직접 부착된 테이블에 INSERT나 UPDATE, DELETE를 해줘야 합니다.

 

트리거 사용의 장단점을 표로 정리하면 다음과 같습니다.

 

장점 단점
1. 강력한 데이터 무결성 보장이 가능
2. 관련 작업을 자동화하여 개발 편의성 증가
3. 비즈니스 로직의 중앙화 (여러 앱이 같은 DB를 공유시, 모두에게 같은 규칙을 적용)
1. 성능 저하 유발 가능
2. 비즈니스 로직이 드러나지 않게 됨
3. 디버깅 복잡도 증가
4. 작동 확인을 위해 명시적으로 실행하는 방법이 부재

 

 

그러면 트리거는 어떤 상황에 쓰면 좋고 어떤 상황에는 안 쓰는게 좋을까요? 개인적으로 생각해본 건 다음과 같습니다.

  • 써도 괜찮을 때
    1. DB에 직접 접근해서 데이터를 수동으로 수정하는 경우가 많아, 휴먼 에러를 방지하고 데이터 무결성을 반드시 지켜주고 싶을 때 (앞서 말한 것처럼 DEFINER로 작성하는 계정에만 특정 테이블에 대한 권한을 주는 식으로 등..)
    2. 여러 애플리케이션이 하나의 DB를 공유하고 있어서 모두에게 동일한 규칙을 강제하고 싶을 때
  • 쓰면 안 좋을 때
    1. 트리거를 부착하고 싶은 테이블의 데이터 변경이 잦아서 성능 저하가 예상될 때 (MySQL은 특히나 Row-level 트리거이므로)
    2. 비즈니스 로직이 자주 변경될 때 (대부분의 경우 애플리케이션 코드를 수정하는게 더 쉬울 것 같습니다)
    3. 서비스의 로직을 애플리케이션에서만 관리하는게 좋다고 판단될 때

 

4. 알아두면 좋은 트리거 주의사항

  1. "INSERT INTO ... ON DUPLICATE SET" 는 중복이 없다면 BEFORE INSERT와 AFTER INSERT 이벤트를 발생시키고, 중복이 있다면 BEFORE UPDATE와 AFTER UPDATE 이벤트를 발생시킵니다.
  2. 테이블에 외래키 관계에 의해 자동으로 변경된 경우는 트리거가 호출되지 않습니다.
  3. 트리거 작성 시 BEGIN .. END 블록 내에서는 ROLLBACK / COMMIT 등 트랜잭션을 명시적으로 시작하거나 종료하는 SQL 문장을 작성할 수 없습니다.
  4. mysql과 information_schema, performance_schema 데이터베이스에 존재하는 테이블들에는 트리거를 생성할 수 없습니다.

 

 

5. 레퍼런스

https://dev.mysql.com/doc/refman/8.4/en/trigger-syntax.html 

https://dev.mysql.com/doc/refman/8.4/en/create-trigger.html

https://docs.oracle.com/en/database/oracle/oracle-database/19/lnpls/dml-triggers.html#GUID-E76C8044-6942-4573-B7DB-3502FB96CF6F

https://www.ibm.com/docs/en/ias?topic=triggers-types

 

 

올해 사이드 프로젝트를 통한 역량 향상의 필요성을 느끼고, 간만에 사이드 프로젝트 동아리를 운좋게 합격해서 들어갔습니다.

현재 MVP 배포를 끝낸 상태에서, 5월 중순에 진행한 온보딩 기간부터 현 시점까지의 회고를 짧게나마 작성해보려 합니다.

 

온보딩 ~ MVP 배포 기간 동안 마주한 상황들에 대한 해결 과정

제가 올해 사이드 프로젝트를 해야겠다고 생각한 것은, 기술 역량 향상도 목적이 있긴 했으나 협업 상황에서 발생하는/발생 가능한 여러 상황들에 대해 능동적으로 다양한 방법들을 써보며 "아 이런 상황에선 이런 방법을 쓰면 더 효율적으로 일을 굴릴 수 있구나" 같은 것들을 익히고 싶던 것도 있습니다. 그래서 이번 프로젝트에서 나름대로 여러 가지를 먼저 제안하고 공유했는데, 그런 것들 위주로 회고글을 작성해봤습니다. (그래서 기술적인 관점에서 고민했던 것들은 최대한 이 글에서는 배제하고, 일을 굴리는 관점에서 고민했던 곳들 위주로 작성했습니다)

 

1. 기능명세서와 와이어 프레임 등이 미흡했던 문제

기획팀으로부터 작성된 기능명세서를 받았으나 개발 관점에서 보면 정의가 좀 더 필요하다고 생각되는 부분들이 보였습니다.

처음에 받은 기능명세서

 

구체적으로 이 기능이 동작할 때 어떤 데이터들을 사용하고 어떤 데이터들을 받는지, 이 기능이 어떤 흐름과 조건에서 동작하는지, 어떤 유효성과 예외가 있는지에 대한 정의가 다소 부족하다고 느꼈습니다. 또한 세부적인 정책 정의가 필요하다고 느끼는 부분들도 있었습니다(~를 하면 ~를 할 수 없게 한다 등).

 

암튼 이런 부분들을 기획팀에게 되물어봐서 정확한 스펙 정의를 해야 했는데, 이걸 4명의 개발자 각자가 기획팀에게 물어보면 불필요한 핑퐁이 더 많아지고 오히려 정리하기 더 힘들어질 것 같다고 생각했습니다. 그래서 노션 페이지를 파서 개발자끼리 기능 명세서에 추가적으로 정리가 필요하다고 생각되는 부분들을 취합하고 이 문서를 기획팀으로 전달하자고 제안을 드렸는데요, 다들 좋다고 해주셔서 이 방법을 적용했습니다.

 

추가 정의가 필요한 사항들을 취합했던 문서

 

그리고 프로젝트 진행 과정 중 전반적으로 기획팀과 개발자 사이에 "이거는 뭘로 해야 해요?"라던지 "이거는 어떤 값 써야 해요?"를 주고받으며 핑퐁하는데 시간도 많이 걸리고 서로 스트레스도 많이 받는 것 같다고 느꼈습니다. 사실 백엔드는 화면에 대한 영향도를 크게 받지는 않지만 프론트엔드는 화면에 대한 영향도도 크게 받기 때문에, 특히 프론트엔드 개발자와 기획팀 간에 이런 일이 많은 듯 합니다. 뭐 암튼.. 그럴 때는 차라리 개발자들이 먼저 기획팀에게 본인들이 원하는 형태로 기능명세서나 화면 설계서 등을 작성해달라고 요청해주는 게 좋겠다는 생각이 들었습니다. 아무래도 PM이나 PD 직군은 개발을 직접 해보지 않은 이상 개발 친화적(?)인 문서 작성을 처음부터 하기에는 힘들 것 같기 때문에 아싸리 처음부터 개발자들이 원하는 형태의 기능명세서 템플릿을 드리면 어떨까라는 생각입니다. 저는 개인적으로 기능명세서는 다음과 같은 템플릿으로 작성되면 좋겠다는 생각이 이번에 든 것 같습니다.

 

내가 생각하는 개발 친화적(?)인 기능 명세서

 

물론 하나의 예시긴 하나, 위 형태로 기능명세서가 작성된다면 데이터의 흐름(입력과 출력)이 잘 드러나고 예외와 조건도 잘 명시되어 있다고 생각합니다. 그래서 다음에 또다시 사이드 프로젝트를 하게 된다면 아싸리 처음부터 이런 양식으로 만들어달라고 해보는 것도 괜찮다는 생각입니다.

 

 

2. 기획/디자인 기간이 늘어나던 문제

대부분 기획단계의 산출물이 나와있어야 개발이 가능하기 때문에 기획과 디자인이 기간이 늘어나면 개발자는 붕 뜨게 될 수도 있는데요, 개인적으로 프로젝트 극 초기부터 이런 상황이 발생할 수 있다고 생각했습니다. 그래서 제가 적용했던 방법은 처음부터 개발자 분들끼리 서로 먼저 할 수 있는 것들을 해두자고 말한 것입니다. 동일직군 개발자끼리 서로 사용할 기술스택과 버전을 맞춰 둔다든가, 커밋 컨벤션 등을 정해둔다든가, 프로젝트 공통 설정 세팅을 미리 한다든가 등등.. 또한 개인적으로 로그인/회원가입 기능은 MVP 단계에서 필요없다고 생각하지만 기획과 디자인 기간이 생각보다 더 늘어난다면 미리 만드는 게 좋겠다 싶어 이것도 미리 해보자고 할려고 했으나, MVP 단계에서 로그인 기능을 넣는 것이 다소 늦게 결정되어 로그인 기능을 미리 만들지는 못 했습니다 (프로젝트 설정을 좀 더 빨리 했으면 미리 만들 수 있었겠다라는 아쉬움이 있네요)

 

 

3. 프로젝트 공통 설정에 대한 문제

백엔드 파트는 이번에 멀티 모듈을 도입하여 프로젝트를 해보기로 했고, 이에 대한 프로젝트 세팅을 제가 하게 됐습니다. 하지만 함께 협업할 백엔드 개발자 분이 Spring을 주력으로 하시던 분이 아니기도 했고, 멀티 모듈 프로젝트가 생소한 개념일 수 있어 많이 헷갈려 하실 수 있겠다는 생각이 들었습니다. 그래서 프로젝트 세팅에 대한 가이드 문서를 만들면 좋겠다는 생각에 다음과 같이 노션에 프로젝트 가이드 문서를 만들어서 공유드렸습니다.

 

다이어그램도 열시미 그렸습니다!

 

4. api 명세와 배포 전 api 테스트 방안에 대한 문제

프론트엔드 분들이 어떻게 백엔드 파트에서 개발한 api에 대해 연동 테스트를 하도록 할지도 고민이었습니다. 회사라면 스테이징 서버와 개발 서버를 운영하고 있기에 개발 서버에 백엔드 파트에서 개발한 내용을 배포해두면 프론트엔드 분들이 개발 서버로 테스트를 해볼 수 있지만, 사이드 프로젝트에서는 비용 이슈로 그렇게 하기는 힘들었습니다. 그렇다고 프론트엔드 분들이 본인 로컬에 백엔드 서버를 직접 띄워서 테스트하는 건 비효율적이라 생각해서 같이 회의를 했었는데, 백엔드 파트에서 OpenApi 스펙의 yaml파일을 만들어서 주면 프론트 파트에서 Mock서버를 만들어서 테스트하는 식으로 합의를 보게 됐습니다.

 

우선은 개발에 들어가기 전에 노션을 통해 api 명세 문서를 만들었고, ai의 힘을 활용해 노션에 작성했던 명세를 openapi 스펙의 yaml로 변환하여 프론트 파트로 전달했습니다. 그리고 api 명세 수정이 있을 때마다 명세 불일치 문제를 만드는 것을 피하기 위해 즉시 노션에 있던 명세 문서를 수정하고 yaml파일을 바꿔서 다시 전달드리는 식으로 운영했는데, 수기로 노션 문서까지 수정해야 하니 당연히 불편했습니다. 그래서 yaml파일을 커밋하면 레포지토리에 커밋된 yaml파일을 읽어 redoc 문서를 정적으로 배포해주는 워크플로우를 등록해서 다음과 같이 api 명세서 문서를 운영하도록 전략을 바꿨습니다.

api 명세 수정 시 더 이상 노션은 수기로 건드리지 않아도 됨
 

다만! yaml파일은 아직 수기로 수정하고 있습니다. 백엔드 단 기능 변화가 있을 때마다 그에 따라 yaml파일을 자동으로 재추출하도록 워크플로우를 등록하면 아예 api 명세 문서 배포 플로우를 자동화할 수 있지만 이 부분은 우선순위 이슈로 현 시점에서는 아직 설정을 안 했습니다.

 

 

5. 기능 흐름에 대한 세부적인 정리가 필요했던 문제

대개의 경우 신규 리소스 등록(Create) 시 해당 리소스의 key값을 클라이언트에서 지정하지 않습니다. 하지만 저희 서비스는 게임을 저장할 때 해당 게임을 구성하는 이미지들의 경로도 함께 저장해야 하며, 이 이미지들은 백엔드에서 준 S3 presigned url에 업로드하게 되는데요. 이때 저는 관리의 용이성을 위해 S3 버켓에 해당 게임 아이디(key값)로 디렉토리를 만들고 그 하위에 다음과 같이 저장하도록 하고 싶었습니다.

 

 

다만 이렇게 할려면 백엔드에서 클라이언트로 presigned url을 발급해줄 때 해당 게임이 사용하는 gameId(key값)를 서버가 알고 있어야 하는데요. 이때 문제는 게임을 신규로 등록하는 경우는 gameId를 어떤 걸 쓰는지 모르기 때문에, 게임을 신규로 등록할 때 게임이 사용하게 될 gameId를 미리 지정한 다음 다음 이를 활용해  presigned url을 발급하는 식으로 게임 등록 흐름을 설계했습니다.

 

이렇다보니 게임을 신규로 등록할 때 해당 게임이 사용하는 key값을 클라이언트가 요청 데이터에 함께 보내는 형태가 되기도 했고, 프론트엔드 분들 입장에선 presigned url에 이미지를 저장하는 것이 생소한 흐름일 수 있겠다는 생각이 들었습니다. 그래서 게임을 등록하는 흐름에 대한 명세 느낌의 문서를 만들면 좋겠다는 생각에 다음처럼 문서를 만들어 공유드렸습니다.

 

 

 

프론트엔드 개발자 분이 다음처럼 메시지 남겨주셨었는데 나름 뿌듯한 순간이었습니다 :)

 

 

6. 배포 후 예상치 못하는 상황이 생길 수 있던 문제

MVP 배포 전, 프론트 서버와 백엔드 서버가 미리 배포된 상태에서 서로 연결이 잘 되는지 등을 테스트해보면 좋겠다는 생각이 들었으나, 이런저런 이슈들로 배포 일정이 조금씩 연기되며 해당 테스트들을 여유있게 할 수 없던 상황이 됐습니다. 대표적으로 예상되는 문제로는 프론트에서 백엔드 서버로 HTTP 요청이 안 간다든지, S3 이미지들이 로딩이 안 된다든지, CORS 문제가 생긴다든지 등이 있었습니다. 또한 백엔드 프로젝트는 local 프로필에서는 H2 DB를 사용하도록 설정한 것 등이 있었기 때문에 prod 프로필에 대한 설정을 다 마쳤는지 등도 배포 시에 점검이 필요한 상황이었습니다. 그래서 아예 이런 이슈들이 발생할지를 체크리스트로 만들어 활용하면 좋을 것 같아서 별도 문서로 만들어서 공유했습니다.

 

 

 

그래서 다행히(?) MVP 배포 후 이미지가 로딩이 안 된다든지 등 설정 미흡으로 인한 이슈는 없었습니다.

 

또한 아무래도 제가 평일 낮에는 회사에 있다보니 이때 이슈가 터지면 대응이 어렵겠다는 생각이 들어, ERROR 레벨의 로그는 디스코드를 통해 발송하도록 추가 설정을 개발해서 적용해뒀습니다.

 

 

 

다음 번에 이렇게 하면 좋겠다 싶은 점, 이런 거 있으면 좋겠다 싶은 것들

1. PR 리뷰 자동화

사실 저희 팀 프론트 분들이 코드래빗이라는 서비스를 통해 PR을 자동화한 걸 보고 "오 이거 괜찮은데?"라는 생각이 들었습니다. 물론 돈을 내야 하는 서비스긴 하나 그럴 만한 가치가 있다는 생각이 들었는데요, 다음에 프로젝트를 할 때는 초기에 이런 거를 제안해서 세팅해서 사용하면 괜찮다는 생각이 들었습니다.

 

2. 좀 더 효율적인 API 명세 작성과 공유 방법

아무래도 개발한 것이 당장 없는 상태에서 API 명세서를 먼저 만들기 위해 노션을 통해 명세 문서를 만든 다음 AI를 통해 OpenAPI 스펙의 yaml파일로 변환하여 사용했는데, 이 과정에서 좀 더 효율적인 방법은 없었을까 라는 생각이 듭니다. 프로젝트 설정을 하는 초기에 빠르게 yaml파일을 구축하는 방법이 있을지 좀 더 고민해야 할 것 같네요.

 

3. 테크리더의 필요성

아무래도 개발자들 각각이 기획팀과 소통하는 것보다는 개발자들 의견과 상황을 취합해서 기획팀과 소통하는 구조가 효율성 측면에서 더 나을 것 같다는 생각이 중간중간 든 적이 있어서, 그런 역할을 하는 테크리더가 있으면 어떨까라는 생각이 들었습니다. 백엔드 파트 리더, 프론트 파트 리더로 분리해도 될 것 같기도 하네요. 다만 오히려 테크리더를 거쳐서 의견이 전달되는 구조가 안 좋게 작용할 수도 있겠다는 생각이 한편으로 들기도 합니다. 그래도 효율성 측면에서의 장점이 있긴 할 것 같아서 다음 번에 프로젝트를 하게 되면 초기 단계에서 테크리더를 선출해보는게 어떻냐는 의견을 내보려고 합니다.

 

 

 

 

감상

간만에 사이드 프로젝트 동아리에 참가해서 퇴근 후에도 시간을 투자해서 진행하고 있습니다. 기술 역량 향상, 일 굴리는 방법 습득 등의 목적(?)이 있어서 신청했기 때문에 최대한 적극적으로 하고 있는데, 사이드 프로젝트라는 특성 상 녹록치 않은 점들도 분명 있는 것 같습니다. 하지만 그런 과정에서도 기술적으로 안 해본 기술을 사용하며 배우는 것도 있고, "아 이렇게 해보니까 좋네", "아 이렇게 하는 것보다 이렇게 하는 게 낫네" 등 일을 굴리는 관점에서도 배우는 것들이 있어서 개인적으로 만족하고 있습니다. 현재 고도화 기간이기 때문에 최대한 고도화 기간에 집중하고, 내년에도 기회가 된다면 사이드 프로젝트를 해보고 싶다는 생각입니다.

 

 

 

 

 

 

이 활동은 젝트에서 진행한 프로젝트에 대한 회고입니다. 관심 있으신 분들은 둘러보세요.

https://ject.kr

 

젝트

젝트(JECT)는 다양한 포지션 멤버들과 협업할 수 있는 IT 사이드 프로젝트 동아리예요. 홈페이지에서 더 자세한 내용을 확인해보세요!

ject.kr

 

 

'생각정리, 일상' 카테고리의 다른 글

이상적인 개발자에 대한 고민  (0) 2025.10.22

들어가며

BE 개발자라면 DB를 공부하면서 트랜잭션 격리 수준(Transaction Isolation Level)을 접한 적이 있을 겁니다. 저 역시 취준 시절 기술 면접을 준비하면서, 그리고 DB를 공부하면서 트랜잭션 격리 수준을 공부한 적이 있는데요. 사실 공부한 적만 있지 사이드 프로젝트 또는 실무에서 트랜잭션 격리 수준으로 인한 문제를 마주쳤던 적은 없었습니다. 하지만 이번에 사내에서 낙관적 락으로 동시성 문제를 방지하는 기능을 개발하는 과정에서 트랜잭션 격리 수준에 대해 어설프게 알고 있던 덕에 뜻밖의 곤욕을 치르게 됐는데요. 아무래도 저처럼 이론으로만 트랜잭션 격리 수준을 공부했던 분들에게 도움이 될 수도 있는 내용인 것 같아 공유하고자 이 글을 쓰게 됐습니다.

 

 하기 내용은 트랜잭션 격리 수준, 그 중에서도 REPEATABLE_READ에 대한 지식이 있음을 바탕으로 작성했습니다.

 보안 문제 등으로 제가 담당한 시스템의 정확한 명칭과 세부 업무, 기능은 언급하지 않고자 했습니다. 이 점 양해 부탁드립니다.

 

 

개발 과정 & 고민했던 점

제가 이번에 개발한 기능은 "통보서 중복 작성 방지" 기능이었습니다. 기존 통보서 작성 프로세스는 다음과 같았습니다.

 

  1. 통보서를 보낼 업무(BUSINESS_INFO 테이블의 레코드)에 대해 특정 프로세스 진행
  2. 고객에게 보낼 통보서 내용을 DB에 INSERT ( = 통보서 작성)
  3. 해당 업무의 통보상태를 NOT_REGISTER(통보 내용 미작성)에서 REGISTER(통보 내용 작성)으로 UPDATE

 

해당 비즈니스 로직은 코드 상에서는 다음처럼 구현되어 있었습니다.

 

@Transactional
public void processNotice(String bizSeqNbr) {
    // 통보서를 보낼 업무 정보 조회
    BusinessInfo businessInfo = readBusinessInfo(bizSeqNbr);

    // .. 생략 (특정 프로세스 진행)

    // 고객에게 보낼 통보서 내용 INSERT
    insertNotice(notice);

    // 해당 업무의 통보상태를 UPDATE
    updateBusinessNoticeStatus(bizSeqNbr, NoticeStatus.REGISTER);
}

 

-- updateBusinessNoticeStatus

UPDATE BUSINESS_INFO
SET NOTICE_STATUS    = #{BUSINESS_NOTICE_STATUS}
  , LAST_CHANGE_ID   = #{EMPNO}
  , LAST_CHANGE_DTTM = NOW()
WHERE BUSINESS_SEQUENCE_NUMBER = #{BIZ_SEQ_NBR};

 

 

통보서 작성 후 해당 업무의 통보상태(BUSINESS_INFO.NOTICE_STATUS)를 REGISTER로 갱신하므로, 중복 작성을 방지하는 가장 쉬운 방법은 해당 업무의 통보상태 값이 REGISTER라면  통보서를 작성하지 않도록 하는 것이라고 생각했습니다. 하지만 단순히 이 상태 조회 체크 로직만 둔다면 동시성 문제가 발생할 수 있으니 이를 막기 위해서는 업무 정보를 조회하는 시점부터 비관적 락을 적용하는 방법도 있었으나, 이 시스템은 소수의 직원이 사용하는 사내 시스템이었기 때문에 중복 작성이 흔하게 일어나진 않을 거라 판단하여 다음과 같은 낙관적 락 형태를 적용한 쿼리를 활용하기로 했습니다. 

 

-- updateBusinessNoticeStatusToRegister

UPDATE BUSINESS_INFO
SET NOTICE_STATUS    = #{BUSINESS_NOTICE_STATUS}
  , LAST_CHANGE_ID   = #{EMPNO}
  , LAST_CHANGE_DTTM = NOW()
WHERE BUSINESS_SEQUENCE_NUMBER = #{BIZ_SEQ_NBR}
AND NOTICE_STATUS = 'NOT_REGISTER'; 

-- version 등의 컬럼을 추가할 수 없던 상황이라.. 통보서 작성 전의 통보상태는 NOT_REGISTER임을 활용
-- 갱신된 레코드가 0개라면 예외를 일으켜 롤백

 

 

그러나 사용 중인 DB(=MySQL)은 트랜잭션 격리 수준으로 디폴트인 REPEATABLE_READ를 사용중이었습니다. 동일 트랜잭션에서는 여러 번 SELECT를 해도 항상 같은 결과가 나오도록 보장하는 격리 수준으로, 트랜잭션 시작 후 최초로 읽기 시작한 시점의 스냅샷을 참조하는 원리인데요. 저는 이 스냅샷을 UPDATE 시에도 동일하게 활용할 것이라고 생각해서, 다음처럼 낙관적 락을 적용해도 통보서를 중복으로 작성되는 문제가 생길 수 있다는 생각이 들었습니다.

 

 

하지만 여러 번의 테스트 결과, 중복 갱신은 발생하지 않았습니다. 벌어지지 않을 일에 대해 "헉 이거 문제 생기는 거 아냐?"라고 생각하고 있던 거죠.. 근데 왜 발생하지 않았을까요? 저는 트랜잭션 안에서 SELECT와 UPDATE가 같은 시점의 데이터(스냅샷)를 바라보고 수행된다고 생각했지만, 실제로는 SELECT는 과거의 스냅샷을 바라보고 UPDATE는 현재를 바라보고 수행되기 때문이었습니다.

 

 

어설프게 알면 당하는 MySQL REPEATABLE_READ의 함정

이런 현상은 MySQL 공식 문서에서도 찾아볼 수 있었습니다.

 

A consistent read means that InnoDB uses multi-versioning to present to a query a snapshot of the database at a point in time. The query sees the changes made by transactions that committed before that point in time, and no changes made by later or uncommitted transactions. The exception to this rule is that the query sees the changes made by earlier statements within the same transaction. This exception causes the following anomaly: If you update some rows in a table, a SELECT sees the latest version of the updated rows, but it might also see older versions of any rows. If other sessions simultaneously update the same table, the anomaly means that you might see the table in a state that never existed in the database.

 

  • 일관된 읽기(Consistent Read)란 InnoDB가 MVCC을 사용해, 쿼리에 특정 시점의 DB 상태를 스냅샷으로 제공하는 것.
  • 쿼리는 해당 시점 이전에 커밋된 트랜잭션의 변경분은 볼 수 있지만, 그 이후에 커밋되었거나 아직 커밋되지 않은 트랜잭션의 변경분은 볼 수 없다. (= REPEATABLE_READ의 원리)
  • 단, 같은 트랜잭션 내에서 수행된 DML(INSERT, UPDATE 등)로 인한 변경분은 볼 수 있다.
  • 이 때문에 아래와 같은 비일관성(anomaly) 이 발생 가능하다.
    1. 같은 트랜잭션 내에서 일부 레코드만 UPDATE한 후 SELECT를 실행하면, 업데이트된 레코드만 최신 버전으로 보인다.
    2. 여러 트랜잭션이 동시에 동일 테이블에 대해 업데이트를 하고 있다면, 트랜잭션 내에서 SELECT로 조회되는 결과는 실제 데이터베이스에 존재한 적 없던 상태를 보여줄 수도 있다. (트랜잭션 내에서는 동일 트랜잭션의 DML로 인한 변경분만 보이므로)

 

The snapshot of the database state applies to SELECT statements within a transaction, not necessarily to DML statements. If you insert or modify some rows and then commit that transaction, a DELETE or UPDATE statement issued from another concurrent REPEATABLE READ transaction could affect those just-committed rows, even though the session could not query them. If a transaction does update or delete rows committed by a different transaction, those changes do become visible to the current transaction.

 

  • 스냅샷은 트랜잭션 내의 SELECT 문에만 적용되며, DML(INSERT, UPDATE 등)에도 적용되어야 하는 건 아니다.
  • A 트랜잭션이 일부 레코드를 INSERT 또는 UPDATE한 뒤 커밋했을 때, 동시에 실행 중이던 REPEATABLE_READ 수준의 B 트랜잭션에서 실행되는 DELETE나 UPDATE 문은 A 트랜잭션이 방금 커밋한 행에도 영향을 줄 수 있다(B 트랜잭션에서는 그 레코드들을 SELECT로 조회할 수 없더라도)
    • >> UPDATE / DELETE는 특정 시점의 스냅샷이 아닌 현재를 바라보고 수행되기 때문으로 이해할 수 있습니다.
  • 한 트랜잭션이 다른 트랜잭션에서 커밋한 레코드를 UPDATE하거나 DELETE할 경우에는 그로 인한 변경사항이 현재 트랜잭션 내에서 즉시 반영되어 보이게 된다. (하단 예제 이미지 참고)

 

 

이를 토대로 정리해보겠습니다. UPDATE / DELETE는 특정 시점의 스냅샷이 아닌 현재를 바라보고 수행됩니다. 따라서 하나의 트랜잭션 안에서 SELECT한 결과를 조건으로 활용해 UPDATE / DELETE 등을 해줄 때, 이런 트랜잭션이 여러 개가 동시에 실행된다면 내가 예상한 레코드가 UPDATE / DELETE되지 않거나 내가 예상하지 않은 레코드가 UPDATE / DELETE되는, 즉 예상치 못한 동작이 발생할 수 있습니다

 

결국 트랜잭션 격리 수준은 읽기와 관련된 문제들(Dirty Read, Non-Repeatable Read 등)들에 대한 해결책은 될 수 있으나, 쓰기에 대한 문제들(Lost Update 등)은 여전히 발생 가능합니다. 이를 위해 낙관적 락, 비관적 락(사실 끝나서 얘기하지만 저는 낙관적 동시성 제어 등으로 부르는게 더 맞다고 생각하는 편)을 적절히 적용해서 쓰기와 관련된 문제들을 해결할 수 있어야 하죠. 다행히 제가 이번 기능 개발에서 적용한 낙관적 락 형태의 쿼리는 시스템 특성상 해당 쿼리를 활용하는 코드가 제한되어 있어 동시성 문제가 나지 않았지만, 이번 삽질을 통해 낙관적 락 적용시에는 version등의 컬럼을 활용해 UPDATE 조건을 정확하고 유일하게 적용해야 쓰기 관련된 동시성 문제들을 깔끔하게 해결할 수 있다는 인사이트를 얻었습니다. 단순한 CRUD를 넘어 격리 수준 등을 토대로 나올 수 있는 복합적인 동시성 문제들까지 고려하는 습관을 잡아야 할 것 같습니다 :)

 

 

Reference

https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html

https://stackoverflow.com/questions/59287861/repeatable-read-isolation-level-select-vs-update-where 

 

들어가며

작년 말, IfKakao 2024에서 카카오페이에서 지연이체 개발기란 제목의 세미나가 공개됐습니다. Kafka를 활용해 지연이체 서비스를 설계한 과정이 담겨있었는데요. 개인적으로 굉장히 흥미롭게 봤기 때문에, 직접 설계 과정을 따라가보면 왜 카카오페이에서 세미나에 녹여냈던 선택들을 했는지 더 잘 이해할 수 있고 그 과정에서 공부가 많이 되겠다는 생각이 들어, 회사 동기들과 직접 Kafka를 활용해 예약이체 서비스를 사이드 프로젝트 수준에서 설계해봤습니다.

 

참고로 실제 이체 프로세스가 어떻게 정교하게 흘러가는지는 잘 몰랐기 때문에.. 이체 프로세스는 출금계좌의 돈을 빼고, 타행이체일 경우는 API호출을 가정하여 1초간 Thread.sleep()을 하는 수준으로만 가정하고 설계를 진행했습니다.

 

본 글은 다음 목차로 진행됩니다.

 

  1. 예약이체란?
  2. 개략적인 설계
  3. 세부 프로세스 설계
  4. 속도 높이기
  5. 소감
  6. Reference

 

1. 예약이체란?

 

이 글에선 은행 점검 시간에 송금 건을 예약하여 은행 점검 시간 이후 자동으로 예약된 송금 건이 실행되도록 하는 것을 말하도록 하겠습니다. 참고로 송금 예약을 눌렀을 때 어떻게 저장하는지는 여기서 다루지 않고, 저장된 예약이체 건을 실행하는 것에 대한 아키텍처와 프로세스만을 다뤘습니다.

 

2. 개략적인 설계

다음 내용들에 대한 개략적인 설계입니다.

 

  1. 데이터 스키마 설계
  2. API 설계
  3. 개략적인 아키텍처와 프로세스 

 

1) 데이터 스키마 설계

예약이체 테이블 (scheduled_transfer)

필드 설명 자료형
scheduled_transfer_id (PK) 예약이체 건 식별자 bigInt
from_account 출금계좌(source) varchar(20)
to_account 송금계좌(destination) varchar(20)
to_bank_code 송금은행코드 varchar(10)
transfer_amount 송금금액 decimal(15, 2)
transfer_available_dttm 송금가능일시 datetime
status 예약이체 건 상태 tinyInt
scheduled_dttm 예약일시 datetime

 

예약이체 건 상태값으론 0, 1, 2 등이 저장되며 각각 PENDING, COMPLETED, FAILED 등을 의미합니다. 신규 상태값이 추가될 수도 있음을 고려해 enum이 아닌 tinyInt타입을 사용하도록 했습니다. 마지막 수정일시 등의 컬럼도 필요하겠으나 위 표에서는 생략했습니다.

 

고객 정보, 계좌 정보, 은행 정보에 대한 스키마는 아주 간단한 수준(식별자, 고객명, 은행코드, 은행명 등만 있는 수준)으로만 설계했으므로 따로 작성하진 않겠습니다.

 

2) API 설계

앞서 말씀드렸듯 예약이체 건을 저장하는 프로세스는 다루지 않았기 때문에, 예약이체 건을 저장하는 API는 설계하지 않았습니다.

 

POST /v1/scheduled-transfers/:id/execute

예약 이체 건을 실행하는 API입니다. id는 예약이체 건의 식별자이며, API 호출 시 body에 담아 전달하는 인자들은 다음처럼 설계했습니다.

필드 설명 자료형
from_account 출금계좌(source) varchar(20)
to_account 송금계좌(destination) varchar(20)
to_bank_code 송금은행코드 varchar(10)
transfer_amount 송금금액 decimal(15, 2)

 

 

3) 개략적인 아키텍처와 프로세스

가장 초기에 구상한 아키텍처와 프로세스는 다음과 같습니다.

 

각 컴포넌트별 역할 및 프로세스

  1. 스케쥴러(Producer) : scheduled_transfer 테이블에 PENDING상태로 있는 예약이체 건들 중 송금시간이 다가온 것들(송금가능일시 <= 현재시간)을 5분 주기로 조회하여 Kafka로 발행하는 역할을 합니다.
  2. Kafka : 스케쥴러가 발행한 예약이체 건들에 대한 브로커 역할을 합니다. 예약이체 건들은 scheduled-transfer라는 토픽에 저장되며, 해당 토픽은 파티션 3개로 구성했습니다.
  3. Consumer : Kafka에 발행된 예약이체 건들을 가져와서 코어뱅킹 서버로 이체 실행 요청을 보내는 역할을 합니다. 각 Consumer들은 Kafka 파티션들과 1 : 1로 대응되도록 3개로 구성했습니다.
  4. 코어뱅킹 서버 : Consumer로부터 요청받은 예약 이체 건을 실행하는 내부 서버 역할을 합니다.

 

완료된 예약 이체 건의 상태 갱신은 누가 할까?

초기엔 단순하게 Consumer에서 코어뱅킹 서버로 이체 실행 요청을 보낸 후, 응답값에 따라 Consumer에서 예약 이체 건들의 상태를 갱신하는 프로세스를 구상했습니다. 그러나 다음과 같은 케이스들이 발생할 수 있었습니다.

 

  1. 코어뱅킹 서버에서 이체 실행은 완료됐으나 네트워크 오류 등으로 Consumer로 응답을 못 준 경우
  2. 코어뱅킹 서버로부터 응답은 왔으나 Consumer에서 예약 이체 건의 상태 갱신에 실패하는 경우
  3. 등등..

 

위 상황에서는 예약 이체 건의 상태를 갱신할 수 없으니, 실제로 이체가 실행됐어도 스케쥴러가 계속해서 kafka로 예약 이체 건들을 발행해주는 문제가 발생할 수 있다고 생각했습니다. 예약 이체 테이블(scheduled_transfer)과 계좌 테이블들이 같은 DB에 있었기 때문에, 코어뱅킹 서버에서 이체 실행과 예약 이체 건의 상태 갱신을 하나의 DB 트랜잭션에서 처리하도록 설계하여 위 문제를 해결할 수 있겠다는 생각이 들었습니다. 그러나 실무 상황이라면 예약 이체 테이블과 계좌 테이블이 다른 DB에 있다든가, 코어뱅킹 서버에서 예약 이체 테이블이 있는 DB로 접근할 수 없다든가 하는 제약들이 따를 수 있습니다. 따라서 코어뱅킹 서버에서 예약이체 건 식별자(scheduled_transfer_id)에 대한 멱등 처리를 해줘서 기 실행됐던 예약 이체 건에 대한 실행 요청을 받은 경우 이미 실행된 건임을 알리는 응답을 주도록 한 뒤 Consumer에서 해당 응답에 따라 예약 이체 건들의 상태를 갱신하도록 설계했습니다.

 

 

리소스 낭비 방지를 위한 Consumer 단 필터링 프로세스 추가

스케쥴러가 5분 전에 Kafka로 발행한 예약 이체 건이 아직 실행되지 않았다면 스케쥴러가 동일한 예약 이체 건을 Kafka로 여러 번 발행할 수 있습니다. 물론 코어뱅킹 서버에서 예약 이체 건 식별자(scheduled_transfer_id)에 대한 멱등 처리를 해줘서 최대한 이체가 중복으로 발생하지 않도록 처리했으나, 처리될 필요가 없는 예약 이체 건도 코어 뱅킹 서버로 전달되어 리소스를 낭비하게 되는 상황은 발생 가능하다고 생각했습니다. 만약 코어뱅킹 서버에서 멱등 처리가 제대로 기능하지 않게 된다면 이는 리소스 낭비에서 끝나지 않고 중복 이체 발생으로도 이어질 수 있습니다. 따라서 Consumer에서 Kafka에서 가져온 예약 이체 건들의 PENDING 여부를 확인 후 필터링된 건들만 이체 실행 요청을 보내도록 하여 처리될 필요가 없는 건들을 필터링하도록 설계했습니다.

 

정리하면 다음과 같은 프로세스를 설계하게 됐습니다.

 

 

3. 세부 프로세스 설계

1) 여전히 존재하는 중복 이체의 가능성

스케쥴러가 Kafka로 동일한 예약 이체 건을 중복으로 쌓아두는 상황은 여전히 가능한 상황이었습니다. Consumer에서 상태가 PENDING인지 체크하는 과정을 추가하여 처리할 필요가 없는 예약 이체 건에 대한 리소스 낭비를 막고자 했고 코어뱅킹 서버에서도 멱등 처리를 해주긴 했으나, 만약 동일한 예약이체 건들이 서로 다른 Consumer에서 동시에 실행된다면 다음과 같이 중복으로 이체가 발생할 수 있었습니다. 

 

 

이 문제를 해결하려면 동일한 예약 이체 건 여러 개가 동시에 실행되는 것을 제어할 수 있어야 한다고 생각했습니다.

 

구체적인 방법으로는 다음 방법들을 구상했습니다.

 

방법 1. 하나의 예약 이체 건을 정확히 한 번만 Kafka로 발행하자

애당초 동일한 예약 이체 건이 Kafka에 여러 번 중복으로 발행되지 않는다면 동일한 예약 이체 건들이 동시에 실행되는 상황 자체가 벌어지지 않는다는 점을 활용한 해결 방법입니다. 대표적인 구현 방법으로는 스케쥴러가 PENDING 상태인 예약 이체 건들을 조회해서 Kafka에 발행해줄 때, 해당 예약 이체 건들의 상태를 PUBLISHED로 바꿔주는 방법이 있습니다.

 

이 방법은 

 

  1. 스케쥴러가 DB에 해당 예약 이체 건의 상태를 PUBLISHED로 갱신하는 작업
  2. 스케쥴러가 Kafka에 해당 예약 이체 건을 발행하는 작업

 

으로 구분할 수 있으며, 하나의 예약 이체 건을 정확히 한 번만 Kafka로 발행한다는 제약을 지키려면 두 작업의 원자성이 보장되어야 합니다. DB 트랜잭션을 통해 예약 이체 건의 상태를 PUBLISHED로 갱신한 다음 Kafka로 예약 이체 건을 발행하는 작업의 성공/실패 여부에 따라 트랜잭션을 커밋 or 롤백하면 된다고 생각했으나, 다음과 같은 상황도 충분히 발생할 수 있었습니다.

 

 

즉 하나의 DB 트랜잭션만으로는 두 작업의 원자성을 완벽하게 보장할 수 없었습니다. 2 Phase Commit이나 트랜잭셔널 아웃박스 등을 활용하는 방법이 있겠지만, 결국 "어떤 상황이 닥쳐도 하나의 예약 이체 건을 정확히 한 번만 발행해준다"라는 것을 구현하기는 굉장히 어렵겠다는 생각이 들었습니다. 또한 어찌저찌 해서 정확히 한 번 발행에 성공한다고 해도 다음과 같은 상황들을 고려해야 했습니다.

 

  1. 정확히 한 번 발행된 예약 이체 건을 실행하다가 중간에 실패하면 재시도는 어떻게 할 것인가? 다시 예약 이체 건의 상태를 PENDING으로 바꾸게 할 것인가?
  2. 정확히 한 번 발행된 예약 이체 건을 Consumer에서 중복으로 consume하게 되는 상황이 오면 어떻게 할 것인가? 정확히 한 번 발행됐어도, 해당 예약 이체 건을 실행하는 것도 정확히 한 번만 실행되는 것을 어떻게 처리해줄 것인가? (물론 코어뱅킹 서버에서 멱등 처리를 해주긴 했습니다만..)
  3. 등등..

 

즉 정확히 한 번 발행에 성공해도 논리적인 관점 등에서 데이터의 일관성이 깨질 수 있는 여러 샛길들이 많다고 생각했습니다. 따라서 하나의 예약 이체 건을 정확히 한 번만 발행한다는 제약 조건을 지키기 위해 그런 샛길들을 모두 고려하며 구현하는 것보다는, 같은 이체 건 여러 개가 동시에 실행되는 것을 제어할 수 있는 다른 방법을 적용하는 것이 더 낫다고 판단했습니다.

 

 

방법 2. 계좌 락을 걸어보자

두 번째로 구상한 방법은 동시성 제어에 보편적으로 많이 활용되는 "락"을 활용하는 것이었습니다. Consumer에서 예약 이체 건의 상태를 체크하는 부분부터 Consumer에서 예약 이체 건의 상태를 갱신하는 부분에 락을 걸어 같은 이체 건이 동시에 실행되는 것을 제어하자는 아이디어였습니다. 예약 이체 건의 상태 체크와 상태 갱신 모두 Consumer에서 수행되고 있기 때문에, Consumer에서 예약 이체 테이블(scheduled-transfer)의 레코드에 SELECT FOR UPDATE를 통해 배타 락을 거는 방법을 우선적으로 고려했습니다. 그러나 이 방법은 락을 걸고 코어뱅킹 서버로 요청한 이체 실행의 응답이 오기까지 DB 커넥션이 늘어질 여지가 있었기 때문에, 다른 형태로 락을 걸어보자고 생각했습니다. 그 결과 예약 이체 건에 계좌 락을 거는 형태를 고려하게 됐습니다.

 

계좌 락을 얻는 방법으론 계좌 락을 위한 테이블을 별도로 만들어 활용하는 방법과 분산 락을 활용하는 방법이 있었습니다. 전자의 경우 예약 이체 테이블(scheduled-transfer)에 배타 락을 거는 방법과 동일하게 락을 걸고 코어뱅킹 서버로 요청한 이체 실행의 응답이 오기까지 DB 커넥션이 늘어질 여지가 있다고 판단했습니다. 또한  출금계좌에 락을 거는 것은 결국 전체 시스템에서 해당 계좌로 접근하는 스레드를 하나로 제한하기 위함이기도 한데, 실무에서는 계좌 정보를 하나의 DB에서 관리하지 않고 여러 시스템 또는 모듈들이 독립적인 DB를 사용 중일 수 있고 하나의 서버가 여러 서버로 분리될 수도 있기 때문에 테이블 기반의 계좌 락은 전체 시스템에서 해당 계좌로 접근하는 스레드를 하나로 제한하기엔 확장성이 낮다는 생각이 들었습니다. 따라서 분산 락을 활용해 계좌 락을 구현하는 것으로 결정했습니다.

 

분산 락을 통해 같은 이체 건들이 동시에 실행되는 것을 제어하게 되는 과정을 나타내면 다음과 같게 됩니다.

 

 

 

 

 

2) 예약 이체 건이 송금가능일시가 지나도 장기간동안 실행되지 않는 이슈가 발생

하지만 이렇게까지 해놓고 테스트를 돌려보니, 특정 예약 이체 건들이 송금가능일시가 지나도 1 ~ 2시간 이상 실행되지 않는 이슈가 발생했습니다. 

 

원인은 위 흐름도와 같이 출금계좌는 같지만 엄연히 서로 다른 이체 건들이 동시에 실행될 때 한 쪽에선 분산 락 획득을 실패하여 이체가 실행되지 않던 상황이 생길 수 있었다는 것이었습니다. 락을 획득할 때까지 대기시키는 방법 등이 떠올랐으나 그렇게 되면 현재 구조에선 뒤에 쌓이는 예약 이체 건들이 밀릴 수 있었고, 대기시키는 방법도 이체 실행이 지연되는 건 마찬가지이니 근본적인 해결법은 아니라고 생각했습니다. 따라서 락 경합 자체를 줄일 수 있는 방법들을 고려하게 됐습니다.

 

 

방법 1. 분산 락 키를 바꾸자

현재는 출금계좌를 키로 해서 분산 락을 잡고 있었는데, 다른 값을 키로 잡으면 되지 않을까라는 아이디어였습니다.

 

출금계좌는 각 예약 이체 건들의 고유값이 아니기 때문에 출금계좌를 분산 락 키로 잡으면 서로 다른 이체 건들끼리도 락 경합이 발생 가능합니다. 반면 예약 이체 건 식별자(scheduled_transfer_id)는 각 예약 이체 건들의 고유값이므로 여기에 분산 락 키를 잡아주게 되면 같은 예약 이체 건들이 동시에 실행되는 상황에 한해서만 락 경합이 발생됩니다. 따라서 락 경합을 줄이려는 목적을 달성할 수 있다는 생각이 들었습니다.

 

다만 앞서 말했듯 출금계좌에 락을 거는 것은 시스템 전체에서 해당 계좌로 접근하는 스레드를 하나로 제한하는 효과를 줍니다. 예약 이체 건 식별자에 대해 락을 걸면 락 경합은 분명 줄어들겠지만 해당 계좌로 접근하는 스레드가 여러 개가 될 수 있어 예상치 못한 동시성 관련 문제를 안겨줄 수 있습니다. 이체는 결국 오류없이 수행되는 것이 가장 중요하다고 생각했기 때문에 분산 락 키는 출금계좌를 그대로 쓰게 하여 해당 계좌로 접근하는 스레드를 하나로 제한시켜 안전성을 높이고, 대신 락 경합을 줄일 수 있는 다른 방법을 고려해보기로 했습니다.

 

 

방법 2. 예약 이체 건들을 출금계좌별로 파티셔닝하자

두 번째 방법은 예약 이체 건들을 출금계좌별로 같은 파티션에 가게끔 설정하는 아이디어입니다. 같은 출금계좌를 가진 서로 다른 예약 이체 건들이 동시에 실행되는 이유는 이들이 서로 다른 Consumer에서 실행될 수 있기 때문인데요. 현재 구조(3개 파티션, 3개 Consumer)에서는 각 Consumer들이 서로 다른 파티션을 담당하고 있기 때문(Kafka는 기본적으로 파티션 하나에 같은 Consumer 그룹 내에선 단일 Consumer 스레드가 할당됨)에 같은 출금계좌를 갖는 예약 이체 건들을 같은 파티션으로 발행하게 되면 하나의 Consumer에서만 예약 이체 건들을 처리하게 됩니다. 또한 현재 하나의 Consumer 스레드에서는 담당하는 파티션에 발행된 예약 이체 건들을 순서대로 처리 중이므로 이 경우 같은 출금계좌를 갖는 예약 이체 건들이 동시에 실행되는 것이 방지되는 효과를 가져올 수 있습니다.

 

Kafka Producer(스케쥴러)에서 메시지를 발행할 때 어떤 파티션으로 발행할지는 Partitioner라는 컴포넌트가 담당하는데요. kafka-clients 3.6.2 기준으로, 메시지 발행 시 키를 지정하지 않는다면 다음 그림과 같이 Sticky partitioning전략을 통해 메시지를 파티션으로 발행하게 됩니다.

 

Sticky partitioning은 하나의 파티션을 유지하며 메시지를 발행하다가 특정 조건(배치 크기 초과, 시간 초과 등)이 충족되면 새로운 파티션을 선택하여 메시지를 발행하는 전략으로, 같은 파티션에 연속해서 메시지를 발행하므로 배치 최적화가 가능해지고 불필요한 파티션 변경을 줄여 네트워크 처리 비용 등을 감소할 수 있다는 장점이 있습니다. 다만 현재 설계 중인 예약 이체 시스템과 같이 동시에 실행되면 안 좋은 메시지들(ex: 같은 출금계좌를 가진 서로 다른 예약 이체 건들)이 각기 다른 파티션에 분배되어 동시에 실행되는 문제를 가져다 줄 수 있습니다.

 

반면 Kafka Producer(스케쥴러)에서 메시지를 발행할 때 키를 지정하게 된다면, Partitioner는 다음과 같이 키를 기반으로 파티션을 지정하여 메시지를 발행하게 됩니다.

 

이 경우 같은 출금계좌를 갖는 예약 이체건들은 같은 파티션에 할당되므로, 이들이 동시에 실행되는 상황이 방지되게 됩니다.

 

이렇게 해두고 보니.. 스케쥴러에서 동일한 예약 이체 건을 발행해도 이들은 동일한 파티션으로 할당되고(물론 리밸런싱 등이 발생하면 다른 파티션에 발행될 순 있음), 앞서 말씀드렸듯이 현재 하나의 Consumer 스레드에서는 담당하는 파티션에 발행된 예약 이체 건들을 순서대로 처리 중이므로 동일한 예약 이체 건들이 서로 다른 Consumer에서 동시에 실행되는 상황도 없어지게 됩니다. 즉, 동시성 문제 해결을 위해 분산 락을 사용할 필요가 없어지게 됩니다. 분산 락을 획득하고 해제하는 것도 네트워크 I/O가 수반되는 것이므로 "그러면 이제 분산 락을 굳이 할 필요가 없어졌는데?"라고 생각했으나, 역시나 앞서 말씀드렸듯이 출금계좌에 락을 거는 것은 시스템 전체에서 해당 계좌로 접근하는 스레드를 하나로 제한하는 효과를 줍니다. 예약 이체 시스템이 아닌 다른 시스템에서도 해당 계좌에 접근하는 로직 등이 있을 수 있고, 이들도 분산 락을 통해 계좌 락을 걸어주고 있다면 예약 이체 시스템을 넘어서 시스템 전체에서 해당 계좌로 접근하는 스레드가 하나가 된다는 얘기입니다. 따라서 동시성 문제를 제어할 필요성이 없어지긴 했어도 시스템 전체에서의 안전성을 높이기 위해 여전히 분산 락을 잡아주는 것은 유효하다고 판단하고, 계속해서 분산 락을 사용해도 된다고 생각하게 됐습니다.

 

 

4. 속도 높이기

1) Batch Read 설정

현재 설정은 Consumer들이 각자가 담당하는 파티션에 발행된 예약 이체 건들을 하나 하나 가져와서 처리하고 있는데요. 이런 상황에서 처리량을 높이고 싶을 땐 대표적으로 "일괄 처리"를 도입해볼 수 있고 Consumer에서도 Batch read를 설정하여 파티션에 발행된 예약 이체 건들을 일괄로 가져와서 처리할 수 있습니다.

 

다만 Consumer에서 특정 개수만큼 예약 이체 건들을 모을 때까지 기다렸다가 일괄로 가져가는 형태가 되기 때문에 각 예약 이체 건의 입장에서 보면 실시간성이 떨어질 수 있음을 고려해야 합니다. 예약 이체 시스템의 경우 송금 가능 일시가 되자마자 즉각적인 이체가 발생되어야 하는 서비스는 아니라고 판단하고 Batch Read를 도입해도 된다고 판단했습니다.

 

2) 병렬 처리 및 출금계좌별 스레드 지정

Batch Read로 예약 이체 여러 건을 한 번에 가져와도 기본적으로는 하나의 Consumer 스레드가 이들을 처리합니다. 따라서 처리량을 높이기 위해 복수의 스레드로 이들을 병렬 처리하는 방식을 도입할 수 있습니다.

 

다만 이 경우 같은 출금계좌를 가진 예약 이체 건들이 서로 다른 스레드에서 동시에 실행될 수 있고 이는 잦은 락 경합으로 이어질 수 있습니다. 이전에 출금계좌를 기준으로 특정 파티션에 발행되도록 설정했듯이, Consumer에서 출금계좌별로 처리할 스레드를 따로 지정주어 이 문제를 해결할 수 있습니다.

 

 

5. 소감

저랑 동기들이 설계했던 부분은 여기까지입니다. 막상 돌이켜보니 사실상 카카오페이 세미나에서 발표된 내용과 큰 차이가 없긴 합니다만.. 그래도 특정 문제(카카오페이에서 맞닥뜨린)가 생겼을 때 "이런 방법도 있었을 텐데 왜 이건 쓰지 않았을까?"라는 고민들이 드는 지점이 많았는데 직접 설계를 해보니 "아 이래서 그랬구나"라고 더 이해가 잘 되는 부분들이 많았던 것 같습니다. 예를 들면 "예약 이체 건들을 계좌별로 파티셔닝되게 했다면 분산 락이 필요없어진 것 같은데 왜 안 뺐을까? -> 아 안전성을 위해서였구나!" 등등.. 뭔가 다른 사람들이 설계하는 과정을 따라가보는 것은 처음이었는데 그 안에서 배운 것들이 굉장히 많네요. 또한 스스로 아직 많이 부족함을 느끼게 된 것 같기도 합니다.

 

준비하는 과정에서도 여러 레퍼런스(개인이 올린 블로그 글들은 다 비슷한 감들이 많아.. 테크블로그에서 발행한 컨텐츠들을 최대한 참고하려고 했습니다)들을 봤습니다. 신뢰성 보장을 위해 여러 고민 끝에 기술을 도입하고, 그 기술의 도입으로 인한 리스크들도 기술적인 고민들을 가미하며 해결하는 과정들이 상당히 흥미로웠습니다. 저도 그런 엔지니어가.. 그런 전문성을 가진 사람이 되고 싶다는 생각이 드네요. 더욱,, 정진해야겠습니다.

 

 

6. Reference

1. 지연이체 서비스 개발기 (카카오페이)

https://youtu.be/LECTNX8WDHo?si=8cn67Fbr4CbDtkaA

 

2. 분산 시스템에서 메시지 안전하게 처리하기 (강남언니)

https://blog.gangnamunni.com/post/transactional-outbox/

 

3. 풀필먼트 입고 서비스에서 분산락을 사용하는 방법 (컬리)

https://helloworld.kurly.com/blog/distributed-redisson-lock/

 

4. Kafka 메시지 중복 및 유실 케이스별 해결 방법 (올리브영)

https://oliveyoung.tech/2024-10-16/oliveyoung-scm-oms-kafka/

 

5. SLASH 22 - 애플 한 주가 고객에게 전달 되기까지 (토스증권)

https://youtu.be/UOWy6zdsD-c?si=iTtbPbEDpFaWdPt8

 

 

 

+ Recent posts