읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

문제 제기

Python을 사용한다면 반드시 성능 관련된 이슈가 나오고 그 중 자주 나오는 토픽으로 GIL을 꼽는다. 당장 알고리즘 문제 풀이에도 허용 시간이 C++/Java에 반해 길다는 점으로도 체감할 수 있다. 면접뿐만 아니라 개발에서도 GIL에 대한 이야기는 반드시 나온다는 생각에 시간을 내서 정리해보려 한다. 당장 컴파일 언어보다 느린 상황에 GIL까지 도입되었다니 그렇다면 왜 파이썬은 GIL을 채택했는가에 대해 알아보자.

GIL(Global Interpreter Lock)이란?

GIL은 파이썬 인터프리터에 한 개의 Thread가 하나의 바이트코드를 실행할 수 있도록 걸어두는 Lock이다. 하나의 Thread는 파이썬 인터프리터의 모든 자원을 사용하나 다른 Thread는 사용할 수 없도록 Lock을 건다는 의미이다. 그림으로 살펴보자.

Development_Python_GIL_001

위 그림으로 인해 멀티 쓰레드로 구현했을 경우 GIL을 바꾸는 소위 Thread Context Switch 과정에 따른 비용이 발생하여 오히려 시간이 오래 걸리는 문제가 발생하기도 한다.

Python wiki의 GIL에 관한 설명 중 현재 포스팅과 관련없는 글을 제거하여 발췌한 내용이다.

In CPython, the global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. The GIL prevents race conditions and ensures thread safety. In short, this mutex is necessary mainly because CPython's memory management is not thread-safe.

In hindsight, the GIL is not ideal, since it prevents multithreaded CPython programs from taking full advantage of multiprocessor systems in certain situations. Luckily, many potentially blocking or long-running operations, such as I/O, image processing, and NumPy number crunching, happen outside the GIL. Therefore it is only in multithreaded programs that spend a lot of time inside the GIL, interpreting CPython bytecode, that the GIL becomes a bottleneck.

요약하자면 다음과 같다.

  • GIL은 파이썬 객체로 접근함을 보호하기 위한 뮤텍스임
  • CPython의 메모리 관리 정책은 Thread-Safe하지 않기 때문에 뮤텍스가 필요함
  • 현재 멀티프로세서 환경에서는 GIL이 이상적이지 않음을 인정하나 I/O, 이미지 처리, NumPy 모듈의 number crunching 등 작업은 GIL 밖에서 이루어짐에 따라 오직 "멀티쓰레드"로 작성된 프로그램만이 바이트 코드를 인터프리팅 하느라 병목 현상이 발생해 성능이 저하될 것임

그렇다면 GIL이 도입된 배경으로는 CPython의 메모리 관리 정책이 왜 Thread-Safe하지 않은지 찾아보고 그 해결책이 GIL이어야만 했는지 알아보면 어느정도 궁금증이 해결될 듯하다.

Thread-Safe? Mutex?

자바에서도 그렇고 Thread-Safe에 대한 개념은 중요하다. 자바에서는 Synchronized 예약어로 구현되는데 사실상 mutex를 부여함과 다를 바 없다. 즉, 여러 쓰레드가 공유된 자원에 한 번에 접근하여 R/W 작업을 수행할 수 있는 상태를 Thread Safe하지 않다고 표현한다.

import threading
x = 0

def foo():
    global x
    for _ in range(1000000):
        x += 1

def bar():
    global x
    for _ in range(1000000):
        x += 1

thread1 = threading.Thread(target=foo)
thread2 = threading.Thread(target=bar)

thread1.start()
thread2.start()

thread1.join()
thread2.join()
print(x)

x라는 공유된 변수에 thread1과 thread2가 동시에 접근해서 1씩 더하는 코드이다. 결과는 제각각이겠지만 2000000이 출력되어야 함에도 필자의 환경에서는 1310848이라는 전혀 엉뚱한 값이 출력된다. 이는 공유된 변수에 두 쓰레드가 동시에 접근하면서 발생된 문제다. x += 1x = x + 1이 축약된 문장으로 볼 수 있는데 우항의 x에 값을 대입하는 과정 중 다른 쓰레드가 값을 변경하면 이후 좌변의 x에 값을 대입할 땐 중간 결과가 무시된다.(소위 씹힌다.) 간단히 그림으로 표현하면 아래와 같다.

Development_Python_GIL_002

이렇듯 여러 쓰레드가 하나의 공유 자원이 동시에 접근하면서 발생하는 문제를 race condition(경쟁 상태)이라 하며 이 문제를 해결하기 위해 mutex 등을 도입한다.
Mutex는 이렇게 공유 자원에 하나의 쓰레드만 진입하며 작업을 처리할 수 있도록 만들어진 lock 개념이다. 간단히 비유하자면 한 사람만 이용할 수 있는 공중화장실은 출입문의 잠금장치를 열어야 이용이 가능하며 사람들은 줄 따위 서지 않고 호시탐탐 출입문을 노리고 있는 상황을 가정하자. 이용하고자 대기 중인 여러 사람이 Thread이고 화장실 시설이 resource, 화장실 출입문이 mutex, 화장실 출입문의 잠금장치가 lock이다.

CPython의 메모리 관리

Python wiki GIL 설명에서 CPython의 메모리 관리 전략은 Thread Safe하지 않다고 한다. CPython은 C언어로 구현된 파이썬 코어를 의미한다. C 언어는 Thread Safe를 위해 별도의 작업을 수행하지 않으며 race condition 대응은 사용자의 몫으로 남긴다. 그렇다면 C로 구현된 CPython 또한 동작 환경에선 race condition 대응을 위해 별도의 작업을 수행해야 함을 의미한다.

Reference counting

CPython은 C언어의 메모리 할당 관련 함수인 malloc()과 free()를 내부적으로 많이 사용할 것이다. 그러므로 자칫 메모리 누수의 위험이 존재한다. 이러한 이슈에 대응하기 위한 메모리 관리 전략으로 reference counting을 사용하고 있다. 간단히 설명하면 Python의 모든 객체에 카운트를 첨부하여 객체가 참조될 때 증가, 참조가 삭제되면 감소시키는 방식으로 동작한다. 객체에 대한 카운트가 0이 되면 메모리에서 할당이 삭제된다.(Python의 GC)

from sys import getrefcount

class foo():
    def __init__(self):
        print("Test Class")

a = foo()
print(f'a reference count {getrefcount(a)}')
b = a
print(f'a reference count {getrefcount(a)}')
c = a
print(f'a reference count {getrefcount(a)}')
b = 10
print(f'a reference count {getrefcount(a)}')
d = a
print(f'a reference count {getrefcount(a)}')
c = 0
print(f'a reference count {getrefcount(a)}')

'''
result :
a reference count 2
a reference count 3
a reference count 4
a reference count 3
a reference count 4
a reference count 3
'''

foo() 클래스가 a에 할당되면서 참조값이 1 증가하고 getrefcount() 함수에 a 객체를 넣으면서 호출 시 1이 증가된 상태로 출력한다. 다른 변수에 a 객체를 할당하면 참조값이 증가하고 a 객체를 할당했던 변수에 다른 값을 넣어 참조를 끊으면 카운트가 감소함을 볼 수 있다. 이 값이 0이 되면 CPython이 알아서 메모리를 회수한다. 내부 코어 구조체와 코드를 찾아보자.

// Python 3.8 기준 내부 폴더 - Include/object.h 파일
typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt; // 참조값
    struct _typeobject *ob_type;
} PyObject;

객체를 나타내는 구조체에 ob_refcnt로 카운트 값을 갖게끔 하는 것을 볼 수 있다.

// Python 3.8 기준 내부 폴더 - Include/object.h 파일
static inline void _Py_DECREF(const char *filename, int lineno,
                              PyObject *op)
{
    (void)filename;
    (void)lineno;
    _Py_DEC_REFTOTAL;
    if (--op->ob_refcnt != 0) {
#ifdef Py_REF_DEBUG
        if (op->ob_refcnt < 0) {
            _Py_NegativeRefcount(filename, lineno, op);
        }
#endif
    }
    else {
        _Py_Dealloc(op);
    }
}

_Py_DECREF 함수에선 참조값이 0이면 객체를 메모리에서 해제함을 볼 수 있다.

Thread Safe를 위한 결정

앞서 C 언어에서는 race condition 해결을 사용자의 몫으로 남겨놨고 CPython은 메모리 관리 전략으로 Reference Counting을 도입하고 있음을 확인하였다. 그렇다면 Python은 Reference Counting을 Thread Safe하게 만들도록 별도의 작업을 수행해야 한다. 만약 Thread Safe하지 않다면 참조값을 증가시키는 행위를 수행이 참조값을 감소시키는 행위에 의해 무시되었을 때 엄연히 살아있는 객체를 죽여버리거나 그 반대의 경우 메모리 누수가 발생하여 파멸적인 결과를 낳을 수 있기 때문이다.
CPython의 메모리 관리를 Thread Safe하게 만들기 위해 뮤텍스를 도입할 수 있으며 객체 각각에 대한 뮤텍스를 생성하여 관리하면 되겠다. 그러나 객체 각각에 대해 하나씩 대응되면 뮤텍스로 보호하면 성능 상의 이슈뿐만 아니라 데드락이라는 치명적인 상황을 초래할 수 있다.
결국 Python은 뮤텍스로 모든 reference에 대해 보호하지 않고 파이썬 인터프리터 자체를 하나의 쓰레드만 사용할 수 있도록 global하게 잠궈버리면 해결이 가능함을 이유로 쓰레드가 Python bytecode를 실행하기 위해서는 파이썬 인터프리터를 잠군 Interpreter Lock을 획득하여 작업을 해야하는 정책을 채택하였다. 이를 Global Interpreter Lock이라 부른다.

GIL 선정 이유

Python은 1991년에 도입되었는데 Multi Thread의 개념은 상당히 오래되었으나 당장 인텔 펜티엄 4가 2002년에 SMT(Simultaneous MultiThreading, SMT)를 처음으로 지원했다는 점을 고려하면 이 시기에는 Multi Thread에 대해 고민이 주류가 아니었을 것이다. 5년 후에 배포된 Java는 Multi Thread를 고려하였다는 점으로 볼 때 아키텍처가 급격하게 발전한 탓도 있으리라 생각한다. Python 도입 이후 많은 C extension들이 이미 만들어졌는데, Multi Thread 개념이 정착되고 thread로 인한 문제를 해결하기 위해서 Python 진영에서 만들어낸 C extension들을 새로운 메모리 관리 방법에 맞춰서 모두 바꾸는 것은 불가능했다. 대신 Python이 GIL을 도입하면 C extension들을 바꾸지 않아도 현실적으로 해결이 가능했기에 도입된 것이다.

Python은 싱글 쓰레드로만 동작해야 하나?

이 부분에 대해서는 개발자들의 역량에 따라 달라진다. Python은 threading 모듈, multiprocessing 모듈 등 몇 가지 옵션을 제공한다. 하나의 포스팅으로 정리하기에는 양이 좀 많을 것으로 예상되기에 이번 포스팅에서는 자세히 다루지는 않겠지만 간단히 적어본다.

I/O bound

Python의 GIL은 파이썬 인터프리터를 Global하게 잠궈서 bytecode를 하나의 쓰레드만 처리할 수 있도록 하는 정책이다. 그러면 bytecode처리는 하드웨어 관점에서 CPU가 담당하게 되고 CPU-bound 작업이 아니라 I/O 바운드 작업이라면 요청을 걸어두고 대기하면 되기 때문에 멀티 쓰레드로 구현했을 때 성능을 개선시킬 수 있다.

Multi Processing

병렬 처리를 위해 쓰레드가 아니라 멀티 프로세싱 모듈을 사용할 수도 있다. 하나의 인터프리터가 아니라 여러 프로세스를 구동시켜서 작업을 처리함을 의미한다. 즉, 적절히 작업을 분할하여 각 프로세스에 배분해야 하며 쓰레드 간 정보 전달보다 프로세스간 정보 전달(IPC)의 코스트가 높기 때문에 오히려 기본적으로 제공되는 단일 쓰레드에서의 성능이 더 좋을 수 있다. 따라서, 적절히 상황을 보면서 도입해야 한다.

결론

결론적으로 GIL은 멀티 쓰레드에 대한 개념이 범용적으로 쓰이지 않던 시절에 개발된 언어의 한계를 해결하기 위해 도입된 개념으로 "안전"을 얻고 "성능"을 잃는 trade-off 정책으로 볼 수 있다. 따라서, 하드웨어를 직접 컨트롤하거나 압도적 퍼포먼스를 보여야 하는 프로그램에 Python을 도입하려면 어느정도 각오를 가지고 프로그램을 설계해야 함을 의미한다. CPU 자원 처리 권한을 lock하는 개념이기 때문에 I/O 기기에 요청만 하고 결과를 받아보는 I/O bound 작업은 별도로 분리하여 멀티 쓰레드로 처리하면 성능 개선을 도모할 수 있으나 거대한 프로젝트에서 작업을 분리하여 처리하는 중 발생할 수 있는 side-effect를 고려할 실력이 아니라면 얌전히 싱글 쓰레드 환경에서 로직을 다듬는 게 좋아보이긴 한다.

'Development > Python' 카테고리의 다른 글

Python 함수 코드가 일반 코드보다 빠른 이유  (1) 2021.08.01

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

문제 제기

자바 가비지 컬렉션은 자바 언어를 공부하면서 꼭 나오는 개념이다. 기본 개념이라 칭하기에는 난이도가 있다. 그러나 GC가 어떻게 동작하는지 이해를 해야 메모리 이슈 등의 문제에 대응할 수 있으니 짚고 넘어갈 필요가 있다.

가비지 컬렉션(Garbage Collection)이란?

C/C++에선 개발자가 객체에 메모리 할당 & 해제를 하였으나 자바에서는 그럴 필요가 없어 포인터의 개념이 존재하지 않는다. 더 이상 사용되지 않는 객체를 자바(JVM)가 스스로 메모리에서 삭제하는 작업을 Garbage Collection(이하 GC)라 부른다. 메모리 중에서도 GC는 "힙(Heap)" 영역에 관여한다.

GC 개요

  • stop-the-world

    GC는 할당되지 않은 객체를 메모리에서 삭제하는 과정을 의미한다. 따라서, 프로그램 실행 중에 GC를 수행한다면 다른 thread의 동작 중 메모리에 변경이 발생하여 안전하게 GC를 수행할 수 없다. 따라서 GC가 수행되기 위해서는 메모리에 어떤 변경이 없음을 보장해야 하므로 JVM이 애플리케이션 실행을 중지시킨다. stop-the-world는 GC를 실행하는 thread를 제외한 모든 thread의 작업이 중지되어 마치 프로그램이 정지된 것처럼 보이는 상태를 의미한다. 모든 GC 알고리즘은 stop-the-world를 동반하며 GC 알고리즘의 성능은 stop-the-world 시간을 줄이는 데 초점이 맞춰져 있다.

  • GC 대상 객체 선정

    GC는 두 가지 전제 조건에 따라 동작한다.

    • 대부분의 객체는 금방 접근 불가능 상태(Unreachable)가 된다.
    • 오래된 객체에서 젊은 객체로의 참조는 드물게 발생한다.

    이 두 가지 전제로 인해 HotSpot VM에서는 힙을 Young 영역과 Old 영역으로 나누었다. 이 영역들을 통칭하여 흔히 자바의 힙 구조라고 부른다. 대부분의 객체는 금방 접근 불가능한 상태로 전환되기에 Young 영역을 두고 오래된 객체들은 젊은 객체보다 오래된 객체들을 참조할 가능성이 높기에 Old 영역으로 몰아넣는다.

    어떤 객체가 Unreachable한 객체인지 판단은 Java Reference에 대한 이해가 필요한데 추후에 정리하려 한다. 우선 GC Root로부터 힙에 존재하는 개체로 참조가 생성되므로 GC Root에 대해 알고 있어야 한다. GC Root가 될 수 있는 항목을 대강 나열하면 아래와 같다.

    1. JVM 메모리의 Stack 영역에 존재하는 참조 변수
    2. Method Area의 static 변수에 의한 참조
    3. JNI에 의해 생성된 객체들에 대한 참조

Development_Java_Garbage_Collection_001

Java Heap의 구조

Development_Java_Garbage_Collection_002

Heap 영역은 Java 7까지는 Special heap 영역으로 Permanent Generation이 존재했으나 Java 8 이후 Metaspace로 대체되면서 2개 영역으로 크게 구분된다.

Young Generation

  • Eden

    • 새로 생성한 대부분의 객체가 저장되는 위치
    • Eden 영역에서 GC가 발생한 뒤 살아남은 객체는 Survivor 영역으로 이동
  • 두 개의 Survivor 영역

    • Eden에서 GC가 발생한 후 생존한 객체들을 담는다.
    • 둘 중 하나는 반드시 비어있어야 한다.
    • Eden과 Old 영역 간의 완충 역할로 Tenuring Threshold값이 초과된 객체는 Old 영역으로 보낸다.

Tenured(=Old) Generation

  • Old
    • Minor GC를 진행하면서 Tenuring Threshold값이 초과된 객체를 보관한다.
    • Old 영역이 가득 차면 Minor GC를 수행할 수 없기 때문에 Major GC가 발생한다.
    • Java 8 부터는 Permanent Gen이 없어지고 Full GC의 의미가 없어져 Major GC = Full GC로 간주하기도 한다.
    • 엄밀히 말하면 Major GC는 Tenured Generation을 청소, Full GC는 Heap 영역 전체를 청소한다고 봐야 한다. 다만 Major GC가 Minor GC에 실패하여 수행되므로 Major GC를 수행한 뒤에는 Minor GC를 수행할 것이다. 따라서 Major GC의 수행은 Full GC와 다를 바 없어 JVM에서도 Major GC를 Full GC 취급한다.

Permanent Generation

Java 7까지 존재했던 영역으로 Method Area의 데이터를 보관했다. 클래스의 메타 데이터, intern된 스트링 Constant Pool, Numeric Constant Pool 등을 보관했었다. Heap 전체 영역을 청소하는 Full GC 발생 시 해당 영역도 GC의 대상이 된다.

Serial GC

가장 기초적인 GC 알고리즘이다. 싱글 thread로 동작하며 live object를 mark한 뒤 특정 영역에 모으고 나머지 객체를 버리는 과정이라 이해할 수 있다. 적은 메모리와 CPU 코어 개수가 적을 때 적합하므로 서버 등 고성능을 요구하는 환경에서는 사용을 지양해야 한다.

Development_Java_Garbage_Collection_003

Minor GC

  • mark and copy : live object를 mark하고 다른 영역으로 copy한 후 기존 영역에 존재하던 object는 GC한다.
  1. 새롭게 생성된 객체는 Eden 영역에 저장된다.

  2. Eden 영역이 Full 상태가 되면 live object를 확인한다. (mark)

  3. 아직 live object라고 마킹된 객체는 Survivor(From) 영역으로 옮긴다.

  4. live obejct가 옮겨진 뒤 Eden 영역에는 Garbage 밖에 남지 않아 Minor GC를 수행한다. 따라서, Eden 영역은 반드시 Empty 상태여야 하며 Survivor(To) 영역 중 하나로 옮겨진 객체들의 Tenuring Threshold 값은 1 증가한다.

Development_Java_Garbage_Collection_004

  1. 다시 객체들이 생성되면서 Eden에 쌓이고 가득 차서 GC가 발생하면 Survivor(From)에 계속 저장한다. 결국 Survivor(From) 영역이 가득 차서 더 이상 GC를 수행할 수 없게 되면 Eden과 Survivor(From) 영역의 live object 리스트를 확인한다. (mark)
  2. 살아있는 객체 확인 작업이 완료되면 Survivor(To) 영역으로 살아있는 객체를 옮긴다.
  3. 옮기는 작업이 끝나면 Eden과 Survivor(From) 영역에는 Garbage만 존재하므로 Minor GC를 수행한다. 옮겨진 객체들의 Tenuring Threshold 값은 1 증가한다. 그리고 From과 To를 전환한다.

Development_Java_Garbage_Collection_005

  1. 또 객체들이 생성되면서 Eden이 가득 차면 Survivor(From) 영역으로 옮기는 GC를 수행하며 결국 Survivor(From) 영역도 가득 차게 된다. GC를 수행하기 위해 live object 탐색을 하던 중 Tenuring Threshold 기준을 충족한 객체가 발견되었으므로 해당 객체를 Old 영역으로 Promotion 시킨다. 물론 Threshold 값을 1 더해준다.
  2. 나머지 live object는 비어있는 Survivor(To) 영역으로 옮긴다. 옮기면서 Threshold 값을 1 더한다.
  3. 옮기는 작업이 끝나면 Eden과 Survivor(From) 영역에는 Garbage만 존재하므로 Minor GC를 수행한 뒤 From과 To를 전환한다.

Development_Java_Garbage_Collection_006

Minor GC가 발생된 이후에는 Eden 영역과 Survivor 영역 중 하나는 완전히 비어있는 상태여야 한다. 그렇지 않으면 뭔가 잘못된 상태임을 의미한다.

참고로 Object Header에 Age를 기록하는 부분의 크기가 6bit이기 때문에 Age의 임계값은 31이다.

Major GC

  • Mark-Sweep-Compact
    • Mark : Old 영역에 살아있는 객체를 식별
    • Sweep : 힙을 순차적으로 확인하여 마킹된 객체만 남김
    • Compaction : 살아남은 객체들이 연속되게 쌓이도록 힙의 앞부분부터 채워넣음

Development_Java_Garbage_Collection_007

Parallel GC

Serial GC와 동작 알고리즘은 동일하나 Serial GC는 GC Thread가 하나인 것에 반해 Parallel GC는 GC Thread가 여러 개이므로 Serial GC보다 빠르게 GC를 처리할 수 있다. 메모리가 충분하고 코어의 개수가 많은 환경에서 유리하다.

Development_Java_Garbage_Collection_008

Parallel Old GC

JDK 5버전에서는 Minor GC와 Major GC가 구분되어 있었으나 JDK 7부터는 기본 Parallel GC 옵션 실행에도 Parallel Old GC도 같이 수행되었다. Yound과 Old 영역이 병렬로 처리된다. 다만, Serial GC와는 달리 Sweep이 아니라 Summary 단계를 수행한다.

  • Mark-Summary-Compact
    • Mark : Old 영역에 살아있는 객체를 식별
    • Summary : 이전에 GC를 수행하여 컴팩션된 영역에 살아있는 객체 식별
    • Compact : 살아남은 객체들이 연속되게 쌓이도록 힙의 앞부분부터 채워넣음

CMS(Concurrent Mark Sweep) GC

Major GC

CMS GC는 Old 영역을 청소하는 Major GC에 대한 알고리즘이다. Young 영역에 대한 GC는 Parallel GC로 수행된다. CMS GC를 구성하는 몇 가지 단계가 있다.

Development_Java_Garbage_Collection_009

  1. Initial Mark : 클래스 로더에서 가장 가까운 객체 중 살아 있는 객체 탐색 후 종료
    • 싱글 Thread로 동작하며 STW Pause가 매우 짧음
  2. Concurrent Mark : Initial Mark 단계에서 살아있다고 보증된 객체가 참조하는 객체들을 따라가면서 확인
    • 다른 Thread가 실행 중인 상태에서 동시에 진행됨
  3. Remark : Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인
    • STW Pause 발생하며 Initial Mark 시 발생한 STW Pause보다는 훨씬 길다.
  4. Concurrent Sweep : Remark
    • 다른 Thread가 실행 중인 상태에서 동시에 진행됨

sweep 과정을 다른 Thread와 동시에 진행하기 위해서 Mark에 시간을 투자한다. 따라서, STW Pause 시간이 매우 짧다는 장점이 있다. 모든 애플리케이션의 응답 속도가 매우 중요할 때 사용하는 알고리즘으로 Low Latency GC라고도 부른다.

다만 STW Pause가 짧은 대신 대표적으로 2개의 Trade off가 존재한다.

  1. 다른 GC 알고리즘보다 많은 메모리와 CPU를 요구
  2. Compaction 단계가 존재하지 않음

즉, 고성능 환경이 보장되지 않으면 오히려 속도가 느릴 가능성이 있고 메모리의 파편화가 많아 Compaction 작업 실행 시 다른 GC 알고리즘보다 STW Pause 시간이 길다. 따라서 Compaction 작업을 고려하여 계획을 수립해야 한다.

G1(Garbage First) GC

메모리가 크고 CPU 코어가 많은 환경을 위해 설계되었으며 JDK 9 버전부터는 기본 GC 알고리즘으로 채택되었다. 여타 다른 GC 알고리즘은 Young, Old를 분명하게 구분하여 생각했던 것과는 달리 heap 메모리 영역을 작은 단위의 region으로 분할하여 관리한다.

Development_Java_Garbage_Collection_010

region은 기본적으로 전체 힙 공간을 2048 개로 분할하여 배정된다.

  • Young 영역
    • 멀티 Thread로 GC가 동작하며 살아남은 객체는 Survivor region으로 이동한다.
    • evacuation, compacting 수행
    • 사전에 정의된 aging threshold 값 초과 시 Survivor region의 오래된 객체는 Old region으로 이동한다.
    • Minor GC을 수행할 때마다 Eden, Survivor 영역의 크기는 자동으로 계산되어 정해진다.
  • Old 영역
    • 멀티 Thread로 GC가 동작하며 Major GC가 전체 힙 공간을 대상으로 동작하던 다른 알고리즘과 달리 일부 region에 대해서만 GC를 수행한다.
    • GC를 수행할 region 선택은 region 내 객체를 대상으로 liveness(살아있는 객체 / 사용하는 객체)값을 기준으로 판단한다.
      • liveness가 높은 객체는 재사용될 가능성이 높다고 판단, liveness가 적은 region을 GC한다는 점에서 Garbage First라는 이름이 붙여졌다.

G1 GC의 동작 과정까지 숙지하기엔 아직 과한 감이 있어서 추후에 필자의 지식 수준이 갖춰지면 다루려 한다.

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

문제 제기

자바 상속에는 치명적인 문제가 존재한다고 흔히들 말하며 상위 클래스를 구현하여 자식 클래스에 extend하기 보다 interface 클래스를 구현하여 implement하는 습관을 들이라고 설명한다. 그렇다면 왜 그래야 하는지 정리해보려 한다.

상속의 문제점

개방 폐쇄 원칙(캡슐화) 약화

상속받은 자식 클래스에서 부모 클래스의 public, protected 메소드에 접근이 가능하기 때문에 부모 클래스의 구현사항에 의존하게 된다. 허나 자식 클래스 규칙에 부합하지 않는 부모 클래스 메소드가 존재하고 그 메소드가 의도치 않게 자식 클래스의 코드에서 동작한다면 자식 클래스의 규칙을 위반한 결과가 반환될 수 있기에 문제가 발생한다. 완벽한 캡슐화를 원한다면 상속을 포기하거나 적절한 private 사용으로 인터페이스 은닉과 함께 getter, setter 코드 구현 등 정확하게 상속을 구현해내야 한다.

설계가 유연하지 않음 - 클래스간 결합 문제

상속은 자식 클래스에 기능을 점진적으로 추가하며 확장하는 목적으로는 편리하다. 그러나 자식 클래스가 부모 클래스를 상속하면 상호 간의 의존성(결합도, coupling)이 생긴다. 객체지향 프로그래밍에서는 결합도를 낮추고 클래스에 관련된 메소드를 몰아넣어서 해당 클래스가 갖는 책임에 집중(응집도, cohesion)하고 독립성을 높이라고 설명한다. 따라서, 부모-자식 간의 결합도가 높다면 부모 클래스의 변경으로 인해 자식 클래스가 취약해질 수 있다. 이를 취약한 기반 클래스 문제라고 부르며 상위 부모 클래스 수정 시 모든 자식 클래스를 검증, 수정, 테스트하는 비용이 발생할 수 있다.

리스코프 치환 원칙의 위배

결국 잘못된 상속으로 인한 캡슐화 약화, 클래스 간 결합 두 개의 문제의 결과물로 리스코프 치환 원칙 위배가 발생한다.

리스코프 치환 원칙이란 상위 타입을 사용하는 메소드에 하위 타입의 객체를 매개변수로 전달할 경우에도 정상 동작 해야함을 의미한다. 이를 테면 부모 클래스의 메소드 동작으로 어떤 인과관계가 정해지는 메소드에 자식 클래스의 성질에 기반하여 overriding한 setter 동작이 그 인과관계를 해치는 경우가 발생하는데 이 경우는 자식 클래스가 아니라 별도의 타입으로 정의해야 한다.

리스코프 치환 원칙 위배 예제

수학적 개념으로는 정사각형은 직사각형의 일부에 속하기 때문에 코드 상으로도 의식 없이 extend했다고 가정하자. 직사각형 클래스는 높이, 너비를 따로 입력받아 setter 메소드를 정의하였고 정사각형 클래스는 너비건 높이건 모두 같으므로 두 메소드를 오버라이딩하여 너비, 높이를 동일하게 설정하게끔 정의하였다.

public class Rectangle {
    private int width;
    private int height;

    public void setWidth(final int width) {
        this.width = width;
    }

    public void setHeight(final int height) {
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
}
public class Square extends Rectangle {
    @Override
    public void setWidth(final int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(final int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

반드시 한쪽 변을 다른 변보다 길게 만드는 메소드를 정의하고 매개변수로 직사각형 타입을 받게 하자.

public class Test {
    public static void main(String[] args) {
        Test test = new Test();
        Rectangle foo = new Rectangle(5, 3);
        Square bar = new Square(4);
        System.out.println("expected: " + foo.getWidth() + ", " + (foo.getWidth() + 1));
        test.increaseHeight(foo);
        System.out.println(foo.getWidth() + ", " + foo.getHeight());
        System.out.println("expected: " + bar.getWidth() + ", " + (bar.getWidth() + 1));
        test.increaseHeight(bar);
        System.out.println(bar.getWidth() + ", " + bar.getHeight());
    }

    public void increaseHeight(final Rectangle rectangle) {
        if (rectangle.getHeight() <= rectangle.getWidth()) {
            rectangle.setHeight(rectangle.getWidth() + 1);
        }
    }
}

직사각형의 하위 클래스인 정사각형 타입을 매개변수로 넣으면 한쪽 변은 절대로 다른 변보다 길어질 수 없다. 그러므로 메소드를 호출한 뒤 반드시 사각형의 두 변 사이 차이가 존재한다는 사실이 성립하지 않는다.

expected: 5, 6
5, 6
expected: 4, 5
5, 5

따라서, 정의한 메소드가 올바른 결과를 갖기 위해서는 메소드에서 매개변수 타입을 체크하는 instanceof 연산자를 이용한 분기문을 정의해야 한다. 설사, 타입 체크하는 분기문을 선언하여 메소드의 정상동작을 보장하여도 타입 체킹하는 행위 자체가 이미 원칙 위반에 해당하여 정의하고자 하는 메소드는 직사각형 타입 확장에 열려있지 않음을 의미한다. 따라서 정사각형 타입이 직사각형 타입 하위에 속함이 적합해 보이지만 엄밀하게 정사각형은 직사각형이 갖는 모든 성질을 만족할 수 없기 때문에 직사각형 타입을 매개변수로 받아 한쪽 변 길이를 다른 쪽 변보다 길게 만드는 메소드를 정의하고자 한다면 직사각형 타입과 정사각형 타입은 별개의 타입으로 선언해야 한다.

잘못된 상속 예제 케이스

상속은 코드 재사용과 다형성을 목적으로만 사용해선 안된다. 애매한 설계로 인해 문제가 발생한 케이스 2개를 보통 예제 케이스로 소개한다.

Stack 클래스에서의 상위 클래스(Vector) 메소드 호출

Stack 클래스의 경우 Vector 클래스를 상속받는다. 따라서 Vector 클래스의 public/protected 인터페이스는 모두 접근이 가능함을 의미한다. Vector 클래스의 add 메소드는 public으로 정의되어 있어 stack 클래스 안에서 사용할 수 있다. 그런데 add 메소드를 Stack 클래스에서 호출하면 Stack 클래스 규칙에 맞지 않는 결과가 반환될 수 있다.

Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.add(0, "4th");

assertEquals("4th", stack.pop()); //에러

위 코드의 경우 Stack 클래스의 원소를 더하는 연산은 항상 FILO를 준수해야 한다. 허나 add 메소드로 넣은 원소는 가장 맨 앞에 위치하므로 pop연산 시 LIFO 원칙에 위배된다. 또한 Vector 클래스의 get 메소드도 Stack에서 사용할 수 있어 원소를 확인하고자 하면 pop연산으로 값을 확인해야 함에도 get 메소드를 사용하여 조회할 수 있어 역시 Stack 규칙에 어긋난다.

Properties Class에서의 상위 클래스(Hashtable) 메소드 호출

Hashtable 클래스로부터 상속되었기에 Properties 클래스에서 put 메소드를 호출할 수 있었다. 다만, Properties 클래스를 위해 새롭게 getProperty, setProperty를 정의한 것이 화근이었다. HashTable의 메소드는 삽입할 시 키 타입의 제약이 없어 키를 String 객체가 아닌 다른 타입의 객체로 넣을 수 있다. 다만, 이렇게 규칙을 준수하지 않은 채로 삽입하면 Properties는 객체에 String 키 값을 제외한 다른 타입의 키는 존재하지 않는다고 전제하기 때문에 propertyNames() 메소드 등에서 Exception이 발생한다.
해당 클래스를 설계한 이펙티브 자바의 저자 조슈아 블로치는 본인의 책에서 문제점을 인식하고 고치려 시도한 시점에는 너무나 많은 사용자들이 문자열 이외의 타입을 키로 삽입한 상태였기에 수정할 수 없었다고 설명한다.

리스코프 치환 원칙 위배 해결

상속을 하려면 자식 클래스가 부모 클래스에 대해 "is a kind of(소위 IS-A)" 관계를 만족해야 한다. 즉, 하위 클래스가 상위 클래스의 모든 역할을 수행하며 분류에 정확히 속하는지 확인하고 수행해야 한다. 단순히 다형성과 코드 재사용만 생각해서 사용하면 조금씩 기능이 어긋나면서 낭패를 볼 수 있다. 만약 애매하게 기능이 포함된다면 Has-A 관계가 성립하므로 컴포지션 패턴을 사용해 새로운 클래스로 정의해야 한다.

Development_Java_inheritance_problem_composite_pattern_001.png

Composite pattern

Development_Java_inheritance_problem_composite_pattern_002.png

composition(조합 or 합성)이란? 기존 클래스를 확장하지 않고 새로운 클래스를 생성하여 private 필드로 기존 클래스의 인스턴스를 참조하도록 설계하는 방법이다. 클래스 다이어그램을 보면 component가 leaf와 composite는 directory를 implement하지만 composite는 directory에서 파생된 복수 개의 객체를 다룰 수 있어 마치 재귀적인 구조를 이루는 형태가 된다.

  1. Component : Base Component라고도 하며 interface 혹은 추상 클래스로 정의한다. 이 인터페이스를 사용해 leaf와 composite를 정의한다.
  2. leaf : 하위 객체를 정의하는 클래스로 Component 인터페이스에 정의된 메소드를 제외한 다른 메소드는 정의할 수 없다.
  3. composite : base component 클래스 메소드와 leaf 클래스 객체에 대해 동작하는 메소드들을 정의한다. leaf 클래스 객체들에 대한 헬퍼 클래스로 볼 수 있다.
  4. client : base coponent 객체(leaf 클래스 객체들)을 사용하는 composition 객체를 다룬다. 사실상 모든 클래스에 접근한다.

composite pattern 적용 예제

  • Base Component
public interface Worker {
    void printDepartment();

    void printWorkerName();

    void printWorkerId();
}
  • Leaf
public class Engineer implements Worker {
    private Integer id;
    private String name;

    public Engineer(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public void printWorkerId() {
        System.out.println(id);
    }

    public void printWorkerName() {
        System.out.println(name);
    }

    public void printDepartment() {
        System.out.println(getClass().getSimpleName());
    }
}
public class Designer implements Worker {
    private Integer id;
    private String name;

    public Designer(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public void printWorkerId() {
        System.out.println(id);
    }

    public void printWorkerName() {
        System.out.println(name);
    }

    public void printDepartment() {
        System.out.println(getClass().getSimpleName());
    }

}
  • Composite
import java.util.ArrayList;

public class WorkerList implements Worker {
    private ArrayList<Worker> workerArrayList = new ArrayList<>();

    public void printDepartment() {
        for (Worker worker : workerArrayList) {
            worker.printDepartment();
        }
    }

    public void printWorkerName() {
        for (Worker worker : workerArrayList) {
            worker.printWorkerName();
        }
    }

    public void printWorkerId() {
        for (Worker worker : workerArrayList) {
            worker.printWorkerId();
        }
    }

    public void addWoker(Worker worker) {
        workerArrayList.add(worker);
    }

    public void removeWorker(Worker worker) {
        workerArrayList.remove(worker);
    }
}
  • Client
public class Test {
    public static void main(String[] args) {
        Engineer kim = new Engineer(1, "kim");
        Engineer kang = new Engineer(2, "kang");
        Designer choi = new Designer(3, "choi");
        Designer kwon = new Designer(4, "kwon");
        WorkerList workerList = new WorkerList();
        workerList.addWoker(kim);
        workerList.addWoker(kang);
        workerList.addWoker(choi);
        workerList.addWoker(kwon);
        workerList.printDepartment();
        workerList.printWorkerName();
    }
}
Engineer
Engineer
Designer
Designer
kim     
kang    
choi    
kwon

Properties 클래스 개선

Properties 클래스는 키와 값이 모두 String으로 이루어져야 한다. 그리고 기본 속성은 Map 타입에 속하기 때문에 Map클래스를 Component 클래스로 삼고 Leaf 클래스로서 Map<String, String>을 implements하여 구현해야 한다.

import java.util.Collection;
import java.util.Map;
import java.util.Set;

public class Properties implements Map {
    /*
    Map에 정의된 메소드들 overriding
    Leaf 클래스이므로 이외의 메소드를 정의하면 안됨
    */
}

Composite 클래스는 Properties만의 특징에 기반한 새로운 메소드를 정의하는 Helper 클래스로 정의를 해야하는데 Composite Pattern의 정의대로라면 composite 클래스는 component 클래스를 인자로 받는 클래스로 뭔가 맞지 않다. 실력이 좋았다면 제대로 개선해볼 수 있겠으나 지금 실력 상황에서 이런 방식으로는 의도대로 구현 방법이 떠오르지 않는다.

그렇다면 Properties 클래스는 Map 타입을 implement하여 구현된 Hashtable 클래스와 대부분의 특징을 공유하지만 스트링 키를 보장해야 한다는 점에 기인하여 메소드 동작만 조금 다르다는 점을 주목해보자. 캡슐화를 강화한다는 측면에서 Properties 클래스가 어떠한 클래스도 implements, extend하지 않은 채 독립적인 클래스로 정의하였다. 그리고 private 인스턴스 변수에 Hashtable 객체를 할당한 뒤 해당 객체에 값을 넣고 조회하기 위한 getter, setter 메소드를 정의해보자.

import java.util.Enumeration;
import java.util.Hashtable;

public class Properties {
    /*
    별도의 클래스 선언하여 상위 클래스 봉쇄
    상위 특징을 갖는 클래스를 private 선언하여 외부에서 접근할 수 없도록 캡슐화
    */
    private final Hashtable<String, String> table = new Hashtable<String, String>();

    public String getProperty(String key) {
        return table.get(key);
    }

    public void setProperty(String key, String value) {
        table.put(key, value); 
    }

    // key, value String 보장으로 Exeception 차단
    public Enumeration<String> propertyNames() {
        return table.keys(); 
    }
}

Properties의 문제가 Hashtable의 메소드를 호출할 수 있다는 점이었다. 그러나 위 코드처럼 private 인스턴스로 encapsulation을 강화한 뒤 getter, setter 메소드에 매개변수로 String 타입을 강제하면 기존 propertyNames() 메소드 동작 시 키가 String 타입이 아니면 Exception을 발생시키던 문제 등을 해결할 수 있다.

네이버 파이낸셜이라는 회사에서 주관한 코딩테스트다. 금융기업이라 그런지 채용 절차도 겁나 길다. 혹시 모르는 마음에 프로그래머스에서 접수해 응시하였다. 테스트 케이스만 보면 일단 올솔인데 히든까지 고려하면 3솔이지 싶다.

1번은 hash자료구조를 잘 사용해야 한다. 전체 유저 정보를 담는 hash구조와 주어진 input의 각 요소마다 유효한 지 체크하는 hash구조 2개로 해결할 수 있다.

2번은 달팽이 알고리즘 문제이다. spiral traverse에 대한 경험이 없으면 아마 풀기 힘들지 않을까. 그나마 검색 가능이라 어지 해결되는 문제였지 그게 아니라면 무조건 네이버 코테 기준 3-4번에 랭크되어야 한다 생각한다. 전체 빈칸의 개수를 total로 잡고 0이 될 때까지 조건에 따라 달팽이 순회를 반복하여 0이 되는 순간의 좌표를 리턴하면 된다.

3번은 유저 정보를 담는 hash구조를 사용하여 주어진 input값에 대한 최종 hash구조를 완성한 뒤 각 유저끼리 후보자에 선정이 되는지 완전탐색으로 검증하면 된다. python이라 쉬웠지 java 등 언어제약이 걸린다면 꽤나 데이터를 처리하느라 골머리를 썩혔겠다.

4번은 bfs던 완전탐색이던 다익스트라처럼 해결할 수 있다. 부호 반전 키워드가 있으므로 점수가 음수더라도 0노드를 만나 반전될 수 있으므로 방문배열의 초기값은 -INF로 설정해야 한다. 그리고 갈 수 있는 방향이 오른쪽, 아래쪽밖에 없으므로 왼쪽, 위쪽을 생각하여 회귀(or 사이클)가 형성되는 문제는 고려할 필요없다. 따라서, 모든 경우의 수에 대해 탐색하면 되겠다. 필자는 bfs처럼 초기 좌표와 점수를 큐에 넣고 탐색하였다. 다만, 좀 아쉬운 점은 0노드의 부호반전으로 인해 계속 최저로 가더라도 결국 0으로 반전하면 최댓값이기에 노드의 중간버퍼를 [최대, 최소] = [-INF, INF]로 초기화하여 탐색했으면 완벽하지 않았나 싶다.

난이도는 파이썬 기준으로 브론즈 상위, 실버 상위, 실버 중간, 골드 하위 정도로 보인다. 과제는 자바-노드 언어제한이 걸린 걸 봐서는 프레임워크 기능구현 관련 과제가 아닐까 생각한다. 카카오를 제외하면 코테는 어느정도 무난하게 해결이 되는 듯 싶다. 다만 코테는 개발자의 서류전형이나 다름이 없어 면접과 포트폴리오 준비가 많이 필요해보인다.

결과 : 1차 코딩테스트 통과했고 2차 과제테스트 진행 예정입니다. java, node.js 언어 고정으로 보아 프레임워크 경험을 물어볼 듯 하네요... 얼른 스프링 공부를 하던가 해야지...

2021 Dev-Matching: 웹 백엔드 개발자(하반기) 코딩테스트 후기 후속 포스팅입니다.

1차 진행상황: 메일로 결과가 수신되는데 지원한 기업들을 보니 생각보다 열심히 검색했나 봅니다. 유니콘까진 모르겠지만 유니콘으로 발전할 가능성이 있는 기업들을 열심히 찾아서 지원했더라구요.. 10군데나 이력서가 들어갔어서 가급적 추가 코딩테스트 or 전형 진행 의향이 있는 기업들만 기록하려 합니다.

- 올거나이즈코리아 백엔드 : 코드바이트에서 2차 코테 진행, 한국인이 대부분인 회사이나 본사가 실리콘밸리에 있어선지 코딩테스트도 영어로 진행되었습니다. 지원자에 대한 개인적인 질문거리(ai 부서 경력, 즐겨하는 일, 원격근무 여부 등), 객관식 질문(머신러닝 관련으로 보입니다.), 알고리즘 문제(난이도...는 스트링/데이터 재처리, 정규표현식 파싱 등 백준 기준 브론즈 5 ~ 골드 5 수준) 5문제 출제되었습니다. 갠적으론 부담없이 재미있게 풀었습니다. 회사 직원 수는 작은데 투자규모가 큰 신기한 회사였습니다. 아니 백엔드라며 왜 ai가 튀어나오는지 '-`...

- 카카오 엔터프라이즈 데이터 플랫폼 : 해커랭크에서 2차 코테 진행, 총 4문제 3시간 30분 주어졌습니다. 문제 난이도는 역시 카카오는 카카오였습니다. 1번은 비교적 쉬웠으나 2번은 요구사항에 맞게 문자열을 필터링하는 문제인데 너무 안풀려서 특이 요건만 따로 함수로 작성하고 기본 요건은 내장 모듈이 있길래 그걸로 넣고 풀어버렸습니다. 전혀 출제자의 의도따윈 고려하지 않아서 높은 점수를 받긴 힘들어보입니다. 3번은 자리수를 하나씩 규칙에 따라 반전시키며 해결하는 문제인데 완탐으로 보이긴 하나 그 방법이 떠오르지 않아 해결하지 못했습니다. 4번은 edge들이 주어지면 이진 트리에 해당하는지 먼저 검사해서 에러 여부 반환하고 valid한 트리라면 규칙에 맞게 순회 정보를 구성해서 반환해야 했습니다. 난이도 가늠이 잘 안되긴 하지만 비교적 문제 수가 비슷한 카카오 인턴(5문제에 4시간)과 비교하면 카카오 인턴 코테의 마지막 고난도 문제를 제외한 4문제가 출제된 수준으로 보입니다. 3문제를 온전하게 풀어내야 턱걸이이지 않을까 싶은데 2번 풀이는 제가 생각해도 문제가 있지 싶어서... 면접까지 가기는 힘들어 보입니다.

- 그렙 : 추가 코딩테스트 없이 1차 면접 진행 예정이었는데... 아직 면접 준비가 덜 되었고 준비할 시간조차 빠듯한 상황이라 취소메일을 보냈습니다. 갠적으론 면접을 보려 했는데 아쉽네요.

2차 진행 상황: 면접 응시한 기업 별로 이야기를 간략히 적어보려 합니다.

- 올거나이즈 코리아 : 이직결심 후 첫 기술면접이었습니다. 결과는 레알 대참사. 일단 백엔드 관련 포폴이랄 것도 없었기에 면접관님도 질문풀이 너무 좁다 생각하신 듯 하고 그나마 배운 지식을 읊어보라던 요청으로 꺼낸 알고리즘, 자료구조, 네트워크마저 블로그에 정리했던 내용임에도 당시 기억이 나질 않아 어버버거렸습니다. 끝나고서 이걸 왜 몰랐지 계속 후회스럽고 수치스러운 면접이었습니다. 덕분에 면접을 진행하기 전 이제까지 공부했던 내용들을 리마인드하지 않으면 참사가 일어나는구나 몸으로 경험할 수 있었던 시간이었습니다. 정말 면접을 위해 들어온 CTO님께 죄송스러울 수준이네요. 번외로 회사 자체는 정말 괜찮아 보였습니다. 한, 미, 일 오피스 세 군데서 같이 협업을 하는 듯 했습니다. 다음 시리즈 투자를 받는다면 유니콘으로 발전할 수 있지 않을까 생각합니다.

- 카카오 엔터프라이즈 데이터 플랫폼 : 코딩테스트 합격 후 추가전형 진행 메일을 받았습니다. 그런데 수시채용의 성격으로 면접이 진행된다면 최종합격까지는 어려워 보이네요 '-`...

  - 카카오 엔터프라이즈 데이터 플랫폼 pre 인터뷰 진행 : 면접자 3, 면접관 2로 진행되었습니다. 분위기는 상당히 가볍게 진행되었습니다. 알고리즘, 자료구조, 네트워크, 운영체제 등 기본지식에 대한 질문을 받았고 기본 자격체크의 성격이 강했습니다. 아마 본 1-2차 인터뷰가 진행되면 프로젝트나 자소서에서 질문이 들어가지 싶습니다. 

 - 카카오 엔터프라이즈 데이터 플랫폼 1차 인터뷰 진행 : pre 인터뷰를 통과하여 1차 인터뷰를 보게 되었습니다. 추후 일정 전달받고 진행 예정입니다.

 - 카카오 엔터프라이즈 데이터 플랫폼 1차 인터뷰 결과 : 1차 인터뷰 통과 후 2차 인터뷰 일정 통보받았습니다.

 - 카카오 엔터프라이즈 데이터 플랫폼 2차 인터뷰 : 다른 기업에 합격해 취소 메일 전송했습니다.

+ Recent posts