Java Basics (4)

Wonho Kim·2025년 1월 12일

Java Basics

목록 보기
4/6

해당 시리즈의 게시물은 codelatte.io 사이트를 참고하여 정리한 내용입니다.
https://www.codelatte.io/courses/java_programming_basic

16. String 객체와 문자열 상수

String name = "포도";

자바에서 String 객체와 문자열을 사용하는 구문을 분석해보면 아래와 같다.

[String 참조 자료형] [변수명] [문자열]

여기서 쌍따옴표로 감싸진 "포도"는 문자열 상수라고 부른다.

문자열을 생성하는 방법은 아래와 같이 다양하다.

// 문자열 상수
String name = "포도";

// String 객체를 이용하여 생성
String name = new String ("포도");

// String 객체를 이용하여 생성
String name = String.format("%s도", "포");

// String 객체를 이용하여 생성
String name = String.join("%s%s", new String[] {"포", "도"});

// StringBuilder 객체를 이용하여 생성
String name = new StringBuilder().append("포").append("도").toString();

// StringBuffer 객체를 이용하여 생성
String name = new StringBuffer().append("포").append("도").toString();

// 문자열 상수를 더하여 생성
String name = "포"+"도";

// 변수에 저장된 문자열 상수와 문자열 상수를 더하여 생성
String text = "포";
String name =  text+"도";

지금까지 당연하게 사용해온 얘기를 다시 하는 이유는 문자열을 생성하는 방법은 다양하고, 이에 따라 Java 언어를 실행하는 가상머신이 문자열을 관리하는 방법이 달라지며 그에 따라 문자열을 비교하는 방법도 달라진다.

16-1. 문자열 비교

문자열 상수String 인스턴스로 저장된 문자열서로 다른 관리 방법을 가지고 있다.

문자열 상수(리터럴)는 자바 가상머신이 관리하는 String constant pool에 저장된다.

그리고 문자열 상수가 아닌 다른 방법으로 만들어진 String 객체는 자바 가상머신이 관리하는 Heap 공간에 저장된다.

따라서 문자열의 경우 "==" 연산자를 이용하여 같은 문자열인지 비교할 수 없다.

아래 case들을 살펴보자.

  • case1
String text1 = "포도";
String text2 = "포도";

// 문자열 상수끼리 비교하기 때문에 같다.
System.out.println(text1 == text2);

문자열 상수는 String constant pool에 한번 저장되면 동일한 문자열 상수를 다른 메모리 공간안에 저장하지 않고 이미 저장된 문자열 상수를 사용하므로 동일하다.

  • case2
String text1 = “포도”;
String text2 = “포”+“도”;

// 문자열 상수끼리 비교하기 때문에 같다.
System.out.println(text1 == text2);

문자열 상수를 "+"연산을 진행하여 새로운 문자열 상수를 만들어 내는 것인데, 이 경우 컴파일 시에 문자열 상수를 만들어 낸다.

따라서 이 역시 동일한 String constant pool에 저장되므로 메모리 주소가 동일하여 같다.

  • case3
String text1 = "포";
String text2 = "포도";
String text3 = text1+"도";

// StringBuilder 인스턴스와 문자열 상수끼리 비교하기 때문에 동일하지 않다.
System.out.println(text2 == text3);

case2와 비슷해보이지만 다른 부분은 text1 변수 자체와 "도" 문자열 상수를 "+" 연산을 진행하게 되면 내부적으로 StringBuilder 인스턴스를 생성하고 연산하여 String 객체를 반환한다.

text1 변수라는 것은 Runtime 환경에서 언제든지 값이 변할 수 있다고 판단하기에 String 인스턴스로 최종 변환하게 되는 것이다.

따라서 인스턴스와 문자열 상수끼리 비교이므로 동일하지 않다.

  • case4
String text1 = new String("포도");
String text2 = new String("포도");

// 동일하지 않다 (인스턴스 끼리 비교)
System.out.println(text1 == text2);

new 키워드를 사용하여 만든 객체는 각각 별도의 메모리 공간에 저장된다. 따라서 text1과 text2는 서로 다른 메모리 공간에 존재하므로 동일하지 않다.

  • case5
String text1 = new StringBuilder("포").append("도").toString();
String text2 = "포도";

// 동일하지 않다 (인스턴스와 상수끼리 비교)
System.out.println(text1 == text2);

text1은 new 키워드를 이용하여 StringBuilder 인스턴스를 사용하여 문자열을 생성했고, text2는 문자열 상수를 저장하고 있다. 따라서 위의 설명과 같이 인스턴스와 상수를 비교하므로 동일하지 않다.

16-2. 같은 문자열 비교?

그렇다면 문자열 비교가 이렇게 까다로워서 자바 언어 어떻게 쓰냐라고 할 수 있다...

그래서 String 클래스의 eqauls 메서드를 사용하면 동일한 문자열인지 비교할 수 있다!

public boolean equals(Object object);

equals 메서드는 메모리 주소를 비교하지 않고, 저장된 문자열을 토큰화하여 문자를 하나씩 하나씩 비교하여 동일한지 확인한다.

따라서 "==" 대신 "equals"를 사용하면 16-1 챕터에서 설명한 case1 ~ case5 모두 동일한 문자열로 판단할 수 있다.

String text1 = new String("포도");
String text2 = "포도";

// 인스턴스와 문자열 상수와 비교이나 문자열은 동일하다.
System.out.println(text1.equals(text2));

추가로, Java 언어는 문자열 상수에서 String 객체의 메서드를 사용할 수 있는 희한한 코드도 가능하다.

String text1 = "포도";

// 문자열 상수도 String 클래스가 가지고 있는 메서드를 사용할 수 있다.
System.out.println("포도".equals(text1));

코드 작성자의 편의를 위해 컴파일 시에 예외처리가 되기 때문이라고 한다.

17. 상속과 다형성

17-1. 상속

자식 클래스가 부모 클래스가 가지고 있는 요소들에 접근할 수 있고 요소들을 가질 수 있도록 하는 것을 상속이라고 한다.

public class ParentDog {

}

public class ChildDog extends ParentDog {

}

위와 같이 extends 키워드를 사용한 상속은 다중 상속이 불가능하며 자식 클래스는 하나의 부모 클래스에서만 상속 받을 수 있다.

아래와 같이 조부모, 부모, 자식 클래스로 구성도 가능하다.

public class GrandParentDog {

}

public class ParentDog extends GrandParentDog {

}

public class ChildDog extends ParentDog {

}

부모 클래스로부터 상속 받을 수 있는 요소는 변수와 메서드이다.

public class ParentDog {
    protected String color = "black";
    static protected int age = 10;

    protected void bark() {
        System.out.println("왈왈!");
    }
}

public class ChildDog extends ParentDog {
    // color, age 변수와
    // bark(); 메서드의 요소를 상속 받는다
}
public class Main {

    public static void main(String[] args) {
        // ChildDog 클래스에 변수나 메서드를 선언하지 않아도 접근할 수 있습니다.
        ChildDog childDog = new ChildDog();
        System.out.println(childDog.color);
        childDog.bark();
    }

}

17-2. 상속과 생성자

이전에 설명했듯이 상속받을 수 있는 요소는 변수와 메서드이며 생성자는 상속받을 수 없다.

이 말은 즉 부모 클래스에서 생성자가 명시적으로 선언되어 있는 경우는 자식 클래스에서 필수적으로 부모의 생성자를 호출해야하는 제약조건이 따른다.

public class ParentDog {
    String name;

    ParentDog(String name) {
        this.name = name;
    }
}
public class ChildDog extends ParentDog {
    ChildDog(String name) {
        super(name); // 부모 ParentDog 클래스의 생성자 호출
    }
}

위와 같이 부모의 생성자가 명시적으로 선언되어 있는 경우 자식 클래스에서 부모의 생성자 중 하나를 반드시 호출해야 하며, super()를 사용하여 호출한다.

여기서 super(name)은 ParentDog(String name)에 매칭된다.

주의할 점은 super() 메서드는 자식 클래스 생성자 내부에서만 호출이 가능하며 일반적인 메서드에서 호출이 불가능하다.

아래와 같이 특수한 케이스에서는 super() 메서드를 명시적으로 호출하지 않아도 된다.

  1. 부모 클래스의 생성자가 명시적으로 선언되어 있지 않은 경우
  2. 부모 클래스의 생성자가 명시적으로 선언되어 있어도, 기본 생성자만 명시되어 있는 경우
public class ParentDog {
    String name;

    ParentDog() {
        this.name = "이쁜 강아지"
    }

}

public class ChildDog extends ParentDog {
    ChildDog(String name) {
        // super(); 생략 가능
   }

    ChildDog(String name1, String name2) {
       // super(); 생략 가능
   }
}

이 경우 super()를 명시적으로 호출하지 않아도 내부적으로 부모 생성자를 호출하기 때문이다.

17-3. 다형성

다형성이란 여러가지 형태에 속할 수 있는 성질, 하나의 객체 인스턴스가 여러가지 자료형을 가질 수 있는 것을 의미한다.

보통 다형성을 표현할 때 is-a 관계라고 한다.
산소는 기체이다. (기체는 산소이다)
웰시코기는 개이다. (개는 웰시코기이다)

public class Dog {
    protected String color;

    public void bite() {
        System.out.println("깨물다");
    }

    public void bark() {
        System.out.println("짖는다");
    }
}

public class Bulldog extends Dog {

}

public class Retriever extends Dog {
    public void swim() {
        System.out.println("수영하다");
    }
}

Bulldog 클래스는 Dog 클래스를 상속받고 있고, Retriever 클래스도 Dog 클래스를 상속받고 있다.

이 경우 다형성을 사용할 수 있다.

Bulldog bulldog = new Bulldog();
Dog dog = new BullDog();

// 또는

Retriever retriever = new Retriever();
Dog dog = new Retriever();

자식 객체는 부모 객체의 요소를 가지고 메모리 공간에 적재된다.

Retriever 인스턴스를 생성 시 부모 클래스인 Dog 클래스의 요소도 포함된만큼 메모리를 할당하여 적재된다.

다형성을 사용하는 이유나 더욱 자세한 내용은 아래 링크를 참고하길 바란다.
(내가 적는 것보다 설명이 매우 좋고 잘 되어있다.)

Inpa Dev: 자바의 다형성 완벽 이해하기
https://inpa.tistory.com/entry/OOP-JAVA%EC%9D%98-%EB%8B%A4%ED%98%95%EC%84%B1Polymorphism-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4

17-4. 객체의 형 변환

인스턴스를 생성할 때 자료형을 따라가는 것이 아니라 new 키워드를 이용하여 생성한 인스턴스가 메모리에 적재된다.

따라서 객체의 형 변환은 형 변환하는 자료형으로 사용하겠다의미만 존재한다.

인스턴스 자체가 변환되는 것이 아니다. 왜냐하면 참조 자료형은 단순히 참조값만 저장하기 때문에 실제로는 생성했던 인스턴스를 사용하기 때문이다.

Dog dog = new Retriever();
Retriever retriever = (Retriever)dog;
retriever.swim();

Dog dog = new Bulldog();
Bulldog bulldog = (Bulldog)dog;

Dog dog = new Dog();
Retirever retriever = (Retriever)dog; // 에러 발생, 개는 리트리버가 될 수 없다.

사실 해당 내용은 업캐스팅/다운캐스팅과 관련이 있다. 더욱 자세한 내용을 알고 싶다면 아래 링크를 참고하길 바란다.

Inpa Dev: 업캐스팅/다운캐스팅 이해하기
https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%97%85%EC%BA%90%EC%8A%A4%ED%8C%85-%EB%8B%A4%EC%9A%B4%EC%BA%90%EC%8A%A4%ED%8C%85-%ED%95%9C%EB%B0%A9-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

instanceof 연산자

해당 인스턴스가 특정 인스턴스가 맞는지 확인할 수 있는 연산자이며 맞으면 true, 틀리면 false를 반환한다.

Dog dog = new Retriever();

System.out.println(dog instanceof Retriever); // true
System.out.println(dog instanceof Dog); // true
System.out.println(dog instanceof Bulldog); // false

17-5. 오버라이드(Override)

오버라이드 의미는 더 우선시한다는 의미로 부모 클래스에 정의된 내용보다 자식 클래스에서 정의한 내용을 더 우선시한다. 다시 말해 행위의 내용을 재정의하는 것과 같다.

public class Dog {
    protected String color;

    public void bite() {
        System.out.println("앙!");
    }

    public void bark() {
        System.out.println("왈왈!");
    }
}

public class Jindodog extends Dog {

    // 부모의 메서드를 재정의 했다.
    public void bite() {
        System.out.println("와작");
    }

    // 부모의 메서드를 재정의 했다.
    public void bark() {
        System.out.println("컹컹!");
    }
}
Dog dog1 = new Dog();
dog1.bite(); // "앙"
dog1.bark(); // "왈왈!"

Dog dog2 = new Jindodog();
dog2.bite(); // "와작"
dog2.bark(); // "컹컹!"

자식 클래스에서 오버라이드 된 메서드가 존재하는 경우 생성된 인스턴스에 따라 호출되는 내용이 달라진다.

super.메서드명(매개변수)

자식 클래스에서 부모 클래스 메서드를 재정의 했을 때, 부모의 메서드를 호출하는 방법은 super.메서드명(매개변수)를 사용하면 된다.

public class Dog {
    protected String color;

    public void bite() {
        System.out.println("앙!");
    }

    public void bark() {
        System.out.println("왈왈!");
    }
}
public class Jindodog extends Dog {

    // 부모의 메서드를 재정의 했다.
    public void bite() {
        // bite()를 호출하면 스코프에 의해 
        // 자식 클래스의 bite() 메서드를 호출하는 상황이 발생한다.
        // bite();

        // 부모 메서드를 호출하는 방법은 super 키워드를 사용하면 된다.
        super.bite(); 
        System.out.println("와작");
    }

    // 부모의 메서드를 재정의 했다.
    public void bark() {
        System.out.println("컹컹!");
    }

}

접근제어 지시자와 오버라이드 관계

public class Dog {
    protected String color;

    private void eat() {
        System.out.println("먹는다");
    }

    void bite() {
        System.out.println("앙!");
    }

    protected void bark() {
        System.out.println("왈왈!");
    }
}
public class Jindodog extends Dog {

    // 오버라이드가 아닌 Jindodog 클래스에서 사용할 새로운 메서드
    private void eat() {
        System.out.println("잘먹는다");
    }

    public void bite() {
        System.out.println("와작");
    }

    // 부모의 메서드를 재정의 했다.
    public void bark() {
        System.out.println("컹컹!");
    }

}

부모 클래스에서 private 지시자로 선언된 클래스는 자식 클래스에서 접근할 수도 없고 오버라이드 할 수도 없다.

부모 클래스의 메서드를 오버라이드 시 접근제어 지시자는 이미 부모 클래스에서 선언된 접근제어 지시자보다 더 공개된 범위의 접근제어 지시자로만 변경이 가능하다.

부모 default 메서드 -> 자식 default, protected, public 메서드
부모 protected 메서드 -> 자식 protected, public 메서드

변수는 오버라이드가 불가능

메서드만 오버라이드가 가능하며, 변수는 오버라이드가 불가능하다.

public class Dog {
    public String color = "검정";
}

public class Jindodog extends Dog {
    public String color = "베이지";

    public String getColor() {
        return color;
    }

    public String getParentColor() {
        return super.color;
    }
}

Dog dog = new Jindodog();

System.out.println(dog.color); 
// 검정

변수가 오버라이드 되었다면 "베이지"가 출력되어야 하지만 부모 클래스의 "검정"이 그대로 출력되었으므로 오버라이드가 불가능하다는 것을 의미한다.

추가로, 자식 클래스 내부에서 부모의 color 변수에 접근하는 방법은 super 키워드를 사용할 수 있다.

super.color;

18. 추상화

내용에 중점을 두는 것 보다 핵심적인 개념을 추려내는 것을 추상화라고 한다. 추상화를 하는 과정에서는 공통적인 것을 추려내는 것도 포함된다.

사진 설명과 같이 추상화의 핵심 행위는 바라보는 관점에 따라 달라질 수 있다. 식탁에 앉는다가 핵심 행위라면 식탁이 대리석인지 나무인지 플라스틱 식탁인지는 중요하지 않다는 것이다.

위 사진을 Person 클래스로 옮기면 다음과 같다.

public class Person {
	private final Table table;
    private final Spoon spoon;
    private final Rice rice;
    
    public Person(Table table, Spoon spoon, Rice rice) {
    	this.table = table;
        this.spoon = spoon;
        this.rice = rice;
    }
    
    public void eat() {
		table.seat();
        spoon.taken();
        spoon.scoop(rice);
        rice.eaten();
    }
}

여기서 Person 클래스의 eat() 메서드는 "앉고, 들고, 푸고, 먹는다"에만 집중하지 그 외에 어떻게 밥을 먹는지, 어떤 식탁인지 등등 핵심 행위가 아닌 것들은 숨긴다.

따라서 우리가 지금까지 배운 개념을 적용하여 설명하면 아래와 같다.

  • 다형성: Rice, Spoon, Table을 기반으로 다양한 여러 클래스들로 확장
  • 추상화: 핵심적인 개념만 추림, eat(), seat(), taken(), scoop(), eaten()
  • 캡슐화: 핵심 행위가 아닌 것을 숨김

18-1. abstract 클래스

abstract 클래스는 추상 클래스라고도 하며, 추상화를 좀 더 구조적으로 도와주는 도구이다.

public abstract class Dog {
    protected String name;

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

    public abstract void bite();

    public abstract void bark();

    protected void eat() {
        System.out.println("먹는다");
    }
}

추상 클래스를 만들기 위해서는 class 앞에 abstract 키워드를 사용하여 추상 클래스라는것을 명시한다.

abstract void bite();

abstract void bark();

abstract 메서드는 메서드의 이름과 반환형, 매개변수만 선언하고 내용에 대해서는 정의하지 않는다. 해당 내용에 대한 정의는 상속받는(extends)하는 클래스에 위임하고 있다.

public class Retriever extends Dog {

    public Retriever(String name) {
        super(name);
    }

    // 반드시 추상 메서드는 Override 해야 한다.
    public void bite() {}

    // 반드시 추상 메서드는 Override 해야 한다.
    public void bark() {}

}

abstract 클래스의 특징

  • 생성자를 정의할 수 있다.
  • 단독으로 인스턴스를 생성할 수 없다.
  • 추상 메서드를 사용 시 abstract 키워드가 선언되어 있어야 한다.
  • abstract 메서드는 확장하는 클래스에서 내용을 정의해야 하므로 private 지시자를 선언할 수 없다.
  • 확장하는 클래스는 abstract 메서드를 반드시 override 해야하며 구현체가 존재해야한다.

18-2. interface

interface 역시 abstract 클래스와 동일하게 추상화를 좀 더 구조적으로 도와주는 도구이다. 또한 현업에서는 거의 interface를 사용하고 abstract 클래스는 많이 사용하지 않는다...

☃️ interface 핵심 개념

1. 아무런 구현이 되어있지 않으며 모든 메서드가 추상 메서드이다.
2. 객체를 생성하지 않기 때문에 객체 멤버 변수가 없으며, 객체를 생성하지 않으므로 생성자도 없다.
3. 정적 메서드는 선언이 가능하며, 오버라이딩 할 수 없어 무조건 구현부가 존재해야한다.

public interface Dog {
    public static final String color = "검정";

    public abstract void bite();

    public abstract void bark();

    public default void eat() {
        System.out.println("먹는다");
    }

    public static String getColor() {
         return color;
    }
}

인터페이스를 만들기 위해서는 interface 인터페이스명을 사용하면 된다.

아래는 interface 내부에 선언한 변수와 메서드에 대해 자세히 설명해 놓은 것이다.

public abstract void bite();

public abstract void bark();

인터페이스 역시 abstract 메서드를 제공하며, 메서드의 이름과 반환형, 매개변수만 선언하고 내용에 대해서는 정의하지 않는다.

해당 내용에 대한 정의는 구현(implements)하는 클래스에 위임하고 있다.

근데 인터페이스 내의 추상 메서드는 모두 public abstract 의미를 내포하고 있어서 생략해도 된다.
(붙여도 되고 안붙여도 똑같이 적용된다는 말)

public static final String color = "검정";

인터페이스 내에 선언하는 변수는 모두 public static final이어야 하며, 이 역시 의미를 내포하고 있어서 생략해도 자동으로 public static final 변수가 된다.
(붙여도 되고 안붙여도 똑같이 적용된다는 말)

※ static final?

static은 인스턴스를 생성하지 않아도 바로 사용할 수 있도록 정적으로 메모리에 적재되는 키워드이고, final은 최초에 한번 값이 저장되면 그 이후에 값 변경이 불가능하다는 말이다.

2가지 의미를 모두 합치면 아래와 같다.

  1. 최초에 한번 값을 저장시킨 후 값 변경이 불가능하다.
  2. 프로그램 실행 전에 정적으로 메모리에 적재해야한다.

=> 상수를 선언하는 것과 동일하게 된다.
=> 위에 설명한 인터페이스 핵심 개념에 적어놨듯이 객체를 생성하지 못하므로 클래스 멤버 변수밖에 선언을 못하기 때문에 변수가 자동으로 static final이 된다.

public default void eat() {
	System.out.println("먹는다");
}

Java 8 이후 인터페이스에서 default 메서드를 구현할 수 있다.

default 메서드는 인터페이스를 구현하는 모든 하위 클래스에서 필수로 구현되는 메서드로 메서드 중복 구현을 방지하는 역할을 한다.

사실 인터페이스의 기본 원칙에 따르면 구현체가 인터페이스에 있는 모든 메서드를 구현해야하는 것에 위배되는 것이 맞지만, 이렇게 기준을 깐깐하게 잡으면 인터페이스에 메서드 하나 추가할 때마다 모든 구현체가 굳이 사용하지도 않는 메서드를 모두 구현해야하는 번거로움이 따르기 때문에 도입하게 되었다고 한다.

이는 객체지향 설계 5대 원칙 중 하나인 개방 폐쇄 원칙(OCP)와 관련이 있다. 해당 내용을 설명하기에는 자바 언어 스터디와 초점이 벗어나는 내용이므로 생략한다.

public class Retriever implements Dog {

    public Retriever(String name) {
        super(name);
    }

    // 반드시 추상 메서드는 Override 해야 한다.
    public void bite() {}

    // 반드시 추상 메서드는 Override 해야 한다.
    public void bark() {}

}

추상 클래스의 abstract 메서드와 동일하게, abstract 메서드는 내용이 정의되어 있지 않으므로 구현(implements)하는 클래스에 위임하여 반드시 override 하도록 구성된다.

public interface Dog {

    // 인터페이스는 생성자를 선언 및 정의를 할 수 없다.
    public Dog(String name) { 
        this.name = name;
    }
}

인터페이스는 생성자를 선언 및 정의할 수 없다. 인터페이스는 확장(extends)을 위해 사용하기 보다는 메서드 규약(Protocol)을 위해 사용하는 경우가 많기 때문에 생성자를 선언할 수 없다.

(사실 객체를 생성하지 못하기 때문에 생성자를 선언 및 정의할수도 없다.)

interface 특징

  • 생성자를 선언 및 정의할 수 없다.
  • 인터페이스 내에서 선언하는 변수는 모두 public static final이 선언된 변수이다.
  • default 메서드도 아니고 static 메서드도 아닌 메서드는 모두 public abstract가 선언된 메서드이다.
  • default 메서드나 static 메서드는 모두 public이 선언된 메서드이다.
  • 상속받는 클래스는 abstract 메서드를 반드시 override 해야하며 구현체가 반드시 존재해야 한다.
  • 인터페이스의 내용을 실제로 구현하는 클래스는 인터페이스를 implements해야 한다.

18-3. 익명 클래스

Java에는 익명 클래스라는 존재가 있다. 익명 클래스란 상속 받는 클래스를 명시적으로 별도의 Java 파일을 통해 클래스를 만들지 않고 코드 내부에 이름이 존재하지 않는 클래스를 만드는 것이다.

public class Coffee {
    public void make() {
        System.out.println("Make!!");
    }
}
public class Main {

    public static void main(String[] args) {
 
        // TODO : Coffee 클래스를 상속 받는 익명 클래스
        Coffee coffee = new Coffee() {
            // make 메서드 오버라이드
            public void make() {
                System.out.println("Override Make!!");
            }
            // 새로운 메서드
            public void serve() {
                System.out.println("Serve");
            }
        }
        coffee.make();
        // Override Make!! 

        // serve() 메서드는 호출할 수 없다
        coffee.serve();
        // compile error
    }
}

Coffee 클래스를 상속받는 익명 클래스로 정의했다. 별도의 파일을 이용하는 것이 아니라 아래 형식처럼 코드 내부에서 상속받는 클래스를 재정의 하는 방법이다.

Coffee coffee = new Coffee() {
	...
    ...
}

new Coffee()를 통해서 생성하는 인스턴스는 Coffee 클래스가 아닌 Coffee 클래스를 상속받는 익명 클래스이므로, 정작 Coffee 클래스 자체에는 serve() 메서드가 선언되어 있지 않기 때문에 coffee.serve() 메서드는 외부에서 호출할 수 없다.

추상 클래스를 상속 받는 익명 클래스

public abstract class Person {
    abstract public void eat();
    abstract public void sleep();

    public void walk() {
        System.out.println("walk!");
    }
}
public class Main {

    public static void main(String[] args) {
 
        // TODO : Person 클래스를 상속 받는 익명 클래스
        Person person = new Person() {

            public void eat() {
                System.out.println("eat!");
                // walk() 메서드는 Person 클래스의 메서드를 의미한다
                walk();
            }

            public void sleep() {
                System.out.println("sleep!");
            }
        }

        person.eat();
        // eat!
        // walk!

    }
}

Person 클래스를 상속받는 어떤 클래스든 abstract 메서드는 재정의를 해야한다. 따라서 익명 클래스를 정의할때도 abstract 메서드는 반드시 재정의해야한다.

확실히 알아야 할 점은 언뜻 보면 new Person()으로 Person 인스턴스를 생성하는 것처럼 보이지만, 정확하게는 Person 클래스를 상속받는 익명 클래스의 인스턴스를 생성하는 것이다.

※ 인터페이스를 구현하는 익명 클래스

public interface Operate {
    int operate(int a, int b);
}

public class Plus implements Operate {
    public int operate(int a, int b) {
        return a + b;
    }
}

public class Calculator {
    private int a;
    private int b;

    public Calculator(int a, int b) {
        this.a = a;
        this.b = b;
    }

    public int result(Operate op) {
        return op.operate(this.a, this.b);
    }
}

public class Main {

    public static void main(String[] args) {
 
        Calculator calculator = new Calculator(20, 10);
        Operate operate = new Plus();
 
        int result = calculator.result(operate);
        // 30

    }
}

위에 작성한 코드 내용은 익명 클래스 없이 지금까지 우리가 학습한 인터페이스 개념을 적용한 예시 코드이다.

Operate 인터페이스를 구현하기 위해 Plus 클래스를 선언 및 사용하였는데 이를 별도의 파일로 만들지 않고 익명 클래스로 전환하면 다음과 같다.

public class Main {

    public static void main(String[] args) {
 
        Calculator calculator = new Calculator(20, 10);

        int result = calculator.result(new Operate() {
            public int operate(int a, int b) {
                return a + b;
            }
        });

        System.out.println(result);
        // 30
    }
}

인터페이스의 익명 클래스와 람다 표현식

Java 8 이후 람다 표현식이 가능하게 되었다.

Operate operate = new Operate() {
    public int operate(int a, int b) {
        return a + b;
    }
};

위 코드를 람다 표현식으로 전환하면 아래와 같다.

Operate operate = (a, b) -> {
    return a + b;
};

코드를 작성해야하는 글자 수가 눈에 띄게 줄어든 것을 확인할 수 있다. 함수명을 따로 작성하지 않기 때문에 이를 람다 또는 익명 함수라고 부른다.

람다를 적용하여 우리가 지금까지 작성한 코드에 대입하면 다음과 같다.

public class Main {

    public static void main(String[] args) {
 
        Calculator calculator = new Calculator(20, 10);

        int result = calculator.result((a, b) -> {
            return a + b;
        });

        System.out.println(result);
        // 30
    }
}

심지어 람다는 아래처럼 더 간단하게 작성도 가능하다.

Operate operate = (a, b) -> a + b;

위 처럼 코드를 줄이기 위해서는 반환값이 있어야 하며, 람다 내부 구문이 코드 한 줄로 작성이 가능해야한다.

다시 말해 아래처럼 코드가 2줄 이상이면 더 간단하게 줄일 수 없으며, return 키워드 명시가 필요하다.

// 이런 구문은 더 간단히 작성될 수 없다.
Operate operate = (a, b) -> {
    System.out.println("Operate");
    return a + b;
};

람다 표현식의 제한

람다 표현식을 사용하기 위해서는 다음의 제약 조건이 있다.

  1. 인터페이스이어야 한다.
  2. 인터페이스에는 하나의 추상 메서드만 선언되어야 한다.
public interface Operate {

    // 오버라이드 해야 할 메서드가 두 개인 경우
    // 람다 표현식이 불가능하다.
    int operate(int a, int b);
    void print();
}
Operate operate = new Operate() {
    public int operate(int a, int b) {
        return a + b;
    }
    public void print() {
        System.out.println("출력");
    }
};

19. 예외처리

해당 내용은 Inpa Dev님자바 에러(Error)와 예외 클래스 이해하기, try catch 문법 & 응용 정리, 예외 던지기(throw) & 예외 연결(Chained Exception)의 내용도 함께 추가하였습니다.
https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%97%90%EB%9F%ACError-%EC%99%80-%EC%98%88%EC%99%B8-%ED%81%B4%EB%9E%98%EC%8A%A4Exception-%F0%9F%92%AF-%EC%B4%9D%EC%A0%95%EB%A6%AC

예외 처리를 잘 하는 방법은 프로그램 사용자의 입장에서 생각하는 것이다. 우리가 만든 프로그램은 대체로 사용자를 위해서 만들게 되므로 예외처리를 적절하게 잘 하는 것은 매우 중요하다.

JVM은 프로그램을 실행하는 도중에 예외가 발생하면 해당 예외 클래스로 객체를 생성하고 예외 처리 코드에서 예외 객체를 이용할 수 있도록 해준다.

Throwable은 모든 에러와 예외에 대한 최상위 클래스이다. Object 클래스를 상속받고 있으며 Error와 Exception을 하위 클래스로 가지고 있다. 실제로 사용할 일은 거의 없으니 개념적으로 그렇구나 하고 넘어가면 된다.

Error는 프로그램 실행 도중 해결할 수 있는 문제가 아니라 이후에 발견되어 처리해야 하는 문제들이 Error 클래스와 관련되어 있으며, 코드상으로 별도의 예외 처리가 불가능하다.

  1. OutOfMemeoryError
    자바 가상 머신이 메모리가 부족하여 인스턴스를 할당할 수 없고 가비지 컬렉터가 메모리를 사용할 수 없을 때 발생

  2. StackOverflowError
    스레드의 Stack 메모리가 꽉 찼을 경우 발생

  3. VirtualMachineError
    자바 가상 머신에 문제가 생겼거나 실행되는데 리소스가 부족할 경우 발생

따라서 우리가 중점적으로 봐야할 클래스는 바로 Exception 클래스(예외 클래스)이다.

Exception은 프로그램 실행 도중 종료될 수 있는 문제와 연관되어 예외 처리를 선택적으로 하거나, 꼭 해야 하는 클래스들의 슈퍼 클래스이다.

아래 Exception 클래스의 트리 구조를 보면 파란색과 붉은색으로 구분해 놓은 것을 확인할 수 있다. 여기서 파란색은 컴파일 에러(Checked Exception), 붉은색은 런타임 에러(Unchecked Exception) 클래스로 나뉘기 때문이다.


따라서 Exception 클래스를 상속받게 되면 사용자의 실수와 같은 외적인 요인에 의해 발생하는 컴파일 시 발생하는 예외 클래스에 대해 생성하는 것이므로 반드시 체크해 주어야 한다는 의미로 Checked Exception이라고 한다.

반대로 RuntimeException 클래스를 상속받게 되면 프로그래머의 실수로 발생할 수 있는 런타임 도중 발생하는 예외 클래스에 대해 생성하는 것이므로 반드시 예외로 던질 필요는 없다고 해서 Unchecked Exception이라고 한다.

따라서 상속받는 클래스가 Exception인 경우 Checked Exception, 상속받는 클래스가 RuntimeException인 경우 Unchecked Exception으로 보면 된다.

19-1. Checked Exception

반드시 try ~ catch로 감싸거나 throws로 던져서 처리해야지 컴파일되는 예외이며 직접적으로 Exception 클래스를 상속받는다.

IOException

IOException 클래스 자체는 아래와 같이 extends Exception을 통해 직접적으로 상속받고 있음을 확인할 수 있다.

아래는 입력받는 클래스인 InputStream 중 read 메서드의 코드이다.

public abstract class InputStream implements Closeable {
    ...
    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }
    ...
}

입출력에 관련한 예외는 주로 throws를 통해 메서드에 선언되어 있다. 반드시 예외 처리를 해야 컴파일이 된다.

아래와 같이 출력하는 메서드인 System.out.writePrinStream 클래스에 선언되어 있으며, 이 역시 throws 구문을 통해 IOException에게 예외 처리를 위임하고 있다.

따라서 System.out.write() 메서드는 Checked Exception인 IOExeption이 선언된 메서드이므로 반드시 try ~ catch 구문을 작성해야 주어야 한다.

import java.io.IOException;

public class Main {
    public static void main(String[] args) {
        byte[] numArray = {'1', '2', '3', '4'};
        try {
            System.out.write(numArray);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

InterruptedException

스레드와 관련된 예외이며 주로 throws를 통해 메서드에 선언되어 있다. 반드시 예외 처리를 해야 컴파일이 된다.

public class Thread implements Runnable {
    ...
    public static void sleep(long millis, int nanos) throws InterruptedException {
    ...
}

    public final synchronized void join(long millis) throws InterruptedException {
    ...
    }
    ...
}

19-2. Unchecked Exception

try ~ catch 구문을 사용하지 않아도 되는 예외이며 프로그램 실행도중 발생할 수 있는 예외들로 구성되어 있다.

RuntimeException

Unchecked Exception의 최상위 클래스로 Exception 클래스를 직접적으로 상속받은 클래스이지만, 특수하게 Runtime Exception을 상속 받는 예외들은 try ~ catch 구문에 대한 강제성이 없다.
(이유는 Exception 클래스의 트리 구조 사진 참고)

NullPointerException

public class Main {
    public static void main(String[] args) {
    	// 일부로 예외를 무한적으로 발생시켜도 에러로그만 쌓이지 프로그램 자체는 왠만해선 죽지는 않는다. (미약한 오류이기 때문에)
        while(true) {
            String s = null;
        	s.length(); // NullPointException - Unchecked Exception 이어서 예외를 발생시키는 옳지 못한 코드임에도 불구하고 빨간줄이 없다
        } 
    }
}

참조 자료형 변수에 인스턴스가 저장되어 있지 않고 null 값이 저장되어 있을 때, 인스턴스 메서드를 호출할 때, 또는 변수의 접근할 때 발생할 수 있다.

ClassCastException

class Warrior {
}

class SuperWarrior extends Warrior {
}

Warrior warrior = new Warrior();
SuperWarrior superWarrior = (SuperWarrior) warrior;
// 생성된 인스턴스는 Warrior 이므로 SuperWarrior로 형 변환시
// ClassCastException 발생

객체 형 변환 시 올바르지 않은 객체로 형 변환할 경우 발생한다.

19-3. 예외 메시지 출력

예를 들어 아래와 같은 try catch문의 Exception e에서 Exception은 변수의 클래스 타입이고, e는 변수이다.

Sample sample = new Sample();

try {
    sample.addSample(100);
    sample.printSample(); // 만일 이 메서드를 실행하는데 에러가 나버리면 !
    
} catch (Exception e) {
	// ... catch 문의 코드가 실행되고
} finally {
	 sample.shouldBeRun(); // 에러가 나든 안나든 무조건 finally 문은 실행된다.
}

그리고 해당 객체 변수 안에는 아래와 같이 에러 메시지를 출력하는 메서드를 제공한다.

  • printStackTrace(): 예외발생 당시의 호출 스택(Call Stack)에 있었던 메서드의 정보와 예외 메시지를 화면에 출력
  • getMessage(): 발생한 예외클래스의 인스턴스에 저장된 메시지를 얻을 수 있다.
try{
    ...
    System.out.println(0/0); // ArithmeticException 예외 발생
    ...
} catch(ArithmeticException e){ // e는 해당하는 에러의 예외정보가 담겨 있는 참조 변수 이다

	// 에러 메세지
    System.out.println(e.getMessage()); // by zero

	// 상세한 에러 추적 메세지
	e.printStackTrace(); // java.lang.ArithmeticException: / by zero at MyClass.main(MyClass.java:5)
}

19-4. throw vs throws

프로그램 상으로 에러가 아니라 하더라도 로직 상 개발자가 일부러 에러를 내서 로그에 기록하고 싶은 경우 throw 키워드를 사용할 수 있다.

지금까지 설명한 예외는 0으로 나누는 ArthimeticException이나 NULL 객체를 참조하는 NullPointerException과 같이 프로그램이 알아서 에러를 탐지하고 처리하였지만, 이번에는 사용자가 일부러 에러를 throw하여 에러를 catch한다는 의미로 보면 된다.

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        try {
            Scanner s = new Scanner(System.in);
            System.out.print("음수를 제외한 숫자만 입력하세요 : ");
            int num = s.nextInt(); // 사용자로부터 정수를 입력 받음
            if (num < 0) {
                // 만일 사용자가 말을 안듣고 음수를 입력하면 강제로 에러 발생 시켜버리기!!
                throw new ArithmeticException("왜 하지말라는 짓을 하시는 거죠? ㅡㅡ"); // ArithmeticException 예외 클래스 객체를 생성해 catch문으로 넘겨버린다고 생각하며 된다
            }
            System.out.println("음수를 입력하지 않으셨군요. 감사합니다");
        } catch (ArithmeticException e) {
            System.out.println(e.getMessage());
        } finally {
            System.out.println("프로그램을 종료합니다");
        }
    }
}

일반적으로 예외가 발생할 수 있는 코드를 작성할 때 try ~ catch 구문으로 처리하는 것이 가장 기본적이지만, 경우에 따라서는 다른 곳에서 예외를 처리하도록 호출한 곳으로 떠넘길 수 있다.

throws 키워드는 예외가 호출한 곳으로 떠넘기는 역할을 하며 메서드 선언부 끝에 작성한다.

public class Main {
    public static void main(String[] args) {
        try {
            method1();
            method2();
            method3();
        } catch (ClassNotFoundException | ArithmeticException | NullPointerException e) {
            System.out.println(e.getMessage());
        }
    }

    public static void method1() throws ClassNotFoundException {
        throw new ClassNotFoundException("에러이지롱");
    }

    public static void method2() throws ArithmeticException {
        throw new ArithmeticException("에러이지롱");
    }

    public static void method3() throws NullPointerException {
        throw new NullPointerException("에러이지롱");
    }
}

위 코드의 경우 method1이 호출되면 throw 키워드를 통해 ClassNotFoundException 예외가 발생하게 되고, 해당 예외 클래스는 maincatch 블록인 ClassNotFoundException으로 전달되어 코드를 실행하게 되는 것이다.

이를 확장하면 예외를 전달받은 메서드가 또 다시 자신을 호출한 메서드에게, 마치 연쇄적으로 전달이 가능하다. 이런 식으로 계속 호출 스택에 있는 메서드들을 따라가 전달하다가 제일 마지막에 있는 main메서드에서 throws를 사용하면 JVM에서 처리하게 된다.

19-5. Checked 예외를 Unchecked 예외로 변환

위에서 설명한 write 메서드의 경우 Chcecked 예외를 사용하도록 선언되어 있으므로 원칙적으로는 반드시 try ~ catch로 감싸주어야 컴파일이 가능하다.

public class Main {
	public static void main(String[] args) {
    	FileWriter file = new FileWriter("data.txt");
        // 컴파일 오류! 처리되지 않은 예외
        file.write("Hello World");
    }
}

따라서 항상 아래와 같이 작성해야 한다.

public class Main {
    public static void main(String[] args) {
        try {
            FileWriter file = new FileWriter("data.txt");
            file.write("Hello World");
        } catch (Exception e) {
            System.out.println(e.getMessage());
            e.printStackTrace();
        }
    }
}

근데 만약 try ~ catch가 필수적이지 않고, 코드 가독성도 떨어진다고 느낀다면 이를 Unchecked 예외로 변환할 수 있다.

class MyCheckedException extends Exception { ... } // checked excpetion

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

    public static void install() {
        throw new RuntimeException(new IOException("설치할 공간이 부족합니다."));
        // Checked 예외인 IOException을 Unchecked 예외인 RuntimeException으로 감싸 Unchecked 예외로 변신 시킨다
    }
}

처리해야할 Checked 예외를 Unchecked 예외인 RuntimeException()으로 감싸면 가능하다.

19-6. 예외 발생 확인법?

예외를 찾는 방법과 처리하는 방법은 매우 다양한데...
그 중 대표적인 예시만 들면 아래와 같다.

case1: 일부러 예외 발생시키기

int num = -1;

int[] array = new int[3];
System.out.println(array[num]);

코드를 실행해보면 ArrayIndexOutOfBoundsException 예외를 뿜어낸다.

따라서 이 내용을 근거로 아래와 같이 작성한다.

int num = -1;

int[] array = new int[3];
try {
    System.out.println(array[num]);
} catch (ArrayIndexOutOfBoundsException ex) {
    System.out.println("잘못된 인덱스입니다.");
}

case2: RuntimeException 클래스로 대응하기

모든 예외를 체크할 수 없으나 예외가 발생할 가능성이 높다고 판단할 경우 사용한다.

int num = -1;

int[] array = new int[3];
try {
    System.out.println(array[num]);
} catch (RuntimeException ex) {
    System.out.println("잘못된 인덱스입니다.");
}

RuntimeException 클래스는 프로그램 실행 도중 발생하는 예외와 관련된 슈퍼클래스이기 때문에 RuntimeException을 이용하여 예외에 대응할 수 있다.

case3: 커스텀 예외 만들기

이미 만들어진 예외를 사용하지 않고, 직접 예외를 적용하기 위해 예외 클래스를 만들 수 있다.

Unchecked Exception을 만들기 위한 Cumstom Exception

public class CustomException extends RuntimeException {
    public CustomException() {
        super("커스텀 예외다!");
    }
}

public class ExceptionTest {
    String name;

    public void setName(String name) {
        if (null == name) {
            throw new CustomException();
        }
    }
}

위 코드에서는 RuntimeException을 상속받았기 때문에 Unchecked Exception이 되며 try ~ catch 구문을 반드시 사용하지 않아도 된다.

Checked Exception을 만들기 위한 Cumstom Exception

public class CustomException extends Exception {
    // Exception 클래스를 상속받는다.

    public CustomException() {
        super("커스텀 예외다!");
    }
}

public class ExceptionTest {
    String name;

    public void setName(String name) throws CustomException {
        if (null == name) {
            throw new CustomException();
        }
    }
}

위 코드에서는 Exception을 상속받았고, 메서드에 throws 키워드를 이용하여 커스텀 클래스 예외를 명시하였기 때문에 Checked Exception이 되며 try ~ catch 구문을 반드시 사용해야한다.

profile
새싹 백엔드 개발자

0개의 댓글