728x90

Java를 사용하여 개발을 하기 위해서는 JDK, JRE, JVM을 필요로 한다.

 

오늘은 이것들이 무엇이며, 우리가 프로그램을 개발하고 실행되기까지 어떤 일들이 벌어지는지 알아보도록 하겠다.


1. JDK( Java Deployment Kit )

가장 먼저 알아볼 것은 자바 개발 도구이다.

JDK는 기본적으로 우리가 자바를 사용하여 개발하고, 실행하기 위해 필요한 것들이 담겨있다.

 

자바로 개발된 프로그램을 실행하기 위한 JRE와 필수적 그리고 기본적인 자바 개발 도구들(Java, Javac,...)등이 포함된다.

 

그럼 JDK만 설치되어 있다면 메모장만 사용해도 코드가 돌아가게 할 수 있나요?

 

물론이다. 이를 증명하기 위해 메모장으로 코딩을 진행해 보겠다.

우선, 텍스트 파일에 기본적인 코딩을 해보았다.

MacOs에서 진행

이후 해당 파일을 Java컴파일러로 컴파일하기 위해. java파일로 변경해 준다.

javac를 사용하여 컴파일 결과

javac명령어를 사용하여 컴파일을 진행한다.


왜 컴파일을 진행해야 할까?

Java 코드는 사람이 읽기 쉬운 고급 언어이다.

이는 사람은 이해할 수 있지만 컴퓨터는 이해할 수 없는 언어라는 의미이다.

 

그럼 외국인과 대화를 하기 위해 번역가가 필요하듯이 언어도 번역가가 필요한데 이 역할을 수행하는 것이 컴파일러다.

 

또한 컴파일과정을 거치며, 문법적으로 잘못된 부분까지 체크를 진행하게 되고, 이때 발생하는 오류가 CompileException이다.

즉, 개발을 진행하면서 오류가 발생하였다면 문법이 잘못되었거나 실행할 수 없는 파일이라 보면 된다.

 

자바 컴파일러가 변환작업을 진행하면 자바 코드 소스를 바이트코드로 변환하게 되는데, 이때 확장자는. class고 이때서야 JVM이 해당 소스를 실행시킬 수 있다.


이후 java 명령어를 통해 Class를 실행시키면 정상적으로 작동하는 것을 확인해 볼 수 있다.

 

이렇듯 JDK를 설치한다면 단순한 text로도 개발이 가능한 것을 확인해 볼 수 있다.


2. JRE ( Java Runtime Environment )

우리가 만든 Java프로그램이 동작하려면, 단순히 컴파일을 거쳐. class파일을 만들었다고 실행되는 게 아니다.

실행을 하기 위한 머신, 라이브러리, 파일들이 필요한데, JRE는 이를 모아놓은 패키지이다.

 

위 글에서 보았듯 JRE는 JDK에 포함되어 있어 JDK를 설치하면 함께 설치된다.

( 버전에 따라 다르다. )

 

JRE은 다음과 같은 구성으로 이루어져 있다.

구성요소 설명
JVM 바이트코드를 읽어 실행하는 엔진
Java 클래스 라이브러리 입출력, 네트워크, 컬렉션 등 기본 기능을 제공
Java 클래스 로더 필요한 클래스를 메모리에 동적으로 로드
런타임 라이브러리 및 지원 파일 JVM이 동작하는 데 필요한 추가 리소스 

 

이를 보면 알 수 있지만, JVM은 이론적으로 JRE 없이 단독 설치하는 것은 어려우며, 실용적이지 않다.

왜냐면 JVM은 JRE의 일부분이며, JRE가 JVM이 작동할 때 필요한 기능들을 제공하기 때문이다.


3. JVM ( Java Virtual Machine )

자바 가상 엔진으로 Java로 작성된 프로그램을 실행시키고 관리하는 실질적인 주체입니다.

 

"JVM은 JRE 없이 사용하기 어려우면 JRE가 주체 아닌가요?"

위와 같은 의문이 들었다면 JRE가 무엇인지 다시 확인해 볼 필요가 있습니다.

 

JRE는 실행 환경 전체를 말하는 "패키지"고,

그 안에 포함된 JVM이 실제로 프로그램을 실행하고 메모리를 관리하는 주체로 이해하면 된다.

 

JVM은 운영체제에 영향을 받는데, 이 영향은 JVM에 한정적이기 때문에 Java 프로그램은 모든 플랫폼에서 제약 없이 동작할 수 있다.

이는 Java로 개발된 프로그램의 가장 큰 이점인데, Java로 작성된 코드는 컴파일 후 운영체제와 상호작용을 하는 것이 아닌 JVM과 상호작용을 하고, JVM이 운영체제와 상호작용을 하기 때문에 Java로 개발된 프로그램은 이식성이 좋다.

window JVM, Linux JVM 따로 있다.

위 그림과 같이 컴파일된  바이트코드는 모든  JVM에서 동일하게 인식하기 때문에 운영체제에 맞는 JVM만 설치된다.

  1. 자바 컴파일러가. java를. class로 컴파일(바이트코드).
  2. JVM이 바이트코드를 바이너리코드로 변환.
  3. 변환된 바이너리코드를 CPU에서 실행.

여기까지가 간단하게 Java코드가 실행되기까지의 과정을 간단하게 다뤄보았다.

이제 내부적으로 어떤 일이 일어나는지 알아보도록 하자.


지금부터 알아볼 JVM의 구성요소이다.

  • Class Loader
  • Execution Engine
    • JIT ( Just In Time )
    • Interpreter
    • Garbage Collector
  • Runtime Data Area
    • Method Area
    • Heap
    • PC Register
    • JVM Stack
    • Native Mathod Stack

3.1. Class Loader

클래스 로더는. class 파일을 메모리에 올려 런타임에 사용할 수 있게 만드는 역할을 한다.

  • 클래스를 읽고( Loading )
  • 검증하고 ( Verification )
  • 필드와 메서드를 위한 메모리를 준비하고( Preparation )
  • 심볼릭 참조를 직접 참조로 바꾸고 ( Resolution )
  • 초기화 코드를 실행( Initialization )

여기까지 모든 클래스를 "사용 가능"하게 만드는 전체 파이프라인의 시작점이다.

이 작업들은 아래와 같이 3단계로 구분할 수 있다.

  1. Loading: 클래스 파일을 JVM의 메모리에 로드한다.
  2. Linking: 클래스 파일을 사용하기 위해 검증한다.
    - Verifying: 읽은 클래스가 JVM에 명시된 대로 구성되어 있는지 검사한다.
    - Preparing:
    클래스가 필요로 하는 메모리를 할당한다.
    - Resolving: 
    클래스의 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경한다.
  3. Initializing: 클래스 변수들을 적절한 값으로 초기화한다. (Static 블록 실행 및 필드 초기화)
더보기

용어정리

상수 풀( Constant Pool ): 클래스 파일에 있는 다양한 정보 저장소(클래스명, 메서드명, 필드 정보 등)

심볼릭 레퍼런스( Symbolic Reference ): "이 클래스/메서드/필드는 이름으로 이렇게 적혀있어요"라는 문자열 기반 정보

다이렉트 레퍼런스( Direct Reference): JVM 내부에서 사용하는 메모리 주소 또는 포인터

해당과정이 무작정 이뤄지지는 않는다.

JVM은 클래스를 “필요할 때” Load 하는데 이는 다음과 같은 이유가 존재한다.

  • 메모리 효율성
  • 애플리케이션 실행 시작 시간 단축
  • 모듈화의 유연성 확보

즉, 처음에 모두 메모리에 적재할 경우 비효율성과 유연성을 떨어트리기 때문에 애플리케이션에서 필요로 할 때 메모리에 적재한다.


3.2. Execution Engine

실행엔진은 바이트코드를 해석하고, CPU가 이해할 수 있는 바이너리코드로 변환하여 실행한다.

변환하고 실행할 때 실행엔진은 코드를 실행하기 위해 InterpretrJIT을 사용한다.


3.2.1. Interpreter

  • 바이트코드 명령어를 하나씩 읽어 해석하고 실행한다.

3.2.2 JIT Compiler

  • Interpreter의 중복코드 중복 해석 및 실행에 대응하고자 도입.
  • 바이트코드 전체를 컴파일하여 NativeCode로 변경.
  • NativeCode로 변경한 바이트코드는 Interpreting 되지 않고 캐싱해 두었다 NativeCode로 직접 실행
    NativeCode는 C나 C++, 어셈블리어로 구성된 인터프리터언어를 의미한다.
  • 중복 코드 실행의 경우 계속 Interpreting 하는 것보다 성능이 좋지만, NativeCode로 변경하는데 자원이 소모되기 때문에 Interpreter방식으로 사용하다 일정 기준이 넘어가면 JIT Compile방식 사용.

3.2.3 Garbage Collector

JVM은 Heap 메모리 영역에서 더 이상 참조되지 않는 메모리회수하기 위해 Garbage Collecor를 사용한다.

 

개발자가 직접 사용하는 것이 아닌 자동으로 작동하기 때문에 더욱 손쉽게 메모리 관리를 할 수 있다.

더 이상 사용하지 않는 데이터의 경우 null로 초기화해 주면, 더욱 효율적으로 Garbage Collector를 이용할 수 있다.

 

Garbage Collector는 주기적으로 작동하는 프로세스로서 참조되지 않는 메모리가 언제 해제되는지 알 수 없으며, GC가 작동하게 되면 GC관련 Thread를 제외한 다른 Thread들이 중지되기 때문에 오버헤드가 발생한다.

더보기

STW( Stop The World ): GC를 수행하기 위해 JVM이 프로그램 실행을 멈추는 현상


3.3. Runtime Data Area

 

Runtime Data Area는 JVM 메모리 영역으로 자바 프로그램을 실행할 때 사용되는 데이터를 사용 목적에 맞춰 나누어져 있다.

 

아래와 같이 크게 2가지 특징으로 분리를 해볼 수 있다.

  • Thread-specific ( Thread마다 개별로 존재 )
    • PC Register
    • Stack
    • Native Method Stack
  • Shared (모든 Thread가 공유)
    • Heap
    • Method Area

Thread-specific은 Thread마다 개별로 존재하여 각 Thread 간 메모리 공유가 불가능하다.

Shared는 Thread와 별개로 할당된 메모리 영역으로 각 Thread들이 해당 메모리에 접근한다면 동일한 데이터를 사용할 수 있다.


3.3.1. PC Register

우리가 JVM의 PC Register를 이해하기 위해서는 CPU의 PC Register와 무엇이 다른지 이해해야 한다.

 

CPU의 PC Register는 현재 CPU가 실행할 다음 명령어의 주소를 저장하는 하드웨어 레벨 Register이다.

하지만 JVM의 PC Register는 물리적인 레지스터가 아니라 가상 머신 레벨에서의 명령어 실행 흐름 제어 구조이다.

 

그럼 왜 필요할까?

 

JVM이 바이트코드를 실행할 때 다음 구조를 따른다.

[바이트코드] → [PC Register (현재 위치)] → [Opcode 해석기] → [Operand Stack]

 

아래 코드를 사용하여 한스탭씩 따라가 보자.

int a = 1 + 2;

코드를 바이트코드로 컴파일하면 아래와 같은 결과물이 나온다.

iconst_1
iconst_2
iadd
istore_1

해당 코드를 실행한다면 PC Register는 iconst_1의 위치를 가르치고,

순차적으로 프로그램의 흐름을 추적한다.

 

그럼 PC Register가 가리키는 바이트코드를 Interpreter가 읽어 해당 명령에 대응되는 C/C++ 함수를 실행한다.

이후 Interpreter가 실행한 C/C++함수가 CPU를 작동시키게 된다.

 

이렇듯 Java는 CPU에게 바로 연산시키는 것이 아닌 현재 실행해야 하는 연산을 제공하여야 하며, 이를 위한 버퍼 공간으로 사용하기 위해 PC Register메모리 영역이 존재한다.

 

또한 PC Register는 Thread마다 각각 가지고 있다.

즉, "현재 Thread의 어떤 명령어가 실행 중인가?"에 대한 명령어 주소를 기억하고 있다.


3.3.2. Stack

스택은 원시타입( Primitive type ) 데이터와 참조타입(Reference type)의 주소값, 현재 실행 중인 메서드의 스택 프레임을 저장하는 메모리 영역이다.

 

LIFO구조로 push와 pop기능 방식으로 동작한다.

Thread마다 하나씩 존재하며, Thread가 시작될 때 할당되며, Thread가 종료되면 사라진다.

 

이때, 프로그램 실행 중 할당된 메모리를 벗어난 데이터들이 들어온다면 StackOverFlowError가 발생한다.


3.3.3. Native Method Stack

이는 실제 개발하면서 보기는 힘들고, 내부 라이브러리를 깊게 파보면 native선언을 해둔 것을 확인할 수 있다.

이는 Java로 구현된 것이 아닌 C/C++과 같은 언어로 구현되었다는 의미이며,

예시로 Thread Class를 보면 registerNatives 메서드는 native선언이 되어 있는 것을 확인할 수 있다.

public class Thread implements Runnable {
    /* Make sure registerNatives is the first thing <clinit> does. */
    private static native void registerNatives();
    static {
        registerNatives();
    }
}

 그럼 왜 Java로 작성하지 않고 C/C++로 대신 구현하는 것일까?

  • 시스템 리소스 접근
  • 성능이 중요한 로직을 C/C++로 최적화
  • 기존 C/C++ 라이브러리와 연동

그렇다 컴파일 언인 Java보다 인터프리터언어인 C/C++ 이 성능적으로 앞서 성능 최적화를 한다거나 시스템에 직접 접근하려 할 때 사용하는 것을 확인할 수 있다.

 

Native Method Stack을 통해 C함수 호출 프레임을 관리하여 JVM은 해당 기능을 사용할 수 있는 것이다.


3.3.4. Heap

힙영역은 참조타입( Reference type ) 데이터들이 저장되며, new연산자로 생성되는 Instance들이 저장된다.

 

해당 영역에 존재하는 데이터들은 Stack영역에서 참조하지 않으면 GC대상이 된다.


3.3.5. Method Area

 흔히 ClassStatic으로 선언된 변수 혹은 메서드들이 프로그램이 시작될 때 메모리에 로드되어 프로그램이 종료될 때까지 남아 있다.

 

Heap과 동일하게 모든 Thread가 공유하고, 여러 정보들을 저장하고 있다.

  • Field Info: 멤버 변수명, data type, 접근 제어자의 정보
  • Method Info: 메서드명, return type, 매개변수, 접근 제어자
  • Type Info: Class인지 Interface인지 저장, Type의 속성, Super Class의 이름

즉, 정적 필드와 클래스 구조만을 가지고 있다.(클래스는 new로 생성할 경우 Instance가 Heap에 생성된다.)


3.3.6. JNI

Java Native Interface로 다른 언어로 만들어진 애플리케이션과 상호 작용할 수 있는 인터페이스를 제공한다.


3.3.7. Native Method Library

C/C++로 작성된 라이브러리다.

 

자바에서 native 키워드로 선언된 메서드를 호출할 때, 필요에 따라 JNI를 통해 해당 네이티브 라이브러리를 호출하며, 이 과정에서 Native Method Stack이 사용된다.


4. 글을 마치며

JVM, JDK, JRE에 어떤 것들이 존재하고, 어떤 역할을 하는지에 대해 알아보았다.

이전부터 알고 있던 내용들도 있었지만, 시간을 충분히 투자하며 차근차근 공부해 보니 확실히 모르고 있었던 부분도 많이 보였던 거 같다.

 

"Java개발자가 JVM을 이렇게까지 알아야 하나요? 그냥 코드 잘 짜면 잘 돌아가잖아요."

 

어느 정도 맞는 말인 거 같다.

JVM내부 요소들을 건드리며 개발을 안 하더라도 서버 스펙만 좋다면 아무런 문제 없이 돌아갈 것이다.

개판으로 작성해도 잘 돌아갈 텐데 잘 짰으면 얼마나 잘 돌아갈까.

 

하지만 알고 있다면 메모리를 좀 더 신중하게 사용할 수 있을 것이고, 성능적으로 한 번쯤은 고민해보지 않을까?

그리고 어떤 문제가 발생했다면 이를 해결하기 위해 고민을 할 때 이러한 공부들은 많은 도움이 될 것이다.

 

그리고 도구를 사용하면 어떤 도구인지 알고 있으면 잘 사용하지 않겠나. 


References

https://inpa.tistory.com/entry/JAVA-%E2%98%95-JVM-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-%EC%8B%AC%ED%99%94%ED%8E%B8#%EB%9F%B0%ED%83%80%EC%9E%84_%EB%8D%B0%EC%9D%B4%ED%84%B0_%EC%98%81%EC%97%AD_runtime_data_area

https://d2.naver.com/helloworld/1230

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90
728x90

오늘은 필자가 경험한 인증 서비스 Refactoring 다뤄보도록 하겠다.

현업에서 다룬 만큼 어떻게 그리고 왜 Refactoring 했는지와 연관되지 않는 부분은 제거하고 작성하겠다.

 

앞서 해당 글에서 작성하면서 사용한 도메인의 설정을 정의하고 가겠다.

  • 상품: 유저가 구매하는 상품이며 보통 1년에 1~4개를 구매한다.
             상품 유형은 A, B, C, D 상품이 존재한다.
  • 인증: 유저가 상품을 구매한 구매자인지 인증하기 위한 도메인이며, 현재 인증기간일 경우 할인이 적용된다.

 

우선 필자가 만난 인증 서비스는 아래와 같았다.

Before

간단하게 유저가 상품을 구매하면 인증서버에 해당 유저가 상품을 구매했다고 상품에 명시되어 있는 인증기간 동안 구매자 인증을 해주는 시스템이다.

 

 해당 시스템은 매우 간단하지만 도메인전문가의 니즈는 조금 복잡했다.

  • 고객의 인증기간은 관리자가 조절할 수 있다.
  • 상품을 구매하지 않더라도 관리자가 임의로 인증기간을 할당할 수 있다.
  • 구매자가 현재 얼마만큼의 인증기간을 가지고 있는지 확인해야 한다.
  • 상품마다 인증기간이 다르며, 인증기간 사이에 비인증 기간이 존재할 수 있다.

또한 해당 서버에는 고질적인 문제가 있었다.

왜 어째서인지 고객이 상품을 구매하였지만 인증기간이 존재하지 않는 데이터들도 존재하는 것이었다.

 

왜 그럴까? 

우선 인증서버에서 인증 데이터를 어떤 것들을 저장하는지 확인해 보자.

A시작일 A종료일 B상품 B시작일 B종료일 C상품 C시작일 C종료일 D상품 D시작일 D종료일

 

위 칼럼을 확인 후 필자는 이와 같이 생각했다.

  • 인증은 A, B, C, D상품 인증일 중 하나라도 현재 인증 기간 범위 내에 존재한다면 구매자로 인증된다.
  • 구매한 상품의 타입이 같다면 인증기간을 기존 인증기간이 존재한다면 두 데이터를 비교하여 더 빠른 인증시작일과 더 늦게 종료되는 인증 종료일을 저장한다.
  • 스케줄링을 통해 인증 종료된 인증 데이터에 대해서는 빈칸으로 수정해 준다.( Mysql을 사용하였고, 해당 칼럼 타입 VARCHAR이었다. )

그리고 데이터에도 문제가 있다는 것을 확인하였다.

  • 상품을 구매하였지만 인증데이터가 생성되지 않는 유저가 존재한다.
  • 어째서인지 들어갈 수 없는 날짜가 인증 종료기간에 들어가 있다.
  • 인증기간은 존재하지만 상품 아이디가 없다.

그럼 상황을 정리해 보자.

  • 상품을 구매할 때마다 인증 데이터의 신뢰도가 떨어진다.
  • 현재 인증일인지 확인하기 복잡하다.
  • 테스트한 데이터가 운영 데이터베이스에 있다.
  • 인증 데이터에 상품 아이디가 존재하지 않는 이유는 관리자가 임의로 생성한 데이터였기 때문이다.
  • 이상한 날짜가 들어간 이유는 인증 종료일을 강제로 31일로 넣었기 때문이다.

필자는 위 상황을 정리하며 해당 시스템에 어떤 고객 니즈가 존재하는지, 기존 시스템을 파악하며 어떤 문제가 존재하는지를 파악하였다.

 

이제 이를 해결하기 위해 어떤 고민이 있었고, 어떻게 해결하였는지 알아보자.


Chapter 1. 상품을 구매할 때마다 인증 데이터의 신뢰도가 떨어진다.

필자가 맨 처음 고민한 부분이다.

 

위 인증 데이터는 상품을 구매할 경우 인증기간을 상품과 상관없이 인증기간을 인증 데이터에 저장하고 있었다.

이렇게 하였을 경우 문제점은 이와 같았다.

  • 정상적으로 인증 데이터가 생성되지 않을 수 있다.
  • 상품의 실제 인증기간과 별개의 인증기간이 만들어질 수 있다.
  • 현재 인증기간이 어떤 상품을 구매해서 생긴 것인지 정확히 알기 어렵다.

위와 같은 문제가 발생한다면 이는 또다시 이런 일이 발생하지 않도록 하는 게 좋다 생각했다.

 

그럼 어떻게 풀어낼 수 있을까?

 

우선 간단하게 생각했다.

 

상품을 구매할 때마다 인증기간을 생성한다면 인증기간을 신뢰도가 높고, 효율적으로 관리할 수 있을 것이라 생각했다.

그리하여 인증과 인증기간을 분리하기로 하였다.

인증 테이블

인증 아이디 고객 아이디

인증 기간 테이블

인증 기간 아이디 인증 아이디 상품 아이디 인증 시작일 인증 종료일

 

위와 같이 정규화를 하여 관리한다면 구매한 상품에 대한 인증기간을 정확하게 관리할 수 있다.

 

또한 상품 타입이 늘어나거나 줄어들 때마다 테이블 칼럼을 수정하지 않아도 된다.


Chapter 2. 현재 인증된 고객인지 빠르고 정확하게 확인하고 싶다.

기존 인증 데이터의 경우 A, B, C, D 상품 인증 시작일과 종료일을 하나의 Record로 관리하고 있었다.

 

따라서 sql을 작성한다고 가정하면 이렇다.

SELECT *
FROM 인증
WHERE 유저아이디 = :유저아이디
  AND (
    (A시작일 <= :현재날짜 AND A종료일 >= :현재날짜) OR
    (B시작일 <= :현재날짜 AND B종료일 >= :현재날짜) OR
    (C시작일 <= :현재날짜 AND C종료일 >= :현재날짜) OR
    (D시작일 <= :현재날짜 AND D종료일 >= :현재날짜)
  );

 

하지만 Chapter 1에서 정규화된 테이블을 사용한다면 이와 같이 작성될 것이다.

SELECT 인증아이디
FROM 인증
WHERE 유저아이디 = :유저아이디;


SELECT *
FROM 인증기간
WHERE 인증아이디 = :인증아이디
 AND 시작일 <= :현재날짜
 AND 종료일 >= :현재날짜;

 

 

비정규화 쿼리와 정규화 쿼리를 비교해 보자.

  비정규화 정규화
인덱스 활용도 낮음 높음
유지보수 어려움 (A~D 컬럼 늘어나면 쿼리 수정 필요) 쉬움
쿼리 단순성 조건문 복잡 명확하고 간결
확장성 나쁨 (인증 수 늘면 테이블 변경 필요) 좋음 (행으로 관리)
성능 느림 (OR 조건 병렬평가) 빠름 (범위 인덱스 활용 가능)

 

이와 같이 정규화를 적절하게 적용한다면 매우 많은 이점을 가질 수 있게 된다.

 

물론 서버에서 조회한 다음 로직으로 현재 인증일인지 판단한다 하면 성능자체는 비정규화 방식이 좋을 수도 있다 생각한다.

왜냐면 정규화를 한다면 최소 4배 이상의 데이터가 생길 것이기 때문이다.

 

하지만 유지보수성과 관리의 측면에서 바라본다면 좀 더 효율적으로 관리할 수 있을 것이다.


Chapter 3. 서버가 다운되더라도 인증 기간은 정상적으로 생성하고 싶다.

정규화를 통해 데이터를 잘 관리할 수 있게 되었다면 우리는 다음 문제를 해결해야 한다.

상품을 구매하였는데 인증 기간이 생기지 않는 상황이다.

 

이는 몇 가지 고민을 해볼 수 있다.

1. 하나의 트랜잭션으로 묶는다.

만약 MA라면 가장 간단할 거다.

 

하지만 여기서도 고민해 볼 것은 존재한다 생각한다.

인증 기간이 생성되지 않는다고 상품 구매를 원복 시킨다?

 

뭔가 배보다 배꼽이 커진 거 같다.

 

???: "상품을 구매하였기 때문에 인증 기간이 생성되어야 합니다."

???: "인증 기간이 생성되지 않았기 때문에 상품 구매는 취소됩니다."

 

논리적으로 앞뒤가 바뀐 거 같지 않은가?

 

필자는 이에 주도권은 상품에게 존재한다 생각하였다.

그렇기 때문에 하나의 트랜잭션으로 묶는 것은 적절하지 않다 생각한다.

2. 스케줄링을 돌려 인증 기간을 생성한다.

이 또한 간단하게 처리할 수 있는 방법이라 생각한다.

 

다만 구매한 상품들 중 인증 안된 상품을 어떻게 찾을 것인가?

만약 한다면 n초 전 구매한 상품들은 전부 인증기간을 생성해야 하는 가?

 

이러한 부분을 원활하게 처리하기 위해서는 스케줄링은 적합하지 않다 판단했다.

3. Event를 사용하여 비동기로 생성한다.

상품을 구매하면 상품정보와 고객 정보로 Message를 만들어 발행하는 것이다.

 

이렇게 진행한다면 인증 서비스에서는 해당 Topic으로 발행된 Message를 컨슈밍 하여 인증 기간을 생성하는 것으로 깔끔한 과정이 완성된다.

 

물론 과정만 깔끔하다 해서 해당 방법을 채택한 것은 아니었다.

  1. 고가용성 보장
    인증 기간 생성 중 서버 장애가 발생해도, 메시지를 재처리하여 복구 가능하다.
  2. 비동기 처리로 API 성능 향상
    인증 생성은 후속 작업이므로, 클라이언트 응답 속도에 영향을 주지 않는다.
  3. 프로세스 간 결합도 감소
    상품 구매와 인증 생성 로직이 분리되어 유지보수 용이하다.

이러한 장점을 적용시키기 위해 Event를 발행하는 방법으로 개발을 진행하였다.

After

 

이렇게 리펙토링 및 정규화 과정을 거치면서 도메인전문가의 니즈를 충족시키며 기존 발생했던 문제들 혹은 불편한 점들을 개선시킬 수 있었다.

  • 고객이 구매한 상품 인증기간을 관리자가 조절할 수 있다.
  • 상품을 구매하지 않더라도 관리자가 해당 유저의 인증기간을 새로 생성 혹은 기존 인증 기간을 수정할 수 있다.
  • 생성된 인증기간들을 통해 관리자는 유저가 보유한 인증기간을 쉽게 파악할 수 있다.
  • 상품마다 인증기간이 따로 관리되기 때문에 비인증 기간을 파악하기 편하다.

그럼 이렇게만 처리하면 모든 게 다 베스트인가?

완벽하게 잘 처리한 것인가?

 

필자는 주어진 자원 내에 최적의 방법을 선택하여 과제를 해결했다 생각한다.

다만 해당 처리에도 이슈들은 분명 존재한다.

  • Kafka관련 이슈들
  • 기존 데이터 마이그레이션

하지만 이와 관련된 처리는 다른 글에서 다루도록 하고, 여기선 우선 리팩터링 하는 과정에서 했던 고민들과 어떻게 해결하였는지만 다루도록 하겠다.

 

또한, 개발자인 필자가 위와 같이 판단하였다고 맘대로 진행하면 안 된다.

도메인전문가와 기획자와의 소통은 필수 요소이며, 소통을 얼마나 잘하느냐에 따라 결과물이 달라지기 때문이다.

 

똑같은 일 두 번 하기 싫으면 소통을 자주 하는 것을 권장한다.

 

더 좋은 방법 혹은 이야기해보고 싶은 내용이 있다면 댓글 부탁드립니다.

 

 

 

 

 

 

728x90

'Server' 카테고리의 다른 글

[EDA] EDA는 왜 적용하게 된걸까?  (0) 2025.04.03
[MSA] 왜 MSA로 가야하나요?  (0) 2025.04.03
[Architecture] Clean Architecture VS Hexagonal Architecture  (0) 2025.01.14
[DNS] 너의 주소는?  (6) 2024.12.30
[ JPA ] OSIV가 뭔가요?  (1) 2024.11.12
728x90

이번 글에서 필자는 EDA 즉 Event Driven Architecture를 적용해 보면서 했던 경험들과 고민들을 작성하고자 한다.

 

해당 글을 작성하기에 앞서 알아야 하는 개념들에 대해 정리 후 본론으로 들어가 보자.

  • Event란?
  • 비동기 or 동기
  • 트랜잭션

Event란?

본글에서 말하는 Event는 도메인 모델의 상태 변화 즉 변화를 일으키는 사건을 의미한다.

 

예를 들어 상품을 주문한다 가정해 보자.

  1. 상품 재고를 확인한다.
  2. 상품 재고를 차감한다.
  3. 주문을 생성한다.
  4. 주문 완료 알림을 보낸다.

위와 같은 프로세스를 가진다고 가정한다면 상품이라는 도메인은 '상품 재고가 차감되었다.'라는 이벤트를 발행하고,

주문은 '주문이 생성되었다'라는 이벤트를 발행하게 될 것입니다.


비동기 or 동기

비동기와 동기는 통신 방식입니다.

 

동기 방식의 통신은 현재 Thread가 요청을 보낸 후 응답을 받을 때까지 대기하는 것이고,

비동기는 현재 Thread가 요청을 보낸 후 응답을 받을 때까지 대기하지 않고, 후속 작업을 콜백이나 이벤트 기반으로 처리하는 것입니다.

 

즉 작업의 완료 여부를 어떻게 처리하느냐의 차이가 있습니다.


트랜잭션

업무 처리 단위를 의미합니다.

해당 업무는 ACID를 보장해야 하며 이를 보장하는 방법이 트랜잭션입니다.

  • Atomic(원자성): 트랜잭션 내 데이터들은 데이터베이스에 모두 반영되거나 전혀 반영되지 않아야 합니다.
  • Consistency(일관성): 트랜잭션의 작업 결과는 항상 일관성을 띄워야 합니다.
  • Isolation(독립성): 각각의 트랜잭션은 독립적으로 실행되어야 합니다.
  • Durability(영구성): 트랜잭션이 성공적으로 끝났을 때 결과는 영구적으로 반영되어야 합니다.

위의 4가지 원칙을 보장하여야 합니다.

  • Active(활성): 트랜잭션이 실행 중인 상태
  • Parially Committed(부분완료): 트랜잭션 내 연산이 모두 끝났지만 반영되지 않은 상태
  • Committed(완료): 트랜잭션이 종료되어 데이터베이스에 반영된 상태
  • Failed(실패): 트랜잭션 연산 중 오류가 발생한 상태
  • Aborted(철회): 트랜잭션이 비정상 종료되어 Rollback 연산을 수행한 상태

트랜잭션은 위와 같은 5가지 상태를 가지게 됩니다.

 

특이점으로 Parially Committed에서 Aborted로 가는 경우는 연산은 끝났지만 데이터베이스에 오류가 발생 혹은 종료되어 Commit상태로 가지 못하는 상황 등이 존재합니다.


서비스 간 직접 호출 방식과 Event발행 소비 방식

앞선 예시를 가져와 주문, 상품, 알림 이 3가지 서비스를 예시로 사용하여 두 방식 간의 차이가 무엇인지 알아보겠습니다.

  • OrderService: 주문 서비스
  • ProductService: 상품 서비스
  • NotifyService: 알림 서비스

 

Event발행 방식을 알기 전 저는 서비스 간 호출을 진행할 때 아래와 같이 코드를 작성하였습니다. 

public class OrderUsecase{
    private final OrderService orderService; 
    private final ProductService productService;
    private final NotifyService notifyService;
    
    public void order(...) {
    	// 상품 재고 차감
    	productService.reduceStock(...);
        // 주문 생성
        Order order = orderService.createOrder(...);
        // 주문 알림 발행
        notifyService.sendOrderNotification(order);
    }
}

 

이와 같이 코드를 작성하더라도 문제는 없을 것입니다.

 

상품재고가 없다면 reduceStock Transaction에서 오류가 발생하여 다음 Process를 진행하지 않을 것이고,

주문이 생성되지 않는다면 알림이 발행되지 않기 때문입니다.

 

여기서 고민해 볼 사항은 '알림 발행이 실시간성을 보장해야 하고 알림 발행에 실패한 것이 주문 프로세스에 영향을 끼쳐야 하는가?'입니다.

 

물론 간단하게 처리하려면 notifyService의 sendOrderNotification메소드에 @Async 어노테이션을 붙이면 간단하게 비동기로 처리할 수 있을 것입니다.

 

하지만 여기서 고민해 볼 부분은 만약 이렇게 개발을 계속하게 된다면 어떤 불편함이 존재할까요?

 

NotifyService는 알림을 발행하는 여러 서비스에서 불리게 될 것이며 종속성을 띄게 될 것입니다.

 

그럼 우리는 이 NotifyService의 알림 발행 기능을 수정하기 위해서는 여러 서비스를 돌아다니며 수정해야 하고, 이는 기능이 추가되면 추가될수록 관리가 힘들어진다는 불편함을 낳을 것입니다.

 

또한 알림을 발행하다 실패하거나 서버가 다운되어 버리면 알림이 발행되지 않는 상황이 발생할 수 있습니다.

 

그럼 어떻게 해야 이 불편함과 예외상황을 대응할 수 있을까요?

 

서버가 의도치 않게 다운되는 현상을 JVM에 Graceful Shutdown옵션을 주면서 어느 정도 해결할 수 있습니다.

다만, 이는 서버를 종료할 때 내부 작동 중인 작업을 모두 수행한 후 종료되는 방식으로 중간에 오류가 터졌다거나 아니면 컴퓨터가 나갔다거나 하는 상황은 대응할 수 없습니다. (ex: OOM, 하드웨어 장애, kill -9)

 

이런 상황을 대응하기 위해서는 몇 가지 방법을 생각해 볼 수 있습니다.


1. DB에 발행해야 하는 알림을 저장

첫 번째로 DB에 발행해야 하는 알림을 저장하고, 스케줄링을 통해 n개의 알림을 발행하는 방법입니다.

 

문자 단체발송이나 카카오톡 단체 메시지 발송의 경우 이와 같은 방법을 많이 사용하는 것을 봤는데요.

이는 스케줄링을 통해 n개의 메시지만 처리하기 때문에 메시지 발행 서버의 부하가 일정하다는 장점이 존재합니다.

 

다만 너무 많은 알림이 저장되게 된다면, 메시지 발생시간이 지연되게 되는 단점이 존재합니다.

또한, 발행한 메시지에 대해 flag값을  변경해 주거나 데이터를 삭제해주어야 하기 때문에 DB부하가 발생할 수 있습니다.


2. Event발행

문제점 중 첫 번째 NotifyService의 호출 서비스가 많아 NotifyService의 관리가 힘들다!

 

이 부분은 Event를 발행하여 소비하는 방법으로 변경하면서 간편하게 변경할 수 있습니다.

  1. 주문 생성
  2. OrderCreated Event발행
  3. NotifyEventHandler에서 OrderCreated Event 소비
  4. NotifyService 알림 발행

Event를 발행한다면 Order와 Notiry 간의 결합이 느슨한 결합이 되어 유연성과 확장성이 좋은 형태로 변경할 수 있습니다.

 

그럼 서버가 다운되어 버린다면 알림은 서버가 다시 살아났을 때 잘 발행할 수 있을까요?

 

이벤트 스트리밍 플랫폼(kafka, net, rabbitmq 등)을 사용한다면 서버가 다운되더라도 정상적인 알림 발행이 가능합니다.

 

왜 가능할까요?

 

우선 이벤트 스트리밍 플랫폼은 따로 Instance를 필요로 합니다.

즉 이벤트 관리 서버가 존재한다는 의미이죠.

 

이벤트가 발생하면 Message를 만들어 발행하 Queue에 담아 보관을 하게 됩니다.

즉, 메인 서비스 서버가 죽었다 하더라도 이벤트를 관리하는 서버가 죽지 않는다면 얼마든지 이벤트를 소비할 수 있다는 것이죠.

 

그럼 이벤트 스트리밍 서버 여기서가 다운된다면 메시지는 사라지는 거 아닌가요?

Kafka를 예시로 들면 Kafka는 메시지를 Log Segment로 저장하므로, Kafka가 다운된다 하더라도 문제가 없습니다.

 

다만 옵션에 따라 오래된 Log는 삭제하니 이에 문제가 발생할 수 있습니다.

 

하지만 이벤트 스트리밍 플랫폼은 이벤트에 대해 고가용성을 보장하는 만큼 어느 정도 신뢰를 가질 수 있습니다.

 

그럼 문제가 다 해결될까요?

 

우리가 위에 해결하고자 하는 불편함과 문제 상황 대응은 이렇게 충분한 것 같습니다.

Kafka와 같은 이벤트 스트리밍 플랫폼을 사용할 때 발생할 수 있는 문제들이 존재하지만,

이는 아래 글에서 다루도록 하겠습니다.

 


EDA가 뭔가요?

지금까지는 Event를 발행하면 어떤 문제점 및 문제 상황을 대응할 수 있을지 알아보았습니다.

하지만 결국 Event Driven Architecture가 무엇인지는 아직 잘 와닿지는 않네요.

 

Martin Fowler가 EDA 개념을 정리하면서 나오게 되었는데요,

EDA는 쉽게 Event를 발행하고 수신자가 Event를 소비하는 형태의 시스템 아키텍처입니다.

 

EDA는 크게 3가지 구성요소를 가지게 됩니다.

  • Event generator: 표준화된 형식의 이벤트를 생성
  • Event Producer: 이벤트를 필요로 하는 시스템까지 발송
  • Event Consumer: 이벤트를 구독하고 처리  

이렇게 Event를 사용하여 통신을 하게 되었을 때 장점은 무엇이 있을 까요?

  • 비동기 처리에 용이
  • Loose coupling

등이 존재한다 생각합니다.

 

단점은 어떤 점들이 존재할까요?

  • 트랜잭션 관리의 어려움
  • 디버깅의 어려움
  • Event관리의 어려움

이러한 장단점이 존재하므로 EDA를 적용한다면 현재 서비스에 어떤 문제를 해결하기 위해 적용하는지가 명확해야 합니다.

 

그럼 비동기 개발을 하려면 그냥 EDA적용하면 되는 건가요?

아닙니다. 앞서 말했듯이 무작정 EDA를 적용한다면 Event를 받아 발행하는 Message관리도 어려울 것이고,

관심사가 명확하지 않다면 오히려 복잡한 시스템으로 탄생하게 될 것입니다.

 

그럼 언제 적용하는 것이 좋을까요?

서버 간 결합을 낮춰야 할 때, 여러 작업이 비동기로 처리되어야 할 때와 같이 어떤 문제를 해결하고자 적용하는 것이 적절하다 생각합니다.

 

실제로 EDA를 적용하여 개발하였지만, 오히려 Event들을 처리하는 Process의 복잡도에 의해 리펙토링 하는 상황도 있었고,

실시간성을 보장해야 하지만 Event를 사용하여 비동기로 처리하게 되면서 실시간성을 훼손시키는 상황도 발생하였습니다.

 

즉 EDA든 Event든 만능치료제가 아니니 꼭 알고 사용하기를 권장드립니다. 

 


References

https://medium.com/dtevangelist/event-driven-microservice-%EB%9E%80-54b4eaf7cc4a

https://jaehun2841.github.io/2019/06/23/2019-06-23-event-driven-architecture/

 

 

728x90
728x90

요즘 공고를 보거나 이름 내놓으라는 기업들을 보다 보면 MSA라는 단어를  많이 접하게 된다.

 

본인이 개발할 때도 MSA로 서비스를 분리하여 개발하고 관리하였는데 과연 MSA가 무조건 정답일까?

그럼 이전 모놀리식 아키텍처를 사용하는 것은 잘못된 개발이었을까?라는 의문이 생길 수 있다.

 

오늘 이 글은 필자가 개발하고 공부하면서 고민했던 내용들을 정리하고자 작성한다.


모놀리식 아키텍처가 뭔가요?

해당 내용을 다루기 전에 모놀리식 아키텍처가 무엇인지 알아보자.

 

모놀리식 아키텍처란 프로그램을 하나의 코드베이스에 개발하는 전통적인 아키텍처 모델을 의미한다.

 

이게 무슨 말인가?

 

우리가 서비스를 제공한다 하면 기본적으로 아래와 같은 비즈니스 기능들을 제공하게 될 것이다.

  • 회원관리
  • 게시판
  • 알림

위와 같은 기능들을 제공한다 가정할 때 하나의 애플리케이션이 모든 비즈니스 기능들을 제공하는 책임을 가지는 것이다.

 

즉 Client가 회원관리 요청을 보내든 게시판관리 요청을 보내든 알림 관련 기능 요청을 혹은 응답을 보내든 하나의 서버에서 해당 기능들을 처리하는 것을 의미한다.

 

이렇게 개발하였을 때의 장점과 단점은 무엇일까?

 

우선 장점은 이와 같이 생각해 볼 수 있다.

1. 개발 시작이 간단하다.

하나의 코드베이스에서 모두 개발하기 때문에 간단한 설계 이후 바로 개발이 가능하다.

왜냐면 어차피 하나의 코드베이스에서 개발하기 때문에 추후 리펙토링이 간단하기 때문이다.

2. 배포가 간단하다.

하나의 코드베이스에서 개발하게 된다면 하나의 애플리케이션만 관리하면 되므로 하나만 잘 관리하면 문제 될 일이 없을 것이다.

3. 기술이 통합된다.

하나의 애플리케이션에서 개발하고 관리하게 되므로 사용하는 기술이 단일화될 것이다.


그렇다면 단점은 무엇이 있을까?

1. 확장이 어렵다.

새로운 기능이 추가될 때마다 애플리케이션의 크기가 거대해질 수밖에 없다.

이렇게 된다면 추후 유지보수성을 떨어트릴 뿐만 아니라 추가 기능구현에도 제약이 발생할 확률이 높다.

2. 배포가 어렵다.

애플리케이션에 간단한 수정내용을 반영하고 배포를 하게 된다면 통째로 재배포하게 된다.

이는 애플리케이션의 크기가 작다면 괜찮겠지만 크기가 커졌을 경우 배포시간이 길어지게 되면서 리소스를 많이 소모하게 되는 현상이 발생하게 된다.

3. 기술에 제약이 생긴다.

애플리케이션에서 사용하는 기술들이 통합되다 보니 추가적인 기술 도입이나 서로 다른 언어로 개발하는 것은 매우 어려운 일이 될 것이다.

예를 들어 Java/Spring으로 개발 중인 애플리케이션에 Python/Django를 사용해야 하는 상황이 발생한다면 이는 매우 어려운 길을 걷게 될 것이다.

 

위와 같은 장단점들을 보았을 때 모놀리식 아키텍처를 사용하는 것은 대규모 프로젝트 혹은 많은 비즈니스 기능들을 처리해야 하는 상황에서는 어려운 점들이 보일 것이다.

 

다만 소규모 프로젝트나 MVP처럼 빠른 시일 내에 개발하여야 하거나 적은 비즈니스 기능들을 처리하는 애플리케이션을 개발한다 할 때에는 오히려 개발 및 관리가 편한 아키텍처라 볼 수 있다.


MSA가 뭔가요?

그럼 우리가 이번글에서 비교하고자 하는 MSA는 무엇일까?

 

MSA란 Micro Service Architecture의 약자로 직역하자면 '작은 서비스 아키텍처'이다.

 

MSA는 서비스의 크기를 작게 나누는 개념이라고 볼 수 있다.

 

우리가 모놀리식 아키텍처를 사용하면서 가장 큰 불편함이 무엇인지를 생각해 보자.

기능이 추가됨에 따라 하나의 서비스가 커져 여러 불편한 상황이 발생하는 것이 문제점이라 생각한다.

 

즉, MSA는 기존에 하나의 서비스에 다 개발하였던 비즈니스 기능들을 관심사에 맞춰 부리한 여러 개의 서비스로 나눠 관리할 수 있는 아키텍처라고 보면 된다.

모놀리식 아키텍처를 MSA로 나눠보기

 

이와 같이 모놀리식 아키텍처에서 MSA를 적용하여 서비스들을 나눠볼 수 있다.

 

이렇게 보면 MSA를 적용했을 때의 장점은 다음과 같이 생각해 볼 수 있다.

1. 독립성 및 확장성

특정 비즈니스 기능들을 묶어 개발하게 되면서 각 서비스들은 특정 관심사를 책임지게 된다.

이는 각각의 서비스가 담당하는 비즈니스 기능들이 명확해지면서 추후 오류 추적이나 디버깅이 수월해질 수 있다.

 

또한 기능을 추가하더라도 각각의 관심사에 맞는 서비스에 기능을 추가하면 되므로 확장성과 유지보수성이 좋다.

2. 스케일의 용이

모놀리식의 경우 하나의 서비스를 관리하기 때문에 대용량 트래픽 혹은 대용량 데이터 처리와 같은 과제를 만나게 된다면 스케일 아웃과 같은 방법으로 처리하는 것은 매우 비효율적인 상황이 발생할 것이다.

 

하지만, MSA로 분리한 경우 대용량 트래픽이 몰리는 서비스만 스케일 업이나 스케일 아웃을 통해 해결하면 되므로 MA에 비해 컨트롤할 수 있는 방법이 많아진다.

3. 배포가 쉽다.

간단한 수정사항이 발생하였을 경우 해당 수정이 발생한 애플리케이션만 재배포를 진행하면 되므로 재배포에 대해 MA에 비해 적은 부담을 가지게 된다.

4. 기술적 제약이 적다

각각의 서비스가 물리적으로 분리되어있다 보니 특정 서비스의 기술을 다르게 개발할 수 있는 유연함이 생긴다.

 

즉 Java로 구현하든 Python으로 구현하든 Javascript로 구현하든 서로 영향을 미치는 범위가 적어 유연한 개발이 가능하다는 의미다.


장점이 있다면 단점 또한 존재하는 법 어떤 단점이 존재할까?

1. 설계의 복잡도

만약 처음부터 MSA를 적용하여 개발을 하게 된다면 어떤 기준으로 서비스들을 나누어야 하는지 설계의 고민이 필요하다.

설계가 잘못되어 하나의 서비스가 의도와 다르게 거대해진다면 이는 MSA를 적용하였지만 각각의 서비스는 MA를 적용하여 개발하는 것과 다를게 없어진다 생각한다.

2. 분산 시스템의 복잡도

여러 서비스로 나누다 보니 각각 어떤 통신을 하는지, 오류가 발생했을 때 해당 오류를 찾기 위해 여러 서비스를 확인해야 한다는 단점이 존재한다.

 

또한, 테스트를 진행할 때 특정 비즈니스 기능들이 모여있어 해당 기능들을 테스트하기는 수월하지만, 서비스 간 통신을 통한 비즈니스 기능들은 테스트하기 어려움이 존재한다.

 

트랜잭션 관리에 있어 어려움이 있으며, 이를 해결하기 위해 보상 트랜잭션과 같은 기능들이 구현해야 하기 때문에 자연스레 개발 난이도 및 유지보수 난이도가 올라간다.

3. 인프라 구축의 난이도 상승

각 서비스를 어떻게 관리할 것인지 또한 어떻게 배포할 것인지 각 서비스 간 통신은 어떻게 할 것인지에 대한 인프라가 구축되어야 한다.

 

이는 MA의 단순한 인프라에 비해 많은 리소스를 필요로 할 것이다.

 

이렇듯 MSA 또한 장점만 가지고 있는 것이 아닌 단점 또한 가지고 있다.


MSA 해보니 어땠나?

MSA를 경험하며 다양한 고민과 배움을 얻을 수 있었다.

 

혼자 사이드 프로젝트를 진행하면서 대규모 서비스들을 개발하는 경험은 쉽지 않다 생각한다.

현장에서 MSA로 구축된 프로젝트를 진행하면서 각 서비스를 나누는 기준과 어떤 어려움이 존재하는지를 경험할 수 있는 기회는 매우 소중한 경험이었다.

 

다만 무엇이든 잘못 설계하면 그렇듯이 너무 잘게 나누어 "굳이 나눌 필요가 있을까?"와 같은 고민들을 하게 될 수 있으며,

각 서비스 간 통신 방법에 대하여 동기와 비동기방식 중 어떤 것을 사용할지에 대해 고민하게 될 것이다.

 

처음부터 MSA를 도입하는 것은 어려운 선택일 수 있다.

먼저 모놀리식 아키텍처를 경험하며 그 한계를 느끼고, 이를 해결하는 과정에서 MSA를 도입하는 것이 더 좋은 학습 과정이 될 것이다.

 

처음에는 'MSA가 이미 적용되어 있으니 그대로 쓰면 되겠지'라는 생각을 했지만,

이는 왜 MSA로 구축하였는지에 대한 질문에 답변을 내놓지 못하는 상황이 발생할 수 있다.

 

MA와 MSA에 잘못된 아키텍처는 없다.

다만 "어떤 상황과 문제를 만났기 때문에 어떤 아키텍처를 적용하였다."를 답변하지 못한다면,

이는 스스로 잘못된 방향으로 경험을 쌓았다고 말하고 싶다.

 

아키텍처는 단순히 잘 돌아가는 것이 목표가 아니다.

주어진 환경에서 최적의 해결책을 찾기 위해 끊임없이 고민하고 실천하는 것이 진정한 개발자의 길이라 생각한다.

 

728x90
728x90

문제: https://school.programmers.co.kr/learn/courses/30/lessons/120844

 


1️⃣ 첫 번째 코드 (리스트 변환 사용)

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

class Solution {
    public int[] solution(int[] numbers, String direction) {
        List<Integer> list = Arrays.stream(numbers).boxed().collect(Collectors.toList());

        if (direction.equals("right")) {
            list.add(0, list.get(list.size() - 1)); // 마지막 원소를 맨 앞에 삽입
            list.remove(list.size() - 1); // 마지막 원소 제거
        } else {
            list.add(list.size(), list.get(0)); // 첫 번째 원소를 맨 뒤에 삽입
            list.remove(0); // 첫 번째 원소 제거
        }
        return list.stream().mapToInt(Integer::intValue).toArray();
    }
}

🔹 특징

  1. 배열을 List<Integer>로 변환한 후, 리스트 연산을 통해 이동을 수행.
  2. add(0, element) 또는 add(size, element)를 사용해 요소를 앞뒤로 이동.
  3. 마지막에 stream()을 사용하여 다시 int[]로 변환.

🟢 장점

  • 코드가 직관적이며 리스트의 메서드를 활용하여 쉽게 이해할 수 있음.

🔴 단점

  • 성능이 비효율적
    • Arrays.stream(numbers).boxed().collect(Collectors.toList()) → O(N)
    • add(index, element)와 remove(index) → O(N)
    • 최종적으로 list.stream().mapToInt(Integer::intValue).toArray() → O(N)
    • 전체 시간 복잡도: O(N) + O(N) + O(N) = O(N)
  • 불필요한 오토박싱 & 언박싱 발생
    • int를 Integer로 변환(boxing), 다시 int로 변환(unboxing) → 성능 저하 가능성 있음.

2️⃣ 두 번째 코드 (배열 인덱스 이동 사용)

class Solution {
    public int[] solution(int[] numbers, String direction) {
        int len = numbers.length;
        int[] answer = new int[len];
        boolean rightMove = "right".equals(direction);
        
        for (int i = 0; i < len; i++) {
            answer[rightMove ? (i + 1) % len : (i - 1 + len) % len] = numbers[i];
        }
        return answer;
    }
}

🔹 특징

  1. 새로운 배열 answer을 생성하고 수학적 연산을 이용해 인덱스를 조정하여 값을 삽입.
  2. 오른쪽 이동(right) → answer[(i + 1) % len] = numbers[i];
  3. 왼쪽 이동(left) → answer[(i - 1 + len) % len] = numbers[i];

🟢 장점

  • 성능이 매우 우수함 (O(N))
    • 단순 반복문과 인덱스 연산만 사용하여 추가적인 리스트 변환 없이 처리.
    • 리스트 변환, 박싱/언박싱이 없어 불필요한 성능 손실 없음.
  • 메모리 사용량이 적음
    • int[]만 사용하므로 추가적인 객체 생성이 없음.

🔴 단점

  • answer[rightMove ? (i + 1) % len : (i - 1 + len) % len] = numbers[i];
    → 가독성이 살짝 떨어질 수 있음.
    → 하지만 수학적 연산이므로 이해하면 훨씬 효율적임.

🏆 최종 결론: 두 번째 코드가 더 좋음!

첫 번째 코드 (리스트 변환) 두 번째 코드 (배열 인덱스 연산)

시간 복잡도 O(N) + O(N) + O(N) = O(N) O(N)
메모리 사용 추가적으로 List<Integer> 사용 int[] 배열만 사용 (메모리 절약)
성능 리스트 변환과 박싱/언박싱으로 성능 저하 빠른 배열 인덱스 계산
가독성 직관적이지만 리스트 변환 과정이 필요 다소 수학적이지만 최적화됨
추천 여부 ❌ 비효율적 ✅ 최적

💡 결론:

두 번째 코드(배열 인덱스 연산 방식)더 빠르고 메모리 효율적이며 최적화됨.

728x90
728x90

문제: https://school.programmers.co.kr/learn/courses/30/lessons/120835

 

 

1️⃣ 첫 번째 코드 분석 (이중 반복문)

class Solution {
    public int[] solution(int[] emergency) {
        int[] answer = new int[emergency.length];

        for(int i = 0; i < answer.length; i++){
            if(answer[i] != 0){
                continue;
            }
            int idx = 1;
            for(int j = 0; j < answer.length; j++){
                if(emergency[i] < emergency[j]){
                    idx++;
                }
            }
            answer[i] = idx;
        }
        return answer;
    }
}

📌 특징

  • 각 요소에 대해 다른 모든 요소와 비교하여 순위를 매김.
  • 시간 복잡도: O(N²) (이중 반복문)

🔴 단점

  • 성능이 좋지 않음
    → emergency의 길이가 길어질수록 성능이 급격히 저하됨.
  • 불필요한 if(answer[i] != 0) 검사가 포함됨 (이 코드에서는 불필요한 로직).
  • 가독성이 떨어짐 (배열을 두 번 순회하여 index를 계산하는 것이 직관적이지 않음).

2️⃣ 두 번째 코드 분석 (정렬 + 해시맵)

import java.util.*;

class Solution {
    public int[] solution(int[] emergency) {
        int[] arr = emergency.clone();
        Arrays.sort(arr);
        Map<Integer, Integer> indexMap = new HashMap<>();
        for(int i = 0; i < arr.length; i++) {
            indexMap.put(arr[i], arr.length - i);
        }
        int[] answer = new int[emergency.length];
        for(int i = 0; i < emergency.length; i++) {
            answer[i] = indexMap.get(emergency[i]);
        }
        return answer;
    }
}

📌 특징

  • emergency 배열을 정렬하여 작은 값부터 큰 값까지 순위를 매김.
  • 해시맵(indexMap)을 이용하여 각 값의 순위를 빠르게 찾음.
  • 시간 복잡도: O(N log N) (정렬) + O(N) (해시맵 탐색) = O(N log N)

✅ 장점

  • 성능이 훨씬 좋음
    → O(N²) 대신 O(N log N)으로 성능 개선.
  • 가독성이 높음
    → 정렬 후 순위를 매기는 과정이 명확하고 직관적.
  • 불필요한 연산을 줄임
    → 중복 비교 없이 한 번의 정렬과 해시맵 탐색만으로 순위 결정.

3️⃣ 세 번째 코드 분석 (정렬)

import java.util.Arrays;

class solution {
    public int[] solution(int[] emergency) {
        int n = emergency.length;
        int[] answer = new int[n];
        Integer[] sortedIndex = new Integer[n];
        for( int i = 0; i < n; i++ ) {
            sortedIndex[i] = i;
        }

        Arrays.sort(sortedIndex, (i1, i2)-> emergency[i2] - emergency[i1]);

        for( int i = 0; i < n; i++ ) {
            answer[sortedIndex[i]] = i + 1;
        }

        return answer;
    }
}

📌 특징

  • emergency 배열의 Index를 정렬.
  • 정렬된 Index를 활용하여 각 값의 Rank를 매김.
  • 시간 복잡도: O(N) (Index 적용) + O(N log N) (정렬) + O(N) (answer작성) = O(N log N)

✅ 장점

  • 성능이 훨씬 좋음
    → O(N²) 대신 O(N log N)으로 성능 개선.
  • 가독성이 높음
    → 정렬 후 순위를 매기는 과정이 명확하고 직관적.

4️⃣ 네 번째 코드 분석 (우선순위 큐)

import java.util.Comparator;
import java.util.PriorityQueue;

public class Solution {
	public int[] solution(int[] emergency) {
        int n = emergency.length;
        int[] answer = new int[n];
        PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(o -> o[0]));
        for( int i = 0; i < n; i++ ) {
            pq.add(new int[]{emergency[i], i});
        }

        for( int i = 0; i < n; i++ ) {
            answer[pq.poll()[1]] = n - i;
        }

        return answer;
    }
}

📌 특징

  • 우선순위 큐를 활용하여 정렬된 데이터를 사용.
  • 시간 복잡도: O(log n) ( PriorityQueue 요소 추가) + O(N log N) ( PriorityQueue 요소 추출) = O(N log N)

🔴 단점

  • 우선순위 큐 사용시 큐 값을 꺼내는 데 추가적인 시간 오버헤드 발생

🏆 결론: 세 번째 코드가 더 낫다!

  • 성능: O(N log N) vs O(N²) → 두번째와 세번째 코드가 훨씬 빠름.
  • 가독성: 정렬된 배열을 활용함으로써 가독성 향상
  • 유지보수성: 코드가 짧고 이해하기 쉬움.
728x90
728x90

Java에서 제네릭과 메서드 오버로딩이 함께 사용될 때, 우리가 기대했던 것과는 다른 결과가 나오는 경우가 있습니다.

 

이번 글에서는 아래 코드에서 왜 B0가 출력되는지 심층적으로 분석해 보겠습니다.


코드 분석

class Main {
  public static class Collection<T> {
    T value;

    public Collection(T t) {
        value = t;
    }

    public void print() {
       new Printer().print(value);
    }

    class Printer {
      void print(Integer a) {
        System.out.print("A" + a);
      }
      void print(Object a) {
        System.out.print("B" + a);
      }
      void print(Number a) {
        System.out.print("C" + a);
      }
    }
  }
  
  public static void main(String[] args) {
      new Collection<>(0).print();
  }
}

위 코드를 실행하면 B0가 출력됩니다.

하지만 우리는 print(Integer a) 또는 print(Number a)가 호출될 것이라고 예상할 수도 있습니다.

 

그 이유를 하나씩 살펴보겠습니다.


1. 제네릭 타입 결정

new Collection<>(0);

위 코드에서 Collection <T> Collection <T>의 타입 매개변수 TInteger로 추론됩니다.

따라서 Collection <Integer> Collection <Integer>가 됩니다.

 

즉, value의 타입은 Integer가 됩니다.

그런데 print() 메서드를 호출하면 다음 메소드가 작동하게 됩니다.

new Printer().print(value);

 

이때, valueInteger 타입이므로 print(Integer a), print(Object a), print(Number a) 중 하나가 호출될 것입니다.


2. 오버로딩과 정적 바인딩

Java의 메서드 오버로딩컴파일 타임에 정적으로 결정됩니다.

즉, 컴파일러는 print(value)가 호출될 때, value컴파일 시점 타입을 기준으로 가장 적절한 메서드를 선택합니다.

 

하지만 중요한 점은, 제네릭에서 T는 컴파일 시점에는 그냥 타입 매개변수일뿐이며, 구체적인 타입으로 변환되지 않는다는 것입니다.

 

즉, TInteger이지만, 컴파일러는 Type을 Object로 간주하여 처리합니다.

결국, print(value);는 아래와 동일하게 해석됩니다.

new Printer().print((Object) value);

따라서 가장 적합한 메서드는 print(Object a)가 되고, 결과적으로 "B0"가 출력됩니다.


3. 왜 print(Integer a)print(Number a)가 호출되지 않을까?

제네릭 코드에서 타입이 특정 타입으로 지정되었더라도, 컴파일 타임에는 T 자체가 Object로 간주됩니다.

즉, T를 포함한 연산에서는 TObject 타입으로 처리되는 경우가 많습니다.

 

아래 코드로 비교해 보겠습니다.

일반적인 경우:

class Test {
    void print(Integer a) { System.out.print("A" + a); }
    void print(Object a) { System.out.print("B" + a); }
    void print(Number a) { System.out.print("C" + a); }

    public static void main(String[] args) {
        Test test = new Test();
        test.print(0); // "A0" 출력
    }
}

위 코드에서는 0Integer 타입이므로 print(Integer a)가 정확히 호출됩니다.

제네릭을 사용한 경우:

class Collection<T> {
    T value;
    public Collection(T t) { value = t; }
    void print() { new Printer().print(value); }
    
    class Printer {
        void print(Integer a) { System.out.print("A" + a); }
        void print(Object a) { System.out.print("B" + a); }
        void print(Number a) { System.out.print("C" + a); }
    }
}
public class Main {
    public static void main(String[] args) {
        new Collection<>(0).print(); // "B0" 출력
    }
}

여기서 TInteger이지만, 컴파일 타임에는 Object로 간주되므로 print(Object a)가 호출됩니다.


4. 해결 방법

만약 print(Integer a)가 호출되길 원한다면, 다음과 같이 해결할 수 있습니다.

명시적 캐스팅 사용

void print() {
    new Printer().print((Integer) value);
}

위처럼 명시적으로 Integer로 캐스팅하면, print(Integer a)가 호출됩니다.

타입 상한 경계 설정

class Collection<T extends Integer> {
    T value;
    public Collection(T t) { value = t; }
    void print() { new Printer().print(value); }
    
    class Printer {
        void print(Integer a) { System.out.print("A" + a); }
        void print(Object a) { System.out.print("B" + a); }
        void print(Number a) { System.out.print("C" + a); }
    }
}
public class Main {
    public static void main(String[] args) {
        new Collection<>(0).print(); // "A0" 출력
    }
}

이처럼 T extends Integer를 사용하면 T는 최소한 Integer로 간주되므로 print(Integer a)가 호출됩니다.


정리

  1. 제네릭의 타입은 컴파일 타임에 Object로 간주된다.
  2. 오버로딩된 메서드는 컴파일 시점에 정적으로 결정된다.
  3. 명시적 캐스팅 또는 타입 상한 경계 설정을 사용하면 원하는 메서드를 호출할 수 있다.

정처기 실기를 준비 중에 만난 문제에서 그동안 놓쳤던 부분을 알게 되어 작성하게 되었습니다.

 

당연히 Integer를 호출할 것이라 생각하고, Type을 찍어도 Integer를 출력하지만 답은 "B0"가 나오는 상황이 답답하여 정리하게 되었는데요.

이처럼 놓친 내용들이 있는지 확인하며 기본기를 탄탄히 다 저나 갈 생각입니다.

728x90
728x90

Java에서 멀티스레드 환경을 개발하다 보면, 스레드마다 독립적인 데이터를 저장하고 관리해야 할 때가 있습니다.

이럴 때 유용하게 활용할 수 있는 클래스가 바로 ThreadLocal입니다.

 

이번 글에서는 ThreadLocal의 개념, 사용 방법, 그리고 실제 사례를 중심으로 살펴보겠습니다.

ThreadLocal이란?

ThreadLocal은 각 스레드마다 별도의 변수를 저장할 수 있도록 지원하는 Java 클래스입니다.

 

동일한 ThreadLocal 인스턴스를 사용하더라도, 각 스레드가 자신만의 독립적인 값을 가지며, 다른 스레드와 공유되지 않습니다.

즉, 하나의 변수를 스레드 간에 안전하게 관리할 수 있는 메커니즘을 제공합니다.

ThreadLocal의 특징

  1. 각 스레드마다 독립적인 데이터 저장 공간을 제공합니다.
  2. 스레드 간 데이터 공유를 방지하며, 멀티스레드 환경에서 데이터 충돌을 줄입니다.
  3. 특정 스레드에 한정된 데이터를 저장할 때 유용합니다.

ThreadLocal의 사용 방법

ThreadLocal은 주로 다음과 같은 메소드를 사용하여 데이터를 저장하고 접근합니다.

set()

현재 스레드의 ThreadLocal 변수에 값을 저장합니다.

ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("Thread A's Data");

get()

현재 스레드의 ThreadLocal 변수에 저장된 값을 가져옵니다.

String data = threadLocal.get();
System.out.println(data);

remove()

현재 스레드의 ThreadLocal 변수에 저장된 값을 삭제합니다. 이는 메모리 누수를 방지하기 위해 권장되는 작업입니다.

threadLocal.remove();

initialValue()

ThreadLocal 변수를 초기화할 때 사용됩니다. 기본적으로 null을 반환하지만, 필요하면 오버라이딩하여 초기값을 설정할 수 있습니다.

ThreadLocal<Integer> threadLocal = new ThreadLocal<>() {
    @Override
    protected Integer initialValue() {
        return 0;
    }
};
System.out.println(threadLocal.get());

사용 시 주의사항

ThreadLocal은 사용 후 반드시 remove() 메소드를 호출하여 데이터를 정리해야 합니다.

이를 소홀히 하면, 스레드가 종료된 후에도 ThreadLocal 객체에 데이터가 남아 메모리 누수가 발생할 수 있습니다.

 

또한, 너무 많은 데이터를 ThreadLocal에 저장할 경우 OOM을 야기할 수 있으니 적절하게 사용하는 것을 권장합니다.

 

ThreadLocal은 부모 Thread가 자식 Thread로 데이터를 전달하지 않습니다.

따라서 데이터를 상속하기 위해서는 ThreadLocal를 상속한 InheritableThreadLocal를 사용하시면 됩니다.


사용해 보기

ThreadLocal 정의

@Getter
@Setter
public class CustomContext {
    private static final ThreadLocal<CustomContext> CONTEXT = ThreadLocal.withInitial(CustomContext::new);

    private Object data;

    public static CustomContext getContext() {
        return CONTEXT.get();
    }

    public static void clearContext() {
        CONTEXT.remove();
    }
}

JWT Token 유저 ID ThreadLocal에 저장 및 사용 후 정리

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomContext customContext = CustomContext.getContext();;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        if(!(handler instanceof HandlerMethod))
            return true;

        String token = request.getHeader("authorization");
        if(Strings.isEmpty(token)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT token is missing");
            return false;
        }

        token = token.substring(7);
        if(!jwtTokenProvider.validateToken(token)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT Token is invalid");
            return false;
        }
        String userId = jwtTokenProvider.getUserId(token);
        customContext.setData(userId);

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        CustomContext.clearContext();
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }
}

ThreadLocal 데이터 사용

@Getter
@Setter
public class CreateQuoteRequest {
    transient private String userId;
    private long amount;
    private CurrencyCode targetCurrency;

    public CreateQuoteRequest() {
        CustomContext context = CustomContext.getContext();
        Assert.notNull(context);
        this.userId = (String) context.getData();
    }
}

후기

기존 ID를 넘겨줄 때 Token에서 뽑아 HttpServletRequest에 담거나 하여 사용을 하였지만,

이와 같이 ThreadLocal을 사용하게 된다면 본인 Thread 내에서 데이터를 쉽게 뽑아 사용할 수 있다는 점이 매력적으로 다가온 기술입니다.

 

물론 메모리에 올려두고 사용한다는 점에서 비효율적일 수 있지만, 코드를 생각했을 때 좀 더 보기 편하고 관리하기 편하다는 점이 부정할 수 없다 생각하는데요.

 

추후 좀 더 관리하기 좋은 방법을 찾게 된다면 포스팅하도록 하겠습니다. 

728x90

+ Recent posts