도입

동시성 제어는 여러 사용자가 동시에 시스템을 사용할 때 데이터 무결성을 유지하고 성능 저하를 방지하는 데 중요한 역할을 한다. 특히 분산 환경에서는 이러한 문제가 더욱 두드러지며, 이를 해결하기 위한 방법으로 Redis와 Redisson 라이브러리가 자주 사용된다. 본 포스팅에서는 동시성 제어를 위해 사용되는 Redisson의 tryLock 메서드가 어떻게 작동하는지 소개한다.
본 포스팅은 Redisson 3.41.0 버전을 기준으로 작성되었다.


이론적 배경

1. 동시성 제어의 필요성

  • 동시성 이슈는 여러 프로세스가 동시에 동일한 자원에 접근할 때 발생한다. 이를 해결하기 위해 락(lock) 메커니즘이 사용된다.
  • 단일 시스템에서는 일반적인 락으로 해결할 수 있지만, 분산 시스템에서는 분산 락이 필요하다.

2. Redis와 Redisson

  • Redis는 인메모리 데이터베이스로, 높은 성능과 다양한 데이터 구조를 제공한다.
  • Redisson은 Redis 기반의 클라이언트 라이브러리로, 분산 락 및 다양한 동시성 제어 기능을 지원한다.
  • Redisson을 이용한 실제적인 분산락 적용 방법은 이전 포스팅 동시성 이슈와 Redis( Redisson )를 이용한 해결방법에서 확인할 수 있다.

tryLock 메서드의 작동 원리

Redisson의 tryLock 메서드는 분산 락을 구현하는 데 핵심 역할을 한다. 아래는 주요 작동 원리와 코드에 대한 설명이며, 아래 코드는 Redisson 3.41.0 기준으로 동작한다. 특정 버전에서는 내부 구현이 달라질 수 있으니 공식 문서를 참고하기 바란다.

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
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 {
// 3. 락에 대한 pub/sub 채널 구독
current = System.currentTimeMillis();
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;
}

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

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;
}

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;
} finally {
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}

return var16;
}
}
}

1. 최초 락 획득 시도

1
2
3
4
5
6
7
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
} else {
// ttl != null이면 락이 선점되어 있음을 의미
...
}
  • this.tryAcquire()는 락 획득 가능 여부를 확인한다.
    • 반환값이 null인 경우, 현재 락을 바로 획득할 수 있음을 의미한다.
    • 반환값이 null이 아닌 경우, 이미 락이 선점되었거나 대기가 필요함을 나타낸다.

2. 타이머 갱신

1
2
3
4
5
6
7
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
...
}
  • 남은 대기 시간을 갱신하며, 시간이 0 이하인 경우 락 획득을 포기한다.

3. 락에 대한 pub/sub 채널 구독

1
2
3
4
5
6
7
8
9
10
current = System.currentTimeMillis();
CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);

try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException var21) {
...
} catch (ExecutionException var22) {
...
}
  • subscribe() 는 Pub/Sub를 통해 락 이벤트를 감지할 준비를 한다.
  • subscribeFuture.get()을 호출해 설정된 시간 동안 구독이 성공하기를 기다린다.
    • 시간이 초과되면(TimeoutException) 구독을 종료하고 false를 반환한다.
    • ExecutionException이 발생하면 에러를 로깅하고 false를 반환한다.
1
2
3
protected CompletableFuture<RedissonLockEntry> subscribe(long threadId) {
return this.pubSub.subscribe(this.getEntryName(), this.getChannelName());
}
  • threadId는 구독 채널 생성에 직접 사용되지 않고, 락 이름(lock name)과 채널 ID를 기반으로 Pub/Sub 채널이 생성된다.
  • 이 과정은 락 해제 이벤트를 감지하기 위해 필수적이다.
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
public CompletableFuture<E> subscribe(String entryName, String channelName) {
// a. Semaphore 생성 및 acquire 호출
AsyncSemaphore semaphore = this.service.getSemaphore(new ChannelName(channelName));
CompletableFuture<E> newPromise = new CompletableFuture();
semaphore.acquire().thenAccept((c) -> {
if (newPromise.isDone()) {
semaphore.release();
} else {
// b. Pub/Sub 채널 확인 및 생성
E entry = (PubSubEntry)this.entries.get(entryName);
if (entry != null) {
// (1) 기존 엔트리가 있는 경우
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (2) 새로운 엔트리를 생성
E value = this.createEntry(newPromise);
value.acquire();
E oldValue = (PubSubEntry)this.entries.putIfAbsent(entryName, value);
if (oldValue != null) {
oldValue.acquire();
semaphore.release();
oldValue.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (3) Redis Pub/Sub 연결 로직 수행
RedisPubSubListener<Object> listener = this.createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener});
newPromise.whenComplete((r, e) -> {
if (e != null) {
s.completeExceptionally(e);
}

});
// (4) Pub/Sub 구독 상태 관리 및 실패 처리
s.whenComplete((r, e) -> {
if (e != null) {
this.entries.remove(entryName);
value.getPromise().completeExceptionally(e);
} else {
if (!value.getPromise().complete(value) && value.getPromise().isCompletedExceptionally()) {
this.entries.remove(entryName);
}

}
});
}
}
}
});
return newPromise;
}

a. Semaphore 생성 및 acquire 호출

1
2
3
4
5
6
7
8
9
10
AsyncSemaphore semaphore = this.service.getSemaphore(new ChannelName(channelName));
CompletableFuture<E> newPromise = new CompletableFuture();
semaphore.acquire().thenAccept((c) -> {
if (newPromise.isDone()) {
semaphore.release();
} else {
...
}
...
});
  • getSemaphore()를 통해 해당 채널 이름에 대한 비동기 세마포어를 가져온다.
    • 여러 스레드나 인스턴스에서 동일한 채널에 구독 요청이 들어올 수 있으므로, 세마포어를 사용해 동시성을 제어한다.
  • acquire()를 호출하여 락 획득을 시도한다.
    • 이 작업은 비동기로 수행되며, 락 획득이 완료되면 thenAccept 블록이 실행된다.
    • 이 때 획득 시도하는 락은 채널 구독을 위한 락이다.
    • 구독 요청은 AsyncSemaphore 내부의 큐(FastRemovalQueue)를 통해 순서대로 처리된다.
  • 작업 완료 상태 확인 후 락 해제:
    • 만약 newPromise.isDone() 상태라면, 작업이 이미 완료된 것이므로 락을 해제한다.
    • 이는 동일한 채널을 구독 중인 다른 스레드가 작업을 이미 완료했음을 감지하기 위함이다.

b. Pub/Sub 채널 확인 및 생성

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
E entry = (PubSubEntry)this.entries.get(entryName);
if (entry != null) {
// (1) 기존 엔트리가 있는 경우
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (2) 새로운 엔트리를 생성
E value = this.createEntry(newPromise);
value.acquire();
E oldValue = (PubSubEntry)this.entries.putIfAbsent(entryName, value);
if (oldValue != null) {
oldValue.acquire();
semaphore.release();
oldValue.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (3) Redis Pub/Sub 연결 로직 수행
RedisPubSubListener<Object> listener = this.createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener});
newPromise.whenComplete((r, e) -> {
if (e != null) {
s.completeExceptionally(e);
}

});
// (4) Pub/Sub 구독 상태 관리 및 실패 처리
s.whenComplete((r, e) -> {
if (e != null) {
this.entries.remove(entryName);
value.getPromise().completeExceptionally(e);
} else {
if (!value.getPromise().complete(value) && value.getPromise().isCompletedExceptionally()) {
this.entries.remove(entryName);
}

}
});
}
}
  • lock key(entryName)로 Pub/Sub 채널을 확인하거나 생성한다.
    • entries 맵에서 entryName에 해당하는 채널을 가져오고, 이미 존재하면 재사용한다.
    • 채널이 없을 경우 새로 생성해 등록한다.

(1) 기존 채널이 있는 경우:

1
2
3
4
5
6
7
8
9
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
  • entry.acquire()를 호출해 채널을 재사용한다.
  • 이후 semaphore.release()로 채널 구독을 위한 락을 해제한다.
  • 작업 완료 시 entry.getPromise()로 결과를 처리한다.

(2) 새 채널을 생성하는 경우:

1
2
3
4
5
6
7
8
9
10
11
12
13
E value = this.createEntry(newPromise);
value.acquire();
E oldValue = (PubSubEntry)this.entries.putIfAbsent(entryName, value);
if (oldValue != null) {
oldValue.acquire();
semaphore.release();
oldValue.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
  • createEntry(newPromise)로 새로운 엔트리를 생성한다.
  • putIfAbsent(entryName, value)를 통해 이미 생성된 엔트리가 있는지 확인한다.
  • 기존 엔트리가 있으면 이를 사용하고, 없으면 새로 생성한 엔트리를 등록한다.

(3) Redis Pub/Sub 채널 연결:

1
2
3
4
5
6
7
8
RedisPubSubListener<Object> listener = this.createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener});
newPromise.whenComplete((r, e) -> {
if (e != null) {
s.completeExceptionally(e);
}

});
  • createListener(channelName, value)를 통해 실제 Redis Pub/Sub 채널을 구독한다.
  • subscribeNoTimeout()로 비동기적으로 연결을 시도한다.

(4) Pub/Sub 구독 상태 관리 및 실패 처리:

1
2
3
4
5
6
7
8
9
10
11
s.whenComplete((r, e) -> {
if (e != null) {
this.entries.remove(entryName);
value.getPromise().completeExceptionally(e);
} else {
if (!value.getPromise().complete(value) && value.getPromise().isCompletedExceptionally()) {
this.entries.remove(entryName);
}

}
});
  • 구독 실패 처리:
    • e != null 조건은 Pub/Sub 구독 중 예외가 발생했음을 의미한다.
    • 실패한 경우, 다음 작업을 수행한다:
      • this.entries.remove(entryName): 실패한 구독에 해당하는 entryNameentries 맵에서 제거한다.
      • value.getPromise().completeExceptionally(e): 예외를 호출자에게 전달하여 구독 실패 원인을 알린다.
  • 구독 성공 상태 관리:
    • 구독이 성공적으로 완료되었더라도, 추가적인 상태 확인을 수행한다:
    • value.getPromise().complete(value): valuePromise로 완료하고 성공 여부를 반환한다.
    • Promise가 이미 완료되었거나(isCompletedExceptionally로 확인) 정상적으로 완료되지 않은 경우:
      • this.entries.remove(entryName): 일관성을 유지하기 위해 해당 엔트리를 제거한다.
  • 상태 확인의 목적:
    • Redis Pub/Sub 구독 실패 및 이상 상태를 감지하고, 관련 자원을 정리하여 시스템 일관성을 보장한다.
    • 실패나 이상 상태 시 적절한 정리 작업이 이루어지도록 설계되어 있다.

c. 락에 대한 Pub/Sub 채널 구독 정리

1. Semaphore 생성 및 acquire 호출

  • AsyncSemaphore는 채널 단위의 subscribe 작업에 대한 동시성을 제어한다.
  • acquire() 호출 시, 내부적으로 대기 큐가 생성되어 락이 해제될 때 대기 중인 작업이 순차적으로 처리된다.
  • 이를 통해 여러 스레드가 동일한 채널에 대해 동시에 subscribe 요청을 보내더라도 안전하게 작업이 처리된다.
  • 작업 완료 상태(newPromise.isDone()) 확인 후, 이미 완료된 작업에 대한 락을 해제한다.

2. Pub/Sub 채널 확인 및 생성

  • entries 맵에서 entryName에 해당하는 채널을 가져오고, 이미 존재하면 이를 재사용한다.
  • 존재하지 않을 경우:
    • createEntry(newPromise)로 새로운 엔트리를 생성하여 등록한다.
    • putIfAbsent(entryName, value)를 통해 중복 등록을 방지하고, 기존 엔트리가 있으면 이를 재사용한다.
    • 기존 엔트리와 새 엔트리는 동일한 처리 로직으로 이어지며, 작업이 완료되면 결과를 호출자에게 전달한다.

3. Redis Pub/Sub 채널 연결

  • 새로운 엔트리에 대해 createListener(channelName, value)를 통해 Redis Pub/Sub 채널을 구독한다.
  • subscribeNoTimeout() 를 사용하여 비동기적으로 구독을 시도한다.
  • 구독 중 예외 발생 시, 구독 결과를 처리하는 CompletableFuture에 예외 상태를 전달한다.

4. Pub/Sub 구독 상태 관리 및 실패 처리

  • Pub/Sub 구독이 성공했는지 여부를 확인하고, 실패 또는 이상 상태가 발생하면 관련 자원을 정리한다:
    • e != null(구독 실패)일 경우:
      • entries 맵에서 해당 entryName을 제거한다.
      • value.getPromise().completeExceptionally(e)로 구독 실패 원인을 호출자에게 전달한다.
    • 구독이 성공했더라도:
      • value.getPromise().complete(value) 호출로 성공 여부를 확인한다.
      • 성공하지 못했거나(isCompletedExceptionally) 예외 상태라면, entries에서 해당 엔트리를 제거한다.
  • 이를 통해 시스템의 데이터 일관성을 유지하고, 자원 누수를 방지한다.

결과적으로

  • Semaphore를 사용해 subscribe 요청의 동시성을 관리한다.
  • Pub/Sub 채널의 상태를 확인하거나 생성하고, Redis 채널과 비동기적으로 연결한다.
  • 구독 성공 및 실패 상태를 처리하고, 필요시 자원을 정리하여 시스템의 안정성을 보장한다.

여기까지가 락 이벤트를 감지하기 위한 Pub/Sub 채널에 구독하기 위한 로직이다.

4. 락 획득 재시도 및 Latch 대기 로직

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
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;
}

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;

a. 락 획득 재시도

  • Pub/Sub 채널 구독이 성공한 상태에서 다시 tryAcquire를 반복 호출해 락 획득을 재시도한다.
  • ttl == null일 경우, 즉시 락을 획득할 수 있으므로 true를 반환하며 종료한다.

b. 남은 시간 갱신

  • 락 획득을 재시도하기 전에 현재 시간을 기준으로 남은 대기 시간을 갱신한다(time -= ...).
  • 남은 대기 시간이 없으면(time <= 0), acquireFailed() 를 호출해 실패 처리를 진행한 후 false를 반환한다.

c. Latch 대기

  • ttl 값에 따라 Latch를 사용해 일정 시간 대기한다.
    • ttl >= 0인 경우: 락 해제까지의 남은 시간 동안 대기한다.
    • ttl < 0 또는 현재 남은 시간 이하인 경우: 설정된 대기 시간까지 대기한다.
    • Latch다른 스레드/프로세스가 락을 해제하거나 갱신하는 이벤트가 발생하면 해제되는 동기화 장치로 사용되며, Redisson에서는 Semaphore를 Latch를 사용한다.

d. 반복 조건

  • do-while 루프를 통해 남은 대기 시간이 소진될 때까지 락 획득을 반복 시도한다.
  • 대기 시간이 소진되면 acquireFailed()를 호출하고 false를 반환하며 종료한다.

5. 구독 해제

1
2
3
} finally {
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}
  • Pub/Sub 채널을 구독했으므로, 메서드를 종료하기 전에 반드시 unsubscribe 메서드를 호출해 리소스를 해제한다.
  • 이는 락 획득 여부와 관계없이 실행되며, Pub/Sub 구독을 통해 사용된 자원을 정리하는 역할을 한다.

tryLock()의 장점 및 한계

장점

  1. 효율적인 이벤트 기반 처리

    • Pub/Sub 메커니즘을 활용하여 락 해제 이벤트를 감지함으로써, 불필요한 대기 시간을 최소화한다.
    • 락이 해제될 가능성이 있는 경우 반복적으로 대기하거나 재시도하지 않고 이벤트 기반으로 처리하므로 자원을 효율적으로 사용한다.
  2. 정교한 타이머 관리

    • 남은 대기 시간을 지속적으로 갱신하며, 설정된 제한 시간 내에 락 획득 여부를 결정한다.
    • 대기 시간이 초과될 경우 즉시 실패 처리를 진행하므로, 클라이언트 응답 지연을 방지한다.
  3. 안정적인 자원 정리

    • 락 획득 여부와 관계없이 finally 블록을 통해 모든 구독 리소스를 해제하여 시스템 자원의 안정성을 유지한다.
  4. Latch를 활용한 동기화

    • 락 획득 시도가 실패하더라도, Latch를 통해 TTL 동안 대기하며 락 해제 가능성을 최대한 활용한다.

한계

  1. 락 획득 시도와 Pub/Sub의 비동기 처리 간의 간격

    • 락 획득 재시도와 Pub/Sub 이벤트 처리 사이의 간격에서 레이스 컨디션이 발생할 가능성이 있다. 락이 해제된 후 새로 락이 설정되는 순간에 간격이 생길 수 있으므로, 타이밍에 민감한 경우 신중한 설계가 필요하다.

      레이스 컨디션 발생 원인
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      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;
      }

      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);

      a. 첫 번째 tryAcquire 호출

      1
      ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
      • 현재 스레드에서 락을 획득하려 시도하지만, 락이 이미 다른 스레드에 의해 점유된 상태라 TTL(남은 유효 시간)을 반환받는다.
      • 이 시점에서 현재 스레드는 락 해제 이벤트를 기다릴 준비한다.

      b. 락 반납 발생

      • 락을 점유하고 있던 스레드(또는 프로세스)가 락을 반납한다.
      • 이 시점에서 락은 잠깐 동안 해제된 상태가 된다.

      c. 다른 스레드가 락을 채감

      • 락 해제 이벤트가 Pub/Sub를 통해 현재 스레드에 전달되기 전에, 다른 스레드가 tryAcquire()를 호출하여 락을 빠르게 획득한다.
      • 이로 인해 현재 스레드는 락 해제 이벤트를 감지하지 못하게 된다.

      d. 현재 스레드의 Pub/Sub 대기

      1
      2
      ((RedissonLockEntry) this.commandExecutor.getNow(subscribeFuture))
      .getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
      • 현재 스레드는 여전히 락 해제 이벤트를 기다리지만, 락은 이미 다른 스레드에 의해 점유된 상태이다.
      • 이로 인해 현재 스레드는 대기 시간을 낭비하거나, 최종적으로 time이 초과되어 락 획득에 실패할 수 있다.
  1. Pub/Sub 메시지 손실 가능성

    • Pub/Sub 시스템 특성상 구독자와 퍼블리셔 간 네트워크 이슈가 발생하면 락 해제 메시지를 놓칠 가능성이 있다. 이 경우 락을 획득하지 못하고 대기 시간을 낭비할 위험이 있다.
  2. 복잡한 로직으로 인한 디버깅 난이도

    • tryLock()의 로직이 복잡하여, 장애 발생 시 원인을 파악하거나 디버깅하는 데 시간이 소요될 수 있다.

결론

Redisson의 tryLock()은 분산 환경에서 동시성 제어를 효과적으로 구현하기 위해 설계된 복잡한 로직을 제공한다.

tryLock()은 동시성 제어에서 효율성과 안정성을 동시에 추구하는 설계로, 분산 환경에서의 락 처리 문제를 효과적으로 해결한다. 그러나 복잡한 로직 구조와 Pub/Sub 시스템의 특성을 고려하여 사용 환경에 적합한 설정을 적용하고, 잠재적인 한계를 관리할 수 있는 보완책을 함께 설계해야 한다.

본 글이 tryLock() 내부 로직에 대한 깊은 이해를 돕고, 동시성 제어가 필요한 시스템 설계 시 참고 자료가 되길 바라며, 실제적으로 Redisson을 활용하여 분산락을 적용하고자 하는 사용자는 이전 글 동시성 이슈와 Redis( Redisson )를 이용한 해결방법을 참고하길 바란다.

댓글 공유

도입

동시성 제어는 여러 사용자가 동시에 시스템을 사용할 때 발생할 수 있는 데이터 무결성과 성능 문제를 해결하는 데 중요한 역할을 한다. 본 글은 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와 같은 도구를 활용하면 코드의 불확실성과 부수효과를 줄이면서도 객체 생성과 테스트를 효율적으로 수행할 수 있습니다.
특히, 복잡한 객체나 다양한 조건의 테스트 데이터를 쉽게 생성할 수 있어 테스트 작성 비용을 절감하고 테스트 품질을 향상시킬 수 있습니다.

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

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


참고자료


전체 코드

댓글 공유

문제

https://leetcode.com/problems/count-triplets-that-can-form-two-arrays-of-equal-xor/

정렬된 n개의 음의 아닌 정수 배열 nums와 정수 maximumBit가 주어지고, 아래 쿼리를 n번 수행한다.

nums [0] XOR nums [1] XOR … XOR nums [nums.length-1] XOR k


를 최대화하는 k <2maximumBit의 음이 아닌 정수를 찾는데 이 때, k는 각 쿼리의 정답이다.
쿼리를 반복할 때 마다 배열 nums의 마지막 element를 제거하고, answer 배열을 반환하는데, 여기서 answer[i]는 i번째 쿼리의 정답 즉, k 이다.


코드

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 int[] getMaximumXor(int[] nums, int maximumBit) {
int[] pxor = getPrefixXOR(nums);

int[] answer = new int[nums.length];

int answerIndex = 0;
int maxValue = (1 << maximumBit) - 1;

for (int i = pxor.length-1; i > 0; --i) {

answer[answerIndex++] = pxor[i] ^ maxValue;
}

return answer;
}

private int[] getPrefixXOR(int[] nums) {
int[] psum = new int[nums.length + 1];

for (int i = 1; i <= nums.length; ++i) {
psum[i] = psum[i-1] ^ nums[i-1];
}
return psum;
}

흐름

  1. 누적합(prefix sum) 알고리즘을 통해 누적 XOR 배열을 구한다.
  2. 이 때, 2의 maximumBit승보다 작은 수 중에 가장 큰 수는 당연히 (2^maximumBit)-1 한 수 이므로, (2^maximumBit)-1 값을 변수에 할당해놓고,
  3. nums.length의 길이인 N 번 만큼 쿼리를 반복하면서 마지막 원소를 제거하면서 k 값을 구해 answer[i]에 저장한다.
  4. 끝.

pxor[i] ^ maxValue가 K인 이유

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void xorTEST() {
int x = 93;
int y = 12;
int z = x ^ y;

System.out.println("----- 초기값 -----");
System.out.println("x = " + x);
System.out.println("y = " + y);
System.out.println("z = " + z);

System.out.println("----- XOR -----");
System.out.printf("x ^ y = %d(z)\n", x^y);
System.out.printf("x ^ z = %d(y)\n", x^z);
System.out.printf("y ^ z = %d(x)\n", y^z);
}
1
2
3
4
5
6
7
8
----- 초기값 -----
x = 93
y = 12
z = 81
----- XOR -----
x ^ y = 81(z)
x ^ z = 12(y)
y ^ z = 93(x)

우리가 구해야 하는 값은 쿼리의 값을 가장 크게 할 값인 K이고, 우리는 이미 가장 큰 값을 (2^maximumBit)-1 로 구해놓았으므로,
누적합 알고리즘으로 구한 XOR값에 가장 큰 값을 XOR하면 k가 나오게 된다.


결과


댓글 공유

서론

패키지 구조를 수정 후, 기존에 정상 작동하던 custom repository에서 에러가 발생하여, 그 원인과 해결 방법에 대해 정리한다.


본론

코드의 구조는 아래처럼 되어 있다.

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RequiredArgsConstructor
@Service
public class ReviewFindService {

private final ReviewRepository reviewRepository;

public Review findById(long id) {
return this.reviewRepository.findById(id)
.orElseThrow(EntityNotFoundException::new);
}

public List<Review> findMyReviews() {
return this.reviewRepository.myReviews();
}
}

Repository

1
2
3
public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewCustomRepository {

}

Custom Repository

1
2
3
4
public interface ReviewCustomRepository {

List<Review> myReviews();
}

Custom Repository Implementation

1
2
3
4
5
6
7
8
@Repository
public class ReviewCustomRepositoryImpl implements ReviewCustomRepository {

@Override
public List<Review> myReviews() {
return new ArrayList<>();
}
}

그림으로


Service에서 Repository를 선언하여 사용하고, Repository는 CustomRepository를 상속받고, CustomRepositroy를 CustomRepositoryImpl가 구현체로 작성된 상태이다. 이때, ReviewFindService의 findMyReviews()를 호출하면, 아래와 같은 exception이 발생하고, 코드가 정상적으로 작동하지 않았다.
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
Caused by: java.lang.IllegalArgumentException: Failed to create query for method public abstract java.util.List com.jgji.sokdak.domain.review.domain.ReviewCustomRepository.myReviews()! No property 'myReviews' found for type 'Review'
at org.springframework.data.jpa.repository.query.PartTreeJpaQuery.<init>(PartTreeJpaQuery.java:96)
at org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy$CreateQueryLookupStrategy.resolveQuery(JpaQueryLookupStrategy.java:119)
at org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy$CreateIfNotFoundQueryLookupStrategy.resolveQuery(JpaQueryLookupStrategy.java:259)
at org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy$AbstractQueryLookupStrategy.resolveQuery(JpaQueryLookupStrategy.java:93)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.lookupQuery(QueryExecutorMethodInterceptor.java:103)
... 134 more
Caused by: org.springframework.data.mapping.PropertyReferenceException: No property 'myReviews' found for type 'Review'
at org.springframework.data.mapping.PropertyPath.<init>(PropertyPath.java:91)
at org.springframework.data.mapping.PropertyPath.create(PropertyPath.java:438)
at org.springframework.data.mapping.PropertyPath.create(PropertyPath.java:414)
at org.springframework.data.mapping.PropertyPath.lambda$from$0(PropertyPath.java:367)
at java.base/java.util.concurrent.ConcurrentMap.computeIfAbsent(ConcurrentMap.java:330)
at org.springframework.data.mapping.PropertyPath.from(PropertyPath.java:349)
at org.springframework.data.mapping.PropertyPath.from(PropertyPath.java:332)
at org.springframework.data.repository.query.parser.Part.<init>(Part.java:81)
at org.springframework.data.repository.query.parser.PartTree$OrPart.lambda$new$0(PartTree.java:250)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195)
at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:177)
at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:913)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:578)
at org.springframework.data.repository.query.parser.PartTree$OrPart.<init>(PartTree.java:251)
at org.springframework.data.repository.query.parser.PartTree$Predicate.lambda$new$0(PartTree.java:384)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195)
at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:177)
at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:913)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:578)
at org.springframework.data.repository.query.parser.PartTree$Predicate.<init>(PartTree.java:385)
at org.springframework.data.repository.query.parser.PartTree.<init>(PartTree.java:93)
at org.springframework.data.jpa.repository.query.PartTreeJpaQuery.<init>(PartTreeJpaQuery.java:89)
... 138 more

우선, 구현체인 ReviewCustomRepositoryImpl에 Break point 걸어보니, 아예 ReviewCustomRepositoryImpl의 myReviews()가 호출조차 되지 않는 것을 확인했다.
그 후 이것저것 코드도 수정해 보고 하였는데, ReviewCustomRepository를 따로 선언하여 사용하니 정상적으로 호출되었다.

Service에서 CustomRepository를 직접 참조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RequiredArgsConstructor
@Service
public class ReviewFindService {

private final ReviewRepository reviewRepository;
private final ReviewCustomRepository reviewCustomRepository;

public Review findById(long id) {
return this.reviewRepository.findById(id)
.orElseThrow(EntityNotFoundException::new);
}

public List<Review> findMyReviews() {
return this.reviewCustomRepository.myReviews();
}
}

기존에 계속 사용하던 구조이기에, CustomRepository를 선언하여 사용하는 방식으로는 문제 해결이 안 된다고 판단하여, 서론에서 말했던 것처럼 최근에 작업한 패키지 구조 수정 중 무언가 문제가 발생하였다고 판단되어 패키지 구조를 열어보니…



위 이미지처럼, Custom Repository interface와 구현체인 Custom Repository Impl 클래스가 서로 다른 패키지에 존재하고 있었고,



위처럼 같은 패키지 아래 존재 하도록 위치를 조정해주니 정상적으로 동작하는 것을 확인할 수 있었다.
interface는 기본적으로 접근제어자가 public으로 선언되는데, 패키지 위치가 다른 것이 어째서 문제가 되는지 찾아보니.. Spring 공식 문서에서 아래와 같은 가이드를 찾을 수 있었다.

The repository infrastructure tries to autodetect custom implementation fragments by scanning for classes below the package in which it found a repository. These classes need to follow the naming convention of appending a postfix defaulting to Impl.


결론

결론적으로, Custom Repository를 작성할 때는 구현체 클래스에 postfix로 Impl가 붙게 만들어야 하고, interface와 같은 위치, 혹은 하위 패키지에 존재하도록 하여야 한다는 것을 알았다.


참고 사이트

댓글 공유

서론

토이 프로젝트를 진행 중, 장소의 위치의 위도, 경도 좌표를 저장해야 했는데, float type의 ‘latitude’, ‘longitude’라는 2개의 컬럼을 만들어서 각각 따로 저장하려고 했는데, 우연찮은 기회에 공간 데이터 타입이라는 것이 따로 있다는 것을 발견하여 적용해본 경험을 글로 작성하여 보려고 한다.

본론

공간(空間, 영어: space)은 어떤 물질 또는 물체가 존재할 수 있거나 어떤 일이 일어날 수 있는 장소이다.


공간 데이터란, 위 같은 점, 선, 면의 공간을 데이터화 한 것을 의미하는데, 필자는 지도에서 위치를 표시하기 위해 2D 공간 데이터인 점(Point) 데이터를 저장할 필요가 있었다.
필자는 Spring boot + JPA(Hibernate) + H2 환경을 사용하고 있어서, 공간 데이터를 다루기 위해 hibernate-spatial 의존성을 추가해주었다.


1
2
3
4
5
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-spatial</artifactId>
<version>${hibernate.version}</version>
</dependency>

그 후, hibernate-spatial 라이브러리에 포함된 The JTS Topology Suite를 사용해 좌표 데이터를 다루면 되는데, 위에서 말한 것처럼 필자는 2D 데이터를 다루기 위해 Point를 사용했다.


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
import javax.persistence.Column;
import javax.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.locationtech.jts.geom.Point;

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Address {

@Column(name = "road")
private String road;

@Column(name = "jibun")
private String jibun;

@Column(name = "zip", nullable = false)
private String zip;

@Column(name = "location", nullable = false)
private Point location;

@Builder
public Address(String road, String jibun, String zip, Point location) {
this.road = road;
this.jibun = jibun;
this.zip = zip;
this.location = location;
}
}
  • 만약 필자처럼 Point를 사용할 경우 org.springframework.data.geo.Point를 import하면 정상적으로 동작하지 않으므로, 꼭 import문을 확인하길 바란다.

위처럼 당차게 Entity class를 작성하고, 아래 같은 테스트 데이터를 넣고, 호기롭게 테스트를 돌려본 결과…

입력 데이터

1
2
3
4
5
6
7
8
9
10

2022-11-30 22:48:44.230 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [TIMESTAMP] - [null]
2022-11-30 22:48:44.231 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [TIMESTAMP] - [null]
2022-11-30 22:48:44.231 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [지번 서울시]
2022-11-30 22:48:44.232 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [VARBINARY] - [POINT (19 2)]
2022-11-30 22:48:44.233 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [5] as [VARCHAR] - [도로명 서울시]
2022-11-30 22:48:44.234 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [6] as [VARCHAR] - [01006]
2022-11-30 22:48:44.235 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [7] as [BIGINT] - [null]
2022-11-30 22:48:44.235 TRACE 21788 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [8] as [VARCHAR] - [우리집]

  • 위 로그처럼 sql에 입력된 파라미터들이 보고 싶은 경우 아래처럼 설정을 추가해주면 된다.
1
2
3
4
5
6
7
logging:
level:
org:
hibernate:
type:
descriptor:
sql: trace

발생한 에러 로그

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Caused by: org.h2.jdbc.JdbcSQLDataException: Value too long for column "LOCATION BINARY VARYING(255)": "X'aced00057372001f6f72672e6c6f636174696f6e746563682e6a74732e67656f6d2e506f696e74... (1151)"; SQL statement:
insert into place (id, create_date, update_date, jibun, location, road, zip, category_id, name) values (default, ?, ?, ?, ?, ?, ?, ?, ?) [22001-214]
at org.h2.message.DbException.getJdbcSQLException(DbException.java:506)
at org.h2.message.DbException.getJdbcSQLException(DbException.java:477)
at org.h2.message.DbException.get(DbException.java:223)
at org.h2.message.DbException.getValueTooLongException(DbException.java:322)
at org.h2.value.Value.getValueTooLongException(Value.java:2573)
at org.h2.value.Value.convertToVarbinary(Value.java:1371)
at org.h2.value.Value.convertTo(Value.java:1125)
at org.h2.value.Value.convertForAssignTo(Value.java:1092)
at org.h2.table.Column.validateConvertUpdateSequence(Column.java:369)
at org.h2.table.Table.convertInsertRow(Table.java:926)
at org.h2.command.dml.Insert.insertRows(Insert.java:167)
at org.h2.command.dml.Insert.update(Insert.java:135)
at org.h2.command.CommandContainer.executeUpdateWithGeneratedKeys(CommandContainer.java:242)
at org.h2.command.CommandContainer.update(CommandContainer.java:163)
at org.h2.command.Command.executeUpdate(Command.java:252)
at org.h2.jdbc.JdbcPreparedStatement.executeUpdateInternal(JdbcPreparedStatement.java:209)
at org.h2.jdbc.JdbcPreparedStatement.executeUpdate(JdbcPreparedStatement.java:169)
at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:197)
... 136 more

테이블 생성 SQL

1
2
3
4
5
6
7
8
9
10
11
12
create table place (
id bigint generated by default as identity,
create_date timestamp,
update_date timestamp,
jibun varchar(255),
location varbinary(255) not null,
road varchar(255),
zip varchar(255) not null,
category_id bigint,
name varchar(255) not null,
primary key (id)
)

이처럼 예상하지 못한 결과가 발생하였는데… 로그를 보니, table create 시 좌표를 저장하는 컬럼의 타입이 기대했던 GEOMETRY이 아닌, varbinary 타입으로 생성되어서 발생한 에러인가 싶어서 컬럼 타입을 “GEOMETRY”로 지정하고 생성해봤지만, 결과는 마찬가지였다.

수정한 코드 및 실행된 SQL

1
2
3
4
5
6
7
8
9

...

// columnDefinition 속성으로 컬럼 타입 지정
@Column(name = "location", nullable = false, columnDefinition = "GEOMETRY")
private Point location;

...


1
2
3
4
5
6
7
8
9
10
11
12
create table place (
id bigint generated by default as identity,
create_date timestamp,
update_date timestamp,
jibun varchar(255),
location GEOMETRY not null, -- 정상적으로 컬럼의 데이터 타입이 GEOMETRY로 변경
road varchar(255),
zip varchar(255) not null,
category_id bigint,
name varchar(255) not null,
primary key (id)
)

다시 원인을 검색해본 결과… hibernate-spatial에서는 H2가 아닌 H2의 확장 데이터베이스인 GeoDB를 지원한다고 되어있는 것을 확인할 수 있었다.

The GeoDBDialect supports the GeoDB a spatial extension of the H2 in-memory database.

그 후 GeoDB를 적용하기 위해 properties 혹은 yaml에 아래처럼 dialect 설정에 org.hibernate.spatial.dialect.h2geodb.GeoDBDialect를 추가해 주면,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
datasource:
url: jdbc:h2:mem:test
username: sa
password:
driver-class-name: org.h2.Driver

jpa:
database-platform: org.hibernate.dialect.H2Dialect
defer-datasource-initialization: true
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.spatial.dialect.h2geodb.GeoDBDialect # 추가 혹은 변경

dialect 설정 전 dialect

1
2022-11-30 23:37:47.975  INFO 15720 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect

dialect 설정 후 dialect

1
2022-11-30 23:35:42.124  INFO 18400 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.spatial.dialect.h2geodb.GeoDBDialect


이처럼 테스트가 성공하는 모습을 확인할 수 있었다.


참고 사이트

댓글 공유

서론

필자는 항상 단순하고 반복적인 일은 기계가 하고, 사람은 사색해야 한다고 생각한다. 당연하지만 이러한 생각은, 코딩할 때도 마찬가지다. class를 생성할 때마다 붙여야 하는 Annotation들을 (예를 들면 @Getter 같은) 붙이는 게 그렇게 귀찮을 수가 없었는데… 그러던 중 Intelli J의 Live Template 이라는 기능을 발견하여 필자 같은 사람들의 고통을 줄여 주고자 이 글을 작성한다.


본론


우선 위 이미지처럼 preference(settings)을 열어서 'Live Templates'를 검색해서 Live Templates 메뉴를 클릭한다.

열면 오른쪽에 여러 Template 그룹들이 존재하는데 필자처럼 Java가 없다면 Java 그룹을 생성해주거나, 기존 그룹에 탬플릿만 추가하여도 된다.


필자는 Entity 클래스를 생성하거나, request, response dto를 생성할 때마다

1
2
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)

annotation을 붙이는게 너무 귀찮아서 위 코드를 탬플릿으로 지정하고 단축키는 ‘gn’으로 설정해줬다.


그리고 해당 annotation들은 class에만 적용하면 되므로 Declaration으로만 지정해주었다.


모든 설정을 완료하고 이제 설정한 단축키를 지정한 위치에서 사용해보면 이처럼 간단하게 우리가 탬플릿화한 코드가 자동으로 생성되는 모습을 볼 수 있다.

이 글에선 소개하지 않았지만, template text를 입력하는 곳에서 $METHOD_NAME$ 처럼 $$ 으로 변수를 선언하여 사용하는 방법들도 있으니, 필요한 사람들은 꼭 활용하여 생산성을 높일 수 있기를 바란다.


결론

이렇게 우리는 또 작은 할 일과 짧은 시간을 기계에 넘기고 다른 생산적인 일을 할 시간을 얻을 수 있었다. 티끌 모아 태산이라는 말이 있듯이 이런 작은 일들이 줄여서 큰일을 할 수 있기를 바라며 글을 마친다.

참고 사이트

댓글 공유

발생 경위

기존 Spring 4.3.3을 사용하던 프로젝트를 5.3.22으로 업그레이드 이후 발생하였다.

에러로그 전체

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'command' available as request attribute
at org.springframework.web.servlet.support.BindStatus.<init>(BindStatus.java:153)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getBindStatus(AbstractDataBoundFormElementTag.java:178)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getPropertyPath(AbstractDataBoundFormElementTag.java:199)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getName(AbstractDataBoundFormElementTag.java:164)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.writeDefaultAttributes(AbstractDataBoundFormElementTag.java:123)
at org.springframework.web.servlet.tags.form.AbstractHtmlElementTag.writeDefaultAttributes(AbstractHtmlElementTag.java:460)
at org.springframework.web.servlet.tags.form.SelectTag.writeTagContent(SelectTag.java:405)
at org.springframework.web.servlet.tags.form.AbstractFormTag.doStartTagInternal(AbstractFormTag.java:87)
at org.springframework.web.servlet.tags.RequestContextAwareTag.doStartTag(RequestContextAwareTag.java:83)
at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspService(new_jsp.java:263)
at org.apache.jasper.runtime.HttpJspBase.service(HttpJspBase.java:70)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:476)
at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:386)
at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:330)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:728)
at org.apache.catalina.core.ApplicationDispatcher.doInclude(ApplicationDispatcher.java:591)
at org.apache.catalina.core.ApplicationDispatcher.include(ApplicationDispatcher.java:527)
at org.apache.jasper.runtime.JspRuntimeLibrary.include(JspRuntimeLibrary.java:868)
at org.apache.jasper.runtime.PageContextImpl.doInclude(PageContextImpl.java:679)
at org.apache.jasper.runtime.PageContextImpl.include(PageContextImpl.java:673)
at org.apache.tiles.request.jsp.JspRequest.doInclude(JspRequest.java:123)
at org.apache.tiles.request.AbstractViewRequest.dispatch(AbstractViewRequest.java:47)
at org.apache.tiles.request.render.DispatchRenderer.render(DispatchRenderer.java:45)
at org.apache.tiles.request.render.ChainedDelegateRenderer.render(ChainedDelegateRenderer.java:68)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:259)
at org.apache.tiles.template.InsertAttributeModel.renderAttribute(InsertAttributeModel.java:188)
at org.apache.tiles.template.InsertAttributeModel.execute(InsertAttributeModel.java:132)
at org.apache.tiles.jsp.taglib.InsertAttributeTag.doTag(InsertAttributeTag.java:299)
at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspx_meth_t_005finsertAttribute_005f4(layout_jsp.java:10881)
at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspService(layout_jsp.java:1242)
at org.apache.jasper.runtime.HttpJspBase.service(HttpJspBase.java:70)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:476)
at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:386)
at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:330)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:728)
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:470)
at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:395)
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:316)
at org.apache.tiles.request.servlet.ServletRequest.forward(ServletRequest.java:265)
at org.apache.tiles.request.servlet.ServletRequest.doForward(ServletRequest.java:228)
at org.apache.tiles.request.AbstractClientRequest.dispatch(AbstractClientRequest.java:57)
at org.apache.tiles.request.render.DispatchRenderer.render(DispatchRenderer.java:45)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:259)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:397)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:238)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:221)
at org.apache.tiles.renderer.DefinitionRenderer.render(DefinitionRenderer.java:59)
at org.springframework.web.servlet.view.tiles3.TilesView.renderMergedOutputModel(TilesView.java:147)
at org.springframework.web.servlet.view.AbstractView.render(AbstractView.java:316)
at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1404)
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1148)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1087)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:634)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.catalina.filters.ExpiresFilter.doFilter(ExpiresFilter.java:1228)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter.doFilterInternal(OpenEntityManagerInViewFilter.java:186)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:94)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:330)
at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:118)
at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:84)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:113)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:103)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:113)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter.doFilter(RememberMeAuthenticationFilter.java:146)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:154)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:45)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:199)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:199)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:57)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:87)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:50)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:192)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:160)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:354)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:267)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:199)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:543)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:81)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:678)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:87)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:609)
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:810)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1623)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.base/java.lang.Thread.run(Thread.java:829)

해결 과정

에러로그 일부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
	
...

java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'command' available as request attribute
at org.springframework.web.servlet.support.BindStatus.<init>(BindStatus.java:153)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getBindStatus(AbstractDataBoundFormElementTag.java:178)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getPropertyPath(AbstractDataBoundFormElementTag.java:199)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getName(AbstractDataBoundFormElementTag.java:164)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.writeDefaultAttributes(AbstractDataBoundFormElementTag.java:123)
at org.springframework.web.servlet.tags.form.AbstractHtmlElementTag.writeDefaultAttributes(AbstractHtmlElementTag.java:460)
at org.springframework.web.servlet.tags.form.SelectTag.writeTagContent(SelectTag.java:405)
at org.springframework.web.servlet.tags.form.AbstractFormTag.doStartTagInternal(AbstractFormTag.java:87)
at org.springframework.web.servlet.tags.RequestContextAwareTag.doStartTag(RequestContextAwareTag.java:83)
at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspService(new_jsp.java:263)

....


> Neither BindingResult nor plain target object for bean name 'command' available as request attribute

짧은 영어 실력과 구글 번역기에 도움으로 해석해보자면 BindingResult이나 ‘command’ 이라는 이름의 bean 을 request의 속성으로 사용할 수 없어서 발생하는 에러인 것 같은데,
에러 로그를 확인 한 결과… 위에서 보이는 것 처럼, JSP의 Form Tag에서 에러가 발생하는 것으로 추측되어 4.3.3 version에서 Form Tag를 구현하는 FormTag를 확인해보니 commandName 관련된 메서드들이 @Deprecated 로 선언되어서 곧 없어진다고 알려주고 있었는데, 이번에 업그레이드한 5.3.22 version의 FormTag에선 commandName의 getter, setter 메서드들이 없는 것을 확인할 수 있었다.


그리고 이미지를 보면 알 수 있듯이 기존의 setCommandName 메서드에서도 modelAttribute에 commandName을 저장해서 쓰는 것을 확인할 수 있는데, 5.3.22 에선 해당 메서드가 없어지면서 변수 modelAttribute에 ‘command’가 할당되어 있기에, ‘command’ 라는 bean을 속성으로 사용할 수 없다고 에러가 발생하는 것이었다.



이렇게 된 것이니 당연하게도 해결 방법은 modelAttribute에 우리가 지정한 속성값이 set 되도록 하면 되는데, 위에서 본 것처럼 기존의 setCommandName에서도 modelAttribute의 값을 사용하고 있었으므로, setModelAttribute로 modelAttribute 값이 저장되도록 하면 문제는 해결될 것이기에, JSP form tag에 commandName 속성을 모두 modelAttribute로 변경한 후 확인해 보면 정상적으로 동작 하는 것을 확인할 수 있을 것이다.



참고 사이트

댓글 공유

서론

현재 운영 중인 서비스는 같은 데이터베이스를 공유하는 5개의 프로젝트가 존재하는데, 모두 Spring or Spring boot + JPA를 사용하는 프로젝트들이다.

이 때문에, 한 프로젝트에서 도메인(Entity) 클래스에 변수를 추가한 경우에 다른 모든 프로젝트에서도 동일하게 추가 해주지 않으면 데이터베이스 조회 시 등 에러가 발생 할 수 밖에 없는데 이런 copy & paste 반복 작업을 개발자가 손수 하다보면 휴먼에러가 발생할 수 밖에 없음은 물론 같은 데이터베이스를 사용하는 Entity 임에도 불구하고 코드가 모두 다른 이상한 상황이 발생 할 수 밖에 없는 구조였다.

그렇기에 어떻게 할까 고민하던 중 멀티모듈 설계 이야기 with Spring, Gradle 이라는 글을 보고 필자의 서비스에도 단일 프로젝트 멀티모듈 구조로 가져갈 까 하다, 각각의 프로젝트마자 깃을 따로 관리하는게 서비스에는 맞다는 생각이 들어 멀티 프로젝트에 Nexus maven 저장소를 이용해서 도메인 클래스들을 분리 시키기로 했다.


본론

현재 시스템의 구조

이 처럼 여러 개의 프로젝트가 하나의 데이터베이스를 공유하는 구조로, 같은 데이터베이스 이므로 동일한 내용의 도메인 클래스가 각자 프로젝트에 각각 중복으로 존재하기 때문에 관리가 힘든 상황이다.

때문에 도메인 클래슬들만 모아놓은 새로운 프로젝트를 생성하고, 그 프로젝트를 jar파일로 만들어 라이브러리화 한 후, 내부 Maven 저장소인 Nexus에 업로드해서 다른 프로젝트들에서 dependency에 추가해서 시스템적으로 정합성을 유지하면서 사용 할 수 있게 하려고 한다.

간단하게 정리하면

  1. Nexus에 모듈화된 도메인 프로젝트 업로드
  2. 다른 프로젝트들에서 라이브러리화 된 도메인 프로젝트를 dependency 추가하여 사용

하는 것이 이번 글의 목표이다.

이 포스트에 Nexus를 설치하는 내용까지 담으려고 하니 내용이 너무 길어질 것 같아 nexus는 이미 설치되어 있다고 가정한다.


1. Nexus 설정

다른 분들의 글들을 보고 nexus를 무사히 설치하고 실행하면 아래와 같은 화면을 볼 수 있을 것이다.

그 후 Sign in 버튼을 클릭하면 초기 아이디와 비밀번호가 저장된 파일의 위치를 알려준다.


1
cat /설치경로/nexus3/admin.password


  • 설정 하다보면 아래처럼 익명 사용자도 접근하게 할 것인지 물어보는데 당연히도 수정 할 수 있으므로 적당히 선택해주면 된다.


2. Repository 생성

우선 모듈화한 도메인 프로젝트를 업로드 할 레파지토리부터 생성해야한다.



레파지토리 type은 group, hosted, proxy가 있지만, 우리는 위 처럼 maven2의 hosted로 생성한다.

  • 이때 hosted는 레파지토리 관리자가 직접 호스팅하는 레파지토리 type이고,
  • 다른 것들과의 차이점은 여기서 확인 해 볼 수 있다.



  • Version policy는

    • Release
    • Snapshot
    • Mixed

    3 가지가 존재하는데, 보통 Snapshot 레파지토리, Release 레파지토리를 따로 만들어 관리해야하지만, 필자는 Mixed로 지정하고 사용하였다.



  • Deployment policy는

    • Allow redeploy
      • 같은 버전으로 재배포 가능하고 덮어씀
    • Disable redeploy
      • 같은 버전으로 다시 배포 할 수 없음
    • Read-only
      • 아예 배포가 허용되지 않음
    • Deploy by Replication Only
      • 레플리카를 사용하는 경우 레플리카로만 배포되고 다른 배포는 차단됨
  • 자세한 내용은 여기서 확인 할 수 있다.



3. Repository access 유저 생성

Role 생성



필터란에 생성한 레파지토리명으로 검색해서 nx-repository-view-… 로 시작하는 권한들을 넣어주고, 해당 Role로 레파지토리에 업로드도 할 수 있게 하기 위해 nx-component-upload 권한도 넣어준다.

유저 생성



  • 각 항목들은 각자에 맞게 넣어주고 Role엔 조금 전에 생성한 Role을 넣어주고 생성을 완료하면 우리가 처음에 로그인한 어드민 계정과는 달리 우리가 생성한 repository만 접근 할 수 있는 것을 확인 할 수 있다.


4. 라이브러리 업로드

settings.xml 설정

nexus 레파지토리에 접근하기 위해서는 우선, maven 설정 파일인 settings.xml을 수정해야야 한다. 기본적으로는 ~/.m2/settings.xml 으로 설정되어 있으므로 이 곳에 생성해서 사용하거나, 이미 settings.xml을 사용하고 있다면 그 파일을 수정해주면 되는데.. 이도 저도 모르겠으면 아래 이미지 처럼 인텔리제이에서 확인하여 그 위치의 settings.xml을 수정해준다.


1
2
3
4
5
6
7
8
9
10
11
12
13
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">

<servers>
<server>
<id>junggu-demo-repository</id>
<username>junggu-demo-user</username>
<password>qweqwe</password>
</server>
</servers>
</settings>
  • id : 레파지토리 서버의 ID (자유롭게 지정)
  • username : 레파지토리 접근을 위해 생성했던 nexus access 계정 Id
  • password: 레파지토리 접근을 위해 생성했던 nexus access 계정 password

코드 작성

모듈화할 코드를 작성하면 되는데, 우리는 도메인 영역을 모듈화 할 것이므로 Entity 클래스를 생성해서 테스트 해본다.


POM.xml 설정

코드 작성을 완료했으면 이제 해당 프로젝트를 nexus repository에 업로드 하기 위해 pom.xml에 아래 처럼 설정을 추가해줘야한다.

1
2
3
4
5
6
7
<distributionManagement>
<repository>
<id>junggu-demo-repository</id>
<name>junggu nexus repository~ name is free</name>
<url>http://localhost:8081/repository/junggu-nexus-demo/</url>
</repository>
</distributionManagement>
  • id : settings.xml에서 설정했던 id와 동일해야함.
  • name : 사람이 식별 할 수 있게 설정.
  • url : 생성한 repository의 url

  • <distributionManagement> 와 <repository>대한 자세한 설명은 이 쪽으로

배포하기

1
mvn clean compile deploy
  • 위 명령어로 deploy 하거나 혹은 Intellij에서 deploy를 실행시켜도 된다.


  • 무사히 deploy가 성공했다면 nexus에서 jar 파일이 정상적으로 업로드된 것을 확인 할 수 있고, 이 후엔 이 라이브러리화된 jar파일을 다른 프로젝트들에서 dependency에 추가해서 사용하면 된다.


업로드된 라이브러리 사용하기

업로드된 라이브러리를 사용하기 위해서 사용하고자 하는 프로젝트의 pom.xml에 아래와 같은 설정을 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...


<!-- 모듈화되서 업로드된 도메인 프로젝트 -->
<dependency>
<groupId>com.junggu.ji</groupId>
<artifactId>demo</artifactId>
<version>0.0.1</version>
</dependency>

...


<!-- 라이브러리가 업로드된 Nexus Repository 정보 -->
<repositories>
<repository>
<id>junggu-demo-repository</id>
<name>junggu nexus repository</name>
<url>http://localhost:8081/repository/junggu-nexus-demo/</url>
</repository>
</repositories>
  • 우선 라이브러리가 업로드된 nexus repository의 정보를 입력하고
  • 우리가 업로드한 라이브러리 정보도 dependency에 추가해준다.
  • 이 때, dependency의 속성들이 헷갈린다면, nexus에서 업로드된 jar를 선택 할 경우 아래 이미지처럼 친절하게 알려주므로 복사해서 사용하면 된다.


Entity 호출하기


  • 위 이미지 처럼 dependency 추가 후 maven을 새로고침하면 보이는 것 처럼 추가도 잘 되고 코드에서도 사용 할 수 있는 것 처럼 보이는데 막상 코드에서 사용하려고 하면 아래 이미지에서 보는 것 처럼 정상적으로 호출되지 않는 것을 알 수 있다.


  • 결론부터 이야기하면 우리가 jar로 만든 프로젝트는 Spring boot 프로젝트를 그대로 jar로 패키징 한 것으로, 일반적인 jar 파일이 아닌 Spring boot가 실행 가능한 jar 형태로 패키징 되었기 때문에, jar에 포함된 클래스들을 호출 할 수가 없는 것이다.
    • 여기서 말하는 실행 가능한 jar는 META-INF에 저장된 MANIFEST.MF에 저장된 정보를 읽어서 실행시키기 때문에 우리가 일반적으로 호출하는 형태로는 사용 할 수 없다.



이 실행 가능한 jar를 일반적인 jar로 만들어서 repository에 배포하기 위해서 모듈화하는 프로젝트의 pom.xml에서 아래 설정을 제거 해주고 다른 dependency들에게 각각 version을 지정한 후 maven compiler plugin도 알맞게 추가해주면 되는데

이 때 우리가 삭제한 spring-boot-parent에 포함된 의존성들은 여기서 확인 할 수 있으므로, 필요한 것이나 각각 dependency들을 무슨 버전으로 지정해야 할 지 모르겠다면 여기서 확인 후 추가해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<plugins>

...


<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
<compilerArgument>-Xlint:all</compilerArgument>
</configuration>
</plugin>

...

<plugins>
  • 이제 verison을 올린 후 새로고침 해보면 이전과 다르게 BOOT-INF가 없어지고 바로 com. 으로 시작하는 자바 패키지가 노출되는 것을 확인 할 수 있다.


이제 테스트 코드를 작성해서 돌려보면 테스트가 성공 할 줄 알았으나.. 애석하게도 아래와 같이 Not a managed type 라는 에러를 내뿜으며 우리의 기대를 무참히 박살내는데…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Caused by: java.lang.IllegalArgumentException: Not a managed type: class com.junggu.ji.demo.domain.Junggu
at org.hibernate.metamodel.internal.MetamodelImpl.managedType(MetamodelImpl.java:582)
at org.hibernate.metamodel.internal.MetamodelImpl.managedType(MetamodelImpl.java:85)
at org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation.<init>(JpaMetamodelEntityInformation.java:75)
at org.springframework.data.jpa.repository.support.JpaEntityInformationSupport.getEntityInformation(JpaEntityInformationSupport.java:66)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getEntityInformation(JpaRepositoryFactory.java:233)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:182)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:165)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:76)
at org.springframework.data.repository.core.support.RepositoryFactorySupport.getRepository(RepositoryFactorySupport.java:325)
at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.lambda$afterPropertiesSet$5(RepositoryFactoryBeanSupport.java:323)
at org.springframework.data.util.Lazy.getNullable(Lazy.java:231)
at org.springframework.data.util.Lazy.get(Lazy.java:115)
at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.afterPropertiesSet(RepositoryFactoryBeanSupport.java:329)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.afterPropertiesSet(JpaRepositoryFactoryBean.java:144)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1863)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1800)
... 85 more

이는 우리가 추가한 라이브러리의 Entithy가 component scan에 인식되지 않아서 Bean context에 등록되지 않았기 때문에 발생하는 에러이다.

왜냐면, spring boot는 기본적으로 @SpringBootApplication안에 있는 @EnableAutoConfiguration으로 스프링이 정의한 외부 의존성들과 @ComponentScan으로 해당 어노테이션 하위 패키지에 있는 클래스들을 탐색해서 Bean으로 등록하는데,

우리가 추가한 라이브러리는 둘 중 어느 위치에도 존재하지 않으므로 당연히 Bean에 등록 될 수없는 것이다.

라이브러리안에 있는 Entity Bean context 등록하기

여러 방법이 있겠지만, 필자는 SessionFactory Bean을 따로 만들어서 우리가 만든 Entity가 있는 라이브러리의 패키지까지 스캔 하도록 추가하는 방식을 사용하였다.

1
2
3
4
5
6
7
8
@Bean
public LocalSessionFactoryBean entityManagerFactory(DataSource dataSource) {
LocalSessionFactoryBean localSessionFactory = new LocalSessionFactoryBean();
localSessionFactory.setDataSource(dataSource);
localSessionFactory
.setPackagesToScan("com.junggu.ji.usenexus", "com.junggu.ji.demo");
return localSessionFactory;
}

적당히 작성한 테스트 코드와 성공한 모습
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
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource("classpath:application-test.properties")
@Import(DatabaseConfig.class)
@DataJpaTest
public class NexusTest {

@Autowired
private JungguRepository jungguRepository;

private final String name = "지중구";
private final int age = 30;
private final Gender gender = Gender.MALE;

private Junggu junggu = null;

@BeforeEach
void setUp() {

junggu = Junggu.builder()
.name(name)
.age(age)
.gender(gender)
.build();
}

@Test
void save() {
Junggu when = jungguRepository.save(junggu);

assertThat(when.getName()).isEqualTo(junggu.getName());
assertThat(when.getAge()).isEqualTo(junggu.getAge());
assertThat(when.getGender()).isEqualTo(junggu.getGender());
}
}


결론

이렇게 우리는 여러 프로젝트에서 공통으로 사용되는 부분을 따로 빼서 모듈화 하는 방법을 알아보았다.

필자는 단순히 Entity 클래스의 관리를 편하게 하기 위해 이번 모듈화를 진행했지만, 레이어 별로 프레젠테이션 레이어, 서비스 레이어, 도메인 레이어등으로 구분하여 모듈을 구분 할 수도 있을 것 이다.

다만, 이렇게 멀티 프로젝트에 nexus를 이용해서 모듈화를 구현하는 경우, 모듈화한 프로젝트를 수정 했을 때 모듈화된 라이브러리를 사용하는 다른 프로젝트 모든 프로젝트에서도 손수 version을 올려줘야한다는 단점이 있기 때문에(물론 Deployment policy를 Allow redeploy로 설정했다면 상관없다.), 각자의 서비스에서 단일 프로젝트 멀티 모듈 구조가 맞을 지 아니면 필자처럼 멀티 프로젝트인 상태로 모듈화를 구성 할지는 잘 판단해서 구조화를 해야 할 것 이다.


P.S

여담으로.. 필자는 이번 모듈화를 진행하면서 DTO에 의존적인 Entity들에 대해 굉장히 많이 리팩토링을 진행 할 수 있었고, 레이어 별 분리에 대해 다시 한번 생각해보는 계기가 되어서 고생은 좀 많이 했지만, 개인적으로 굉장히 뜻 깊은 시간을 보낼 수 있었다.


참고 사이트

댓글 공유

서론

어쩌다 보니 연속해서 @RequestBody에 관한 글을 작성하게 되었는데… 이전에 말했던 것 처럼 @RequestBody는 Jackson 라이브러리를 사용하기 때문에 기본생성자를 이용해 object(DTO)를 바인딩한다. 그 때문에 다른 생성자에서 validtion등을 처리했다고 해도 당연히 처리되지 않는데 이 때 사용할 수 있는 @JsonCreator에 대해 알아본다.


본론

현재 상황



위 이미지들 처럼 Controller와 dto를 만들었다고 했을 때, /test 를 호출해보면 어떻게 될까?


@RequestBody를 사용 할 경우 Jackson 라이브러리를 사용해서 기본 생성자를 이용해 매핑시키기 때문에 “여기타고 생성이 될까요?” 라는 로그는 찍히지 않는다.

이 때, DTO에 처리 혹은 Assert 등을 이용한 validtion을 추가하고 싶다면 어떻게 될까?

생성자에 추가해서 처리하면 좋겠지만, 기본 생성자를 이용해서 생성되므로, controller 혹은 service 단에서 처리해야 한다.(물론 validtion의 경우엔 @Valid를 통해 어느정도 처리 가능하다.)

이런 경우, 기본 생성자가 아닌 다른 생성자를 이용해 dto를 바인딩 시키고 싶은 경우 사용 할 수 있는 annotation이 바로 @JsonCreator이다.

이제 다시 코드로 돌아와서, 이 @JsonCreator를 생성자에 선언 해놓고 다시 /test를 요청한다면


아까와 다르게 @Builder 패턴이 적용된 생성자를 통해 바인딩 되어서 “여기타고 생성이 될까요?” 라는 로그가 정상적으로 찍힌 걸 볼 수 있다.


결론

이제 우리는 @RequestBody를 사용할 때 기본 생성자만이 아닌 다른 생성자로도 DTO를 바인딩 할 수 있게 되었으므로, @Valid보다 더 정교한 validtion이나, String을 encoding 해야 할 경우에도 service나 controller가 아닌 생성자에서 처리 할 수 있게 되었으므로 테스트도 더 간편하게 할 수 있게 되었다.

번외로 보통 @JsonCreator는 인스턴스화 할 때 사용 할 생성자를 정의 할 때 사용 하는 annotation으로,
보통 아래 코드처럼 @JsonProperty와 같이 사용해서 엔티티와 완벽히 일치되지 않는 JSON을 역직렬화할 때 주로 사용하는 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
public class BeanWithCreator {
public int id;
public String name;

@JsonCreator
public BeanWithCreator(
@JsonProperty("id") int id,
@JsonProperty("theName") String name) {
this.id = id;
this.name = name;
}
}

개인적으로 이렇게 쓸 일이 몇 번이나 있을까 싶긴하지만…


전체 코드

참고 사이트

댓글 공유

Junggu Ji

author.bio


author.job