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
728x90

최근 아키텍처 공부를 하면서 흔히 접할 수 있는 Clean ArchitectureHexagonal Architecture를 만나게 되었습니다.

 

여러 블로그들과 글들을 읽으면서 Clean Architecture와 Hexagonal Architecture의 다른 점이 무엇인지 점점 모호해지는 것을 느꼈습니다.

 

왜냐면 두 아키텍처에서는 Entity를 중심에 두고 그다음 UseCase를 통해 로직을 정의 한 다음 그다음 DIP를 적용하여 의존성을 내부 Core로 의존관계를 형성하면서, 외부의 변경 ( API변경, DB변경 등 )으로부터 내부 구현체들은 안전하다는 장점을 띄고 있기 때문입니다.

 

그렇다면 두 아키텍처는 어떤 점에서 차이를 보이고 있는지 알아보도록 하겠습니다.


Clean Architecture - Robert C. Martin

클린 아키텍처를 한 번이라도 보았다면 많이 익숙한 그림일 것입니다.

해당 그림은 의존관계를 내부로 의존하도록 하는 것을 가시화하여 보여주고 있습니다.

 

그림과 같은 의존관계를 형성한다면 파란색 원 즉 UI, Web, DB와 같은 외부 시스템 변경이 있다 하더라도 Application내부에는 영향이 없는 관계를 형성할 수 있습니다.

 

즉, 우리가 보호하려 하는 도메인의 정책 및 비즈니스 규칙들이 보호된다는 강점을 가질 수 있습니다.

 

클린 아키텍처에서는 다음과 같은 특징을 가집니다.

  • 프레임워크에 독립적이다.
  • 비즈니스 로직들 즉 내부 로직들은 외부 통신 없이 테스트 가능하다.
  • 내부 영향 없이 쉽게 외부 시스템을 변경할 수 있다.
  • 데이터베이스 종류와 관계없이 변경할 수 있다.
  • 외부 시스템에서는 Application 내부의 일에 대해 알 수 없다.

이러한 특징들은 외부와 분리된 흔히 모듈화 된 Application를 만들 수 있다는 점이 있습니다.

 

그럼 클린 아키텍처에서는 어떤 점을 중요하게 보아야 할까? 바로 종속성 규칙을 가장 중요하게 보면 될 거 같습니다.

종속성 규칙이 유효하다면 위 그림에 존재하는 원의 개수가 달라진다 하더라도 클린 아키텍처를 따르고 있다고 볼 수 있습니다.

 

다만, 종속성 규칙을 위배한다면 클린 아키텍처를 온전히 적용하였다고 보기는 어려울 것 같습니다.


Hexagonal Architecture - Alistair Cockburn

해당 아키텍처는 2005년에 처음 소개하면서 "Ports and Adapters"아키텍처라고 불렸습니다.

처음 소개 된 이름을 보면 감이 오시겠지만 핵사고 날 아키텍처에서 가장 중요한 개념은 Ports와 Adapters입니다.

 

Ports는 시스템이 외부와 통신하는 인터페이스를 정의하며,

AdaptersPorts와 외부 시스템 간의 실제 구현을 담당합니다.

 

즉, 이 또한 DIP를 활용하여 Application에서 외부에 의존하지 않고, Ports라는 Interface를 제공함으로써 Application이 하나의 모듈화 되는 아키텍처입니다.

 

그렇다면 왜 이 아키텍처 이름은 "Hexagonal Architecture"라 불리게 되었는지 의문이 들게 됩니다.

왜 "Hexagonal"인가?

  1. 다양한 외부 인터페이스 표현
    육각형은 여러 방향에서 포트를 연결할 수 있는 모양을 상징합니다. 이는 애플리케이션이 다양한 외부 시스템과 상호작용할 수 있다는 점을 강조합니다.
  2. 대칭성
    육각형의 대칭성은 애플리케이션이 외부 시스템과의 관계에서 특정 방향에 종속되지 않고, 내부 도메인 로직이 외부 의존성에서 독립적이어야 한다는 철학을 나타냅니다.
  3. 시각적 단순화
    Cockburn은 육각형이 포트와 어댑터를 배치하는 데 직관적인 도형이라고 보았습니다. 이 도형은 외부 시스템과 내부 도메인 간의 관계를 명확히 표현할 수 있습니다.

위와 같은 이유로 "Hexagonal Architecture"로 불리고 있습니다.

 

그럼 위 그림을 보면 Adapters두 육각형을 연결하는 역할 즉, 실질적인 구현체라는 것은 알겠지만,

Ports는 어디에 있는 걸까요?

 

그림을 자세히 보면 내부 육각형의 면이 굵은 것을 확인할 수 있습니다.

바로 이 육각형의 면이 외부 시스템과 연결을 위해 제공되는 Interface 즉, Ports입니다.

 

Hexagonal Architecture는 추후 Clean Architecture와 Onion architecture의 철학에 영향을 미치게 됩니다.


Clean Architecture와 Hexagonal Architecture의 차이

두 아키텍처는 기본적으로 같은 목표를 가진 설계 철학을 가지고 있어 혼동하기 쉽습니다.

 

그게 그거 아닌가!라고 생각 할 수 있지만, 딱 잡고 말하자면

Clean Architecture는 애플리케이션을 여러 계층으로 나누고, 의존성 방향을 바깥에서 안쪽으로 향하는 아키텍처이고,

Hexagonal ArchitecturePortsAdapters를 통해 외부 시스템과 내부 시스템을 분리하여 의존 방향이 Application 내부로 향하는 아키텍처입니다.

 

 즉, 두 아키텍처는 유지보수성과 확장성을 높이고, Core를 외부 시스템으로부터 분리하는데 초점을 맞춘다는 공통점이 존재합니다.

 

그럼 어떤 아키텍처를 사용하는 것이 좋을까요?

 

만약 애플리케이션의 종속성 규칙을 엄격하게 지키고 싶다면 Clean Architecture를,

외부 시스템과 내부 시스템의 엄격한 분리를 원한다면 Hexagonal Architecture를,

만약 둘 다 엄격하게 가져가고 싶다면 섞어 사용하면 될 것 같습니다.

 

결국 우리가 이야기하는 아키텍처는 하나의 시스템을 설계하는 방법일 뿐 상황에 맞춰 최선의 방법으로 설계하는 것이 가장 중요하다 생각합니다.

 

물론, 다른 개발자들을 설득하는 것은 본인의 몫이라는 것은 비밀입니다. 


Reference

Clean Architecture

Hexagonal Architecture

 

728x90

'Server' 카테고리의 다른 글

[EDA] EDA는 왜 적용하게 된걸까?  (0) 2025.04.03
[MSA] 왜 MSA로 가야하나요?  (0) 2025.04.03
[DNS] 너의 주소는?  (6) 2024.12.30
[ JPA ] OSIV가 뭔가요?  (1) 2024.11.12
URL로 어떻게 사이트를 접속할 수 있을까?  (10) 2024.10.23
728x90

컴퓨터 프로그래밍에서 실수 연산은 흔히 오차 문제를 동반합니다.

 

특히 금융, 과학 계산, 그리고 데이터 분석 등 정확한 수치가 중요한 분야에서는 이 문제가 더욱 부각되는 문제인데요.

이번 글에서는 Double의 한계, Float를 사용한 실수 연산의 특징, 그리고 BigDecimal을 활용한 오차 없는 실수 연산 방법을 살펴보겠습니다.


1. Double의 문제: 부동소수점 연산의 한계

Double은 대부분의 프로그래밍 언어에서 기본 실수형으로 사용됩니다.

 

이는 64비트 IEEE 754 표준을 따르며, 빠른 연산 속도와 넓은 범위의 숫자를 처리할 수 있는 장점이 있습니다.

하지만 정확도 면에서는 한계가 있습니다.

public class DoubleExample {
    public static void main(String[] args) {
        double a = 0.1;
        double b = 0.2;
        double c = a + b;

        System.out.println("0.1 + 0.2 = " + c);
    }
}

출력 결과.

0.1 + 0.2 = 0.30000000000000004

왜 이런 일이 발생할까요?

  • Double은 2진수 기반의 부동소수점 표현 방식을 사용합니다.
  • 10진수 0.1과 0.2를 정확히 표현할 수 없기 때문에, 이진수로 변환될 때 근삿값으로 저장됩니다.
  • 이로 인해 연산 결과에도 미세한 오차가 발생합니다.

2. Float 사용: 더 작은 범위, 더 큰 오차

Float는 Double보다 작은 32비트 부동소수점 자료형입니다.

메모리 사용량이 적고 연산 속도가 빠르지만, 표현할 수 있는 유효 숫자 범위가 좁습니다.

public class FloatExample {
    public static void main(String[] args) {
        float a = 0.1f;
        float b = 0.2f;
        float c = a + b;

        System.out.println("0.1f + 0.2f = " + c);
    }
}

출력 결과.

0.1f + 0.2f = 0.3

겉보기에는 정확해 보이지만, 실제로는 내부에서 근삿값을 저장하고 있어 더 작은 숫자 범위에서 더 큰 오차가 발생할 가능성이 높습니다.

Float의 주요 특징

  • 적합한 경우: 높은 정밀도가 요구되지 않는 경우 (예: 게임 개발, 그래픽 연산).
  • 부적합한 경우: 금융 계산이나 통계 연산 등 오차가 치명적인 경우.

3. BigDecimal 사용: 실수 연산의 정밀도 보장

BigDecimal은 Java에서 제공하는 클래스 중 하나로, 정확한 실수 연산을 보장하기 위해 사용됩니다.

이 클래스는 실수를 10진법 기반으로 처리하므로, 부동소수점의 근삿값 문제를 해결할 수 있습니다.

import java.math.BigDecimal;

public class BigDecimalExample {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal("0.1");
        BigDecimal b = new BigDecimal("0.2");
        BigDecimal c = a.add(b);

        System.out.println("0.1 + 0.2 = " + c);
    }
}

출력 결과.

0.1 + 0.2 = 0.3

중요 사항

  • new BigDecimal(double)로 생성할 경우 부동 소수점의 오차가 그대로 발생할 위험성이 존재합니다.
  • "scale()"메서드로 소수 자릿수를 설정하여 계산 결과를 정리할 수 있습니다.

그럼 뭘 사용해야 할까?

실수 연산은 문제 되는 상황을 인지하고 있지 않다면 쉽게 간과할 수 있는 중요한 이슈입니다.

 

특히 환율, 이율 등과 같이 금전적인 부분이 연결되어 있다면 이는 서비스의 신뢰도를 낮추게 되는 매우 크리티컬 한 문제가 될 수 있습니다.

 

그렇기 때문에 위에 주어진 Double, Float, BigDecimal들을 잘 활용하여 적재적소에 사용해야 하는 것은 중요한 기본이라 생각합니다.

728x90
728x90

여러분, 혹시 친구와 나눈 대화를 기억하지 못해서 당황했던 적 있으신가요?

 

"어? 네가 그 얘기 했었어? 난 기억이 안 나는데..."

 

이런 상황이 발생하면 뭔가 민망하지만, 상대는 이런 대화를 Stateless한 태도로 받아들일 수 있을지도 모릅니다.

 

오늘은 이런 "기억하지 않는 대화"와 비슷한 Stateless, 그리고 그와 연관된 Connectionless에 대해 이야기해볼까 합니다.


Stateless란 무엇인가요?

Stateless는 말 그대로 "상태를 기억하지 않는 것"입니다.
쉽게 말해, 여러분이 음식점에서 주문할 때를 떠올려봅시다.

  1. 여러분: "김치찌개 주세요!"
  2. 직원: "네"
  3. 직원: "누구 김치찌개 시켰나요?"
  4. 여러분: "저요!"

직원은 여러분의 상태를 기억하지 않습니다.
다시 말해, 여러분이 누구인지, 이전에 뭘 주문했는지에 대한 정보는 그때그때 잊혀지는 거죠.
HTTP도 딱 이와 같습니다.

HTTP는 요청과 응답 사이에 상태를 저장하지 않습니다.
즉, Stateless한 프로토콜입니다.


Connectionless란 무엇인가요?

Connectionless는 연결을 유지하지 않는다는 의미입니다.
마치 음식점에서 음식을 주문하고 나면, 직원이 테이블 옆에 서서 대기하지 않는 것과 비슷하죠.

  1. 여러분: "김치찌개 주세요!"
  2. 직원: "네" (메모한 뒤 떠남)

직원이 계속 옆에 서 있으면 더 빨리 요구를 들어줄 수도 있겠지만,
그렇게 하면 다른 손님들에게 서비스를 제공하지 못하게 되는 문제가 생깁니다.

HTTP의 Connectionless도 마찬가지입니다.

클라이언트(브라우저)와 서버는 요청을 보낸 뒤 연결을 끊고, 다음 요청 때 다시 연결을 시작합니다.


HTTP는 왜 Stateless를 채택했을까요?

"왜 기억을 안 하지?"라고 궁금할 수 있습니다.
그 이유는 간단합니다: 확장성과 효율성 때문이죠.

  • 확장성:
    상태를 기억하지 않으면 서버는 요청이 올 때마다 독립적으로 처리할 수 있습니다.
    많은 사용자가 동시에 접속해도, 서로의 상태를 신경 쓰지 않아도 되니 시스템이 더 단순해지고 확장성이 높아집니다.
  • 효율성:
    상태를 저장하려면 많은 메모리와 자원이 필요합니다.
    기억해야 할 정보가 많아질수록 서버는 더 느려지겠죠?
    하지만 Stateless구조라면 이런 부담을 줄일 수 있습니다.

여기서드는 의문!

 

"확장성과 효율성을 위해 Stateless하고, Connectionless하면 요청 할 때마다 연결을 끊고 다시 맺을텐데,

성능의 문제가 생길 수 있지 않을까요?"

 

이를 해결하기 위해 "Keep-Alive"가 등장하게 됩니다.


HTTP의 Keep-Alive: "계속 얘기하자!"

음식점 상황을 다시 생각해 봅시다.

  • 기본 HTTP 방식:
    직원이 주문을 받을 때마다 떠났다가 다시 돌아옵니다.
  • HTTP Keep-Alive 방식:
    "저 김치찌개 말고 밥도 추가요!"
    직원이 "네, 계속 말씀하세요!"라고 하며 테이블을 계속 오갑니다.

Keep-Alive는 여러 요청을 처리할 때 연결을 유지한 채 대화를 이어가도록 해줍니다.
그 덕분에 매번 연결을 새로 맺을 필요가 없어 성능이 개선됩니다.


TCP의 Keep-Alive와 HTTP의 Keep-Alive의 차이

HTTP에서의 Keep-Alive와 TCP의 Keep-Alive는 어떤 차이가 있는지 알아보겠습니다.

 

이제 조금 더 깊이 들어가서, TCP Keep-AliveHTTP Keep-Alive의 차이를 알아봅시다.

  1. TCP Keep-Alive:
    TCP 레벨에서 네트워크 연결이 끊어졌는지 확인하는 작은 패킷을 주기적으로 보내는 기능입니다.
    즉, "연결이 살아 있나?"를 체크합니다.
  2. HTTP Keep-Alive:
    HTTP 요청과 응답을 처리할 때 연결을 유지하는 기능입니다.
    "새로운 연결을 맺지 말고 기존 연결을 재사용하자"는 뜻입니다.

비유하자면,

  • TCP Keep-Alive는 친구가 계속 살아있는지 확인하는 안부 전화이고,
  • HTTP Keep-Alive는 한 번에 많은 대화를 효율적으로 끝내는 방법입니다.

 

이러한 기능들은 우리가 사용하는 HTTP프로토콜에서 간단하게 확인해 볼 수 있는데요.

 

HTTP 1.1버전 기준으로 Connection속성keep-alive가 Default로 설정되어있고,

이에 대해 keep-Alive는 60초Timeout을 가지게 됩니다.


Reference

https://developer.mozilla.org/en-US/docs/Web/HTTP/Overview

https://en.wikipedia.org/wiki/HTTP_persistent_connection

https://blog.naver.com/whdgml1996/222153047879

 

728x90

+ Recent posts