입사 당시에 제가 생각한 회사 내에서 가장 큰 문제는 애플리케이션의 잦은 오류였습니다. 이러한 오류의 원인은 애플리케이션 코드 내부에 있었으며, 그 중심에는 MyBatis를 활용한 SQL 중심 프로그래밍 방식이 있었습니다.
그때 상황을 떠올려 보면, 개발 업무가 들어오면 지금까지 해왔던 방식으로 Map을 활용한 SQL 중심 프로그래밍을 하였고, 그 결과 운영되는 서비스 곳곳에서 오류들이 발생하였고, 데이터 또한 믿을 수 없는 상태였습니다. 단순한 오류를 추적하는 데에도 많은 시간이 필요했습니다. 이러한 문제들이 지속되다 보니 나중에는 회사 구성원들이 오류가 있는 것을 더 이상 크게 신경 쓰지 않는 상황까지 갔던 것 같아요.
항상 이러한 문제를 어떻게 해결해 나갔는지에 대해 한 번쯤 정리해보고 싶었습니다. 이 글이 레거시 환경에서도 문제를 바로 잡기 위해 항상 고민하고 노력하는 분들에게 조금이나마 도움이 되었으면 좋겠습니다.
SQL 중심 프로그래밍과 MyBatis
MyBatis를 사용하면 SQL을 필수로 작성하기 때문에 많은 개발자들이 Mybatis는 SQL 중심 프로그래밍을 하는 것이라고 생각하는 것 같아요.
그러나, 저는 SQL 중심 프로그래밍을 단순히 CRUD를 SQL로 처리하는 것으로만 생각하는 것은 잘 못 됐다고 생각합니다. MyBatis를 잘 활용한다면 도메인 모델과 데이터베이스 간의 매핑을 일관성 있게 유지할 수 있기에 도메인 주도 설계(DDD)와 같은 객체지향 프로그래밍을 하기에 오히려 더 적합할 수 있다는 생각이 있습니다.
이러한 생각은 예전에 MyBatis로 도메인 주도 설계를 하셨던 분을 만나보며 얻게 됐습니다. 그전까지는 MyBatis가 한계가 있는 기술이라고 생각하고, 도메인 주도 설계적인 프로그래밍을 할 수 없다고 생각하고 있었거든요. 그 후로 MyBatis를 사용하는 방법에 대해서 더 많이 찾아보는 계기가 됐던 것 같습니다.
도메인 시나리오
- Account는 계좌를 나타내며 다음과 같은 기능을 가집니다.
- 입금 (deposit)
- 출금 (withdraw)
- 이체 (transfer)
- 위와 같은 기능이 호출될 때 Transaction 객체가 생성되어 transactions 테이블에 추가됩니다.
- 각 기능들은 입력 값의 유효성을 검사합니다.
- 예를 들어, 입금 금액이 0보다 작은 경우 예외가 발생하고, 출금 금액이 계좌 잔액보다 큰 경우 예외가 발생합니다.
-- 계좌
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance DECIMAL(10, 2) NOT NULL
);
-- 거래 내역
CREATE TABLE transactions (
id INT PRIMARY KEY,
account_id INT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
type VARCHAR(20) NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);
문제점 인식하기
제 경험과는 다르게 SQL 프로그래밍으로 하더라도 서비스를 오류 없이 잘 운영하고 있고 개발 속도도 빠른데 이걸 왜 해야 하는데? 라는 물음을 가질 수도 있는데요. 그래서 SQL 중심적인 프로그래밍이 어떤 문제를 야기하는지 먼저 살펴보겠습니다.
우선 예제를 위해 시나리오를 토대로 SQL 중심 프로그래밍을 이용하여 API 만드는 과정을 샘플 코드로 작성했습니다.
transaction.mapper.xml
<mapper namespace="com.moon.refactoring.legacy.mapper.TransactionMapper">
<insert id="saveTransaction" parameterType="map">
INSERT INTO transactions (account_id, amount, type)
VALUES (
#{accountId},
<choose>
<when test="type == 'DEPOSIT'">
#{amount}
</when>
<when test="type == 'WITHDRAW'">
-#{amount}
</when>
<when test="type == 'TRANSFER_FROM'">
-#{amount}
</when>
<when test="type == 'TRANSFER_TO'">
#{amount}
</when>
</choose>,
#{type}
)
</insert>
</mapper>
트랜잭션을 남기는 이유는 이력관리를 벗어나 어떤 결과로 계좌에 남은 금액이 변경됐는지를 추적하기 위함입니다. 이렇게 중요한 트랜잭션에 대한 비즈니스 로직 자체가 SQL에 들어있는 샘이죠.
account.mapper.xml
<mapper namespace="com.moon.refactoring.legacy.mapper.AccountMapper">
<select id="getAccount" parameterType="int" resultType="map">
SELECT id, balance
FROM accounts
WHERE id = #{id}
</select>
<update id="updateAccount" parameterType="map">
<choose>
<when test="type == 'DEPOSIT'">
UPDATE accounts SET balance = balance + #{amount}
WHERE id = #{accountId};
</when>
<when test="type == 'WITHDRAW'">
UPDATE accounts SET balance = balance - #{amount}
WHERE id = #{accountId} AND balance >= #{amount};
</when>
<when test="type == 'TRANSFER_FROM'">
UPDATE accounts SET balance = balance - #{amount}
WHERE id = #{fromAccountId} AND balance >= #{amount};
</when>
<when test="type == 'TRANSFER_TO'">
UPDATE accounts SET balance = balance + #{amount}
WHERE id = #{toAccountId};
</when>
</choose>
</update>
</mapper>
계좌 금액을 수시로 변경하는 예금, 출금, 이체들이 choose 태그 내에서 type에 따라 실행되고 있습니다.
AccountService
@Service
public class AccountService {
private final AccountMapper accountMapper;
private final TransactionMapper transactionMapper;
public AccountService(AccountMapper accountMapper, TransactionMapper transactionMapper) {
this.accountMapper = accountMapper;
this.transactionMapper = transactionMapper;
}
public Map<String, Object> getAccount(Map<String, Object> param) {
return accountMapper.getAccount(param);
}
@Transactional
public int updateAccount(Map<String, Object> param) {
int executeAccount = accountMapper.updateAccount(param);
if (executeAccount > 0) {
return transactionMapper.insertTransaction(param);
}
return executeAccount;
}
....
}
오류가 생기더라도 updateAccount() 소스만 봐서는 무슨 일을 하는지 도무지 알 수 없습니다. 결국 SQL을 살펴보게 될 테지만, Map을 사용했기에 파라미터로 어떤 값이 들어왔는지 알 수 있어야 제대로 디버깅이 가능하겠죠.
어디서 많이 보셨던 코드일까요? 샘플 코드인 만큼 간략하게 적긴 했지만 SQL에 비즈니스를 녹여낸 전형적인 SQL 중심적인 프로그래밍과 입력과 출력 모델이 모두 컬렉션으로 이루어져 있는 상태입니다.
이런 방식은 다음과 같은 많은 문제를 가지고 있습니다.
- 디버깅이 어려움
- 유지보수가 어려움
- 성능 저하 가능성이 있음
- 중복 코드가 발생할 확률이 높음
- 테스트가 어려움
핵심 개념 살펴보기
도메인 주도 설계(DDD)를 MyBatis와 함께 사용하면 코드의 가독성과 유지보수성이 향상되며, 비즈니스 로직을 더 잘 표현할 수 있습니다. 애플리케이션의 복잡성이 증가할수록 이러한 개선 방법은 더욱 중요해집니다.
우선 목표를 이루기 위해서는 다음의 개념과 기술들에 대해 숙지하셔야 합니다.
- Layered Architecture
- Entity, ValueObject(VO), DTO
- MyBatis Document - https://mybatis.org/mybatis-3/ko/index.html
- Nested select 와 Nested result 개념과 활용법에 대해 알고 계셔야 합니다.
- Association 과 Collection 매핑에 대해 알고 계셔야 합니다.
1. Layered Architecture 란?
Presentation, Domain, Persistence로 이뤄진 단방향으로만 늘어진 의존성을 가진 아키텍처를 레이어드 아키텍처라 부릅니다. 이 세 가지 레이어는 각자의 역할에 차이를 가집니다.
- Presentation(Controller)
- 사용자와 상호 작용에 대한 HTTP 처리를 책임지는 계층
- Domain(Service)
- 비즈니스 로직을 구현하는 부분을 책임지는 계층
- Persistence (DAO)
- 데이터 스토리지를 접근하기 위한 책임을 가진 계층
2. Entity, VO, DTO 차이
엔티티(Entity)
개발자들은 주로 JPA에서만 사용하는 엔티티(Entity) 개념에 대해 알고 있지만, 여기서 말하는 도메인 주도 설계(DDD)에서 유래한 개념으로, 고유한 식별자를 가지며 객체의 생명주기 내내 이어지는 추상적인 연속성을 가진 객체를 말합니다. 여러 형태를 거쳐 전달되는 이러한 추상적인 연속성을 갖는 객체를 엔티티라고 부릅니다.
예를 들어, 사용자를 나타내는 객체는 각 사용자의 고유한 ID를 사용하여 구별할 수 있으며, 사용자의 이름이나 주소와 같은 속성이 변경되더라도 해당 ID는 동일하게 유지됩니다.
값 객체(Value Object)
연속성과 상태를 기술하는 속성을 가진 객체로, 사물의 어떤 특징을 묘사하는 객체입니다. 주로 불변성을 가지며 식별성을 갖지 않습니다. 예를 들어, 주소나 금액, 날짜 등은 값 객체로 표현될 수 있습니다.
DTO (Data Transfer Object)
프로세스 간에 데이터를 전달하는 객체입니다. 주로 로직을 가지지 않는 순수한 데이터 객체로 사용됩니다.
Entity, VO, DTO 객체는 각기 다른 역할을 가진 레이어에서 사용됩니다. Entity와 VO는 비즈니스 레이어 또는 데이터베이스와의 상호작용하는 레이어에서, DTO는 Presentation 레이어에서 사용됩니다. 이러한 객체들을 각 레이어의 책임에 맞게 사용하면 더 나은 컨벤션을 가져갈 수 있습니다.

- 엔티티에 대해 조금 더 자세히 알고 싶다면 참고하세요. - https://johngrib.github.io/wiki/pattern/entity/
- 레이어드 아키텍처에 대해 더 자세히 알고 싶다면 참고하세요. - https://jojoldu.tistory.com/603
3. MyBatis 활용
MyBatis는 JPA보다 더 많은 유연성과 높은 수준의 제어를 제공합니다. 객체와 데이터베이스 간의 매핑을 위해 XML 또는 어노테이션을 사용하며, 이를 통해 개발자는 데이터베이스와의 상호작용을 보다 명확하게 이해하고 제어할 수 있습니다. 그러나 JPA와 달리 MyBatis에서는 SQL 쿼리를 직접 작성해야 하므로, 개발자가 데이터베이스 관련 지식을 보다 깊이 이해해야 한다는 점을 염두에 두어야 합니다.
MyBatis에서는 ResultMap을 생성할 때 association과 collection이라는 태그를 제공하여 객체 연관 관계를 설정할 수 있습니다. association 태그는 has one 관계를, collection 태그는 has many 관계를 설정합니다.
예제 엔티티
public class Order {
private Long id;
private String orderNumber;
private Date orderDate;
private Customer customer;
private List<OrderDetail> orderDetails;
}
public class OrderDetail {
private Long id;
private Product product;
private Integer quantity;
private Integer totalPrice;
}
public class Product {
private Long id;
private String name;
private Integer price;
}
Association
두 개의 엔티티를 연결하는 태그입니다. Order 엔티티와 Product 엔티티를 연결하려면 다음과 같은 코드를 작성할 수 있습니다.
<resultMap id="orderResultMap" type="Order">
<id property="id" column="id"/>
<association property="product" resultMap="productResultMap"/>
</resultMap>
<resultMap id="productResultMap" type="Product">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="price" column="price"/>
</resultMap>
위 코드에서 association 태그는 Order 엔티티와 Product 엔티티를 연결하며, resultMap 속성은 Product 엔티티의 결과를 처리하기 위한 resultMap의 ID를 지정합니다.
Collection
하나의 엔티티에 여러 개의 하위 엔티티를 가지고 있을 때 사용하는 태그입니다. Order 엔티티와 OrderDetail 엔티티를 연결하려면 다음과 같은 코드를 작성할 수 있습니다.
<resultMap id="orderResultMap" type="Order">
<id property="id" column="id"/>
<collection property="orderDetails" ofType="OrderDetail" resultMap="orderDetailResultMap"/>
</resultMap>
<resultMap id="orderDetailResultMap" type="OrderDetail">
<id property="id" column="id"/>
<result property="productId" column="product_id"/>
<result property="quantity" column="quantity"/>
</resultMap>
위 코드에서 collection 태그는 Order 엔티티와 OrderDetail 엔티티를 연결하며, ofType 속성은 하위 엔티티의 클래스를 지정합니다.
Nested Result, Nested Select
연관된 데이터를 한 번의 쿼리로 가져오거나 여러 번의 쿼리로 나눠 가져올 때 사용하는 방법입니다. 이를 통해 즉시 로딩(Eager Loading)과 지연 로딩(Lazy Loading)을 구현할 수 있습니다.
Nested Result
연관된 데이터를 조인(sql)을 사용해 한 번의 쿼리로 가져오는 방법입니다. MyBatis에서 resultMap을 사용하여 결과를 매핑하고, association 태그 또는 collection 태그를 사용하여 연관된 객체를 구성합니다. 이 방법은 모든 데이터가 한 번에 로드되므로 즉시 로딩이라고 합니다.
한 번의 쿼리로 모든 관련 데이터를 가져올 수 있어 성능이 좋고, N+1 문제를 방지할 수 있는 장점을 가지고 있지만, 필요하지 않은 데이터까지 한 번에 가져와 메모리 낭비가 발생할 수 있고 조인을 사용하기 때문에 쿼리가 복잡해지고 대용량 데이터의 경우 조회 시간이 증가할 수 있습니다.
Nested Select
연관된 데이터를 나눠서 여러 번의 쿼리로 가져오는 방법입니다. MyBatis에서 resultMap을 사용하여 결과를 매핑하고, association 및 collection 요소에 select 속성을 사용하여 연관된 객체를 구성합니다. 이 방법은 관련 데이터가 필요한 시점에 로드되므로 지연 로딩이라고 합니다.
필요한 데이터만 로드하기 때문에 메모리를 효율적으로 사용할 수 있으며, 대용량 데이터의 경우 조회 시간이 줄어들 수 있지만, 여러 번의 쿼리가 발생하여 성능이 저하될 수 있고 N+1 문제가 발생할 수 있습니다.
문제점 개선하기
앞서 살펴봤던 핵심 개념들의 일부를 활용해 문제점을 개선해 보겠습니다.
Account
public class Account {
private Long id;
private BigDecimal balance;
private List<Transaction> transactions = new ArrayList<>();
public Account() {
}
public Account(Long id, BigDecimal balance, List<Transaction> transactions) {
this.id = id;
this.balance = balance;
this.transactions = transactions;
}
public void withdraw(BigDecimal amount) {
changeAmount(amount);
Transaction transaction = Transaction.createWithdrawalTransaction(this.id, amount);
this.transactions.add(transaction);
}
public void deposit(BigDecimal amount) {
changeAmount(amount);
Transaction transaction = Transaction.createWithdrawalTransaction(this.id, amount);
this.transactions.add(transaction);
}
public void transfer(BigDecimal amount) {
changeAmount(amount);
Transaction transaction = Transaction.createOutgoingTransferTransaction(this.id, amount);
this.transactions.add(transaction);
}
public void receive(BigDecimal amount) {
changeAmount(amount);
Transaction transaction = Transaction.createIncomingTransferTransaction(this.id, amount);
this.transactions.add(transaction);
}
private void changeAmount(BigDecimal amount) {
if (balance.add(amount).compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Remaining amount must be greater than 0");
}
this.balance = this.balance.add(amount);
}
}
계좌가 변경될 사유로는 송금, 출금, 이체라는 일정한 템플릿이 정해져 있습니다.
Transaction
public class Transaction {
private Long id;
private Long accountId;
private BigDecimal amount;
private Type type;
public Transaction() {
}
private Transaction(Long accountId, BigDecimal amount, Type type) {
validateAmount(amount);
this.accountId = accountId;
this.amount = amount;
this.type = type;
}
private Transaction(Long id, Long accountId, BigDecimal amount, Type type) {
validateAmount(amount);
this.id = id;
this.accountId = accountId;
this.amount = amount;
this.type = type;
}
public static Transaction createWithdrawalTransaction(Long accountId, BigDecimal amount) {
return new Transaction(accountId, amount, WITHDRAW);
}
public static Transaction createDepositTransaction(Long accountId, BigDecimal amount) {
return new Transaction(accountId, amount, DEPOSIT);
}
public static Transaction createOutgoingTransferTransaction(Long from, BigDecimal amount) {
return new Transaction(from, amount, DEPOSIT);
}
public static Transaction createIncomingTransferTransaction(Long to, BigDecimal amount) {
return new Transaction(to, amount, DEPOSIT);
}
private void validateAmount(BigDecimal amount) {
if (amount.compareTo(BigDecimal.TEN) <= 0) {
throw new IllegalArgumentException("Amount should be greater than 10");
}
}
템플릿을 벗어난 거래 내역은 생성되서는 안되며, 만들어져야 할 이유도 없기 때문에 트랜잭션에서는 디폴트 생성자를 제외한 생성자는 private으로 막고 팩토리 메서드를 통해서만 트랜잭션을 생성할 수 있도록 제한하였습니다.
WithdrawService, TransferService
public class WithdrawalService {
private final AccountRepository accountRepository;
public WithdrawalService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public void withdraw(Long accountId, BigDecimal withdrawalAmount) {
Account account = accountRepository.findAccountWithTransactions(accountId);
account.withdraw(withdrawalAmount);
accountRepository.updateBalance(account);
}
}
@Service
public class TransferService {
private final AccountRepository accountRepository;
public TransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public void transfer(Long from, Long to, BigDecimal amount) {
Account sender = accountRepository.findAccountWithTransactions(from);
Account recipient = accountRepository.findAccountWithTransactions(to);
if (recipient == null) {
throw new RuntimeException("Invalid recipient account");
}
sender.transfer(amount);
recipient.receive(amount);
accountRepository.updateBalance(sender);
accountRepository.updateBalance(recipient);
}
}
문제를 개선하기 전에 Service에서는 계좌가 업데이트 됐는지 여부를 판단하여 트랜잭션을 따로 추가하는 로직을 가졌지만, 개선된 코드에서는 계좌가 업데이트될 때 관련된 객체들도 집합으로 묶으며, 도메인 모델의 복잡도를 낮췄고 객체 간의 관계를 명확히 정의할 수 있습니다. 이로 인해 도메인 로직이 더 이해하기 쉽고, 유지보수하기 수월해집니다.
account.mapper.xml
<mapper namespace="com.moon.refactoring.mapper.AccountMapper">
<!-- Account 객체와 Transaction 객체의 연관 관계를 설정 -->
<resultMap id="accountWithTransactions" type="Account">
<id property="id" column="id"/>
<result property="balance" column="balance"/>
<collection property="transactions"
ofType="Transaction"
column="id"
foreignColumn="account_id"/>
</resultMap>
<!-- 계좌 잔액과 거래 내역을 조회하는 SQL 매핑 -->
<select id="findAccountWithTransactions" resultMap="accountWithTransactions">
SELECT a.id AS id, a.balance AS balance,
t.id AS transaction_id, t.account_id, t.amount, t.type
FROM accounts a
LEFT JOIN transactions t ON a.id = t.account_id
WHERE a.id = #{id}
</select>
<!-- 계좌의 잔액과 거래 내역을 갱신하는 SQL 매핑 -->
<update id="updateBalance" parameterType="Account">
UPDATE accounts
SET balance = #{balance}
WHERE id = #{id};
<foreach item="transaction" collection="transactions" separator=";" close=";">
<!-- 거래 내역을 추가하는 SQL 매핑 -->
INSERT INTO transactions (account_id, amount, type)
VALUES (#{transaction.accountId}, #{transaction.amount}, #{transaction.type})
ON DUPLICATE KEY UPDATE id = id
</foreach>
</update>
</mapper>
Nestsed Result를 통해 계좌를 가져올 때 거래내역도 함께 가져오도록 'findAccountWithTransactions'를 정의하였습니다. 또한 'updateBalance'를 통해 계좌가 변경되면 그 변경 사유를 함께 추가할 수 있도록 내부에 transaction을 추가할 수 있도록 쿼리를 구성하였습니다. 이로 인해 Account와 Transaction의 라이프 사이클을 함께 관리할 수 있습니다.
문제를 개선하기 전 코드보다 개선 후 코드가 더 나아 보이시나요? 하지만 레거시 환경에서 코드 스타일을 바꾸다 보면 더 많은 문제를 낳을 수 있으니, 현재 처한 환경에 맞는 개선 방법을 찾아가는 것이 가장 중요하다고 생각합니다.
'회고' 카테고리의 다른 글
Flyway에 대한 경험과 교훈 (2) | 2022.11.06 |
---|---|
어떤 글을 쓸 것인가? (0) | 2022.03.27 |
댓글