먼저, 힙과 스택의 구조부터 알아보자.

+-------------------------+
|    Method Area          |  클래스 정보, static 변수
+-------------------------+
|         Heap            |  new로 생성한 객체 저장
| (모든 스레드 공유)       |
+-------------------------+
|       Stack (Thread 1)  |  메서드 호출마다 Frame 생성
| - 메서드 Frame           |  지역변수, 파라미터
| - 메서드 Frame           |  리턴 주소 등
+-------------------------+
|       Stack (Thread 2)  |  스레드마다 별도 Stack
| - 메서드 Frame           |
+-------------------------+
|     PC Register         |  현재 실행 중인 명령어 주소
| (스레드별)               |
+-------------------------+
| Native Method Stack     |  JNI 호출용
+-------------------------+

 

  • Heap: 객체(인스턴스) 저장 (공용 영역)
  • Stack: 메서드 호출 정보 저장 (스레드 프라이빗)

 


OOME, OutOfMemoryError

Java의 Heap 공간은 거의 모든 객체와 인스턴스가 저장되는 영역이다.

그러나 무한 루프 안에서 객체를 계속 생성하거나,

대규모 컬렉션(List, Map 등)을 관리하면서 참조를 끊지 않게 되면,

GC(Garbage Collector)가 불필요한 객체를 수거하지 못해 Heap 공간이 고갈된다.

이럴 때 OutOfMemoryError(OOME)가 발생한다.

 

대표적인 상황은

  • 무한 객체 생성
  • 캐시(메모리)에 데이터 무한 적재
  • 참조 해제 없이 객체를 쌓아두는 메모리 누수(Leak)

다음 코드 예시를 살펴보자.

import java.util.ArrayList;
import java.util.List;

public class OOMExample {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[10 * 1024 * 1024]); // 10MB 배열 계속 추가
        }
    }
}

 

위 코드는 10MB 짜리 byte 배열을 무한히 리스트에 추가하는 코드이다.

결국 GC가 치우지 못하고 Heap이 가득 차게 되면서 java.lang.OutOfMemoryError: Java heap space 가 발생한다.


SOE, StackOverflowError

Stack 영역은 각 스레드 프라이빗(스레드의 지역변수같은 느낌)하며,

하나의 스레드 안에서 발생하는 메서드 호출과 지역변수를 관리한다.

메서드가 호출될 때마다 Stack Frame이 쌓이는데,

이 호출이 탈출 조건 없이 무한 반복되거나,

메서드 호출 깊이가 지나치게 깊어지면 Stack 크기를 초과하여

StackOverflowError(SOE)가 발생한다.

 

대표적인 상황은

  • 탈출 조건이 없는 재귀 호출
  • 너무 깊은 메서드 체인 호출
  • 의도치 않은 순환 호출(loop)

다음 예시 코드를 살펴보자.

public class SOEExample {
    public static void main(String[] args) {
        recursiveMethod();
    }

    private static void recursiveMethod() {
        recursiveMethod(); // 탈출 조건 없는 재귀 호출
    }
}

 

 

위 코드는 recursiveMethod()가 끝나지 않고 자기 자신을 계속 호출하는 코드이다.

Stack Frame이 무한히 쌓이다가 Stack 공간을 초과하게 되면서, java.lang.StackOverflowError 가 발생한다.

 


정리

구분 OutOfMemoryError StackOverflowError
발생 위치 Heep or Method Area 등 Stack
원인 객체를 너무 많이 생성해서 Heap 공간 부족 메서드 호출이 너무 깊어져 Stack 공간 초과
대표 상황 - 무한 객체 생성
- 메모리 누수(Leak)
- 재귀 호출 무한 루프
- 메서드 깊이 너무 깊음
해결 방법 - GC 튜닝
- 객체 수명 관리
- Heap 사이즈 조정
- 재귀 줄이기
- 코드 개선
- Stack 사이즈 조정
에러 메시지 java.lang.OutOfMemoryError java.lang.StackOverflowError

 

자바에서는 객체들끼리 비교를 할 수 있도록 하는 인터페이스를 제공한다.

그런데 두 개나 있다!

 

왜 두개나 있으며, 두 인터페이스는 어떻게 쓰일까?

 

표로 먼저 알아보자.

구분 Comparable Comparator
정의 위치 객체 내부 (compareTo) 외부에서 별도로 구현 (compare)
사용 목적 기본 정렬 기준 정의 다른 정렬 기준 추가 정의
정렬 방식 Collections.sort(list) Collections.sort(list, comparator)
인터페이스 메서드 int compareTo(T o) int compare(T o1, T o2)

Comparable - 객체 스스로 비교 기준을 가짐

class Person implements Comparable<Person> {
    String name;
    int age;

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

    @Override
    public int compareTo(Person other) {
        return this.age - other.age; // 나이 오름차순 정렬
    }
}

 

Comparable 은 특정 객체를 생성했을 때, 해당 객체의 Override 메소드로써 호출이 가능하다.

Comparable 인터페이스를 상속(implements)한 뒤에, 해당 객체를 PriorityQueue(우선순위 큐)에 넣으면 자동으로 compareTo 메서드의 기준대로 정렬이 되어서 큐에 삽입 된다.

 

즉, 가장 처음에 poll() 해서 나오는 값은, compareTo 메서드의 기준에 따라 가장 앞 순위에 배치된 Person 객체일 것이다 !

이때, compareTo 객체는, 더 적은 정수값이 나온 객체가 더 우선순위를 갖는다.

List<Person> list = new ArrayList<>();
Collections.sort(list); // Person 내부 compareTo 기준으로 정렬

 

구체적으로 위와 같이 사용된다.


Comparator - 외부에서 비교 기준을 정함

Comparator<Person> byName = new Comparator<Person>() {
    @Override
    public int compare(Person a, Person b) {
        return a.name.compareTo(b.name); // 이름 기준 정렬
    }
};

 

독립적인 전략 객체처럼 사용되며, 주로 외부에서 사용된다.

즉, 정렬 전략을 외부에서 넘겨주는 독립 객체인 것이다!

Collections.sort(list, byName); // 이름 기준으로 정렬
list.sort((a, b) -> a.name.compareTo(b.name)); // 혹은 람다로 이렇게 표현도 가능!

 

위와 같이 사용된다.


왜 두개나 있을까?

왜냐하면.. 두 인터페이스의 목적이 다르기 때문이다!

구분 설명
Comparable 정렬 기준을 가진 객체 스스로가 구현하는 인터페이스 (implements Comparable<T>)
Comparator 외부에서 독립적으로 정의하는 비교자 객체 (implements Comparator<T>)

기본적으로 Java는 무조건 call by value이다

객체든 원시타입이든 전부 `값`으로 전달된다.

하지만, 객체일 경우에는 `참조값(reference)`이 값으로 전달된다.


기본 타입

void change(int x) {
    x = 100;
}

int a = 10;
change(a);
System.out.println(a); // 결과는 10이 나온다. 100으로 변하지 않는다.

 

보통은 완전한 값 복사의 형태를 띄기 때문에, a 의 값은 바뀌지 않는다.

 

그러나..

참조 타입(객체)

class Box { int value = 10; }

void change(Box b) {
    b.value = 100;
}

Box box = new Box();
change(box);
System.out.println(box.value); // 100 이 출력된다! 값이 바뀐것이다.

 

파라미터 Box b 는 Box box 객체를 가리키는 참조값을 복사한다.

따라서 b.value 의 값을 바꾸면, box.value 의 값도 바뀌는 것이다.


이에 따른 DFS 에서 주의할 점.

Fish[][] copiedTank = originalTank; // 얕은 복사

 

위 이중 배열에는 Fish 객체가 정의되어 있다.

기존 originalTank 이중 배열을 복사하려는 로직이지만, 이렇게되면 의미가 없어진다.

 

왜냐하면 복사한 배열인 copiedTank 는 originalTank 의 참조값을 가져올 것이기 때문에,

copiedTank 내부의 Fish 객체를 수정하면 자동으로 originalTank 내부의 Fish 객체도 수정되어버린다.

 

이때 DFS는, 분기별로 다른 상태값을 가지면서, 다시 이전 상태로 돌아가(BackTraking) 새로운 분기 상태 객체를 만들어야 한다. 그런데 객체 배열 복사를 위와 같이 해버리면, 다시 이전 상태로 돌아갈 수 없게 된다. 이미 원본 객체도 수정되어 버렸기 때문이다.

 

그래서 깊은 복사를 해야한다.

Fish[][] copy = new Fish[4][4];
for (...) {
    copy[i][j] = original[i][j] == null ? null : new Fish(...);
}

 

매 객체를 순회하면서 Fish 객체를 새로 생성해주는 깊은 복사를 해주어야만 한다.


그런데 변화할 Fish 객체가 적다면, 매번 모든 배열을 복사하는 것은 메모리에 부하가 크다.

거기다가 상태를 다르게 가져가야하는 분기점의 수가 많다면 상황은 더 악하된다.

 

이 때 사용하기 좋은 것이 in-place + BackTracking 전략이다.

어떤 전략일까? 바로..

 

별도의 상태 복사 없이 현재 상태를 그대로 사용하면서,

재귀 호출 전과 후에 직접 원상복구하는 방식

 

void dfs(state) {
    // 현재 상태 변경
    변경하기();

    // 다음 상태로 재귀
    dfs(nextState);

    // 상태 원상복구 (backtrack)
    복구하기();
}

 

DFS 란, 재귀를 하면서 해당 분기의 모든 처리가 끝난 다음에, 다시 이전의 메소드로 돌아간다(BackTracking).

 

다시 이전의 메소드(상태)로 돌아갔을 때, 상태가 일관적이어야 하므로,

다시 다른 분기를 타기전에 상태를 원상복구 해주는 것이다.

 

간단한 예시를 들자면 다음과 같다.

Fish eatenFish = tank[x][y];
int[] prevPos = fishPos[eatenFish.num].clone();

// 상태 변경
tank[x][y] = null;
fishPos[eatenFish.num][0] = -1;

// DFS 호출
dfs(...);

// 복구
tank[x][y] = eatenFish;
fishPos[eatenFish.num] = prevPos;

 

그러나 단점도 존재한다.

 

먼저 코드가 복잡해질 가능성이 존재하고, 디버깅이 어렵다는 점이다.


결론

자바에서 객체는 얕은 복사로 이루어진다.

따라서 DFS를 수행하며 객체를 복사할 때는 깊은 복사를 해야한다.

 

그러나 깊은 복사는 메모리 초과를 일으킬 수 있는 상황이 존재한다.

그럴 땐 in-place + BackTraking 전략을 사용하자.

 

그러나 이 전략은 이럴 때만 사용하자.

  • 변경할 상태가 적고 단순할 때
  • 배열이 크고, 깊은 복사에 대한 비용이 클 때
  • 복구 로직이 간단할 때

그러나 이럴 때는 조심하자.

  • 상태가 얽혀 있고 복잡할 때
  • 배열이 그렇게 크지 않을 때
  • 많은 상태가 변경되고, 복구 로직이 복잡할 때

먼저 프록시(Proxy)란?

프록시란, 주로 실제 객체 앞에서 실행되는 대리 객체처럼 이해하면 된다.

 

다음 코드를 보면, RealService 객체에 대한 지연 초기화(Lazy Initialization)를 구현하고 있는 Proxy를 확인할 수 있다. 이는 실제 객체의 값을 캐싱하는 역할을 한다.

package proxy;

import lombok.extern.slf4j.Slf4j;

public class ProxyTest {
    public static void main(String[] args) {
        CertainService service = new CacheProxy();
        System.out.println(service.call());
        System.out.println(service.call());
    }
}

@Slf4j
class CacheProxy implements CertainService {
    private RealService service;
    private String cachedResult;

    @Override
    public String call() {
        if (cachedResult == null) {
            if (service == null) {
                service = new RealService();
            }
            cachedResult = service.call();
        }
        else {
            log.info("Returning cached result.");
        }
        return cachedResult;
    }
}

@Slf4j
class RealService implements CertainService {
    @Override
    public String call() {
        log.info("HELLO, I'M REAL SERVICE.");
        return "R E A L !! ";
    }
}

interface CertainService {
    String call();
}

 

프록시는 객체지향의 SOLID 원칙 중, SRP 과 OCP 를 잘 따를 수 있는 구조이다.

 

SRP, 즉 Single Responsibility Principle, 단일 책임원칙.

프록시에는 다양한 역할이 있다. 내가 상단에 작성한 코드는 단순히 값을 캐싱하는 용도로 작성했지만, 캐싱 용도 이외에도 다른 부가기능들에 대해서도 생성할 수가 있다. 또한 프록시는 연쇄적으로 연결하여 작성하는 것도 가능하다.

 

OCP, 즉 Open-Closed Principle, 개방-폐쇄 원칙.

개인적으로 내가 생각하는 프록시의 가장 강력한 존재 이유이다. 프록시는 기존 객체를 변경하지 않고 기능을 추가한다. 즉, 확장에는 열려있고 수정에는 닫혀있다.

 

 

그렇다. 프록시는 다양하게 확장적으로 존재할 수 있다.

그렇기 때문에 동적 프록시가 필요한 것이다. 프록시들은 정말 다양하고 많이 존재할 수 있기 때문이다.

 

동적 프록시를 사용하면 컴파일 시점에 프록시 객체를 만들지 않아도 된다.

동적 프록시는, 인터페이스만 알고 있다면, 런타임 시점에 다양한 프록시 로직을 적용할 수 있다.

 

InvocationHandler, 동적 프록시를 생성하는 방법.

InvocationHandler는 java.lang.reflect 패키지에 포함되어 있는 인터페이스이다.

그렇다. Java 리플랙션의 라이브러리에 포함되어 있다. 이 말의 뜻은, 메타데이터를 이용한다는 뜻이다.

 

(혹시 Java 리플랙션이 뭔지 모른다면 다음 포스팅을 참고)

https://jinn-o.tistory.com/292

 

Java 리플랙션(Reflection) 에 대하여

어떤 로직은 런타임 시점에 실행되어야 한다.컴파일과 동시에 확정되어 버리는 코드는, 가끔은 유연성과 확장성 관점에서 성능이 떨어질 수 있다. 일부 애플리케이션에서는 외부 설정 파일이나

jinn-o.tistory.com

 

package proxy;

import lombok.extern.slf4j.Slf4j;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class ProxyTest {
    public static void main(String[] args) {
        // 실제 서비스
        CertainService realService = new RealService();

        // 동적 프록시 - 캐스팅 해주어야함
        CertainService proxyInstance = (CertainService) Proxy.newProxyInstance(
                realService.getClass().getClassLoader(),
                new Class<?>[]{CertainService.class},
                new CacheProxy(realService)
        );

        proxyInstance.call();
    }
}

@Slf4j
class CacheProxy implements InvocationHandler {
    private final Object target;

    public CacheProxy(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("CacheProxy 실행");
        long startTime = System.currentTimeMillis();

        // 메인 로직
        Object result = method.invoke(target, args);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("CacheProxy 종료 resultTime={}", resultTime);
        return result;
    }
}

@Slf4j
class RealService implements CertainService {
    @Override
    public String call() {
        log.info("HELLO, I'M REAL SERVICE.");
        return "R E A L !! ";
    }
}

interface CertainService {
    String call();
}

 

InvocationHandler 인터페이스는 invoke(...) 메서드를 통해서 로직을 가로챈다.

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

 

 

  • proxy
    • 현재 호출을 처리하는 프록시 객체 자체
    • Proxy.newProxyInstance를 통해 생성된 객체
    • 일반적으로 이 프록시 객체는 invoke 메서드 내에서 직접 사용되지 않지만, 재귀 호출이나 프록시 객체에 대한 참조가 필요한 경우 사용
  • method: 호출된 메서드를 나타내는 Method 객체
    • 프록시 객체에서 호출된 메서드를 나타내는 리플렉션 객체로, 호출된 메서드의 이름, 매개변수 타입, 리턴 타입 등의 메타데이터를 가지고 있다. 이를 통해, 어떤 메서드가 호출되었는지 확인하고, 특정 메서드에 대해 조건을 추가하거나 동작을 달리할 수 있다.
    • method.invoke(target, args) 형태로 사용하면 프록시를 통해 실제 객체의 메서드를 호출
  • args: 호출된 메서드의 인자 값들을 포함한 배열
    • 호출된 메서드에 전달된 실제 인자 값들을 배열 형태로 받는다. (null 가능)
    • 이를 활용해 인자 값에 따라 다른 로직을 수행하거나, 인자 값 자체를 수정하여 실제 메서드에 전달할 수 있다.

 

Proxy.newProxyIntance(...) 메서드에 대하여

프록시 객체를 생성하는 JDK 메서드이다. 이 메서드는 클래스 로더, 인터페이스, 호출 핸들러 의 세가지 파라미터를 받아 동적 프록시를 생성한다. 

public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException

 

 

  • ClassLoader loader:
    • 프록시 클래스를 로드하는 데 사용될 클래스 로더
    • 일반적으로 대상 객체의 클래스 로더(target.getClass().getClassLoader())를 전달
    • 프록시 클래스가 어떤 클래스나 인터페이스의 이름을 참조할 때, 해당 클래스나 인터페이스가 어디에 있는지 이 클래스 로더를 통해 알아낸다.
  • Class<?>[] interfaces:
    • 프록시가 구현할 인터페이스 목록
    • Proxy.newProxyInstance는 이 인터페이스 목록을 기반으로 프록시 객체의 구조를 정의한다. 즉, 프록시 객체는 여기서 지정된 인터페이스들에 선언된 메서드를 구현하는 것이다.
    • Class<?>[] 배열이므로, 여러 인터페이스를 동시에 지정할 수 있다. 단, 프록시는 인터페이스를 기반으로 생성되므로 구현할 인터페이스가 반드시 필요(클래스를 직접 상속할 수는 없습니다).
    • 예를 들어, new Class<?>[]{ServiceA.class, SomeOtherInterface.class}처럼 전달하면 프록시가 ServiceA와 SomeOtherInterface를 동시에 구현
  • InvocationHandler h:
    • 프록시 메서드 호출을 처리하는 핸들러 객체로, InvocationHandler 인터페이스를 구현한 객체
    • 프록시의 모든 메서드 호출이 InvocationHandler의 invoke() 메서드로 전달되며, 이 메서드에서 메서드 호출 전후로 로직을 추가하거나 실제 메서드를 호출하는 작업을 처리
    • 이 핸들러를 통해 프록시 메서드의 호출 흐름을 제어하고, 추가적인 기능(로깅, 트랜잭션 관리 등)을 수행

 

 

핵심은, 동적 프록시를 이용하면, 프록시들을 미리 구체화시키지 않아도 된다는 것이다.

스프링에서는 동적 프록시를 통해 AOP 기능을 구현하여, 로깅, 트랜잭션, 보안 등을 런타임에 자동으로 주입하고 있다. 이로써 개발자는 비즈니스 로직에 집중할 수 있으며, 프록시 로직을 미리 구현하지 않아도 다양한 부가 기능을 필요에 따라 적용할 수 있는 것이다.

어떤 로직은 런타임 시점에 실행되어야 한다.

컴파일과 동시에 확정되어 버리는 코드는, 가끔은 유연성과 확장성 관점에서 성능이 떨어질 수 있다. 일부 애플리케이션에서는 외부 설정 파일이나 사용자 입력을 통해 런타임에 클래스나 메서드를 로드하고 실행할 필요가 있다.

 

패키지 java.lang.reflect

 

Java 내장 라이브러리인 reflect 패키지에는 여러 클래스가 존재한다.

우리는 이 패키지에 있는 클래스들을 활용하여 메타데이터에 접근할 수 있다.

 

객체의 메타데이터에 접근하기

package reflection;

import lombok.extern.slf4j.Slf4j;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

@Slf4j
public class ReflectionTest {

    public static void main(String[] args) throws Exception {
        reflection();
    }

    static void reflection() throws Exception {
        // 클래스 정보
        Class<?> aClass = Class.forName("reflection.A");
        log.info("aClass = {}", aClass);

        // 인스턴스 생성
        Object aInstance = aClass.getDeclaredConstructor().newInstance();

        // 메서드 정보
        Method callA = aClass.getDeclaredMethod("call", String.class);
        Object result = callA.invoke(aInstance, "hello, I'm A.");
        log.info("result={}", result);
    }
}

@Slf4j
class A {
    public String call(String str) {
        log.info("A 클래스의 call 메서드입니다={}", str);
        return "A";
    }
}

 

이런식으로 클래스나 메서드 등의 메타데이터에 접근하는 것이 가능하다.

 

그래서 리플렉션, 왜 필요할까? 언제 쓰이는데?

  1. 동적인 객체 조작 및 유연한 코드 작성
    • 런타임에 클래스, 메서드, 필드에 동적 접근하여 유연하개 객체를 조작할 수 있다.
  2. 프레임워크와 라이브러리의 자동화 기능 구현
    • Spring, Hibernate와 같은 프레임워크에서 특히 이 개념을 중요하게 핵심적으로 사용하고 있다. 리플렉션을 통해 동적으로 객체를 생성하고, 의존성을 주입하며, 트랜잭션을 관리한다.
    • 예를 들어, Spring에서는 @Autowired 어노테이션을 통해 필요한 객체를 의존성 주입할 때 리플렉션을 사용하여 해당 필드에 접근하고 값을 설정한다. 이처럼 리플렉션은 프레임워크에서 코드 자동화와 의존성 관리 기능을 제공하는 핵심 기술이다.
  3. 플러그인 및 확장 가능 시스템 구현
    • 리플렉션은 런타임에 외부 플러그인이나 모듈을 로드하고, 동적으로 기능을 확장할 수 있도록 돕는다.
    • 예를 들어, IDE(통합 개발 환경)에서 플러그인을 동적으로 로드하여 새로운 기능을 추가할 때 리플렉션이 사용된다. 이를 통해 프로그램 실행 중에도 새로운 기능을 추가할 수 있다.
  4. 어노테이션 기반의 동적 로직 처리
    • 어노테이션을 통해 클래스나 메서드에 특정 로직을 적용할 때 리플렉션이 사용된다. 애플리케이션이 런타임에 특정 어노테이션이 붙은 클래스, 메서드, 필드 등을 탐색하고, 그에 따른 특정 로직을 수행할 수 있게 하기 위해서이다.
    • isAnnotationPresent 메서드를 사용해 특정 어노테이션이 있는지 확인하거나, **getAnnotation**을 사용해 어노테이션 정보를 가져올 수 있다.
    • Spring에서는 리플렉션을 사용해 @Autowired가 붙은 필드를 찾고, IoC 컨테이너에서 적합한 빈을 주입
    • 예를 들어, @Transactional 어노테이션이 붙은 메서드를 실행할 때 리플렉션을 통해 어노테이션을 감지하고, 메서드 호출 전후에 트랜잭션을 시작하고 종료하는 로직을 추가할 수 있다.
  5. 테스트 및 디버깅에서의 유용성
    • 리플렉션을 통해 비공개(private) 필드나 메서드에도 접근할 수 있기 때문에, 테스트 코드 작성이나 디버깅 시에 내부 상태를 쉽게 확인할 수 있다.

 

그러나 리플랙션을 남발하면 안된다. 리플랙션은 특수한 상황에서만 쓰이는 것이다.

 

리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다. 하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.

 

가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고, 가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류이기 때문이다.

1. 입출력(I/O)은 비싸다.

Java에서의 I/O 작업은 데이터가 메모리에서 직접 처리되는 것이 아니라, 외부 장치(파일 시스템, 네트워크, 콘솔 등)과의 통신이 필요하므로 시간이 많이 걸립니다.

 

이 때, 일반적으로 메모리(RAM)에서 데이터를 읽는 것은 매우 빠른 속도로 데이터를 처리할 수 있지만, 디스크(HDD, SDD)같은 외부 장치는 메모리에 비해 속도가 훨씬 느릴 수 밖에 없습니다. 왜냐하면 디스크 I/O 작업은 시스템 호출(system call)이라는 작업을 필요로 하는데, 이 시스템 호출은 사용자 모드에서 실행되는 애플리케이션 코드가 운영체제 커널 모드로 전환되어 작업을 처리하는, 이른바 컨텍스트 스위칭(context switching)이 발생하게 됩니다. 이 과정은 추가적인 오버헤드를 발생시키며, CPU 성능에 부담을 줍니다. 

 

또한, 대부분의 I/O 작업은 블로킹(blocking) 방식으로 동작하기 때문에, 데이터가 완전히 읽히거나 쓰일 때까지 해당 프로그램이 대기해야 합니다. 예를 들어, 파일로부터 데이터를 읽거나 네트워크로부터 응답을 기다리는 동안 프로그램은 그 작업이 완료될 때까지 다른 작업을 진행할 수 없습니다. 이는 프로그램의 전체 성능에 영향을 미칠 수 밖에 없는 것입니다.

 

2. 버퍼(buffer)의 등장

버퍼는 일정 크기의 메모리 공간을 미리 할당하여, 데이터를 모아둔 뒤 한꺼번에 처리하는 방식을 말합니다. 이렇게 한꺼번에 모아서 데이터를 처리하기 때문에, I/O 작업의 빈도를 줄일 수 있게 됩니다.

 

3. 그래서 BufferedReader 란?

버퍼(buffer)를 통해 효율적으로 입력을 처리하는 Java 클래스의 일종입니다. 이 클래스는 데이터를 한 줄씩 읽는 데 최적화되어 있으며, 이를 통해 많은 데이터를 읽는 작업을 효율적으로 수행할 수 있습니다.

 

4. BufferedReader 와 함께 쓰이는 InputStream 은 왜 붙는걸까?

보통 BufferedReader를 이용해서 작업을 처리할 때 다음과 같이 코드를 작성하곤 합니다.

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

 

BufferedReader 클래스는 입력 스트림(InputStream)으로부터 데이터를 미리 읽어와 버퍼에 저장해 둡니다. 프로그램이 데이터를 필요로 할 때마다 매번 I/O 작업을 수행하는 대신, 미리 읽어둔 데이터를 버퍼에서 꺼내 사용하므로 I/O 작업 횟수가 줄어들고, 성능이 향상됩니다.

 

5. 입력 스트림(InputStream)이란?

입력 스트림은 말그대로, 데이터의 흐름을 말합니다. 주로 파일 등의 외부 자원으로부터 프로그램 내부로 데이터를 전달할 때 사용됩니다. Java 에서는 InputStream 클래스나 그 하위 클래스인 FileInputStream 을 통해 데이터를 읽어들이는 작업을 수행합니다. 그러나! 입력 스트림은 연속적으로 한 바이트씩 데이터를 읽어오기 때문에, 큰 데이터나 빈번한 I/O 작업에서는 비효율적입니다.

 

6. 그래서 BufferedReader가 필요한 것이다.

BufferedReader는 입력 스트림에서 데이터를 미리 일정량 읽어와서 버퍼(buffer)라는 메모리 공간에 저장해 둡니다. 이렇게 하면 데이터를 필요할 때마다 I/O 작업을 직접 수행하는 것이 아니라, 이미 메모리에서 저장된 데이터를 가져오기 때문에 성능이 크게 향상됩니다.

 

7. 그럼 InputStreamReader는 뭘까? 왜 굳이 이걸 쓰는거지?

InputStreamReader는 바이트 스트림(InputStream)을 문자스트림(Reader)으로 변환해주는 중간 다리 역할을 하는 클래스입니다. 자바에서 InputStream은 바이트 단위로 데이터를 처리하는 반면, Reader는 문자 단위로 데이터를 처리합니다. 따라서, 이 두 스트림을 연결한 InputStreamReader는 바이트 데이터를 문자로 읽을 수 있게 변환하는 기능을 제공합니다.

 

- 바이트 스트림 (InputStream) 클래스 예시 : InputStream, FileInputStream

- 문자 스트림 (Reader) 클래스 예시 : Reader, FileReader

 

따라서 InputStreamReader를 사용하는 이유는, 바이트 데이터를 문자로 변환하는 데 필수적인 클래스이기 때문입니다. 특히 파일이나 네트워크에서 바이트 단위로 데이터를 읽어와야 할 때, 이를 사람이 읽을 수 있는 문자 데이터로 변환하기 위해 사용됩니다. 문자 인코딩 방식을 지정할 수 있는 유연성 덕분에 다양한 데이터 소스와 결합하여 사용할 수 있으며, 효율적으로 데이터를 처리할 수 있습니다.

 

8. 그래서 BufferedReader를 사용하면 readLine()을 사용할 수 있는 것이다.

버퍼링을 통해 문자 데이터를 효율적으로 관리하고 있기 때문이다. !!

Stack 이란?

Java에서 사용하는 컬렉션 자료구조 중 하나로, LIFO(Last In First Out) 정책을 따르는 선형 자료구조형이다. 즉, 가장 최근에(마지막에) 삽입된 요소가 가장 빨리 나오는 자료구조이다. 이러한 특성 때문에 스택은 일상 생활에서 접할 수 있는 접시 쌓기나 페이지 방문 기록과 같은 활동을 수학적으로 모델링할 때 자주 사용된다. 알고리즘에서는 대표적으로 괄호 쌍 맞추기 문제 등이 있다.

 

주요 연산에는 다음이 있다.

 

Push
스택에 새로운 요소를 추가 (스택의 최상단(맨 위)에 요소를 놓는다.)
Pop

스택에서 최상단 요소를 제거하고 그 값을 반환 (스택이 비어 있을 경우 오류 발생)
Peek 또는 Top

스택의 최상단 요소를 반환하지만 제거하지는 않는다. (현재 스택의 가장 위에 있는 요소를 확인할 수 있다.)
IsEmpty

스택이 비어 있는지 확인 (비어 있다면 true, 아니면 false를 반환)
Size

스택에 저장된 요소의 수 반환

 

 

 

Stack을 구현할 때 Java에서 제공하는 두 가지 자료구조 클래스

 

Stack 클래스

java.util.Stack 클래스는 Java 1.0부터 있던 전통적인 스택 구현이다.

이 클래스는 Vector 클래스를 상속받으며, LIFO (Last-In-First-Out) 스택의 동작을 구현한다.

 

ArrayDeque 클래스

java.util.ArrayDeque는 배열 기반의 더블 엔디드 큐 (deque)를 구현하며, Java 6부터 도입되었다.

ArrayDeque는 스택과 큐의 기능을 모두 지원한다.

 

 

Stack 설계시, ArrayDeque 클래스가 권장되는 이유

 

1. 성능

ArrayDeque는 내부적으로 배열을 사용하여 요소를 저장하며, 이 배열은 필요에 따라 동적으로 확장한다. 또한 양방향에서의 추가 및 삭제를 모두 상수 시간 O(1)에 처리할 수 있다. 물론, Stack 클래스도 Vector 클래스를 상속받아 배열이 가득 차면 두 배로 확장하는 방식을 사용하긴 하지만, 이 둘의 확장 성능에는 차이가 있다. 그 이유는 Stack이 Vector 클래스를 상속받는다는 점이다.

 

Vector 클래스의 모든 공개 메소드는 synchronized 키워드로 선언되어 있다. 이는 Vector가 동기화된 메소드를 사용하기 때문에 멀티 스레드 환경에서 안전하지만, 단일 스레드 환경에서는 불필요한 성능 저하를 초래할 수 있다. 모든 작업에 동기화 오버헤드가 추가되기 때문에 상대적으로 ArrayDeque보다 느릴 수 있다. 특히, Vector의 메소드 호출마다 동기화 락을 획득하고 해제하는 비용이 발생한다.

 

2. 메모리 사용

ArrayDeque는 Stack과 동일하게 필요에 따라 내부 배열의 크기를 조절하지만, 원형 배열을 사용하여 메모리를 보다 효율적으로 사용하며, 필요할 때만 배열을 확장한다. 두 자료구조 모두 기본적으로 용량이 가득 찼을 때마다 용량을 두 배로 확장하는데, 이 둘의 차이점은 원형 큐의 사용 유무에 있다.

 

  • 원형 큐의 Head 와 Tail
    ArrayDeque는 'head'와 'tail' 포인터를 사용하여 큐의 시작과 끝을 관리하기 때문에, 요소를 추가하거나 제거할 때 이 포인터들을 조정하여, 배열의 물리적인 시작이나 끝에 구애받지 않고 요소를 처리할 수 있다.
  • 배열 재활용
    배열의 한 쪽 끝에 공간이 부족해지면, 다른 쪽 끝의 여유 공간을 활용할 수 있다. 이는 삽입이나 삭제가 빈번히 일어나는 양방향에서 효율적이다.
  • 확장 시점의 최적화
    ArrayDeque는 배열의 확장이 필요한 시점을 좀 더 유연하게 조절할 수 있다. 예를 들어, 한쪽 끝에 도달했을 때 전체 배열을 확장하지 않고 다른 쪽 끝의 여유 공간을 먼저 사용할 수 있다.

 

3. 유연성과 현대적인 API

ArrayDeque는 Deque 인터페이스를 구현하므로, 스택(Stack) 및 큐(Queue)의 기능을 모두 지원한다. 이를 통해 push, pop, peek 등 스택의 연산을 지원하면서도 offer, poll 등 큐의 연산도 사용할 수 있어 활용 범위가 넓다.


Stack은 Java 초기의 오래된 클래스이며, 현대의 컬렉션 프레임워크와 일관성이 떨어진다. 또한, Stack은 Vector의 메소드를 모두 상속받기 때문에 스택에 필요하지 않은 많은 메소드들이 노출된다.

 

4. Best Practices 권장

Java 커뮤니티와 공식 문서는 스택 구현을 위해 Stack 클래스보다는 ArrayDeque 사용을 권장한다. 이는 ArrayDeque가 더 현대적이고, 성능이 뛰어나며, 기능적으로 더 유연하기 때문이다.

 

 

Java 컬렉션 프레임워크?

Java의 컬렉션 프레임워크는 1998년에 출시된 Java 2 Platform, Standard Edition (J2SE)에 포함된 기능으로, Java 1.2 버전에서 처음 도입되었다. 이는 프로그래머가 데이터를 효율적으로 조작할 수 있도록 돕는 매우 중요한 부분이다. 데이터를 저장, 검색, 정렬, 조작 및 관리할 수 있는 다양한 방법을 제공함으로써, Java 프로그래밍의 생산성과 효율성을 크게 향상시킨다. 실제로 대부분의 Java 어플리케이션에서는 데이터 컬렉션을 다루는 일이 매우 흔하며, 이러한 작업을 위해 컬렉션 프레임워크를 활용하는 것이 일반적이다. 따라서, 이를 이해하고 사용할 수 있는 능력은 Java 개발자에게 필수적인 기술로 간주된다.  이전의 자바 버전에서 제한적이고 비효율적이었던 여러 데이터 구조를 표준화하고 개선하여 통합된 프레임워크를 제공 다양한 유형의 데이터 구조를 제공하여, 개발자가 데이터를 효과적으로 관리할 수 있게 돕는다. 이 데이터 구조들은 주로 java.util 패키지 안에 정의되어 있다.

 

다음은 Java 컬렉션 프레임워크들의 종류이다.

알고리즘 문제를 풀 때 각 특성에 맞게 적절하게 사용하도록 하자.

 

List 인터페이스

순서가 있고 중복을 허용하는 컬렉션

  1. ArrayList
    배열 기반의 리스트 구현으로, 무작위 접근이 빠르다.
  2. LinkedList
    이중 연결 리스트 기반의 구현으로, 데이터 추가 및 삭제가 빈번할 때 유리하다.
  3. Vector
    ArrayList와 비슷하지만, 동기화가 지원된다.
  4. Stack
    LIFO (Last In First Out) 구조를 지원하는, Vector를 확장한 클래스이다.

 

Set 인터페이스

중복을 허용하지 않는 컬렉션

  1. HashSet
    해시 테이블을 사용하여 요소를 저장하며, 순서를 보장하지 않는다.
  2. LinkedHashSet
    해시 테이블과 연결 리스트를 결합하여, 요소의 삽입 순서를 유지한다.
  3. TreeSet
    레드-블랙 트리 구조를 사용하여 요소를 정렬 상태로 저장한다.

 

Queue 인터페이스

순서대로 요소를 처리하는 컬렉션

  1. LinkedList
    Queue 인터페이스도 구현하며, FIFO (First In First Out) 구조를 지원한다.
  2. PriorityQueue
    우선순위 큐를 구현하며, 요소들이 자연스러운 순서나 제공된 Comparator에 따라 정렬된다.

 

Map 인터페이스

키와 값의 쌍으로 데이터를 저장하는 컬렉션

  1. HashMap
    키에 대한 해시 테이블을 사용하여 데이터를 저장하며, 순서를 보장하지 않는다.
  2. LinkedHashMap
    해시 테이블과 연결 리스트를 결합하여, 요소의 삽입 순서 또는 접근 순서를 유지한다.
  3. TreeMap
    레드-블랙 트리를 사용하여 키에 따라 정렬된 상태로 데이터를 저장한다.

Collections 클래스와 Collection 인터페이스는 둘다 Java의 java.util 패키지에 속하지만, 서로 다른 역할을 수행한다.

 

Collection 인터페이스

Collection은 Java 컬렉션 프레임워크의 가장 기본이 되는 인터페이스이다.

List, Set, Queue 등이 이 인터페이스를 구현하고 있다.

 

Collections 클래스

Collections는 인터페이스가 아닌 유틸리티 클래스이다.
Collection 인터페이스를 구현하는 객체들을 다루기 위한 정적 메소드(정렬, 탐색, 동기화, 변경 불가능 설정 등)를 제공한다. 말그대로 따로 구현체가 있다기보다는 특정 작업을 지원해주는 클래스인 것이다.
예를 들어, Collections.sort(), Collections.synchronizedList(), Collections.unmodifiableList()와 같은 메소드들은 컬렉션 객체들을 조작하거나 변환하는 데 사용된다.

 


Collections 클래스와 Collection 인터페이스의 관계

Collections 클래스는 Collection 인터페이스를 구현하는 다양한 객체들을 조작하는 데 사용되는 도구집이다.

Collections 클래스는 Collection 인터페이스를 직접 구현하거나 확장하지 않지만, Collection 인터페이스를 구현하는 모든 종류의 컬렉션 객체들과 상호작용한다. Collections 클래스를 통해 제공되는 메소드들은 컬렉션 데이터 구조를 더욱 효율적으로 관리할 수 있게 해주는 도구로서, 기본적인 컬렉션 작업을 단순화시키고 향상시키는 역할을 한다.


이처럼 Collections 클래스와 Collection 인터페이스는 서로 보완적인 관계를 가지며, Java 컬렉션 프레임워크 내에서 중요한 역할을 담당한다.

 

비슷한 맥락으로,

 

Arrays 유틸리티 클래스와 List 인터페이스

Maps 유틸리티 클래스 Map 인터페이스

Sets 유틸리티 클래스와 Set 인터페이스

Streams 유틸리티 클래스와 Stream 인터페이스

Files 유틸리티 클래스와 Path 인터페이스

 

Java 에는 이러한 객체지향적 설계가 곳곳에 숨어있다.

나는 이런 패턴을 찾는 것을 무척이나 좋아한다.

 

이러한 유틸리티 클래스 - 구현체 인터페이스 관계에 대해서

OOP적 관점으로 다시 한번 살펴보자

 

 

단일 책임 원칙 (Single Responsibility Principle)

단일 책임 원칙은 Java의 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙 SOLID 에서 S에 해당하는 원칙이다. 이 원칙은 "클래스는 하나의 책임만 가져야 한다"는 원칙이다. 유틸리티 클래스는 이 원칙을 따라, Collections 클래스는 컬렉션 객체들을 조작하는 데 필요한 정적 메소드들로 구성되어 있으며, 각 메소드는 특정한 유형의 작업만을 담당합니다. 이는 메인 클래스가 비대해지는 것을 방지하고, 관련 기능들을 유지보수하기 쉽게 분리했다.

 

 

인터페이스 분리 원칙 (Interface Segregation Principle)

인터페이스 분리 원칙은 Java의 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙 SOLID 에서 I에 해당하는 원칙이다. 이 원칙은 "클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안된다"는 원칙이다. 즉, 자신이 사용하는 메서드에만 의존해야 된다는 말인데, Collection 인터페이스는 기본적인 컬렉션 작업을 정의하고, 이보다 더 특화된 작업은 List, Set, Queue 등의 하위 인터페이스에서 정의합니다. 유틸리티 클래스는 이러한 인터페이스에 종속된 특화된 기능을 제공함으로써 인터페이스 분리 원칙을 강화한다.

 

 

팩토리 패턴 (Factory Pattern)

팩토리 패턴은 Java의 OOP적 설계 양식(Design Pattern) 중 하나의 패턴(양식)이다. 이 패턴은 객체 생성을 위한 인터페이스를 정의하며, 이를 통해 클라이언트가 시스템에서 사용할 객체의 클래스를 명시하지 않고 객체를 생성할 수 있도록 한다. 쉽게 말하자면, 객체 생성을 캡슐화 해서, 객체를 생성하는 코드와 사용하는 코드를 분리하는 것이다. Java의 유틸리티 클래스들은 팩토리 메소드를 제공한다. 예를 들어, Collections의 unmodifiableList나 synchronizedList와 같은 메소드는 새로운 컬렉션 객체를 생성하고 반환한다.

 

팩토리 패턴 구체적 설명 및 예시 코드

https://github.com/SMJin/JAVA-DesignPattern/tree/master/FactoryPattern

 

JAVA-DesignPattern/FactoryPattern at master · SMJin/JAVA-DesignPattern

Contribute to SMJin/JAVA-DesignPattern development by creating an account on GitHub.

github.com

 



전략 패턴 (Strategy Pattern)

전략 패턴은 Java의 OOP적 설계 양식(Design Pattern) 중 하나의 패턴(양식)이다. 이 패턴은 실행 중에 알고리즘을 선택할 수 있도록 하여 알고리즘을 클라이언트 코드로부터 분리시킨다. 유틸리티 클래스들은 이 패턴을 약간 변형된 형태로 사용할 수 있다. 예를 들어, Collections.sort() 메소드는 다양한 정렬 전략을 받아들일 수 있도록 Comparator 인터페이스를 파라미터로 사용한다.

 

전략 패턴 구체적 설명 및 예시 코드

https://github.com/SMJin/JAVA-DesignPattern/tree/master/StrategyPattern

 

JAVA-DesignPattern/StrategyPattern at master · SMJin/JAVA-DesignPattern

Contribute to SMJin/JAVA-DesignPattern development by creating an account on GitHub.

github.com

 



데코레이터 패턴 (Decorator Pattern)

데코레이터 패턴은 Java의 OOP적 설계 양식(Design Pattern) 중 하나의 패턴(양식)이다. 이 패턴은 객체에 동적으로 새로운 책임을 추가하는 방법을 제공한다. Collections 유틸리티 클래스는 이 패턴을 사용하여 기존 컬렉션 객체에 새로운 행동을 추가한다. 예를 들어, unmodifiableCollection 메소드는 주어진 컬렉션에 변경 불가능성을 추가하는 데코레이터를 제공한다.

 

데코레이터 패턴 구체적 설명 및 예시 코드

https://github.com/SMJin/JAVA-DesignPattern/tree/master/DecoratorPattern

 

JAVA-DesignPattern/DecoratorPattern at master · SMJin/JAVA-DesignPattern

Contribute to SMJin/JAVA-DesignPattern development by creating an account on GitHub.

github.com

 

 

 

가변성과 불변성

Java에서 "가변성"과 "불변성"은 객체의 상태가 시간이 지남에 따라 변경될 수 있는지 여부를 나타낸다. 객체가 한 번 생성된 후 내부 상태를 변경할 수 없다면 불변(immutable)이라고 하며, 상태를 변경할 수 있다면 가변(mutable)이라고 한다.

불변 객체 (Immutable Objects)

객체 생성 후 그 상태가 바뀌지 않는 객체를 의미한다.

 

Java에서의 불변 객체 예시

  • String: 문자열
  • BigInteger: 임의 정밀도를 지원하는 정수
  • BigDecimal: 임의 정밀도를 지원하는 십진수
  • java.awt.Color: 색상 객체
  • java.time.LocalDate: 날짜 객체 (java.time 패키지의 대부분의 클래스가 불변성을 가짐)

☆ 임의 정밀도란?

더보기

수학적 연산에서 사용하는 숫자들의 정밀도(즉, 숫자가 나타내는 정확성의 범위)를 사용자가 필요에 따라 설정할 수 있음을 의미한다. 컴퓨터 프로그래밍에서, 특히 숫자를 다루는 데 있어 기본 제공되는 데이터 타입들(int, float, double 등)은 고정된 크기와 정밀도를 가지고 있다. 이러한 데이터 타입들은 표현할 수 있는 숫자의 범위나 소수점 이하의 정밀도가 제한적이다.

 

이에 반해서, 임의 정밀도는 매우 큰 정수나 매우 작은 소수를 정확하게 표현할 수 있다. 과학적 계산, 암호화, 금융 분야에서는 매우 큰 수치나 매우 높은 정밀도가 요구될 수 있다. 이때 필요한 것이 임의 정밀도를 지원하는 데이터 타입이다.

 

불변 객체의 장점은 다음과 같다.

  1. 스레드 안전
    객체를 변경하지 않기 때문에 동기화를 걱정할 필요가 없다. 여러 스레드가 동시에 같은 객체에 접근해도 괜찮다. 변경에 대한 우려 자체가 없기 때문이다.
  2. 안전한 참조 공유
    객체를 복사하지 않고도 안전하게 참조를 공유할 수 있다. 이는 메모리 사용을 효율적으로 만든다.
  3. 캐시성
    불변 객체는 내부 상태가 변하지 않기 때문에 캐싱과 재사용이 용이하다.
  4. 버그 예방
    프로그램이 예기치 않게 객체를 변경하는 버그를 줄일 수 있다.

 

불변 객체의 사용상 유의점

객체를 불변으로 만들면 안전성과 멀티스레드 환경에서의 사용을 간소화할 수 있지만, 객체의 상태를 빈번하게 변경해야 하는 경우에는 새로운 객체를 생성해야 하므로 성능 오버헤드가 발생할 수 있다. 즉, 불변객체의 값을 변경한다는 것은 변경된 값으로 새로운 객체를 재생성한다는 의미이다.

 

 

 

 

가변 객체 (Mutable Objects)

객체 생성 후 내부 상태를 변경 할 수 있는 객체를 의미한다.

 

Java에서의 가변 객체 예시

  • StringBuilder: 문자열
  • ArrayList: 요소를 동적으로 추가하거나 삭제할 수 있는 리스트
  • HashMap: 키와 값을 저장하는 해시 테이블
  • java.util.Date: 날짜와 시간 정보
  • java.awt.Point: 2차원 좌표계의 x와 y 좌표점

 

가변 객체의 장점은 다음과 같다.

  1. 유연성
    상태를 변경할 수 있으므로 사용자의 요구에 더 유연하게 대응할 수 있다.
  2. 성능 최적화
    새로운 객체를 계속 생성하지 않고 기존 객체의 상태를 변경함으로써 성능을 향상시킬 수 있다.

 

가변 객체의 사용상 유의점

내부 상태를 변경할 필요가 있거나, 성능상의 이유로 새 객체 생성을 최소화해야 하는 경우에 유리한 객체이다. 그러나 추가적인 관리가 필요하고, 설계가 복잡해질 수 있다. 무엇보다 멀티스레드 환경에서는 동기화를 신경써야 하며, 객체를 공유할 때 부주의하게 상태 변경이 발생하지 않도록 주의해야 한다.

+ Recent posts