개요

Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다.

 

ThreadLocal이란?

ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.

각 스레드마다 독립적인 변수를 가질 수 있게 해주는 기능인데 쉽게 말해, ThreadLocal에 저장된 데이터는 해당 Thread만 접근할 수 있는 데이터 저장소라고 할 수 있습니다. 개인이 가지는 사물함이라고 생각하시면 쉽습니다.

 

 

 

동시성 문제

그럼 왜 ThreadLocal을 알아야 할까요? JAVA는 멀티쓰레딩 환경으로 한 메소드에 동시에 접근이 가능합니다. 그래서 항상 동시성문제에 신경써야하죠. 특히 Spring Container에서는 Bean으로 등록해 객체를 싱글톤으로 관리해 자원을 최소화 합니다. 하나의 객체를 여러명이 사용하면 문제가 생기기 마련입니다. 읽기 메소드는 멱등성을 보장받아 문제가 생기지않지만 수정, 생성, 삭제 등 데이터가 변경되었을 때 문제가 생기죠.

 

싱글톤패턴에 대해서 알고싶다면 아래 글을 읽어보세요!

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

public class BankAccount {
    private int balance = 0;
    
    // 동시성 문제가 발생하는 메서드
    public int transfer(int amount) {
        int currentBalance = balance;         // 현재 잔액 읽기
        balance = currentBalance + amount;    // 잔액 업데이트
        return balance;
    }
    
    public int getBalance() {
        return balance;
    }
}

 

가령 BankAccount 객체가 싱글톤으로 관리되고, 여러 사용자가 BankAccount를 사용한다고 했을때,

 

  1. 사용자A : bankAccount.transfer(1000)
  2. 사용자A 현재 잔액 읽음 : 현재 잔액 0
  3. 사용자B : bankAccount.transfer(1000)
  4. 사용자B 현재 잔액 읽음 : 현재 잔액 0
  5. 사용자A : 잔액 업데이트 - 결과 반환 1000
  6. 사용자B : 잔액 업데이트 - 결과 반환 1000

최종 잔액이 2000이 될 것으로 예상했지만 동시성 문제로 최종 잔액이 1000이 되었습니다.

 

 

 

ThreadLocal의 특징

 

  • 스레드 안전성: 각 스레드가 자신만의 독립된 변수를 가지므로, 동기화 없이도 스레드 안전성을 보장합니다.
  • 데이터 격리: 다른 스레드의 데이터에 접근할 수 없어 데이터 격리가 완벽하게 이루어집니다.
  • 성능: 동기화가 필요 없으므로, synchronized 키워드 사용 대비 성능상 이점이 있습니다.

 

 

ThreadLocal

public class ThreadLocal<T> {
    
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    boolean isPresent() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        return map != null && map.getEntry(this) != null;
    }
    
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }
    
    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null) {
            m.remove(this);
        }
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

    static class ThreadLocalMap {

        static class Entry extends WeakReference<java.lang.ThreadLocal<?>> {
            Object value;

            Entry(java.lang.ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    }
}

 

ThreadLocal은 ThreadLocalMap을 가지고있고 여기에서 key, value로 데이터를 보관합니다.

그리고 이때 get()메소드에서 Thread.currentThread()를 사용해 Thread를 꺼내고 그 ThreadLocalMap을 반환해서 가져오게됩니다.

 

 

이제 개념을 알았으니 어디에서 사용하고 있는지 간단하게 알아보겠습니다.

 

 

 

사용 사례

 

Spring Security

public class SecurityContextHolder {

    // ... 코드 생략

    private static void initializeStrategy() {
        if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
            Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
        } else {
            if (!StringUtils.hasText(strategyName)) {
                strategyName = "MODE_THREADLOCAL"; // ThreadLocal 전략이 Default
            }

            if (strategyName.equals("MODE_THREADLOCAL")) {
                strategy = new ThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
                strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_GLOBAL")) {
                strategy = new GlobalSecurityContextHolderStrategy();
            } else {
                try {
                    Class<?> clazz = Class.forName(strategyName);
                    Constructor<?> customStrategy = clazz.getConstructor();
                    strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
                } catch (Exception ex) {
                    ReflectionUtils.handleReflectionException(ex);
                }

            }
        }
    }
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal();
    
    // ... 코드 생략
}

 

 

 

 

Transaction

public class DataSourceTransactionManager extends AbstractPlatformTransactionManager implements ResourceTransactionManager, InitializingBean {

    // ... 코드 생략
    
    protected Object doGetTransaction() {
        DataSourceTransactionObject txObject = new DataSourceTransactionObject();
        txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
        ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(this.obtainDataSource());
        txObject.setConnectionHolder(conHolder, false);
        return txObject;
    }
    
    protected void doBegin(Object transaction, TransactionDefinition definition) {
    
        // ... 코드 생략
        if (txObject.isNewConnectionHolder()) {
            TransactionSynchronizationManager.bindResource(this.obtainDataSource(), txObject.getConnectionHolder());
        }    
        // ... 코드 생략
    }
}

public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
    
    // ... 코드 생략
}

 

 

 

Hibernate

public class ThreadLocalSessionContext extends AbstractCurrentSessionContext {
    private static final CoreMessageLogger LOG = (CoreMessageLogger)Logger.getMessageLogger(CoreMessageLogger.class, ThreadLocalSessionContext.class.getName());
    private static final Class<?>[] SESSION_PROXY_INTERFACES = new Class[]{Session.class, SessionImplementor.class, EventSource.class, LobCreationContext.class};
    private static final ThreadLocal<Map<SessionFactory, Session>> CONTEXT_TL = ThreadLocal.withInitial(HashMap::new);
    
    // ... 코드 생략
}

 

 

 

 

ThreadLocal 주의사항

 

메모리 누수

ThreadLocal 사용 후에는 반드시 remove()를 호출하여 메모리 누수를 방지해야 합니다. 특히 스레드 풀을 사용하는 환경에서는 더욱 중요합니다.

 

try {
    // ThreadLocal 사용
    userContext.set(new UserContext());
    // 비즈니스 로직
} finally {
    // 반드시 삭제
    userContext.remove();
}

가능하면 try-with-resources 패턴을 사용해 자원을 반납하면 안전하게 사용할 수 있습니다.

 

성능 고려사항

  1. ThreadLocal은 각 스레드마다 별도의 메모리를 사용합니다.
  2. 많은 수의 ThreadLocal 변수를 사용하면 메모리 사용량이 증가할 수 있습니다.
  3. get()과 set() 연산은 매우 빠르지만, 너무 빈번한 접근은 피하는 것이 좋습니다.

 

static final 선언

static final로 선언하는걸 권장합니다. 위에 사용 사례를 보시면 모두 static final로 선언되어있는걸 보실 수 있습니다.

 

  • static 사용 이유
    • ThreadLocal 객체 자체는 모든 스레드가 공유해도 됩니다
    • 각 스레드마다 값을 따로 저장하는 것은 ThreadLocal의 내부 구현이 처리함
    • 불필요한 인스턴스 생성을 방지할 수 있음
    • 전역적으로 접근이 필요한 경우가 많음 (예: Transaction, Security 등)
  • final 사용 이유
    • ThreadLocal 인스턴스 자체는 변경될 필요가 없음
    • 한 번 생성된 후에는 참조가 변경되면 안됨
    • 실수로 ThreadLocal 참조를 바꾸는 것을 방지
    • 불변성을 보장하여 스레드 안전성을 높임

 

 

 

끝!

Stream API 개요

Java Stream API는 데이터 처리를 위한 강력한 도구이지만, 잘못 사용하면 오히려 성능이 저하될 수 있습니다. 이번 포스트에서는 Stream API의 효율적인 사용법과 병렬 스트림 활용 방법에 대해 알아보겠습니다.

 

 

 

병렬 스트림(Parallel Stream)

 

기본 사용법

// 순차 스트림
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
                .mapToInt(n -> n)
                .sum();

// 병렬 스트림
int parallelSum = numbers.parallelStream()
                        .mapToInt(n -> n)
                        .sum();

// 기존 스트림을 병렬로 변환
int anotherSum = numbers.stream()
                       .parallel()
                       .mapToInt(n -> n)
                       .sum();

 

병렬 스트림이 효과적인 경우

// 1. 데이터가 많은 경우
List<Integer> largeList = new ArrayList<>(1000000);
// 리스트 초기화...

long count = largeList.parallelStream()
                     .filter(n -> n % 2 == 0)
                     .count();

// 2. 독립적인 연산이 많은 경우
double average = largeList.parallelStream()
                         .mapToDouble(this::complexCalculation)
                         .average()
                         .orElse(0.0);

private double complexCalculation(int number) {
    // CPU 집약적인 계산
    return Math.sqrt(Math.pow(number, 2));
}

 

 

 

성능 최적화 전략

 

적절한 데이터 구조 선택

// ArrayList - 병렬 처리에 좋음
List<Integer> arrayList = new ArrayList<>();
arrayList.parallelStream()...

// LinkedList - 병렬 처리에 비효율적
List<Integer> linkedList = new LinkedList<>();
linkedList.stream()...  // 순차 처리 권장

// Array - 가장 효율적
int[] array = {1, 2, 3, 4, 5};
Arrays.stream(array).parallel()...

 

 

Unboxing 오버헤드 방지

// 비효율적인 방법 (boxing/unboxing 발생)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
                .mapToInt(n -> n)  // unboxing
                .sum();

// 효율적인 방법
int[] primitiveNumbers = {1, 2, 3, 4, 5};
int sum = Arrays.stream(primitiveNumbers)
               .sum();

 

 

적절한 중간 연산 사용

// 비효율적인 방법
List<String> result = strings.stream()
                           .filter(s -> s.length() > 3)
                           .sorted()  // 전체 정렬 후 필터링
                           .limit(5)
                           .collect(Collectors.toList());

// 효율적인 방법
List<String> betterResult = strings.stream()
                                 .filter(s -> s.length() > 3)
                                 .limit(5)  // 먼저 개수 제한
                                 .sorted()  // 필요한 요소만 정렬
                                 .collect(Collectors.toList());

 

 

병렬 스트림 주의사항

 

상태 공유 피하기

// 잘못된 예 - 상태 공유로 인한 문제
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ArrayList<>();

numbers.parallelStream()
       .map(n -> n * 2)
       .forEach(result::add);  // 동시성 문제 발생

// 올바른 예
List<Integer> safeResult = numbers.parallelStream()
                                .map(n -> n * 2)
                                .collect(Collectors.toList());

 

 

순서 의존성 주의

// 순서에 의존적인 작업 - 병렬 처리 부적합
String result = strings.parallelStream()
                      .reduce("", (a, b) -> a + b);  // 순서 보장 안됨

// 올바른 방법
String betterResult = String.join("", strings);  // 더 효율적

 

 

주의점

  1. 데이터 크기가 작은 경우 순차 처리가 더 효율적
  2. 공유 상태 수정은 피하기
  3. 올바른 데이터 구조 선택
  4. 순서 의존성 고려
  5. 성능 측정 후 판단

 

마치며

Stream API의 효율적인 사용과 병렬 스트림의 적절한 활용은 애플리케이션의 성능을 크게 향상시킬 수 있습니다. 하지만 무조건적인 병렬 처리가 아닌, 상황에 맞는 적절한 선택이 중요합니다.

StringTokenizer

StringTokenizer는 문자열을 지정된 구분자를 기준으로 토큰화하는 Java의 유틸리티 클래스입니다.

 

        String str = "a;b;c;d;e";

        StringTokenizer st = new StringTokenizer(str, ";");
        while (st.hasMoreTokens()) {
            System.out.print(st.nextToken()); // abcde
        }
        
        StringTokenizer st = new StringTokenizer(str, "abc");
        while (st.hasMoreTokens()) {
            System.out.print(st.nextToken()); // de
        }

가령 "a;b;c;d;e" 라는 문자열에서 ";" 문자열로 나누어 토큰화 하고싶다면 위 처럼 사용하는 것입니다.

위와 같은 역할을 수행하는게 split() 메소드인데요 split() 메소는 정규식을 사용하기때문에 활용성이 높고 정규식을 잘 이해하고있다면 더욱 편한게 사실입니다.

그러면 이 StringTokenizer를 왜 사용했을까요? split() 메소드는 내부에서 정규식을 사용하기때문에 StringTokenizer보다 속도가 4배정도 느리다고 합니다. 그래서 속도가 중요한 부분에서 이 StringTokenizer를 사용했었죠. 하지만 현재는 사용하지않는것을 권장하고있습니다. 이 글에서 이 부분에 대해서 알아보겠습니다.

 

 

 

 

Document

JAVA 5 StringTokenizer

JAVA 6 StringTokenizer

JAVA 7 StringTokenizer

JAVA 8 StringTokenizer

JAVA 9 StringTokenizer

JAVA 10 StringTokenizer

JAVA 11 StringTokenizer

JAVA 21 StringTokenizer

 

StringTokenizer is a legacy class that is retained for compatibility reasons although its use is discouraged in new code. It is recommended that anyone seeking this functionality use the split method of String or the java.util.regex package instead.

StringTokenizer는 호환성을 이유로 유지되는 레거시 클래스이지만 새 코드에서는 사용이 권장되지 않습니다. 이 기능을 원하는 사람은 대신 String의 split 메서드나 java.util.regex 패키지를 사용하는 것이 좋습니다.

 

문서에는 StringTokenizer는 새 코드에서는 사용하는것을 권장하지 않는다고 작성되어있습니다.

 

StringTokenizer의 주석에도 권장하지 않는다고 하는데 왜 그런지 간단하게 알아보겠습니다.

 

 

 

 

권장하지 않는 이유

 

유연성의 문제

StringTokenizer는 매우 단순한 방식으로 문자열을 분리합니다. 기본적으로 정규 표현식을 지원하지 않기 때문에 복잡한 문자열 처리에는 한계가 있습니다.  split() 메소드나 java.util.regex 패키지를 사용하면 정규표현식을 지원하여 복잡한 구분자를 처리하여 더 유연하고 안정적인 방식으로 문자열을 분리할 수 있습니다. 그러니 StringTokenizer를 사용할 이유가 없어지고 있는 것이지요.

 

 

클래스 개선문제

StringTokenizer는 Java 초기에 도입된 클래스입니다. 이후 자바가 발전하면서 더 나은 기능을 제공하는 클래스들이 도입되면서 StringTokenizer의 사용이유가 더욱 줄어들었습니다. 따라서 새로운 기능이 추가되거나 개선되지 않습니다.

 

제한된 기능

정규표현식을 지원하지않고, 복합한 문자열 처리에 한계가있습니다. 또한 반환 타입이 String[] 같은 배열이 아닙니다.

 

 

 

 

결론

StringTokenizer의 장점도 존재합니다. 정규표현식이 없기때문에 상대적으로 처리속도가 빠릅니다. (split의 약 4배)

그리고 간단한 문자열을 처리하기에는 StringTokenizer를 사용하는것이 더 적합할 수 있습니다.

 

결론은 더 이상 StringTokenizer를 사용하지 말고 split 메소드를 사용하는게 현대적인 방법이라고 생각합니다.

 

개요

오늘은 Java 14부터 도입된 Record와 Java 15에서 preview로 시작된 Sealed Classes 에 대해서 알아보겠습니다.
참고로 Sealed Classes 는 Java 17부터 정식적으로 확정된 것같습니다.

 

JDK 17 Release Notes, Important Changes, and Information

These notes describe important changes, enhancements, removed APIs and features, deprecated APIs and features, and other information about JDK 17 and Java SE 17. In some cases, the descriptions provide links to additional detailed information about an issu

www.oracle.com

 
 

Record

Record는 불변(immutable) 데이터 객체를 생성하기 위한 새로운 유형의 클래스입니다. 주로 데이터를 운반하는 목적으로 사용되며, 기존의 많은 코드를 줄여줍니다.
 

Record 적용 전

public class Student {

    private final String name;
    private final int age;

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

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return age == student.age && Objects.equals(name, student.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Student[name=" + name + ", age=" + age + "]";
    }

}

 

Record 적용 후

public record Student(String name, int age) { }

 

  • 모든 필드가 private final로 선언됩니다
  • 자동으로 equals(), hashCode(), toString() 메서드가 생성됩니다
  • 생성자와 접근자(getter)가 자동으로 생성됩니다
  • 상속이 불가능합니다

IntelliJ에서 record 를 추천하기도합니다.

 

    public static void main(String[] args) {
        Student student = new Student("철수", 20);

        System.out.println("student = " + student);
        System.out.println("student.name() = " + student.name());
        System.out.println("student.age() = " + student.age());
        
        // student = Student[name=철수, age=20]
        // student.name() = 철수
        // student.age() = 20
    }

 
 
 
 

Sealed Classes

Sealed Classes는 상속을 제한하는 새로운 방법을 제공합니다. 클래스나 인터페이스가 어떤 클래스에 의해 상속(extends)/구현(implements) 될 수 있는지 명시적으로 선언할 수 있습니다.
 

public sealed class Shape permits Circle, Rectangle, Square {
    // 공통 속성 및 메서드
}

public final class Circle extends Shape {
    private final double radius;
    // Circle 구현
}

public final class Rectangle extends Shape {
    private final double width;
    private final double height;
    // Rectangle 구현
}

public final class Square extends Shape {
    private final double side;
    // Square 구현
}

 
permits 한 클래스에서 sealed class를 구현하지 않거나, permits 에 명시되지않은 클래스에서 sealed 클래스를 구현하려고하면 에러가 발생합니다.
 

public sealed class Shape permits Circle, Rectangle, Square { // 에러!!
    // 공통 속성 및 메서드
}

public class Circle {
    private final double radius;
    // Circle 구현
}
public sealed class Shape permits Circle, Rectangle, Square {
    // 공통 속성 및 메서드
}

public final class Triangle extends Shape { // 에러!!

}

 

  • 상속/구현하는 클래스는 final, non-sealed, sealed 중 하나를 선언해야 합니다.
  • sealed class와 permit 된 subclass는 한 패키지 내에 존재해야 합니다.
  •  
키워드 설명
final 더 이상의 상속을 금지
sealed 제한된 상속
non-sealed 상속 허용

 

final

public final class Circle extends Shape {
    // Circle 구현
}

 

sealed

public sealed class Rectangle extends Shape permits ColoredRectangle {
    // Rectangle 구현
}

 

non-sealed

public non-sealed class Square extends Shape {
    // Square 구현
}

 
 

Sealed Classes 를 써야하는 이유가 있을까?

먼저 Sealed Classes를 도입하면 좋은 점을 알아보겠습니다.
 

타입 안정성 강화

  • 상속 가능한 클래스를 명확히 제한해서 타입 안정성을 높힙니다.
  • 컴파일 시점에서 가능한 모든 하위 타입을 알 수 있습니다.
  • switch 문법에서 하위 클래스가 모두 존재한다는 것을 컴파일러가 보장합니다. (switch JAVA 21 문법)

도메인 모델링 개선

  • 도메인 모델의 제약조건을 코드로 표현할 수 있습니다
  • 특정 타입이 가질 수 있는 모든 변형을 명시적으로 정의할 수 있습니다

 
sealed로 온라인 결제 시스템에서 지원하는 결제 수단을 예시로 만들어보겠습니다.

public sealed interface PaymentMethod permits 
    CreditCard, 
    BankTransfer, 
    DigitalWallet {
    
    boolean process(int amount);
    String getPaymentInfo();
}

public final class CreditCard implements PaymentMethod {
    private final String cardNumber;
    private final String expiryDate;
    private final String cvv;

    @Override
    public boolean process(int amount) {
        // 신용카드 결제 처리 로직
        return true;
    }

    @Override
    public String getPaymentInfo() {
        return "Card: " + cardNumber.substring(12);
    }
}

public final class BankTransfer implements PaymentMethod {
    private final String accountNumber;
    private final String bankCode;

    @Override
    public boolean process(int amount) {
        // 계좌이체 처리 로직
        return true;
    }

    @Override
    public String getPaymentInfo() {
        return "Bank: " + bankCode + "-" + accountNumber;
    }
}

public final class DigitalWallet implements PaymentMethod {
    private final String walletId;
    
    @Override
    public boolean process(int amount) {
        // 디지털 월렛 결제 처리 로직
        return true;
    }

    @Override
    public String getPaymentInfo() {
        return "Wallet: " + walletId;
    }
}

// 결제 처리 서비스
public class PaymentService {
    public String processPayment(PaymentMethod method, int amount) {
    	// JDK 21 switch 문법
        return switch (method) {
            case CreditCard card -> 
                "신용카드 결제: " + card.getPaymentInfo();
            case BankTransfer transfer -> 
                "계좌이체: " + transfer.getPaymentInfo();
            case DigitalWallet wallet -> 
                "디지털 월렛: " + wallet.getPaymentInfo();
        }; // 모든 결제 수단이 처리됨을 컴파일러가 보장
    }
}

 

  • 지원하는 결제 수단을 명확하게 제한할 수 있습니다.
  • 새로운 결제 수단 추가 시 컴파일러가 관련된 모든 코드를 체크합니다.
  • switch 문에서 모든 케이스를 처리했는지 컴파일 시점에 확인할 수 있습니다.

 

개요

오늘은 객체 지향 프로그래밍(OOP)의 핵심 개념인 상속(Inheritance)과 복합(Composition)에 대해 설명해드리겠습니다. 이 두 개념은 코드 재사용과 유지보수성을 높이는 중요한 설계 방식입니다. 예제와 함께 특징을 살펴보며 공부해도록 하겠습니다.

 

 

 

상속

상속은 OOP에서 가장 기본개념중에 하나입니다. 부모 - 자식 클래스로 정의하여 자식클래스에서 부모 클래스의 메소드를 override, 재사용 할 수 있다는 특징이 있습니다.

 

@AllArgsConstructor
public class Car {

    private final String brand;
    private final String model;
    private final int year;

    public void start() {
        System.out.println("차량이 시동됩니다.");
    }

    public void stop() {
        System.out.println("차량이 정지합니다.");
    }

}

 

public class ElectricCar extends Car {

    private int batteryCapacity;
    private int batteryLevel;

    public ElectricCar(String brand, String model, int year, int batteryCapacity) {
        super(brand, model, year);
        this.batteryCapacity = batteryCapacity;
        this.batteryLevel = 100;
    }

    @Override
    public void start() {
        System.out.println("전기차가 조용히 시동됩니다.");
    }

    public void charge() {
        System.out.println("배터리를 충전합니다.");
        this.batteryLevel = 100;
    }
    
}

 

ElectricCar 클래스는 Car 클래스를 상속받고있습니다. 따라서 Car와 ElectricCar는 부모-자식 관계입니다.

ElectricCar는 Car의 기능을 재사용하거나 새로운 기능을 손쉽게 추가할 수 있고, 때에 따라서 Override 할 수 있습니다. 그리고 Car 에서 변경된 사항은 ElectricCar에도 그대로 반영됩니다.

 

 

 

복합

@AllArgsConstructor
public class Engine {

    private final String type;
    private final int hp;

    public void start() {
        System.out.println(type + " 엔진이 가동됩니다.");
    }

}

 

@AllArgsConstructor
public class GasCar {

    private final Car car; // 복합관계
    private final Engine engine; // 복합관계

    public void start() {
        car.start();
        engine.start();
    }
}

 

Engine 클래스와 GasCar 클래스를 구현했습니다. GasCar는 Car와 Engine을 필드에서 참조하고있습니다.

이처럼 GasCar가 Car를 필드로 가지는 것이 복합관계라고합니다. GasCar를 통해 Car 메소드를 호출하는 방식이죠.

 

 

상속의 장단점

장점

  1. 코드 재사용성
    • 부모 클래스의 기능을 그대로 물려받아 사용할 수 있어 코드 중복을 줄일 수 있습니다.
    • 공통 기능을 부모 클래스에 구현하면 모든 자식 클래스가 활용할 수 있습니다.
  2. 간단한 확장성
    • 기존 클래스를 수정하지 않고도 새로운 기능을 추가할 수 있습니다.
  3. 다형성 구현
    • 부모 타입으로 자식 객체를 참조할 수 있어 유연한 프로그래밍이 가능합니다.
Car car = new ElectricCar("Tesla", "Model 3", 2024, 75);

 

단점

  1. 강한 결합도
    • 부모 클래스의 변경이 모든 자식 클래스에 영향을 미칩니다.
    • 부모 클래스의 내부 구현을 자식 클래스가 알아야 할 수 있습니다.
  2. 취약한 기반 클래스 문제
    • 부모 클래스의 변경이 예상치 못한 자식 클래스의 동작 변경을 일으킬 수 있습니다.
  3. 단일 상속 제한
    • Java는 다중 상속을 지원하지 않아 한 클래스만 상속할 수 있습니다.

 

복합의 장단점

장점

 

  1. 유연한 설계
    • 런타임에 구성요소를 변경할 수 있습니다.
    • 다중 복합이 가능합니다.
  2. 캡슐화 향상
    • 내부 구성요소의 구현을 완전히 숨길 수 있습니다.
    • 인터페이스를 통한 통신이 가능합니다.
  3. 느슨한 결합도
    • 각 클래스가 독립적으로 동작하여 유지보수가 용이합니다.
// Engine을 쉽게 교체할 수 있음
public void updateEngine(Engine newEngine) {
    this.engine = newEngine;
}

 

 

 

단점

  1. 코드 가독성
    • 복잡한 구성의 경우 코드 추적이 어려울 수 있습니다.
  2. 구현의 복잡성
    • 여러 클래스를 조합하여 사용하므로 초기 설계가 더 복잡할 수 있습니다.
// 복합 관계에서는 위임 메서드를 많이 작성해야 할 수 있음
public class GasCar {
    private Car car;
    private Engine engine;
    
    public void start() { car.start(); }
    public void stop() { car.stop(); }
    public void accelerate() { engine.accelerate(); }
    // ... 더 많은 위임 메서드들
}

 

 

결론

상속과 복합에 대한 글을 읽다보면 'IS-A' 일때는 상속, 'HAS-A' 일때는 복합을 사용하라는 글이 많습니다.

'전기차는 차다' -> 상속,

'차는 엔진을 갖고있다' -> 복합,

이렇게 이해하면 조금더 와닿을것 같습니다.

 

최근의 객체지향 설계에서는 "상속보다는 복합을 사용하라"는 원칙이 널리 받아들여지고 있습니다. 복합이 더 유연하고 안전한 설계를 제공하기 때문입니다. 하지만 각각의 상황에 맞는 적절한 선택이 중요합니다.

 

마지막으로 위의 예제를 상속과 복합관계를 이용하여 CasCar를 조금 더 깔끔하게 바꿔보면서 글을 마치겠습니다.

 

@AllArgsConstructor
public class Car {

    private final String brand;
    private final String model;
    private final int year;
    private final Engine engine;

    public void start() {
        System.out.println("차량이 시동됩니다.");
        engine.start();
    }

    public void stop() {
        System.out.println("차량이 정지합니다.");
    }

}

public class GasCar extends Car {

    private final int fuelCapacity;
    private double currentFuel;

    public GasCar(String brand, String model, int year, Engine engine, int fuelCapacity) {
        super(brand, model, year, engine);
        this.fuelCapacity = fuelCapacity;
        this.currentFuel = fuelCapacity;
    }

    public void refuel(double amount) {
        currentFuel = Math.min(currentFuel + amount, fuelCapacity);
    }

}

 

차는 엔진을 가지고있다 -> private final Engine engine

가솔린차는 차이다 -> GasCar extends Vehicle

 

들어가며

String의 불변성 - StringBuilder와 StringBuffer의 차이점을 다뤘습니다. 이번에는 String + 연산에 대해서 조금 더 자세하게 다뤄보고도록 하겠습니다. 이전 글을 보고 오시는것을 추천드립니다.

 

 

[JAVA] StringBuilder, StringBuffer 의 차이점과 주의사항

들어가며이 글에서는 StringBuilder, StringBuffer 차이점뿐만 아니라 사용 이유에 대해서도 다루고있습니다. [JAVA] String의 불변성(immutability)들어가며JAVA를 처음 접할 때, String은 불변객체라고 배우고

tmd8633.tistory.com

 

 

 

이 글을 쓰는 이유

지난시간에 String 의 불변성과 StringBuilder에 대해서 알아보았습니다. 이전 글들을 작성하면서 String + 연산이 최적화 되었다는 얘기를 수 없이 보았습니다.

 

        String[] arr = {"2", "3", "4"};
        String str = "1";

        for (String s : arr) {
            str += s;
        }

이렇게 String + 연산을 하게되면 String의 불변성 때문에 "12", "123", "1234"의 메모리가 heap 영역에 저장되고 GC 대상도 증가하게 되는 것은 이제 충분히 이해가 됩니다.

그런데 이 과정을 JDK 5 부터 컴파일 시에 내부적으로 StringBuilder를 사용해서 최적화를 하였고, 완전히 최적화가 되지않아서(매번 StringBuilder객체가 생성되는 문제 등) JDK 9부터 지금까지 StringConcatFactory가 사용되어 + 연산이 완전 최적화 되었다고 했습니다. 이를 실제로 알아보기 위해 bytecode를 까봤습니다.

 

 

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 13 L0
    ICONST_3
    ANEWARRAY java/lang/String
    DUP
    ICONST_0
    LDC "2"
    AASTORE
    DUP
    ICONST_1
    LDC "3"
    AASTORE
    DUP
    ICONST_2
    LDC "4"
    AASTORE
    ASTORE 1
   L1
    LINENUMBER 14 L1
    LDC "1"
    ASTORE 2
   L2
    LINENUMBER 16 L2
    ALOAD 1
    ASTORE 3
    ALOAD 3
    ARRAYLENGTH
    ISTORE 4
    ICONST_0
    ISTORE 5
   L3
   FRAME FULL [[Ljava/lang/String; [Ljava/lang/String; java/lang/String [Ljava/lang/String; I I] []
    ILOAD 5
    ILOAD 4
    IF_ICMPGE L4
    ALOAD 3
    ILOAD 5
    AALOAD
    ASTORE 6
   L5
    LINENUMBER 17 L5
    ALOAD 2
    ALOAD 6
    INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/StringConcatFactory.makeConcatWithConstants(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
      // arguments:
      "\u0001\u0001"
    ]
    ASTORE 2
   L6
    LINENUMBER 16 L6
    IINC 5 1
    GOTO L3
   L4
    LINENUMBER 19 L4
   FRAME CHOP 3
    RETURN
   L7
    LOCALVARIABLE s Ljava/lang/String; L5 L6 6
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE arr [Ljava/lang/String; L1 L7 1
    LOCALVARIABLE str Ljava/lang/String; L2 L7 2
    MAXSTACK = 4
    MAXLOCALS = 7
}

 

StringConcatFactory.makeConcatWithConstants 메소드가 실제로 String + 연산에 사용되고 있었습니다.

 

그럼 직접 StringBuilder를 사용하지 않고 String + 연산을 해도 최적화가 되는건가? 라는 생각에 테스트를 진행했습니다.

속도테스트는 100배가량 +연산이 느렸고, 메모리도 낭비되는 것을 확인할 수 있었습니다.

 

그럼 무슨 최적화가 되었다는걸까요? 그 결과를 글로 적고싶어서 이렇게 남깁니다.

 

 

Concat

String의 concat() 메소드를 사용해보셨나요? 앞문자열과 뒷문자열을 합쳐주는 기능을 수행하는 메소드입니다.

StringConcatFactory는 그 concat 메소드처럼 여러 문자열을 하나로 합쳐주는 역할을 수행하는 것이었습니다.

 

String str1 = "1";
String str2 = str1 + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9";

 

JDK5 이전에는 이런 String + 연산을 하면 "12", "123", "1234" ... 이런 데이터가 저장이 되었지만, StringConcatFactory는 이런 연속적인 문자열 합치는 것을 한번에 수행하면서 최적화가 되는 것입니다.

 

   L0
    LINENUMBER 8 L0
    LDC "1"
    ASTORE 1
   L1
    LINENUMBER 9 L1
    ALOAD 1
    INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/StringConcatFactory.makeConcatWithConstants(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
      // arguments:
      "\u000123456789"
    ]
    ASTORE 2

저는 str1 + "2" + "3' ... + "9" 까지 각각 문자열을 더했지만 이를 StringConcatFactory 에서 "23456789" 문자열로 하나로 합쳐 불필요한 메모리 낭비를 최적화 한겁니다. 그러니까 배열에서 하니씩 꺼내며 + 연산할때에는 최적화가 안되었던것이지요.

 

그러니까 String + 연산 은 절대하지말고 StringBuilder, StringBuffer나 쓰십쇼

들어가며

        int[] arr = {1,2,3,4,5};
        int sum = 0;
        
        // for문
        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }

        // 향상된 for문
        for (int i : arr) {
            sum += i;
        }

 

향상된 for문은 JAVA 5에서 추가되었습니다. 이전 for문 보다 가독성이 좋아졌고, 더욱 간결해졌습니다.

이번 글에서는 for문과 향상된 for문에 어떤 차이가 있는지 알아보도록 하겠습니다.

 

 

 

for문

        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }

이 부분에 대해서 bytecode를 확인해보겠습니다.

 

L3
FRAME APPEND [[I I I]
    ILOAD 3        // i 값 로드
    ALOAD 1        // 배열 arr 로드
    ARRAYLENGTH    // 배열 길이를 가져옴
    IF_ICMPGE L4   // i가 배열 길이와 같거나 크면 L4로 점프
L5
    ILOAD 2        // sum 값 로드
    ALOAD 1        // 배열 arr 로드
    ILOAD 3        // i 값 로드
    IALOAD        // 배열 arr[i] 값 로드
    IADD          // sum + arr[i] 계산
    ISTORE 2      // 결과를 sum에 저장
L6
    IINC 3 1      // i++
    GOTO L3       // 다시 조건 검사로 이동

 

바이트코드를 해석해본다면 인덱스 i를 매번 증가시키고, 배열의 해당 인덱스에 접근해서 값을 더하는 방식입니다.

중요한건 인덱스를 통해 값을 가져오는 방식이라는 것입니다. 인덱스 개념이 없는 HashMap 과 같은 데이터에서는 사용할 수 없습니다. 배열구조를 가진 데이터에서 데이터를 로드할 수 있습니다.

 

 

 

향상된 for문

 

 

L3
FRAME FULL [[Ljava/lang/String; [I I [I I I] []
    ILOAD 5        // 인덱스 로드
    ILOAD 4        // 배열 길이 로드
    IF_ICMPGE L4   // 인덱스가 배열 길이와 같거나 크면 L4로 점프
    ALOAD 3        // 배열 arr 로드
    ILOAD 5        // 인덱스 로드
    IALOAD        // 배열 arr[인덱스] 값 로드
    ISTORE 6       // 로컬 변수 6에 저장 (배열 요소)
L5
    ILOAD 2        // sum 값 로드
    ILOAD 6        // 로컬 변수 6(배열 요소) 로드
    IADD          // sum + 배열 요소 계산
    ISTORE 2      // 결과를 sum에 저장
L6
    IINC 5 1      // 인덱스 증가
    GOTO L3       // 다시 조건 검사로 이동

 

여기에서 눈 여겨볼 부분은 로컬 변수 6입니다. 일반 for문은 인덱스를 통해 데이터를 바로 sum에 저장하는 방식이었습니다.

하지만 향상된 for문은 로컬변수6에 arr[인덱스] 를 저장하고 로컬변수6을 sum에 저장하는 방식입니다.

 

왜 이런 번거로운 과정이 추가된걸까요?

 

 

Iterator 안전성

향상된 for문이 좋은점은 배열 뿐만이 아니고 Iterator 에서 동작할 수 있다는 것입니다. Iterable 은 Collection 인터페이스에서 상속 받고 있기때문에 컬렉션(List, Set, Map 등) 에서도 향상된 for문을 동작할 수 있다는 것이죠. 이를 위해 Iterator나 내부적인 인덱스 처리로 컬렉션을 순회하는데, 컬렉션의 상태가 중간에 변경될 가능성을 방지하는 안전한 구조가 필요하기 때문입니다.

 

        List<Integer> arr = List.of(1,2,3,4,5);
        int sum = 0;
        
        // 이 코드는
        for (int i : arr) {
            sum += i;
        }
        
        // Iterator 와 동일하다
        Iterator<Integer> iterator = arr.iterator();
        while (iterator.hasNext()) {
            sum += iterator.next();
        }

 

List의 향상된 for문

   L3
   FRAME APPEND [java/util/List I java/util/Iterator]
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
    IFEQ L4
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    ISTORE 4
   L5
    LINENUMBER 15 L5
    ILOAD 2
    ILOAD 4
    IADD
    ISTORE 2
   L6
    LINENUMBER 16 L6
    GOTO L3

 

 

List의 Iterator

   L3
    LINENUMBER 13 L3
   FRAME APPEND [java/util/List I java/util/Iterator]
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
    IFEQ L4
   L5
    LINENUMBER 14 L5
    ILOAD 2
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    IADD
    ISTORE 2
    GOTO L3

 

향상된 for문은 컴파일 시 iterator를 사용합니다.

 

 

재사용과 성능최적화

        for (int i : arr) {
            System.out.println(i);
            sum += i;
        }

for 문 안에서 i의 값을 여러번 사용된다면 어떻게 될까요?

 

   L3
   FRAME FULL [[Ljava/lang/String; [I I [I I I] []
    ILOAD 5
    ILOAD 4
    IF_ICMPGE L4
    ALOAD 3
    ILOAD 5
    IALOAD
    ISTORE 6
   L5
    LINENUMBER 11 L5
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ILOAD 6
    INVOKEVIRTUAL java/io/PrintStream.println (I)V
   L6
    LINENUMBER 12 L6
    ILOAD 2
    ILOAD 6
    IADD
    ISTORE 2
   L7
    LINENUMBER 10 L7
    IINC 5 1
    GOTO L3

 

L3에서 ISTORE 6 에 i 값을 저장하고

L5와 L6에서 ILOAD 6 을 통해 로컬변수 6을 로드해 재사용하고 있는 것을 확인할 수 있었습니다. 로컬변수6 데이터를 저장하므로써 단 한번 배열에 접근하는 것으로 다음의 데이터를 모두 처리할 수 있게되었습니다.

 

 

 

읽기전용

향상된 for문에서 배열이나 컬렉션의 요소를 순회할 때, 해당 요소는 로컬변수에 저장되기때문에 읽기 전용상태로 처리됩니다. 즉 복사본을 사용하기때문에 안전한 처리가 가능해집니다.

 

        int[] arr = {1,2,3,4,5};

        for (int i : arr) {
            i = 100;
        }

 

i 는 로컬변수에서 로드된 데이터이기 때문에 i에 직접 할당할 수 없습니다.

다만 참조타입인 경우 얕은복사로 인해 내부데이터의 변경은 가능합니다.

 

    public class Item {

        public int value;

        public Item(int value) {
            this.value = value;
        }
    }

    public static void main(String[] args) {
        List<Item> items = List.of(new Item(1), new Item(2));

        for (Item item : items) {
            item.value = 100;
        }

        for (Item item : items) {
            System.out.println(item.value); // 100
        }
    }

 

 

일관성

향상된 for문은 모든 배열과 Iterable 객체에서 동작하게 설계되었습니다.따라서 배열이 아닌 다른 컬렉션 객체를 다룰 때도 일관성 있게 순회하기 위해, 배열의 요소를 먼저 임시 변수에 저장하고 그 후에 작업을 수행하는 것이 통일된 방식이라고 할 수 있겠습니다.

 

 

 

 

마무리

향상된 for문은 시작, 끝 인덱스를 지정할 수 없습니다. 무조건 전체 순회만 가능하다는 것이죠.

그리고 순회 중에는 데이터를 삭제가 불가능합니다. 시작할 때 전체 길이를 저장하는데 중간에 전체길이가 달라지면 예외가 발생합니다.

 

일반 for문, 향상된 for문간에는 성능차이가 있을까요? 우리는 위에 bytecode를 직접 확인해봤습니다. 향상된 for문에서 추가적으로 로컬변수를 사용하는 차이가 있지만 이는 무시할 정도이고, 로컬변수 도입으로 인한 재사용성으로 인한 최적화도 무시할 정도라고 생각이 됩니다. 따라서 성능차이는 없다고 생각합니다.

 

 

결론은 향상된 for문은 Iterable 객체를 손쉽게 쉽게 사용하기 위해 만들어졌다! 그리고 컴파일 시에 로컬변수를 사용된다!

개요

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 문을 사용하는것도 고려해볼만 합니다.

들어가며

이 글에서는 StringBuilder, StringBuffer 차이점뿐만 아니라 사용 이유에 대해서도 다루고있습니다.

 

[JAVA] String의 불변성(immutability)

들어가며JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.  String str = "apple"; str = "banana";왜냐하면, 재할

tmd8633.tistory.com

 

이전 글에서 String의 불변성에 대해서 읽어보시는걸 추천드립니다.

 

 

불변성

String 은 불변성입니다.

StringBuffer, StringBuilder 는 가변성입니다.

 

        String str = "1";
        System.out.println("hashCode = " + str.hashCode());
        System.out.println("====================================");
        String[] arr = {"2", "3", "4"};
        for (String s : arr) {
            str += s;
            System.out.println("hashCode = " + str.hashCode());
        }
//        hashCode = 49
//        ====================================
//        hashCode = 1569
//        hashCode = 48690
//        hashCode = 1509442

 

String은 불변객체이기때문에 String Constant Pool에서 str += s 될때마다 불필요한 새로운 데이터를 heap 영역에 저장합니다.

System.out.println("12".hashCode()); // 1569

 

실제로 중간과정에서 만들어진 String의 hashCode를 가져와보면 Pool에서 불필요한 데이터가 저장된걸 볼 수 있습니다.

 

 

여기에서 StringBuffer or StringBuilder를 사용하면 어떻게 될까요?

        StringBuilder sb = new StringBuilder("1");
        System.out.println("hashCode = " + sb.hashCode());
        System.out.println("====================================");
        String[] arr = {"2", "3", "4"};
        for (String s : arr) {
            sb.append(s);
            System.out.println("hashCode = " + sb.hashCode());
        }
//        hashCode = 918221580
//        ====================================
//        hashCode = 918221580
//        hashCode = 918221580
//        hashCode = 918221580

가변객체이기때문에 hashCode가 변하지 않은것을 확인할 수 있습니다.

 

String의 불변특성때문에 문자열 더하기의 연산은 불필요한 데이터를 저장하게되며, hashCode의 캐싱에도 불필요한 데이터가 저장된다는 문제가 있습니다. 따라서 String의 문자열 더하기는 StringBuilder 나 StringBuffer를 사용하도록 해야합니다.

 

그러면 StringBuilder와 StringBuffer는 무슨 차이가 있을까요?

 

 

StringBuilder vs StringBuffer

 

Builder와 Buffer는 같은 추상클래스를 상속받고 있고, 그 구조가 완벽하게 동일합니다. 그 차이점은 구현체에서 찾을 수 있습니다.

 

StringBuffer는 동기화를 지원하고 (Thread-Safe),

StringBuilder는 동기화를 지원하지 않습니다.

 

 

 

StringBuffer 대부분의 메소드에는 synchronized 키워드가 붙어있습니다.

 

 

 

언제 어떻게 사용하면 될까?

 

StringBuffer

동기화를 지원하기때문에 멀티스레드 환경에서 안전하게 동작합니다. 동시성 문제를 고려해야한다면 StringBuffer를 사용해야합니다.

 

StringBuilder

StringBuilder는 StringBuffer 보다 일반적으로 성능상 빠릅니다. 동기화 처리가 없기 때문입니다. 따라서 단일스레드 환경에서는 불필요한 동기화 처리를 하지 않는 StringBuilder를 사용해야합니다.

 

 

 

사용 시 주의점

 

1. capacity

StringBuilder, StringBuffer 둘다 용량증가정책은 동일하게 설정되어있습니다. 이 capacity는 문자열의 수용량입니다. 기본 capacity는 16입니다.

 

 

 

Collection List를 최적화해보자

Collection List Collection중에 가장 많이 사용하는 것은 아마도 List일것입니다. List에는 여러 구현체가 있습니다. ArrayList, LinkedList, Vector 가 있는데 오늘 알아볼것은 ArrayList와 LinkedList입니다. 최적화

tmd8633.tistory.com

이전 글에서 List의 최적화를 다뤘는데 List의 initialCapacity와 같은 개념입니다.

 

 

append 메소드를 사용할때, capacity가 가득차면 추상클래스에서 ensureCapacityInternal 메소드를 통해 capacity를 2배증가시킵니다.

 

이 과정에서 StringBuffer는 동기화 처리과정에서 오버헤드가 발생할 수 있습니다. (락 획득과 해제, 락에 의한 대기시간 증가 등)

 

capacity는 생성자에서 사용자가 조절할 수 있습니다.

 

 

 

2. null 처리

        String str = null;

        StringBuilder sb = new StringBuilder("1");
        sb.append(str);
        System.out.println("sb = " + sb); // sb = 1null

        StringBuffer sb2 = new StringBuffer("1");
        sb2.append(str);
        System.out.println("sb2 = " + sb2); // sb2 = 1null

        String a = "1";
        a += str;
        System.out.println("a = " + a); // a = 1null

 

StringBuilder, StringBuffer 에 null이 append 되었을 때 따로 처리해주지 않습니다. null 이 들어오면 "null" 문자열로 인식되어 append 됩니다. 사용하기 전에 null 처리를 반드시 해야됩니다.

들어가며

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의 불변성과 관련이 있다는 것을 알아가는 시간이었습니다. 

 

+ Recent posts