📚 Java 웹 기술은 서블릿과 JSP로 시작해 MVC 패턴을 거쳐 MVC 프레임워크의 도입으로 구조화되었으며, 이후 어노테이션 기반의 Spring MVC와 비동기 처리에 최적화된 Spring WebFlux로 발전해왔다.

 

  1. Servlet의 등장 (1997)
    • 개요
      • Java를 사용한 웹 개발의 시초로, 서버에서 동적으로 콘텐츠를 생성하기 위해 사용되었다. 클라이언트의 요청을 받고, 그에 대한 응답을 생성하는 기본적인 구조를 제공했다.
    • 단점
      • 코드의 복잡성이 증가하고 유지보수가 어려워졌다.
  2. JSP (JavaServer Pages) 도입 (1999)
    • 개요
      • JSP는 HTML 내에 Java 코드를 삽입할 수 있는 기술로, 웹 페이지를 더 쉽게 동적으로 생성할 수 있도록 도와주었다.
    • 단점
      • JSP 내에 비즈니스 로직을 분리할 수 없다.
  3. Servlet, JSP 기반의 MVC 패턴 도입
    • 개요
      • MVC 패턴이 도입되면서, UI, 비지니스 로직, 데이터를 분리하여 개발하는 방식이 등장했다.
      • Servlet은 주로 컨트롤러(비지니스 로직)로 사용되었고, JSP는 뷰(UI)를 담당하게 되었다.
    • 장점
      • MVC 패턴은 웹 애플리케이션의 유지보수성과 확장성을 크게 향상시켰다.
    • 단점
      • 개발자가 중복적으로 설정해줘야 하는 부분들이 다수 발생했다.
  4. MVC 프레임워크의 등장과 발전 (2000~2010)
    • 개요
      • Struts, Spring 등의 MVC 프레임워크가 등장하며, 웹 애플리케이션 개발이 더욱 구조화되고 효율적으로 변했다.
      • 그중 Spring MVC는 단순하면서도 강력한 기능을 제공하여, Java 웹 개발의 표준으로 자리 잡게 되었다.
      • 중복적으로 설정해야 하는 부분들을 프레임워크로 자동화 했다.
    • 단점
      • 여전히 애플리케이션 개발 관련 설정이 복잡했다.
  5. Annotation 기반의 Spring MVC(2007~현재)
    • 개요
      • Annotation을 통해 애플리케이션 설정의 복잡함을 줄여주었다.
    • 장점
      • 더 직관적이고 간결한 방식으로 웹 애플리케이션을 개발할 수 있게 되었다.
  6. Spring Boot의 등장(2014~현재)
    • 개요
      • Spring 프레임워크를 보다 쉽게 사용하도록 만든 도구로, 설정과 복잡한 초기 설정 작업을 자동화했다.
      • 내장 Tomcat을 가지고 있다.
    • 장점
      • 개발자들이 빠르게 애플리케이션을 개발할 수 있도록 도와준다.
  • 최신 기술 동향
    1. Web Servlet
      • Spring MVC
        • 안정적이고 동기식 프로그래밍 모델을 기반으로 한 웹 애플리케이션 개발에 널리 사용된다.
    2. Web Reactive
      • Spring WebFlux
        • 비동기 및 넌블로킹 모델을 기반으로 한 웹 프레임워크로, 높은 동시성을 요구하는 애플리케이션에서 효율적인 성능을 제공한다. 함수형 프로그래밍 스타일을 지원하며, 서블릿 기술 대신 Netty 등의 비동기 서버를 사용한다.
        • 서블릿 기술을 사용하지 않으며, 실시간 데이터를 처리하거나 높은 동시성을 요구하는 애플리케이션에 적합하다.
        • RDBMS 지원 부족과 높은 기술적 난이도 등으로 인해, 아직은 MVC 모델이 많은 실무에서 더 많이 사용되고 있다.

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 열거형 ( Enum )  (0) 2024.11.22
[JAVA] HASH란 무엇인가  (1) 2024.11.21
[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] NULL  (0) 2024.11.13

열거형 ( Enum )

📌 상수들의 집합을 정의하는 자료형입니다.

  • 일반적으로 정수나 문자열로 정의할 수 있는 여러 상수들을 열거형을 사용하여 보다 의미 있게 묶을 수 있습니다.
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
    // 0      1       2             3        4         5       6
}

 

  • 열거형을 사용하면 자동적으로 0부터 시작하는 정수값이 할당됩니다
class Card{
	enum Kind { A, B, C }
    enum Value { test1, test2 }
    
    if(Card.Kind.A==Card.Value.test1) {} // 이거 타입이 달라 비교불가 에러난다.

}
  • 열거형으로 상수를 정의한 경우 값을 비교하기 전에 타입을 먼저 맞춰본다. 즉, 설령 값이 같아도 타입이 다르면 에러가 난다.

 

열거형의 특징

  • 상수값을 그룹화하여 코드의 가독성을 높입니다.
  • 타입 안전을 보장하여 잘못된 값을 사용할 가능성을 줄입니다.
  • 각 상수에 (예: 정수, 문자열 등)을 할당할 수 있으며, 그 값을 활용할 수 있습니다.
  • equals()가 아닌 '=='으로 비교가 가능하다

 

열거형(Enum)에는 맴버(필드, 메서드 등)를 추가할 수 있습니다. 각 열거형 상수는 고유한 상태동작을 가질 수 있도록 설계할 수 있습니다. 이를 통해 열거형 상수마다 다른 값을 저장하거나 다른 행동을 할 수 있습니다.

 

열거형에 필드, 생성자, 메서드 추가하기

  1. 필드 추가: 열거형 상수에 추가적인 값을 할당할 수 있습니다.
  2. 생성자 추가: 열거형 상수를 초기화할 때 사용하는 생성자를 추가합니다.
  3. 메서드 추가: 각 열거형 상수가 자신의 동작을 정의할 수 있는 메서드를 추가합니다.
public enum Day {
    MONDAY(1, "Start of the work week"),
    TUESDAY(2, "Second day of the work week"),
    WEDNESDAY(3, "Middle of the work week"),
    THURSDAY(4, "Almost there"),
    FRIDAY(5, "Last day of the work week"),
    SATURDAY(6, "Weekend!"),
    SUNDAY(7, "Weekend!");

    private final int dayNumber;   // 필드: 요일의 숫자
    private final String message;  // 필드: 요일에 대한 메시지

    // 생성자: 열거형 상수마다 값 초기화
    Day(int dayNumber, String message) {
        this.dayNumber = dayNumber;
        this.message = message;
    }

    // getter 메서드
    public int getDayNumber() {
        return dayNumber;
    }

    public String getMessage() {
        return message;
    }

    // 특정 동작을 정의하는 메서드
    public void printDetails() {
        System.out.println(dayNumber + ": " + message);
    }
}

public class Main {
    public static void main(String[] args) {
        Day today = Day.MONDAY;
        System.out.println(today.getDayNumber());  // 출력: 1
        System.out.println(today.getMessage());    // 출력: Start of the work week
        today.printDetails();  // 출력: 1: Start of the work week
    }
}

 

더보기

설명

  1. 필드: dayNumber와 message는 각 요일에 대한 추가적인 정보를 저장하는 필드입니다.
  2. 생성자: Day(int dayNumber, String message) 생성자는 열거형 상수에 값(숫자와 메시지)을 할당합니다.
  3. 메서드:
    • getDayNumber()와 getMessage()는 각 요일의 숫자와 메시지를 반환하는 메서드입니다.
    • printDetails()는 각 요일의 정보를 출력하는 메서드입니다

 

상속 관계에서의 열거형 사용

열거형은 다른 클래스를 상속할 수 없지만, 인터페이스를 구현할 수 있습니다. 예를 들어, 모든 연산을 Operation 인터페이스로 정의하고, 각 열거형에서 apply 메서드를 구현할 수 있습니다.

public interface Operation {
    double apply(double x, double y);
}

public enum MathOperation implements Operation {
    ADD {
        @Override
        public double apply(double x, double y) {
            return x + y;
        }
    },
    SUBTRACT {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    };

    // 추가 메서드를 열거형에 정의할 수도 있음
}

 

 

 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] JAVA 웹 기술의 역사  (0) 2024.12.12
[JAVA] HASH란 무엇인가  (1) 2024.11.21
[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] NULL  (0) 2024.11.13

HASH

📌 입력 데이터를 고정된 크기의 값으로 변환하는 함수 또는 그 과정을 의미합니다

    • 다른 말로는 해시 값, 해시 코드, 체크섬 이라고 합니다.
    • key와 value 쌍으로 데이터를 저장하며, 키를 해시 함수를 통해 고유한 인덱스에 대응시켜 빠르게 값을 조회합니다.

 

  •  중복 허용을 안함, 순서 유지 안함, 빠른 검색, 삭제 가능
  • 해시 함수를 통해 시간복잡도가 1(반드시 1은 아닙니다. 충돌할 경우 달라지기도 합니다.) 한번에 정보의 위치를 찾을 수 있는 자료구조형태(해시 테이블)를 제작하는 과정
  • 꼭 주소로 구현할 필요는 없다 
  • 왼쪽의 index/data table의 경우 이차원 배열로 map은 아니다.
  • map으로 만들경우 메서드 차제적으로 for문으로 index값을 일일히 찾을 필요 없이 바로 index(key)를 찾을 수 있다.
  • 그래서 시간복잡도가 1으로 속도 개선에 큰 장점을 가지게 된다.
  • 위 그림은 엄밀히 말해, 에러난것, 키값이 중복되고 있다.
  • 2번째는 map이 반드시 주소를 이용해 구현되는 것은 아니지만 주소를 이용할 경우, 삭제가 편하다
  • 배열의 경우 삭제를 할 경우, 한번 복사를 하고 삭제를 하다보니 속도가 느리다. 다만 map의 경우 연결된 주소만 끊어버리면 삭제가 바로 진행되기에 삭제도 빠르다.
  • 여기서 중요한게, 오른쪽의 Map은 HASH MAP이라 불러야 한다. MAP은 자료구조

 

해시의 사용 사례

  1. 해시 테이블(Hash Table):
    • 키-값 쌍 데이터를 저장하고 검색할 때 사용. (예: Java의 HashMap, Python의 dict)
  2. 데이터베이스:
    • 데이터베이스에서 인덱스를 생성하거나 검색 속도를 높이기 위해 사용.
  3. 캐싱(Caching):
    • 자주 사용하는 데이터를 해시를 통해 빠르게 검색.
  4. 암호화 및 보안:
    • 비밀번호 저장, 데이터 무결성 검증 등에 사용. (예: SHA-256, MD5)
  5. 파일 검색:
    • 파일의 고유한 해시 값을 이용하여 중복 파일을 찾거나 무결성을 확인.

장점

  1. 빠른 검색 속도 (O(1))
    • 해시 테이블을 사용하면 데이터를 저장하거나 검색하는 데 평균적으로 **O(1)**의 시간 복잡도를 가짐.
      예: 키를 기반으로 바로 접근하므로 순차 탐색이 필요 없음.
  2. 효율적인 데이터 저장 및 관리
    • 다양한 크기의 데이터(문자열, 숫자 등)를 간단한 키-값 구조로 저장할 수 있음.
    • 해시 함수를 통해 고정된 크기의 배열에 데이터를 저장해 메모리 사용을 최적화함.
  3. 충돌 해결 기법의 다양성
    • 체이닝(Chaining), 오픈 어드레싱(Open Addressing) 등의 충돌 해결 기법으로 충돌 발생 시에도 효율적으로 처리 가능.
  4. 유연한 데이터 처리
    • 문자열, 숫자, 객체 등 다양한 유형의 데이터를 처리할 수 있어 범용적임.
      예: Java의 HashMap, Python의 dict처럼 키-값 쌍을 쉽게 저장하고 검색.
  5. 확장성
    • 데이터베이스, 캐싱 시스템, 파일 검색 등에서 널리 사용되며, 다양한 응용 가능.
  6. 보안 기능
    • 해시 알고리즘(SHA-256, MD5 등)은 암호화, 데이터 무결성 검증 등에 사용됨.

단점

  1. 충돌(Collision) 문제
    • 서로 다른 키가 같은 해시 값을 가질 경우 성능이 저하될 수 있음.
    • 충돌 해결 기법을 적용해야 하며, 이로 인해 성능이 떨어질 수 있음.
      • 예: 체이닝 사용 시, 연결 리스트를 순차적으로 탐색해야 하는 경우 발생.
  2. 메모리 낭비 가능성
    • 해시 테이블은 초기 크기를 고정하거나 동적으로 조정하는데, 너무 큰 크기를 할당하면 메모리 낭비가 발생.
    • 너무 작은 크기일 경우 충돌이 잦아져 성능 저하 가능.
  3. 정렬되지 않은 데이터
    • 해시 테이블은 키-값을 저장하는 구조이므로 데이터가 정렬되지 않음.
    • 정렬된 데이터가 필요하면 별도의 작업 필요.
  4. 해시 함수 설계의 중요성
    • 해시 함수가 데이터를 균등하게 분배하지 못하면 충돌이 증가해 성능이 저하될 수 있음.
      • 예: 키 값이 편향된 데이터(연속된 숫자 등)를 처리할 때.
  5. 해시 함수의 연산 비용
    • 복잡한 해시 함수는 연산 비용이 증가할 수 있어 작은 데이터에서는 오히려 비효율적.
  6. 재해시(Rehashing) 비용
    • 테이블이 가득 차거나 특정 임계치를 초과할 경우 크기를 늘려야 하며, 이 과정에서 모든 데이터를 다시 해싱해야 함.
    • 이는 큰 데이터셋에서 성능에 영향을 미칠 수 있음.
  7. 데이터 삭제 시 문제
    • 오픈 어드레싱을 사용하는 경우, 데이터를 삭제하면 그 자리의 값이 비워지면서 연결된 데이터 검색에 영향을 줄 수 있음.

 

 

해시 함수 ( Hash Function )

📌 입력받은 데이터를 해시 값으로 출력시키는 알고리즘

  • 알고리즘의 형태는 다양합니다. 목적에 맞게 다양하게 설계되고 유용하게 사용됩니다.
  • 자료구조, 캐시, 검색, 에러 검출, 함도 등 유용하게 많이 사용됩니다.
// 해시 함수 예시
Integer hashFunction(String key) {
	
    return (int)(key.charAt(0)) % 10;
}

 

  • 위의 그림을 코드로 만든 것, 이 경우 위 그림처럼 return으로 key값을 반환해 준다.

해시 함수의 특징

  • 일관성: 동일한 입력값에 대해 항상 동일한 해시 값을 반환.
  • 고른 분배: 입력값이 다양해도 출력값이 균일하게 분포.
  • 빠른 계산: 빠른 시간 안에 값을 계산.
  • 충돌 최소화: 서로 다른 입력값이 동일한 해시 값을 가지는 확률을 낮춤.

 

 

해시 테이블 ( Hash Table )

📌 키와 값을 함께 저장해 둔 데이터 구조

  • 아래와 같이 행, 열로 구성된 표에 저장되는 것과 유사합니다.
  • 다만 테이블에 데이터를 저장할 때 순서는 무작위로 지정되어 저장됩니다.
  • 그로인해 중간에 여유 공간이 발생할 수 있습니다.
  • 해시 테이블은 map 형태의 자료구조입니다.

 

  • 하나의 주소를 갖는 파일 영역을 Bucket, 한개의 레코드를 저장할 수 있는 공간을 Slot이라 말합니다.

 

 

해싱 ( Hashing )

📌 데이터를 해시 함수를 이용해 고유한 *해시 값(Hash Value)*으로 변환하는 과정

  • 변환된 해시 값은 위 예시처럼 데이터의 저장 위치를 결정하거나, 데이터 검색 시 활용됩니다.
  • 해싱을 잘못해서 key가 중복되면 충돌이 발생할 수 있습니다.

 

 

충돌 (Collision)

📌 해시 함수로 계산된 key값이 곂치는 경우, 동일한 값을 인덱스에 저장하려 할 때 발생합니다.

 

왜 충돌이 발생하는가?

  1. 해시 테이블 크기의 한계:
    • 해시 값의 범위(테이블 크기)가 유한하기 때문에, 입력값(키)이 다양해도 모든 값을 고유한 해시 값으로 매핑할 수 없음.
    • 예: 테이블 크기가 10이면, hash(21) = 1, hash(11) = 1처럼 서로 다른 키가 동일한 인덱스에 매핑될 수 있음.
  2. 해시 함수의 설계 문제:
    • 해시 함수가 키를 균일하게 분산하지 못하거나 특정 키 값 패턴에 대해 편향된 결과를 생성하면 충돌 발생 가능성이 증가.

충돌 해결 방법

1. 체이닝 (Chaining)

  • 방식: 충돌이 발생한 동일한 해시 값의 데이터를 연결 리스트(Linked List) 형태로 저장.
  • 동작 과정:
    • 충돌 시, 같은 인덱스에 새로운 데이터를 리스트의 형태로 추가.
    • 데이터를 검색할 때 해당 인덱스의 리스트를 순회하며 원하는 데이터를 찾음.
  • 장점:
    • 테이블 크기를 초과하는 데이터도 저장 가능.
    • 테이블의 크기를 크게 증가시키지 않아도 됨.
  • 단점:
    • 충돌이 많아지면 리스트가 길어져 검색 시간이 O(n)으로 증가.

 

2. 오픈 어드레싱 (Open Addressing)

  • 방식: 충돌이 발생하면 다른 빈 슬롯(인덱스)을 찾아 데이터를 저장.
  • 기법:
    1. 선형 탐사 (Linear Probing):
      • 충돌이 발생하면 고정된 간격(보통 1)을 더해 다음 인덱스를 탐색.
      • 예: hash(21) = 1에서 충돌 시, index = 2, index = 3 탐색.
      장점: 구현이 간단.
      단점: 충돌이 많아지면 클러스터링(연속된 충돌) 발생 가능.
    2. 이차 탐사 (Quadratic Probing):
      • 충돌 시 인덱스 이동 간격을 제곱 형태로 증가.
      • 예: index = 1, index = 1 + 1^2, index = 1 + 2^2.
      장점: 클러스터링 문제를 어느 정도 해결.
      단점: 테이블의 크기가 소수(Prime)여야 효율적.
    3. 이중 해싱 (Double Hashing):
      • 충돌 시 다른 해시 함수를 사용해 새로운 위치를 계산.
      • 예: hash1(21) = 1, 충돌 발생 시 hash2(21) = 3.
      장점: 충돌 분산 효과가 높음.
      단점: 해시 함수 설계가 복잡.


충돌 해결 기법의 선택

  • 데이터의 크기와 성격에 따라 충돌 해결 기법이 달라짐.
    • 데이터가 많고 빈도가 높은 경우 → 체이닝 적합.
    • 메모리가 제한적일 경우 → 오픈 어드레싱 적합.

충돌 해결 방법 비교

 

 

 

해시셋 ( Hashset )

📌 Set 인터페이스를 구현한 클래스 중 하나로, 데이터 중복, 순서를 유지하지 않는 집합입니다.

  • HashMap, HashSet 등 해시 기반 컬렉션입니다.
  • 컬렉션 = java에서 데이터를 효율적으로 저장하고 조작하기 위한 객체들의 그룹(container)을 말합니다.

 

HashSet의 작동 원리

HashSet은 내부적으로 HashMap을 사용해 데이터를 저장합니다.
데이터를 저장할 때, 키(key)와 값(value) 중에서 값(value)은 더미 객체로 설정되고, 키(key) 저장됩니다.

  1. 저장: add(E e) 메서드를 호출하면, 입력 데이터의 해시 코드(hashCode)를 계산해 해시 테이블의 위치를 결정.
  2. 중복 확인: equals() 메서드를 사용해 중복 여부를 판단.
    • 같은 해시 코드를 가진 데이터라도 equals() 메서드가 true를 반환하면 중복으로 간주.
import java.util.HashSet;

public class HashSetExample {
    public static void main(String[] args) {
        // HashSet 선언
        HashSet<String> set = new HashSet<>();

        // 데이터 추가
        set.add("Apple");
        set.add("Banana");
        set.add("Cherry");

        // 중복 추가 시 무시
        set.add("Apple");

        // 출력
        System.out.println(set);  // 출력: [Banana, Apple, Cherry] (순서가 랜덤)

        // 특정 값 포함 여부 확인
        System.out.println(set.contains("Banana"));  // true

        // 데이터 삭제
        set.remove("Cherry");

        // 크기 확인
        System.out.println(set.size());  // 2
    }
}

 

HashSet의 주요 메서드

  • set의 주요 메서드를 계승하고 있습니다.

 

HashSet과 다른 Set 구현체 비교

 

 

Set 인터페이

 🐳 중복을 허용하지 않는 집합(Collection)을 나타내는 인터페이스입니다.

 

Set의 주요 특징

  1. 중복 허용 안 함
    • 동일한 요소를 여러 번 저장할 수 없습니다.
    • 예: {1, 2, 2, 3} -> {1, 2, 3}
  2. 순서 유지 안 함
    • 데이터의 저장 순서를 보장하지 않습니다.
    • 일부 구현체(LinkedHashSet)는 예외적으로 삽입 순서를 유지.
  3. null 값
    • null 값을 허용하지만, 한 번만 저장 가능.
  4. 기본 연산 지원
    • 집합 연산을 쉽게 구현할 수 있습니다(교집합, 합집합, 차집합).

 

 

해시맵 ( Hashmap )

📌 Map 인터페이스의 구현체로, 키(Key)와 값(Value)의 쌍으로 데이터를 저장합니다.

  • 각각의 키는 고유하며, 각 키에 대해 값을 매핑합니다. HashMap은 해시 테이블을 사용하여 데이터를 빠르게 검색하고 관리할 수 있도록 합니다.
  • HashMap, HashSet 등 해시 기반 컬렉션입니다.
  • 컬렉션 = java에서 데이터를 효율적으로 저장하고 조작하기 위한 객체들의 그룹(container)을 말합니다.

 

HashMap의 주요 특징

  1. 키-값 쌍(Key-Value Pair)
    • HashMap은 데이터를 **키(Key)**와 **값(Value)**의 쌍으로 저장합니다.
    • 키는 중복될 수 없고, 값은 중복될 수 있습니다.
    • 예: map.put("Alice", 25)에서 "Alice"는 키, 25는 값입니다.
  2. 중복 키를 허용하지 않음
    • 동일한 키로 여러 번 값을 넣으면, 기존의 값이 새로운 값으로 덮어씌워집니다.
    • 예: map.put("Alice", 25) 이후 map.put("Alice", 30)을 호출하면 "Alice"의 값은 30으로 변경됩니다.
  3. 순서 보장하지 않음
    • HashMap은 저장된 데이터를 순서대로 유지하지 않습니다.
      순서가 중요한 경우, LinkedHashMap을 사용할 수 있습니다.
  4. 빠른 검색 속도
    • HashMap은 해시 테이블을 사용하여 **O(1)**의 평균 시간 복잡도로 검색, 삽입, 삭제가 가능합니다.
    • 해시 충돌을 처리하는 방법이 있기 때문에 최악의 경우에도 성능이 크게 저하되지는 않습니다.
  5. null 값 허용
    • HashMap은 한 개의 null 키여러 개의 null 값을 허용합니다.
    • 예: map.put(null, "some value") 또는 map.put("some key", null)이 가능합니다.

 

import java.util.HashMap;

public class HashMapExample {
    public static void main(String[] args) {
        // HashMap 생성
        HashMap<String, Integer> map = new HashMap<>();

        // 데이터 추가
        map.put("Alice", 25);  // "Alice" 키에 25 값 저장
        map.put("Bob", 30);    // "Bob" 키에 30 값 저장
        map.put("Charlie", 35); // "Charlie" 키에 35 값 저장

        // 값 출력
        System.out.println("Alice's age: " + map.get("Alice")); // 출력: Alice's age: 25

        // 존재 여부 확인
        System.out.println("Contains 'Bob'? " + map.containsKey("Bob")); // 출력: Contains 'Bob'? true

        // 모든 키와 값 출력
        for (String key : map.keySet()) {
            System.out.println(key + ": " + map.get(key));
        }
        // 출력:
        // Alice: 25
        // Bob: 30
        // Charlie: 35

        // 키 삭제
        map.remove("Bob");  // "Bob" 삭제
        System.out.println("Contains 'Bob' after removal? " + map.containsKey("Bob")); // 출력: false
    }
}

 

HashMap의 성능

  • 검색, 삽입, 삭제: 평균적으로 O(1)의 시간 복잡도를 가집니다.
    • 해시 테이블을 사용하기 때문에, 키를 해시 함수로 변환하여 빠르게 위치를 찾습니다.
  • 충돌 처리: 여러 항목이 동일한 해시 값을 갖는 경우 체이닝(Chaining) 또는 오픈 어드레싱(Open Addressing) 방식으로 충돌을 처리합니다.
  • 최악의 경우: 해시 충돌이 많아지면, 검색/삽입/삭제가 O(n)으로 성능이 저하될 수 있습니다. 이를 방지하기 위해 적절한 해시 함수충돌 처리 전략이 중요합니다.

 


 

HashMap의 장점과 단점

장점

  1. 빠른 데이터 검색: 해시 테이블을 사용하여 평균적으로 O(1)의 시간 복잡도로 데이터를 빠르게 조회, 삽입, 삭제할 수 있습니다.
  2. 키를 통한 데이터 접근: 키를 사용하여 데이터를 빠르고 효율적으로 관리할 수 있습니다.
  3. null 허용: null 값을 키와 값에 사용할 수 있어 유연성 제공.

단점

  1. 순서 보장 없음: HashMap은 순서가 보장되지 않으므로, 데이터가 저장된 순서를 유지하려면 LinkedHashMap을 사용해야 합니다.
  2. 메모리 사용량: 해시 테이블은 추가 메모리를 사용하므로, 작은 데이터 세트에서는 오히려 비효율적일 수 있습니다.
  3. 충돌 처리 필요: 해시 충돌이 발생하면 성능이 저하될 수 있으므로, 충돌 처리 방법에 대한 고려가 필요합니다.

 


 

HashMap의 주요 메서드

 

 

HashCode( ) 메서드

📌Object 클래스에 정의된 메서드로, 객체를 식별하기 위한 정수 값(해시 코드)을 반환합니다. 이 값은 객체를 빠르게 검색하거나 비교할 때 사용됩니다.

 

hashCode()의 특징

  1. 정수 값 반환
    • 객체의 해시 코드를 나타내는 정수를 반환.
    • 예: 123456, -987654, 등.
  2. 같은 객체는 같은 해시 코드
    • 동일한 객체(equals() 메서드로 비교 시 true)라면 동일한 hashCode를 반환해야 함.
    • 하지만, 서로 다른 객체가 같은 해시 코드를 가질 수도 있음(해시 충돌).
  3. 기본 구현
    • Object 클래스에서 기본적으로 정의되어 있으며, 객체의 메모리 주소를 기반으로 해시 코드를 생성.
  4. 재정의 가능
    • 개발자가 특정 로직에 따라 hashCode()를 재정의 가능. 주로 equals() 메서드와 함께 재정의.
public class HashCodeExample {
    public static void main(String[] args) {
        Object obj1 = new Object();
        Object obj2 = new Object();

        System.out.println(obj1.hashCode()); // 출력: (예) 366712642
        System.out.println(obj2.hashCode()); // 출력: (예) 1829164700
    }
}
  • obj1과 obj2는 서로 다른 객체이므로 해시 코드가 다름.

 

hashCode()와 equals()의 관계

  1. 규칙:
    • equals()가 true인 두 객체는 항상 같은 hashCode() 값을 가져야 함.
    • 그러나, 같은 hashCode() 값을 가진 객체가 **반드시 equals()가 true**는 아님.
  2. 의미:
    • hashCode()는 빠른 검색을 위한 값이고,
    • equals()는 객체의 논리적 동등성을 비교하는 메서드.

재정의된 hashCode()와 equals()

Java에서 hashCode()와 equals()는 함께 재정의해야 합니다.
HashSet이나 HashMap 같은 컬렉션은 hashCode()와 equals()의 일관성을 가정하여 동작합니다.

 

 

< 예제: 커스텀 클래스에서 hashCode()와 equals() 재정의 >

import java.util.Objects;

class Person {
    String name;
    int age;

    // 생성자
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

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

    // equals() 재정의
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return age == person.age && Objects.equals(name, person.name);
    }
}

public class HashCodeEqualsExample {
    public static void main(String[] args) {
        Person p1 = new Person("Alice", 25);
        Person p2 = new Person("Alice", 25);

        // 해시 코드가 동일함
        System.out.println(p1.hashCode() == p2.hashCode()); // true

        // equals() 비교
        System.out.println(p1.equals(p2)); // true
    }
}

실행 결과:

  • p1과 p2는 같은 name과 age를 가지므로 equals()와 hashCode() 모두 동일하게 동작.

 


hashCode()와 컬렉션

  • HashSet, HashMap 등은 hashCode()를 사용해 데이터를 관리.
  • 데이터를 추가할 때, 먼저 hashCode() 값으로 버킷(bucket)을 결정하고,
    동일한 버킷에 여러 데이터가 들어올 경우 **equals()**로 중복 여부를 확인.

< 예제: HashSet에서 hashCode()와 equals() >

import java.util.HashSet;

class Student {
    int id;
    String name;

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

    @Override
    public int hashCode() {
        return id; // ID를 기반으로 해시 코드 생성
    }

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

public class HashSetExample {
    public static void main(String[] args) {
        HashSet<Student> set = new HashSet<>();

        set.add(new Student(1, "Alice"));
        set.add(new Student(1, "Alice")); // 중복 데이터
        set.add(new Student(2, "Bob"));

        System.out.println(set.size()); // 출력: 2
    }
}

 


hashCode() 사용 시 주의점

  1. equals와 일관성 유지
    • equals()가 true면 hashCode()도 같아야 함.
  2. 해시 충돌
    • 서로 다른 객체가 같은 hashCode()를 가질 경우, 충돌 발생.
    • 충돌이 많아지면 해시 기반 자료구조의 성능 저하.
  3. 재정의 필요성
    • 사용자 정의 클래스에서는 반드시 hashCode()와 equals()를 함께 재정의해야 올바르게 동작.

 

 

equals메서드 정의시 항상 hashcode()를 함께 정의해야하는 이유

 

Java의 기본 규약

Java에서 equals()와 hashCode()의 관계를 다음과 같이 규정합니다:

  1. equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 가져야 한다.
    • 동일한 객체를 나타내므로 동일한 해시코드가 필요.
  2. hashCode()가 같다고 해서 equals()가 반드시 true는 아니다.
    • 다른 객체들이 해시 충돌을 일으킬 수 있음.

 

함께 정의하지 않으면 발생하는 문제

1. HashMap과 HashSet에서 데이터 검색 실패

  • HashMap과 HashSet은 내부적으로 객체를 저장할 때 먼저 hashCode()로 버킷(bucket)을 찾고, 이후 equals()로 동등성을 확인합니다.
  • 만약 equals()와 hashCode()가 일관되지 않다면, 검색, 삽입, 삭제 작업이 제대로 동작하지 않습니다.
import java.util.HashSet;

class Person {
    String name;

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

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return name.equals(person.name);
    }

    // hashCode()를 정의하지 않음
}

public class HashCodeEqualsTest {
    public static void main(String[] args) {
        HashSet<Person> set = new HashSet<>();
        set.add(new Person("Alice"));

        // 동일한 이름의 객체를 검색하려고 함
        System.out.println(set.contains(new Person("Alice"))); // false
    }
}

 

더보기

원인

  • equals()는 동일한 객체라고 판단하지만, hashCode()를 재정의하지 않았으므로 다른 해시 버킷에 저장됩니다.
  • 결과적으로 HashSet은 데이터를 찾지 못합니다.

 

< 수정 >

import java.util.Objects;

class Person {
    String name;

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

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return name.equals(person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name); // name 기반으로 해시코드 생성
    }
}

public class HashCodeEqualsTest {
    public static void main(String[] args) {
        HashSet<Person> set = new HashSet<>();
        set.add(new Person("Alice"));

        // 동일한 이름의 객체를 검색
        System.out.println(set.contains(new Person("Alice"))); // true
    }
}

결과

  • equals()와 hashCode()를 함께 재정의하여, 동일한 이름의 객체를 올바르게 검색합니다.

 

왜 hashCode()를 함께 정의해야 하는가?

  1. 효율성:
    • hashCode()는 검색을 빠르게 하기 위해 사용됩니다.
      (equals()는 최종 확인 단계로만 호출되므로 성능 부담이 적음).
  2. 충돌 방지:
    • hashCode()가 올바르게 정의되지 않으면, 모든 데이터가 같은 해시 버킷에 저장되어 성능이 크게 저하됩니다.
      예: O(1) → O(n)
  3. 컬렉션 동작 보장:
    • HashMap, HashSet 등 해시 기반 컬렉션이 의도한 대로 작동하려면 반드시 두 메서드가 일관성 있어야 합니다.

정리

  • equals()는 객체의 논리적 동등성을 비교.
  • hashCode()는 빠른 검색과 데이터 저장을 위해 사용.
  • 두 메서드는 일관성을 유지해야 하며, 함께 재정의하지 않으면 해시 기반 자료구조가 잘못 동작하거나 성능이 저하될 수 있습니다.
    따라서 항상 equals()와 hashCode()를 함께 재정의하세요.

 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] JAVA 웹 기술의 역사  (0) 2024.12.12
[JAVA] 열거형 ( Enum )  (0) 2024.11.22
[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] NULL  (0) 2024.11.13

간단한 정렬 Sort

 

1. Arrays 클래스의 정렬

  • java.util.Arrays 클래스는 배열을 정렬하는 데 사용됩니다.
  • 기본적으로 오름차순으로 정렬하며, 사용자 정의 기준으로 정렬하려면 Comparator를 사용합니다.
  • 문자열의 경우 아스키코드 순 (알파벳 순)으로 오름차순 정렬되며, 한글도 가나다 순으로 정렬됩니다.
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        int[] numbers = {5, 2, 8, 3, 1};
        Arrays.sort(numbers);
        System.out.println(Arrays.toString(numbers)); // [1, 2, 3, 5, 8]

        String[] names = {"Charlie", "Alice", "Bob"};
        Arrays.sort(names);
        System.out.println(Arrays.toString(names)); // [Alice, Bob, Charlie]

        // 사용자 정의 기준 정렬
        Arrays.sort(names, (a, b) -> b.compareTo(a)); // 내림차순
        System.out.println(Arrays.toString(names)); // [Charlie, Bob, Alice]
    }
}

 

2. Collections 클래스의 정렬

  • java.util.Collections 클래스는 리스트를 정렬할 때 사용됩니다.
  • Collections.sort()는 Comparable 인터페이스를 구현한 클래스의 자연 정렬 기준을 따르며, 필요하면 Comparator로 미리 설정된 정렬 기준을 제공합니다. (오름차순 외의 내림차순과 같은)
  • 인터페이스의 장점대로 다양한 정렬기준을 프로그래머가 직접 제시할 수 있습니다. 

 

import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(5, 2, 8, 3, 1);
        Collections.sort(numbers);
        System.out.println(numbers); // [1, 2, 3, 5, 8]

        // 내림차순 정렬
        Collections.sort(numbers, Collections.reverseOrder());
        System.out.println(numbers); // [8, 5, 3, 2, 1]
    }
}

 

  • 둘이 다르지만 사실, Collections.Sort() -> list.sort() 함수-> List.java.sort 메서드-> Array.sort(a Comparator) 함수
  • 위와 같은 순서로 호출하다보니 결국 array, collections 배열 모드 실상은 array.sort로 돌아간다
  • 즉 근본적으로 차이가 없다

 

+ 공식 Collections 클래스 문서

https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/Collections.html#reverseOrder()

 

 

복잡한 정렬 Stream API

📌 Java 8 이상에서 도입된 Stream API는 컬렉션 또는 배열을 쉽게 정렬할 수 있는 기능을 제공합니다.

  • sorted() 메서드는 기본적으로 자연 정렬을 따르며, 사용자 정의 기준도 제공합니다.

 

import java.util.*;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Charlie", "Alice", "Bob");

        // 오름차순 정렬
        List<String> sortedNames = names.stream()
                                        .sorted()
                                        .collect(Collectors.toList());
        System.out.println(sortedNames); // [Alice, Bob, Charlie]

        // 내림차순 정렬
        List<String> reversedNames = names.stream()
                                          .sorted(Comparator.reverseOrder())
                                          .collect(Collectors.toList());
        System.out.println(reversedNames); // [Charlie, Bob, Alice]
    }
}

 

 

복잡한 정렬 Comparator와 Comparable

📌 Java에서 객체를 정렬하기 위해 사용하는 두 가지 인터페이스입니다.

 

1. Comparable

  • 정의: 객체 자체가 "자연 정렬 순서"를 정의하도록 설계된 인터페이스.
  • 메서드: compareTo(T o) 메서드를 구현해야 합니다.
  • 사용 방법:
    • 클래스가 직접 Comparable을 구현하고, 정렬 로직을 내부에서 정의합니다.
    • 정렬 기준이 고정적이며, 클래스 설계 시 미리 정의되어야 합니다.
import java.util.*;

class Student implements Comparable<Student> {
    private String name;
    private 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 int compareTo(Student other) {
        return Integer.compare(this.age, other.age); // 나이를 기준으로 오름차순 정렬
    }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
            new Student("Alice", 23),
            new Student("Bob", 21),
            new Student("Charlie", 22)
        );

        Collections.sort(students); // Comparable에 정의된 정렬 기준 사용
        System.out.println(students); // [Bob (21), Charlie (22), Alice (23)]
    }
}

 

2. Comparator

  • 정의: 객체 외부에서 정렬 기준을 정의할 수 있는 인터페이스.
  • 메서드: compare(T o1, T o2) 메서드를 구현해야 합니다.
  • 사용 방법:
    • 정렬 기준을 필요할 때마다 변경할 수 있도록 별도의 클래스를 작성하거나 람다식을 사용합니다.
    • 동일한 클래스에 대해 여러 정렬 기준을 제공할 수 있습니다.
    • 사용자 정렬 기준을 제기하면 보통 Comparator를 쓰게됩니다.
import java.util.*;

class Student {
    private String name;
    private 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 String toString() {
        return name + " (" + age + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
            new Student("Alice", 23),
            new Student("Bob", 21),
            new Student("Charlie", 22)
        );

        // 이름 기준으로 정렬
        students.sort(Comparator.comparing(Student::getName));
        System.out.println(students); // [Alice (23), Bob (21), Charlie (22)]

        // 나이 기준으로 정렬
        students.sort(Comparator.comparingInt(Student::getAge));
        System.out.println(students); // [Bob (21), Charlie (22), Alice (23)]
    }
}

 

 

 

 

  • 이외에도 Guava와 같은 서드파티 라이브러리도 정렬 도구를 제공합니다.
  • 우선순위 큐, 트리셋을 사용한 데이터를 추가하면서 정렬 상태를 유지하는 정렬도 존재합니다.
  • Java의 기본 정렬은 Timsort를 기반으로 하며, 효율적이고 안정적입니다.

 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 열거형 ( Enum )  (0) 2024.11.22
[JAVA] HASH란 무엇인가  (1) 2024.11.21
[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] NULL  (0) 2024.11.13
[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13

★Enum (열거형)

 

Enum의 주요 메서드

  • values(): 열거형에 정의된 모든 값을 배열로 반환.
  • valueOf(String name): 이름으로 열거형 상수를 찾음.
// 모든 값을 배열로 반환
enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

public class EnumValuesExample {
    public static void main(String[] args) {
        Day[] days = Day.values();
        for (Day day : days) {
            System.out.println(day);  // MONDAY, TUESDAY, ... 출력
        }
    }
}

 

사용 시 유의점

  • Enum은 상수 값을 정의하는 데 유용하지만, 값의 추가나 변경이 자주 일어날 경우 유연성에서 부족할 수 있습니다.
  • Enum을 사용할 때는 상수의 집합이 의미가 있는지, 그 값들이 변경되지 않도록 보장해야 하는 경우 적합합니다.

 

< 예제 >

enum Day {
    MONDAY("월요일"), TUESDAY("화요일"), WEDNESDAY("수요일"),
    THURSDAY("목요일"), FRIDAY("금요일"), SATURDAY("토요일"), SUNDAY("일요일");

    private String koreanName;

    Day(String koreanName) {
        this.koreanName = koreanName;
    }

    public String getKoreanName() {
        return koreanName;
    }
}

public class EnumExample {
    public static void main(String[] args) {
        Day today = Day.MONDAY;
        System.out.println(today.getKoreanName());  // 월요일 출력
    }
}

//////////////////////////////응용


enum Season {
    SPRING, SUMMER, FALL, WINTER
}

public class EnumComparisonExample {
    public static void main(String[] args) {
        Season currentSeason = Season.SUMMER;

        if (currentSeason == Season.SUMMER) {
            System.out.println("It's summer!");
        }
    }
}

 

 

★람다 (Lambda Expression)

 

 

★스트림 (Stream)

 

 

 

★제네릭 (Generics)

📌 클래스, 인터페이스, 메서드 등에서 사용하는 타입을 매개변수화하여 코드의 재사용성유연성을 높여주는 Java의 중요한 기능

제네릭의 사용 상황

  1. 컬렉션 프레임워크:
    • List, Set, Map 등에서 제네릭을 사용하여 타입을 명확히 지정함으로써 타입 안전성을 보장하고, 불필요한 캐스팅을 피할 수 있습니다.
  2. 유틸리티 클래스 작성:
    • 제네릭을 사용하면 다양한 타입에 대해 범용적으로 사용할 수 있는 클래스나 메서드를 작성할 수 있습니다. 예를 들어, Box 클래스를 사용하면 Integer, String 등 다양한 타입을 처리할 수 있습니다.
  3. 타입 제한이 필요한 경우:
    • 특정 타입이나 그 하위 타입에 대해서만 작동하도록 제네릭을 사용할 때 타입 제한을 두어, 보다 강력한 타입 안전성을 유지할 수 있습니다.
     
  4. API 설계:
    • 제네릭을 사용하면 클래스나 메서드의 재사용성을 높이기 위해, 라이브러리나 프레임워크를 설계할 때 유용합니다. 예를 들어, Java의 ArrayList는 다양한 타입을 받을 수 있도록 제네릭을 사용합니다.
public <T extends Number> void printNumber(T num) {
    System.out.println(num);
}

제네릭의 단점

  • 타입 불일치: 컴파일 타임에 타입을 지정하므로 런타임에 타입을 변경할 수 없습니다.
  • 다형성 제한: 제네릭 타입은 **원시 타입(primitive type)**을 사용할 수 없습니다. 예를 들어, int 대신 Integer와 같은 래퍼 클래스를 사용해야 합니다.
  • 복잡성: 제네릭을 사용할 때 코드가 다소 복잡해질 수 있습니다.

1. 제네릭 클래스

// 제네릭 클래스를 정의할 때 타입 파라미터 <T>를 사용
class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<Integer> intBox = new Box<>();
        intBox.setItem(10);
        System.out.println(intBox.getItem());  // 10

        Box<String> strBox = new Box<>();
        strBox.setItem("Hello");
        System.out.println(strBox.getItem());  // Hello
    }
}

 

2. 제네릭 메서드

// 제네릭 메서드는 메서드 레벨에서 타입 파라미터를 지정
public class Main {
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3};
        String[] strArray = {"Hello", "World"};

        printArray(intArray);  // 1 2 3
        printArray(strArray);  // Hello World
    }
}

 

3. 제네릭 인터페이스

interface Pair<K, V> {
    K getKey();
    V getValue();
}

class KeyValuePair<K, V> implements Pair<K, V> {
    private K key;
    private V value;

    public KeyValuePair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Pair<Integer, String> pair = new KeyValuePair<>(1, "One");
        System.out.println(pair.getKey() + ": " + pair.getValue());  // 1: One
    }
}

 

 

★static

📌 Java에서 클래스나 객체의 특정 구성 요소에 대한 정적(Static) 특성을 정의하는 키워드입니다. 이를 사용하면 해당 구성 요소가 클래스에 속하게 되며(객체가 아닌 클래스의 변수, 메소드 선언 가능), 객체의 인스턴스화 없이 접근할 수 있습니다.

 

 

★추상 클래스 (Abstract Class)

📌 구현되지 않은 메서드(즉, 추상 메서드)를 포함할 수 있는 클래스입니다. 추상 클래스는 직접 인스턴스를 생성할 수 없으며, 다른 클래스에서 상속받아 사용하도록 설계됩니다.

 

정의:

  • 추상 클래스abstract 키워드로 선언되며, 구현되지 않은 메서드를 포함할 수 있습니다.
  • 인스턴스를 생성할 수 없다는 특징이 있으며, 이를 상속받은 자식 클래스에서 추상 메서드를 구현해야 합니다.

사용처:

    • 공통된 속성과 동작을 여러 클래스에서 공유해야 할 때 사용합니다.
    • 공통된 기본 기능을 부분적으로 구현하고, 그 외의 구체적인 구현은 자식 클래스에서 하도록 유도할 때 유용합니다.

  • 저 인터페이스 공통된 기능을 다른 클래스들에게 강제 한다는 말은 인터페이스에서 사용된 변수, 메소드는 반드시 인터페이스를 구현할 클래스에서 반드시 구현되어야만 한다. (추상 클래스는 안해도 상관없다.)
  • 이런 일이 발생하는 이유는 객체의 청사진이 클래스라면, 클래스의 청사진이 인터페이스이기 때문

 

★인터페이스

📌 클래스의 청사진으로, 인터페이스를 구현할 클래스는 반드시 인터페이스에서 사용된 변수, 메소드를 반드시 구현해야한다.

 

  • implements 키워드를 사용하여 인터페이스를 구현할 수 있습니다. 
public class 클래스명 implements 인터페이스명 { 
			// 추상 메서드 오버라이딩
			@Override
	    public 리턴타입 메서드이름(매개변수, ...) {
			       // 실행문
	    }
}

 

  • 인터페이스 간의 상속은 implements 가 아니라 extends 키워드를 사용합니다.
public class Main extends D implements C {

    @Override
    public void a() {
        System.out.println("A");
    }

    @Override
    public void b() {
        System.out.println("B");
    }

    @Override
    void d() {
        super.d();
    }

    public static void main(String[] args) {
        Main main = new Main();
        main.a();
        main.b();
        main.d();
    }
}

interface A {
    void a();
}

interface B {
    void b();
}

interface C extends A, B {
}

class D {
    void d() {
        System.out.println("D");
    }
}

 

 

This

📌 현재 객체 자신을 참조하는 키워드로, 객체 내부에서 객체의 멤버(필드, 메서드, 생성자)를 명확히 참조하기 위해 사용됩니다.

정의 객체의 현재 인스턴스를 참조하는 키워드로, 객체 내부에서 멤버 변수와 메서드를 호출하거나 생성자 호출에 사용.
특징 ✔ 객체 자신의 주소를 나타냄
✔ 인스턴스 멤버에 접근할 때 사용
✔ 메서드나 생성자에서 주로 활용됨
✔ 정적(static) 맥락에서는 사용 불가

 

  • 위 경우 입력값 model이 객체의 model이 아닌 매개변수 model에 입력됨

 

  • 위 경우 입력값 model이 객체의 model에 입력됨
  • 매개변수, 지역 변수, 클래스 변수의 이름이 동일할 경우 구분을 위해 사용
  • this. = 현재 객체의 변수
  • this() = 현재 객체 생성자

 

final

📌 현재 클래스, 변수를 더이상 변경 불가하게 만들어 버린다. 상속도 막힌다.

 

Object

📌 Java 내 모든 클래스들의 최상위 부모 클래스, 다양한 기능 제공

 


 

★객체지향 프로그래밍

class Car {
    String model;
    String color;
    
    // 기본 생성자
    public Car(String model, String color) {
        this.model = model;
        this.color = color;
    }
    
    // gasPedal 메서드: 속도를 설정하는 메서드로 kmh 값을 speed 필드에 저장하고 반환
    public double gasPedal(double kmh) {
        speed = kmh; // 자동차의 현재 속도를 kmh 값으로 설정
        return speed; // 설정된 속도 반환
    }
}

public class Main {
    public static void main(String[] args) {
        // 인스턴스화 과정
        Car car1 = new Car("Sedan", "Red"); // Car 클래스로부터 car1 객체 생성
        Car car2 = new Car("SUV", "Blue");  // Car 클래스로부터 car2 객체 생성
        
        System.out.println(car1.model);  // Sedan 출력
        System.out.println(car2.model);  // SUV 출력
    }
}

Car car1 = new Car("Sedan", "Red");는 Car 클래스를 사용하여 "Sedan" "Red"라는 속성값을 가지는 새로운 Car 객체를 생성하고, 그 객체를 car1이라는 참조형 변수에 참조하도록 하는 코드

인스턴스(객체) car1, car2
인스턴스화 new
메서드 public double gasPedal(double kmh)
클래스 class Car
생성자 public Car(String model, String color)    /     생성자는 메서드와 다르게 클래스와 이름이 같다

 

 

★오버라이딩

 🐳 부모 클래스로부터 상속받은 메서드의 내용을 재정의 하는 것을 오버라이딩이라고 합니다.

 

  1. 선언부가 부모 클래스의 메서드와 일치해야 합니다.
  2. 접근 제어자를 부모 클래스의 메서드 보다 좁은 범위로 변경할 수 없습니다.
  3. 예외는 부모 클래스의 메서드 보다 많이 선언할 수 없습니다

오버라이딩 사용 상황:

  • 부모 클래스의 메서드를 자식 클래스에서 새롭게 정의하고자 할 때.
  • 상속 구조에서 특정 메서드를 변경하고자 할 때.
  • 다형성을 활용하여, 자식 클래스의 특성에 맞는 기능을 구현하고자 할 때.

 

★오버로드

 🐳 오버로딩은 하나의 클래스 내에서 동일한 이름을 가진 메서드를 여러 개 정의하는 것입니다. 하지만 오버로딩된 메서드는 매개변수의 개수나 타입이 달라야 합니다.

  • 즉, 메서드 이름은 같지만 매개변수의 타입, 개수, 순서가 다르면 컴파일러가 어떤 메서드를 호출할지 구분할 수 있습니다. 반환 타입만 달라서는 오버로딩이 발생하지 않습니다. 컴파일 시점에 메서드가 결정됩니다

오버로딩 사용 상황:

  • 하나의 메서드 이름으로 다양한 입력을 처리하고자 할 때.
  • 매개변수의 개수나 타입이 다른 경우에도 같은 작업을 처리하고 싶을 때.
  • 메서드를 다수 정의해야 하는 경우, 메서드 이름을 통일하고 코드의 가독성을 높이기 위해.

 

 

다형성 & 추상화

동일한 메서드 이름이지만 다른 방식으로 동작할 수 있게 함

  • sound를 한 부모에서 상속받았지만 내용은 다름 = 다형성은 오버라이딩으로 구현됨
// 부모 클래스
class Animal {
    public void sound() {
        System.out.println("Animal makes a sound");
    }
}

// 자식 클래스 1
class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("Dog barks");
    }
}

// 자식 클래스 2
class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("Cat meows");
    }
}

 

참조형 자료구조 

 

 

클래스 & 객체 & 인터페이스

 

 

인스턴스 맴버와 클래스 맴버

 

 

생성자

항목 내용
정의 객체 생성 시 초기화 작업을 수행하는 특수한 메서드. 클래스 이름과 동일한 이름을 가지며 반환 타입이 없음.
(초기화의 이유 : 안정적이고 예측 가능한 상태에서 객체를 사용할 수 있도록 보장하기 위해서 )
특징 ✔ 클래스 이름과 동일
✔ 반환 타입이 없음 (void도 사용하지 않음)
✔ 객체 생성 시 자동 호출
✔ 오버로딩 가능
사용처 ▶ 객체 초기화 시 필수 데이터를 설정하거나 특정 작업 수행
객체를 생성하면서 동시에 원하는 상태로 설정해야 할 때 사용
종류 - 기본 생성자: 매개변수가 없는 생성자 (컴파일러가 자동 생성, 명시적 정의 가능).
- 매개변수 생성자: 원하는 값으로 객체 초기화
기본값 - 생성자를 정의하지 않으면 컴파일러가 기본 생성자를 자동으로 제공.
- 생성자를 정의하면 기본 생성자는 제공되지 않음.

 

 

< 접근 제어자 정리표 >

접근 제어자 같은 클래스 같은 패키지 (클래스 폴더) 자식 클래스 외부 클래스
public O O O O
protected O O O X
default O O X X
private O X X X

 

 

접근 제어

< 접근 제어자 정리표 >

접근 제어자 같은 클래스 같은 패키지 (클래스 폴더) 자식 클래스 외부 클래스
public O O O O
protected O O O X
default O O X X
private O X X X

 

< 사용 가능한 접근 제어자  >

클래스 public, default
메서드 & 멤버 변수 public, protected, default, private
지역변수 없음

 

 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] HASH란 무엇인가  (1) 2024.11.21
[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] NULL  (0) 2024.11.13
[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13
[JAVA] Generic  (3) 2024.11.12

NULL

📌참조 변수가 아무 객체도 가리키지 않음을 나타내는 특수한 값입니다.

  • 특징:
    • 객체가 생성되지 않았거나, 특정 객체를 참조하지 않는 상태를 나타냅니다.
    • 참조 타입(객체 타입)에만 사용할 수 있습니다. (예: String, Integer, 사용자 정의 클래스 등)
    • 원시 타입(예: int, float)에는 사용할 수 없습니다.
    • null 값의 변수로 메서드를 호출하려고 하면 **NullPointerException**이 발생합니다.

빈 공간 (Empty String)

  • 의미: 길이가 0인 문자열로, 내용이 없는 문자열을 의미합니다.
  • 표현: 빈 문자열은 "" (따옴표만 있는 문자열)로 표현됩니다.
  • 특징:
    • 문자열 객체가 존재하지만, 내용이 없습니다.
    • 메모리에는 문자열 객체가 생성되며, 이는 null과 다릅니다.
    • isEmpty() 메서드로 확인할 수 있습니다.

 

 

 

 

  • NULL은 사용에 있어 주의가 필요한데 null이 반환되면 보통 nullPointerException이 발생하게 된다.
public class NullIsDanger {
    public static void main(String[] args) {

        SomeDBClient myDB = new SomeDBClient();
        
        String userId = myDB.findUserIdByUsername("HelloWorldMan");

        System.out.println("HelloWorldMan's user Id is : " + userId);
    }
}

class SomeDBClient {

    public String findUserIdByUsername(String username) {
        // ... db에서 찾아오는 로직
				String data = "DB Connection Result";

        if (data != null) {
            return data;
        } else {
            return null;
        } 
    }
    
}
  1. 논리적으로도, 환경적으로도 null이 반환될 여지가 있음에도, null이 반환될 수 있음을 명시하지 않았습니다.
  2. 메인 함수 쪽에서, 사용할 때 null 체크를 하지 않아, 만약 null이 반환 된다면, nullPointerException이 발생하게 됩니다.

 

  • 위 문제를 해결하는 방법은 크게 3가지인데
  • 1. null이 반환될 여지를 명시하고, 그 메서드를 사용하는 사람이 조심하기
  • 2. 결과값을 감싼 객체를 반환 = 결과값 + 성공 여부를 함께 반환 = 성공 못해도 값을 null로 반환하진 않는다.
  • 3. java.util.Optiona 객체 사용

+ 감싼 객체 : 보통 반환은 1개의 데이터만 반환하지만 객체의 형태로 반환하면 여러 데이터를 담아 반환이 가능하다.

 

 

java.util.Optional

📌 Java 8에서 도입된 클래스입니다. 널(null) 값을 명시적으로 처리하기 위해 사용됩니다. Optional은 값이 있을 수도 있고 없을 수도 있다는 개념을 명확하게 표현하고, 널 포인터 예외(NullPointerException)를 방지하는 데 도움을 줍니다.

 

Optional 기본 정리

  • Java8에서는 Optional<T> 클래스를 사용해 Null Pointer Exception을 방지할 수 있도록 도와줍니다.
  • Optional<T>는 null이 올 수 있는 값을 감싸는 Wrapper 클래스입니다.
  • Optional이 비어있더라도, 참조해도 Null Pointer Exception가 발생하지 않습니다.
  • Optional은 객체를 생성하는데 오버헤드가 있을 수 있기 때문에 성능이 중요한 경우 과도한 사용을 피하는 것이 좋습니다.
  • 모든 메서드에서 Optional을 사용해야 할 필요는 없으며, 주로 널 처리가 필요한 곳에서만 사용하는 것이 바람직합니다.

 

Optional 사용법

1. Optional 생성

  • Optional.of(): 값이 반드시 존재할 때 사용합니다. 만약 null을 넣으려 하면 NullPointerException이 발생합니다.
  • Optional.ofNullable(): 값이 null일 수도 있는 경우에 사용합니다.
  • Optional.empty(): 빈 Optional 객체를 생성합니다. (값이 없음을 나타냄)
Optional<String> presentValue = Optional.of("Hello");
Optional<String> nullableValue = Optional.ofNullable(null); // null이 될 수 있음
Optional<String> emptyValue = Optional.empty(); // 빈 Optional

 

2. 값 존재 여부 확인

  • isPresent(): 값이 존재하면 true, 그렇지 않으면 false를 반환합니다.
  • ifPresent(): 값이 존재하면 해당 값을 처리하는 람다를 실행합니다.
Optional<String> value = Optional.of("Hello");

// 값이 존재하는지 확인
if (value.isPresent()) {
    System.out.println(value.get()); // 값이 존재하면 출력
}

// 값이 존재하면 처리하는 방식
value.ifPresent(v -> System.out.println("Value: " + v));

 

3. 값을 안전하게 얻기

  • get(): Optional에 값이 존재하면 해당 값을 반환합니다. 값이 없으면 NoSuchElementException을 발생시킵니다.
  • orElse(): 값이 존재하면 해당 값을 반환하고, 없으면 기본값을 반환합니다.
  • orElseGet(): 값이 존재하면 해당 값을 반환하고, 없으면 제공된 Supplier로 기본값을 생성하여 반환합니다.
  • orElseThrow(): 값이 없으면 예외를 던집니다.
Optional<String> value = Optional.ofNullable("Hello");

// 값이 없으면 예외를 던짐
String result = value.orElseThrow(() -> new IllegalArgumentException("Value is missing"));

// 값이 없으면 기본값을 반환
String result2 = value.orElse("Default Value");

// 값이 없으면 Supplier로 기본값을 생성
String result3 = value.orElseGet(() -> "Generated Value");

 

4. 값 변환

  • map(): Optional에 값이 있을 때, 해당 값에 연산을 적용하여 새로운 Optional을 반환합니다.
  • flatMap(): map()과 유사하지만, 결과가 또 다른 Optional일 때 사용됩니다. (중첩된 Optional을 평평하게 만들어줍니다.)
Optional<String> value = Optional.of("Hello");

// 값이 있을 때 대문자로 변환
Optional<String> upperCaseValue = value.map(String::toUpperCase);
System.out.println(upperCaseValue.get()); // HELLO

// flatMap 예시
Optional<String> result = value.flatMap(v -> Optional.of(v + " World"));
System.out.println(result.get()); // Hello World

 

5. 필터링

  • filter(): Optional에 값이 있을 때, 특정 조건을 만족하는 값만 남기고 필터링합니다.
Optional<String> value = Optional.of("Hello");

// 값이 "Hello"인 경우에만 값 반환
Optional<String> filteredValue = value.filter(v -> v.equals("Hello"));
filteredValue.ifPresent(System.out::println); // Hello

 

< 예시 >

public class OptionalExample {
    public static void main(String[] args) {
        Optional<String> name = Optional.ofNullable(getName());

        // 값이 있으면 출력
        name.ifPresent(System.out::println);

        // 값이 없으면 기본값 반환
        String defaultName = name.orElse("Default Name");
        System.out.println("Name: " + defaultName);

        // 값이 없으면 예외 던지기
        String forcedName = name.orElseThrow(() -> new RuntimeException("Name is missing"));
    }

    public static String getName() {
        // 값이 없으면 null을 반환
        return null;
    }
}

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13
[JAVA] Generic  (3) 2024.11.12
[JAVA] 오류 및 예외에 대한 이해  (1) 2024.11.12

프로세스

📌 운영체제로부터 자원을 할당받는 작업의 단위

 

프로세스 작업 단위

  • 프로세스는 “실행 중인 프로그램”을 의미합니다.
  • 예를 들어 우리가 Java 프로그램을 실행시키면 이 프로그램은 프로세스라는 이름으로 운영체제 위에서 실행됩니다.
  • 즉, OS 위에서 실행되는 모든 프로그램은 OS가 만들어준 프로세스에서 실행됩니다.
    • 카카오톡, 브라우저, JAVA 프로그램 모두 프로세스로 실행되며
    • 크롬 브라우저를 2개 띄우면 크롬 브라우저 프로세스도 2개가 띄워진 것입니다.

 

 

프로세스 구조

📌 OS가 프로그램 실행을 위한 프로세스를 할당해 줄 때 프로세스 안에 프로그램 Code와 Data 그리고 메모리 영역(Stack, Heap)을 함께 할당해 줍니다.

  1. Code는 Java main 메소드와 같은 코드를 말합니다.
  2. Data는 프로그램이 실행 중 저장할 수 있는 저장 공간을 의미합니다.
    1. 전역변수, 정적 변수(static), 배열 등 초기화된 데이터를 저장하는 공간
  3. Memory (메모리 영역)
    • Stack : 지역변수, 매개변수 리턴 변수를 저장하는 공간
    • Heap : 프로그램이 동적으로 필요한 변수를 저장하는 공간 (new(), mallock())

 

 

 

 

쓰레드

📌 프로세스가 할당받은 자원을 이용하는 실행의 단위

https://madplay.github.io/post/process-vs-thread

 

  • 위 그림처럼 OS는 프로세스 마다 메모리와 CPU 사용량을 할당해 준다.
  • 쓰레드 1개 생성 = 프로세스 1개를 실행할 일꾼을 배치 한다는 의미이다.
  • 여기서 프로세스는 정해진 자원을 할당받으면 위 처럼 같은 프로세스를 2번 실행하면 자원을 반씩 할당 받는가? 라고 생각할 수 있는데, 프로세스가 할당받은 총 자원의 양은 변하지 않으며, 그 자원을 여러 쓰레드가 협업하며 사용하는 방식이다.
  • 흔히 쓰레드는  LIFO FIFO 방식으로 운영되다보니 확실히 쓰레드 1개인 프로세스와 2개인 프로세스의 처리 속도는 다를 수 밖에 없다.

 

쓰레드의 생성

  • 프로세스가 작업 중인 프로그램에서 실행 요청이 들어오면 쓰레드(일꾼)을 만들어 명령을 처리하도록 합니다.

쓰레드의 자원

  • 프로세스 안에는 여러 쓰레드(일꾼)들이 있고, 쓰레드들은 실행을 위한 프로세스 내 주소 공간이나 메모리 공간(Heap)을 공유 받습니다. (자원에 대한 정보는 프로세스에게 있고 쓰레드는 공유하는 것)
  • 추가로, 쓰레드(일꾼)들은 각각 명령 처리를 위한 자신만의 메모리 공간(Stack)도 할당받습니다.

 

 

Java 쓰레드

📌  일반 쓰레드와 동일하며 JVM 프로세스 안에서 실행되는 쓰레드를 말합니다.

  • Java 프로그램을 실행하면 앞서 배운 JVM 프로세스 위에서 실행됩니다.
  • Java 프로그램 쓰레드는 Java Main 쓰레드부터 실행되며 JVM에 의해 실행됩니다.
  • = Java는 메인 쓰레드가 main() 메서드를 실행시키면서 시작이 됩니다.

 

싱글 & 멀티 쓰레드

 

싱글 쓰레드

📌 프로세스 안에서 하나의 쓰레드만 실행되는 것을 말합니다.

  • Java 프로그램의 경우 main() 메서드만 실행시켰을 때 이것을 싱글 쓰레드 라고 합니다.
  • 지금까지 코드스니펫을 통해 실습한 모든 Java 프로그램들은 main() 메서드만 실행시켰기 때문에 모두 싱글 쓰레드로 실행되고 있었습니다.
    • Java 프로그램 main() 메서드의 쓰레드를 ‘메인 쓰레드’ 라고 부릅니다.
    • JVM의 메인 쓰레드가 종료되면, JVM도 같이 종료됩니다.

멀티 쓰레드

📌 프로세스 안에서 여러 개의 쓰레드가 실행되는 것을 말합니다.

  • 하나의 프로세스는 여러 개의 실행 단위(쓰레드)를 가질 수 있으며 이 쓰레드들은 프로세스의 자원을 공유합니다.
  • Java 프로그램은 메인 쓰레드 외에 다른 작업 쓰레드들을 생성하여 여러 개의 실행 흐름을 만들 수 있습니다.
  • 멀티 쓰레드 장점
    • 여러 개의 쓰레드(실행 흐름)을 통해 여러 개의 작업을 동시에 할 수 있어서 성능이 좋아집니다.
    • 스택을 제외한 모든 영역에서 메모리를 공유하기 때문에 자원을 보다 효율적으로 사용할 수 있습니다.
    • 응답 쓰레드와 작업 쓰레드를 분리하여 빠르게 응답을 줄 수 있습니다. (비동기)
  • 멀티 쓰레드 단점
    • 동기화 문제가 발생할 수 있습니다.
      • 프로세스의 자원을 공유하면서 작업을 처리하기 때문에 자원을 서로 사용하려고 하는 충돌이 발생하는 경우를 의미합니다.
      • A쓰레드는 자원이 10개 남았다고 들어서 10개 예약했는데, 지금 자원을 쓰고 있는 B가 써보니 자원이 8개 남았다 
      • 이 과정에서 자원이 얼마 남았는지 쓰레드끼리 서로 정확히 알고 있어야 하는데, 이 서로 알려주는 과정을 동기화라고 한다.
    • 교착 상태(데드락)이 발생할 수 있습니다.
      • 둘 이상의 쓰레드가 서로의 자원을 원하는 상태가 되었을 때 서로 작업이 종료되기만을 기다리며 작업을 더 이상 진행하지 못하게 되는 상태를 의미합니다.
      • A,B 쓰레드 둘다 동시에 자원에 접근하면 코드가 에러가 난다. 병렬처리라고 하지만, 실제로 PC는 아주 빠른 속도로 데이터를 번갈아 가면서 처리하는 것이라 자원은 무조건 하나의 쓰레드만 차지할 수 있다. 근데 A,B가 동시에 요청하면 OS상에서 쓰레드가 중지된다.

쓰레드의 구현

 

Thread (클래스)

public class Main {
    public static void main(String[] args) {
        TestThread thread = new TestThread();
        thread.start();
    }
}

class TestThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i <100; i++) {
            System.out.print("*");
        }
    }
}
  • Java에서 제공하는 Thread 클래스를 상속받아 쓰레드를 구현해 줍니다.
  • run() 메서드에 작성된 코드가 쓰레드가 수행할 작업입니다.

 

Runnable (인터페이스)

public class Main {
    public static void main(String[] args) {

        Runnable run = new TestRunnable();
        Thread thread = new Thread(run);

        thread.start();
    }
}

class TestRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i <100; i++) {
            System.out.print("$");
        }
    }
}
  • Java에서 제공하는 Runnable 인터페이스를 사용하여 쓰레드를 구현해 줍니다.
  • 여기서의 run() 메서드도 마찬가지로 쓰레드가 수행할 작업입니다.
  • 그렇다면 Thread를 직접 상속받아 사용하는 방법이 더 간단해 보이는데 왜? Runnable을 사용하여 쓰레드를 구현하는 방법을 소개해 드렸을까요?
  • 바로 클래스와 인터페이스 차이 때문입니다. Java는 다중 상속을 지원하지 않습니다.
  • 그렇기 때문에 Thread를 상속받아 처리하는 방법은 확장성이 매우 떨어집니다.
  • 반대로 Runnable은 인터페이스이기 때문에 다른 필요한 클래스를 상속받을 수 있습니다.
    • 따라서 확정성에 매우 유리합니다.

람다식

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            int sum = 0;
            for (int i = 0; i < 50; i++) {
                sum += i;
                System.out.println(sum);
            }
            System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
        };

        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}
  • run() 메서드에 작성했던 쓰레드가 수행할 작업을 실행 블록 { } 안에 작성하시면 됩니다.
  • setName() 메서드는 쓰레드에 이름을 부여할 수 있습니다.
  • Thread.currentThread().getName() 은 현재 실행 중인 쓰레드의 이름을 반환합니다.

멀티 쓰레드의 처리

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };
        Runnable task2 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("*");
            }
        };


        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task2);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}


// 결과
$$$***$$$**$$$***$$***$$$***$$**$$**$$**$*$*$**$$**$**... (등등)
  • 메인 쓰레드에서 멀티 쓰레드로 여러 개의 작업을 실행해 봅시다.
  • $ 와 * 이 순서가 일정하지 않게 출력되는 모습을 볼 수 있습니다.
  • 여러 번 실행해서 보면 순서가 정해져 있지 않아 출력의 형태가 계속해서 변화하는 것을 확인할 수 있습니다.
    • 즉, 2개의 쓰레드는 서로 번갈아가면서 수행됩니다.
  • 여기서 이 두 쓰레드의 실행 순서나 걸리는 시간은 OS의 스케줄러가 처리하기 때문에 알 수 없습니다.
  • = 그래서 $$$***$$$**$$$***$$***$$$***$$**$$**$$**$*$*$**$$**$**... 이따구로 나옴...

 


 

 

함수형 인터페이스

📌 단 하나의 추상 메서드만 가지는 인터페이스를 말합니다. 보통 함수를 값으로 전달하기위해 사용됩니다.

  • 뒤의 람다식의 이해를 위해 필요한 챕터입니다.
// Car 클래스 내부에 두 메서드 구현

public static boolean hasTicket(Car car) {
        return car.hasParkingTicket;
}

public static boolean noTicketButMoney(Car car) {
        return !car.hasParkingTicket && car.getParkingMoney() > 1000;
}

함수형 인터페이스를 다음과 같이 정리합니다.
interface Predicate<T> {
    boolean test(T t);
}

이 두 함수를 리팩토링
public static List<Car> parkingCarWithTicket(List<Car> carsWantToPark) {
        ArrayList<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            if (car.hasParkingTicket()) {
                cars.add(car);
            }
        }

        return cars;
  }

public static List<Car> parkingCarWithMoney(List<Car> carsWantToPark) {
    ArrayList<Car> cars = new ArrayList<>();

    for (Car car : carsWantToPark) {
        if (!car.hasParkingTicket() && car.getParkingMoney() > 1000) {
            cars.add(car);
        }
    }

    return cars;
}
  • 우리는 함수를 값으로 처리할 수 있게 됐기 때문에, 거의 똑같은 두 개의 함수 따위는 필요가 없다.
  • 즉 아래와 같이 수정된다.
// 변경점 1 : Predicate<Car> 인터페이스를 타입 삼아 함수를 전달합니다!
public static List<Car> parkCars(List<Car> carsWantToPark, Predicate<Car> function) {
      List<Car> cars = new ArrayList<>();

      for (Car car : carsWantToPark) {
					// 변경점 2 : 전달된 함수는 다음과 같이 사용됩니다!
          if (function.test(car)) {
              cars.add(car);
          }
      }

      return cars;
  }

 

< 실제 사용 >

parkingLot.addAll(parkCars(carsWantToPark, Car::hasTicket));
parkingLot.addAll(parkCars(carsWantToPark, Car::noTicketButMoney));
  • 새로운 문법 Car::hasTicket 이 보이시나요?
  • 우리는 함수를 값으로 취급하기 때문에, 함수를 참조로 불러서 쓸 수 있습니다.
  • Car 클래스의 hasTicket 함수를 값으로 가져와서 전달하는 부분입니다!

 

< 예제 >

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

public class LambdaAndStream {
    public static void main(String[] args) {
        ArrayList<Car> carsWantToPark = new ArrayList<>();
        ArrayList<Car> parkingLot = new ArrayList<>();

        Car car1 = new Car("Benz", "Class E", true, 0);
        Car car2 = new Car("BMW", "Series 7", false, 100);
        Car car3 = new Car("BMW", "X9", false, 0);
        Car car4 = new Car("Audi", "A7", true, 0);
        Car car5 = new Car("Hyundai", "Ionic 6", false, 10000);

        carsWantToPark.add(car1);
        carsWantToPark.add(car2);
        carsWantToPark.add(car3);
        carsWantToPark.add(car4);
        carsWantToPark.add(car5);

        parkingLot.addAll(parkCars(carsWantToPark, Car::hasTicket));
        parkingLot.addAll(parkCars(carsWantToPark, Car::noTicketButMoney));


        for (Car car : parkingLot) {
            System.out.println("Parked Car : " + car.getCompany() + "-" + car.getModel());
        }


    }

    public static List<Car> parkCars(List<Car> carsWantToPark, Predicate<Car> function) {
        List<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            if (function.test(car)) {
                cars.add(car);
            }
        }

        return cars;
    }


}

Car 클래스 내부에 메서드 구현
class Car {
    private final String company; // 자동차 회사
    private final String model; // 자동차 모델

    private final boolean hasParkingTicket;
    private final int parkingMoney;

    public Car(String company, String model, boolean hasParkingTicket, int parkingMoney) {
        this.company = company;
        this.model = model;
        this.hasParkingTicket = hasParkingTicket;
        this.parkingMoney = parkingMoney;
    }

    public String getCompany() {
        return company;
    }

    public String getModel() {
        return model;
    }

    public boolean hasParkingTicket() {
        return hasParkingTicket;
    }

    public int getParkingMoney() {
        return parkingMoney;
    }

    public static boolean hasTicket(Car car) {
        return car.hasParkingTicket;
    }

    public static boolean noTicketButMoney(Car car) {
        return !car.hasParkingTicket && car.getParkingMoney() > 1000;
    }
}

함수형 인터페이스를 다음과 같이 정리합니다.
interface Predicate<T> {
    boolean test(T t);
}

 

 

람다식

📌 Java 8 (함수형 프로그래밍, 병렬의 시작 굉장히 중요한 버전이다.)부터 도입된 기능으로, 익명 함수(이름 없는 함수)를 간단하게 표현할 수 있는 문법입니다. 주로 코드를 간결하게 하고, 함수형 프로그래밍 스타일을 지원하기 위해 사용됩니다.

 

기존 방식 람다
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello, World!");
    }
};
() -> System.out.println("Hello, World!");


(매개변수) -> { 함수 본문 }

 

// 주말의 주차장 추가
ArrayList<Car> weekendParkingLot = new ArrayList<>();

weekendParkingLot
.addAll(parkCars(carsWantToPark, (Car car) -> car.hasParkingTicket() && car.getParkingMoney() > 1000));

 

  • 새로운 문법! (Car car) -> car.hasParkingTicket() && car.getParkingMoney() > 1000))
  • 함수를 값으로 전달하는데, 어딘가에 구현하지 않고 그냥 간단하게 구현해서 넘기고 싶으면 람다식을 이용하면 됩니다!
  • 람다식은 “함수 값”으로 평가되며, 한 번만 사용됩니다.

람다 함수의 장점

  1. 코드 간결성: 익명 클래스보다 간결하고 가독성이 좋음.
  2. 함수형 프로그래밍 지원: Stream API와 결합해 데이터 처리를 선언적으로 표현.
  3. 지연 평가 가능: 필요한 시점까지 계산을 미뤄 성능 최적화에 유리.
  4. 익명 클래스 중복 감소: 반복적인 익명 클래스 코드를 줄여 유지보수성 향상.
  5. 병렬 처리 지원: Stream API와 함께 멀티코어 병렬 처리에 유리.

람다 함수의 단점

  1. 디버깅 어려움: 스택 트레이스가 불명확해 오류 추적이 어려울 수 있음.
  2. 재사용성 제한: 복잡한 로직은 재사용이 어렵고 가독성 저하 가능.
  3. 가독성 저하 가능성: 다중 중첩된 람다식은 코드 흐름을 파악하기 어려움.
  • 한발쏘면 끝나는 샷건으로 간결하고 현재의 상황을 모두 깔끔하게 정리할 수 있지만, 다시 못쓴다...

 

< 람다 함수 문법 >

// 기본적으로 문법은 다음과 같습니다.
(파라미터 값, ...) -> { 함수 몸체 }

// 아래의 함수 두개는 같은 함수입니다.
// 이름 반환타입, return문 여부에 따라 {}까지도 생략이 가능합니다.
public int toLambdaMethod(int x, int y) {
	return x + y;
}

(x, y) -> x + y

// 이런 함수도 가능하겠죠?
public int toLambdaMethod2() {
	return 100;
}

() -> 100

// 모든 유형의 함수에 가능합니다.
public void toLambdaMethod3() {
	System.out.println("Hello World");
}

() -> System.out.println("Hello World")

 

 

스트림

📌  Java 8부터 제공되는 기술로 람다를 활용해컬렉션(또는 배열)을 함수형으로 간단하게 처리하기 위한 API로, 함수형 프로그래밍 스타일을 지원하는 매우 강력한 도구입니다. 스트림을 사용하면 데이터 처리를 선언적이고 간결하게 할 수 있으며, 병렬 처리와 같은 고급 기능도 지원합니다.

 

  • 스트림은 정확하게는 Java 8부터 제공되는, 한 번 더 추상화된 자료구조자주 사용하는 프로그래밍 API를 제공한 것입니다. 자료구조를 한 번 더 추상화했기 때문에, 자료구조의 종류에 상관없이 같은 방식으로 다룰 수 있습니다.
  • ( + 병렬 처리에 유리한 구조로 조건부로 성능도 챙길 수 있습니다.)
  • 조금 더 쉽게 “비유” 하자면, 자료구조의 “흐름”을 객체로 제공해 주고, 그 흐름 동안 사용할 수 있는 메서드들을 api로 제공해 주고 있는 것이죠 일단은 “자료구조” (리스트, 맵, 셋 등)의 흐름이라고 비유하면 이해가 조금 더 쉬울 것 같습니다.
  • 스트림 메서드는 collection에 정의되어 있기 때문에, 모든 컬렉션을 상속하는 구현체들은 스트림을 반환할 수 있습니다.

https://steady-coding.tistory.com/309

 

  • 말이 어려운데, 보통 우리가 데이터를 다룰때 각 자료구조마다 약간씩은 사용방식이 다른 경우가 많다.
  • 예를 들면, List를 정렬할 때는 Collections.sort()를 사용하고, 배열을 정렬할 때는 Arrays.sort()를 사용한다
  • 비슷한 기능들을 하나의 방식으로 통일해서 사용할 수 없을까? 에서 시작한 것이 스트림이다.
  • 스트림은 데이터 소스가 무엇이던 다 통일하여 같은 방식으로 다룰 수 있게 해준다.
  • 그래서 자료구조를 추상화 했다는 말이 나오는 것
  • 더 나아가 하나의 "흐름" 처럼 위 그림과 같이 과정을 연속적으로 처리한다.
        List<String> filteredList = namesList.stream() // List를 스트림으로 변환
            .filter(name -> name.startsWith("A") || name.startsWith("B")) // A 또는 B로 시작하는 이름 필터링
            .sorted() // 알파벳 순 정렬
            .map(String::toUpperCase) // 대문자로 변환
            .collect(Collectors.toList()); // List로 변환
  • 정말 그냥 대놓고 1열로 나열해 버린다.

 

< 예시 >

import java.util.*;
import java.util.stream.*;

public class WithoutStreamExample {
	// 스트림 적용 안한 상태
    public static void main(String[] args) {
        // 1. List를 필터링, 정렬, 변환하는 작업
        List<String> namesList = Arrays.asList("Alice", "Bob", "Charlie", "David");
        ♣////////////////////////////////////////////////////////////////////////////////
        List<String> filteredList = new ArrayList<>();
        
        for (String name : namesList) {
            if (name.startsWith("A") || name.startsWith("B")) { // A 또는 B로 시작하는 이름 필터링
                filteredList.add(name.toUpperCase()); // 대문자로 변환
            }
        }
        
        Collections.sort(filteredList); // 알파벳 순 정렬
        ////////////////////////////////////////////////////////////////////////////////
        System.out.println("Filtered List without Stream: " + filteredList);

    public static void main(String[] args) {
    	// 스트림 적용한 상태
        // 1. List를 스트림을 통해 필터링, 정렬, 변환하는 작업
        List<String> namesList = Arrays.asList("Alice", "Bob", "Charlie", "David");
		
        ★////////////////////////////////////////////////////////////////////////////////
        List<String> filteredList = namesList.stream() // List를 스트림으로 변환
            .filter(name -> name.startsWith("A") || name.startsWith("B")) // A 또는 B로 시작하는 이름 필터링
            .sorted() // 알파벳 순 정렬
            .map(String::toUpperCase) // 대문자로 변환
            .collect(Collectors.toList()); // List로 변환
		////////////////////////////////////////////////////////////////////////////////
        
        System.out.println("Filtered List with Stream: " + filteredList);


}}
  • 기존의 방식(♣)은 for, if, 외부 메서드 호출을 통해 여러 과정을 거쳐 filter(구분) 하거나 collect(수집) 해야했지만
  • 스트림(★)은 filter, map, collect와 같은 기능이 이미 구현이 되어있고, 1열로 그냥 나열해서 써도 되다보니 간결하고 처리가 빠르다.

 

더보기
  • 스트림을 사용하지 않은 방식에서는 for 루프와 조건문을 활용하여 데이터를 필터링하고, 별도의 메서드(Collections.sort)로 정렬을 수행하며, 최종적으로 변환을 진행했습니다.
    • 코드가 길어지고, 각 작업 단계가 명확하게 분리되지 않아 가독성이 떨어질 수 있습니다.
    • List와 배열을 다룰 때 처리 방식에 차이가 있으며, 필터링과 변환 작업을 추가할수록 코드 복잡도가 높아집니다.
  • 스트림을 사용한 방식에서는 filter, sorted, map, collect 등 스트림 메서드를 체인 형태로 연결하여 간결하고 일관된 방식으로 데이터를 처리했습니다.
    • List와 배열을 동일한 스트림 메서드로 처리하여 코드의 일관성이 높아집니다.
    • 각 작업이 명확하게 분리되고 체이닝으로 연결되어 있어 가독성과 유지보수성이 크게 향상됩니다.

+ 체인 형태  : 메서드를 연달아 호출하여 작업을 순차적으로 처리하는 방식

 

스트림의 특징

  1. 원본 데이터 불변성: 스트림은 데이터를 읽거나 쓰는 동안 원본 데이터를 변경하지 않습니다. 스트림이 데이터를 처리하는 방식은 원본 데이터의 복사본을 사용하여 전달하고 처리하는 방식이므로, 데이터가 변조되지 않습니다.
  2. 일회성(단일 소비 가능): 스트림은 한 번 사용되면 재사용할 수 없습니다. 데이터를 읽는 스트림은 데이터를 한 번 읽고 나면 다시 읽을 수 없으며, 새로운 스트림을 만들어야 합니다. 이런 특성은 자바의 java.util.stream 패키지의 스트림에도 적용되며, 데이터 처리 후 스트림이 자동으로 소멸됩니다.
  3. 단방향성: 스트림은 데이터를 한 방향으로만 전달합니다. 읽기와 쓰기는 각각 별도의 스트림을 사용해야 하며, 데이터를 주고받으려면 각각 입력 스트림과 출력 스트림을 따로 생성해야 합니다.
  4. 연속성: 스트림은 데이터를 끊김 없이 연속적으로 처리합니다. 이는 파일이나 네트워크 통신에서 특히 유용하며, 데이터를 필요한 순간에 조금씩 처리해 메모리 사용량을 줄입니다.
  5. 시간 지연 최소화: 스트림은 데이터를 즉시 처리하거나 대기하지 않고 바로 전달하므로, 실시간 처리가 중요한 작업에 적합합니다. 네트워크에서 데이터가 도착하는 즉시 처리할 수 있도록 설계되었습니다.
  6. 추상화된 데이터 처리: 스트림은 데이터 소스(파일, 네트워크, 메모리 등)와 데이터가 전달될 위치(출력 화면, 파일 등)를 추상화하여 개발자가 데이터의 출처에 관계없이 동일한 방식으로 처리할 수 있게 해줍니다.
  7. 버퍼링 지원: 스트림에는 버퍼를 사용하여 데이터 전송 속도를 최적화할 수 있는 클래스(BufferedInputStream, BufferedReader 등)가 있습니다. 버퍼링된 스트림은 데이터가 일정량 모였을 때 한 번에 읽거나 쓰는 방식으로 성능을 향상시킵니다.
  8. 다양한 데이터 타입 지원: 바이트 스트림과 문자 스트림처럼 서로 다른 데이터 타입을 처리할 수 있는 다양한 스트림이 제공됩니다. 바이트 스트림(InputStream, OutputStream)은 이진 데이터를, 문자 스트림(Reader, Writer)은 텍스트 데이터를 처리하는 데 사용됩니다.
  9. 필터링과 연결 가능: 필터 스트림(FilterInputStream, FilterOutputStream 등)은 스트림에 필터 기능을 추가할 수 있습니다. 예를 들어, BufferedInputStream을 사용해 버퍼링을 추가하거나, DataInputStream을 통해 기본 데이터 타입을 처리할 수 있습니다. 스트림을 체인처럼 연결하여 다단계로 처리가 가능합니다.

 

스트림은 왜 필요할까?

1. 효율적인 데이터 처리

  • 연속적 데이터 처리: 스트림은 데이터를 한 번에 모두 불러오지 않고, 필요할 때 필요한 만큼만 읽거나 씁니다. 대용량 파일, 실시간 네트워크 데이터, 영상 스트리밍 등을 처리할 때 메모리를 절약하고 성능을 높일 수 있습니다.
  • 버퍼링으로 성능 개선: 스트림의 버퍼링 기능(BufferedInputStream, BufferedReader 등)은 데이터를 임시로 저장해 두었다가 한 번에 처리함으로써 IO 성능을 향상시키고 대기 시간을 줄입니다.

2. 코드 간소화와 유지보수성 향상

  • 고수준 추상화 제공: 스트림을 사용하면 데이터 소스와 관계없이 일관된 방식으로 데이터를 처리할 수 있습니다. 예를 들어, 파일, 네트워크, 메모리에서 데이터를 읽거나 쓸 때 동일한 인터페이스를 사용하여 코드의 가독성이 높아지고 유지보수가 쉬워집니다.
  • 필터링과 변환 용이: 스트림은 체이닝을 통해 여러 필터와 변환 기능을 연달아 적용할 수 있습니다. 데이터 처리를 단일 흐름으로 작성할 수 있어 복잡한 로직을 간결하게 구현할 수 있습니다.

3. 원본 데이터 보호

  • 불변성 유지: 스트림을 통해 데이터를 처리하면 원본 데이터는 그대로 유지되고, 필요할 경우 스트림의 복사본을 사용하여 데이터를 읽고 씁니다. 이로 인해 데이터 무결성을 보호할 수 있습니다.

4. 병렬 처리와 실시간 데이터 처리에 유리

  • 실시간 처리: 네트워크 소켓에서 전송되는 데이터나 파일에서 실시간으로 읽어와야 하는 경우, 스트림을 사용하면 데이터가 도착할 때마다 바로 처리할 수 있습니다.
  • 병렬 처리: 자바 8의 Stream API는 대량 데이터를 병렬 처리할 수 있어 멀티코어 CPU 환경에서 성능을 최적화할 수 있습니다.

5. 다양한 데이터 타입 지원

  • 스트림은 바이트와 문자 기반으로 나뉘어 다양한 데이터 타입을 지원합니다. 이진 데이터(이미지, 영상 파일 등)나 텍스트 데이터 모두 쉽게 처리할 수 있는 클래스를 제공해 개발자가 데이터의 형태에 맞는 스트림을 선택해 사용할 수 있습니다.

스트림을 사용하는 방법

1. 스트림을 받아오기 (.stream())
carsWantToPark.stream()
2. 스트림 가공하기
.filter((Car car) -> car.getCompany().equals("Benz"))
3. 스트림 결과 만들기
.toList();

 

스트림 API

  • https://www.baeldung.com/java-8-streams
  • 위는 스트림에서 제공하는 사이트로, 스트림 api 종류에 관해 상세히 기술되어 있다.
  • 아래는 자주 사용하는 api 메서드들이다.
import java.util.*;
import java.util.stream.*;

public class StreamApiExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Edward", "Frank");

        // 1. filter: 특정 조건에 맞는 요소만 걸러냄
        List<String> filteredNames = names.stream()
            .filter(name -> name.startsWith("A") || name.startsWith("D")) // A나 D로 시작하는 이름
            .collect(Collectors.toList());
        System.out.println("Filtered Names: " + filteredNames);

        // 2. map: 각 요소를 다른 값으로 변환
        List<String> upperCaseNames = names.stream()
            .map(String::toUpperCase) // 대문자로 변환
            .collect(Collectors.toList());
        System.out.println("Upper Case Names: " + upperCaseNames);

        // 3. sorted: 정렬
        List<String> sortedNames = names.stream()
            .sorted() // 기본 알파벳 순 정렬
            .collect(Collectors.toList());
        System.out.println("Sorted Names: " + sortedNames);

        // 4. distinct: 중복 제거
        List<String> namesWithDuplicates = Arrays.asList("Alice", "Bob", "Alice", "Charlie", "Bob");
        List<String> distinctNames = namesWithDuplicates.stream()
            .distinct() // 중복 제거
            .collect(Collectors.toList());
        System.out.println("Distinct Names: " + distinctNames);

        // 5. limit: 앞에서부터 n개의 요소만 선택
        List<String> limitedNames = names.stream()
            .limit(3) // 처음 3개의 이름만 선택
            .collect(Collectors.toList());
        System.out.println("Limited Names: " + limitedNames);

        // 6. skip: 앞에서부터 n개의 요소 건너뛰기
        List<String> skippedNames = names.stream()
            .skip(2) // 처음 2개의 이름 건너뛰기
            .collect(Collectors.toList());
        System.out.println("Skipped Names: " + skippedNames);

        // 7. anyMatch: 조건을 만족하는 요소가 하나라도 있는지 확인
        boolean hasNameStartingWithC = names.stream()
            .anyMatch(name -> name.startsWith("C"));
        System.out.println("Any name starts with 'C': " + hasNameStartingWithC);

        // 8. allMatch: 모든 요소가 조건을 만족하는지 확인
        boolean allNamesStartWithA = names.stream()
            .allMatch(name -> name.startsWith("A"));
        System.out.println("All names start with 'A': " + allNamesStartWithA);

        // 9. noneMatch: 모든 요소가 조건을 만족하지 않는지 확인
        boolean noNameStartsWithZ = names.stream()
            .noneMatch(name -> name.startsWith("Z"));
        System.out.println("No name starts with 'Z': " + noNameStartsWithZ);

        // 10. reduce: 모든 요소를 하나의 값으로 합침
        Optional<String> concatenatedNames = names.stream()
            .reduce((name1, name2) -> name1 + ", " + name2); // 모든 이름을 콤마로 연결
        concatenatedNames.ifPresent(result -> System.out.println("Concatenated Names: " + result));

        // 11. count: 스트림의 총 요소 수 계산
        long nameCount = names.stream()
            .count();
        System.out.println("Count of Names: " + nameCount);

        // 12. collect: 결과를 특정 컬렉션으로 수집
        Set<String> nameSet = names.stream()
            .collect(Collectors.toSet()); // Set으로 수집
        System.out.println("Names as Set: " + nameSet);
    }
}

 

스트림에 대해 더 자세히 알고 싶다면 추천하는 사이트는 https://hstory0208.tistory.com/entry/Java%EC%9E%90%EB%B0%94-Stream%EC%8A%A4%ED%8A%B8%EB%A6%BC%EC%9D%B4%EB%9E%80 여기다.

 

 


 

 

데몬 쓰레드

📌 일반적인 사용자 쓰레드와 달리, 백그라운드에서 실행되는 쓰레드입니다. 이 쓰레드는 주 쓰레드가 종료되면 자동으로 종료되는 특징이 있습니다. 주로 백그라운드에서 실행되어 보조적인 작업을 수행하며, 주 쓰레드가 모든 작업을 끝내면 남아 있는 데몬 쓰레드도 종료됩니다.

 

+ 보조적인 역할을 담당하며 대표적인 데몬 쓰레드로는 메모리 영역을 정리해 주는 가비지 컬렉터(GC)가 있다.

 

public class Main {
    public static void main(String[] args) {
        Runnable demon = () -> {
            for (int i = 0; i < 1000000; i++) {
                System.out.println("demon");
            }
        };

        Thread thread = new Thread(demon);
        thread.setDaemon(true);

        thread.start();

        for (int i = 0; i < 100; i++) {
            System.out.println("task");
        }
    }
}

 

  • demon 쓰레드는 우선순위가 낮고 다른 쓰레드가 모두 종료되면 강제 종료 당하기 때문에 main() 쓰레드의 task가 100번이 먼저 찍히면 종료되어 1000000번 수행이 되지 않고 종료됩니다.

 

사용자 쓰레드

📌 일반적인 프로그램의 주 작업을 수행하는 쓰레드입니다. 자원을 활용해 특정 작업을 수행하며, 모든 사용자 쓰레드가 종료될 때까지 프로그램이 계속 실행됩니다. 이는 데몬 쓰레드와 달리, 사용자 쓰레드가 실행 중인 한 JVM이 종료되지 않는다는 특징이 있습니다.

 

+ 프로그램 기능을 담당하며 대표적인 사용자 쓰레드로는 메인 쓰레드가 있습니다.

 

사용자 쓰레드의 특징

  1. 주요 작업 담당
    • 프로그램의 핵심 작업을 담당하며, 직접적인 연산, 데이터 처리, 입출력 등 다양한 작업을 수행합니다.
  2. 모든 사용자 쓰레드가 종료되어야 JVM이 종료
    • 프로그램 내 모든 사용자 쓰레드가 종료되지 않으면 JVM이 계속 실행됩니다. 반면, 사용자 쓰레드가 모두 종료되면 데몬 쓰레드가 남아있어도 프로그램은 종료됩니다.
  3. 데몬 쓰레드와 구분
    • 기본적으로 생성되는 모든 쓰레드는 사용자 쓰레드로 설정되어 있으며, 명시적으로 setDaemon(true)를 호출하지 않는 한 사용자 쓰레드입니다.
public class Main {
    public static void main(String[] args) {
        Runnable userTask = () -> {
            try {
                for (int i = 0; i < 5; i++) {
                    System.out.println("사용자 쓰레드 작업 중...");
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("사용자 쓰레드 종료");
        };

        Thread userThread = new Thread(userTask);  // 사용자 쓰레드로 설정
        userThread.start();

        System.out.println("메인 쓰레드 종료");
    }
}


메인 쓰레드 종료
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 종료

 

설명: 사용자 쓰레드가 모두 종료되기 전에는 JVM이 계속 실행되기 때문에, 메인 쓰레드가 종료된 이후에도 프로그램이 끝나지 않고 사용자 쓰레드의 작업이 끝날 때까지 실행됩니다.

 

  • 사실 데몬 선언 없이 만드는 코드는 전부 사용자 쓰레드로 실행된다.
  • 그냥 코드를 짜면 다 사용자 쓰레드라는 것

 

쓰레드 우선순위

📌쓰레드 작업의 중요도에 따라서 쓰레드의 우선순위를 부여할 수 있습니다.

  • 작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있습니다. 

 

  • 쓰레드는 생성될 때 우선순위가 정해집니다.
    • 이 우선순위는 우리가 직접 지정하거나 JVM에 의해 지정될 수 있습니다.
  • 우선순위는 아래와 같이 3가지 (최대/최소/보통) 우선순위로 나뉩니다.
    • 최대 우선순위 (MAX_PRIORITY) = 10
    • 최소 우선순위 (MIN_PRIORITY) = 1
    • 보통 우선순위 (NROM_PRIORITY) = 5
      • 기본 값이 보통 우선순위입니다.
    • 더 자세하게 나눈다면 1~10 사이의 숫자로 지정 가능합니다.
    • 이 우선순위의 범위는 OS가 아니라 JVM에서 설정한 우선순위입니다.
  • 스레드 우선순위는 setPriority() 메서드로 설정할 수 있습니다.
  • getPriority()로 우선순위를 반환하여 확인할 수 있습니다.
public class Main {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };

        Runnable task2 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("*");
            }
        };

        Thread thread1 = new Thread(task1);
        thread1.setPriority(8);
        int threadPriority = thread1.getPriority();
        System.out.println("threadPriority = " + threadPriority);

        Thread thread2 = new Thread(task2);
        thread2.setPriority(2);

        thread1.start();
        thread2.start();
    }
}

 

 

 

쓰레드 그룹

📌 여러 개의 쓰레드를 그룹으로 묶어 관리하고 제어할 수 있도록 해주는 Java의 기능입니다. 이를 통해 관련된 쓰레드를 하나의 단위로 묶어 일괄 처리하거나, 특정 그룹에 속한 쓰레드들에 대해 동시에 작업을 수행하는 것이 가능합니다.

 

https://self-learning-java-tutorial.blogspot.com/2014/03/thread-groups.html

 

  • 쓰레드들은 기본적으로 그룹에 포함되어 있습니다.
    • JVM 이 시작되면 system 그룹이 생성되고 쓰레드들은 기본적으로 system 그룹에 포함됩니다.
  • 메인 쓰레드는 system 그룹 하위에 있는 main 그룹에 포함됩니다.
  • 모든 쓰레드들은 반드시 하나의 그룹에 포함되어 있어야 합니다.
    • 쓰레드 그룹을 지정받지 못한 쓰레드는 자신을 생성한 부모 쓰레드의 그룹과 우선순위를 상속받게 되는데 우리가 생성하는 쓰레드들은 main 쓰레드 하위에 포함됩니다.
    • 따라서 쓰레드 그룹을 지정하지 않으면 해당 쓰레드는 자동으로 main 그룹에 포함됩니다.

 

쓰레드 그룹 생성

  • ThreadGroup 클래스로 객체를 만들어서 Thread 객체 생성 시 첫 번째 매개변수로 넣어주면 됩니다.
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");

// Thread에 ThreadGroup 이 할당된것을 확인할 수 있습니다.
System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());

 

쓰레드 그룹으로 묶어서 쓰레드 관리

  • ThreadGroup 객체의 interrupt() 메서드를 실행시키면 해당 그룹 쓰레드들이 실행 대기 상태로 변경됩니다.
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");
Thread thread2 = new Thread(group1, task, "Thread 2");

// interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
group1.interrupt();

 

< 예제 >

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println(Thread.currentThread().getName() + " Interrupted");
        };

        // ThreadGroup 클래스로 객체를 만듭니다.
        ThreadGroup group1 = new ThreadGroup("Group1");

        // Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
        // Thread(ThreadGroup group, Runnable target, String name)
        Thread thread1 = new Thread(group1, task, "Thread 1");
        Thread thread2 = new Thread(group1, task, "Thread 2");

        // Thread에 ThreadGroup 이 할당된것을 확인할 수 있습니다.
        System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());
        System.out.println("Group of thread2 : " + thread2.getThreadGroup().getName());

        thread1.start();
        thread2.start();

        try {
            // 현재 쓰레드를 지정된 시간동안 멈추게 합니다.
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
        group1.interrupt();

    }
}

 

 

 

쓰레드 상태

📌 우리는 동영상을 보거나 음악을 듣고 있을 때 일시정지 후에 다시 보거나 듣기도 하고 중간에 종료 시키기도 합니다. 쓰레드도 마찬가지로 상태가 존재하고 이를 제어를 할 수 있습니다.

 

쓰레드 상태

 

  • 이처럼 쓰레드는 실행과 대기를 반복하며 run() 메서드를 수행합니다.
  • run() 메서드가 종료되면 실행이 멈추게 됩니다.

 

쓰레드 상태 종류

상태
Enum
설명
객체생성
NEW
쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태
실행대기
RUNNABLE
실행 상태로 언제든지 갈 수 있는 상태
일시정지
WAITING
다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태
일시정지
TIMED_WAITING
주어진 시간 동안 기다리는 상태
일시정지
BLOCKED
사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
종료
TERMINATED
쓰레드의 작업이 종료된 상태

 

NEW (새 상태)

  • 설명: 쓰레드가 생성되었지만 아직 시작되지 않은 상태입니다.
  • 특징: new Thread()를 호출하여 객체를 생성했지만 start() 메서드를 호출하지 않은 상태입니다.

2. RUNNABLE (실행 가능 상태)

  • 설명: 실행 중이거나 실행 준비가 된 상태입니다.
  • 특징: start()가 호출되어 쓰레드가 스케줄러에 의해 실행될 준비가 되었을 때입니다. CPU가 사용 가능하면 실행됩니다.
  • 예시: RUNNABLE 상태는 대기 중이지만 곧 CPU에서 실행될 수 있는 상태도 포함됩니다.

3. BLOCKED (블로킹 상태)

  • 설명: 다른 쓰레드가 사용 중인 리소스(예: synchronized 블록)를 기다리는 상태입니다.
  • 특징: 락(lock)을 얻지 못해 일시적으로 실행할 수 없는 상태입니다. 예를 들어, synchronized 블록에 접근하려 했으나 이미 다른 쓰레드가 해당 블록에 들어가 있을 때 발생합니다.

4. WAITING (대기 상태)

  • 설명: 다른 쓰레드가 특정 작업을 완료할 때까지 대기하는 상태입니다.
  • 특징: wait() 메서드 호출, join() 메서드 호출 시 대기 상태가 됩니다. 지정된 조건이 충족되어야 다음 단계로 넘어갈 수 있습니다.
  • 예시: 특정 쓰레드가 종료될 때까지 join()을 호출해 기다리거나, Object.wait()를 호출한 경우.

5. TIMED_WAITING (시간 지정 대기 상태)

  • 설명: 지정된 시간 동안 기다리는 상태입니다.
  • 특징: 특정 시간 동안 기다리며, 시간이 지나면 자동으로 RUNNABLE 상태로 돌아갑니다. sleep(time), wait(time), join(time), LockSupport.parkNanos(), LockSupport.parkUntil() 등이 있습니다.
  • 예시: Thread.sleep(1000);는 1초간 TIMED_WAITING 상태가 됩니다.

6. TERMINATED (종료 상태)

  • 설명: 쓰레드가 종료된 상태입니다.
  • 특징: 쓰레드의 작업이 완료되거나, 예외로 인해 종료된 경우입니다. 이 상태에서는 다시 실행할 수 없습니다.

 

 

쓰레드 제어

📌 쓰레드의 실행을 관리하고, 여러 쓰레드 간의 작업을 조정하는 과정입니다.

 

쓰레드 제어

 

start() 

 

 

📌 쓰레드를 시작하는 메서드로, 쓰레드를 NEW 상태에서 RUNNABLE 상태로 전환시킵니다.

  • start() 메서드를 호출해야만 쓰레드가 실행을 시작할 수 있으며, 이를 호출하지 않으면 쓰레드는 시작되지 않습니다.

 

Thread thread = new Thread(task);
thread.start();  // 쓰레드 시작

 

 

sleep()

📌 현재 쓰레드를 지정된 시간 동안 멈추게 합니다.

  • sleep()은 쓰레드 자기 자신에 대해서만 멈추게 할 수 있습니다. 
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("task : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread");
        thread.start();

        try {
            thread.sleep(1000);
            System.out.println("sleep(1000) : " + Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

  • Thread.sleep(ms); ms(밀리초) 단위로 설정됩니다.
  • 예외 처리를 해야 합니다.
    • sleep 상태에 있는 동안 interrupt()를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있습니다.

interrupt()

📌 일시정지 상태인 쓰레드를 실행 대기 상태로 만듭니다.

public class Thread implements Runnable {
    /* Make sure registerNatives is the first thing <clinit> does. */
    private static native void registerNatives();
    static {
        registerNatives();
    }

    private volatile String name;
    private int priority;

    /* Whether or not the thread is a daemon thread. */
    private boolean daemon = false;

    /* Interrupt state of the thread - read/written directly by JVM */
    private volatile boolean interrupted;
	
		...

		public void interrupt() {
        if (this != Thread.currentThread()) {
            checkAccess();

            // thread may be blocked in an I/O operation
            synchronized (blockerLock) {
                Interruptible b = blocker;
                if (b != null) {
                    interrupted = true;
                    interrupt0();  // inform VM of interrupt
                    b.interrupt(this);
                    return;
                }
            }
        }
        interrupted = true;
        // inform VM of interrupt
        interrupt0();
    }

		...

		public boolean isInterrupted() {
        return interrupted;
    }
}
  • Thread 클래스 내부에 interrupted 되었는지를 체크하는 boolean 변수가 존재합니다.
  • 쓰레드가 start() 된 후 동작하다 interrupt()를 만나 실행하면 interrupted 상태가 true가 됩니다.
  • isInterrupted() 메서드를 사용하여 상태 값을 확인할 수 있습니다.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println("task : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread");
        thread.start();

        thread.interrupt();

        System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
        
    }
}
  • !Thread.currentThread().isInterrupted()로 interrupted 상태를 체크해서 처리하면 오류를 방지할 수 있습니다.
  • [ sleep 상태에 있는 동안 interrupt()를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있습니다. ]

 

join() 

📌정해진 시간 동안 지정한 쓰레드가 작업하는 것을 기다립니다.

  • 시간을 지정하지 않았을 때는 지정한 쓰레드의 작업이 끝날 때까지 기다립니다.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                Thread.sleep(5000); // 5초
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        Thread thread = new Thread(task, "thread");

        thread.start();

        long start = System.currentTimeMillis();

        try {
            thread.join();

        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // thread 의 소요시간인 5000ms 동안 main 쓰레드가 기다렸기 때문에 5000이상이 출력됩니다.
        System.out.println("소요시간 = " + (System.currentTimeMillis() - start));
    }
}
  • Thread.sleep(ms); ms(밀리초) 단위로 설정됩니다.
  • 예외 처리를 해야 합니다.
    • interrupt()를 만나면 기다리는 것을 멈추기 때문에 InterruptedException이 발생할 수 있습니다.
  • 시간을 지정하지 않았기 때문에 thread가 작업을 끝낼 때까지 main 쓰레드는 기다리게 됩니다.

 

yield()

📌 남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행 대기 상태가 됩니다.

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                }
            } catch (InterruptedException e) {
                Thread.yield();
            }
        };

        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");

        thread1.start();
        thread2.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread1.interrupt();

    }
}

 

  • thread1과 thread2가 같이 1초에 한 번씩 출력
  • 5초 뒤에 thread1에서 InterruptedException이 발생
  • Thread.yield(); 이 실행
  • thread1은 실행 대기 상태로 변경
  • 남은 시간은 thread2에게 리소스가 양보됩니다.

 

synchronized

📌 멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있습니다. 이로 인해서 장애나 버그가 발생할 수 있습니다. 이러한 일을 방지하기 위해 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 침범하지 못하도록 막기 위해, 모든 쓰레드는 현재 자원의 상태와 현재 쓰레드가 실행되고 있는지 등의 여부를 동일하게 알고 있어야 합니다. 이 상태 정보를 모두 동일하게 알도록 각 쓰레드가 가진 정보를 통일시키는 것이 '쓰레드 동기화(Synchronization)'라고 합니다.

  • 동기화를 하려면 다른 쓰레드의 침범을 막아야 하는 코드들을 ‘임계 영역( 공유 자원에 접근하는 코드 부분)’으로 설정하면 됩니다.
  • 임계 영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능합니다.
    • 즉, 임계 영역은 한 번에 한 쓰레드만 사용이 가능합니다.
1. 메서드 전체를 임계 영역으로 지정합니다.
public synchronized void asyncSum() {
	  ...침범을 막아야하는 코드...
}

2.특정 영역을 임계 영역으로 지정합니다.
synchronized(해당 객체의 참조변수) {
		...침범을 막아야하는 코드...
}

 

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        synchronized (this) {
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }
}
  • 위 코드는 동기화가 실행되어 있어, 순서대로 사과를 먹지만
  • 만약 동기화를 안했다면 남은 사과의 수가 뒤죽박죽 출력될 뿐만 아니라 없는 사과를 먹는 경우도 발생한다 = 오류

 

wait(), notify()

📌침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait() 을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있습니다.

  • 그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 되고,
  • 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서,
  • 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 됩니다. 

wait()

  • 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다립니다.

notify()

  • 해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받습니다.
  • 말 그대로 '알리다' 라는 의미
public class Main {
    public static String[] itemList = {
            "MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
    };
    public static AppleStore appleStore = new AppleStore();
    public static final int MAX_ITEM = 5;

    public static void main(String[] args) {

        // 가게 점원
        Runnable StoreClerk = () -> {
                while (true) {
                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.restock(itemList[randomItem]);
                    try {
                        Thread.sleep(50);
                    } catch (InterruptedException ignored) {
                    }
                }
        };

        // 고객
        Runnable Customer = () -> {
                while (true) {
                    try {
                        Thread.sleep(77);
                    } catch (InterruptedException ignored) {
                    }

                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.sale(itemList[randomItem]);
                    System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
                }
        };


        new Thread(StoreClerk, "StoreClerk").start();
        new Thread(Customer, "Customer1").start();
        new Thread(Customer, "Customer2").start();

    }
}

class AppleStore {
    private List<String> inventory = new ArrayList<>();

    public void restock(String item) {
        synchronized (this) {
            while (inventory.size() >= Main.MAX_ITEM) {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                try {
                    wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
                    Thread.sleep(333);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 재입고
            inventory.add(item);
            notify(); // 재입고 되었음을 고객에게 알려주기
            System.out.println("Inventory 현황: " + inventory.toString());
        }
    }

    public synchronized void sale(String itemName) {
        while (inventory.size() == 0) {
            System.out.println(Thread.currentThread().getName() + " Waiting!");
            try {
                wait(); // 재고가 없기 때문에 고객 대기중
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        while (true) {
            // 고객이 주문한 제품이 있는지 확인
            for (int i = 0; i < inventory.size(); i++) {
                if (itemName.equals(inventory.get(i))) {
                    inventory.remove(itemName);
                    notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
                    return; // 메서드 종료
                }
            }

            // 고객이 찾는 제품이 없을 경우
            try {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                wait();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

 

Lock

 

📌synchronized 블럭으로 동기화하면 자동적으로 Lock이 걸리고 풀리지만, 같은 메서드 내에서만 Lock을 걸 수 있다는 제약이 있습니다. 이런 제약을 해결하기 위해 Lock 클래스를 사용합니다.

 

  • ReentrantLock
    • 재진입 가능한 Lock, 가장 일반적인 배타 Lock
    • 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계 영역으로 진입이 가능합니다.
public class MyClass {
    private Object lock1 = new Object();
    private Object lock2 = new Object();
    
    public void methodA() {
        synchronized (lock1) {
            methodB();
        }
    }
    
    public void methodB() {
        synchronized (lock2) {
            // do something
            methodA();
        }
    }
}
  • methodA는 lock1을 가지고, methodB는 lock2를 가집니다.
  • methodB에서 methodA를 호출하고 있으므로, methodB에서 lock2를 가진 상태에서 methodA를 호출하면 lock1을 가지려고 할 것입니다.
  • 그러나 이때, methodA에서 이미 lock1을 가지고 있으므로 lock2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있습니다.
  • 하지만 ReentrantLock을 사용하면, 같은 스레드가 이미 락을 가지고 있더라도 락을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않습니다.
  • 즉, ReentrantLock을 사용하면 코드의 유연성을 높일 수 있습니다.
  • ReentrantReadWriteLock
    • 읽기를 위한 Lock과 쓰기를 위한 Lock을 따로 제공합니다.
    • 읽기에는 공유적이고, 쓰기에는 베타적인 Lock입니다.
    • 읽기 Lock이 걸려있으면 다른 쓰레드들도 읽기 Lock을 중복으로 걸고 읽기를 수행할 수 있습니다. (read-only)
    • 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 허용되지 않습니다. (데이터 변경 방지)
  • StampedLock
    • ReentrantReadWriteLock에 낙관적인 Lock의 기능을 추가했습니다.
      • 낙관적인 Lock : 데이터를 변경하기 전에 락을 걸지 않는 것을 말합니다. 낙관적인 락은 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용합니다.
      • 낙관적인 락을 사용하면 읽기와 쓰기 작업 모두가 빠르게 처리됩니다. 쓰기 작업이 발생했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행합니다. 이러한 방식으로 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 락을 사용하여 더 빠른 처리가 가능합니다.
    • 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능합니다.
    • 무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 겁니다.

 

Condition

📌 wait()와 notify()는 몇 가지 문제점을 가지고 있습니다. 그 중 하나가 **"대기 중인 쓰레드를 구분할 수 없다"**는 점입니다. 이를 해결한 것이 Condition 인터페이스입니다.

더보기

[추가 설명]

wait()과 notify()는 객체에 대한 모니터링 락(lock)을 이용하여 스레드를 대기시키고 깨웁니다. 그러나 wait()과 notify()는 waiting pool 내에 대기 중인 스레드를 구분하지 못하므로, 특정 조건을 만족하는 스레드만 깨우기가 어렵습니다.

이러한 문제를 해결하기 위해 JDK 5에서는 java.util.concurrent.locks 패키지에서 Condition 인터페이스를 제공합니다. Condition은 waiting pool 내의 스레드를 분리하여 특정 조건이 만족될 때만 깨우도록 할 수 있으며, ReentrantLock 클래스와 함께 사용됩니다. 따라서 Condition을 사용하면 wait()과 notify()의 문제점을 보완할 수 있습니다.

  • 좀 더 자세히 설명드리겠습니다.wait()와 notify()는 동기화된 객체의 모니터에서 사용되며, 주로 다음과 같은 동작을 합니다:
    • wait(): 쓰레드를 대기 상태로 전환하고, 다른 쓰레드가 notify()나 notifyAll()을 호출할 때까지 대기합니다.
    • notify(): 대기 중인 쓰레드 중 하나를 깨워서 실행하도록 합니다.
    • notifyAll(): 대기 중인 모든 쓰레드를 깨웁니다.
    하지만 이러한 방식에는 대기 중인 쓰레드를 구분할 수 없다는 문제가 있습니다. 예를 들어, 여러 쓰레드가 wait() 상태에 있을 때, notify()가 호출되면, 무작위로 하나의 쓰레드만 깨우게 되어 그 중에 원하는 쓰레드가 아닌 다른 쓰레드가 깨어날 수 있습니다.

2. Condition 인터페이스

  • **Condition**은 **Lock**과 함께 사용되며, Object 클래스의 메서드인 wait(), notify(), notifyAll() 대신에 사용할 수 있습니다.

3. Condition의 장점

  • 다양한 조건을 처리할 수 있다: 여러 조건을 갖는 대기 상태를 만들고, 조건에 맞는 쓰레드만 깨울 수 있습니다.
  • 대기 중인 쓰레드를 구분할 수 있다: Condition을 사용하면 notify()와 notifyAll() 대신에 조건을 명시적으로 확인하고, 원하는 쓰레드만 깨울 수 있습니다.

4. Condition 사용 예시

  • Condition을 사용하여 wait()와 notify()의 문제를 해결하는 방법은 다음과 같습니다:
  • Condition은 java.util.concurrent.locks 패키지에 있는 고급 동기화 기능입니다. Condition을 사용하면 wait()와 notify()에서 발생하는 문제를 해결할 수 있습니다. 특히, 대기 중인 쓰레드를 구분하고 특정 조건을 만족하는 쓰레드만 깨어나게 할 수 있는 기능을 제공합니다.
  • 이런 상황은 특정 조건에 맞는 쓰레드만 깨어나야 하는 경우에 문제가 됩니다.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionExample {
    private static int count = 0;
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        // 쓰레드 1
        Thread thread1 = new Thread(() -> {
            lock.lock();
            try {
                while (count <= 0) {
                    condition.await();  // count가 0보다 클 때까지 대기
                }
                System.out.println("Thread 1: count is " + count);
                count--;
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        // 쓰레드 2
        Thread thread2 = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Thread 2: incrementing count");
                count++;
                condition.signal();  // 대기 중인 쓰레드를 하나 깨운다.
            } finally {
                lock.unlock();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }
}

 

5. Condition 메서드

Condition은 다음과 같은 주요 메서드를 제공합니다:

  • await(): 쓰레드를 대기 상태로 전환합니다. wait()와 유사하지만 Condition에서는 대기 상태에 진입하기 전에 명시적으로 Lock을 획득해야 합니다.
  • signal(): 대기 중인 쓰레드 중 하나를 깨웁니다. notify()와 유사합니다.
  • signalAll(): 대기 중인 모든 쓰레드를 깨웁니다. notifyAll()과 유사합니다.

6. 결론

  • wait()와 notify()는 쓰레드 간 통신에 유용하지만, 대기 중인 쓰레드를 구분할 수 없다는 한계가 있습니다.
  • 이를 해결한 것이 **Condition**입니다. Condition을 사용하면 대기 중인 쓰레드를 구분하고 조건에 맞는 쓰레드만 깨어나게 할 수 있습니다.
  • **Condition**은 Lock 객체와 함께 사용되며, 복잡한 동기화 문제를 해결하는 데 강력한 도구가 됩니다.

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] NULL  (0) 2024.11.13
[JAVA] Generic  (3) 2024.11.12
[JAVA] 오류 및 예외에 대한 이해  (1) 2024.11.12
[JAVA] 인터페이스  (2) 2024.11.12

Wrapper 객체

📌 Java에서 기본 데이터 타입(primitive type)을 객체로 다룰 수 있도록 해주는 클래스들을 말합니다. 기본 데이터 타입은 객체가 아니기 때문에 객체로 다루어야 하는 경우에 Wrapper 클래스를 사용합니다.

 

  • 박싱(기본 데이터를 Wrapper 클래스로 변경하는 행위)해서 객체화된 원시 값들은 이제 클래스처럼, 구현되어 있는 메소드들을 자유롭게 이용이 가능하고, 객체만 할 수 있는 것들을 할 수 있게 됩니다.
  • generic을 알기 위해서는 Wrapper 객체를 이해하는 것이 좋습니다.

좌 : 기본 데이터 타입 / 우 : 객체 데이터 타입

  • 좀 더 전문적인 설명으로, 기본 타입은 '값'의 의미로만 사용되지만 객체로서의 기능이 필요한 경우 추상화를 통해 객체의 속성을 부여한다. 즉, 기본 값을 객체화 해서 "감싼다"라는 의미로 이러한 객체를 Wrapper Class라고 한다.
  • 추가로 반대로 객체를 값으로 되돌릴 경우 언박싱 이라고 한다.

Integer num = new Integer(17);  // Boxing
int n = num.intValue(); // UnBoxing

Character ch = 'X'; // AutoBoxing
char c = ch; // AutoUnBoxing

 

 

 

Generic

📌 Java에서 타입을 매개변수[메서드나 함수에 전달되는 값 또는 정보 ]로 사용할 수 있게 해주는 기능입니다. 즉, 클래스를 만들 때, 어떤 타입을 사용할지 미리 정의하지 않고 나중에 사용할 때 구체적인 타입을 지정할 수 있게 하는 방법입니다.

= 타입을 먼저 정하지 않고 나중에 정할 수 있게 해준다. 코드 재사용성, 안정성 보장에 큰 역활을 한다.

 

public class Generic {
    public String plusReturnFunction(int a, int b) { ... }

}

//=


public class Generic {
    public Object plusReturnFunction(Object a,Object b) { ... }
}

 

  • 데이터를 객체로서 만들어 사용하는 것은 메소드 사용이나 다른 기능적인 면에서 유용하지만
  • 기본적으로 타입 안정성이 침해받는다(int는 정수형 이라는 규정을 깨고 애매한 형태의 타입을 사용하니..)
  • 추상화로 인해 타입의 명확성이 떨어진 만큼 연산자의 사용도 당연히 불가능해진다.
 

타입 변수

제네릭에서 사용되는 변수로, 타입 매개변수라고도 불립니다. 타입 변수는 클래스, 메서드, 또는 인터페이스에서 사용될 타입을 일반화하여 정의한 변수입니다. 즉, 구체적인 타입을 지정하지 않고, 유연하게 다양한 타입을 처리할 수 있도록 하는 역할을 합니다.

 

  • 아래의 T를 말합니다.
// 1.
public class Generic<T> {
		// 2.
    private T t;
    // 3.
    public T get() {
        return this.t;
    }

    public void set(T t) {
        this.t = t;
    }

    public static void main(String[] args) {
				// 4.
        Generic<String> stringGeneric = new Generic<>();
				// 5.
        stringGeneric.set("Hello World");
				
        String tValueTurnOutWithString = stringGeneric.get();

        System.out.println(tValueTurnOutWithString);
    }
}
더보기
  1. 제네릭은 클래스 또는 메서드에 사용할 수 있습니다. 클래스 이름 뒤에 <> 문법 안에 들어가야 할 타입 변수를 지정합니다.
  2. 🤔 타입 변수의 이름을 T로 사용하는 이유는 일종의 컨벤션이기 때문입니다. 당연히 컨벤션이기 때문에, 여러분이 원하시는 어떠한 변수를 넣어도 문제가 없습니다. 다만 당연히 컨벤션이기 때문에, 굳이 다른 이유가 없다면 T를 사용하는 게 협업에 유리하겠죠? 이와 함께 자주 사용되는 변수명으로는 T,U,V, E 등이 있습니다.
  3. 선언 해둔 타입 변수는 해당 클래스 내에서 특정한 타입이 들어갈 자리에 대신 들어갈 수 있습니다. 2번에서는 private 프로퍼티인 t의 타입이 들어가야 할 자리에 들어갔네요
  4. 메서드의 리턴 타입에 들어가는 것 역시 마찬가지입니다.
  5. 여기부터는 제네릭을 통해 구현한 클래스를 사용하는 부분입니다, 클래스에 선언했기 때문에 인스턴스를 만들기 위해서 타입 변수에 들어갈 실제 변수의 값을 넣어줘야 합니다. 여기서는 String이네요
  6. 아까 타입 변수로 대체해뒀던 곳에 String이 들어가 있기 때문에, 이와 같이 사용할 수 있습니다.

 

 

 

Generic 용어

< DevAndy 님의 Live Study 14주차 발췌>

 

Box<T> 제네릭 클래스, 'T의 Box' 또는 'T Box'라고 읽는다.
T 타입 변수 또는 타입 매개변수. (T는 타입 문자)
Box 원시 타입(raw type)

 

https://velog.io/@dev-mage/hello-java-world-generics-type-parameter

 

제네릭 타입 호출 타입 변수에 타입을 지정하는 것을 말한다.
매개변수화된 타입 지정된 타입(String 같은 것들)
  • 컴파일 전까지는 Menu<Coffee>는 여전히 제네릭 타입으로 기능하고, 존재하지만
  • 컴파일 이후 '원시 타입'인 Menu로 바뀐다. 
  • 즉, 제네릭 타입은 제거된다.

 

Generic 다형성

제네릭 클래스의 객체를 생성할 때 참조 변수에 지정한 제네릭 타입생성자에 지정한 제네릭 타입은 일치해야 한다는 점에서 제네릭 타입은 상속 관계를 고려하지 않습니다. 하지만 타입 매개변수를 사용하는 제네릭 클래스에서 상속 관계에 있는 자식 객체를 저장할 수 있는 방법도 있습니다. 다만, 저장할 때꺼낼 때 주의해야 합니다.

1. 제네릭 클래스의 객체 생성 시 제네릭 타입 일치

제네릭 클래스는 타입 안전성을 제공하기 위해 참조 변수생성자에서 지정한 타입이 일치해야 합니다. 예를 들어:

// 클래스 상속 구조
public class Menu { ... }
public class CoffeeMenu extends Menu { ... }

// 제네릭 객체 생성 시
ArrayList<Menu> menus1 = new ArrayList<Menu>();  // 올바름
ArrayList<Menu> menus2 = new ArrayList<CoffeeMenu>();  // 에러: 타입 불일치

여기서 ArrayList<Menu>와 ArrayList<CoffeeMenu>는 타입이 불일치합니다. **ArrayList<Menu>**는 Menu 타입의 객체만 저장할 수 있으며, **ArrayList<CoffeeMenu>**는 CoffeeMenu 객체만 저장할 수 있습니다. 다형성을 적용하려면 제네릭 타입이 일치해야 하므로, menus2는 ArrayList<Menu>로 선언해야 합니다.

2. 제네릭 클래스에서 다형성 사용

제네릭 클래스에서 타입 매개변수상속 관계를 반영하지 않지만, 타입이 일치하는 경우에는 다형성을 적용할 수 있습니다. List<Menu>로 선언하고, CoffeeMenu 객체를 추가할 수 있습니다. 이는 제네릭 타입이 아닌 클래스 타입 간 다형성을 적용한 예입니다.

List<Menu> menus = new ArrayList<Menu>();  // List<Menu>로 선언
menus.add(new CoffeeMenu());  // OK: CoffeeMenu는 Menu의 자식 클래스

3. 형변환 필요

하지만 제네릭 타입의 타입 안전성을 유지하기 위해, 저장된 객체를 꺼낼 때에는 형변환이 필요합니다. ArrayList<Menu>에 저장된 객체를 CoffeeMenu 타입으로 꺼낼 때는 형변환을 해줘야 합니다.

List<Menu> menus = new ArrayList<Menu>();
menus.add(new CoffeeMenu());  // OK: CoffeeMenu는 Menu의 자식 클래스

// 꺼낼 때 형변환이 필요
CoffeeMenu coffeeMenu = (CoffeeMenu) menus.get(0);  // 형변환 필요

형변환을 통해 Menu 객체를 CoffeeMenu 객체로 변환할 수 있지만, 런타임 오류가 발생할 수 있습니다. 만약 menus에 CoffeeMenu 객체가 아닌 다른 Menu 객체가 저장되어 있으면 ClassCastException이 발생합니다.

 

제네릭의 제한

1. 객체의 static 멤버에 사용할 수 없습니다.

static T get() { ... } // 에러

static void set(T t) { ... } // 에러
  • 타입 변수는 인스턴스 변수로 간주되고, 모든 객체에 동일하게 동작해야 하는 static 필드 특성상 사용할 수 없습니다

2. 제네릭 배열을 생성할 수 없습니다.

 

제네릭의 문법

 

1. 다수의 타입 변수를 사용할 수 있습니다.

public class Generic<T, U, E> {
    public E multiTypeMethod(T t, U u) { ... }
}


Generic<Long, Integer, String> instance = new Generic();
instance.multiTypeMethod(longVal, intVal);

 

2. 다형성 즉 상속과 타입의 관계는 그대로 적용됩니다.

  • 대표적으로 부모 클래스로 제네릭 타입 변수를 지정하고, 그 안에 자식 클래스를 넘기는 것은 잘 동작합니다.

 

3. 와일드카드를 통해 제네릭의 제한을 구체적으로 정할 수 있습니다.

public class ParkingLot<T extends Car> { ... }

ParkingLot<BMW> bmwParkingLot = new ParkingLot();
ParkingLot<Iphone> iphoneParkingLot = new ParkingLog(); // error!
  1. <? extends T> : T와 그 자손들만 사용 가능
  2. <? super T> : T와 그 조상들만 가능
  3. <?> : 제한 없음
더보기

이렇게 제한을 하는 이유는 다형성 때문입니다. 위의 코드에서, T는 Car의 자손 클래스들이라고 정의했기 때문에, 해당 클래스 내부에서 최소 Car 객체에 멤버를 접근하는 코드를 적을 수 있습니다. 반대로 그러한 코드들이 있을 여지가 있기 때문에, Car 객체의 자손이 아닌 클래스는 제한하는 것이죠

 

**와일드카드(Wildcard)**는 제네릭에서 사용되는 특별한 기호(?)로, 타입을 불특정으로 지정할 때 사용됩니다. 와일드카드는 타입을 유연하게 다룰 수 있도록 도와주며, extends나 super와 함께 사용하여 타입을 제한할 수 있습니다.

 

 

4. 메서드를 스코프로 제네릭을 별도로 선언할 수 있습니다.

출처 : Head First Java

// 또는 ..
static <T> void sort(List<T> list, Comparator<? super T> c) { ... }
  1. 이렇게 반환 타입(T) 앞에 <> 제네릭을 사용한 경우, 해당 메서드에만 적용되는 제네릭 타입 변수를 선언할 수 있습니다.
  2. 타입 변수를 클래스 내부의 인스턴스 변수 취급하기 때문에 제네릭 클래스의 타입 변수를 static 메서드에는 사용할 수 없었지만, 제네릭 메소드의 제네릭 타입 변수는 해당 메소드에만 적용되기 때문에 메소드 하나를 기준으로 선언하고 사용할 수 있습니다.
  3. 같은 이름의 변수를 사용했다고 해도 제네릭 메소드의 타입 변수는 제네릭 클래스의 타입 변수와 다릅니다.
public class Generic<T, U, E> {
		// Generic<T,U,E> 의 T와 아래의 T는 이름만 같을뿐 다른 변수
    static <T> void sort(List<T> list, Comparator<? super T> c) { ... }
}

 

 

스코프

📌 프로그래밍에서 변수나 함수, 클래스 등의 유효 범위를 의미합니다. 즉, 코드 내에서 어떤 변수나 함수가 접근 가능한 영역을 말합니다.

스코프의 종류

  1. 전역 스코프(Global Scope): 프로그램 전체에서 접근할 수 있는 범위입니다. 전역 변수나 전역 함수가 여기에 해당합니다. 예를 들어, 클래스의 필드나 정적 변수들이 전역 스코프에 속합니다.
  2. 지역 스코프(Local Scope): 특정 함수나 메서드, 블록 내에서만 유효한 범위입니다. 함수 안에 선언된 변수나 매개변수는 그 함수 내부에서만 접근할 수 있습니다.
  3. 블록 스코프(Block Scope): 특정 코드 블록(if, for, while 등의 제어문) 내에서만 유효한 범위입니다. let이나 const로 선언된 변수는 이 블록 내에서만 접근 가능합니다.
  4. 클래스 스코프(Class Scope): 클래스 내에서 정의된 필드나 메서드는 그 클래스 내부에서만 접근할 수 있습니다.
public class ScopeExample {

    // 전역 변수
    static int globalVar = 10;

    public static void main(String[] args) {
        // main 메서드 내에서만 유효한 지역 변수
        int localVar = 5;

        // if문 안에서만 유효한 블록 변수
        if (true) {
            int blockVar = 20;
            System.out.println(blockVar); // 출력 가능
        }

        System.out.println(globalVar); // 출력 가능
        System.out.println(localVar);  // 출력 가능

        // System.out.println(blockVar); // 오류! blockVar는 if문 블록 밖에서는 접근 불가
    }
}
더보기

코드 설명

  1. 전역 변수(globalVar): globalVar는 클래스 어디서든지 접근할 수 있는 변수입니다. 여기서는 main 메서드 내에서 사용됩니다.
  2. 지역 변수(localVar): localVar는 main 메서드 내에서만 사용 가능하고, 다른 메서드나 클래스 바깥에서는 접근할 수 없습니다.
  3. 블록 변수(blockVar): blockVar는 if문 블록 내에서만 유효한 변수로, 블록 외부에서는 접근할 수 없습니다.

 

제네릭에서의 스코프

제네릭에서 스코프는 제네릭 타입 매개변수가 유효한 범위를 나타냅니다. 예를 들어, 클래스 레벨에서 제네릭을 선언하면, 그 클래스 내에서 제네릭 타입을 사용할 수 있고, 메서드 레벨에서 제네릭을 선언하면, 그 메서드 내에서만 제네릭 타입을 사용할 수 있습니다.

public class MyClass<T> {
    T obj; // 클래스 스코프

    public <E> void print(E element) {  // 메서드 스코프
        System.out.println(element);
    }
}

 

  • 클래스 스코프: 클래스 자체에 제네릭 타입 T를 선언하면, 클래스 내부의 모든 메서드와 필드에서 T를 사용할 수 있습니다.
  • 메서드 스코프: 메서드 내에서 제네릭 타입 E를 선언하면, 그 메서드 내에서만 E를 사용할 수 있습니다.

 

 

자료구조

 

1. 자료구조와 인터페이스

  • 자바의 **컬렉션(Collection)**은 컴퓨터공학의 자료구조 개념을 추상화하여 구현한 것입니다.
  • 자바의 인터페이스는 실제 구현이 없고, 추상적인 명세(규격)만을 정의합니다. 즉, 인터페이스는 "어떤 행동을 해야 한다"라는 규칙만 제시하고, 실제 동작은 이를 구현한 클래스에서 정의됩니다.
  • 예를 들어, List 인터페이스는 순서를 가지며 중복을 허용하는 자료구조의 규칙을 정의하고, 이를 구현하는 클래스(예: ArrayList, LinkedList)는 이 규칙을 따릅니다.

2. 배열 vs. 리스트

  • 배열은 메모리에서 연속적인 공간을 차지하고, 인덱스를 사용해 빠르게 접근할 수 있지만, 중간에 데이터를 추가하거나 삭제할 때 비용이 큽니다.
  • 리스트는 배열처럼 순서를 가지고 있지만, 배열과 달리 동적 크기를 가지며, 특정 연산에 따라 구현체가 달라질 수 있습니다.

3. 제네릭과 자료구조

  • 제네릭은 자료구조에 저장되는 데이터 타입을 유연하게 정의할 수 있도록 합니다. 예를 들어, List<Integer>는 Integer 타입만 저장할 수 있도록 제한하고, List<String>은 String 타입만 저장할 수 있습니다.
  • List<E>와 같은 제네릭 인터페이스는 저장되는 데이터 타입을 추상화하여, 다양한 타입에 대해 동일한 동작을 적용할 수 있도록 해줍니다.

4. List 인터페이스 예시

  • 자바에서 List<E> 인터페이스는 데이터를 저장하고 관리하는 다양한 메서드(add(), size(), isEmpty() 등)를 정의하고 있습니다.
  • addAll() 메서드와 같이 와일드카드(? extends E)를 사용하면 다른 타입의 컬렉션을 받아 처리할 수 있습니다.

5. Collection 구조

  • 자바의 컬렉션은 Iterable을 상속하고, Collection 인터페이스를 기반으로 하여 List, Queue, Set 등이 구현됩니다.
  • 자료구조를 사용할 때는 인터페이스에서 제공하는 기능을 확인하고, 실제 구현체에서 어떻게 동작하는지 살펴보는 것이 중요합니다.

 

자바 컬렉션

결론

  • 자바의 컬렉션은 자료구조의 개념을 추상화하고, 이를 인터페이스와 제네릭을 통해 구현한 것
  • 자료구조는 추상적인 타입으로 정의되고, 실제 동작은 구현체에서 정의됩니다. 제네릭을 사용하면 다양한 데이터 타입을 처리할 수 있는 유연성을 제공한다. 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] NULL  (0) 2024.11.13
[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13
[JAVA] 오류 및 예외에 대한 이해  (1) 2024.11.12
[JAVA] 인터페이스  (2) 2024.11.12
[JAVA] 클래스 간의 관계와 상속  (0) 2024.11.12

오류 및 예외에 대한 이해

 

오류(Error)🔥 예외(Exception)🚨
  • 오류(Error)는 일반적으로 회복이 불가능한 문제입니다.
    • 이는 시스템 레벨에서, 또는 주로 환경적인 이유로 발생합니다.
    • 코드의 문제로 발생하는 경우도 있지만, 일단 발생하는 경우 일반적으로 회복이 불가능합니다.
    • 에러가 발생한 경우 우리는 어떠한 에러로 프로그램이 종료되었는지를 확인하고 대응합니다.
  • 예외(Exception)는 일반적으로 회복이 가능한 문제입니다.
    • 회복이 가능하다는 전제는 우리가 “그 예외가 발생할 수 있다는 것을 인지하고, 대응했을 것입니다”.
    • 현실적으로 코드 레벨에서 할 수 있는 문제 상황에 대한 대응은 “예외 처리”에 속합니다.
  • 보통 만나게 되는 문제는 예외이고 이는 "예외처리"로 예방이 가능하다.
  • 보통 예외는 코드의 실행, 예외 처리 관점 2가지의 종류로 나뉘는데
코드 실행 관점 예외처리 관점
  • 컴파일 에러(예외) 📂
    • .java 파일을 .class 파일로 컴파일할 때 발생하는 에러
    • 대부분 여러분이 자바 프로그래밍 언어의 규칙을 지키지 않았기 때문에 발생합니다.
    • 예를 들어 있지 않은 클래스를 호출한다거나, 접근이 불가능한 프로퍼티나 메소드에 접근한다거나 하는 경우에 발생합니다.
    • 컴파일 에러가 발생하는 경우 해결 방법은 문법에 맞게 다시 작성하는 것입니다.
  • 런타임 에러(예외) ❤️‍🔥
    • 우리가 주로 다루게 될 에러(예외)입니다.
    • 문법적인 오류는 아니라서, 컴파일은 잘 되었지만 “프로그램”이 실행 도중 맞닥뜨리게 되는 예외입니다.
확인된 예외✅ (Checked Exception)
  • 컴파일 시점에 확인하는 예외입니다.
  • 반드시 예외 처리를 해줘야 하는 예외입니다.

    주로 외부 자원(파일, 네트워크, 데이터베이스 등)과의 작업에서 발생할 수 있는 예외입니다.



미확인된 예외🚫 (Unchecked Exception)
  • 런타임 시점에 확인되는 예외입니다.
  • 예외 처리가 반드시 필요하지 않은 예외입니다.

    프로그래머의 실수로 발생하는 예외(정수를 0으로 나눈다던가...)

 

+ 컴파일 에러는 예외 처리를 하지 않아서 발생하는 오류()를 말한다. 만약 예외 처리를 생각도 못했는데 컴파일 중 에러가 나왔다면 이건 그냥 에러다.

 


1. Checked Exception (체크드 예외)

컴파일 타임에 반드시 예외 처리를 요구하는 예외입니다.
컴파일러가 예외 처리(try-catch 또는 throws 선언)가 되어 있는지 확인하며, 처리하지 않으면 컴파일 에러가 발생합니다.

특징

  • 발생 가능성이 높은 예외로 간주되며, 반드시 처리해야 합니다.
  • 주로 외부 환경과 상호작용하는 코드에서 발생합니다.
    예: 파일 입출력, 네트워크 연결, 데이터베이스 접근 등.
  • Exception 클래스에서 파생되지만, RuntimeException을 제외한 모든 예외가 해당됩니다.

대표적인 Checked Exception

  • IOException (파일 입출력 오류)
  • SQLException (SQL 관련 오류)
  • ClassNotFoundException (클래스를 찾을 수 없음)
import java.io.*;

public class CheckedExample {
    public static void main(String[] args) {
        try {
            FileReader reader = new FileReader("nonexistent.txt");  // 파일 읽기 시도
        } catch (FileNotFoundException e) {
            System.out.println("파일을 찾을 수 없습니다: " + e.getMessage());
        }
    }
}

 

  • 여기서 FileReader는 파일이 존재하지 않을 경우 FileNotFoundException을 발생시킵니다.
  • 이를 try-catch 블록으로 처리하지 않으면 컴파일 에러가 발생합니다.

 

 

 

보통 대부분의 예외는 아래의 처리 방식을 기준으로 한다

1 예외를 인지, 정의 = 예외 클래스 생성
2 예외 발생시 알림 = throw( 예약어 )
3 사용자가 예외 핸들링(처리)

 

2. Unchecked Exception (언체크드 예외)

컴파일 타임에 예외 처리를 강제하지 않는 예외입니다.
예외 처리를 하지 않아도 프로그램은 정상적으로 컴파일됩니다. 다만, 런타임 시 발생하면 프로그램이 중단될 수 있습니다.

특징

  • 주로 프로그래머의 실수로 인해 발생합니다.
  • 개발자가 예외 처리를 강제받지 않아도 되지만, 적절히 처리해야 프로그램이 중단되지 않습니다.
  • RuntimeException 클래스와 그 하위 클래스들이 포함됩니다.

대표적인 Unchecked Exception

  • NullPointerException (널 참조 접근)
  • ArrayIndexOutOfBoundsException (배열 인덱스 초과)
  • ArithmeticException (0으로 나누기)
  • ClassCastException (잘못된 형변환)

예제

public class UncheckedExample {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3};

        // 예외 처리 없이 실행
        System.out.println(numbers[5]);  // ArrayIndexOutOfBoundsException 발생
    }
}
  • 이 코드는 예외 처리를 하지 않아도 컴파일은 되지만, 실행 시 ArrayIndexOutOfBoundsException이 발생하며 프로그램이 중단됩니다.

 

3. Checked vs Unchecked 정리

구분 Checked Exception Unchecked Exception
검사 시점 컴파일 타임 런타임
예외 처리 강제 여부 예, 처리하지 않으면 컴파일 에러 발생 아니요, 처리하지 않아도 컴파일 가능
주요 원인 외부 환경(입출력, 네트워크 등) 프로그래머 실수 또는 논리 오류
상위 클래스 Exception (단, RuntimeException 제외) RuntimeException
대표 예 IOException, SQLException NullPointerException, ArithmeticException

 

4. 실전에서의 사용

  • Checked Exception: 외부 환경과 상호작용하는 코드에서 주로 발생하므로 적절히 예외 처리를 반드시 해야 합니다.
    • 예: 파일을 읽거나 쓰는 작업에서 파일이 없을 가능성을 항상 고려.
  • Unchecked Exception: 런타임 중 예상치 못한 오류를 처리하려면 예외 처리를 추가로 넣을 수 있지만, 기본적으로 개발자가 코드를 주의 깊게 작성하는 것이 중요합니다.
    • 예: 배열 인덱스를 안전하게 접근하도록 범위를 체크.

 

5. 코드 설계 관점

  • Checked Exception은 호출자에게 예외를 알리는 역할을 하므로 예측 가능한 문제를 나타낼 때 사용.
  • Unchecked Exception코드의 버그나 프로그래머 실수를 나타내며, 예외가 발생했을 때 빠르게 수정을 유도합니다.

 

예외 발생과 try-catch, finally 문

 

자바에서 예외는 크게 **Checked Exception**과 Unchecked Exception 두 가지로 구분됩니다. 두 예외의 차이는 컴파일러가 예외 처리 여부를 검사하는지 여부에 따라 나뉩니다.

throw

📌 프로그래머가 의도적으로 예외를 발생시킬 때 사용하는 키워드입니다. 주로 특정 조건에서 문제가 발생할 가능성이 있다고 판단될 때 직접 예외를 던져서(발생시켜서) 경고하는 용도로 사용됩니다.

public class Main {
    public static void main(String[] args) {
        try {
            checkEligibility(15);  // 18세 미만이므로 예외 발생
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

    public static void checkEligibility(int age) {
        if (age < 18) {
            throw new IllegalArgumentException("나이는 18세 이상이어야 합니다.");  // 예외 발생
        }
        System.out.println("자격이 있습니다.");
    }
}

 

  • 실제로 예외가 발생한 후 실행되는 함수이기에, 이 코드가 실행되면 throw 문과 함께 메서드가 종료된다.
  • 이게 있는 이유는 아래 설명할 예외 던지기 때문에 필요하다.
  • 일반적인 상황에서 프로그래머가 수정을 위해서라면, 컴파일 에러면 충분하다.

예외 던지기

  • 메서드에서 발생한 예외를 처리한 후 다시 호출한 쪽으로 전달하거나, 처리하지 않고 바로 호출한 쪽으로 넘기는 경우에 사용됩니다.

 

1. 예외 되던지기를 사용하는 이유

예외를 처리하지 않고 상위 메서드로 넘기는 이유는:

  1. 책임 분리
    • 모든 메서드가 자신의 역할에만 집중해야 하기 때문입니다.
    • 예외를 완전히 처리하기 위한 **문맥(Context)**은 상위 메서드에서 더 많이 알고 있을 가능성이 큽니다.
    • 예를 들어, readFile 메서드가 파일을 읽는 동안 오류가 발생했을 때, 이 메서드가 문제를 해결할 수는 없고, 상위 메서드에서 다른 파일을 선택하거나 사용자에게 알리는 등 더 적절한 작업을 할 수 있습니다.
  2. 공통 처리 로직 적용
    • 상위 계층에서 예외를 한꺼번에 처리하면, 중복 코드가 줄어듭니다.
    • 예를 들어, 여러 메서드에서 파일을 다룬다면, 파일이 없을 경우 기본적으로 보여줄 에러 메시지는 상위 계층에서 일괄 처리할 수 있습니다.
  3. 다양한 예외의 관리
    • 하위 메서드에서 발생하는 여러 유형의 예외를 상위 계층에서 분류하고 관리할 수 있습니다.
    • 예: FileNotFoundException, SQLException 등 서로 다른 예외를 공통된 방식으로 처리하거나 변환.

 

2. 예제 코드: 두 번의 예외 처리

(1) 예외를 처리한 후 다시 던지는 경우

public class ExceptionRethrowingExample {
    public static void main(String[] args) {
        try {
            processFile();
        } catch (Exception e) {
            // 최상위 메서드(main)에서 최종적으로 예외 처리
            System.out.println("main에서 예외 처리: " + e.getMessage());
        }
    }

    static void processFile() throws Exception {
        try {
            readFile();
        } catch (Exception e) {
            // 하위 메서드에서 일부 로그를 남기고 다시 예외를 던짐
            System.out.println("processFile에서 예외 처리 중... 메시지: " + e.getMessage());
            throw e; // 예외를 상위 메서드로 넘김
        }
    }

    static void readFile() throws Exception {
        // 파일 읽기 중 예외 발생
        throw new Exception("파일 읽기 실패");
    }
}

processFile에서 예외 처리 중... 메시지: 파일 읽기 실패
main에서 예외 처리: 파일 읽기 실패
  • readFile()에서 예외 발생 → processFile()에서 로그 처리 후 예외 되던지기 → main()에서 최종 처리.
  • processFile()이 예외를 일부 처리하고 다시 던짐으로써 예외가 호출 계층을 따라 전달됩니다.

 

(2) 예외 정보를 추가하여 되던지기

public class ExceptionRethrowingWithInfo {
    public static void main(String[] args) {
        try {
            processFile();
        } catch (Exception e) {
            // 최종적으로 예외 처리
            e.printStackTrace();
        }
    }

    static void processFile() throws Exception {
        try {
            readFile();
        } catch (Exception e) {
            // 새로운 예외로 감싸서 추가 정보를 전달
            throw new Exception("processFile 중 문제 발생", e);
        }
    }

    static void readFile() throws Exception {
        // 원래 예외 발생
        throw new Exception("파일 읽기 실패");
    }
}


java.lang.Exception: processFile 중 문제 발생
	at ExceptionRethrowingWithInfo.processFile(ExceptionRethrowingWithInfo.java:10)
	at ExceptionRethrowingWithInfo.main(ExceptionRethrowingWithInfo.java:4)
Caused by: java.lang.Exception: 파일 읽기 실패
	at ExceptionRethrowingWithInfo.readFile(ExceptionRethrowingWithInfo.java:16)
	... 2 more
  • 예외를 감싸면서 추가 정보를 전달하기 때문에, 디버깅 시 문제의 흐름을 쉽게 파악할 수 있습니다.

 

3. 예외 되던지기의 사용 사례

대표적인 사용 상황

  1. 모듈 간 책임 분리
    • 예외 처리 책임을 호출한 메서드에 맡기고, 중간 메서드는 일부 로그나 정리 작업만 수행한 뒤 다시 던집니다.
  2. 일부 처리 후 상위 메서드로 전달
    • 예외가 발생했을 때, 중간 메서드에서 로그를 남기거나 상태를 정리한 후 상위 호출자에게 문제를 알립니다.
  3. 예외 타입 변환
    • 저수준 예외(IOException 등)를 고수준 예외(CustomException 등)로 변환해 호출 계층에 전달.

 

4. 정리

  • 예외 되던지기는 하위 메서드에서 처리하지 못하는 예외를 상위 메서드로 전달하는 중요한 기법입니다.
  • 필요한 경우:
    1. 하위 메서드에서 예외를 완전히 처리할 수 없을 때.
    2. 추가 정보를 포함하거나 새로운 예외로 변환할 때.
    3. 호출 계층에서 더 나은 처리를 기대할 때.

 

throws

📌 해당 메서드가 특정 예외를 던질 가능성이 있음을 알리는 역할을 합니다. 예외 처리 책임을 메서드 호출부로 넘길 때 주로 사용됩니다.

 

public class Main {
    public static void main(String[] args) {
        try {
            divide(10, 0);  // 0으로 나누기 시도, 예외 발생
        } catch (ArithmeticException e) {
            System.out.println("예외 발생: " + e.getMessage());
        }
    }

    // 예외가 발생할 가능성이 있으므로 throws로 명시
    public static int divide(int a, int b) throws ArithmeticException {
        if (b == 0) {
            throw new ArithmeticException("0으로 나눌 수 없습니다.");  // 예외 발생
        }
        return a / b;
    }
}

 

 

예외를 handling

📌 말 그대로 예외 처리를 해주면 된다.

1 위험 감지하기 ( throw )
2 위험 처리 ( try-catch(finally) )

 

  • 이건 팁인데, 타인의 코드를 사용할 경우 클래스, 메소드의 에러처리를 잘 해줘야한다. 추후 꼬이면 아주 고통스러운 시간을 보내게 된다...

 

public class StudyException {
    public static void main(String[] args) {
        OurClass ourClass = new OurClass();

        try {
            // 1. 위험한 메소드의 실행을 "시도" 해 봅니다.
            // "시도" 해보는 코드가 들어가는 블럭입니다.
            ourClass.thisMethodIsDangerous();
        } catch (OurBadException e) {
            // 2. 예외가 발생하면, "잡아서" handling 합니다.
            // 예외가 발생하는경우 "handling" 하는 코드가 들어가는 블럭입니다.
						// 즉 try 블럭 내의 구문을 실행하다가 예외가 발생하면
						// 예외가 발생한 줄에서 바로 코드 실행을 멈추고
						// 여기 있는 catch 블럭 내의 코드가 실행됩니다.
            System.out.println(e.getMessage());
        } finally {
            // 3. 예외의 발생 여부와 상관없이, 실행시켜야 하는 코드가 들어갑니다.
            // 무조건 실행되는 코드가 들어가는 블럭입니다.
            System.out.println("우리는 방금 예외를 handling 했습니다!");
        }

    }
}

 

  1. 위험을 감지했다면, try-catch(finally) 키워드 이용하기
    • **try** - **catch**는 각각 중괄호{}를 통해 실행할 코드들을 담습니다.
    • try 단어의 **“시도한다”**라는 뜻에 맞게 중괄호{} 안에는 예외가 발생할 수 있지만 실행을 시도할 코드를 담습니다.
    • catch 단어의 **“잡는다”**라는 의미에 맞게 중괄호{} 안에는 try 안에 있는 코드를 실행하다가 예외가 났을 때 실행할 코드를 담습니다.
      • catch 는 소괄호()를 통해 어떤 예외 클래스를 받아서 처리할지 정의해 주어야 합니다.
      • catch로 모든 예외를 다 받고 싶으면 Exception 을 넣어주면 됩니다.
      • catch로 일부 예외만 받아서 처리하고 싶으면 해당 예외 클래스명을 넣어주면 됩니다.
      • 1개의 try 문에 catch 문은 여러 개 사용할 수 있습니다. ex) 1try : 4catch
    • 기존 **try - catch**의 맨 마지막에 **finally**를 붙여서 마지막에 반드시 실행할 코드를 넣을 수 있습니다.

 

 

모든 클래스에 다 이렇게 넣는가?

  • 당연히 아니다... 핵심적인 로직이 있는 중요한 메서드에만 예외 처리를 추가하거나,
  • 여러 메서드에서 예외가 발생할 수 있는 경우, 상위 메서드(또는 main 메서드 등)에 try-catch 블록을 두어 한 번에 예외를 처리합니다.

 

 

예외 클래스 구조 이해하기

 

자바의 Throwable Class

    • 시작은 모든 객체의 원형인 Object 클래스에서 시작합니다.
    • 아까 정의한 “문제 상황”을 뜻하는 Throwable 클래스가 Object 클래스를 상속합니다.
    • Throwable 클래스의 자식으로 앞서배운 에러(Error)와 예외(Exception) 클래스가 있습니다.
    • 에러(Error) 클래스와 예외(Exception) 클래스는 각각 IOError 클래스, RuntimeException 클래스와 같이 구분하여 처리됩니다.

  • 참고로 그림의 RuntimeException을 상속한 예외들은 UncheckedException, 반대로 상속하지 않은 예외들은 CheckedException으로 구현되어 있습니다.
즉 NullPointException, ArrayIndexOutOfBoundsException 등의 예외 구현체들은 명시적인 에러 처리를 하지 않아도 컴파일 에러가 발생하지는 않겠죠? 또 Checked Exception에 속하는 에러 구현체들은 핸들링 하지 않으면 컴파일 에러가 발생하는 대신, 컴파일이 됐다면 100% 복구가 가능한 에러였다는 것 역시 알아두시면 좋을 것 같습니다.

 

  • 위의 설명대로 exception은 Object 클래스를 통해 많은 예외 처리를 상속 받은 상태이다. 하지만 결국 프로그램의 세계는 무궁무진 하기에 직접 에러를 정의 구현하는 경우도 있다.
class OurBadException extends Exception {
	public OurBadException() {
		super("위험한 행동을 하면 예외처리를 꼭 해야합니다!");
	}
}

 

 

Chained Exception

📌 원인 예외를 새로운 예외에 등록한 후 다시 새로운 예외를 발생시키는데, 이를 예외 연결이라고 합니다.

원인 예외 연결된 예외
b 라이브러리가 없음 함수가 실행 안됨
  • 이렇게 하는 이유는 추후 한번에 묶어서 다루기가 편하기 때문이다.

 

  • 원인 예외를 다루기 위한 메소드
    • initCause()
      • 지정한 예외를 원인 예외로 등록하는 메소드
    • getCause()
      • 원인 예외를 반환하는 메소드
// 연결된 예외 
public class main {

    public static void main(String[] args) {
        try {
            // 예외 생성
            NumberFormatException ex = new NumberFormatException("가짜 예외이유");

            // 원인 예외 설정(지정한 예외를 원인 예외로 등록)
            ex.initCause(new NullPointerException("진짜 예외이유"));

            // 예외를 직접 던집니다.
            throw ex;
        } catch (NumberFormatException ex) {
            // 예외 로그 출력
            ex.printStackTrace();
            // 예외 원인 조회 후 출력
            ex.getCause().printStackTrace();
        }

        // checked exception 을 감싸서 unchecked exception 안에 넣습니다.
        throw new RuntimeException(new Exception("이것이 진짜 예외 이유 입니다."));
    }
}

// 출력
Caused by: java.lang.NullPointerException: 진짜 예외이유

 

 

예외 처리

📌예외 처리 방식은 크게 3가지로 나뉜다

1 예외 복구하기
2 예외 처리 회피하기
3 예외 전환하기

 

예외 복구하기

public String getDataFromAnotherServer(String dataPath) {
		try {
				return anotherServerClient.getData(dataPath).toString();
		} catch (GetDataException e) {
				return defaultData;
		}
}
  • 실제로 try-catch로 예외를 처리하고 프로그램을 정상 상태로 복구하는 방법입니다.
  • 가장 기본적인 방식이지만, 현실적으로 복구가 가능한 상황이 아닌 경우가 많거나 최소한의 대응만 가능한 경우가 많기 때문에 자주 사용되지는 않습니다.

 

예외 처리 회피하기

public void someMethod() throws Exception { ... }

public void someIrresponsibleMethod() throws Exception {
		this.someMethod();
}
  • 이렇게 처리하면, someMethod()에서 발생한 에러가 someIrresponsibleMethod()의 throws를 통해서 그대로 다시 흘러나가게 되겠죠, 물론 같은 객체 내에서 이런 일은 하지는 않습니다, 예외 처리 회피를 보여드리기 위한 단순한 예시 코드입니다.
  • 관심사를 분리해서 한 레이어에서 처리하기 위해서 이렇게 에러를 회피해서 그대로 흘러 보내는 경우도 있습니다.

 

예외 전환하기

public void someMethod() throws IOException { ... }

public void someResponsibleMethod() throws MoreSpecificException {
		try {
			this.someMethod();
		} catch (IOException e) {
			throw new MoreSpecificException(e.getMessage());
		}
}
  • 예외 처리 회피하기의 방법과 비슷하지만, 조금 더 적절한 예외를 던져주는 경우입니다.
  • 보통은 예외 처리에 더 신경 쓰고 싶은 경우나, 오히려 RuntimeException처럼 일괄적으로 처리하기 편한 예외로 바꿔서 던지고 싶은 경우 사용합니다.

< 추가설명 >

 

  • 추상화된 예외로 변환: 구체적인 예외를 숨기고, 상위 계층에서는 필요하지 않은 세부 사항을 가리지 않도록 상위 계층에서 의미 있는 예외로 전환합니다. 예를 들어, 데이터베이스 접근 중 SQLException이 발생하면, 이 예외를 DataAccessException과 같은 의미 있는 커스텀 예외로 바꾸는 식입니다.
  • 런타임 예외로 일괄 처리: Checked Exception을 RuntimeException 계열로 변환하여 전체 코드에서 예외 처리를 일괄적으로 처리할 수 있게 합니다. 예외 처리를 모든 곳에서 강제하지 않아 코드가 더 간결해지죠.

 

 

< 런타임 예외 일괄 처리 부가 설명 >

1. Checked Exception의 특성

  • Checked Exception은 예외가 발생할 가능성이 있는 모든 곳에서 반드시 try-catch 구문으로 처리하거나 throws 키워드를 이용해 메서드 선언부에 던져야 합니다.
  • 이로 인해 예외가 발생할 가능성이 있는 메서드가 여러 군데 있다면, 그곳마다 예외 처리를 반복적으로 작성해야 해서 코드가 길어지고 복잡해질 수 있어요.

2. RuntimeException으로 전환 시 장점

  • RuntimeException 계열 예외는 일명 "unchecked" 예외라고도 불리며, 이 예외는 컴파일러가 예외 처리를 강제하지 않아요.
  • 그래서 try-catch로 처리하지 않아도 컴파일 오류가 발생하지 않습니다.
  • 즉, 반드시 처리하지 않아도 되는 예외로 바뀌므로 코드가 간결해집니다. 예외가 꼭 필요한 상위 레벨에서만 일괄 처리할 수 있어요.

 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13
[JAVA] Generic  (3) 2024.11.12
[JAVA] 인터페이스  (2) 2024.11.12
[JAVA] 클래스 간의 관계와 상속  (0) 2024.11.12
[JAVA] package와 import  (0) 2024.11.12

인터페이스

📌 Java에서 클래스가 구현해야 하는 메서드들의 목록을 정의하는 일종의 계약입니다.

 

인터페이스 선언

public interface 인터페이스명 { 

	public static final char A = 'A';
    static char B = 'B';
    final char C = 'C';
    char D = 'D';

    void turnOn(); // public abstract void turnOn();
}

 

  • 모든 멤버 변수는 public static final이어야 합니다.
    • 생략 가능합니다.
  • 모든 메서드는 public abstract이어야 합니다.
    • 생략 가능합니다. (static 메서드와 default 메서드 예외)
  • 생략되는 제어자는 컴파일러가 자동으로 추가해줍니다.

 

  • 단순하게 클래스는 설계도, 인터페이스는 장보기 리스트 라고 생각하면 편하다
  • 클래스는 내용과 기능에 대한 부분이 상세히 기록되지만, 인터페이스는 필요한 기능만 제시되어 있고 구현은 안되어 있다.

 

인터페이스 구현

📌 인터페이스는 추상 클래스와 마찬가지로 직접 인스턴스를 생성할 수 없기 때문에 클래스에 구현되어 생성됩니다.

  • implements 키워드를 사용하여 인터페이스를 구현할 수 있습니다. 
public class 클래스명 implements 인터페이스명 { 
			// 추상 메서드 오버라이딩
			@Override
	    public 리턴타입 메서드이름(매개변수, ...) {
			       // 실행문
	    }
}
  • 인터페이스의 추상 메서드는 구현될 때 반드시 오버라이딩 되어야 합니다.
  • 만약 인터페이스의 추상 메서드를 일부만 구현해야 한다면 해당 클래스를 추상 클래스로 변경해 주면 됩니다.
  • 인터페이스 선언 = 인터페이스(명세서) 제작, 인터페이스 구현 = 인터페이스를 구현한 클래스에서 기능 구현

 

인터페이스 상속

📌 인터페이스 간의 상속이 가능합니다.

  • 인터페이스 간의 상속은 implements 가 아니라 extends 키워드를 사용합니다.
  • 인터페이스는 클래스와는 다르게 다중 상속이 가능합니다.
public class Main extends D implements C {

    @Override
    public void a() {
        System.out.println("A");
    }

    @Override
    public void b() {
        System.out.println("B");
    }

    @Override
    void d() {
        super.d();
    }

    public static void main(String[] args) {
        Main main = new Main();
        main.a();
        main.b();
        main.d();
    }
}

interface A {
    void a();
}

interface B {
    void b();
}

interface C extends A, B {
}

class D {
    void d() {
        System.out.println("D");
    }
}
  • 인터페이스 C는 아무것도 선언되어 있지 않지만 인터페이스 A, B를 다중 상속받았기 때문에 추상 메서드 a, b를 갖고 있는 상태입니다.
  • 따라서 Main 클래스에서 인터페이스 C가 구현될 때 a, b 추상 메서드가 오버라이딩됩니다.
  • 또한 인터페이스의 구현은 상속과 함께 사용될 수 있습니다.

 

  • 뭔가 이쯤 오면 추상 클래스가 생각날건데, 둘이 비슷한 건 맞으나, 사용 목적과 특징이 다르다.
특성 추상 클래스 (Abstract Class) 인터페이스 (Interface)
메서드 구현 메서드 구현이 가능 (일부는 구현되고, 일부는 추상 메서드로) 메서드는 구현 없이 선언만 가능 (Java 8 이후 default 메서드 예외)
상속 방식 단일 상속 (하나의 추상 클래스만 상속 가능) 다중 상속 (여러 개의 인터페이스를 구현 가능)
클래스는 내용이 구현되어 있고 변수나 메소드 명이 같으면
구분이 안되는 일이 있는데 인터페이스는 내용이 없어서
다중 상속이 가능하다.
필드 **상태(필드)**를 가질 수 있으며, 인스턴스 변수로 사용 가능 상수 가질 수 있으며, public static final이어야 함
사용 목적 공통된 기능의 구현과 일부 기능의 재사용을 위해 사용 여러 클래스가 공통된 기능을 갖게 하며, 다형성을 구현하기 위해 사용
인스턴스화 인스턴스화 불가 (추상 클래스 자체로 객체 생성 불가) 인스턴스화 불가 (인터페이스 자체로 객체 생성 불가)

 

 

 

디폴트 메서드와 static 메서드

📌 디폴트 메서드는 추상 메서드의 기본적인 구현을 제공하는 메서드입니다.

  • 메서드 앞에 default 키워드를 붙이며 블럭{ }이 존재해야 합니다.
  • default 메서드 역시 접근 제어자가 public이며 생략이 가능합니다.
  • 추상 메서드가 아니기 때문에 인터페이스의 구현체들에서 필수로 재정의 할 필요는 없습니다.
public class Main implements A {

    @Override
    public void a() {
        System.out.println("A");
    }


    public static void main(String[] args) {
        Main main = new Main();
        main.a();

        // 디폴트 메서드 재정의 없이 바로 사용가능합니다.
        main.aa();
    }
}

interface A {
    void a();
    default void aa() {
        System.out.println("AA");
    }
}

 

static 메서드

📌 인터페이스에서 static 메서드 선언이 가능합니다.

  • static의 특성 그대로 인터페이스의 static 메서드 또한 객체 없이 호출이 가능합니다.
  • 선언하는 방법과 호출하는 방법은 클래스의 static 메서드와 동일합니다.
    • 접근 제어자를 생략하면 컴파일러가 public을 추가해 줍니다. 
public class Main implements A {

    @Override
    public void a() {
        System.out.println("A");
    }

    public static void main(String[] args) {
        Main main = new Main();
        main.a();
        main.aa();
        System.out.println();

        // static 메서드 aaa() 호출
        A.aaa();
    }
}

interface A {
    void a();
    default void aa() {
        System.out.println("AA");
    }
    static void aaa() {
        System.out.println("static method");
    }
}

 

 

다형성

 

자동 타입 변환

public class Main {
    public static void main(String[] args) {
        
        // A 인터페이스에 구현체 B 대입
        A a1 = new B();
        
        // A 인터페이스에 구편체 B를 상속받은 C 대입
        A a2 = new C();
        
    }
}

interface A { }
class B implements A {}
class C extends B {}

 

강제 타입 변환

public class Main {
    public static void main(String[] args) {

        // A 인터페이스에 구현체 B 대입
        A a1 = new B();
        a1.a();
        // a1.b(); // 불가능

        System.out.println("\nB 강제 타입변환");
        B b = (B) a1;
        b.a();
        b.b(); // 강제 타입변환으로 사용 가능
        System.out.println();

        // A 인터페이스에 구편체 B를 상속받은 C 대입
        A a2 = new C();
        a2.a();
        //a2.b(); // 불가능
        //a2.c(); // 불가능

        System.out.println("\nC 강제 타입변환");
        C c = (C) a2;
        c.a();
        c.b(); // 강제 타입변환으로 사용 가능
        c.c(); // 강제 타입변환으로 사용 가능


    }
}

interface A {
    void a();
}
class B implements A {
    @Override
    public void a() {
        System.out.println("B.a()");
    }

    public void b() {
        System.out.println("B.b()");
    }
}
class C extends B {
    public void c() {
        System.out.println("C.c()");
    }
}

 

 

인터페이스의 다형성

// LG TV 구현체를 조작
MultiRemoteController mrc = new LgTv("LG");
mrc.turnOnOff();
mrc.volumeUp();

// 조작 대상을 Samsung TV로 교체
mrc = new SamsungTv("Samsung");
mrc.turnOnOff();
mrc.channelUp();
  • 멀티 리모컨 인터페이스 변수 = TV 구현 객체;를 선언하여 자동 타입 변환된 인터페이스 변수를 사용하여 TV 구현 객체의 기능을 조작할 수 있습니다.
  • TV 구현 객체를 교체해도 멀티 리모컨 인터페이스 변수는 전혀 수정 작업 없이 그대로 기능을 호출할 수 있습니다.
  • 다형성은 ‘여러 가지 형태를 가질 수 있는 능력’이라고 배웠습니다.
  • 사용 방법은 동일하지만 다양한 특징과 결과를 가질 수 있는 것이 바로 다형성입니다.
    • 즉, 멀티 리모컨으로 티비를 사용하는 방법은 동일하지만 어떤 TV 구현 객체가 대입되었느냐에 따라 실행 결과가 다르게 나옴을 통해 다형성이 적용되었음을 확인할 수 있었습니다.
// 매개변수와 반환타입 다형성 확인 메서드
default MultiRemoteController getTV(Tv tv) {
    if(tv instanceof SamsungTv) {
        return (SamsungTv) tv;
    } else if(tv instanceof LgTv){
        return (LgTv) tv;
    } else {
        throw new NullPointerException("일치하는 Tv 없음");
    }
}
  • 또한 인터페이스도 마찬가지로 매개변수와 반환 타입에서 다형성이 적용될 수 있습니다.
  • 위 예제는 반환 타입에는 인터페이스, 매개변수에는 추상 클래스로 다형성이 적용되어있습니다.
    • 인터페이스의 default 메서드입니다.
  • 전체 예제를 통해 더 자세하게 확인해 보겠습니다.
public abstract class Tv {

    private String company; // 티비 회사
    private int channel = 1; // 현재 채널 상태
    private int volume = 0;  // 현재 볼륨 상태
    private boolean power = false; // 현재 전원 상태

    public Tv(String company) {
        this.company = company;
    }

    public void displayPower(String company, boolean power) {
        if(power) {
            System.out.println(company + " Tv 전원이 켜졌습니다.");
        } else {
            System.out.println(company + " Tv 전원이 종료되었습니다.");
        }
    }

    public void displayChannel(int channel) {
        System.out.println("현재 채널은 " + channel);
    }

    public void displayVolume(int volume) {
        System.out.println("현재 볼륨은 " + volume);
    }

    public String getCompany() {
        return company;
    }

    public int getChannel() {
        return channel;
    }

    public int getVolume() {
        return volume;
    }

    public boolean isPower() {
        return power;
    }

    public void setChannel(int channel) {
        this.channel = Math.max(channel, 0);
    }

    public void setVolume(int volume) {
        this.volume = Math.max(volume, 0);
    }

    public void setPower(boolean power) {
        this.power = power;
    }
}

public class SamsungTv extends Tv implements MultiRemoteController{

    public SamsungTv(String company) {
        super(company);
    }

    @Override
    public void turnOnOff() {
        setPower(!isPower());
        displayPower(getCompany(), isPower());
    }

    @Override
    public void channelUp() {
        setChannel(getChannel() + 1);
        displayChannel(getChannel());
    }

    @Override
    public void channelDown() {
        setChannel(getChannel() - 1);
        displayChannel(getChannel());
    }

    @Override
    public void volumeUp() {
        setVolume(getVolume() + 1);
        displayVolume(getVolume());
    }

    @Override
    public void volumeDown() {
        setVolume(getVolume() - 1);
        displayVolume(getVolume());
    }
}

public class LgTv extends Tv implements MultiRemoteController {

    public LgTv(String company) {
        super(company);
    }

    @Override
    public void turnOnOff() {
        setPower(!isPower());
        displayPower(getCompany(), isPower());
    }

    @Override
    public void channelUp() {
        setChannel(getChannel() + 1);
        displayChannel(getChannel());
    }

    @Override
    public void channelDown() {
        setChannel(getChannel() - 1);
        displayChannel(getChannel());
    }

    @Override
    public void volumeUp() {
        setVolume(getVolume() + 1);
        displayVolume(getVolume());
    }

    @Override
    public void volumeDown() {
        setVolume(getVolume() - 1);
        displayVolume(getVolume());
    }
}

public interface MultiRemoteController {

    void turnOnOff();
    void channelUp();
    void channelDown();
    void volumeUp();
    void volumeDown();

    // 매개변수와 반환타입 다형성 확인 메서드
    default MultiRemoteController getTV(Tv tv) {
        if(tv instanceof SamsungTv) {
            return (SamsungTv) tv;
        } else if(tv instanceof LgTv){
            return (LgTv) tv;
        } else {
            throw new NullPointerException("일치하는 Tv 없음");
        }
    }

}

public class Main {
    public static void main(String[] args) {

        // LG TV 구현체를 조작
        MultiRemoteController mrc = new LgTv("LG");
        mrc.turnOnOff();
        mrc.volumeUp();
        mrc.channelDown();
        mrc.channelUp();
        mrc.turnOnOff();

        // 조작 대상을 Samsung TV로 교체
        System.out.println("\n<Samsung TV로 교체>");
        mrc = new SamsungTv("Samsung");
        mrc.turnOnOff();
        mrc.channelUp();
        mrc.volumeDown();
        mrc.volumeUp();
        mrc.turnOnOff();

        // 매개변수, 반환타입 다형성 체크
        System.out.println("\n<매개변수, 반환타입 다형성 체크>");

        MultiRemoteController samsung = mrc.getTV(new SamsungTv("Samsung"));
        samsung.turnOnOff();

        SamsungTv samsungTv = (SamsungTv) samsung;
        samsungTv.turnOnOff();


        System.out.println();
        MultiRemoteController lg = mrc.getTV(new LgTv("LG"));
        lg.turnOnOff();

        LgTv lgTv = (LgTv) lg;
        lgTv.turnOnOff();

    }
}

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] Generic  (3) 2024.11.12
[JAVA] 오류 및 예외에 대한 이해  (1) 2024.11.12
[JAVA] 클래스 간의 관계와 상속  (0) 2024.11.12
[JAVA] package와 import  (0) 2024.11.12
[JAVA] 접근 제어자  (1) 2024.11.12

+ Recent posts