도입

동시성 제어는 여러 사용자가 동시에 시스템을 사용할 때 발생할 수 있는 데이터 무결성과 성능 문제를 해결하는 데 중요한 역할을 한다. 본 글은 Redis와 Redisson 라이브러리를 활용하여 분산 환경에서 발생하는 동시성 이슈를 해결하기 위한 방안을 탐구하고자 한다. 본 글에서는 사내에서 발생했던 동시성 문제와 비슷한 상황을 예시 코드로 재현하여 문제를 정의하고, 이를 해결하기 위한 방법을 설명한다.


이론적 배경

  1. 동시성 제어와 분산 락의 중요성
    • 동시성 이슈는 분산 환경에서 동일한 데이터에 여러 프로세스가 동시에 접근할 때 발생한다. 이를 해결하기 위해 락(lock) 메커니즘이 널리 사용된다.
    • 일반적인 락(mutex lock, spin lock 등)은 단일 시스템 내에서만 작동하며, 분산 락은 여러 시스템 간의 경쟁 상태를 관리한다.
      • 단일 시스템의 예로는 하나의 WAS(Tomcat)에서 실행되는 애플리케이션이 있고, 이 환경에서는 스레드 간의 동기화가 주로 필요하다.
      • 여러 시스템의 예로는 여러 개의 WAS(Tomcat) 인스턴스가 로드 밸런서를 통해 분산 처리하는 환경이 있으며, 이러한 경우 서버 간 데이터 동기화를 위한 분산 락이 필요하다.
  1. Redis와 Redisson
    • Redis는 인메모리 데이터베이스로 높은 처리 속도와 다양한 데이터 구조를 지원한다. 이를 활용한 분산 락 구현은 효율적인 동시성 제어를 제공한다.
    • Redisson은 Redis 클라이언트 라이브러리로, 분산 락을 포함한 다양한 동시성 제어 기능을 제공한다.

연구방법

1. 문제 정의

  • 챗봇 대화 서비스를 운영중이었고, 유저가 챗봇과 대화 시 대화에 필요한 서비스 내 재화가 소모 되는 방식이었다.
  • 서버 구조는 다음과 같았다:
    • 메시지큐를 이용해 채팅 서버로 메시지를 전달하고, 답장 서버에서 답장을 메시지큐를 통해 다시 수신하여 처리하는 구조였다.
    • 답장 서버는 8대가 운영 중이었고, 유저는 동시에 여러 챗봇과 대화가 가능했다.
    • 메시지큐에 동일한 유저가 여러 챗봇과의 대화를 진행하며 생성된 메시지가 각각 저장되고, 여러 서버에서 이러한 메시지를 동시에 처리하면서 동시성 문제가 발생하여, 유저가 소비해야 하는 재화가 올바르게 차감되지 않는 현상이 나타났다.
    • 이를 재현하기 위해 아래와 같은 코드를 작성하여 실험을 설계하였다.

2. 실험 설계

  • 문제를 재현하기 위해 아래 코드를 작성하여 실험을 설계하였다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public ResponseEntity<String> consumeResource(int userId) {
    Optional<User> userOpt = resourceRepository.findById(userId);
    try {
    Thread.sleep(100); // 로직 수행에 100ms가 걸린다고 가정하고, 100ms 지연
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }

    User user = userOpt.get();
    user.consumeResource(1);

    resourceRepository.save(user);
    log.info("User {} 자원 1 사용, 남은 자원: {}", userId, user.getRemainingResources());
    return ResponseEntity.ok("자원 소비. 남은 자원: " + user.getRemainingResources());
    }
    • 자원 소비 중 동시성 문제가 발생하는 현상을 확인하기 위한 예시 코드로, 여러 스레드에서 동일한 유저의 데이터를 동시에 처리할 경우 재화가 예상보다 적게 차감되는 동작을 확인하였다.
    • 이 코드는 단일 시스템에서 동작하는 코드로, 분산 락이 아닌 일반적인 락으로도 해결할 수 있는 상황이다. 하지만 실제 문제와 동일하게 여러 스레드나 서버가 하나의 자원에 동시 접근하는 상황을 재현할 수 있으므로, 실험 결과의 신뢰성에는 문제가 없을 것으로 보인다.

2.1. 실험 결과

  • wrk를 사용하여 동일 자원에 대해 다수의 요청을 보냈으며, 다음과 같은 결과가 확인되었다.
  • 테스트에 사용된 lua script
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    -- 요청 수 제한
    local max_requests = 10 -- 요청 수 제한
    local request_count = 0 -- 현재 요청 수

    request = function()
    if request_count >= max_requests then
    wrk.thread:stop() -- 요청 제한에 도달하면 쓰레드 중지
    end

    request_count = request_count + 1
    local user_id = 1
    local path = "/resources/" .. user_id .. "/consume"
    return wrk.format("POST", path)
    end
  • 실험 명령어:
    1
    wrk -t5 -c20 -d1s -s test_consume.lua http://localhost:8080
  • 실험 결과 요약:
    • 로그에서 동일 자원에 대해 여러 요청이 동시에 처리되며 자원의 남은 개수가 정확히 감소하지 않는 현상이 나타났다.
    • 이는 요청 간 자원 상태가 정확히 반영되지 않고 동일한 초기 상태로 처리된 결과이다.
    • 예를 들어, 로그에서 “User 1 자원 1 사용, 남은 자원: 9”가 반복 출력되었으며, 이후 일부 요청만 자원이 감소된 상태를 올바르게 반영하였다.
  • 문제 분석:
    • 요청이 짧은 시간에 집중적으로 발생하며, 트랜잭션 커밋이 완료되기 전에 다른 요청이 동일 자원의 상태를 조회하고 처리하였다.
    • 이러한 동시성 이슈는 예시 코드에서 자원 접근 시 락을 사용하지 않았기 때문에 발생한 것으로 보인다.
    • 결과적으로, 여러 요청이 자원을 중복해서 처리하며 데이터의 무결성이 훼손되었다.

3. 문제 해결

  • 분산 락 설계:

    • RedissontryLock 메서드를 활용하여 특정 리소스에 대한 락을 획득한다.
    • 락 획득에 실패한 요청은 대기하거나 재시도하도록 구현한다.
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      @PostMapping("{userId}/consume")
      public ResponseEntity<String> consumeResource(@PathVariable("userId") int userId) {
      String lockName = "user:" + userId + ":resource:lock";

      RLock lock = redissonClient.getLock(lockName);
      int remainingResources = 0;
      try {
      boolean isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS);

      if (isLocked) {
      remainingResources = resourceService.consumeResource(userId);
      }
      } catch (RuntimeException | InterruptedException e) {
      throw new RuntimeException("Lock 획득 실패", e);
      } finally {
      if (lock.isHeldByCurrentThread()) { // 락 소유 여부 확인
      lock.unlock();
      }
      }

      return ResponseEntity.ok("자원 소비. 남은 자원: " + remainingResources);
      }
  • 락 해제 매커니즘:
    Redisson 내부적으로 락을 관리하고 해제하는 메커니즘은 Redis Pub/Sub 시스템과 AsyncSemaphore를 활용하여 다음과 같은 방식으로 동작한다:

    Redisson tryLock() Code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();
    // 1. 최초 락 획득 시도
    Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) {
    return true;
    } else {
    // 2. 타이머 갱신 및 대기
    time -= System.currentTimeMillis() - current;
    if (time <= 0L) {
    this.acquireFailed(waitTime, unit, threadId);
    return false;
    } else {
    current = System.currentTimeMillis();
    // 3. Pub/Sub 채널 구독
    CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);

    try {
    subscribeFuture.get(time, TimeUnit.MILLISECONDS);
    } catch (TimeoutException var21) {
    if (!subscribeFuture.completeExceptionally(new RedisTimeoutException("Unable to acquire subscription lock after " + time + "ms. Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
    subscribeFuture.whenComplete((res, ex) -> {
    if (ex == null) {
    this.unsubscribe(res, threadId);
    }

    });
    }

    this.acquireFailed(waitTime, unit, threadId);
    return false;
    } catch (ExecutionException var22) {
    ExecutionException e = var22;
    LOGGER.error(e.getMessage(), e);
    this.acquireFailed(waitTime, unit, threadId);
    return false;
    }

    try {
    time -= System.currentTimeMillis() - current;
    if (time <= 0L) {
    this.acquireFailed(waitTime, unit, threadId);
    boolean var25 = false;
    return var25;
    } else {
    boolean var16;
    do {
    long currentTime = System.currentTimeMillis();
    ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) {
    var16 = true;
    return var16;
    }

    time -= System.currentTimeMillis() - currentTime;
    if (time <= 0L) {
    this.acquireFailed(waitTime, unit, threadId);
    var16 = false;
    return var16;
    }

    // 4. Latch 대기와 재시도
    currentTime = System.currentTimeMillis();
    if (ttl >= 0L && ttl < time) {
    ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
    } else {
    ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
    }

    time -= System.currentTimeMillis() - currentTime;
    } while(time > 0L);

    this.acquireFailed(waitTime, unit, threadId);
    var16 = false;
    return var16;
    }
    } finally {
    // 5. 구독 해제
    this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
    }
    }
    }
    }

    1. 최초 락 획득 시도

    • tryAcquire 메서드를 통해 락 획득 가능 여부를 확인하며, null을 반환하면 현재 락을 바로 획득할 수 있음을 의미한다.
    • 락이 이미 선점된 경우, ttl(잠금의 남은 유효 시간)을 반환하여 대기할 시간을 계산한다.

    2. 타이머 갱신 및 대기

    • 대기 중 경과된 시간을 waitTime에서 차감하며 남은 시간이 0 이하일 경우 대기를 종료하고 false를 반환한다.

    3. Pub/Sub 채널 구독

    • 락 해제 이벤트를 감지하기 위해 Redis Pub/Sub 채널에 구독을 요청한다.
    • subscribeFuture를 통해 락이 해제되었거나 구독에 실패한 경우 작업이 비동기적으로 처리된다.

    4. latch 대기와 재시도

    • Pub/Sub 채널 구독이 성공한 상태에서 tryAcquire를 반복 호출하며 락 획득을 재시도한다.
    • TTL 값에 따라 latch 대기 시간이 설정되며, 다른 스레드에서 락이 해제되면 latch가 해제되어 작업이 이어진다.

    5. 구독 해제

    • 락을 획득하든 못 하든 메서드를 종료하기 전에 Pub/Sub 구독 리소스를 해제하여 시스템 자원을 정리한다.

3.1. Redisson 적용 후 실험 결과:

  • 2.1. 실험과 동일한 script와 wrk 명령어를 이용해 실험을 진행했으며, 다음과 같은 결과가 확인되었다.

  • 실험 결과 요약:

    • Redisson을 사용하여 분산 락을 적용한 결과, 모든 요청이 순차적으로 처리되었으며 데이터의 무결성이 유지되었다.
    • 로그에서 확인할 수 있듯이 동일한 자원에 대해 락이 적용되어 요청이 병렬적으로 처리되지 않고 순차적으로 처리되었다.
    • 예를 들어, “User 1 자원 1 사용, 남은 자원: 9”에서 시작하여 요청이 처리될 때마다 자원이 정확히 감소하는 모습을 확인할 수 있었다.
    • 실험 중에는 잘못된 자원 감소나 동시성 문제가 발생하지 않았다.
  • 문제 해결 확인:

    • Redisson의 분산 락을 적용함으로써 동시성 문제로 인한 데이터 무결성 훼손이 해결되었다.
    • 요청량이 많을 때에도 대기 상태로 처리되며 자원의 상태가 정확히 갱신되었다.

결과

  • 테스트 결과
    • Redisson 기반 분산 락을 적용한 후, 중복 트랜잭션 문제가 해결되었다.
    • 부하 테스트(wrk)에서 동일한 유저의 데이터를 처리할 때, 데이터 무결성이 유지됨을 확인하였다.
    • 이로써 분산 서버 환경에서 발생하던 자원 동시성 문제를 해결할 수 있었다.

논의

Pub/Sub 기반 락 메커니즘은 성능과 자원 활용면에서 유리하며, 이를 사용하는 Redisson을 사용하여 분산 환경에서도 효율적으로 동시성 제어가 가능하지만,
Redis를 따로 구축하여야 한다는 점, Redis의 장애 시 락 메커니즘이 정상적으로 동작하지 않을 수 있다는 점과 락 해제 실패 또는 락 대기 시간 초과 시 추가적 오류 처리가 필요하다는 한계점 등이 존재한다.
해당 한계점들을 개선하기 위해 이후에 Redis Cluster를 활용해 고가용성을 확보하여야하고, Redis 외에 기타 다른 도구(Zookeeper, Etcd 등)와의 성능 비교가 필요하다.

결론

본 글에서는 Redis와 Redisson을 활용한 분산 락 메커니즘을 통해 동시성 이슈를 해결하는 방법을 제시하였다.
실험 결과, 제안된 방법은 데이터 무결성을 효과적으로 유지하며, 부하 테스트에서도 우수한 성능을 보였다.
본 글이 분산 환경에서의 동시성 문제를 해결하려는 개발자들에게 실질적인 가이드라인을 제공하길 기대하며, 본 글에서 사용된 tryLock()의 내부 작동원리는 동시성 제어를 위한 Redisson tryLock 메서드의 작동 원리에서 확인 할 수 있다.

댓글 공유

1. Fixture Monkey란?

Fixture Monkey는 네이버의 내부 프로젝트인 Plasma에서 복잡한 테스트 요구 사항을 해결하기 위해 개발된 도구로, 현재는 오픈 소스로 제공되어 Java와 Kotlin 환경에서 쉽게 적용할 수 있습니다. 이 라이브러리는 기존의 고정된 테스트 데이터 대신 복잡한 객체 타입의 테스트 데이터를 자동으로 생성하여 테스트 코드의 일관성과 효율성을 대폭 향상시킬 수 있는 도구입니다.


2. Fixture Monkey 도입의 필요성

1. 클래스에 final 필드가 포함된 경우

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Getter
@RequiredArgsConstructor
public class Member {
private final long id;
private final String name;
private final String email;
private final String phoneNumber;
private final int age;
private final boolean isVerified;
private final LocalDateTime createdAt;

public String getAgeCategory() {
assert this.age >= 0;

if (this.age < 18) {
return "청소년";
} else if (this.age < 65) {
return "청년";
} else {
return "시니어";
}
}
}

문제점

  • Member 클래스의 필드들이 모두 final로 선언되어 있기 때문에, 단순히 테스트에서 특정 메서드(getAgeCategory)만 검증하려 해도 불필요한 모든 데이터를 포함하여 객체를 생성해야 하는 문제가 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testGetAgeCategory() {
// 청소년
Member minor = new Member(1L, "Alice", "alice@example.com", "123456789", 15, true, LocalDateTime.now());
assertEquals("청소년", minor.getAgeCategory());

// 청년
Member adult = new Member(2L, "Bob", "bob@example.com", "987654321", 30, true, LocalDateTime.now());
assertEquals("청년", adult.getAgeCategory());

// 시니어
Member senior = new Member(3L, "Charlie", "charlie@example.com", "555555555", 70, true, LocalDateTime.now());
assertEquals("시니어", senior.getAgeCategory());
}

2. 하나의 클래스에 너무 많은 객체를 포함하는 경우

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Getter
public class Order {
private final long orderId;
private final Customer customer; // 연관된 객체
private final Product product; // 연관된 객체
private final LocalDateTime orderDate;

public Order(long orderId, Customer customer, Product product, LocalDateTime orderDate) {
this.orderId = orderId;
this.customer = customer;
this.product = product;
this.orderDate = orderDate;
}
}

@Getter
public class Customer {
private final long customerId;
private final String name;
private final String email;
private final Address address; // 연관된 객체

public Customer(long customerId, String name, String email, Address address) {
this.customerId = customerId;
this.name = name;
this.email = email;
this.address = address;
}
}

@Getter
public class Product {
private final long productId;
private final String productName;
private final double price;

public Product(long productId, String productName, double price) {
this.productId = productId;
this.productName = productName;
this.price = price;
}
}

@Getter
public class Address {
private final String street;
private final String city;
private final String zipCode;

public Address(String street, String city, String zipCode) {
this.street = street;
this.city = city;
this.zipCode = zipCode;
}
}

문제점

  • 생성자에 따라서 연관관계가 맺어진 다른 객체들 까지 생성이 필요하고, 또 그 객체에 연결된 객체들 까지 무한히 체이닝될 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void testOrderCreation() {
// Address 생성
Address address = new Address("123 Main St", "Seoul", "12345");

// Customer 생성
Customer customer = new Customer(1L, 1L, "John Doe", "john@example.com", address);

// Product 생성
Product product = new Product(1001L, "Laptop", 1500.00);

// Order 생성
Order order = new Order(5001L, customer, product, LocalDateTime.now());

// 테스트
assertNotNull(order.getCustomer());
assertEquals("Laptop", order.getProduct().getProductName());
}
  • 위 코드에선 Order 객체를 생성하려면 Customer와 Product 객체가 필요하며, Customer 객체를 생성하려면 Address 객체도 생성해야 합니다.
  • 테스트에서 단일 객체 검증을 위해 불필요한 객체들을 생성해야 하는 비효율 발생합니다.

3. 객체를 생성할 수 있는 생성자가 존재하지 않는 경우

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class MemberEntity {

@Id
private long id;
private String name;
private String email;
private String phoneNumber;
private int age;
private boolean isVerified;
private LocalDateTime createdAt;

public MemberEntity(String name, String email, String phoneNumber, int age, boolean isVerified) {
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.age = age;
this.isVerified = isVerified;
this.createdAt = LocalDateTime.now();
}
}

문제점

  • 기본 생성자 외에, 모든 필드를 초기화하는 생성자를 제공하지 않으므로 테스트에서 객체를 생성할 수 없습니다.
  • 테스트를 위해 public 생성자를 추가하거나 setter를 작성해야 하지만, 이는 프로덕션 코드의 불필요한 수정으로 이어집니다.
  • 혹은 테스트에서 강제로 객체를 생성하려면 Reflection을 사용해야 할 수 있지만, Reflection은 유지보수성을 떨어뜨리고, 코드 가독성을 저하시킵니다.

4. 요약

문제 상황 설명 Fixture Monkey 도입 효과
클래스에 final 필드가 포함된 경우 final 필드로 인해 모든 필드를 초기화해야 객체 생성 가능 필요한 데이터만 선택적으로 생성 가능
객체가 너무 많은 연관 객체 포함 생성자 체이닝으로 인해 테스트에 필요한 객체 생성이 복잡해짐 의존 객체를 자동 생성하며 필요한 객체만 오버라이드 가능
생성자가 존재하지 않는 경우 생성자가 없어 Reflection 등을 통해 강제로 생성해야 하는 비효율 테스트용 생성자 추가 없이 객체 생성 과정 단순화 가능

3. Fixture Monkey의 객체 생성 방식들

Introspector란 Fixture Monkey에서 객체가 생성되는 방법을 의미합니다.

FixtureMonkey가 생성될 때 넣어주는 Introspector에 따라 객체가 생성되는 방법이 달라지게 됩니다.

1. BeanArbitraryIntrospector

  • Fixture Monkey가 객체 생성에 사용하는 기본 introspector 입니다.
  • 리플렉션과 setter 메서드를 사용하여 새 인스턴스를 생성하므로 생성할 클래스에는 인자가 없는 생성자(또는 기본생성자)와 setter가 있어합니다.

2. ConstructorPropertiesArbitraryIntrospector

  • 생성자에 @ConstructorProperties가 있거나 없으면 클래스가 레코드 타입이어야 합니다.
    (또는 Lombok을 사용하는 경우 lombok.config 파일에 lombok.anyConstructor.addConstructorProperties=true를 추가할 수 있습니다.)

3. FieldReflectionArbitraryIntrospector

  • 리플렉션을 사용하여 새 인스턴스를 생성하고 필드를 설정하므로, 생성할 클래스는 인자가 없는 생성자(또는 기본 생성자)와 getter 또는 setter 중 하나를 가져야 합니다.
  • 만약 final이 아닌 변수가 선언되어 있다면 getter 또는 setter 없이도 사용 가능합니다.

4. BuilderArbitraryIntrospector

  • 클래스 빌더를 사용하여 클래스를 생성합니다.

5. FailoverArbitraryIntrospector

  • 테스트 대상이 너무 다양한 객체를 내부적으로 가지고 있을 때 여러 Introspector를 제공할 수 있습니다.
  • FailoverArbitraryIntrospector를 사용하면 두 개 이상의 introspector를 사용할 수 있으며, introspector 중 하나가 생성에 실패하더라도 FailoverArbitraryIntrospector는 계속 다음 introspector로 객체 생성을 시도합니다.

6. PriorityConstructorArbitraryIntrospector

  • 생성자를 사용해서 타입을 생성합니다.
  • 다만, 생성자 파라미터 이름을 인식하지 못하면 ArbitraryBuilder API를 사용해 생성자 파라미터를 제어할 수 없습니다.
  • 일반적으로 컴파일 시 바이트 코드에 생성자명과 파라미터명이 코드에서 작상한대로 저장되지 않으므로(arg1, arg2 이런식으로 저장됨) 생성자와 파라미터명을 런타임에 알 수 있도록 매핑 시킬 수 있어야 합니다.
  • Fixture monkey 공식문서에선 아래 3가지 방법을 제시하고 있습니다.
    • record 타입
    • JVM 옵션 -parameters 활성화
    • 생성자에 @ConstructorProperties 존재
ConstructorPropertiesArbitraryIntrospector와의 차이점

7. 요약

Introspector 설명 특징
BeanArbitraryIntrospector 기본 introspector로, 인자가 없는 생성자와 setter를 이용해 객체 생성 일반적인 객체 생성에 적합
ConstructorPropertiesArbitraryIntrospector @ConstructorProperties 또는 레코드 타입을 통해 생성자 사용 가능 생성자 프로퍼티를 통한 특정 생성 가능
FieldReflectionArbitraryIntrospector 필드 리플렉션으로 객체 생성 final이 아닌 필드에 대해 setter 없이도 가능
BuilderArbitraryIntrospector 빌더 패턴을 사용하여 객체 생성 빌더 패턴이 있는 객체에 적합
FailoverArbitraryIntrospector 여러 introspector 중 사용 가능한 방식으로 객체 생성 시도 다양한 객체 생성 조건에 유연하게 대응
PriorityConstructorArbitraryIntrospector 우선 순위가 높은 생성자를 사용하여 객체 생성 생성자 우선 사용 상황에 적합

4. 사용 예시

1. PriorityConstructorArbitraryIntrospector 사용

  • PriorityConstructorArbitraryIntrospector를 이용해 기 존재하는 생성자를 활용하여 객체를 생성합니다.
  • Repository에서 데이터를 조회할 경우 @QueryProjection 을 활용하기 위해 DTO(혹은 VO)에서 생성자를 만들어서 사용하고 있는데, 이런 케이스처럼 final 필드가 다수 존재하여, 테스트 객체 생성에 비효율이 발생할 경우 사용하기 좋습니다.
  • 해당 예시에선 withParameterNamesResolver() 메서드를 이용하여 생성자의 파라미터명을 명시적으로 작성하였습니다. 혹은 위에 설명한 다른 방법을 이용해 런타임에 파라미터명을 알 수 있도록 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private FixtureMonkey fixtureMonkey;

@BeforeEach
void setUp() {
this.fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(PriorityConstructorArbitraryIntrospector.INSTANCE
.withParameterNamesResolver(constructor -> List.of("id", "name", "email", "phoneNumber", "age", "verified", "createdAt")))
.build();
}

@ParameterizedTest(name = "{index} => age={0}, expectedCategory={1}")
@MethodSource("getPersonVoArguments")
void testPersonVoMethods(Integer age, String expectedCategory) {
// given
PersonVo personVo = fixtureMonkey.giveMeBuilder(PersonVo.class)
.set(javaGetter(PersonVo::getId), 1L)
.set("age", age)
.sample();

// when
String ageCategory = personVo.getAgeCategory();

// then
assertThat(ageCategory).isEqualTo(expectedCategory);
}

private static Stream<Arguments> getPersonVoArguments() {
return Stream.of(
Arguments.of(null, "Unknown"),
Arguments.of(15, "청소년"),
Arguments.of(30, "청년"),
Arguments.of(70, "시니어")
);
}

실행 결과

  • 테스트에 필요한 age만 넣고 실행 시, 나머지 final 필드들은 알아서 fixture monkey에 의해 채워지거나 null 허용필드인 경우 null이 입력된 것을 볼 수 있습니다.

2. FieldReflectionArbitraryIntrospector 사용

  • 생성자의 파라미터에 존재하지 않는 값 ( id )을 수정하기 위해 FieldReflectionArbitraryIntrospector 를 활용하여 객체를 생성합니다.
  • Entity class인 경우 보통 생성자에 id 필드를 포함한 생성자가 존재하지 않기 때문에(JPA ID를 생성, 관리 하도록 일임하기 때문) Entity 객체 생성 시 사용하기 좋습니다.

Member Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class MemberEntity {

@Id
private long id;
private String name;
private String email;
private String phoneNumber;
private int age;
private boolean isVerified;
private LocalDateTime createdAt;

public MemberEntity(String name, String email, String phoneNumber, int age, boolean isVerified) {
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.age = age;
this.isVerified = isVerified;
this.createdAt = LocalDateTime.now();
}

public void verify() {
this.isVerified = true;
}
}

비즈니스 로직

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 특정 ID를 가진 회원을 활성화
* @param memberId 회원 ID
* @return 활성화된 회원
*/
@Transactional(rollbackFor = Exception.class)
public Member activateMember(long memberId) {
MemberEntity entity = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 ID: " + memberId));

if (entity.isVerified()) {
throw new IllegalStateException("이미 활성화된 회원입니다: " + memberId);
}

entity.verify();
entity = memberRepository.save(entity);
return Member.of(entity);
}

테스트코드 with. fixture monkey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private FixtureMonkey fixtureMonkey;
private MemberService memberService;

@BeforeEach
void setUp() {
fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE)
.build();

memberService = new MemberService(new FakeMemberRepository());
}

@RepeatedTest(value = 100)
void testActivateMemberUsingFixtureMonkey() {
// given
MemberEntity entity = fixtureMonkey.giveMeBuilder(MemberEntity.class)
.set("isVerified", false)
.sample();

Member savedMember = memberService.save(entity);

// when
Member activatedMember = memberService.activateMember(savedMember.getId());

// then
assertThat(activatedMember.isVerified()).isTrue();
assertThat(entity.getId()).isEqualTo(activatedMember.getId());
}

  • fixture monkey를 이용해 MemberEntity 객체 생성 시, 테스트해야하는 비즈니스 로직에서 필요한 값만 지정하여 객체를 생성합니다.
    • 예제에선 isVerified의 상태를 true로 변경하는 것
  • 이로 인해 테스트코드의 본래 목적인, 비즈니스 로직 검증에만 집중할 수 있는 간단한 테스트코드를 작성 할 수 있습니다.

5. 퀴즈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Component
@RequiredArgsConstructor
public class EventEligibilityChecker {

private final MemberService memberService;

@Value("${event.vip.member.ids}")
private String vipMemberIds;

private static final ZoneOffset KST = ZoneOffset.of("+09:00");

/**
* 특정 이벤트 참여 가능 여부를 확인
*
* 조건:
* - VIP 회원이거나
* - 관리자 권한을 가진 회원이거나
* - 이벤트 기간 외에 요청한 회원이라면 false
*
* 그렇지 않다면 true를 반환한다.
*
* @param memberId 확인할 회원 ID
* @param eventStartTime 이벤트 시작 시간
* @param eventEndTime 이벤트 종료 시간
* @return 참여 가능 여부
*/
public boolean canParticipate(long memberId, LocalDateTime eventStartTime, LocalDateTime eventEndTime) {
OffsetDateTime currentTime = OffsetDateTime.now();
boolean isEligible = true;

boolean isVipMember = checkVipMember(memberId);
boolean isAdminMember =
checkAdminMember(memberId);
boolean isOutsideEventPeriod = checkOutsideEventPeriod(currentTime, eventStartTime, eventEndTime);

if (isVipMember || isAdminMember || isOutsideEventPeriod) {
isEligible = false;
}

return isEligible;
}

private boolean checkVipMember(long memberId) {
return Arrays.stream(vipMemberIds.split(","))
.map(String::trim)
.map(Integer::parseInt)
.anyMatch(id -> id == memberId);
}

private boolean checkAdminMember(long memberId) {
return memberService.hasAdminRole(memberId);
}

private boolean checkOutsideEventPeriod(OffsetDateTime currentTime, LocalDateTime eventStartTime, LocalDateTime eventEndTime) {
OffsetDateTime eventStart = eventStartTime.atOffset(KST);
OffsetDateTime eventEnd = eventEndTime.atOffset(KST);
return currentTime.isBefore(eventStart) || currentTime.isAfter(eventEnd);
}
}

위 같은 코드는 어떻게 테스트 코드를 작성 할 수 있을까요?

1. 테스트코드 작성

1. Fixture Monkey 사용

예시 코드
  • 해당 케이스에서 적용 할 수 있는 Introspector 가 존재하지 않기 때문에 Fixture monkey로 객체를 생성 할 수 없습니다.
    • 혹 제가 잘못 알고 있다면, 댓글 등으로 조언 부탁드립니다. (__)

2. @SpringBootTest와 @TestPropertySource 사용

예시 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@SpringBootTest
@TestPropertySource(properties = {
"event.vip.member.ids=1,2,3"
})
class EventEligibilityCheckerTest {

@Autowired
private EventEligibilityChecker eventEligibilityChecker;

@Autowired
private MemberService memberService;

@ParameterizedTest(name = "{index} => memberId={0}, startTime={1}, endTime={2}, expectedResult={3}")
@MethodSource("getMemberArguments")
void testCanParticipate(int memberId, LocalDateTime startTime, LocalDateTime endTime, boolean expectedResult) {
// when
boolean result = eventEligibilityChecker.canParticipate(memberId, startTime, endTime);

// then
assertThat(result).isEqualTo(expectedResult);
}

static Stream<Object[]> getMemberArguments() {
return Stream.of(
new Object[]{1, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), false}, // VIP 멤버
new Object[]{99, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), false}, // 관리자 멤버
new Object[]{4, LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(2), false}, // 이벤트 기간 외
new Object[]{101, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), true} // 일반 멤버 (참여 가능)
);
}
}

3. Reflection 사용

예시 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class EventEligibilityCheckerTestWithReflection {

private EventEligibilityChecker eventEligibilityChecker;

@BeforeEach
void setUp() {
eventEligibilityChecker = new EventEligibilityChecker(new MemberService(new FakeMemberRepository()));

// ReflectionTestUtils를 사용해 @Value 필드 설정
ReflectionTestUtils.setField(eventEligibilityChecker, "vipMemberIds", "1,2,3");
}

@ParameterizedTest(name = "{index} => memberId={0}, startTime={1}, endTime={2}, expectedResult={3}")
@MethodSource("getMemberArguments")
void testCanParticipate(int memberId, LocalDateTime startTime, LocalDateTime endTime, boolean expectedResult) {
// when
boolean result = eventEligibilityChecker.canParticipate(memberId, startTime, endTime);

// then
assertThat(result).isEqualTo(expectedResult);
}

static Stream<Object[]> getMemberArguments() {
return Stream.of(
new Object[]{1, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), false}, // VIP 멤버
new Object[]{99, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), false}, // 관리자 멤버
new Object[]{4, LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(2), false}, // 이벤트 기간 외
new Object[]{101, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), true} // 일반 멤버 (참여 가능)
);
}
}

6. 테스트 하기 쉬운 코드

No Silver Bullet. - Fred Brooks

단지 테스트코드 작성을 도와주는 도구일 뿐 Fixture Monkey 도 만능은 아닙니다.

테스트코드를 작성하기 어렵다는 건 테스트 하고자 하는 코드 (System under test)의 설계 혹은 구조가 잘못되어 있다고 테스트코드가 신호를 보내고 있다고 생각해야합니다.

1. 테스트가 어려운 이유

a. 불확실성

  • 불확실성이란 코드가 외부 데이터나 상태에 의존하여 매번 다른 결과를 만들어내는 것을 의미합니다.
  • 예를 들어, 현재 시간을 기준으로 동작하는 코드, 랜덤 값을 생성하는 코드, 전역 변수를 사용하는 코드 등은 매 실행 시마다 결과가 달라질 수 있습니다.
  • 문제코드에선 @Value로 properties에서 주입받는 값들과 OffsetDateTime.now()를 불확실성이라고 할 수 있습니다.

b. 부수효과

  • 부수효과라 함은 함수 외부의 상태를 변경하는 것을 의미합니다.
  • 예를 들어 메일을 전송하거나 파일에 데이터를 기록하거나 데이터베이스에 저장하는 코드는 외부 시스템에 변화를 일으킵니다.
  • 이런 코드들은 단순히 리턴 값을 확인하는 것만으로는 테스트가 불가능하고, 실제로 외부 상태가 바뀌었는지 확인해야 하기 때문에 테스트 비용이 증가하게 됩니다.

2. 테스트 하기 쉬운 코드로 수정하기

수정된 프로덕션 코드 ( SUT )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Component
@RequiredArgsConstructor
public class EventEligibilityCheckerV2 {

private final MemberService memberService;

private final VIPProperties vipProperties;
private final ClockProvider clockProvider;

private static final ZoneOffset KST = ZoneOffset.of("+09:00");

/**
* 특정 이벤트 참여 가능 여부를 확인
*
* 조건:
* - VIP 회원이거나
* - 관리자 권한을 가진 회원이거나
* - 이벤트 기간 외에 요청한 회원이라면 false
*
* 그렇지 않다면 true를 반환한다.
*
* @param memberId 확인할 회원 ID
* @param eventStartTime 이벤트 시작 시간
* @param eventEndTime 이벤트 종료 시간
* @return 참여 가능 여부
*/
public boolean canParticipate(long memberId, LocalDateTime eventStartTime, LocalDateTime eventEndTime) {
OffsetDateTime currentTime = clockProvider.now();
boolean isEligible = true;

boolean isVipMember = checkVipMember(memberId);
boolean isAdminMember =
checkAdminMember(memberId);
boolean isOutsideEventPeriod = checkOutsideEventPeriod(currentTime, eventStartTime, eventEndTime);

if (isVipMember || isAdminMember || isOutsideEventPeriod) {
isEligible = false;
}

return isEligible;
}

private boolean checkVipMember(long memberId) {
return vipProperties.getIds().contains(memberId);
}

private boolean checkAdminMember(long memberId) {
return memberService.hasAdminRole(memberId);
}

private boolean checkOutsideEventPeriod(OffsetDateTime currentTime, LocalDateTime eventStartTime, LocalDateTime eventEndTime) {
OffsetDateTime eventStart = eventStartTime.atOffset(KST);
OffsetDateTime eventEnd = eventEndTime.atOffset(KST);
return currentTime.isBefore(eventStart) || currentTime.isAfter(eventEnd);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface VIPProperties {

List<Long> getIds();
}

@ConfigurationProperties(prefix = "event.vip.member")
class VIPPropertiesImpl implements VIPProperties {

private final String ids;

@ConstructorBinding
public VIPPropertiesImpl(String ids) {
this.ids = ids;
}

@Override
public List<Long> getIds() {
return Arrays.stream(this.ids.split(","))
.map(String::trim)
.map(Long::parseLong)
.toList();
}
}
...

수정된 테스트 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class EventEligibilityCheckerV2Test {

private EventEligibilityCheckerV2 eventEligibilityChecker;

@BeforeEach
void setUp() {
int year = 2024;
int month = 12;
int day =13;
int hour = 11;
int minute = 11;
int second = 11;

eventEligibilityChecker = new EventEligibilityCheckerV2(
new MemberService(new FakeMemberRepository())
, new FakeVIPProperties(Arrays.asList(1L,2L,3L))
, new FakeSystemClockProvider(OffsetDateTime.of(year, month, day, hour, minute, second, 11, ZoneOffset.UTC))
);
}

@ParameterizedTest(name = "{index} => memberId={0}, startTime={1}, endTime={2}, expectedResult={3}")
@MethodSource("getMemberArguments")
void testCanParticipate(int memberId, LocalDateTime startTime, LocalDateTime endTime, boolean expectedResult) {
// when
boolean result = eventEligibilityChecker.canParticipate(memberId, startTime, endTime);

// then
assertThat(result).isEqualTo(expectedResult);
}

static Stream<Object[]> getMemberArguments() {
int year = 2024;
int month = 12;
int day =13;
int hour = 11;
int minute = 11;
int second = 11;

LocalDateTime eventStartDate = LocalDateTime.of(year, month, day-1, hour, minute, second);
LocalDateTime eventEndDate = LocalDateTime.of(year, month, day+1, hour, minute, second);

return Stream.of(
new Object[]{1, eventStartDate, eventEndDate, false}, // VIP 멤버
new Object[]{99, eventStartDate, eventEndDate, false}, // 관리자 멤버
new Object[]{4, eventEndDate.plusDays(1), eventEndDate.plusDays(2), false}, // 이벤트 기간 외
new Object[]{101, eventStartDate, eventEndDate, true} // 일반 멤버 (참여 가능)
);
}
}

3. 코드 개선으로 인해 얻게된 이점

  1. 책임 분리: 설정값을 전용 프로퍼티 클래스 (VIPProperties)로 관리하게 되어 EventEligibilityCheckerV2의 책임이 명확해졌습니다.
  2. 유연성 증가: VIPProperties, ClockProvider와 같은 인터페이스를 사용하여 설정값에 대한 종속성이 외부로 분리하였고, 설정값을 추상화하여, 다양한 설정 구현체를 주입할 수 있습니다. 위 예시에선 테스트코드 내에서 Fake 구현체 작성하고 해당 구현체로 객체를 생성 하도록하여 테스트하였습니다.
  3. 코드 가독성: @Value 어노테이션 없이 구성 클래스를 사용하므로, 전체적인 코드의 가독성이 향상됩니다.

7. 결론

앞서 설명한 설계와 테스트 코드 개선에 더해 Fixture Monkey와 같은 도구를 활용하면 다음과 같은 이점을 얻을 수 있습니다.

  1. 테스트 데이터 자동 생성

    • Fixture Monkey는 복잡한 객체 구조에 대해 필요한 데이터를 자동으로 생성합니다. 이를 통해 테스트를 작성할 때 불필요한 데이터 생성 로직을 줄이고 테스트 로직에 집중할 수 있습니다.
  2. 객체의 불변성과 유연성 유지

    • 기존 객체를 수정하거나 Setter를 추가하지 않아도 Fixture Monkey를 통해 객체를 유연하게 생성 및 수정할 수 있습니다. 특히, 불변 객체(immutable object)나 생성자가 많은 객체를 쉽게 테스트할 수 있습니다.
  3. 데이터 커스터마이징

    • 특정 필드에 대한 값을 명시적으로 설정하거나 조건에 맞는 객체를 생성할 수 있습니다. 예를 들어, VIP 멤버 ID 목록처럼 특정 조건을 가진 데이터를 간단하게 생성할 수 있습니다.
  4. 테스트 유지보수 비용 절감

    • 새로운 필드가 추가되거나 객체 구조가 변경되어도 Fixture Monkey를 사용하면 테스트 코드 수정 범위를 최소화할 수 있습니다.


잘 작성된 테스트는 단순히 코드의 동작을 검증하는 역할을 넘어, 코드의 설계와 구조적 결함을 발견하게 해줍니다.
Fixture Monkey와 같은 도구를 활용하면 코드의 불확실성과 부수효과를 줄이면서도 객체 생성과 테스트를 효율적으로 수행할 수 있습니다.
특히, 복잡한 객체나 다양한 조건의 테스트 데이터를 쉽게 생성할 수 있어 테스트 작성 비용을 절감하고 테스트 품질을 향상시킬 수 있습니다.

그러나 도구의 활용보다 중요한 것은 테스트 가능한 설계를 만드는 것입니다.
테스트 가능한 설계는 테스트 코드로 하여금 코드 설계의 개선 방향을 제시하게 하며, 결과적으로 유지보수성과 확장성을 높이는 기반이 됩니다.

긴 글 읽어주셔서 감사합니다.


참고자료


전체 코드

댓글 공유

  • page 1 of 1

Junggu Ji

author.bio


author.job