브릿지패턴

브릿지패턴은 객체의 확장성을 높히기 위한 디자인패턴입니다. 객체를 구현클래스와 추상클래스로 분리하고 추상클래스에서 확장해가는 패턴입니다. 오늘은 브릿지 패턴에 대해서 최대한 쉽게 알아보겠습니다.

 

 

 

 

 

예제

 

리모콘과 디바이스의 관계를 보겠습니다.

리모콘은 어떤 디바이스의 종류가 오든 상관없습니다. 그저 전원을 키거나 끄는 역활을 할 뿐입니다.

디바이스는 어떤 리모콘이 오든 상관없습니다. 신호가 오면 전원을 키거나 끄도록 하면 됩니다.

 

여기서 리모콘은 디바이스를 의존하고있어야 어떤 디바이스에 신호를 보낼 지 결정할 수 있습니다. 따라서 리모콘은 디바이스를 의존해야합니다.

리모콘이 디바이스를 의존하는 것을 bridge(다리) 로 표현한 것이 바로 브릿지패턴입니다.

 

 

 

Remote

public abstract class Remote {

    protected final Device device;

    public Remote(Device device) {
        this.device = device;
    }

    public abstract void power();

}

 

public class BasicRemote extends Remote {

    public BasicRemote(Device device) {
        super(device);
    }

    @Override
    public void power() {
        if (device.isOn()) {
            device.off();
        } else {
            device.on();
        }
    }
}

public class SmartRemote extends Remote {

    public SmartRemote(Device device) {
        super(device);
    }

    @Override
    public void power() {
        if (device.isOn()) {
            device.off();
        } else {
            device.on();
        }
    }

    public void runApp(String app) {
        device.runApp(app);
    }
}

 

 

Device

public interface Device {

    void on();
    void off();
    boolean isOn();
    void runApp(String app);

}

 

public class TV implements Device {

    private boolean on = false;

    @Override
    public void on() {
        on = true;
        System.out.println("TV is on");
    }

    @Override
    public void off() {
        on = false;
        System.out.println("TV is off");
    }

    @Override
    public boolean isOn() {
        return on;
    }

    @Override
    public void runApp(String app) {
        if (isOn()) {
            System.out.println("TV Run App " + app);
        } else {
            System.out.println("TV is not On");
        }
    }
}

public class Radio implements Device {

    private boolean on = false;
    @Override
    public void on() {
        on = true;
        System.out.println("Radio is on");
    }

    @Override
    public void off() {
        on = false;
        System.out.println("Radio is off");
    }

    @Override
    public boolean isOn() {
        return on;
    }

    @Override
    public void runApp(String app) {

    }

}

public class Computer implements Device {

    private boolean on = false;

    @Override
    public void on() {
        on = true;
        System.out.println("Computer is on");
    }

    @Override
    public void off() {
        on = false;
        System.out.println("Computer is off");
    }

    @Override
    public boolean isOn() {
        return on;
    }

    @Override
    public void runApp(String app) {
        if (isOn()) {
            System.out.println("Computer run app : " + app);
        } else {
            System.out.println("Computer is not On");
        }
    }
}

 

 

실행결과

    public static void main(String[] args) {
        Device radio = new Radio();
        Remote basicRemote = new BasicRemote(radio);
        basicRemote.power();

        Device tv = new TV();
        SmartRemote smartRemote = new SmartRemote(tv);
        smartRemote.power();
        smartRemote.runApp("YouTube");

        smartRemote.power();
        smartRemote.runApp("Netflix");

    }
Radio is on
TV is on
TV Run App YouTube
TV is off
TV is not On

 

 

브릿지 패턴은 추상클래스와 구현클래스를 분리할 수 있어서 독립적인 확장이 가능합니다. 그리고 런타임시점에 구현체를 변경할 수 있다는 장점이 있습니다. 다만 이 브릿지패턴도 객체지향언어가 모두 그렇듯 분리하면 할수록 코드 복잡성이 증가하는 단점이 있습니다.

개요

if-else 와 switch문은 성능 상 약간의 차이가 있습니다. 오늘은 그 구조를 이해해보고 언제 사용해야 좋을지 바이트코드를 알아보도록 하겠습니다.

 

 

if

int i = 1;

if (i == 1) {
    method1();
} else if (i == 3) {
    method2();
} else {
    method3();
}

 

int i의 값이 1일때 method1()을 실행하고, i의 값이 3일때 method2()를 실행하고, 나머지는 method3()을 실행하는 아주 간단한 구조를 가진 if 문입니다. 이 코드를 bytecode로 변환해보겠습니다.

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    ICONST_1
    IF_ICMPNE L2
   L3
    LINENUMBER 10 L3
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L4
   L2
    LINENUMBER 11 L2
   FRAME APPEND [I]
    ILOAD 1
    ICONST_3
    IF_ICMPNE L5
   L6
    LINENUMBER 12 L6
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L4
   L5
    LINENUMBER 14 L5
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L4
    LINENUMBER 17 L4
   FRAME SAME
    RETURN
   L7
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE i I L1 L7 1
    MAXSTACK = 2
    MAXLOCALS = 2

 

구분을 좀 나누면 이렇게 됩니다.

구조를 잘 보시면

  1. L1에서 조건이 일치하면 L3으로 이동하여 method1을 실행하고, 일치하지 않으면 IF_ICMPNE L2 로 이동합니다.
  2. L2에서 조건이 일치하면 L6으로 이동하여 method2를 실행하고, 일치하지 않으면 IF_ICMPNE L5 로 이동합니다.
  3. else 에서 method3을 실행합니다.

if 문은 결과가 true 가 나오기전까지 순서대로 비교연산을 수행해야합니다. 만약 비교문이 10개라면 최대 10번 비교연산이 수행될 수 있단는 것입니다.

 

 

 

switch

 

int i = 1;

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    LOOKUPSWITCH
      1: L2
      3: L3
      default: L4
   L2
    LINENUMBER 10 L2
   FRAME APPEND [I]
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L5
   L3
    LINENUMBER 11 L3
   FRAME SAME
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L5
   L4
    LINENUMBER 12 L4
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L5
    LINENUMBER 15 L5
   FRAME SAME
    RETURN
   L6
    LOCALVARIABLE args [Ljava/lang/String; L0 L6 0
    LOCALVARIABLE i I L1 L6 1
    MAXSTACK = 1
    MAXLOCALS = 2

 

 

switch 문을 바이트코드로 변환했을 때, 이 부분에서 switch 문이 발생된걸 확인할 수 있습니다. 위에 if-else 문과 구조가 다른데요 눈여겨 볼 것은 LOOKUPSWITCH 입니다.

LOOKUPSWITCH 는 조건마다 비교연산을 하는게 아니고 표현식 값을 가지고 탐색합니다.

if-else 보다 비교연산 없이 탐색하기 때문에 효율적이지만, jump table 생성 비용의 오버헤드가 있습니다.

 

LOOPUPSWITCH, TABLESWITCH 에 대해서  조금 더 Alaboza

더보기

TABLESWITCH

int i = 1;

int j = switch (i) {
    case 0 ->  0;
    case 1 ->  1;
    case 2 ->  2;
    case 5 ->  5;
    case 10 ->  10;
    default -> -1;
};

 이 switch문을 바이트 코드로 바꾸면 어떻게 될까?

 

   L1
    LINENUMBER 9 L1
    ILOAD 1
    TABLESWITCH
      0: L2
      1: L3
      2: L4
      3: L5
      4: L5
      5: L6
      6: L5
      7: L5
      8: L5
      9: L5
      10: L7
      default: L5

우리는 0, 1, 2, 5, 10 에대한 case를 작성했는데 바이트 코드를 보면 TABLESWITCH 안에 0 - 10까지의 데이터가 들어가있습니다. 0, 1, 2, 5, 10을 제외한 나머지 숫자들은 switch 종료 라인인 L5 를 가리키고 있습니다.

 

 TABLESWITCH는 전체 범위를 인덱싱 하기 때문인데요. 이 과정에서 사용되지 않은 빈 공간까지 차지하게 되어버렸습니다.

 

LOOKUPSWITCH와 TABLESWITCH는 컴파일과정에서 효율성을 따져서 자동으로 이 방식이 선택됩니다.

만약 인덱싱과정이 사용된다면 더더욱 switch문을 써야겠죠?

 

 

그래서 뭐가 더 좋을까?

코드를 작성할 때, 성능도 중요하지만 가독성에 대한 것도 고려해야합니다. JAVA 17부터 switch 문이 업데이트되면서 기존의 if-else 보다 가독성이 좋아졌습니다.

 

// switch 문
switch (i) {
    case 1:
        method1();
        break;
    case 3:
        method2();
        break;
    default:
        method3();
        break;
}

// JAVA 17 에서의 switch 문

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}

switch (i) { // 다수 조건식
    case 1,2 -> method1();
    case 3 -> method2();
    default -> method3();
}


// JAVA 21 에서의 switch 문 
switch (animal) { // 타입 캐스팅
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    default -> "Unknown";
};

switch (animal) { // Null 처리
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    case null -> "Null";
    default -> "Unknown";
};

 

 

따라서, 가독성에 중점을 두거나 조건이 많을때는 switch 문을 사용하는것도 고려해볼만 합니다.

들어가며

JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.

 

        String str = "apple";
        str = "banana";

왜냐하면, 재할당에 대해서 문제가 없으니까요.

 

그런데 무슨 불변성이 있다는 것일까요? 이 글에서는 String 이 왜 불변성을 가지는지 알아보겠습니다.

 

 

1. String은 참조타입이다.

String의 불변성을 얘기하기 전에 알고 넘어가야할 부분이 몇가지 있습니다.JAVA에서 String 은 기본타입이 아닌 참조타입인 클래스라는 것입니다.

 

        String str1 = "apple";
        String str2 = "apple";
        System.out.println(str1 == str2); // true
        
        String str1 = new String("apple");
        String str2 = new String("apple");
        System.out.println(str1 == str2); // false

 

"" (큰따옴표)로 생성한 객체는 내용이 같으면 같음 메모리 주소를 가집니다.

new 연산자로 생성한 객체는 내용이 같더라도 다른 메모리 주소를 가집니다.

 

 

2. String Constant Pool

String은 Heap 영역에서 String Constant Pool 에서 관리되고있습니다.

""(큰따옴표)로 생성된 객체는 String Constant Pool 에서 관리되어 다음 동일한 값이 할당된다면 이전에 있던 참조값을 반환해줍니다. ( ==  true)

new 연산자를 사용한다면 String Constant Pool에 같은 값이 존재하더라도 별도의 주소를 가리키게됩니다. ( == false)

 

만약 위 그림에서 str2 = "apple" 이었다가 "banana"로 값이 변경된다면 어떻게 될까요?

변수 str2의 값이 "banana"로 변경된다면 String Constant Pool에서 값이 "banana"인 주소를 찾고 없다면 새로 생성하여 그 주소를 반환해줍니다. 이 과정에서 이전의 "apple" 데이터는 변경되지 않습니다.

 

 

 

불변인 이유

  • String Constant Pool에서 String 을 관리한다는것은 동일한 데이터에 대해 메모리주소를 공유함으로써  Heap 영역의 메모리가 절약한다는 뜻이됩니다. 이 과정에서 String 이 불변성을 유지해야지 메모리 주소를 공유하는 것이 가능합니다.
    만약 String이 불변객체가 아니라면 변수 str1과 str2가 "apple" 의 메모리주소를 공유하고있다고했을때 str2에서 값이 변경되는 순간 str1도 값이 변경되기 때문에 불변성을 가져야합니다.
  • String 객체에 대해 hashCode를 미리 계산하지 않아 메모리를 절약하고, 필요할 때만 계산하고, 계산된 후에는 재사용하여 성능을 향상 시킵니다. hashCode를 재사용한다는 것은 String의 불변성이 보장되어야 가능합니다. hashCode의 캐싱은 HashMap, HashSet과 같은 hash를 사용하는 연산에서 효과적입니다. 일반 객체는 사용할때마다 hashCode를 계산하지만 String은 캐싱된 hashCode를 사용하기 때문에 더욱 빠르게 연산하는 것이 가능합니다.
  • Sring은 불변이기 때문에 Thread-Safe합니다. 값이 변경되지않는 것이 보장된다면 동기화 문제가 발생할 수가 없습니다.
  • String이 불변이 아니라면 보안에 문제가 생길 수 있습니다. 데이터베이스의 username, password이나 host, port가 String값으로 전달된다고 했을때, 이 값이 불변이 아니라면 공격으로부터 값이 변경될 수 있기 때문입니다.

 

이상 String의 불변성에 대해서 알아보았습니다. 문자열의 기본타입이 존재하지않는 이유가 String의 불변성과 관련이 있다는 것을 알아가는 시간이었습니다. 

 

함수형 인터페이스가 뭘까

함수형 인터페이스는 1개의 추상메소드를 가지고 있는데요. 당연하게도 아무런 역할을 하지 않는 인터페이스입니다.

java.util.function

 

이런 함수형 인터페이스는 function 패키지에 담겨져있습니다.

 

그 내용을 열어보면

많은 인터페이스들이 존재합니다. 이 함수형 인터페이스에는 똑같은 특징이 있는데 @FunctionalInterface 어노테이션을 사용하고있다는 겁니다. 이 어노테이션은 해당 인터페이스가 함수형 인터페이스 조건에 맞는지 검사해준다고 하는데요. 그렇지만 이 어노테이션을 사용하지 않더라도 동작하는데에는 문제가 없으니 참고해주시면 될것 같습니다.

 

함수형 인터페이스 정의

  1. 추상메소드가 단 한개인 인터페이스
  2. static, default 메소드가 여러개여도 추상메소드가 단 한개인 인터페이스

 

 

람다표현식

자바 8 이상을 사용하시는 분들은 lambda 에 대해서 익숙하실 겁니다. 함수형 인터페이스의 목적은 람다표현식을 이용해 함수형 프로그래밍을 구현하기 위해서라고 할 수 있습니다.

 

 

 

 

함수형 인터페이스 종류

function 패키지 이외에 Callable, Comparator 등의 함수형 인터페이스가 있지만 이 글에서는 function 패키지 내에서만 설명하겠습니다.

인터페이스 메소드 설명
Runnable void run() 매개변수를 받지 않고, 반환하지 않습니다.
Thread 에서 Runnable 을 사용하고있습니다.
Consumer<T> void accept(T t) 매개변수를 받고, 리턴하지 않을때 사용합니다.
forEach 메소드에서 이 인터페이스를 사용하고있습니다.
Function<T, R> R apply(T t) 제네릭 첫번째 타입을 매개변수로 받고,제네릭 두번째 타입을 반환합니다. Stream 에 map 메소드에서 사용하고있습니다.
Predicate<T> boolean test(T t) 매개변수를 받아 true / false 인지를 반환합니다.
Stream 에 filter에서 사용하고있습니다.
Supplier<T> T get() 매개변수를 받지않고, 어떤 값을 반환합니다.
Optional에 or(),orElseThrow() 등 메소드에서 사용하고있습니다.

 

 

 

 

1. Runnable

함수형 인터페이스 중에서 가장 애매한게 Runnable 이라고 생각합니다. 매개변수를 받지도 않고 반환값도 없는 이 인터페이스는 도대체 어디에 사용되는걸까요?

 

Thread 는 Runnable을 생성자 파라미터로 받아 사용하고 있는 것을 확인할 수 있습니다.

        Thread t = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                System.out.println(i);
            }
        });
        t.start();

 

Thread 에서 어떤 메소드를 실행할지에 대한 표현을 Runnable 를 사용하여 구현하였습니다. 이 Runnable 내에는 어떠한 함수가 와도 타입으로부터 자유롭습니다. 

 

 

 

2. Consumer

인터페이스 메소드 설명
Consumer<T> void accept(T t) 1개의 타입을 받음
BiConsumer<T, U> void accept(T t, U u) 2개의 타입을 받음
IntConsumer void accept(int value) 1개의 int 값을 받음
LongConsumer void accept(long value) 1개의 long 값을 받음
DoubleConsumer void accept(double value) 1개의 double 값을 받음
ObjIntConsumer<T> void accept(T t, int value) 1개의 타입과 1개의 int 값을 받음
ObjLongConsumer<T> void accept(T t, long value) 1개의 타입과 1개의 long 값을 받음
ObjDoubleConsumer<T> void accept(T t, double value) 1개의 타입과 1개의 double 값을 받음

 

 

 

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
    
}

 

이 Consumer 는 forEach 메소드에서 사용하는 것 외에는 어디다가 이 메소드를 사용해야 유용하지? 라는 의문이 들었습니다. 고민을 하다가 Consumer 인터페이스의 변수명을 이용해보자라는 생각이 들었는데요. 하나의 작업을 Consumer로 묶으면 변수명을 보고 로직의 이유를 바로 유츄가 가능하다는 점을 이용해보았습니다.

 

    public static void main(String[] args) {
        
        List<Order> orders = repository.getOrders();

        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        사용한쿠폰중만료된쿠폰삭제.andThen(
        금액환불.andThen(
        영수증생성.andThen(
        주문상태변경)))
        .accept(orders);

    }

 

물론 위에처럼 무턱대고 저렇게 하지는 않겠지만, 적절한 예시가 생각이 나지 않아 일단 이렇게 예시를 만들어봤습니다.

예를들어, 취소요청이 들어온 주문 건에 대한 비즈니스로직을 작성하려고합니다.

1. 사용된 쿠폰은 다시 사용할 수 있게 변경되어야 하고, 현재시간 기준 만료된 쿠폰은 삭제되어야한다.

2. 남은 금액은 환불되어야한다.

3. 환불건에 대해서 기록이 남아야한다.

4. 주문건은 삭제되지않고 기록을 남겨둔다.

 

라는 요청이라고 가정했을 때, 위와같이 Consumer 를 이용해서 각 기능별로 나눈다면 해당 작업에 대한 로직을 변수명을 보고 유추하기 쉽고, 작업의 순서를 파악하기도 쉬워졌습니다. 이렇게 로직을 분리해 결합도를 낮춰 사이드 이펙트에 대한 문제도 조금 해소되는것 같습니다.

 

추가로 andThen의 반환타입은 Consumer<T> 이기 때문에 이어 붙히는것이 가능합니다. 따라서 위의 코드를 더 정리한다면

    public static void main(String[] args) {
        List<Order> orders = service.getOrders();
        Consumer<List<Order>> consumer = service.getConsumeCancelOrder();
        consumer.accept(orders);
    }
    
public class Service {

    private static final Repository repository = new Repository();

    public Consumer<List<Order>> getConsumeCancelOrder() {
        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        return 사용한쿠폰중만료된쿠폰삭제.andThen(
            금액환불.andThen(
            영수증생성.andThen(
            주문상태변경)));
    }
}

accept와 Consumer 를 분리하는 것도 괜찮은것같습니다. 물론 getConsumeCancelOrder 를 호출할때마다 새로 생성할 필요가 없으므로 Spring의 Bean이나 static, Singleton 으로 관리하는 것이 옳지만 여기까지만 설명하도록 하겠습니다.

 

 

Consumer이의외 BiConsumer, IntConsumer 등등 사용법은 Consumer와 동일합니다. 다만 ObjXXXConsumer 인터페이스는 andThen 메소드가 없으니 유의바랍니다.

 

 

 

3. Function

인터페이스 메소드 설명
Function<T, R> R apply(T t) T를 받아서 R을 반환
Function<T, U, R> R apply(T t, U u) T와 U를 받아서 R을 반환
IntFunction<R> R apply(int value) int를 받아서 R을 반환
LongFunction<R> R apply(long value) long을 받아서 R을 반환
DoubleFunction<R> R apply(double value) double을 받아서 R을 반환
XXXToYYYFunction Y applyAsXXX(X value) XXX를 받아서 YYY를 반환
ToXXXFunction<T> X applyAsXXX(T t) T를 받아서 XXX를 반환
ToXXXBiFunction<T, U> X applyAsXXX(T t, U u) T와 U를 받아서 XXX를 반환

 

 

public enum Payment {

    KAKAO(i -> (int) (0.95 * i)),
    NAVER(i -> (int) (0.90 * i)),
    APPLE(i -> i),;

    private final Function<Integer, Integer> function;

    public int execute(int amount) {
        return function.apply(amount);
    }

    Payment(Function<Integer, Integer> function) {
        this.function = function;
    }
}

    public static void main(String[] args) {
        int amount = 10000;

        Payment payment = Payment.KAKAO;
        int execute = payment.execute(amount);
        System.out.println("execute = " + execute); // 9500
    }

 

제가 가장 즐겨사용하는 것이 Function 함수입니다. 특히 열거형타입과 함께 사용한다면 더욱 편해지는데요.

위의 예시 코드는 결제방식에 따라 결제금액이 할인되는(?) 말도안되는 로직이지만 이렇게 enum 타입을 사용한다면 조금더 직관적으로 사용할 수 있다는 장점이 있는것 같습니다.

 

 

 

4. Predicate

인터페이스 메소드 설명
Predicate<T> boolean test(T t) T를 받아 true/false 를 반환
BiPredicate<T, U> boolean test(T t, U u) T와 U를 받아 true/false 를 반환
IntPredicate boolean test(int value) int를 받아 true/false 를 반환
LongPredicate boolean test(long value) long을 받아 true/false 를 반환
DoublePredicate boolean test(double value) double을 받아 true/false 를 반환

 

 

    public static void main(String[] args) {

        Predicate<User> filterScore = user -> user.getScore() > 50;

        List<User> users = service.getUsers();

        for (User user : users) {
            if (filterScore.test(user)) {
                System.out.println(user.getName());
            }
        }

    }

 

Predicate는 매개변수로 들어온 데이터를 true / false로 반환하는 인터페이스입니다.

Stream을 사용할때 filter에서 이 Predicate 인터페이스를 받고 있습니다.

 

 

5. Supplier

인터페이스 메소드 설명
Supplier<T> T get() T를 반환
IntSupplier int getAsInt() int를 반환
LongSupplier long getAsLong() long을 반환
DoubleSupplier double getAsDouble() double을 반환
BooleanSupplier boolean getAsBoolean() boolean을 반환

 

Supplier도 어떻게 사용한느지 사실 조금 애매한 감이 있습니다. 데이터를 받지 않고 어떤 값을 반환한다는게 사실 감이 잡히지 않습니다.

Optional 에서 or() 이나 orElseThrow() 에서 사용하는것을 보았을때는 딱히 메리트있어보이는 함수는 아니었습니다. 그런데 조금더 찾아보니까 get()을 통해 Lazy Evaluation(지연 연산) 을 할 수 있다는 것을 알아냈습니다.

 

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, threadSleep());
        printOnlyValid(0, threadSleep());
        printOnlyValid(-1, threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, String data) {
        if (index > 0) {
            System.out.println(data);
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid 메소드를 실행하기에 앞서 String data 값을 가져오기 위해 threadSleep() 메소드를 실행하는 모습입니다.

threadSleep() 메소드는 3초의 시간이 걸리기때문에 3번의 실행으로 총 9초의 시간이 걸렸습니다.

 

이 메소드를 Supplier 함수를 사용하여 바꿔보겠습니다.

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, () -> threadSleep());
        printOnlyValid(0, () -> threadSleep());
        printOnlyValid(-1, () -> threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, Supplier<String> data) {
        if (index > 0) {
            System.out.println(data.get());
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid의 data 타입을 Supplier로 변경했기때문에 index > 0 일 경우에만 thradSleep() 메소드가 실행되어 3번의 메소드 실행 시간이 9초에서 3초로 줄었습니다.

 

 

결론

함수형 인터페이스는 그 종류도 많고 사용방법도 다양해 언제 어디서 사용할지 애매한 경우가 많은 것같습니다. 그래도 이 글을 작성하면서 JAVA 8 에서 함수형 인터페이스, Lambda, Stream 의 연결성에 대해서 공부가 된것같습니다.

 

공부하면서 작성한 글이기때문에 틀린부분이 있을 수 있습니다. 언제든지 지적해주시기 바랍니다.

그리디 알고리즘

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

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

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

 

예를들어, 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를 사용하자.

 

 

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

직렬화와 역직렬화

  • 직렬화(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 등 다른 포맷을 사용하여 저장한다.

 

 

참고

프로토타입 패턴

프로토타입(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