[나의 로직] remote config 업데이트 시 config 사용 접근 막기 (나름 해답 추가)

일을 하면서 고민을 많이 한 로직에 대해 나는 어떻게 코드를 짰는지에 대해 글을 써보려고 한다.
다시 보면서 나도 돌이켜 볼 수도 있고 더 좋은 로직도 생각해 볼 수도 있고…

기존 서비스에서 JNI와 연동할 때 사용하는 특정 설정값들을 Spring Boot의 application.properties에 박아서 사용했는데 이제 이 설정값들을 어드민 페이지에서 설정할 수 있도록 remote config 방식으로 기능을 변경하는 일을 맡았다.

remote config의 구현의 경우 DB에 설정값들을 저장하고 설정값을 사용하는 서버에서는 이 값들을 컬렉션에 담아 특정 클래스의 필드에 저장시켜놓는 방식으로 구현하였다.
그리고 설정값을 어드민 페이지에서 설정값이 변경되어 업데이트하라는 HTTP 요청을 하면 DB에서 다시 설정값을 가져와 컬렉션에 담고 필드에 저장한다.

구현 자체는 그리 어렵지 않다.
DB에서 설정값을 가져와서 컬렉션에 담아 필드에 박아놓기만 하면 되기 때문이다.
하지만 내가 집중한 문제는 update 하는 순간 설정값이 담긴 컬렉션에 접근을 하려고 할 때 이를 어떻게 처리할 것인가에 대한 것이다.
해당 서비스는 하루 24시간 어느 때고 사용할 수 있는 서비스였고 그러다 보니 새벽에 작업을 한다 하더라도 정말 타이밍 좋게 메모리에 있는 설정값들을 변경하려 할 때 설정값에 접근을 시도할 수도 있다.
예를들어 설정값을 변경하려고 컬렉션을 다 비웠을 때 해당 컬렉션에 접근하면 당연히 아무것도 들어있지 않을 것이고 서비스에 문제가 생길 것이다.
그래서 해당 기능을 구현을 하면서 이 엣지케이스에 대해 혼자 여러가지로 고민을 많이 해봤다.

아래는 나의 상황을 간소하게 비유한 코드이다.

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
public class SingletonMan {
private static class LazyHolder {
private static final SingletonMan INSTANCE = new SingletonMan();
}

private SingletonMan() {}

public static SingletonMan getInstance() {
return LazyHolder.INSTANCE;
}

private String name = "Chris";

public String getName() {
name.length(); // NPE가 일어나는지 확인하기 위해 추가
System.out.println("get name");
return this.name;
}

public String setName(String name) {
this.name = null;

try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
// do nothing...
}

System.out.println("set name");
return this.name = name;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@SpringBootApplication
@RestController
public class SyncTestApplication {

public static void main(String[] args) {
SpringApplication.run(SyncTestApplication.class, args);
}

@GetMapping("/set")
public String set(String name) {
return SingletonMan.getInstance().setName(name);
}

@GetMapping("/get")
public String get() {
return SingletonMan.getInstance().getName();
}

}

위 코드와 같이 HTTP 요청을 통해 싱글톤으로 구현된 클래스의 필드 값을 가져오고 변경한다.
크게 문제가 될 여지는 다음과 시나리오로 요청을 할 경우이다.

  1. /set 요청

    • setName() 호출
    • name 필드가 null로 할당됨
    • 5초 sleep
  2. /get 요청 (sleep 도중에)

    • getName() 호출
    • name.length() 호출 => NPE 발생

그래서 setName()을 호출하여 name 필드에 대한 작업을 하는 동안 getName()이 호출된다면 실행 대기상태
(많은 데이터를 작업하는 것이 아니기 때문에 요청을 튕기는 것이 아니라 작업이 끝날 때까지 대기 후 접근하도록 하는 시나리오)로 놓고 setName()의 실행이 끝난 후 getName()이 실행되게 하고 싶었다.
(위 코드가 어거지이긴 하지만 특정 메서드 실행 시 다른 메서드 실행을 대기시켜야한다는 컨셉을 이해해줬으면 좋겠다.)

이러한 로직을 구현하고 싶어서 SingletonMan에 아래와 같이 lock 객체를 통해 synchronized를 걸었다.

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
public class SingletonMan {
private final Object lock = new Object();

private static class LazyHolder {
private static final SingletonMan INSTANCE = new SingletonMan();
}

private SingletonMan() {}

public static SingletonMan getInstance() {
return LazyHolder.INSTANCE;
}

private String name = "Chris";

public String getName() {
synchronized (lock) {
name.length(); // NPE가 일어나는지 확인하기 위해 추가
System.out.println("get name");
return this.name;
}
}

public String setName(String name) {
synchronized (lock) {
this.name = null;

try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
// do nothing...
}

System.out.println("set name");
return this.name = name;
}
}
}

하지만 이 경우 getName()에 내부에 synchronized가 걸려있어 하나의 thread만 getName()에 접근 가능하기 때문에 서버의 TPS가 현저하게 떨어질 것이라 생각을 하여 최종적으로 아래 코드를 생각해냈다.

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
import java.util.concurrent.atomic.AtomicInteger;

public class SingletonMan {
private final Object lock = new Object();
private final AtomicInteger atomicInteger = new AtomicInteger(0);

private static class LazyHolder {
private static final SingletonMan INSTANCE = new SingletonMan();
}

private SingletonMan() {}

public static SingletonMan getInstance() {
return LazyHolder.INSTANCE;
}

private String name = "Chris";

public String getName() {
if (atomicInteger.get() != 0) {
synchronized (lock) {}
}

name.length(); // NPE가 일어나는지 확인하기 위해 추가
System.out.println("get name");
return this.name;
}

public String setName(String name) {
atomicInteger.incrementAndGet();

synchronized (lock) {

this.name = null;

try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
// do nothing...
}

System.out.println("set name");

atomicInteger.decrementAndGet();
return this.name = name;
}
}
}

AtomicInteger를 사용하여 이 값이 0이 아닐 경우 setName이 진행 중이라고 판단을 하여 lock object의 sync를 기다리게 하였다.
(AtomicInteger를 사용한 이유는 멀티스래딩 환경에서도 안정적인 increment, decrement를 할 수 있게 해주기 때문에 해당 클래스를 사용했다)

일단 최종적으로 원하는 대로는 동작은 한다.
하지만 이런 식의 로직과 코드는 적용해본 적이 없어 확신이 서질 않는다.
좀 더 고민해볼 필요가 있을 것 같다.


ReentrantLock을 사용해볼까도 했지만 고급 동시성 스킬이 필요한게 아니라서 보편적인 synchronized 키워드를 사용하여 구현했다.

아예 이렇게 로직을 만든게 부적절할 수도 있다 그래도 이런 문제에 대해 좀 더 생각해 볼 수 있는 기회가 된 것 같다.

좀 맘에 걸리는 부분은 JIT Compiler가 getName()의 빈 synchronized 부분을 없애지 않을까 하는 염려도 있다. 이 부분은 확인해야할 필요가 있을 것 같다.


위에 까지가 본문이고 이에 대한 나름 깔끔한 해답을 찾아서 밑에 추가한다.

ReentrantLock만 대충 알고 내가 사용하기엔 too much하다는 생각을 했었는데
java.util.concurrent.locks 패키지를 더 파볼 생각을 못해 ReentrantReadWriteLock은 몰랐다.

ReentrantReadWriteLock이 딱 내가 원하는 기능을 제공하는 동시성 관련 java API로
write에 비해 read가 빈번한 상황에서 사용하기 좋은 class 이다.

writeLock().lock()을 하면 읽기나 쓰기 도중엔 lock이 풀릴 때 까지 기다리고
readLock.lock()을 하면 읽기에서는 일반 메서드 처럼 실행이 되고 쓰기 도중에는 write의 lock이 풀릴 때 까지 대기를 한다.

그래서 위에 장황한 코드는 아래와 같이 깔금하게 정리 가능하다.

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
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class SingletonMan {

private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

private static class LazyHolder {
private static final SingletonMan INSTANCE = new SingletonMan();
}

private SingletonMan() {}

public static SingletonMan getInstance() {
return LazyHolder.INSTANCE;
}

private String name = "Chris";

public String getName() {
lock.readLock().lock();

name.length(); // NPE가 일어나는지 확인하기 위해 추가
System.out.println("get name");

lock.readLock().unlock();

return this.name;
}

public String setName(String name) {
lock.writeLock().lock();

this.name = null;

try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
// do nothing...
}

System.out.println("set name");

lock.writeLock().unlock();

return this.name = name;
}
}

참고
https://stackoverflow.com/questions/18354339/reentrantreadwritelock-whats-the-difference-between-readlock-and-writelock

Share