문제

EC2에 서버를 업로드하고 실행하는 과정에서 port 22: Operation timed out 이 발생하며 EC2 멈춤현상을 발견했습니다.

 

 

원인

EC2 프리티어의 RAM 용량은 1GB 정도라 메모리가 부족해 서버가 멈춰버리는 것이 원인이었습니다.

이 원인으로 SSH 접속이 불가능해져 인스턴스를 중지시키고 재시작해야하했었습니다.

 

 

해결

구글 검색을 해보니 SWAP 가상 메모리를 사용하여 메모리를 늘려줄 수 있다고 해서 찾아봤습니다.

이 가상 메모리는 메모리가 부족할 때 디스크 공간을 가져와서 메모리처럼 사용할 수 있게 해준다고합니다.

따라서 이 기능을 사용해 메모리부족 현상을 해결할 수 있을것 같았습니다.

 

 

 

과정

이미 EC2가 작동이 불가능하다면 먼저 인스턴스를 중지시키고 다시 실행시켜 ssh 접속을 하고 아래와 같이 진행해줍니다.

 

 

1. SWAP 메모리 추가

sudo dd if=/dev/zero of=/swapMem bs=128M count=16

2GB의 SWAP 메모리를 생성해줍니다.

 

 

2. 권한 부여

sudo chmod 600 /swapMem

 

 

3. SWAP 메모리를 SWAP 파일로 포맷

sudo mkswap /swapMem

 

 

4. SWAP 메모리 활성화

sudo swapon /swapMem

 

 

5. SWAP 메모리 자동 활성화

sudo vi /etc/fstab

해당 명령어를 작성하면 파일 편집에 들어갑니다.

  1. 'A' 를 눌러 파일 편집을 시작합니다.
  2. 작성되어있는 마지막 행 밑에 /swapfile swap swap defaults 0 0 를 추가로 작성합니다.
  3. ESC를 누르면 가장 밑에 INSERT 라고 표시됩니다.
  4. 콜론(:) 을 누르고 wq + 엔터를 누르면 파일 편집이 완성됩니다.

 

6. 메모리 확인

sudo free -h

2GB로 증가했다면 성공입니다.

 

EC2에서 서버를 다시 실행시키면 정상 작동 되는것을 확인할 수 있습니다.

그리디 알고리즘

그리디 알고리즘은 '탐욕법'이라고도 불리는데 여기에선 그리디 알고리즘으로 사용하겠습니다.

그리디 알고리즘은 해를 구하는 과정에서 각 단계마다 '가장 최선'의 최적해를 선택해 나가면서 결과적으로 전체적인 최적의 해를 구하려는 알고리즘입니다.

하지만 각 단계마다 최적의 해를 구한다고해서 전체의 최적해를 만들지 못할 수도 있습니다. 즉 전체적인 최적의 해를 보장하지 않는다는 말입니다.

 

예를들어, S에서 시작해서 E에 도착할때까지의 수의 합이 가장 높은 노선을 구해봅시다.

 

S에서 시작해서 E까지의 노선중에서 수의 합이 가장 높은 노선은 S - 3 - 8 - E 이고 합은 11이라는것을 바로 알 수 있을 것입니다.

하지만 그리디 알고리즘을 적용해보면

 

1, 3, 5 중에 가장 높은 5를 선택

 

 

3, 2 중에 가장 높은 3을 선택

 

 

 

결과

 

S - 5 - 3 - E 라는 결과가 나왔습니다. 각 노선을 선택할 때마다 가장 높은 수를 선택했지만 최적해인 11에 미치지 못하는 8이 되었습니다. 따라서 그리디 알고리즘은 현재에서 가장 최선의 값을 선택하지만 그것이 항상 최적의 값을 보장하지 않는 알고리즘입니다.

 

 

 

그리디 알고리즘 예제

대표적인 문제로는 거스름돈 문제가 있습니다. 거스름돈에 사용되는 동전의 개수를 최소로 하는 문제입니다.

 

    public static void main(String[] args) {

        Integer[] coins = {100, 50, 10, 500}; // 동전
        int money = 780; // 거스름돈
        int count = 0; // 동전 사용 개수

        // 사용할 동전을 액수가 큰 순서대로 정렬
        Arrays.sort(coins, Comparator.reverseOrder());

        // 차례대로 거스름돈에서 차감
        for (int coin : coins) {
            count += money / coin;
            money %= coin;
        }

        // 결과 출력
        if (money == 0) {
            System.out.println("결과 count = " + count);
        }

    }

 

Collection List

Collection중에 가장 많이 사용하는 것은 아마도 List일것입니다. List에는 여러 구현체가 있습니다.

ArrayList, LinkedList, Vector 가 있는데 오늘 알아볼것은 ArrayList와 LinkedList입니다.

 

최적화를 알아보기 전에 ArrayList와 LinkedList에 대해서 간략하게 공부하고 넘어가겠습니다.

 

 

ArrayList

  • 내부 배열에 객체를 저장해서 INDEX로 관리합니다.
  • 객체를 추가하는경우 자동적으로 저장 용량이 늘어납니다.
  • INDEX를 중간에 추가 또는 삭제할 경우 해당 INDEX 바로 뒤 부터 마지막 INDEX까지 모두 한칸 이동시키고 해당 INDEX에 객체를 추가 또는 삭제시킵니다.

 

 

 

LinkedList

  • 각 객체마다 노드를 연결하여 관리합니다.
  • 객체를 추가 또는 삭제할 때 노드의 연결위치를 변경해 추가 또는 삭제합니다.

 

 

 

 

InitialCapacity

List를 많이 다루어봤어도 아마 InitialCapacity는 생소할 수도 있습니다.

ArrayList의 생성자

보통 기본생성자나 리스트를 copy할 목적으로 사용했지만 사실 ArrayList에는 initialCapacity라는 값도 받고있었습니다.

 

위에 ArrayList의 특징에 객체를 추가하는경우 자동적으로 저장 용량이 늘어납니다. 라고 적어놓았었는데 여기에는 initialCapacity가 관여합니다.

 

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA; // = {};
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

 

initialCapacity를 설정한다는 것은 ArrayList의 배열의 길이를 설정한다는 것과 같습니다.

다르게말하면 객체를 추가할 때 저장용량을 늘리는 작업을 하지 않게되면 속도가 더 빨라진다는 말이죠.

add 할 때 capacity를 초과하면  용량 확장

initialCapacity의 기본값은 10입니다. ArrayList의 길이가 10이 넘어가면 저장용량을 확장하게되는데, List에 저장할 데이터의 수를 이미 알고있다면 initialCapacity를 설정하는것만으로 속도를 향상 시킬 수 있습니다.

 

 

 

속도 테스트 전 테스트 환경

    private static int LOOP = 200;

    public static void speed(String methodTitle, CallbackMethod callback) {

        int avg = 0;
        for (int i = 0; i < LOOP; i++) {

            long startTime = System.currentTimeMillis();

            callback.execute();

            long endTime = System.currentTimeMillis();

            int totalTime= (int) (endTime - startTime);

            avg += totalTime;
        }

        avg /= LOOP;

        System.out.printf("%s Method Average Speed : %dms \n", methodTitle, avg);
    }

 

평균값을 얻기위해 200번 callback (테스트 메서드가 들어올 callback함수) 메서드를 실행해서 최종 평균값을 확인해보겠습니다.

속도비교

private static final int LIST_SIZE = 1000000; // 1_000_000

    public static void main(String[] args) {

        OptimalTest.speed("ArrayList", ListTest::arrayListTest);
        OptimalTest.speed("InitialCapacity ArrayList", ListTest::initialCapacityTest);

//        ArrayList Method Average Speed : 60ms
//        InitialCapacity ArrayList Method Average Speed : 46ms
//        속도 차이 : 14ms
    }
    
    private static void arrayListTest() {
        List<String> test = new ArrayList<>();
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }
    private static void initialCapacityTest() {
        List<String> test = new ArrayList<>(LIST_SIZE);
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }

 

실제로 capacity 값을 미리 설정해준다면 유의미한 속도향상을 확인할 수 있었습니다.

 

ArrayList vs LinkedList

어느정도 예상하시겠지만 ArrayList와 LinkedList의 속도도 비교해보았습니다.

 

    private static final int LIST_SIZE = 1000000; // 1_000_000

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList", ListTest::arrayListTest);
        OptimalTest.speed("InitialCapacity ArrayList", ListTest::initialCapacityTest);
        OptimalTest.speed("LinkedList", ListTest::linkedListTest);
//        ArrayList Method Average Speed : 63ms 
//        InitialCapacity ArrayList Method Average Speed : 47ms
//        LinkedList Method, Speed : 42ms
//        속도 차이 : 22ms
    }

    private static void arrayListTest() {
        ArrayList<String> test = new ArrayList<>();
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }
    private static void initialCapacityTest() {
        ArrayList<String> test = new ArrayList<>(LIST_SIZE);
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }
    private static void linkedListTest() {
        LinkedList<String> test = new LinkedList<>();
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }

 

 

저장용량을 늘릴 필요가 없는 LinkedList가 가장 빨랐고,

저장용량을 설정한 ArrayList가 두번째,

저장용량을 설정하지 않은 ArrayList가 가장 느렸습니다.

 

 

 

Remove

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList", () -> arrayListRemove(arraylist));
        OptimalTest.speed("LinkedList", () -> linkedListRemove(linkedlist));
//        ArrayList Remove Method Average Speed : 146ms
//        LinkedList Remove Method Average Speed : 0ms
    }
    
    private static void arrayListRemove(List<String> arraylist) {
        for (int i = 0; i < 1000; i++) {
             arraylist.remove(0);
        }
    }

    private static void linkedListRemove(List<String> linkedlist) {
        for (int i = 0; i < 1000; i++) {
            linkedlist.remove(0);
        }
    }

200번의 테스트때문에 1000번씩 0번 INDEX를 Remove 한 결과입니다.

 

노드의 경로만 변경해주면되는 LinkedList와는 다르게 ArrayList는 0번 INDEX를 제외한 나머지 배열을 한칸 앞으로 이동해야하기 때문에 속도 차이가 많이 벌어진것입니다.

 

Add

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList Add", () -> arrayListRemove(arraylist));
        OptimalTest.speed("LinkedList Add", () -> linkedListRemove(linkedlist));
//        ArrayList Add Method Average Speed : 277ms 
//        LinkedList Add Method Average Speed : 0ms 
    }
    
    private static void arrayListRemove(ArrayList<String> arraylist) {
        for (int i = 0; i < 1000; i++) {
             arraylist.add(0, String.valueOf(i));
        }
    }

    private static void linkedListRemove(LinkedList<String> linkedlist) {
        for (int i = 0; i < 1000; i++) {
            linkedlist.add(0, String.valueOf(i));
        }
    }

 

0번 INDEX에 Add도 마찬가지였습니다.

여기서 중요한건 가장 뒤에 add하는 것은 LinkedList와 ArrayList와의 차이가 나지않을정도로 작습니다.

 

 

모든 결과를 보았을 때 전체적으로 LinkedList가 훨씬 좋아보입니다. 그렇다면 이제 ArrayList를 사용하지 않고 LinkedList만 사용해야할 까요?

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList Get", () -> arrayListRemove(arraylist));
        OptimalTest.speed("LinkedList Get", () -> linkedListRemove(linkedlist));
//        ArrayList Get Method Average Speed : 0ms 
//        LinkedList Get Method Average Speed : 712ms 
    }
    
    private static void arrayListGet(ArrayList<String> arraylist) {
        for (int i = 0; i < LIST_SIZE; i++) {
            arraylist.get(LIST_SIZE / 2);
        }
    }

    private static void linkedListGet(LinkedList<String> linkedlist) {
        for (int i = 0; i < 100; i++) {
            linkedlist.get(LIST_SIZE / 2);
        }
    }

 

INDEX로 값을 가져오는부분에서는 ArrayList가 압도적으로 빠릅니다. 그 이유는 ArrayList는 배열의 INDEX로 값을 가져오기 때문에 O(1)의 속도로 바로 값을 가져오지만 LinkedList는 head부터 해당 index가 나올때까지 값을 검색하기 때문에 최대 O(n)의 속도를 가지기 때문입니다.

실제로 i를 100만번 수행시켰지만 도저히 끝날 기미가 보이지 않아 linkedListGet 메서드의 i를 100으로 설정시켜서 테스트했습니다.

ArrayList는 100만번 수행시켜도 0ms인 값이 변하지 않았습니다.

 

 

결론

  • ArrayList를 사용할 때, List에 들어갈 데이터의 수를 알고있다면 initialCapacity를 설정하자
  • 데이터의 수의 대략적인 수를 알고있다면 initialCapacity를 넉넉하게 설정해도 좋다. 정확하게 설정하는것보다는 느리지만 기본생성자로 설정하는것보다 유의미한 속도향상이 있었다. ( LIST_SIZE = 100만일때,  LIST_SIZE + LIST_SIZE 의 크기로 설정해도 5ms 내외의 차이정도밖에 확인되지 않았다)
  • INDEX를 자주사용하는 메서드에서는 무조건 ArrayList를 사용하자.
  • List 내에서 추가, 삭제가 빈번히 발생한다면 LinkedList를 사용하자.

 

 

# 혼자 공부하고 작성한 글이기 때문에 정확하지 않을 수 있습니다. 대략적인 가이드? 같은것이기 때문에 참고용도로만 사용해주세요.

다대다 관계

데이터베이스를 설계에서 다대다(N:N)관계는 피하는것은 일반적으로 권장되고있습니다. 하지만 자세한 설명이 추가되지않는다면 그 이유가 헷갈릴 수 있는데요. 이는 데이터의 무결성, 효율성, 유지보수 등 다양한 측면에서 중요한 이유가 있어 권장하지 않는것입니다.

 

 

 

다대다 관계란?

다대다 관계란 하나의 엔티티(Entity)가 다수의 다른 엔티티와 관계를 맺고 있는 상황을 나타냅니다. 예를들어, 학생과 강좌간의 관계에서 한 학생이 여러 강좌를 수강하고, 한 강좌에는 여러 학생이 수강할 수 있는것이 다대다 관계입니다.

 

 

다대다 관계는 왜 피해야할까?

위에 연관관계를 통해 완성된 테이블을 만들어보겠습니다.

 

1. 데이터 중복성과 무결성 문제

위 테이블에 기본키(PK)를 포함해서 데이터의 중복이 발생했고,  데이터의 무결성이 손상되었습니다. 이로 인해 학번만으로 데이터를 구분할 수 없어졌습니다. 

 

 

2. 유지보수 및 확장성

데이터의 추가 또는 변경이 일어난다면 중복된 데이터를 모두 수정 및 추가해야합니다. 예를 들어, 홍길동 학생의 학과가 '수학과'로 변경된다면 2군데 모두 수정해야하고, 학생 테이블에 성별을 추가한다면 중복데이터를 추가해야하는 일이 발생합니다. 

 

 

 

 

결론

학생과 과목테이블 사이에 수강테이블을 만들어 다대다 관계를 일대다(1:N), 다대일(N:1) 관계로 풀어주는 것으로 해결 할 수 있다.

 

 

다음 글을 읽어보는걸 추천드립니다.

 

 

다대다(N:N) 관계를 조금 더 쉽게 풀어보자 (2진법의 활용)

이전 글 다대다(N:N) 관계는 왜 피해야할까?다대다 관계 데이터베이스를 설계에서 다대다(N:N)관계는 피하는것은 일반적으로 권장되고있습니다. 하지만 자세한 설명이 추가되지않는다면 그 이유

tmd8633.tistory.com

 

'일반 > DB' 카테고리의 다른 글

[DB] Lock 이해하기  (0) 2025.01.15
다대다(N:N) 관계를 조금 더 쉽게 풀어보자 (2진법의 활용)  (0) 2024.05.18

싱글톤 패턴

싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴하게됩니다. 주로 공통된 객체를 여러개 생성해서 사용하는 DBCP(DataBase Connection Pool) 과 같은 상황에서 많이 사용합니다. 

 

사용하는 주된 이유는 리소스를 많이 차지하는 무거운 클래스를 한 개의 객체로 관리하면서 메모리 절약을 할 수 있기 때문입니다.

 

 

싱글톤 패턴 구현

public class Singleton {

    private static Singleton instance;

    private Singleton() { }

    public static Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

public static void main(String[] args) {

    Singleton instance1 = Singleton.getInstance();
    Singleton instance2 = Singleton.getInstance();
    Singleton instance3 = Singleton.getInstance();

    System.out.println(instance1); // Singleton@27d6c5e0
    System.out.println(instance2); // Singleton@27d6c5e0
    System.out.println(instance3); // Singleton@27d6c5e0

    System.out.println(instance1 == instance2 && instance2 == instance3); // true
}

 

싱글톤 패턴 구현은 어렵지 않습니다.

생성자의 접근제한자를 private로 선언해서 new 생성자를 통한 객체생성을 제한하고, static 을 붙혀주면됩니다.

세번의 getInstance()로 불러와도 같은 주소를 참조하고있다는걸 확인할 수 있습니다.

다만 이 방식은 멀티쓰레드 환경에서 안전하지 않습니다. if 문에 다수의 사람이 동시에 들어간다면 객체라 여러번 생성될것입니다.

 

이러한 싱글톤 패턴의 종류는 여러가지가 있습니다. 검증된 싱글톤 패턴을 확인하시려면 6번7번으로 넘어가주세요.

 

 

 

1. Eager Initialization

public class Singleton {

    private static final Singleton instance = new Singleton();

    private Singleton() { }

    public static Singleton getInstance() {
        return instance;
    }
}

 

미리 만들어주는 방법입니다. static 영역에 재할당이 불가능한 final 이기때문에 멀티쓰레드 환경에서 안전합니다. 다만 예외처리를 할 수 없고, 리소스가 크다면 static 영억에서 자원을 사용하지 않더라도 메모리에 적재하기 때문에 자원낭비가 될 수 있습니다.

 

 

 

 

2. Static block Initialization

public class Singleton {

    private static Singleton instance;

    private Singleton() { }
    
    static {
        try {
            instance = new Singleton();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static Singleton getInstance() {
        return instance;
    }
}

 

static block ( 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행된느 블록)

 

static block을 통해 예외를 처리할 수 있습니다.

 

 

 

 

3. Lazy Initialization

public class Singleton {

    private static Singleton instance;

    private Singleton() { }

    public static Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

 

처음 싱글톤 패턴을 구현했던 방식입니다. 

 

 

 

 

 

4. Thread Safe Initialization

public class Singleton {

    private static Singleton instance;

    private Singleton() { }

    public static synchronized Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

 

synchronized 키워드를 사용하여 쓰레드의 접근을 한개로 제한시켜 동기화시킬 수 있습니다. 하지만 매번 사용하는것은 성능하락이 될 수 있습니다.

 

 

 

 

5. Double Checked Locking

public class Singleton {

    private static volatile Singleton instance;

    private Singleton() { }

    public static Singleton getInstance() {

        if (instance == null) {

            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }

        }
        return instance;
    }
}

 

volatile 키워드를 사용해서 캐시가 아닌 메인메모리에서 읽도록 지정해줍니다. 

synchronized를 클래스에 동기화를 걸어 최초 초기화만 동기화 작업을 진행하여 리소스 낭비를 최소화 할 수 있습니다.

하지만 volatile 은 JVM 1.5이상이 되어야하고 JVM에 따라서 쓰레드 세이프하지 않는 문제가 발생할 수 있기때문에 사용하지 않는다고 합니다.

 

 

 

 

6. Bill Pugh Solution (LazyHolder)

public class Singleton {

    private Singleton() { }

    private static class SingleInstanceHolder {
        public static final Singleton INSTANCE = new Singleton();
        
    }
    public static Singleton getInstance() {
        return SingleInstanceHolder.INSTANCE;
    }
}

 

클래스 내부에 static 클래스를 만들어 사용합니다. 클래스가 메모리에 로드되지 않고 getInstance 메서드가 호출되어야 로드됩니다. 이 방식은 JVM의 클래스 로더 매커니즘과 클래스가 로드되는 시점을 이용한 방법이라고 합니다.

하지만 Reflection API, 직렬화 / 역직렬화를 통해 클라이언트가 임의로 싱글톤을 파괴할 수 있다는 단점이 존재합니다.

 

 

Reflection API 우회

    public static void main(String[] args) {

        Singleton singleton = Singleton.getInstance();
        System.out.println(singleton); // Singleton@4f3f5b24
        
        try {
            Class<?> singletonClass = Class.forName("blog.design.싱글톤패턴.Singleton");
            Constructor<?> constructor = singletonClass.getDeclaredConstructor();
            constructor.setAccessible(true);
            Singleton newSingleton = (Singleton) constructor.newInstance();
            
            System.out.println(newSingleton); // Singleton@15aeb7ab
            System.out.println(singleton == newSingleton); // false
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        
    }

 

private 생성자를 우회하고 private 필드에 접근할 수 있습니다.

 

 

직렬화 / 역직렬화 우회

public class Singleton implements Serializable { ... }

public static void main(String[] args) {

    Singleton instance = Singleton.getInstance();
    System.out.println(instance); // Singleton@4f3f5b24
    
    // 직렬화
    try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("blog.design.싱글톤패턴.Singleton"))) {
        out.writeObject(instance);
    } catch (IOException e) {
        e.printStackTrace();
    }

    // 역직렬화
    try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("blog.design.싱글톤패턴.Singleton"))) {
        Singleton newInstance = (Singleton) in.readObject();
        
        System.out.println(newInstance); // Singleton@4c98385c
        System.out.println(instance == newInstance); // false
    } catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }

}

 

직렬화 / 역직렬화를 사용하는 경우 readResolve 메서드를 사용하여 싱글톤을 유지할 수 있습니다.

 

 

public class Singleton implements Serializable {

    private Singleton() { }


    private static class SingleInstanceHolder {
        public static final Singleton INSTANCE = new Singleton();

    }
    public static Singleton getInstance() {
        return SingleInstanceHolder.INSTANCE;
    }
    
    private Object readResolve() {
        return SingleInstanceHolder.INSTANCE;
    }
    
}

 

public static void main(String[] args) {

    Singleton instance = Singleton.getInstance();
    System.out.println(instance); // Singleton@4f3f5b24
    
    // 직렬화
    try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("blog.design.싱글톤패턴.Singleton"))) {
        out.writeObject(instance);
        
    } catch (IOException e) {
        e.printStackTrace();
    }

    // 역직렬화
    try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("blog.design.싱글톤패턴.Singleton"))) {
        Singleton newInstance = (Singleton) in.readObject();

        System.out.println(newInstance); // Singleton@4f3f5b24
        System.out.println(instance == newInstance); // true
    } catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }

}

 

readResolve 메서드는 Serializable 인터페이스를 구현한 클래스에서 제공할 수 있는 메서드로 역직렬화시에 호출됩니다.

이 메서드를 사용하여 역직렬화된 객체 대신에 기존의 인스턴스를 반환하도록 지정하면 싱글톤 패턴을 사용할 수 있습니다.

 

 

 

 

 

7. Enum

enum SingletonEnum {
    INSTANCE;

    private final Client client;
	
    SingletonEnum() {
        dbClient = Database.getClient();
    }

    public static SingletonEnum getInstance() {
        return INSTANCE;
    }

    public Client getClient() {
        return client;
    }
}

public class Main {
    public static void main(String[] args) {
        SingletonEnum singleton = SingletonEnum.getInstance();
        singleton.getClient();
    }
}

 

enum은 thread safe하고 상수뿐만 아니라 변수나 메서드를 싱글톤클래스처럼 사용이 가능합니다.

위의 Bill Pugh Solution과 달리 Reflection API 우회에도 안전합니다. 또한 위에 직렬화 / 역직렬화에서 readResolve() 메서드를 구현한것과 달리 enum은 그 과전이 불필요하고 JVM에서 Serialization을 보장해준다고합니다.

 

 

 

싱글톤의 문제

  1. 싱글톤의 전역상태로 인해 코드의 복잡성이 증가할 수 있고 디버깅이 어려워진다.
  2. 테스트 시 mock 객체나 다른 객체로 대체하기 어려워 테스트 작성을 어렵게한다.
  3. 싱글톤의 의존성때문에 클래스간의 결합도를 높힐 수 잇고 유연성을 감소시킬 수 있다.
  4. SOLID 원칙에 위배될 소지가 있다.

직렬화와 역직렬화

  • 직렬화(Serialization) : 컴퓨터 과학의 데이터 스토리지 문맥에서 데이터 구조오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정
  • 역직렬화(Deserialization) : 반대로 일련의 바이트로부터 데이터 구조를 추출하는 과정

 

 

직렬화를 사용하는 이유

자바에서 원시타입 bytem short, int, long, float, double, boolean, char가 있습니다. 이를 제외한 객체들은 모두 주소값을 참조하는 참조타입입니다.

 

원시타입은 Stack영역에 값을 갖고있어 직접 접근이 가능하지만. 참조타입은 Heap에 존재하고 메모리 주소를 참조하고 있어 프로그램이 종료되거나 가비지컬렉터에 의해 Heap 영역에서 데이터가 제거된다면 데이터도 함께 사라집니다. 따라서 이 주소값을 외부에 전송했다고 하더라도 실제 메모리 주소에는 객체가 존재하지 않을 수 있다는것이죠.

 

따라서 참조타입의 객체들을 원시값 형식으로 데이터를 변환하는 과정을 거쳐 전달해야만 합니다.

 

 

직렬화의 형식

  형식 특징
텍스트 기반 형식 CSV, JSON, XML 사람이 읽을수 있는 형태
저장 공간의 효율성이 떨어지고, 파싱하는데 오래걸림
데이터의 양이 적을 때 주로 사용
최근에는 JSON형태로 직렬화를 많이함
모든 시스템에서 사용 가능
이진(Binary) 형식 CBOR, BSON, LEB128, MessagePack, Pickle, Protocol Buffers 사람이 읽을 수 없는 형태
저장 공간의 효율성이 좋고, 파싱이 빠름
데이터의 양이 많을 때 주로 사용
모든 시스템에서 사용 가능
자바 직렬화   Java 시스템 간의 데이터 교환이 필요할 때 사용

 

자바에서의 직렬화와 역직렬화

 

1. Serializable 인터페이스 구현

public class Person implements Serializable {

    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

}

 

 

2. 직렬화 구현

public static void main(String[] args) {

        Person person = new Person("김OO", 20);

        byte[] serialized;

        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            oos.writeObject(person);

            // serialized -> 직렬화된 Person 객체
            serialized = baos.toByteArray();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        // 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
        System.out.println(Base64.getEncoder().encodeToString(serialized));
        
        // 출력 결과
        // rO0ABXNyAB/sp4HroKztmZTsmYDsl63sp4HroKztmZQuUGVyc29uc1NTeAAdcbkCAAJJAANhZ2VMAARuYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7eHAAAAAUdAAF6rmAT08=

    }

 

 

 

3. 역직렬화 구현

역직렬화의 조건은 자바 직렬화 대상 객체가 동일한 serialVersionID를 가지고 있어야합니다.

 

    public static void main(String[] args) {

        byte[] serialized = { -84, -19, 0, 5, 115, 114, 0, 31, -20, -89, ... 생략};

        try {
            ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
            ObjectInputStream ois = new ObjectInputStream(bais);
            
            // 역직렬화된 Person 객체를 가져온다
            Person newObject = (Person) ois.readObject();
            System.out.println("newObject = " + newObject);
            // 출력결과
            // newObject = Person { name = '김OO', age = 20 }
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }

    }

 

 

만약 특정 필드의 값을 직렬화하고 싶지 않다면 transient 키워드를 붙히면됩니다.

public class Person implements Serializable {

    private transient String name;
    private int age;

	// 이하 생략
}

// 해당객체의 역직렬화 결과
// Person { name = null, age = 20 }

 

 

 

직렬화의 주의점

 

1. 객체의 멤버변수가 추가되었을 때

// Caused by: java.io.InvalidClassException: Person; local class incompatible: stream classdesc serialVersionUID = 8310077512291807673, local class serialVersionUID = 2986034573208750977

InvalidClassException 예외가 발생합니다. serialVersionUID가 일치하지 않는다는 뜻입니다.

멤버변수의 추가로인해 serialVersionUID가 새로운 값을 가지면서 이전 serialVersionUID 값과 일치하지 않아 생긴 예외입니다.

 

public class Person implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return String.format("Person { name = '%s', age = %d }", name, age);
    }
}

 

serialVersionUID를 명시해주면 멤버 변수를 추가해도 에러가 발생하지 않습니다. 그리고 기존에 있던 멤버변수를 삭제해도 에러가 발생하지 않습니다.

 

 

2. 객체의 멤버변수의 타입이 변경되었을 때

public class Person implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;
    private String name;
    private long age;

    public Person(String name, long age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return String.format("Person { name = '%s', age = %d }", name, age);
    }
}

// java.io.InvalidClassException: Person; incompatible types for field age

age의 타입을 int -> long 으로 변경한다면 InvalidClassException 이 발생합니다.

 

 

결론

자바 직렬화는 장점이 많지만 단점도 많습니다. 이 단점을 보완하기 힘든 형태로 되어있기 때문에 제약이 많아 사용시에 주의가 필요합니다.

 

  1. 오랫동안 외부에 저장하는 의미 있는 데이터는 직렬화하지 않는다.
  2. 역직렬화시 반드시 예외가 생긴다는것을 염두한다.
  3. 자주 변경되는 비즈니스적인 데이터는 자바 직렬화를 사용하지 않는다.
  4. 긴 만료시간을 가지는 데이터는 JSON 등 다른 포맷을 사용하여 저장한다.

 

 

참고

1. 로컬 저장소의 .git 디렉토리 삭제

rm -rf ./.git

 

 

2. git 초기화

git init

 

 

3. git commit

git add .
git commit -m "first commit"

 

 

4. 원격 저장소 연결

git remote add origin <원격 저장소 URI>

 

 

5. 원격 저장소에 push

git push --force --set-upstream origin main

프로토타입 패턴

프로토타입(Prototype) 패턴은 객체를 생성하는 비용이 높은 경우, 기존 객체를 복제하여 새로운 객체를 생성하는 패턴입니다.

다시 말해 기존의 객체를 새로운 객체로 복사하여 우리의 필요에 따라 수정하는 메커니즘을 제공합니다.

 

이 프로토타입 패턴은 객체를 생성하는 데 비용이 많이 들고, 이미 유사한 객체가 존재하는 경우에 사용됩니다. DB로부터 가져온 데이터를 수차례 수정해야하는 경우 new 라는 키워드를 통해 객체를 생성하는 것은 비용이 너무 많이 들기 때문에 한 번 DB에 접근하여 객체를 가져오고 필요에 따라 새로운 객체에 복사하여 데이터를 수정하는 것이 더 좋은 방법입니다.

 

 

 

 

 

 

 

 

프로토타입 패턴 구현

Java에서는 이 패턴을 구현하기 위해 Cloneable 인터페이스를 사용합니다. Cloneable 인터페이스는 마커(marker) 인터페이스로, 별도의 메소드가 정의되어 있지 않습니다. 이 인터페이스를 구현하는 클래스는 얕은 복사 (shallow copy) 를 지원한다는 표시입니다.

 

Cloneable 인터페이스를 구현하는 이유는 clone() 메소드를 호출하여 객체를 복제하기 위함입니다. clone() 메소드는 Object 클래스에서 상속받은 메소드 중 하나로, 복제 가능한 객체에서 호출할 수 있습니다. 그러나 Cloneable을 구현하지 않은 객체가 clone()을 호출하게 되면, CloneNotSupportedException이 발생합니다.

 

public class Cookie{

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

public class Main {

    public static void main(String[] args) throws CloneNotSupportedException {

        Cookie cookie = new Cookie();
        cookie.clone(); // CloneNotSupportedException 발생!

    }
}

 

따라서 Cloneable 인터페이스를 구현함으로써 해당 클래스의 인스턴스가 복제 가능하다는 것을 나타내고, 이를 통해 프로토타입 패턴을 적용할 수 있게 됩니다. 하지만 주의할 점은 clone() 메소드는 얕은 복사를 수행하므로, 필요에 따라서 깊은 복사(Deep Copy)를 구현해야 할 수 있습니다.

 

 

 

얕은 복사

얕은 복사 (Shallow Copy)는 객체의 필드들을 복제할 때, 참조 타입의 필드는 원본 객체와 복제된 객체가 같은 인스턴스를 참조합니다.

 

public class ShallowCopyExample implements Cloneable {
    private int value;
    private int[] array;

    public ShallowCopyExample(int value, int[] array) {
        this.value = value;
        this.array = array;
    }

    @Override
    public ShallowCopyExample clone() throws CloneNotSupportedException {
        return (ShallowCopyExample) super.clone();
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        int[] originalArray = {1, 2, 3};
        ShallowCopyExample original = new ShallowCopyExample(42, originalArray);

        ShallowCopyExample cloned = original.clone();

        // 값과 배열은 복제되지만 배열은 같은 배열을 참조하고 있음
        System.out.println(original == cloned); // false
        System.out.println(original.array == cloned.array); // true (얕은 복사)
    }
}

 

 

깊은 복사

깊은 복사 (Deep Copy) 는 참조 타입의 필드도 새로운 인스턴스를 생성하여 복사하게 됩니다. 즉 모든 필드 변수들이 새로운 인스턴스를 가집니다.

 

public class DeepCopyExample implements Cloneable {
    private int value;
    private int[] array;

    public DeepCopyExample(int value, int[] array) {
        this.value = value;
        this.array = array.clone(); // 깊은 복사: 배열의 복사본을 생성하여 참조
    }

    @Override
    public DeepCopyExample clone() throws CloneNotSupportedException {
        return (DeepCopyExample) super.clone();
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        int[] originalArray = {1, 2, 3};
        DeepCopyExample original = new DeepCopyExample(42, originalArray);

        DeepCopyExample cloned = original.clone();

        // 값은 복제되고 배열은 복제본을 참조하고 있음
        System.out.println(original == cloned); // false
        System.out.println(original.array == cloned.array); // false (깊은 복사)
    }
}

 

 

 

프로토타입 패턴 구현 예제

public class Employee implements Cloneable {

    private String name;
    private int age;

    public Employee(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    protected Employee clone() {
        try {
            return (Employee) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException(e);
        }
    }
}

public class Employees {

    private final List<Employee> employeeList;

    public Employees(List<Employee> employeeList) {
        this.employeeList = employeeList;
    }

    public List<Employee> getCloneList() {
        List<Employee> clone = new ArrayList<>();

        for (Employee employee : employeeList) {
            Employee cloneEmployee = employee.clone();
            clone.add(cloneEmployee);
        }

        return clone;
    }
    
    public List<Employee> getOriginalList() {
        return employeeList;
    }
}

 

Cloneable를 사용하는 Employee 에 clone() 메소드를 오버라이드 했고, 그 리스트를 담은 Employees 에 비교를 위해 cloneList와 originalList 메소드를 구현했습니다.

 

public class Main {

    public static void main(String[] args) throws CloneNotSupportedException {

        Employee emp1 = new Employee("김OO", 20);
        Employee emp2 = new Employee("이OO", 22);
        Employee emp3 = new Employee("박OO", 25);
        Employee emp4 = new Employee("오OO", 29);
        Employee emp5 = new Employee("나OO", 23);

        List<Employee> employeeList = List.of(emp1, emp2, emp3, emp4, emp5);

        Employees employees = new Employees(employeeList);

        List<Employee> originalList = employees.getOriginalList(); // 같은 인스턴스
        List<Employee> cloneList = employees.getCloneList(); // clone 인스턴스

        System.out.println(employeeList == originalList); // true
        System.out.println(employeeList == cloneList); // false

        Employee originalEmp = employeeList.get(0); // 같은 인스턴스의 첫번째 Employee
        Employee cloneEmp = cloneList.get(0); // clone 인스턴스의 첫번째 Employee

        System.out.println(originalEmp == cloneEmp); // false

    }
}

 

 

프로토타입 패턴의 장/단점

프로토타입 패턴은 객체를 복제하여 새로운 객체를 생성하는 패턴으로, 이를 통해 객체 생성의 비용을 줄일 수 있습니다. 하지만 프로토타입 패턴은 특정 상황에서 유용하게 사용될 수 있지만, 모든 상황에서 적합하지는 않습니다. 

 

 

장점

  1. 객체 생성 비용 감소 : 객체를 복제함으로써 새로운 객체를 생성할 때의 비용을 줄일 수 있습니다. 특히 복잡한 객체 구조이거나 생성에 많은 리소스가 필요한 경우에 유용합니다.
  2. 동적 객체 생성 : 런타임에서 동적으로 객체를 생성할 수 있습니다. 사용자가 필요에 따라 객체를 생성하고 조작할 수 있습니다.
  3. 객체 생성 시간 단축 : 객체를 복제하여 생성하기 때문에 클래스의 초기화나 설정 작업을 생략할 수 있어 객체 생성 시간을 단축할 수 있습니다.

 

 

단점

  1. Cloneable 인터페이스의 한계 : 프로토타입 패턴을 구현하기 위해서는 Cloneable 인터페이스를 필연적으로 사용하게 되는데 이 인터페이스는 마커(marker) 인터페이스이고, clone() 메소드는 얕은 복사만을 수행하기 때문에 깊은 복사를 수행하려면 추가적인 작업이 필요합니다.
  2. 복제과정의 복잡성 : 객체가 복잡한 구조를 가지거나 참조하는 객체들이 많다면, 이를 적절하게 복제하려면 복잡한 복제 과정이 필요할 수 있습니다. 특히 객체 그래프가 순환이 발생하는 경우에 복제 과정이 더욱 복잡해질 수 있습니다.
  3. 메모리 사용량 증가 : 객체를 복제하여 생성하면 메모리 사용량이 증가할 수 있습니다. 특히 객체가 큰 데이터를 가지고 있거나 복제 해야 할 객체 수가 많을 수록 사용량이 증가합니다.

 

 

프로토타입 패턴의 사용여부는 특정 상황과 요구사항에 따라 주의 깊게 고려하여 사용해야합니다.

객체 지향 설계 SOLID 원칙

두문자 약어 이름 개념
S SRP 단일 책임 원칙
Single Responsibility Principle
한 클래스는 하나의 책임을 가져야 한다.
O OCP 개방-폐쇄 원칙
Open-Closed Principle
소프트웨어 개체(클래스, 모듈 등)는 확장에는 열려있으나, 수정에는 닫혀 있어야 한다.
L LSP 리스코프 치환 원칙
Liskov Substitution Principle
객체의 정확성을 깨뜨리지 않으면서 상위클래스의 객체를 하위클래스의 객체로 바꿀 수 있어야한다.
I ISP 인터페이스 분리 원칙
Interface Segregation Principle
범용적인 인터페이스보다 클라이언트를 위한 인터페이스 여러개가 더 낫다.
D DIP 의존관계 역전 원칙
Dependency Inversion Principle
추상화에 의존해야지, 구체화에 의존하면 안된다.

 

 

 

의존관계 역전 원칙

DIP 원칙이란 사용자는 Class를 직접 참조하는것이 아니라 그 Class의 추상클래스 또는 인터페이스를 참조해야한다는 원칙입니다. 이 원칙을 따르면, 상위 계층이 하위 계층에 의존하는 의존관계를 역전(반전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있습니다.

 

  1. 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
  2. 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

 

 

 

 

1. DIP 원칙 위반

public class Robot {

}

public class RacingCar {
    
}

public class Game {
    
}

public class Kid {

    private Robot robot;

    public void setRobot(Robot robot) {
        this.robot = robot;
    }
    
    public void getToyType() {
        System.out.println(robot.toString());
    }
}

 

Kid 클래스에는 한가지의 장난감이 들어가야합니다. 하지만 장난감은 Robot만 존재하지 않습니다. 만약 장난감을 변경해야한다면 Kid(사용자) 클래스를 수정해야합니다.

 

즉 이 코드는 하위 모듈을 의존하고 있습니다.

 

 

 

2. DIP 원칙 적용

public interface Toy { ... }

public class Robot implements Toy { ... }

public class RacingCar implements Toy { ... }

public class Game implements Toy { ... }


public class Kid {

    private Toy toy;

    public void setToy(Toy toy) {
        this.toy = toy;
    }

    public void getToyType() {
        System.out.println(toy.toString());
    }
}

 

Kid 클래스가 Toy 인터페이스를 의존하도록 한다면 Kid 클래스의 변경 없이 OCP 원칙 또한 지키게 되었습니다.

객체 지향 설계 SOLID 원칙

두문자 약어 이름 개념
S SRP 단일 책임 원칙
Single Responsibility Principle
한 클래스는 하나의 책임을 가져야 한다.
O OCP 개방-폐쇄 원칙
Open-Closed Principle
소프트웨어 개체(클래스, 모듈 등)는 확장에는 열려있으나, 수정에는 닫혀 있어야 한다.
L LSP 리스코프 치환 원칙
Liskov Substitution Principle
객체의 정확성을 깨뜨리지 않으면서 상위클래스의 객체를 하위클래스의 객체로 바꿀 수 있어야한다.
I ISP 인터페이스 분리 원칙
Interface Segregation Principle
범용적인 인터페이스보다 클라이언트를 위한 인터페이스 여러개가 더 낫다.
D DIP 의존관계 역전 원칙
Dependency Inversion Principle
추상화에 의존해야지, 구체화에 의존하면 안된다.

 

 

 

인터페이스 분리 원칙

ISP 원칙이란 범용적인 인터페이스보다 사용자가 실제로 사용하는 Interface를 만들어야한다는 의미입니다.

즉, 자신이 이용하지 않는 메소드에 의존하지 않아야하는 원칙인데요. 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 사용자들이 꼭 필요한 메소드들만 이용할 수 있게 하는 것이 중요합니다.

 

 

 

 

 

인터페이스 분리 원칙의 예

 

 

1. ISP 위반 예제

public interface Phone {

    void call(String number);
    void message(String number, String text);
    void app();
    void camera();

}

public class IPhone15 implements Phone{

    @Override
    public void call(String number) {

    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

 

스마트폰을 추상화했습니다. 스마트폰에는 많은 기능들이 포함되어있습니다.

여기서 아이폰을 구현한다면 모든 기능들을 오버라이드 하면 문제가 없어보입니다.

 

만약 버전이 올라가면서 기능이 추가되거나

이전 버전의 핸드폰을 추가할때 지원하지 않는 기능은 어떻게 될까요?

public class FolderPhone implements Phone{

    @Override
    public void call(String number) {
        
    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void app() {
        throw new NotSupportedException();
    }

    @Override
    public void camera() {

    }
}

public class IPhone20 implements Phone{

    @Override
    public void call(String number) {

    }

    @Override
    public void message(String number, String text) {
        throw new NotSupportedException();
    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

 

이전 버전의 폴더폰은 app 기능이 없어서 예외를 던지고, 이후 버전이 올라가면서 더이상 message 기능을 사용하지 않는다면 이후 기능들에서 모든 message는 예외를 던지게됩니다.

 

2. ISP 적용

public interface Phone {

    void call(String number);
}

public interface Message {

    void message(String number, String text);
}

public interface App {

    void app();
}

public interface Camera {

    void camera();
}

 

각각의 기능별로 인터페이스를 분리했습니다.

 

 

public class FolderPhone implements Phone, Message, Camera{

    @Override
    public void call(String number) {
        
    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void camera() {

    }
}

public class IPhoneXR implements Phone, Message, App, Camera{

    @Override
    public void call(String number) {

    }

    @Override
    public void message(String number, String text) {

    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

public class IPhone20 implements Phone, App, Camera{

    @Override
    public void call(String number) {

    }

    @Override
    public void app() {

    }

    @Override
    public void camera() {

    }
}

 

작은 단위로 인터페이스가 분리되어 지원되는 기능만을 구현할 수 있게되었습니다.

 

 

 

+ Recent posts