Garbage Collection

Garbage Collection(이하 GC) JVM에서 메모리 관리를 위한 중요한 기능입니다. GC 메모리의 Heap 영역 할당된 객체 중에서 이상 참조되지 않는 객체를 제거하여 메모리를 확보하는 작업을 말합니다. 과정에서 불필요한 객체가 메모리에서 제거됨으로써, 프로그램의 메모리 사용 효율을 높이고 OutOfMemoryError 같은 문제를 예방할 있습니다.

 

Stop The World

GC 동작하는 동안 GC 관련 Thread 제외한 나머지 Thread 모두 멈추게 되는데, 이를 Stop-The-World라고 합니다. 현상은 GC 수행될 때마다 발생하기 때문에, GC 자주 실행되거나 실행 시간이 길어지면 성능 저하로 이어질 있습니다. 이러한 이유로 GC 최적화는 애플리케이션 개발에 매우 중요한 요소 중 하나 입니다.

 

GC 종류

GC는 크게 두 가지로 구분됩니다:

  • Minor GC: Young Generation 영역에서 발생
  • Major GC (Full GC): Old Generation에서 발생

이를 이해하려면 먼저 JVM Heap 영역 구조 알아야 합니다.

 

Heap 구조

Young Generation

Young Generation은 새로 생성된 객체들이 저장되는 영역입니다. Young Generation의 GC는 Minor GC라고 불리며, 이 영역은 다시 EdenSurvivor1, Survivor2 영역으로 나뉩니다.

  • Eden 영역: 객체가 Heap에 최초로 할당되는 장소입니다. Eden 영역이 가득 차면, 참조가 있는 객체는 Survivor 영역으로 이동하고, 참조가 없는 객체는 삭제됩니다. 이 과정이 바로 Minor GC입니다.
  • Survivor 영역: Eden에서 살아남은 객체가 저장됩니다. Minor GC가 반복되며 살아남은 객체는 Old Generation으로 이동하게 됩니다.

 

Old Generation

Old Generation은 Young Generation에서 살아남은 오래된 객체들이 저장되는 영역입니다. 애플리케이션에서 일정 횟수 이상 참조된 객체가 Old Generation으로 이동합니다. 이곳에서 발생하는 GC는 **Major GC(또는 Full GC)**라고 불리며, 이 과정은 Minor GC보다 오래 걸립니다. Old Generation으로의 이동을 줄이면 Full GC 빈도를 줄여 성능을 개선할 수 있습니다.

 

Permanent Generation

Permanent Generation은 클래스의 메타 데이터나 메소드의 메타 데이터, Static 변수, 상수와 같은 JVM 실행에 필요한 데이터 저장됩니다. JDK 8 이후에는 Metaspace 변경되었습니다.

 

GC의 성능 최적화

1. Young Generation, Old Generation 크기 조정

Young Generation 크기를 적절히 조정하면, 객체가 Old Generation으로 이동하기 전에 Minor GC로 삭제될 확률을 높일 수 있습니다. Old Generation 크기를 키우면 Full GC 횟수가 감소하지만 실행 시간이 길어지고 크기를 줄이면 실행시간이 감소하지만 횟수가 증가하고 Out Of Memory Exception이 발생할 수 있기 때문에 실행중인 어플리케이션에 적절한 크기를 설정하는 것이 중요합니다.

 

2. Object 생성 최소화

Object 생성을 최소화하면 Old Generation으로의 객체 이동이 줄어들기 때문에 Major GC 발생 빈도를 줄여 애플리케이션의 Stop-The-World 현상을 줄일 수 있습니다. 예를 들면 문자열 데이터가 가변적인 상황에서는 String 객체보다는 StringBuilder나 StringBuffer를 사용하면 객체 생성을 줄일 수 있습니다.

 

3. GC 알고리즘 선택

JVM 다양한 GC 알고리즘을 제공하며, 애플리케이션 특성에 맞는 GC 알고리즘을 선택하는 것이 중요합니다. 대표적으로 Serial GC, Parallel GC, CMS GC, G1 GC 등이 있습니다.

Java에서는 문자열을 다루기 위한 다양한 클래스가 제공되는데, 가장 대표적인 것이 String, StringBuilder, 그리고 StringBuffer입니다. 가지 클래스는 모두 문자열을 다루지만, 변경 가능성(Mutability) 스레드 안전성(Thread Safety) 측면에서 차이가 있습니다.

 

String 클래스

String 클래스는 불변(Immutable) 객체로, 한 번 생성된 문자열은 수정할 수 없습니다. 문자열이 변경될 때마다 새로운 String 객체가 생성됩니다.

 

특징

  • 불변성: String은 한 번 생성되면 내용을 변경할 수 없습니다.
  • 새로운 객체 생성: 문자열을 조작할 때마다 새로운 객체가 생성되므로 문자열을 자주 변경하는 경우 메모리 효율이 떨어질 수 있습니다.
  • 리터럴 활용: String 객체는 String Pool에 저장되어, 동일한 리터럴 값이 있을 경우 같은 객체를 공유합니다.
String str = "Hello";
str = str + " World"; // 새로운 객체가 생성됨

 

StringBuilder 클래스

StringBuilder 클래스는 가변(Mutable) 객체로, 문자열을 직접 변경할 수 있습니다. 하지만 StringBuilder 클래스는 스레드 안전성을 제공하지 않기 때문에, 멀티 스레드 환경에서 문자열을 조작할 때 동시성 이슈가 발생할 수 있습니다.

 

특징

  • 가변성: 문자열을 조작할 때 동일 객체 내에서 변경됩니다.
  • 성능 우수: String에 비해 메모리 효율이 높고, 속도가 빠릅니다.
  • 스레드 안전성 미제공: 여러 스레드에서 동시에 접근할 경우, 안전하지 않을 있습니다.
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 같은 객체 내에서 문자열이 변경됨
System.out.println(sb.toString()); // "Hello World" 출력

 

StringBuffer 클래스

StringBuffer는 StringBuilder와 유사하게 가변 객체이지만, **스레드 안전(Thread-Safe)**합니다. 모든 메서드가 동기화(Synchronized) 되어 있어, 여러 스레드에서 접근해도 안전하게 사용할 수 있습니다. 다만, 이로 인해 StringBuilder보다 속도가 약간 느립니다.

 

특징

  • 가변성: StringBuilder와 동일하게 같은 객체 내에서 문자열을 변경합니다.
  • 스레드 안전성 제공: 메서드가 동기화되어 있어, 멀티스레드 환경에서도 안전하게 사용할 수 있습니다.
  • 성능: 스레드 동기화 처리로 인해 StringBuilder보다 느리지만, String보다는 성능이 좋습니다.
StringBuffer sbf = new StringBuffer("Hello");
sbf.append(" World"); // 같은 객체 내에서 문자열이 변경됨
System.out.println(sbf.toString()); // "Hello World" 출력

 

멀티쓰레드 환경 StringBuilder, StringBuffer 테스트

StringBuilderStringBuffer를 각각 2개의 스레드를 생성하여 "A" 문자열을 100000번씩 append하는 테스트 코드입니다.

100000번씩 두 번 더했기 때문에 문자열 길이가 200000이 나오는 것을 예상했지만 StringBuilder는 멀티쓰레드 환경에서 안전성을 보장하지 않기 때문에 기대한 값이 나오지 않았고 StringBuffer는 기대한 값이 나와 테스트를 통과한 것을 확인할 수 있습니다.

또한 실행 시간을 측정한 결과 동기화 처리로 인해 StringBuffer 속도가 41ms로 StringBuilder보다 오래 걸린 것을 확인할 수 있습니다.

@SpringBootTest
public class TestClass {

    @Test
    public void stringBuilderTest() {
        StringBuilder sharedBuilder = new StringBuilder();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                sharedBuilder.append("A");
            }
        };

        // 두 개의 스레드가 동일한 StringBuilder 인스턴스를 동시에 수정
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        long startTime = System.currentTimeMillis();
        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long endTime = System.currentTimeMillis();

        // 실행 시간 계산 및 출력
        long executionTime = endTime - startTime;
        System.out.println("StringBuilder Thread execution time: " + executionTime + " ms"); // 4ms
        Assertions.assertNotEquals(200000, sharedBuilder.length());
    }

    @Test
    public void stringBufferTest() {
        StringBuffer sharedBuffer = new StringBuffer();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                sharedBuffer.append("A");
            }
        };

        // 두 개의 스레드가 동일한 StringBuilder 인스턴스를 동시에 수정
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        long startTime = System.currentTimeMillis();
        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long endTime = System.currentTimeMillis();

        // 실행 시간 계산 및 출력
        long executionTime = endTime - startTime;
        System.out.println("StringBuffer Thread execution time: " + executionTime + " ms"); // 41ms
        Assertions.assertEquals(200000, sharedBuffer.length());
    }
}

 

String, StringBuilder, StringBuffer 비교

구분 String StringBuilder StringBuffer
변경 가능성 불변 (Immutable) 가변 (Mutable) 가변 (Mutable)
스레드 안전성 안전하지 않음 안전하지 않음 스레드 안전 (Synchronized)
속도 가장 느림 가장 빠름 StringBuilder보다 느림
사용 환경 변경이 필요 없는 문자열 단일 스레드에서 자주 변경하는 문자열 멀티스레드에서 자주 변경하는 문자열

 

Exception과 Error의 차이

Exception Error 모두 Java에서 예외와 오류의 최상위 클래스인 Throwable 클래스를 상속하며, 프로그램 실행 발생하는 예외적인 상황을 나타냅니다. 하지만 Exception Error 의미와 목적에서 차이가 있습니다.

 

Error는 애플리케이션 코드로 처리할 수 없는 시스템적 문제를 나타내며, 대부분 JVM 레벨에서 발생합니다. Error는 발생할 경우 프로그램이 즉시 종료될 가능성이 높고, 일반적으로 try-catch 구문으로 처리하지 않습니다. Error는 메모리 부족, 스택 오버플로우와 같은 시스템 자원 문제로 인해 발생하며, 개발자가 해결할 수 없는 경우가 대부분입니다.

 

Error의 대표 예시

  1. OutOfMemoryError
    메모리가 부족할 발생하는 오류로, 주로 객체를 너무 많이 생성하거나 메모리 리소스를 과도하게 사용했을 발생합니다.
  2. StackOverflowError
    재귀 호출이 무한히 반복될 발생하는 오류입니다.
  3. VirtualMachineError
    가상 머신의 심각한 문제가 발생했을 발생합니다.

Exception 프로그램 실행 중에 발생할 있는 예상 가능한 예외 상황 나타냅니다. 주로 사용자 입력 오류, 파일 입출력 오류 프로그램이 처리할 있는 문제들로, Java에서는 try-catch 구문을 통해 Exception 처리할 있습니다. Exception 처리함으로써 프로그램이 강제 종료되지 않고 정상 흐름을 유지할 있게 합니다.

구분 Exception Error
의미 예상 가능한 예외 상황 시스템 수준의 심각한 문제
처리 방법 try-catch로 처리 가능 일반적으로 처리하지 않음
대표 예시 IOException, SQLException OutOfMemoryError, StackOverflowError
복구 가능성 복구 가능성이 있음 복구 불가능, 프로그램 종료 가능성 큼

 

개발을 하다 보면 다양한 예외 상황을 만나게 됩니다. Java에서는 이러한 예외를 체크 예외 언체크 예외 구분하며, 예외를 적절히 처리하는 방법으로 예외 복구, 예외 회피, 예외 전환 있습니다. 이어서 예외의 종류와 효과적인 처리 방법을 정리해 보겠습니다.

Exception

Exception 클래스는 체크 예외와 언체크 예외로 구분되는데 체크 예외는 Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들이고, 언체크 예외는 RuntimeException 클래스를 상속한 클래스들을 말합니다.

 

체크 예외 (Checked Exception)

일반적으로 예외라고 하면 RuntimeException 클래스를 상속 받지 않은 체크 예외를 의미하며 체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해 주어야 합니다. 만약 예외 처리를 해주지 않으면 컴파일 에러가 발생합니다.

 

체크 예외의 예시

  1. IOException: 파일 입출력 시 파일이 없거나 접근할 수 없을 때 발생
  2. SQLException: SQL 문법 에러, DB 접근 실패, 서버나 네트워크 문제로 발생

체크 예외의 장단점

  • 장점: 컴파일 단계에서 예외를 강제하기 때문에, 놓치기 쉬운 예외를 미리 잡을 수 있는 안전장치 역할을 합니다.
  • 단점: 모든 체크 예외를 잡거나 던져야 하기 때문에 번거로워질 있습니다.

언체크 예외 (Unchecked Exception)

언체크 예외는 런타임 예외라고도 불리며 RuntimeException 클래스를 상속한 예외가 여기에 속합니다. 명시적인 예외처리를 강제하지 않기 때문에 catch 문으로 잡거나 throws로 선언하지 않아도 되며 선언하지 않을 경우 자동으로 예외를 던집니다. 주로 프로그램의 로직 오류나 예측 불가능한 상황에서 발생합니다.

 

언체크 예외의 예시

  1. NullPointerException: 할당되지 않은 객체를 사용하려고 할 때 발생
  2. IllegalArgumentException: 허용되지 않는 값으로 메서드를 호출할 때 발생

언체크 예외의 장단점

  • 장점: 신경 쓰고 싶지 않은 예외를 무시할 수 있어 코드 작성이 간편합니다.
  • 단점: 컴파일 단계에서 오류를 잡아주지 않아 예외가 발생할 가능성을 쉽게 놓칠 있습니다.

예외처리 방법

체크 예외와 언체크 예외를 구분해도 실제로 예외가 발생했을 때는 이를 어떻게 처리할지 고민해야 합니다. Java에서는 크게 세 가지 방식(예외 복구, 예외처리 회피, 예외 전환)으로 예외를 처리할 수 있습니다.

예외 복구

첫 번째로 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 방법이 있습니다. 예외가 발생하더라도 정상 상태로 복구 있다면 방법을 사용합니다. 예외 상황에 따라 특정 횟수까지 재시도를 하거나, 대체 로직을 통해 정상 상태로 돌려놓을 있습니다.

int maxretry = MAX_RETRY
int cnt = 0
while (cnt < MAX_RETRY) {
    cnt++
    try {
    	...    		// 예외가 발생할 가능성이 있는 시도
        return;		// 작업 성공
    } catch (SomeException e) {
    	// 로그 출력 후 정해진 시간만큼 대기
    } finally {
    	// 리소스 반납. 정리 작업
    }
}
throw new RetryFailedException(); // 최대 재시도 횟수를 넘기면 직접 예외 발생

예외처리 회피

두 번째로 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 전달하는 방법이 있습니다. 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 합니다. 다른 오브젝트에게 예외처리 책임을 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있을때만 사용해야 합니다.

// 예외처리 회피 1
public void add() throws SQLException {
    ...
}
// 예외처리 회피 2
public void add() throws SQLException {
    try {
        ...
    } catch (SQLException e) {
        throw e;
    }
}

예외 전환

마지막으로 발생한 예외를 그대로 던지는 대신 의미를 부여한 새로운 예외로 전환하여 던지는 방법입니다.

 

예외 전환 사용 목적

1. 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해 사용합니다.

 

예를 들어 앞서 말했듯이 SQLException경우 문법 문제이거나 로직 문제 또는 서버 쪽에 문제가 생겼을 때도 발생할 수 있는데 이 모든 경우에 단순히 SQLException으로 throw를 하면 개발자 입장에서는 원인을 찾기가 쉽지 않습니다. 그렇기 때문에 같은 SQLException이라도 원인을 세분화해서 다른 Exception으로 전환 후 보내준다면 보다 쉽게 에러를 해결할 수 있게 됩니다.

 

2. 예외를 처리하기 쉽고 단순하게 만들기 위해서 사용합니다.

주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.

// 회원가입시 id가 중복되어 에러가 발생한 경우
public void add(User user) throws DuplicateUserIdException, SQLException {
    try {
    	...
    } catch (SQLException e) {
    	// ErrorCode가 MySQL의 "Duplicate Entry(1062)"이면 예외 전환
        // ErrorCode는 데이터베이스 종류에 따라 다름
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) {
            throw DuplicateUserIdException();
        } else {
            throw e; // 그 외의 경우는 SQlException 그대로 전달
        }
    }
}

 

'Java' 카테고리의 다른 글

[Java] Garbage Collection이란?  (0) 2024.11.11
[Java] String, StringBuilder, StringBuffer  (0) 2024.11.05
[Java] 스트림(Stream)이란  (0) 2024.11.04
[Java] 제네릭이란?  (0) 2024.11.03
[Java] final 필드, 메소드, 클래스  (0) 2024.10.30

스트림이란?

Stream은 데이터 소스를 추상화한 형태로, 컬렉션에 저장된 요소들을 하나씩 참조하여 람다식으로 처리할 수 있게 하는 자바 8의 반복자입니다. 쉽게 말해 데이터의 흐름을 다루기 위한 도구로, 데이터를 필터링하고 변환하며 집계할 수 있도록 돕습니다. Stream을 활용하면 복잡한 반복문을 줄일 수 있고, 가독성을 높일 수 있습니다. 또한 내부적으로 최적화된 처리 방식을 제공하여 대용량 데이터를 효율적으로 다룰 수 있습니다.

스트림의 특징

  1. 간결한 코드: 기존의 반복자(iterator) 사용하는 방식보다 간결하고 직관적인 코드를 작성할 있습니다.
  2. 선언적 프로그래밍: '무엇을' 것인지를 명시하여 '어떻게' 것인지를 신경 필요가 없습니다.
  3. 병렬 처리 지원: 간단한 메서드 호출로 손쉽게 병렬 처리를 구현할 있어 성능 향상에 기여합니다.
  4. 가독성 향상: 데이터 처리의 흐름이 명확하게 드러나 코드의 가독성이 높아집니다.
  5. 유연성 증가: 컬렉션 이외의 데이터 소스(배열, 파일 등)에서도 동일한 방식으로 스트림을 사용할 수 있습니다.

Stream이 추가 되기 전인 자바 7 버전까지는 List<String> 컬렉션에서 요소를 순차적으로 처리하기 위해 아래와 같이 Iterator 반복자를 사용하거나 for 문을 활용하여 직접 구현했었습니다.

public class StreamTest {

    public static void main(String[] args) {
        List<String> nameList = new ArrayList<>();
        nameList.add("홍길동");
        nameList.add("스프링");
        nameList.add("스트림");
        Iterator<String> iterator = nameList.iterator();
        while (iterator.hasNext()) {
            String name = iterator.next();
            System.out.println(name);
        }
    }
}

하지만 자바 8 버전 부터는 Stream을 사용하면 더욱 간결하게 동일한 기능을 구현할 수 있습니다. stream() 메서드를 호출하여 스트림을 생성하고, forEach 메서드로 각 요소를 처리합니다.

public class StreamTest2 {

	public static void main(String[] args) {
        List<String> nameList = new ArrayList<>();
        nameList.add("홍길동");
        nameList.add("스프링");
        nameList.add("스트림");
        
        // nameList 리스트에 저장된 요소인 name을 파라미터로 해서 순차적으로 출력
        nameList.stream().forEach(name -> System.out.println(name));
    }
}

 

중간 처리와 최종 처리

Stream API는 크게 두 가지 처리 방식으로 구성됩니다.

  • 중간 처리 (Intermediate Operation): 데이터를 변환하거나 필터링하는 작업입니다. 예를 들어, 필터링(filter()), 매핑(map()), 정렬(sorted()) 등의 메소드가 여기에 해당합니다. 중간 처리 메소드는 지연 연산으로, 최종 처리 메소드가 호출될 때까지 실행되지 않습니다.
  • 최종 처리 (Terminal Operation): 스트림의 모든 데이터를 처리하고 종료하는 작업입니다. 예를 들어, 반복(forEach()), 카운팅(count()), 변환(collect()) 등이 있습니다. 최종 처리가 호출되면 중간 처리 메소드가 실행되며, 결과를 반환하고 스트림이 종료됩니다.

주요 Stream 메소드

Stream은 다양한 메소드를 제공하여 데이터를 효율적으로 다룰 수 있습니다.

filter()

스트림의 요소 조건에 맞는 요소만을 걸러냅니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
List<String> filteredNames = names.stream()
                                  .filter(name -> name.startsWith("이"))
                                  .collect(Collectors.toList());

map()

스트림의 요소를 지정된 함수(Function) 적용하여 새로운 요소로 변환합니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
List<Integer> nameLengths = names.stream()
                                 .map(String::length)
                                 .collect(Collectors.toList());

collect()

스트림의 요소를 모아서 새로운 컬렉션이나 다른 형태의 결과로 반환합니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
Map<String, Integer> nameLengthMap = names.stream()
                                          .collect(Collectors.toMap(
                                              name -> name,          // Key: 이름 자체
                                              name -> name.length()  // Value: 이름의 길이
                                          ));

forEach()

스트림의 각 요소에 대해 지정된 동작을 수행합니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
names.stream().forEach(System.out::println);

reduce()

스트림의 모든 요소를 하나의 결과로 합치는 연산을 수행합니다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream().reduce(0, Integer::sum);

적용 예시

stream().map().collect(toList())는 리스트 요소들을 map()을 이용해 새로운 요소로 매핑한 뒤 collect(toList())를 이용해 매핑된 요소들을 새로운 컬렉션(리스트)에 저장한 후 리턴해 줍니다.

 

아래는 cartId로 찾은 제네릭 타입이 CartProduct인 cartProductList를 stream().map().collect(toList())를 이용해 제네릭 타입이 CartProductResponseDto인 responseDto 리스트로 매핑하여 리턴한 예시입니다.

@Transactional(readOnly = true)
public List<CartProductResponseDto> getAllCartProduct(Long userId) {
    Cart cart = cartRepository.findByUserId(userId)
            .orElseThrow(() -> new ErrorCustomException(ErrorCode.NO_AUTHENTICATION_ERROR));
    List<CartProduct> cartProductList = cartProductRepository.findAllByCartId(cart.getId());
    List<CartProductResponseDto> responseDto = cartProductList
            .stream()
            .map(o -> new CartProductResponseDto(o))
            .collect(toList());
    return responseDto;
}

 

Java Stream API는 반복문을 대체할 수 있는 강력한 도구로, 데이터 필터링, 매핑, 정렬 및 집계와 같은 작업을 더 직관적이고 효율적으로 처리할 수 있습니다. 특히 람다식과 함께 사용하면 복잡한 데이터를 다루는 코드가 훨씬 간결해져 생산성을 높일 수 있습니다.

 

하지만 스트림은 일회용이기 때문에 재사용이 불가하거나 스트림 내에서 발생하는 예외는 일반적인 try-catch 블록으로 처리하기 어렵다는 단점도 존재하기 때문에 상황에 맞게 사용하는 것이 중요하겠습니다.

제네릭이란?

제네릭은 자바의 타입 안정성을 보장하여 잘못된 타입 사용을 컴파일 과정에서 미리 방지해주는 기능입니다. 제네릭을 사용하면 불필요한 타입 변환을 없애주어 프로그램 성능도 향상됩니다. 예를 들어, 제네릭을 사용하지 않는 코드에서는 List에서 값을 가져올 때마다 타입 변환이 필요하지만, 제네릭을 사용하면 특정 타입을 지정해 타입 변환을 생략할 있습니다.

// 제네릭을 사용하지 않을 경우
List list = new ArrayList();
list.add("hello");
String str = (String) list.get(0);

// 제네릭을 사용할 경우
List<String> list = new ArrayList<String>();
list.add("hello");
Stirng str = list.get(0);

클래스에서의 제네릭 사용

다음과 같은 Box 클래스가 있다고 가정해 보겠습니다.

public class Box {
    private Object object;

    public void set(Object object) {
    	this.object = object;
    }
     
    public Object get() {
    	return obejct;
    }
}

위 Box 클래스의 필드 타입을 Object로 선언했는데 Object 클래스는 모든 자바 클래스의 최상위 부모 클래스이기 때문에 어떤 타입이든 담을 수 있습니다. 위 클래스에서도 set메소드로 모든 타입을 저장할 수 있게 했는데 문제는 get 메소드를 사용할 때 발생합니다.

public class BoxExample {
    public static void main(String[] args) {
    	Box box = new Box();
        box.set("홍길동");
        String name = (String) box.get();
    }
}

제네릭을 설명할 때 제네릭을 사용하지 않으면 리스트에서 값을 조회하면서 타입 변환을 해줬어야 했는데  위 코드에서 저장할때 String 타입으로 저장했지만 조회해올 때 다시 String 타입으로 타입변환 해준 것을 확인할 수 있습니다. 이렇게 되면 저장할 때와 조회해올 때 모두 타입 변환이 일어나기 때문에 전체 프로그램에 악영향을 미칠 수 있습니다.

수정된 코드

public class Box<T> {
    private T t;
    
    public void set(T t) {
    	this.t = t;
    }
    
    public T get() {
    	return t;
    }
}
public class BoxExample {
    public static void main(String[] args) {
    	Box<String> box = new Box<String>();
        box.set("홍길동");
        String name = box.get();
    }
}

수정된 코드에서 타입 파라미터 T를 사용하여 Object 타입을 모두 T로 대체 했습니다. 이렇게 제네릭 타입을 사용해서 구현하면 main 메소드에서 Box 객체를 생성할 때 String 타입으로 선언할 수 있어 저장할 때와 조회해올 때 모두 타입 변환이 발생하지 않습니다.

멀티 타입 파라미터

클래스에서 제네릭 타입을 지정할 때 아래와 같이 각 타입 파라미터를 콤마(,)로 구분해서 지정하면 두 개 이상의 멀티 타입 파라미터를 사용할 수 있습니다.

@Getter
@Setter
public class Product<T, M> {
    private T kind;
    private M model
}
Product<Tv, String> product = new Product<>();

메서드에서의 제네릭 사용

제네릭 메서드는 리턴 타입이나 매개변수 타입에 타입 파라미터를 사용하는 메서드입니다. 리턴 타입 앞에 <T> 명시하여 타입 파라미터를 선언합니다.

public <T> Box<T> boxing(T t) { 
    Box<T> box = new Box<>();
    box.set(t);
    return box;
}

이와 같이 제네릭을 사용하면 코드의 재사용성이 높아지고 타입 안정성을 확보할 수 있습니다.

final 키워드

자바의 final 키워드는 "최종적"이라는 의미를 가지며 변수, 메서드, 클래스에 적용되어 수정 불가 상태를 명시합니다. 다음은 final 키워드를 사용하는 방법입니다.

final 변수

변수에 final 키워드가 선언되면 해당 변수가 생성될 때 초기값이 정해지고 다시는 수정될 수 없음을 의미합니다.

final 타입 필드 [= 초기값];

초기값은 변수 선언 시에 주는 방법과 생성자에서 주는 방법이 있습니다. 단순한 값이라면 변수 선언 시에 주면 되지만 복잡한 초기화 코드가 필요하거나 객체 생성 시 외부 데이터, 즉 파라미터에 의해 정해지는 경우에는 생성자에서 초기값을 지정해주어야 합니다. 만약 final 변수를 선언만하고 생성자에서 값을 초기화 해주지 않는다면 컴파일 에러가 발생합니다.

final 변수 선언과 초기화

public class Person {
    public final String nation = "KOREA";
    public final String rrn;
    public String name;
    
    public Person(String rrn, String name) {
    	this.rrn = rrn;
        this.name = name;
    }
}

final 메소드

메소드에 final 키워드가 선언되면 선언된 메소드는 더 이상 오버라이딩을 할 수 없습니다. 즉, 부모 클래스에 final 메소드가 선언되어 있다면 자식클래스가 해당 부모 클래스를 상속했더라도 final 메소드를 재정의 할 수 없다는 것을 의미합니다. 자식 클래스에서 final 메소드를 오버라이딩하면 컴파일 에러가 발생합니다.

public final 리턴타입 메소드명( [매개변수, ...] ) { ... }

final 클래스

클래스에 final 키워드가 선언되면 최종적인 클래스이으로 더 이상 상속할 수 없는 클래스가 됩니다. 즉, final 클래스는 부모 클래스가 될 수 없기 때문에 자식 클래스를 만들 수 없습니다.

public final class 클래스명 { ... }

자바에서 final 클래스의 대표적인 예는 자바 표준 API에서 제공하는 String 클래스로 String 클래스를 들어가서 확인해 보면 아래와 같이 final 키워드가 선언되어 있는 것을 확인할 수 있습니다.

인터페이스란?

인터페이스는 클래스들이 동일한 동작을 보장하도록 설계된 추상화의 형태입니다. 인터페이스는 구현 클래스에서 반드시 오버라이딩하여 구현해야 하는 추상 메소드 집합을 선언하고, 이러한 강제성을 통해 일관된 동작을 보장합니다. 인터페이스는 보통 추상클래스와 많이 비교하는데 추상 클래스가 그 추상 클래스를 상속 받아서 추상 클래스에서 선언된 변수나 메소드를 실체 클래스에서 사용하고 확장시키는데 의의가 있다면 인터페이스는 인터페이스에서 선언한 추상 메소드를 자식클래스에서 구현을 강제함으로써 같은 동작을 보장하는데 의의가 있습니다. 또한 인터페이스는 추상클래스와 달리 다중 상속이 가능하며 만약 인터페이스의 추상 메소드를 구현하지 않으면 컴파일 오류가 발생합니다.

인터페이스 구성

상수 필드(Constant Field)

인터페이스도 상수 필드 선언이 가능한데 이 상수는 인터페이스에 고정된 값으로 수정될 수 없습니다. 그러므로 상수를 선언할 때는 반드시 초기값을 대입해야 합니다.

추상 메소드(Abstract Method)

추상 메소드는 객체가 가지고 있는 메소드를 설명한 것으로 호출할 때 어떤 매개값이 필요하고, 리턴 타입이 무엇인지만 알려줍니다. 즉 메소드 선언만 하고 실제 실행부는 인터페이스를 상속받는 구현 클래스가 가지고 있습니다. 각 구현 클래스는 인터페이스에 정의된 추상 메소드를 반드시 오버라이드해야 합니다.

디폴트 메소드(Default Method)

디폴트 메소드는 인터페이스에 선언되지만 사실은 객체(구현 객체)가 가지고 있는 인스턴스 메소드 입니다. 즉, 디폴트 메소드는 인터페이스에서 선언되지만 인터페이스에서 바로 사용할 수 없고 구현 객체를 통해서 사용할 수 있습니다.

정적 메소드(Static Method)

정적 메소드는 디폴트 메소드와는 달리 객체(구현 객체)가 없어도 인터페이스만으로 호출이 가능합니다.

인터페이스 사용법

// 인터페이스 구현
public interface RemoteControl {

    // 상수
    int MAX_VOLUME = 10;
    int MIN_VOLUME = 0;
	
    // 추상 메소드
    void turnOn();
    void turnOff();
    void setVolume(int volume);
	
    // 디폴트 메소드
    public default void setMute(boolean mute) {
    	if (mute) {
            System.out.println("무음 처리합니다.");
    	} else {
            System.out.println("무음 해제합니다.");
    	}
    }
	
    // 정적 메소드
    public static void changeBattery() {
    	System.out.println("건전지를 교환합니다.");
    }
}

 

RemoteControl 인터페이스에 장비를 켜고 끌수 있는 turnOn, turnOff 추상메소드와 setVolume 추상 메소드를 선언하고 무음 설정을 할수 있는 setMute 디폴트 메소드와 건전지를 교환하는 changeBattery() 정적 메소드를 구현 했습니다.

// RemoteControl 인터페이스를 상속받아 구현한 Television 구현클래스
public class Television implements RemoteControl {

    // 필드
    private int volume;
	
    // turnOn()
    @Override
    public void turnOn() {
    	System.out.println("TV를 켭니다.");
    }
	
    // turnOff()
    @Override
    public void turnOff() {
    	System.out.println("TV를 끕니다.");
    }
	
    // setVolume()
    @Override
    public void setVolume(int volume) {
    	if (volume > RemoteControl.MAX_VOLUME) {
            this.volume = RemoteControl.MAX_VOLUME;
    	} else if (volume < RemoteControl.MIN_VOLUME) {
            this.volume = RemoteControl.MIN_VOLUME;
    	} else {
            this.volume = volume;
    	}
    	System.out.println("현재 TV 볼륨: " + this.volume);
    }
}
// RemoteControl 인터페이스를 상속받아 구현한 Audio 구현클래스
public class Audio implements RemoteControl {
	
    // 필드
    private int volume;
	
    // turnOn()
    @Override
    public void turnOn() {
    	System.out.println("Audio를 켭니다.");
    }
	
    // turnOff()
    @Override
    public void turnOff() {
    	System.out.println("Audio를 끕니다.");
    }
	
    // setVolume()
    @Override
    public void setVolume(int volume) {
    	if (volume > RemoteControl.MAX_VOLUME) {
            this.volume = RemoteControl.MAX_VOLUME;
        } else if (volume < RemoteControl.MIN_VOLUME) {
            this.volume = RemoteControl.MIN_VOLUME;
        } else {
            this.volume = volume;
        }
        System.out.println("현재 Audio 볼륨: " + this.volume);
    }
}

 

RemoteControl 인터페이스를 구현하고 이를 상속받는 Television, Audio 구현 클래스를 생성하여 인터페이스에서 선언한 turnOn(), turnOff(), setVolume() 추상메소드의 실행부를 구현 했습니다.

public class RemoteControlExample {

	public static void main(String[] args) {
		RemoteControl rc;
		
		rc = new Television();
		rc.turnOn();
		rc.turnOff();
		
		rc = new Audio();
		rc.turnOn();
		rc.turnOff();
	}
    
}

 

인터페이스로 구현 객체를 사용하려면 위와 같이 인터페이스 변수를 선언하고 구현 객체를 대입해야 합니다. RemoteControl 인터페이스에 Television 구현 객체를 대입한 후 실행하면 아래와 같은 결과를 얻을 수 있습니다.


여기까지는 인터페이스에서 선언한 추상 메소드를 구현하고 사용해 보았고 지금부터는 디폴트 메소드와 정적 메소드를 사용해 보겠습니다. 인터페이스 구성 문단에서 설명했듯이 디폴트 메소드는 인터페이스에서 실행부를 구현하지만 인터페이스에서 바로 사용할 수 없습니다. 때문에 추상 메소드와 마찬가지로 구현 객체가 필요합니다.

 

먼저 추상 메소드의 실행부를 구현했던 Audio 클래스에 디폴트 메소드를 재정의 했습니다.

// RemoteControl 인터페이스를 상속받아 구현한 Audio 구현클래스
public class Audio implements RemoteControl {
	
    // 필드
    private int volume;
    private boolean mute;
	
    // turnOn()
    @Override
    public void turnOn() {
    	System.out.println("Audio를 켭니다.");
    }
	
    // turnOff()
    @Override
    public void turnOff() {
    	System.out.println("Audio를 끕니다.");
    }
	
    // setVolume()
    @Override
    public void setVolume(int volume) {
    	if (volume > RemoteControl.MAX_VOLUME) {
            this.volume = RemoteControl.MAX_VOLUME;
        } else if (volume < RemoteControl.MIN_VOLUME) {
            this.volume = RemoteControl.MIN_VOLUME;
        } else {
            this.volume = volume;
        }
        System.out.println("현재 Audio 볼륨: " + this.volume);
    }
    
    // setMute()
    @Override
    public void setMute(boolean mute) {
        this.mute = mute;
        if (mute) {
            System.out.println("Audio 무음 처리합니다.");
        } else {
            System.out.println("Audio 무음 해제합니다.");
        }
    }
}
public class RemoteControlExample {

    public static void main(String[] args) {
    	RemoteControl rc;
		
    	rc = new Television();
    	rc.turnOn();
    	rc.turnOff();
		
    	rc = new Audio();
    	rc.turnOn();
    	rc.turnOff();
    	rc.setMute(true);
    	rc.setMute(false);
    }
}

 

이렇게 setMute() 추상 메소드를 Audio 구현클래스에서 재정의 하고 main 메소드를 실행하면 아래와 같은 결과를 얻을 수 있습니다.


디폴트 메소드와 달리 정적 메소드는 구현부 없이 인터페이스에서 바로 호출이 가능합니다.

public class RemoteControlExample {

    public static void main(String[] args) {
    	RemoteControl rc;
		
    	rc = new Television();
    	rc.turnOn();
    	rc.turnOff();
		
    	rc = new Audio();
    	rc.turnOn();
    	rc.turnOff();
    	rc.setMute(true);
    	rc.setMute(false);
        
    	// 인터페이스에서 바로 changeBattery() 정적 메소드 호출
    	RemoteControl.changeBattery();
    }
}

 

위와 같이 RemoteControl 인터페이스에서 바로 changeBattery 정적 메소드를 호출해도 아래와 같이 정상 출력 되는 것을 확인할 수 있습니다.

 

예제처럼 인터페이스는 구현 클래스에 공통적인 기능을 강제하면서, 상수와 정적 메소드, 디폴트 메소드 등을 활용해 유연하고 확장성 있는 설계를 가능하게 합니다. 인터페이스는 다형성을 높여줌으로써 다양한 객체를 하나의 인터페이스 타입으로 다룰 있어 유지보수성 코드 재사용성을 크게 향상시키는 중요한 도구입니다.

추상 클래스란?

객체를 직접 생성할 수 있는 클래스를 실체 클래스라고 한다면 이 클래스들의 공통적인 특성을 추출해서 선언한 클래스를 추상 클래스라고 합니다. 즉, 추상 클래스는 실체 클래스와 상속의 관계를 가지고 있으며 추상 클래스가 부모이고 실체 클래스가 자식으로 구현되어 실체 클래스가 추상 클래스의 특성을 물려받아 추가적인 특성을 가질 수 있도록 합니다. 또한, 추상 클래스는 직접 객체화할 수 없으며, 단일 상속만 허용합니다.

 

추상 클래스를 사용하는 주요 이유는 다음 두 가지입니다.

1. 실체 클래스들의 공통된 필드와 메소드의 이름을 통일할 목적

여러 개발자가 실체 클래스를 설계할  같은 기능을 하는 메소드의 이름을 다르게 설정하는 경우, 코드의 일관성이 떨어질  있습니다. 추상 클래스는 이러한 공통 기능을 정의하여 상속받는 실체 클래스에서 메소드의 이름과 구조를 통일하도록 도와줍니다. 이를 통해 코드의 가독성과 유지보수성을 높일  있습니다.

2. 실체 클래스를 작성할 때 시간을 절약

추상 클래스는 공통된 필드와 메소드를 선언하여, 실체 클래스가 이를 상속받아 사용할  있도록 합니다. 실체 클래스에서 반복적으로 필드와 메소드를 정의할 필요가 없어져 개발 시간을 절약할  있으며, 코드의 재사용성을 높여 개발 실수를 줄일  있습니다.

추상 클래스 선언 및 사용법

추상 클래스는 아래와 같이 클래스를 선언할 때 abstract 키워드를 붙여야 하며 메소드도 마찬가지로 abstract 키워드를 붙여 구현하고 중괄호를 쓰지 않습니다.

public abstract class Animal {
    public String kind;
    
    public void breathe() {
    	System.out.println("울음 소리");
    }
    
    public abstract void sound();
}

 

 

실체 클래스를 추상 클래스를 상속받아 구현하고 추상 클래스에서 구현한 추상 메소드를 Override하여 재정의 합니다.

public class Dog extends Animal {
    public Dog() {
    	this.kind = "포유류";
    }
    
    @Override
    public void sound() {
    	System.out.println("멍멍");
    }
}
public class Cat extends Animal {
    public Cat() {
    	this.kind = "포유류";
    }
    
    @Override
    public void sound() {
    	System.out.println("야옹");
    }
}

 

간단하게 추상 클래스를 구현하고 실체화 했으니 이제 사용방법에 대해서 알아보겠습니다.

첫 번째로 실체 클래스인 Dog와 Cat 변수로 호출했고, 두 번째는 실체 클래스를 추상 클래스인 Animal 객체로 자동 타입 변환하여 sound() 메소드를 호출 했습니다. 세 번째는 부모 타입의 매개 변수에 자식 객체를 대입하여 메소드의 다형성을 적용했으며 세 가지 방법 모두 같은 결과를 출력한 것을 확인할 수 있습니다.

public class AnimalExample {
    public static void main(String[] args) {
        // 실체 클래스로 메소드 호출
    	Dog dog = new Dog();
        Cat cat = new Cat();
        
        dog.breathe();
        dog.sound();
        cat.breathe();
        cat.sound();
        
        System.out.println("-------");
        
        // 추상 클래스로 메소드 호출
        Animal animal = null;
        animal = new Dog();
        animal.breathe();
        animal.sound();
        
        animal = new Cat();
        animal.breathe();
        animal.sound();
        
        System.out.println("-------");
       	
        // 다형성을 활용하여 메소드 호출
        animalSound(dog);
        animalSound(cat);
    }
    
    public static void animalSound(Animal animal) {
    	animal.breathe();
    	animal.sound();
    }
    
}

 

예제처럼 추상 클래스를 상속한 실체 클래스는 공통된 메소드와 속성을 가지면서도 자신만의 고유한 기능을 구현할 있습니다. 다형성을 통해 부모 클래스 타입으로 자식 클래스 객체를 다룰 있으므로, 추상 클래스는 유연하고 확장 가능한 코드 설계에 도움이 됩니다. 이러한 장점 덕분에 추상 클래스는 객체지향 프로그래밍에서 매우 중요한 설계 기법으로, 다양한 상황에 맞춰 쉽게 확장할 있는 코드 구조를 만드는데 유용하게 활용됩니다.

+ Recent posts