자바는 DB를 다룰 때 각 DB별로 API를 만들지 않고 어떤 DB든 간에 JDBC라는 표준을 통해 접근할 수 있도록 인터페이스를 제공하고 있습니다. 아무래도 저는 학창 시절에는 JPA를 잠깐 사용하고, 현재 회사에서는 MyBatis를 사용하면서 DB 접근 기술의 근간에 위치하고 있는 JDBC만을 사용해서 DB를 다뤄본 적은 없는데요. 자바와 스프링 환경에 대한 공부를 하면서 JPA나 MyBatis가 추상화하고 있는 '내부적인 동작 방식'이 궁금해져서 공부를 했었고, 그 내용을 공유하고자 합니다.

 

 

먼저 JDBC가 어떤 계층 구조를 가지고 애플리케이션과 DB 사이를 연결하는지에 대한 구조도입니다.

JDBC 아키텍처 구조도

 

우리가 비즈니스 로직을 짜는 애플리케이션 레이어에서 JDBC API를 호출해서 DB에 접근하게 되는데요. 이렇게 JDBC를 통해 DB를 접근하게 되면 다음 절차를 따르게 됩니다.

 

1) 커넥션 생성

2) 구문(Statement) 생성 및 컴파일

3) 구문 실행

4) 실행 결과 처리

5) 커넥션 해제

 

이제 각 절차들을 조금 더 자세히 알아보고, JDBC를 통해 DB를 다뤄보는 예시를 보겠습니다.

 

참고) 공식 문서 상에서 커넥션을 맺게 되는 target DB를 'data source'라고 표현하는데요. 아무래도 자바 표준 스펙에서 정의하고 있고 이 글에서도 자주 쓰이는 'DataSource'와 헷갈릴 소지가 있어 이 글에서는 'data source'를 '데이터 원천'으로 작성했습니다.

 

 

1. 커넥션 생성 (Establishing Connection)


커넥션 생성은 특정한 데이터 원천과 커넥션(연결)을 맺는 것을 의미합니다. 데이터 원천은 DBMS가 될 수도 파일시스템이 될 수도 있습니다. 커넥션은 JDBC 인터페이스에서 Connection 객체로 표현되는데요, 일반적으로 JDBC를 사용하는 애플리케이션은 커넥션 생성 시 다음 두 클래스 중 하나를 사용하여 커넥션을 생성합니다.

 

 

1) DriverManager

여러 JDBC Driver들을 등록하고 관리하는 역할을 하는 매니저 클래스입니다. DriverManager를 사용해 커넥션을 얻을 경우, 데이터베이스 URL을 사용해 애플리케이션을 데이터 원천에 연결합니다.

 

2) DataSource

DriverManager보다 개선된 기능(커넥션 풀링 등)을 제공하는 인터페이스입니다. 벤더사(MySQL, Oracle 등)마다 DataSource 구현체 클래스를 제공하며, DataSource 객체의 속성(properties)들은 특정한 데이터 원천을 나타내도록 설정됩니다. DataSource 사용 시 아무래도  애플리케이션 입장에서는 인터페이스에 의존하게 되는 것이므로, 내부적인 연결 정보(properties 등으로 표현되는)나 구현체가 바뀌어도 영향을 받지 않게 된다는 장점이 있습니다. 

 

참고) 커넥션 풀링 : DB Connection 객체를 미리 생성해 풀에 보관하고, 필요할 때마다 빌려 쓴 뒤 반납하는 관리 기법을 말합니다.

 

 

1 -  1.DriverManager를 통한 커넥션 생성

참고) dbms는 mysql을 쓴다고 가정합니다.

public Connection getConnectionByDriverManager(String username, String password) throws SQLException {
    Properties connectionProps = new Properties();
    connectionProps.put("user", username);
    connectionProps.put("password", password);

    Connection conn = DriverManager.getConnection(
            "jdbc:mysql://localhost:3306/bank_demo",
            connectionProps
    );
    
    System.out.println("Connected to database successfully.");
    return conn;
}

 

DriverManager의 getConnection 메서드가 커넥션을 생성하며, 코드에 드러나있는 것처럼 데이터베이스URL을 사용하여 커넥션을 생성합니다.

 

 

1 - 2. DataSource를 통한 커넥션 생성

앞서 말했듯 DataSource는 인터페이스로, 벤더사마다 DataSource 인터페이스의 구현체 클래스를 제공합니다. 보통 시스템 관리자가 Tomcat 등을 사용해서 DataSource를 배포(등록)한 뒤 애플리케이션 개발자가 등록된 DataSource를 사용해 커넥션을 얻는 식으로 사용합니다. 참고로 DataSource는 커넥션 풀링과 분산 트랜잭션을 제공할 수 있다는 장점도 가집니다.

 

DataSource는 다음 3가지 방식으로 구현될 수 있습니다. (1번 선택지는 무조건 포함해야 하고, 대개 분산 트랜잭션을 지원하는 DataSource는 커넥션 풀링도 지원합니다.)

 

1) 풀링되지 않거나 분산 트랜잭션에서 쓰이지 않는 심플한 커넥션을 제공하는 Basic한 DataSource

2) 커넥션 풀링을 지원하는 DataSource (즉 여기서 쓰이는 Connection 객체는 재사용될 수 있는 Connection 객체)

3) 분산 트랜잭션을 지원하는 DataSource (즉 여기서 쓰이는 Connection 객체는 분산 트랜잭션에서 사용될 수 있는 Connection 객체)

 

 

1 - 2 - 1. DataSource 객체 배포(등록)

시스템 관리자가 애플리케이션 개발자들이 사용 가능하도록 DataSource 객체를 배포(등록)하는 과정이며, 보통 다음과 같은 형식의 3단계로 구성됩니다.

 

1) DataSource 객체 생성

2) 해당 객체의 properties 세팅

3) JNDI(Java Naming and Directory Interface) API 등의 방법을 통해 해당 객체를 등록

 

참고) 과거 WAS 환경에서는 이러한 DataSource를 JNDI라는 디렉토리 서비스에 등록해두고 찾아 쓰는 방식을 주로 사용했습니다. 현대 스프링 기반 환경에서는 빈으로 등록해서 많이 활용합니다.

 

MysqlDataSource dataSource = new MysqlDataSource();

dataSource.setServerName("localhost");
dataSource.setPortNumber(3306);
dataSource.setDatabaseName("bank_demo");
dataSource.setUser(username);
dataSource.setPassword(password);

Context ctx = new InitialContext();
ctx.bind("jdbc/BankDemoDS", dataSource);

 

 

1 - 2 - 2. DataSource 객체 사용 (애플리케이션 개발자 역할)

애플리케이션 개발자는 시스템 관리자가 등록한 DataSource를 다음과 같이 활용합니다.

 

1) JNDI를 통해 DataSource 조회 (lookup)

2) 반환된 DataSource의 getConnection() 메서드를 호출하여 커넥션 획득

3) 커넥션 풀링 사용 시 커넥션을 다 쓰고 나면 커넥션 반환 (close 호출)

 

Context ctx = new InitialContext();

DataSource dataSource = (DataSource) ctx.lookup("jdbc/BankDemoDS");

Connection conn = dataSource.getConnection();

 

 

 

2. 쿼리문 생성 및 작성 (Create Statements)


JDBC 인터페이스에서 쿼리문(구문)을 표현하는 인터페이스는 Statement입니다. 해당 객체를 '실행'하게 되면 실행결과로 ResultSet 객체(데이터베이스 결과 집합을 의미하는 JDBC 인터페이스 상에서의 객체)를 만들어줍니다. Statement 객체를 만들려면 Connection 객체가 필요하며 생성 예시는 다음과 같습니다.

 

stmt = con.createStatement();

 

 

Statement에는 다음 3종류가 있습니다.

 

1) Statement

파라미터가 없는 간단한 SQL문 생성에 쓰입니다.

 

2) PreparedStatement

Statement를 상속하며, 파라미터가 존재하는 SQL문을 미리 컴파일하는데 쓰입니다.

 

3) CallableStatement

PreparedStatement를 상속하며, 파라미터가 존재할 수 있는 프로시저들을 실행할 때 쓰입니다.

 

 

2 - 1. PreparedStatement

Statement를 상속하는 클래스로, PreparedStatement의 주요 특징은 Statement와는 다르게 생성될 때 SQL문을 받는다는 점입니다. 이렇게 되면 대부분 해당 SQL문이 DBMS로 즉시 보내져 컴파일되는데요, 이를 통해 PreparedStatement가 실행되면 DBMS는 컴파일 과정 없이 SQL문을 바로 실행할 수 있게 된다는 장점을 얻을 수 있습니다.

 

참고) JDBC는 JVM과 DBMS 사이의 연결을 표준화한 스펙인 거고, 미리 컴파일된 쿼리를 저장하는 건 JDBC 쪽이 아니라 MySQL 등이 PREPARE 등을 통해 제공하는 기능입니다. 저희 회사에서 쓰고 있는 SingleStore의 경우는 PREPARE 기능을 제공하지 않는데, 이런 경우는 클라이언트(JDBC 쪽) 수준에서 최적화를 진행합니다. 또한 JDBC Driver 별로 특정 설정들을 통해 DB의 PREPARE를 사용할지 안 할지도 고를 수 있습니다. 이렇게 DB의 기능을 활용하는 PreparedStatement를 ServerPreparedStatement, 클라이언트 수준에서 최적화하는 PreparedStatement를 ClientPreparedStatement라고 부릅니다. 

아래는 MySQL의 PREPARE를 사용하는 예제이며, 이 글 하단 레퍼런스 탭에 있는 카카오페이 테크블로그를 참고했습니다.

-- prepared statement 생성
PREPARE pstmt FROM 'SELECT * FROM TB_CUSTOMER WHERE id = ?';

-- 파라미터를 지정하여 prepared statement 실행
SET @a = 1;
EXECUTE pstmt USING @a;

-- prepared statement 제거
DEALLOCATE PREPARE pstmt;

 

 

PreparedStatement는 파라미터가 없는 SQL문의 실행에도 쓰일 수 있지만, 대개 파라미터가 있는 SQL문의 실행에 자주 쓰입니다. 이때 PreparedStatement는 클라이언트가 제공한 데이터를 SQL문장의 일부로서가 아니라 파라미터로만 취급하기 때문에 악의적인 입력이 코드로 해석되지 않아서, SQL Injection을 자연스럽게 예방하는 효과도 얻을 수 있습니다.

 

참고) 조금 더 풀어서 설명해보면,  PreparedStatement는 SQL문 생성시 파라미터가 들어가는 위치들은 ?(Question Mark)를 두고, 이 구조를 컴파일한 뒤 나중에 ?가 있던 위치에 setter메서드를 통해 파라미터들을 세팅하게 되는데요. 이때 파라미터 값들을 코드가 아니라 '단순한 문자열 값'이나 숫자 데이터로만 처리하게 되어 SQL Injection이 예방되는 효과를 얻게 됩니다.

 

 

PreparedStatement의 생성과 파라미터 세팅, 실행 예시는 다음과 같습니다. SQL문에서 파라미터가 들어갈 곳은 ?(Question Mark)로 표시하며, 파라미터 설정은 PreparedStatement의 setter 메서드 중 적절한 것을 골라 사용합니다. setter 메서드의 파라미터로는 인덱스 번호와 설정할 값을 넘기며 인덱스는 1부터 시작합니다. 참고로 한 번 설정된 파라미터는 해당 인덱스 번호에 대한 setter 메서드를 호출하거나, PreparedStatement의 clearParameters()를 호출하기 전에는 설정된 값을 유지합니다.

 

String updateSql = "UPDATE account SET balance = balance + ? WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(updateSql);

pstmt.setLong(1, 10000L);
pstmt.setString(2, "00000001029301");
        
pstmt.executeUpdate();

 

 

3. 쿼리 실행 (Executing Queries)


Statement 객체에서 execute 계열 메서드를 호출하여 쿼리를 실행 가능하며,  execute 계열 메서드는 다음 3종류가 있습니다.

 

1) execute

두 개 이상의 ResultSet 객체를 리턴하는 SELECT 수행 시 사용하며, 쿼리가 반환하는 첫 번째 결과가 ResultSet객체면 true를 리턴합니다. Statement객체의 getResultSet 메서드를 반복 호출하여 ResultSet객체들을 하나하나 가져올 수 있습니다.

 

2) executeQuery

하나의 ResultSet 객체를 리턴하는 SELECT 수행 시 사용합니다.

 

3) executeUpdate

DML 수행 시 사용하며, 작성된 쿼리를 통해 영향을 받은 row의 수를 리턴합니다. 

 

예를 들면 다음처럼 사용합니다.

ResultSet rs = stmt.executeQuery(query);

 

 

4. 실행 결과 처리 (Processing ResultSet Objects)


4 - 1. ResultSet?

Statement를 실행하여 ResultSet을 얻었다면, Cursor를 통해 ResultSet 객체의 데이터들에 접근할 수 있습니다. ResultSet은 데이터베이스 결과 집합을 나타내는 데이터 테이블로, 일반적으로 SELECT문을 실행하여 생성됩니다. PreparedStatement 등 Statement 인터페이스를 구현하는 모든 객체를 통해 생성 가능하며, 실행된 쿼리 결과를 조작하고 검색하는 다양한 메서드를 제공합니다.

 

Cursor를 통해 ResultSet 객체의 데이터들에 접근할 수 있는데요, 여기서의 Cursor는 DB Cursor의 개념이 아니라, ResultSet 객체의 데이터 하나하나를 가리키는 포인터를 말합니다. 최초에 Cursor는 첫 번째 row 앞에 위치하고 있고, ResultSet객체가 가지는 여러 메서드들을 통해 Cursor가 가리키는 위치(= 가리키는 row)를 바꿀 수 있습니다. 대표적으로 ResultSet의 next메서드는 Cursor를 다음 행으로 이동시키는데, Cursor가 마지막 행 뒤에 위치하게 되면 false를 리턴하는 점을 활용해 while루프를 통하여 ResultSet의 모든 데이터를 순회할 수 있습니다.  그 외에 next말고도 previos, first 등등 Cursor를 움직일 수 있는 방법은 다양합니다.

 

 

4 - 2. ResultSet에서 행의 특정 컬럼값 가져오기

ResultSet은 현재 Cursor가 가리키는 행에서 원하는 컬럼값을 가져올 수 있는 getter메서드들을 제공합니다. 컬럼의 인덱스 번호(1부터 시작)를 사용하거나 컬럼이름을 사용하여 값을 가져올 수 있습니다.

 

String selectSql = "SELECT * FROM TB_CUSTOMER WHERE CUST_ID = ?";
PreparedStatement pstmt = conn.prepareStatement(selectSql);

pstmt.setLong(1, 1L);
ResultSet rs = pstmt.executeQuery();

while (rs.next()) {
    Long custId = rs.getLong(1); // CUST_ID. 1은 컬럼의 인덱스
    String custName = rs.getString("CUST_NAME"); // CUST_NAME

    System.out.println("custId: " + custId + ", custName: " + custName);
}

 

 

 

5. 커넥션 해제 (반납)


Connection객체와 Statement객체, ResultSet객체들을 다 사용했다면 close메서드를 통해 리소스들을 해제합니다. 자바에서 제공하는 try-with-resources 문법을 사용하면 try 블록을 벗어날 때 자동으로 자원 해제를 할 수 있습니다.

 

예를 들면 다음과 같습니다.

String sql = "UPDATE account SET balance = balance + ? WHERE id = ?";
    
try (Connection conn = dataSource.getConnection();
    PreparedStatement pstmt = conn.prepareStatement(sql)) {
        
    pstmt.setLong(1, amount);
    pstmt.setString(2, accountId);
    pstmt.executeUpdate();
        
} catch (SQLException e) {
    // 예외 처리 로직
    log.error("Database error", e);
}

 

참고) SELECT 쿼리일 경우 ResultSet도 해제해야 합니다. 

 

 

JDBC만을 사용한 DB 접근 실습


이제 JDBC만을 사용해 DB에 접근하는 것을 간단하게 실습해봤습니다. 실습 환경은 다음과 같았습니다

 

1) Java 21

2) mysql-connector-j 9.3.0

3) Mysql 8.0.33

 

테이블은 현재 제가 개인적으로 하는 토이 프로젝트에서 쓰고 있는 테이블 중 다음 테이블에 대해 SELECT하는 것으로 해봤습니다.

 

 

 

public static List<Customer> getCustomers(int limit) {
    List<Customer> customers = new ArrayList<>();
    String selectSql = "SELECT CUST_ID, CUST_NAME, RES_ID, CUST_TYPE FROM TB_CUSTOMER LIMIT ?";

    try (Connection conn = JdbcSample.getConnectionByDriverManager();
         PreparedStatement pstmt = conn.prepareStatement(selectSql)) {

        pstmt.setInt(1, limit);

        try (ResultSet rs = pstmt.executeQuery()) {
            while (rs.next()) {
                Long custId = rs.getLong("CUST_ID");
                String custName = rs.getString("CUST_NAME");
                String resId = rs.getString("RES_ID");
                String custType = rs.getString("CUST_TYPE");

                customers.add(new Customer(custId, custName, resId, custType));
            }
        }

    } catch (SQLException e) {
        System.out.println("Database error: " + e.getMessage());
    }

    return customers;
}

 

개인적으론, PreparedStatement나 ResultSet 등 MyBatis나 JPA가 추상화해주고 있는 영역을 직접 공부도 해보고 처리도 해보니 애플리케이션과 DB가 어떻게 대화하고 있는지(?) 좀 더 잘 이해하게 된 것 같습니다. 역시 해보길 잘했다는 생각입니다. 다만 간단한 쿼리를 처리하고 있음에도 불구하고 리소스의 해제를 신경써야 한다거나 파라미터 세팅을 자꾸 해줘야 한다는 점, ResultSet을 매번 꺼내서 내가 사용할 객체의 값으로 세팅해줘야 하는 점들이 "아 이거 반복되면 번거롭겠다"라는게 느껴졌습니다.

 

이를 토대로 MyBatis와 JPA가 어떤 것들을 어떻게 추상화했는지 등도 공부할 수 있겠다는 생각이 듭니다. 또한 커넥션풀은 어떻게 활용되고 있는지에 대한 궁금증도 생기고 있습니다. 그래서 여기서 멈추지 않고, 커넥션풀을 사용하는 DataSource는 커넥션풀을 어떻게 활용하고 있는건지, 그리고 MyBatis와 JPA가 어떤 점들을 해결하려고 했고 어떤 것들을 포기한 기술인지도 공부를 해서 블로그에 올려보겠습니다!

 

글이 잘 읽힐 지 모르겠네요. 그래도 끝까지 봐주셔서 감사합니다.

 

 

레퍼런스


https://docs.oracle.com/javase/tutorial/jdbc/basics/index.html

 

Lesson: JDBC Basics (The Java™ Tutorials > JDBC Database Access)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Dev.java for updated tutorials taking advantag

docs.oracle.com

https://tech.kakaopay.com/post/how-preparedstatement-works-in-our-apps/

 

우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하고 있는가 | 카카오페이 기술 블로그

JDBC를 직접 핸들링하지 않고, 다양한 추상화 계층 위에서 동작하는 우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하는지 탐구한 결과를 공유합니다.

tech.kakaopay.com

 

당시 개발했던 기능 소개

2025년 올해, 회사에서 했던 개발 업무 중 가장 기억에 남는 경험을 꼽으라면 결재문서 데이터를 연동하고 알림을 발송하는 기능에 트랜잭션 아웃박스 패턴을 적용한 것입니다. 회사 내에서 기안되는 결재문서 중에는 CEO까지 결재해야 하는 문서들이 있는데요. 해당 문서들에 비리나 이상, 위법성 등이 없는지 감사팀에서 매번 감사를 해왔는데, 제가 운영하던 감사시스템에 이렇게 CEO까지 결재하는 문서들을 연동해서 새로운 감사 대상 문서가 기안되면 감사팀에게 알림을 보내고, 결재가 진행되다가 반려가 되면 반려 알림 등을 보내도록 하는 기능입니다. "일상감사 기능"이라고도 불렀고, CEO가 결재하는 문서들을 감사하는 만큼 비즈니스 중요도가 매우 높은 업무였습니다.

 

 

데이터 연동 방법에 대한 고민

먼저 데이터를 연동하는 방법부터 고민을 했었습니다. 처음에는 감사시스템에 데이터 수신용 API를 만들고 전자결재시스템에서 새로운 문서가 기안되거나 기안된 문서의 결재가 진행될 때마다 제가 만든 API를 호출하게 해서 결재문서 데이터들을 공급받을까 했지만, 네트워크 오류 등으로 API 호출 등이 실패했을 때 재시도 전략을 어떻게 할 건지 등이 고민됐습니다. 예를 들어 API 호출에 실패해서 재시도해도 그 재시도가 또 실패하면? 등등.. 결국에는 API 호출을 통한 방법으로는 결재문서 데이터가 누락될 수 있다는 점에 보였는데, 이 "일상감사 기능"은 앞서 설명했듯이 비즈니스 중요도가 매우 높아서 데이터가 누락되는 일은 최대한 지양해야 했습니다. 따라서 다른 방법들을 알아봤는데요, 사내에 이기종 DB 간의 데이터 교환을 특정 주기로 해주는 솔루션(통상 EAI 라고 부르는)이 있어서 이를 활용하기로 했습니다.

 

 

결재문서시스템 DB에서 연동해야 하는 데이터들(처리여부 == N)을 조회하고, 이 데이터들을 감사시스템 DB에 적재하고, 결재문서시스템 DB에서 조회했던 데이터들의 처리여부를 갱신(N → Y)합니다. 이 작업을 10분마다 수행하도록 스케쥴해주면, 오류 등이 발생해서 데이터 연동에 실패해도 자연스럽게 10분마다 재시도될 수 있도록 설정할 수 있었습니다. (결과적 정합성이 보장됨) 물론 이렇게 하면 결재문서에 상태변화가 생겨도 즉각적으로 감사시스템에 연동되는 건 아니지만, 감사팀 분들과 소통하며 완전한 실시간성의 필요성보다는 누락없이 안정적으로 데이터를 연동하는 것이 더 중요함을 도출했었기 때문에 이 방법을 통해 데이터를 연동하기로 했었습니다.

※CEO까지 결재하는 문서들은 하루에 많으면 2개 정도 기안되는 상황이라, Kafka같은 컴포넌트의 사용은 연동 대상 데이터 양에 비해 과한 방법이라는 생각이 들어 고려하지 않았습니다

 

 

알림을 보내는 방법에 대한 고민

이후에는 알림을 보내는 방법을 고민했습니다. 사내 표준 상 EAI 솔루션으로 주고받는 데이터들은 실제 감사 업무에서 사용하는 테이블에 연동하는게 아니라 인터페이스 용도로 만든 테이블에 둬야 했기 때문에, 이 인터페이스 테이블에 있는 데이터들을 업무 테이블로 연동하고 알림을 보내야 했습니다. 

 

 

사실 알림을 보내는 것에 대해서 처음에는 연동 후에 알림 보내지 뭐.. 라는 생각이었습니다. 하지만 개발 과정에서 대표적으로 다음과 같은 엣지 케이스가 발생 가능함을 알게 됐습니다.

 

 

1. 결재문서가 기안되고 감사시스템에 연동됨

2. 감사팀에서 확인 후 해당 결재문서를 이상없음으로 처리

3. 이후 해당 결재문서의 결재가 진행되다가 중간에 반려되고, 문서 내용이 수정된 채로 재기안됨

4. 하지만 반려 알림이나 재기안 알림이 발송되지 않음

5. 감사팀 입장에서는 이전에 이상 없다고 처리한 문서의 내용이 바뀌어 있게 됨

 

 

이 기능이 비즈니스 중요도가 높은 업무였던 만큼, 위와 같은 상황이 펼쳐지면 감사 업무에 굉장한 리스크가 될 수 있다는 생각이 들었습니다. 따라서 결재문서 데이터 연동과 알림 발송이라는 두 작업의 원자성을 보장해줘야 한다는 생각이 들었고, 어떻게 해야 데이터가 연동된 후 알림이 어떤 상황이든 발송될 수 있도록 하지? 를 고민했습니다.

 

 

처음에는 다음과 같이 데이터 연동에 성공하면 그 결과에 따라 알림을 발송하는 방법을 떠올렸는데요.

 

이렇게 하면 알림 발송이 실패할 수도 있기 때문에, 데이터는 연동됐지만 알림 발송에 실패하는 케이스가 발생할 수 있게 됩니다.

 

 

두 번째론 알림 발송 결과에 따라 데이터를 연동하는 방법을 떠올렸는데요.

 

이렇게하면 데이터 연동이 실패할 수도 있기 때문에 알림은 발송됐지만 데이터 연동은 실패하는 케이스가 발생할 수 있게 됩니다.

 

 

세 번째로는 데이터 연동 트랜잭션 내에서 알림 발송 결과에 따라 트랜잭션을 커밋 or 롤백하는 방법을 떠올렸는데요.

 

마찬가지로 알림 발송엔 성공했지만 데이터 연동은 실패하는 케이스가 발생할 수 있었습니다.

 

 

즉 데이터 연동과 알림 발송이라는 두 작업의 원자성을 하나의 DB 트랜잭션으로 정확히 보장해주는 건 매우 힘들었습니다. 2 phase commit 같은 분산 트랜잭션도 알아봤지만 연동해야 하는 데이터 양에 비해 오버 엔지니어링이 될 거라는 생각이 들었는데요. 찾아보니 최소 1번은 이벤트를 전달하는 방법으로 트랜잭션 아웃박스 패턴(트랜잭셔널 아웃박스 패턴)이 있었고, 이를 활용해 다음처럼 설계할 수 있겠다는 생각에 도입을 하게 됐습니다.

 

 

 

2번 과정을 보면, 결재문서이력 테이블 (EAI를 통해 전달받은 데이터가 있는 인터페이스 테이블)을 처리해서 업무 테이들에 결재문서 데이터들을 연동하고, 감사팀에게 발송할 메시지를 별도의 outbox테이블에 적재하는데요. 이 과정들을 하나의 DB 트랜잭션에서 처리합니다. 그리고 outbox테이블에 있는 메시지들을 조회해서 알림을 발송하는 별도의 작업이 10분 주기로 수행되도록 만들었습니다. 이렇게 하면 outbox테이블을 조회해서 알림을 발송하는 작업이 10분마다 수행되므로, 알림 발송이 실패해도 성공할 때까지 재시도가 자연스럽게 진행되게 됩니다. 따라서 최소 1번은 알림을 보낼 수 있게 되는 것이고, 이를 통해 결재문서 데이터 연동과 알림 발송이라는 두 작업이 결과적으로 모두 진행되게 하여 원자성을 보장할 수 있었습니다.

 

 

정말 트랜잭션 아웃박스 패턴이 필요했을까?

'데이터 연동 & 알림 발송'이라는 기능을 구현하는 과정이 트랜잭션 아웃박스 패턴을 곁들이면서 다소 복잡해지긴 했습니다만, 데이터 연동과 알림 발송이라는 두 작업의 원자성을 보장해야 하는 것이 우선이었던 만큼 이런 복잡함은 감수해야 했다는 생각이었습니다. 그리고 감사팀 입장에서 왜 이 기능이 필요할지를 직접 고민해보면서 두 작업의 원자성이 필요했던 것을 스스로 도출했던 것이었고, 이를 보장하는 방법들을 여러 가지로 알아보고 스스로 가장 괜찮은 방법이라고 생각되는 것을 적용했던 만큼 솔직하게는 나름대로의 뿌듯함도 있었습니다.

 

하지만 시간이 흐르고 다시 이 기능 개발을 회고해봤습니다. 정말 트랜잭션 아웃박스 패턴이 필요했던 것일까? 다시 시간을 되돌린다고 해도 데이터 연동과 알림 발송이라는 두 작업의 원자성은 필요했던 상황이라고 생각합니다. 하지만 정말 트랜잭션 아웃박스 패턴이 당시의 최선이었을까?

 

뭐 다시 회고해보니, 굳이 필요하지 않았었다.. 라는 생각이 들었습니다. ㅎㅎ..

 

 

다시 돌아가서, 데이터를 연동하고 알림을 발송하던 것을 한 트랜잭션 내에서 처리하던 형태를 살펴봤습니다.

 

이 방법을 당시 적용하지 않았던 것은 앞서 설명드렸듯이 커밋 과정에서 예외가 터지면 알림은 발송됐지만 트랜잭션은 롤백되어 데이터가 연동되지 않기 때문이었습니다.

 

 

하지만 다시 생각해보면, 어차피 데이터 연동 & 알림 발송은 10분 주기로 반복되고 있습니다. 이렇게 알림이 발송됐지만 데이터가 연동되지 않은 케이스가 발생됐다고 해도, 10분 뒤 혹은 그 뒤에 이 프로세스가 재수행되면서 결국 데이터가 연동됩니다. 결국은 이 방법을 써도 결과적으로는 데이터 연동과 알림 발송의 원자성이 보장되는 것입니다.

 

 

트랜잭션 아웃박스 패턴은 결국 쓸 필요가 없던 거였습니다 ㅎㅎ.. 데이터 연동과 알림 발송이라는 두 작업의 원자성을 보장해야 한다는 제약을 스스로 도출해냈었고, 이를 위한 여러 방법을 스스로 고민하면서 오버 엔지니어링 없이 가장 적절한 선택을 했던 거라고 생각했는데, 사실 이조차도 오버 엔지니어링이었겠다는 생각에 당혹스럽기도 하고 멘탈이 조금.. 힘들기도 했습니다. "아 내가  더 넓은 관점에서 보지 못했던 건가? 좁은 관점에서 기술 적용에만 순간적으로 몰두했던 거였나? 불필요한 과한 설계를 내가 적용하고 있던 걸까?" 라는 생각에 스스로를 조금 갉아먹기도 했던 것 같습니다.

 

 

..그래도.. 앞으로가 중요한 것 아니겠습니까! 데이터 정합성 보장이라는 좁은 관점에 매몰되어 10분 주기로 반복 수행되고 있다는 더 큰 관점에서의 맥락을 놓쳤었고, 결국 간단한 길을 두고 다소 돌아간 셈이 됐지만, 이를 통해 어떻게 구현할까 보다 왜 필요한가를 좀 더 신중하게 다시 살펴보자는 교훈을 얻을 수 있었습니다. 완벽함을 위한 '노력'이 사실은 복잡함을 더할 수 있는 '욕심'이 될 수 있음을 배웠고, 덕분에 단순함이 주는 강력함도 느낄 수 있었습니다.

 

 

또한 이 경험을 성장을 위한 발판으로 삼고, 제가 했던 고민의 흔적들로 남길려고 합니다. 단순히 데이터 정합성을 고려하지 않고 개발했다면 트랜잭션 아웃박스 패턴이라는 일종의 오버 엔지니어링은 없었겠지만, 반면 데이터 정합성을 지켜주는 방법과 기술들에 대해 이렇게 고민하지 않았을 거라고 생각합니다. 이 경험에서 학습한 것들을 발판으로 삼아, 앞으로 기술적인 의사결정들을 내릴 때마다 조금 더 넓은 관점에서, 조금 더 신중하게 고민하게 하는 양분으로 삼으려고 합니다.

 

 

앞으로 더 휼룽한 개발자, 엔지니어가 되기를 바라며! ㅎㅎ.. 부끄럽네여

 

 

 

 

 

 

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

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

 

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

 

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

 

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

 

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

 

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

 

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

 

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

 

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

 

 

 

 

 

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

https://ject.kr

 

 

 

 

 

 

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

 

 

+ Recent posts