문제 상황

백준 빵집를 풀때 이상한 점이 확실히 생겼다. 일반적으로 python에서 방문 여부를 확인하기 위해 setlist중에 하나를 사용한다.
set같은 경우는 잘못된 접근 같은 행동에서 안전한 편이고 대부분의 기능에서 O(1)의 시간이 보장된다고 알고 있기 때문에 set을 이용한 풀이를 종종했다.

List에서는 미숙한 코드로 indexError를 경험할 수 있다.

이 문제는 naive 하게 set을 사용하면 시간초과를 당한다. 처음엔 논리를 잘못 구성해 recursionDepth가 커져서 시간초과가 나는건가 라고 생각했지만 논리에는 문제가 없었다. 그래서 set만을 list로 바꿔주었더니 통과했다.

분명 lookup도 똑같이 O(1)이고 set에서는 addO(1)이라고 알고 있는데 왜 차이가 나는 것일까? 이 이해 안되는 상황을 지금부터 알아보자.

메모리 사용량

먼저 간단하게 두 자료구조의 메모리 사용량을 비교해보았다.

아래 코드는 10000개의 행 , 500개의 열을 가진 2차원 board에서 사용되는 방문을 기록하는 일반적인 구현이다.

하나는 List로 하나는 set으로 구현한 뒤 sys.getsizeof(object)를 이용해서 각각의 객체의 메모리 사용량을 체크해보았다.

getsizeof(object [, default]) -> int : Return the size of object in bytes.

R=10000 # ROW
C=500    # COL

a=[[True for _ in range(R)] for _ in range(500)]
sys.getsizeof(a)
>>> 4264

b=set()
for i in range(R):
    for j in range(C):
        b.add((i,j))
sys.getsizeof(b)
>>> 134217944

놀라운 결과다. 같은 기능을 수행하는 서로 다른 자료구조인데 메모리차이가 대략 31,000배 정도의 차이를 보였다.

list4264 Byte, set134217944 Byte를 사용한다. 대략적으로 두 자료구조는 각각 4KB,134MB을 사용한다는 것이다.

List와 Set의 내부구조

왜 이렇게 차이가 나는 것일까?

listset이 내부적으로 데이터를 저장하는 방식에서 차이가 있어서 그렇다. stackoverflow의 한 답변에 따르면

List

List는 그저 원래의 객체들에 대한 참조를 모아놓은것에 불과하다. 만약 1000개의 integer를 만든다고 하면 , 그것들이 만들어지고 list는 오직 그 integer들에 대한 참조만을 담고 있다.

list는 참조들의 collection이고 일종의 주솟값을 가지고 있는것이다. 그렇기에 아래와 같은 코드를 수행해도 여전히 4264 Byte다.

c= [[[1,2,3] for _ in range(10000)] for _ in range(500)]
sys.getsizeof(c)
>>> 4264

list가 세번 중첩되어 있지만 [1,2,3]에 대한 참조만을 가지고 있기 때문에 메모리 사용량이 같은 것이다.

내부적으로는 dynamic array를 사용하고 있다.

Set

반대로 set은 어떨까? Python에서의 set은 내부적으로 key가 dummy value인 dict 를 상당 부분 재사용함으로써 hash table 의 구조를 가지고 있다.

여전히 위의 같은 답변을 참고하자면

반면에, set이나 dictionary는 1000개의 integer들의 hash value를 모두 계산해야하고 그에 따라 메모리 사용량이 증가한다.

예를 들어 set이나 dict나 가장 작은 크기의 기본값은 8이다.(즉, 오직 3개의 값만 저장한다해도 python은 8개를 지정해준다.) resize를 할때, buckets들의 개수는 요소가 50,000개가 되기 전까지는 4배씩 증가한다. 그런 다음에는 2배씩 증가한다.

Set에서의 resize

과연 정말 그렇게 작동할까? resizeset에 일반적으로 값을 추가할 때 호출되기 때문에 add를 할때 호출되는 CPython의 setset_add_entry의 구현 코드를 직접 보자.

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */ 
static int
set_add_entry(PySetObject *so, PyObject *key, Py_hash_t hash)
...
...
  found_unused:
    so->fill++;
    so->used++;
    entry->key = key;
    entry->hash = hash;
    if ((size_t)so->fill*5 < mask*3)
        return 0;
    return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

...
...

위의 코드에서 load factor가 일정 부분(아래서 다시 언급할 것이다.)을 넘어갈때를 보자.
(if ((size_t)so->fill*5 < mask*3) 의 아랫부분이다.)
so(set object)의 크기에 따라 set_table_resize인자를 다르게 주고 있음이 보인다.

** Load factor ** : Hash table 전체에서 얼마나 원소가 차 있는지를 나타내는 수치.
m개의 bucket의 Hash tablen에 n개의 원소가 저장되어 있다면 load_factor = n/m 이다

so->used가 50,000이 넘어가면 현재 used의 2배를, 그렇지 않으면 4배만큼 큰 값으로 resize를 하는 것이다.

여기서 mask는 hash_table의 크기보다 1 작다. modulo의 역할처럼 hash에 쓰이는데 AND 연산을 이용한다.

resize의 내부동작 방식

이제 set_table_resize가 내부적으로 어떻게 동작하는지를 살펴보자.

요약하자면 set_table_resizehash table의 구조를 가지는 setentry 구조체를 기존 oldtable의 2배 혹은 4배의 크기인 newtable이라는 이름으로 동적할당 받은 후 oldtable의 모든 entry들을 newtable에 넣어주는 과정을 거친다.

typedef struct {
    PyObject *key;
    Py_hash_t hash;             /* Cached hash code of the key */
} setentry;

이해가 안가도 당황하지 마세요... 아래서 차근차근 설명해줄거에요...😅

단계별로 살펴보자. 아래 코드부터다.

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */ 
static int
set_table_resize(PySetObject *so, Py_ssize_t minused)
{
    setentry *oldtable, *newtable, *entry;
    ...
    /* Find the smallest table size > minused. */
    /* XXX speed-up with intrinsics */
    size_t newsize = PySet_MINSIZE;
    while (newsize <= (size_t)minused) {
        newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
    }

일단, newtable의 크기를 정해야 한다. set_table_resize의 두번째 인자만큼
newsize를 left shift를 사용해 Pyset_MINSIZE에서 minused만큼 증가시킨다.
(PySet_MINSIZE는 위에 언급한 것처럼 8이다.)

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */
    ...
    /* Get space for a new table. */

    if (newsize == PySet_MINSIZE) {
        ...
        /* A large table is shrinking, or we can't get any smaller. */
        ...
        ...
        }
    }
    else {
        newtable = PyMem_NEW(setentry, newsize);
        if (newtable == NULL) {
            PyErr_NoMemory();
            return -1;
        }
    }

newsizePySet_MINSIZE(8)가 아닐때, PyMem_New(type,n)를 통해서 위에서 계산된 newsize의 크기만큼 setentry의 객체를 생성하는 것이다.

좀 더 엄밀하게 얘기하면 PyMem_new(type,n)PyMem_Malloc(n)를 이용해서 (n)*sizeof(type) Byte만큼의 메모리를 동적할당(malloc) 하는 macro다. (cpython/include/pymem.h 참조)

이제 거의 다 왔다. oldtable들의 값들을 newtable에 옮겨주기만 하면 된다.

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */
    ...
    so->mask = newsize - 1;
    so->table = newtable;
    ...

hashing에 필요한 mask 값을 갱신해주고 (modulo와 비슷한 역할을 한다. 다만 AND 연산자를 사용할뿐이다.)

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */
    /* Copy the data over; this is refcount-neutral for active entries;
       dummy entries aren't copied over, of course */
    newmask = (size_t)so->mask;
    if (so->fill == so->used) {
        for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
            if (entry->key != NULL) {
                set_insert_clean(newtable, newmask, entry->key, entry->hash);
            }
        }
    } else {
        so->fill = so->used;
        for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
            if (entry->key != NULL && entry->key != dummy) {
                set_insert_clean(newtable, newmask, entry->key, entry->hash);
            }
        }
    }

newmask 를 이용해서 oldtable안의 기존의 값들을 newtableset_insert_clean을 이용해 넣어준다.

** 이런 복잡한 과정들을 거쳐서 set에서 resize가 진행되는 것이다. **

그래서 얼마나 걸리는데?

b=set()
for i in range(R):
    for j in range(C):
        b.add((i,j))

위 문제에선 set이 최대 몇 번 resize를 할까? resize 전 후로 b의 메모리 크기가 달라지니 아래와 같은 코드로 확인 할 수 있다.

resize_count=0
prev=cur_size=sys.getsizeof(b)

for i in range(R):
    for j in range(C):
        b.add((i,j))
        if prev!=sys.getsizeof(b):
            prev=cur_size
            resize_count+=1

print(resize_count)
>>>13

13번 resize를 한다. 그럼 그때마다 내부 구조인 hash table의 bucket은 언제 , 그리고 얼만큼 늘어날까?

Set의 load factor

아래와 같은 코드로 확인 할 수 있다.

element_count=0
prev=cur_size=sys.getsizeof(b)

for i in range(R):
    for j in range(C):
        s=time.time()
        b.add((i,j))
        element_count+=1
        if prev!=sys.getsizeof(b):
            cur_size=sys.getsizeof(b)
            print("resize at "+str(element_count))
            prev=cur_size

>>>
resize at 5
resize at 19
resize at 77
resize at 307
resize at 1229
resize at 4915
resize at 19661
resize at 78643
resize at 157286
resize at 314573
resize at 629145
resize at 1258291
resize at 2516582
  1. 처음 bucket의 크기는 8이니 5/8만큼 찼을때 늘어난다.
  2. 19/32만큼 찼을때 늘어난다.
  3. 77/128만큼 찼을때 늘어난다.
    ...

이걸 바탕으로 우린 위에서 언급한 load factor를 계산 할 수 있는데, 미리 계산해왔다. 😅
대략 0.6에 근접하면 resize가 호출된다.

** Python에서, 최소한 CPython의 setload factor는 0.6에 근접하는것 같다.**

직접 계산 해보면 hash table의 bucket의 크기는 8->32->128->...->32768->131072->262144->...->4194304 순으로 늘어난다. ** 정말 50,000을 넘어서부터는 4배가 아니라 2배씩 늘고 있다.**

아까 위에서 언급한 resize를 할지 말지 결정하는 부분을 다시 한번 보자.

    if ( (size_t)so->fill*5 < mask*3)
        return 0;
    return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

masksize보다 1 작은 값이니 bucketsize-1로 바꿔쓸 수 있다.. 식을 보기 좋게 살짝 정리해보자.

    if ( (so->fill) < (size-1) * 0.6 )
        return 0;
    return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

해석해보자. size-1개의 bucket들 중에서

  • 60%보다 덜 차있다면, resize를 진행하지 않고 add 함수를 종료한다.
  • 60%보다 더 차있다면 resize를 진행한다.

** 최소한 CPython의 set의 load factor는 0.6에 아주 근사하다고 말할 수 있게 된 것이다.**

결론

우리는 긴 과정을 거쳐서 setadd를 할 때 사실은 resize를 할 때가 있기 때문에 Overhead를 동반한다는 것을 알게 되었다.

그렇기에 아주 naive하게 setaddO(1)이라는 건 엄밀하지 않다. add하는 과정에서 resize를 호출할때가 있고 , 그 과정에서 현재 element의 약 60% 개의 element들을 옮겨야 하니 , amortized O(1)이다.

우리가 이 상황을 왜 분석했을까? BFS 탐색 문제를 푸는데 시간초과가 나서이다.

그 원인은 add를 반복할때 resize에 있다는 것을 확실하게 알 수 있다.

그럼 어떻게?

List도 index 기반 접근을 하기 때문에 lookup에 대한 시간이 O(1)이다. 또 List를 처음 선언할때 메모리를 할당해주기 때문에, append를 쓰지 않는다면, resize가 필요하지 않다.

List도 내부 구조는 dynamic array이기 때문에 resize가 발생할 수 있다. appendamortized O(1)인 이유가 있다.

정말 특수한 상황을 제외하고는 List를 사용하는게 의문의 시간 초과 오류를 보지 않는 방법이다.

경험상 특수한 상황은 탐색에서 조건적으로 매우 크고 sparse한 범위의 방문을 할 때다.

모든 코드는 Python 3.8.10 [GCC 9.4.0] on linux 버전을 사용했습니다

참고자료

CPython Source Code
Python 메모리 관리 공식문서
Memory consumption of a list and set in Python
Load Factor and Rehashing
Time complexity for adding elements to list vs set in python

'Python' 카테고리의 다른 글

GIL 훔쳐보기  (1) 2022.09.18

문제 제목

경주로 건설

문제 설명


건설회사의 설계사인 죠르디는 고객사로부터 자동차 경주로 건설에 필요한 견적을 의뢰받았습니다.
제공된 경주로 설계 도면에 따르면 경주로 부지는 N x N 크기의 정사각형 격자 형태이며 각 격자는 1 x 1 크기입니다.
설계 도면에는 각 격자의 칸은 0 또는 1 로 채워져 있으며, 0은 칸이 비어 있음을 1은 해당 칸이 벽으로 채워져 있음을 나타냅니다.
경주로의 출발점은 (0, 0) 칸(좌측 상단)이며, 도착점은 (N-1, N-1) 칸(우측 하단)입니다. 죠르디는 출발점인 (0, 0) 칸에서 출발한 자동차가 도착점인 (N-1, N-1) 칸까지 무사히 도달할 수 있게 중간에 끊기지 않도록 경주로를 건설해야 합니다.
경주로는 상, 하, 좌, 우로 인접한 두 빈 칸을 연결하여 건설할 수 있으며, 벽이 있는 칸에는 경주로를 건설할 수 없습니다.
이때, 인접한 두 빈 칸을 상하 또는 좌우로 연결한 경주로를 직선 도로 라고 합니다.
또한 두 직선 도로가 서로 직각으로 만나는 지점을 코너 라고 부릅니다.
건설 비용을 계산해 보니 직선 도로 하나를 만들 때는 100원이 소요되며, 코너를 하나 만들 때는 500원이 추가로 듭니다.
죠르디는 견적서 작성을 위해 경주로를 건설하는 데 필요한 최소 비용을 계산해야 합니다.

예를 들어, 아래 그림은 직선 도로 6개와 코너 4개로 구성된 임의의 경주로 예시이며, 건설 비용은 6 x 100 + 4 x 500 = 2600원 입니다.

또 다른 예로, 아래 그림은 직선 도로 4개와 코너 1개로 구성된 경주로이며, 건설 비용은 4 x 100 + 1 x 500 = 900원 입니다.

도면의 상태(0은 비어 있음, 1은 벽)을 나타내는 2차원 배열 board가 매개변수로 주어질 때, 경주로를 건설하는데 필요한 최소 비용을 return 하도록 solution 함수를 완성해주세요.

제한 사항

  • board는 2차원 정사각 배열로 배열의 크기는 3 이상 25 이하입니다.
  • board 배열의 각 원소의 값은 0 또는 1 입니다.
  • 도면의 가장 왼쪽 상단 좌표는 (0, 0)이며, 가장 우측 하단 좌표는 (N-1, N-1) 입니다.
    원소의 값 0은 칸이 비어 있어 도로 연결이 가능함을 1은 칸이 벽으로 채워져 있어 도로 연결이 불가능함을 나타냅니다.
  • board는 항상 출발점에서 도착점까지 경주로를 건설할 수 있는 형태로 주어집니다.
    출발점과 도착점 칸의 원소의 값은 항상 0으로 주어집니다.

예제 입출력

입력

[[0,0,0],[0,0,0],[0,0,0]]    
[[0,0,0,0,0,0,0,1],[0,0,0,0,0,0,0,0],[0,0,0,0,0,1,0,0],[0,0,0,0,1,0,0,0],[0,0,0,1,0,0,0,1],[0,0,1,0,0,0,1,0],[0,1,0,0,0,1,0,0],[1,0,0,0,0,0,0,0]]    
[[0,0,1,0],[0,0,0,0],[0,1,0,1],[1,0,0,0]]    
[[0,0,0,0,0,0],[0,1,1,1,1,0],[0,0,1,0,0,0],[1,0,0,1,0,1],[0,1,0,0,0,1],[0,0,0,0,0,0]]    

입출력 예 #4

붉은색 경로와 같이 경주로를 건설하면 직선 도로 12개, 코너 4개로 총 3200원이 듭니다.
만약, 파란색 경로와 같이 경주로를 건설한다면 직선 도로 10개, 코너 5개로 총 3500원이 들며, 더 많은 비용이 듭니다.

출력

900
3800
2100
3200

풀이

처음 든 생각은 카카오다운 문제였다. BFS/DFS와 같은 어렵지 않은 탐색 풀이를 요구하지만 , naive하지 않고 여러가지 제약을 주고 문제가 귀엽고 문제 설명이 굉장히 친절하다는 것이다. 예시도 굉장히 많고 각각의 예시에 대해서 설명이 나와있기 때문에 코너케이스 방어에 쉽고 논리구조 쌓기가 수월하다.

그렇다고 마냥 쉬운 문제는 아니다.

사고의 과정

문제 독해를 천천히 해보면, 최단경로를 구하는 문제처럼 포장을 해놓았다. 하지만 바로 위에 언급한 입출력 #4를 보면 파란색 경로가 최단경로임을 알 수 있지만, 최단경로가 아닌 빨간색 경로가 가장 적은 비용으로 목표지점에 도달할 수 있는 경로다. naive하게 최단경로를 구하는 문제는 아닌것이다.

그렇다고 트리의 지름 같은 최장경로 문제일까? 전혀 아니다. 최소한 우리가 구하는 경로는 최단경로에 근접하지만, 그 안에서 비용을 고민해야 하는 문제다.

문제내에 명시된 비용의 제약사항은 아래와 같다.

인접한 두 빈 칸을 상하 또는 좌우로 연결한 경주로를 직선도로라고 합니다. 또한 두 직선 도로가 서로 직각으로 만나는 지점을 코너라고 부릅니다.

쉬운 말로 바꾸면, 경주로가 계속 오른쪽으로 가다가 위,아래 방향으로 바꾸면 되면 코너가 생긴다는 것이다. (왜 왼쪽이 제외되었는가는 아래에서 설명하겠다.) 다시 말해서 탐색의 방향이 전환되는 시점에서 코너가 생긴다는 것이고, 직전의 탐색의 방향이 중요하니 기록 할 필요가 있다.

이걸 본 순간 바로 떠오른 비슷한 문제가 있다. 백준의 로봇이라는 문제인데, 단순히 탐색이 아닌 이전 탐색에서의 방향이 지금의 탐색의 명령개수에 영향을 주는 문제다.

visit 배열에 이전 방향이 포함되어야하나?

직전 탐색의 방향이 중요하다는 것은, 직전 탐색에서의 상황이 단순 x,y좌표가 아니라 상,하,좌,우인 방향 역시 포함한다는 것이다. 일차원 DFS 탐색에 관한 이전 글에서 명시한 내용 중에서 BFS와 DFS든 노드의 방문여부 배열의 차원을 구성하는것은 노드를 방문할때의 상황의 개수에 따라 나뉘어 진다는것 이라는 부분이 있다.

그러면 (x,y)를 방문했는지 체크하는 visit 배열이 직전 방향까지 포함한 3차원이여야 할것같다.

visit[y][x][prev_dir] =이전 방향이 prev_dir인 상태로 (x,y)를 방문했는지

아마 탐색할때 이런 식으로 검사하면 같은 방향 접근시 중복 방문을 막을 수 있다.

if 0<=ny<n and 0<=nx<n and board[ny][nx]!=1 and not visit[ny][nx][dir]:
    visit[ny][nx][dir]=True
    q.append((ny,nx,dir))

확실하게 틀리는 반례

문제 해결!!! 이면 참 좋겠지만, 예시조차 통과하지 못한다. 그 이유는 아래와 같다.

위의 그림에서 파란색 경로와 빨간색 경로가 있다고 할때 빨간색 경로가 최소비용을 가지는 경로다. 주의 깊게 봐야할 부분은 초록색 박스를 친 부분이다.

만약 파란색 경로가 빨간색 경로보다 먼저 수행된다고 한다면 , (3,1)에서 (3,2) 으로 내려올때 visit[2][3][up]의 값은 False에서 True로 바뀌게 된다. (3,2) 를 위에서 아래로 내려오는 방문은 추후 탐색에서 방문 불가능한 상태인것이다.

그런데 빨간색 경로는 정확하게 (3,2) 로 내려오는 경로를 포함하고 있다. visit[2][3][up]을 체크하게 되는데, 이전 탐색인 파란색 경로에서 이미 True로 저장했기 때문에 방문할수 없게 되는 것이다. 최소 비용이 아닌 경로가 미리 방문했기 때문에 최소 비용을 가지는 경로가 정상적인 탐색을 진행할 수 없게 되는 것이다.

그러면 어떻게?

그러면 어떻게 해야하는 걸까 ? 모든 가능한 경로마다 방문했던 좌표를 기록해야 하나? 그러면 확실하게 답을 구할 수 있다. 파란색이 방문한 (3,2) 와 빨간색이 방문한 (3,2) 는 다르기 때문이다. 하지만 공간 복잡도가 지수만큼 증가하기에 적절하지 않다.

문제를 다시 천천히 읽어보면, 각각의 좌표들에 대해서 최소 비용경로로 접근해야하는 것이다. 위의 예시를 다시 보면, (3,2) 를 방문할때 빨간색 경로의 비용과 파란색 경로의 비용을 비교하면 빨간색 경로의 비용이 더 적다. 그 말은 이미 파란색 경로에서 (3,2) 를 방문했더라도, 빨간색 경로가 더 적은 건설비용을 계산 할 수 있으면 (3,2) 를 재방문하게 해야 한다. 조건적인 재방문을 허용할 필요가 있고 visit 배열을 재정의해야 한다.

visit 배열의 DP적 특성

visit[y][x] = (x,y)를 방문했을때의 비용, (x,y)를 방문할때 비용이 visit[y][x]보다 작거나 같으면 재방문 가능

이렇게 정의하고 다시 문제를 보자. 모든 노드들간의 간선이 양의 가중치를 갖는 그래프에서의 DP와 BFS문제로 볼 수 있다.

결국에 문제의 핵심은 (y,x)로 도착하는 최소 비용 경로가 새로 있다면, 중복 방문을 허용하는 아이디어다. 위에 살짝 언급한 아래 사례는 visit 배열의 특성을 생각하면 자명하게 풀린다.

경주로가 계속 오른쪽으로 가다가 위,아래 방향으로 바꾸면 되면 코너가 생긴다는 것이다. (왜 왼쪽이 제외되었는가는 아래에서 설명하겠다.)

오른쪽으로만 가다가 왼쪽으로 방향을 트는 것은 , 왔던 곳을 되돌아 가는 것이고, 모든 좌표끼리의 이동은 양의 가중치를 갖기 때문에 되돌아 갈때는 왔던 것보다 무조건 큰 비용을 갖기 때문에 왼쪽 방향으로 이동을 고려할 필요가 없는 것이다.

visit[y][x]의 비용보다 같을때도 중복 방문을 허용해야 하는 이유가 뭘까?
간단히 생각해서 최소비용 경로가 무조건 (3,3) 을 방문해야 한다고 해보자. (3,3) 을 직선으로 방문할때가 있고, 꺾어서 방문할때가 있다. 만약 (3,3) 을 방문하기 전까지의 둘의 비용이 같다고 할때 ,먼저 꺾어서 방문하게 되는 경우에는 추후에 추가적으로 코너 건설 비용이 500만큼 더 들기 때문에 최소 비용 거리가 아닐 수 있다. 그렇기에 비용이 같을때 방문을 막게 된다면 일직선으로 (3,3) 을 방문하는 최소비용 거리를 구할수 없다.

나머지 부분은 단순 구현이기 때문에 설명은 생략한다.

풀이코드

from collections import deque
def solution(board):
    n=len(board)
    answer = 1e9
    # 최단거리가 최소값이 아닐 수 있다. 하지만 근접하게 풀어야한다.

    dy=[-1,1,0,0]
    dx=[0,0,-1,1]

    q=deque()
    # visit 배열을 모두 INF로 채움
    visit=[[float('inf') for _ in range(n)]for _ in range(n)]

    q.append((0,0,0,-1))

    while q:
        y,x,cost,prev_dir=q.popleft()

        # 도착했다면
        if y==n-1 and x==n-1:
            answer=min(answer,cost)
            continue


        for k in range(4):
            # 이전 방향과 다르다면 코너 건설비용이 추가로 듬
            curve_cost=0 if prev_dir==k else 500

            new_cost=cost+100+curve_cost

            ny,nx=y+dy[k],x+dx[k]

            # (nx,ny) 방문시 이전 방문보다 적은 비용으로 방문하면
            if 0<=ny<n and 0<=nx<n and board[ny][nx]!=1 and new_cost<=visit[ny][nx]:
                visit[ny][nx]=new_cost
                q.append((ny,nx,new_cost,k))
    # 초기 출발시에는 방향이 없으니 무조건 코너를 돌게 하는데
    # 불필요하게 500의 초과 비용이 발생하니 빼줘야함
    return answer-500

백준 난이도로 환산하면 골드4 정도로 보인다.

아주 깔끔하고 재밌는 문제였다. 30~40분 내로 풀이하면 적절하다.

bottom up DP로 풀어도 충분히 답이 나올것같다.

본 글은 Do I need an interface with Spring boot?을 번역한 글입니다.

잘 쓰여진 글을 정리 하는 겸 한글로 공유하고 싶어서 번역했습니다.

들어가면서

Spring boot를 사용하다보면, 종종 service (@Service annotation을 붙인 bean)을 사용하게 된다. 인터넷 상의 많은 예시에서, 사람들이 service들을 위해서 interface를 사용하는 걸 볼 수 있을것이다. 예를 들어서 , 우리가 todo 어플리케이션을 만든다고 할때, TodoService라는 interfaceTodoServiceImpl이라는 구현체를 만들때가 있다.

이 포스트에서, 우리는 왜 그런 것을 하는지와 필요한가에 대해서 알아볼 것이다.

짧은 결론은

짧은 결론은 꽤나 간단하다. interface를 만들 필요가 없다.
service를 만든다고 하면, class의 자체의 이름을 TodoService라고 하고 autowire를 통해서 bean들에 주입하면 된다.
예를 들어서 이런 코드가 있다고 해보자.

@Service
public class TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;

    public TodoFacade(TodoService service) {
        this.service = service;
    }
} 

위에 있는 예시는 @Autowired를 이용한 field injection을 사용하던 생성자 주입을 사용하던간에 작동할 것이다.

그럼 왜 신경써야할까?

만약, 우리가 그게 필요하지 않다면... 왜 그런 방식(inteface를 이용한 방식)을 종종 쓰곤 할까?
음, 첫 번째 이유는 사실 좀 역사적인것이다. 하지만 그걸 살펴보기 전에 , Spring에서 annotation이 어떻게 작동하는지를 설명해야만 한다.

만약 @Cacheable같은 annotation을 사용한다고 하면, cache에서 결과를 얻을것이라고 예상할 수 있다. Spring에서 그것이 작동되는 방식은 bean들을 위한 proxy를 만들고 그 proxy들에 필요한 로직을 추가해주는것이다. 원래 스프링은 JDK dynamic proxies를 사용했다. 이 dynamic proxies는 오직 interface들만을 위해서 만들어졌고, 이것이 예전에는 interface를 작성해줘야 했던 이유다.

그러나, 10여 년 전부터 , Spring이 CGLIB proxying도 지원하기 시작했다. 이 proxy들은 별도의 interface를 필요로 하지 않는다. 심지어 Spring 3.2 버전부터는 CGLIB가 Spring에 내장되어 있어서 별도로 추가해줄 필요도 없다.

느슨한 결합

아마 두 번째 이유는 두 class 간의 느슨한 결합을 만들기 위해서 일 것이다. interface를 사용함으로써, service에 의존하는 class는 더 이상 service의 구현에 의존하지 않게 된다. 이것이 service를 독립적으로 사용할 수 있게 해준다. 예를 들어서 이런 코드가 있다.

public interface TodoService {
    List<Todo> findAllTodos();
}

@Service
public class TodoServiceImpl {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;

    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

그러나 위의 예시에서, 개인적인 의견으로 TodoFacadeTodoServiceImpl이 함께 한다고 생각한다. 여기서 interface를 추가하는건 추가적인 복잡도를 늘릴 수 있다. 개인적으로, 그만한 가치는 없어 보인다.

여러 방식의 구현

느슨한 결합이 유용한 부분은 여러 가지 구현체를 가질 때이다. 예를 들어서 TodoService가 두 가지 구현체를 가진다고 해보자. 하나는 todo 리스트를 메모리에서 가져오는 것이고, 하나는 DB와 같은 곳에서 가져오는 것이다.

public interface TodoService {
    List<Todo> findAllTodos();
}

@Service
public class InMemoryTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Service
public class DatabaseTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;

    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

이런 경우에선 느슨한 결합이 매우 유용한데, TodoFacade가 todo가 메모리에 저장되어 있는지 DB에 저장되어 있는지 알 필요 없기 때문이다. 그건 Facade의 책임이 아니라 어플리케이션 설정의 책임이다.

원하는 것에 따라서 구현방식은 달라진다. 만약에 TodoFacade가 모든 구현체를 호출해야 한다면, collection을 주입해야 한다.

@Component
public class TodoFacade {
    private List<TodoService> services;

    public TodoFacade(TodoService services) {
        this.services = services;
    }
}

만약 구현체 중에 하나가 99%의 상황에서 사용되고 나머지들은 아주 특수한 경우에만 사용된다면, @Primary를 사용해라.

@Primary
@Service
public class DatabaseTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Primary를 사용함으로써, Spring container에게 TodoService에 의존성 주입을 해야할때, 이 구현체를 사용하라고 알려주는 것이다. 만약 다른 걸 사용해야 한다면, @Qualifier를 사용하거나 특정 구현체를 주입함으로써 명시적으로 설정해야 한다. 개인적으로 난 이런 방식을 분리된 @Configuration class에서 사용하는데, 그렇지 않으면 , TodoFacade를 또 다시 구현체에 관한 정보들로 오염시키기 때문이다.

예시 코드를 보자.

@Configuration
public class TodoConfiguration {
    @Bean
    // Using @Qualifier
    public TodoFacade todoFacade(@Qualifier("inMemoryTodoService") TodoService service) {
        return new TodoFacade(service);
    }

    @Bean
    // Or by using the specific implementation
    public TodoFacade todoFacade(InMemoryTodoService service) {
        return new TodoFacade(service);
    }
}

제어의 역전

느슨한 결합의 또 다른 방식은 IoC 혹은 제어의 역전이다. 개인적으로 서로에게 의존하는 여러 가지 module을 사용할 때 제어의 역전이 유용했다. 예를 들어서 OrderServiceCustomerService가 있다고 해보자. Customer는 자신의 profile을 삭제할 수 있고 그때 pending 상태의 order들은 취소되어야 한다. interface 없이 구현했다면, 이런 방식으로 할것이다.

@Service
public class OrderService {
    public void cancelOrdersForCustomer(ID customerId) {
        // TODO: implement
    }
}

@Service
public class CustomerService {
    private OrderService orderService;

    public CustomerService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void deleteCustomer(ID customerId) {
        orderService.cancelOrdersForCustomer(customerId);
        // TODO: implement
    }
}

이렇게 한다면, 상황은 매우 나빠질 수 있다. 어플리케이션 내부의 domain들이 모두 결합되게 되고, 결과적으로 강하게 결합된 어플리케이션을 만들게 될것이다.

그러는 대신에, CustomerDeletionListener라는 interface를 만들 수 있다.

public interface CustomerDeletionListener {
    void onDeleteCustomer(ID customerId);
}

@Service
public class CustomerService {
    private List<CustomerDeletionListener> deletionListeners;

    public CustomerService(List<CustomerDeletionListener> deletionListeners) {
        this.deletionListeners = deletionListeners;
    }

    public void deleteCustomer(ID customerId) {
        deletionListeners.forEach(listener -> listener.onDeleteCustomer(customerId));
        // TODO: implement
    }
}

@Service
public class OrderService {
    public void cancelOrdersForCustomer(ID customerId) {
        // TODO: implement
    }
}

@Component
public class OrderCustomerDeletionListener implements CustomerDeletionListener {
    private OrderService orderService;

    public OrderCustomerDeletionListener(OrderService orderService) {
        this.orderService = orderService;
    }

    @Override
    public void onDeleteCustomer(ID customerId) {
        orderService.cancelOrdersForCustomer(customerId);
    }
}

예시를 보면, 제어의 역전이 일어난 것을 볼 수 있다. 첫 번째 예시에서 우리가 OrderService 안에 있는 cancelOrderForCustomer()를 바꾸면, CustomerService 역시 바뀌어야 한다. 이 말은 OrderService가 제어되고 있다는 것을 말한다.

두 번째 예시에서는 OrderService가 제어되고 있지 않다. 우리가 cancelOrderForCustomer()를 변화시키면, 다른 module의 일부인 오직 OrderCustomerDeletionListener만 바뀌어야 한다. 이것은 CustomerService가 제어하고 있음을 말한다. 또, 두 service들은 느슨하게 결합되어 있기 때문에, 하나가 다른 하나에 직접적으로 의존하고 있지 않다.

비록 두 번째 방법이 복잡도를 더 늘리긴 하지만 (classinterface가 각각 한개씩 늘었으니) domain들이 서로 결합되지 않게 해준다. 리팩토링 하기가 쉬워지는 것이다. 이 listenerevent-driven한 구조로 리팩토링 될 수 있다. domain-driven modular design이나 MSA같은 구조로 리팩토링하기 쉽게 해주는 것이다.

Test

마지막으로 말하고 싶은 건 테스트다. 몇몇 사람들은 dummy 구현체를 가지기 위해서 (여러 구현체를 가질 수 있으니) interface가 필요하다고 주장하곤 한다. 하지만 Mockito같은 mocking 라이브러리가 이 문제를 해결해 준다.

단위 테스트를 작성할 때, MockitoExtension을 사용할 수 있다.

@ExtendWith(MockitoExtension.class)
public class TodoFacadeTest {
    private TodoFacade facade;
    @Mock
    private TodoService service;

    @BeforeEach
    void setUp() {
        this.facade = new TodoFacade(service);
    }

    // TODO: implement tests
}

이 방법은 service가 무엇을 하는지 몰라도 facade를 적절히 테스트할 수 있게 해준다. Mockito.when()을 사용함으로써 service mock이 무엇을 반환하게 하는지 제어할 수 있고, Mockito.verfiy()를 사용함으로써 특정 method가 호출되었는지 확인할 수 있다.
예시 코드다.

@Test
void findAll_shouldUseServicefindAllTodos() {
    Todo todo = new Todo();
    when(service.findAllTodos()).thenReturn(todo);
    assertThat(facade.findAll()).containsOnly(todo);
    verify(service).findAllTodos();
}

심지어 Spring container를 필요로 하는 통합 테스트를 작성할때도,@MockBean annotation을 이용해서 bean들을 mock할 수 있다. 실제 구현체가 있는 package를 탐색하지 않게 해라.

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = TodoFacade.class)
public class TodoFacadeTest {
    @Autowired
    private TodoFacade facade;
    @MockBean
    private TodoService service;
}

그러니까 대부분의 경우에서, 테스트 할때 interface는 필요하지 않다.

결론

만약 개인적으로 interfaceserivce에 사용해야 하냐는 질문을 받는다면, 내 대답은 아니오다. 유일한 예외는 제어의 역전을 사용하거나 여러개의 구현체를 신경써야 하는 경우다.

만약의 경우를 위해서 interface를 만드는 게 좋지 않겠냐고 생각할 수 있다. 개인적으로 여전히 아니오다.
첫 번째로, "You aren't going to need it"(YAGNI) 라는 원칙을 믿는다. 필요할지도 몰라 라는 이유로 복잡성을 높일 이유는 없는데 , 일반적으로 필요하지 않기 때문이다.
두 번째로 필요한 경우라도 전혀 문제 없다. 대부분의 IDE들은 기존의 class에서 method만 추출해서 interface를 만들수 있게 해주고, 모든 코드들을 그 interface를 사용하게끔 순식간에 만든다.

참고하면 좋은 자료

JDK Dynamic Proxy와 CGLIB의 차이점은 무엇일까?
퍼사드 패턴
느슨한 결합 vs 긴밀한 결합
How Mockito Works?
소프트웨어 개발 3대 원칙 : KISS,YAGNI,DRY

스프링 부트에 interface가 필요한가에 대해서는 당연히 YES지만 이 글에선 serviceinterface 가 필요한지, 정확히 말하면 service의 구현체가 필요한지에 대해서 논하고 있습니다.

프로젝트를 시작하면서 Spring boot에서 구현체와 인터페이스를 구분해야하는지 고민이 많았는데 꽤나 자세하고 명쾌해서 도움이 되었습니다.

소프트웨어공학 수업에서 배운 YAGNI를 실제로 보니까 반갑네요. 그냥 무지성으로 외웠는데..

+ Recent posts