김영한님의 자바 강의는 정말 갓갓이다 ...
이번에는 Java 중급1에 내용들을 정리하고 보충하였다.
나중에 다시 이 내용을 보며 복습을 기약하며 !
Object / 불변 객체 / String / 래퍼 클래스 / 열거형 / 날짜와 시간 / 내부 클래스 / 예외 처리
다 하나같이 꼭 알고 있어야 하는 것들 뿐이다.
java.lang 패키지
- 자바가 기본으로 제공하는 라이브러리 중 가장 기본이되는 것
- java.lang 패키지는 모든 자바 애플리케이션에 자동으로 import 됨
- 대표 클래스
- Object
- String
- Integer, Long, Double
- Class
- System
- 이 포스팅에서는 위의 내용들에 대해 주력으로 다룬다.
Object 클래스
- 자바에서 모든 클래스의 최상위 부모 클래스는 항상 Object 클래스이다.
public class Parent extends Object{
public void parentMethod(){
System.out.println("Parent.parentMethod");
}
}
- Object 클래스는 가장 최상위 클래스
- 자동으로 생성되는 클래스로 안쓰는 것을 권장
public class Child extends Parent{
public void childMethod(){
System.out.println("Child.childMethod");
}
}
public class ObjectMain {
public static void main(String[] args) {
Child child = new Child();
String string = child.toString();
System.out.println("string = " + string);
}
}
- child.toString() 을 호출하는 경우
- 본인 타입 탐색 → 부모(Parent) 타입 탐색 → 부모(Object) 타입 탐색
Object 클래스라는게 있다는게 너무 신기하다 !
Object 클래스가 최상위 부모 클래스인 이유가 뭘까 ?
- 공통 기능 제공
- toString, equals, getClass 등의 기능들은 모든 객체에게 꼭 필요한 기능
- 모든 객체가 이러한 메서드를 지원하므로 개발의 일관성 및 단순화
- 다형성의 기본 구현
- 부모는 자식을 담을 수 있음
- Object는 모든 클래스의 부모 클래서스
- 따라서 모든 객체를 참조 할 수 있음
Object 다형성
- Object 클래스는 모든 객체를 참조 할 수 있다.
public class ObjectExample {
public static void main(String[] args) {
Dog dog = new Dog();
Car car = new Car();
action(dog);
action(car);
}
public static void action(Object obj){
if(obj instanceof Dog dog){
dog.sound();
}else if(obj instanceof Car car){
car.move();
}
}
}
- 부모는 자식을 담을 수 있기때문에 Object 타입의 매개변수를 사용해도 된다.
- 어떤 객체든지 인자로 전달 가능
- 📌 하지만 자식의 메서드를 호출 할 때 문제가 발생한다.
- 오버라이딩 된게 있으면 오버라이딩 된 자식 메서드 호출 가능
- 오버라이딩 메서드는 항상 먼저 우선순위를 가짐 !
- 하지만 자식의 메서드가 없는 경우는 다운 캐스팅을하여 호출해야 함
- (지난 자바편에서 했던 내용들 !)
- 오버라이딩 된게 있으면 오버라이딩 된 자식 메서드 호출 가능
- 이럴 경우 다형적 참조를 할 수는 있지만 메서드 오버라이딩을 사용하지 못하는 다형성의 한계를 가진다.
- Object 다형성을 활용하기에는 한계가 있음
Object가 비록 최상위 부모 클래스이지만,
타입으로 받을 경우 다운 캐스팅 없이는 다형성 활용에 한계가 있다.
즉, Object 클래스가 만능이 아니라는 소리 !!
🚨 주의
public class AnimalSoundMain {
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
Animal caw = new Caw();
soundAll(dog);
soundAll(cat);
soundAll(caw);
}
public static void soundAll(Animal animal){
System.out.println("동물 소리 테스트 시작");
animal.sound();
System.out.println("동물 소리 테스트 종료");
}
}
- Object 클래스에서 바로 sound(), move() 메서드를 호출 못하는 이유는 Object 클래스에 해당 메서드가 없기 때문이다.
- 자식에 오버라이딩 메서드 있으면 그게 가장 우선 !!
toString( )
- Object.toString( ) 메서드는 객체의 정보를 문자열 형태로 제공
- 디버깅과 로깅에 유용하게 사용
public class Dog {
private String dogName;
private int age;
public Dog(String dogName, int age) {
this.dogName = dogName;
this.age = age;
}
@Override
public String toString() {
return "Dog{" +
"dogName='" + dogName + '\'' +
", age=" + age +
'}';
}
}
- 만일 이렇게 오버라이딩을 할 경우 자식의 toString이 출력 됨 (자식 오버라이드 메서드 우선 순위)
- toString은 오버라이딩해서도 자주 쓰인다.
Object와 OCP
public class ObjectPrinter {
public static void print(Object object){
String string = "객체 정보 출력" + object.toString();
System.out.println(string);
}
}
- 앞서 만든 ObjectPrinter 클래스는 Car 또는 Dog와 같은 구체적인 클래스를 사용하지 않음
- 추상적인 Object 클래스를 사용 (Object 클래스에 의존)
- 즉. ObjectPrinter 클래스는 구체적인 것이 아니라 추상적인 것에 의존
- 추상에 의존한다는게 정말 중요함
- 이러한 구조가 다형성을 엄청 잘 활용
- print 메소드에 세상의 모든 객체 인스턴스 인수로 받을 수 있음 (다형적 참조)
- 구체적인 클래스는 toString( ) 메서드 오버라이딩 가능
- ObjectPrinter (클라이언트) 코드 변경 없이 무한 확장 가능 (OCP 원칙 충족)
- Java에 쓰는 println도 ObejctPrinter 클래스의 메서드 print와 작동 방식 비슷함
- println 또한 어떤 인자를 넘겨도 이러한 원리 때문에 출력 가능 (신기한 거임 !!)
- println에서는 toString 사용하는게 있어서, println 사용시 toString 호출된다.
equals( ) - 동일성과 동등성
동일성 (Identity)
- == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인
- 완전히 일치해야 한다.
동등성 (Equality)
- equals( ) 메서드를 사용하여 두 객체가 논리적으로 동등한지 확인
- equals( )를 오버라이딩해서 동등의 기준 선택 가능
- 어떤 클래스는 연락처 기반으로, 어떤 클래스는 이름 기반으로 동등성을 나눌 수 있다
@Override
public boolean equals(Object object) {
if (object == null || getClass() != object.getClass()) return false;
UserV2 userV2 = (UserV2) object;
return Objects.equals(id, userV2.id);
}
- 따라서 동등성 비교를 하고 싶으면 equals()를 재정의(오버라이딩) 해야 함
- 저 코드는 IDE가 해결해주니까 외울 필요 없음 !
- 근데 getClass() 메서드는 생각보다 자주 쓰이니 기억해두자.
- equals는 필요할 때 만들면 된다.
"동일"은 완전히 같음을 의미하고, "동등"은 같은 수준을 의미한다.
즉, 동등성이라는 개념은 각각의 클래스마다 다르다 !!
불변 객체
다시 한번 떠올리며
"자바는 항상 값을 복사해서 대입한다"
기존문제
Address a = new Address("서울");
Address b = a;
- 이렇게 할 경우 Side Effect 발생 할 수 있음
- 하지만 참조값 공유를 막을 수 있는 방법이 없음 (객체의 공유를 막을 수 있는 방법이 없음)
공유 참조로 인해 발생하는 문제를 어떻게 해결할 수 있을까 ?
(개발자가 공유 참조 문제가 발생하지 않도록 완벽하게 코드 작성 할 수는 없다 ! )
불변 객체 도입
- 문제의 직접적인 원인은 공유된 객체의 값을 변경한 것에 있다.
- 불변 객체 (Immutable Object)는 객체의 상태 (내부 값, 필드)가 변하지 않는 객체
public class ImmutableAddress {
private final String value;
public ImmutableAddress(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public String toString() {
return "Address{" +
"value='" + value + '\'' +
'}';
}
}
- 해당 코드처럼 value를 final로 선언하면 값 변경이 불가능 (생각보다 단순하게 불변 클래스 구현)
- 불변 객체의 값을 변경하고 싶다면 새로운 불변 객체를 생성해야 함 (핵심)
불변 객체 변경
public class ImmutableObj {
private final int value;
public ImmutableObj(int value) {
this.value = value;
}
public ImmutableObj add(int addValue) {
int result = value + addValue;
return new ImmutableObj(result);
}
}
public int getValue() {
return value;
- 이런식으로 add 메소드에 ImmutableObj 객체를 새로 생성하고 return하면 불변 객체를 변경 할 수 있음
public class ImmutableMain1 {
public static void main(String[] args) {
ImmutableObj obj1 = new ImmutableObj(10);
ImmutableObj obj2 = obj1.add(20);
//계산 이후에도 기존값과 신규값 모두 확인 가능
System.out.println("obj1 = " + obj1.getValue());
System.out.println("obj2 = " + obj2.getValue());
}
}
- 불변 객체는 보통 객체를 새로 만들어서 반환하기 때문에 꼭 반환값을 받아야 한다.
// 실행 결과
obj1 = 10
obj2 = 30
- 일반적으로 불변 객체에서 값을 변경하는 경우 withXxx() 처럼 이름을 만들어 줌
- ex) withYear() ← 이런식으로 이름을 지어주면 좋음 !
- withXxx 로 이름을 지어진 경우, 원복 객체의 상태가 그대로 유지됨을 강조하고 변경사항을 새 복사본에 포함하는 과정을 간결하게 표현
불변 객체 정리
- 불변이라는 단순한 제약을 사용해서 사이드 이펙트라는 큰 문제를 막을 수 있음 ( 참조 공유 시 데이터 변경 문제)
- 캐시 안정성 / 멀티 쓰레드 안정성 / 엔티티의 값 타입 등의 불변 설계의 이점이 있음
- 자바에서 가장 많이 사용되는 String 클래스가 불변 객체
- 자바가 기본으로 제공하는 Integer, LocalDate 등 수 많은 클래스가 불변으로 설계
- 하지만 모든 클래스를 불변으로 만드는 것은 아님
왠지모르게 싱글톤 방식이랑 비슷하게 느껴지지만, 완전히 다른 개념이다.
싱글톤 방식은 하나의 인스턴스만 가지고 상태 변경이 가능하지만,
불변 객체는 여러개의 객체가 존재하지만 상태가 변하지 않는 객체 !!
String 클래스
- String은 int,bolean 같은 기본형이 아니라 참조형인 클래스이다.
String str1 = "hello"; //기존
String str1 = new String("hello"); //변경
- 원래는 이렇게 써야하는데 자바에서 편의상 " " 로 처리
String 클래스 구조
public final class String {
//문자열 보관
private final char[] value;// 자바 9 이전
private final byte[] value;// 자바 9 이후
//여러 메서드
public String concat(String str) {...}
public int length() {...}
...
}
- 대략 이렇게 생겼음
- 다양한 메서드 종류가 있다
- lenght, charAt, indexOf, replace, valueOf 등등
String a = "hello";
String b = " java";
String result1 = a.concat(b);
String result2 = a + b;
String 클래스 비교
- String 클래스를 비교할 때는 == 비교가 아닌 equals() 비교를 해야 한다.
- String에서 equals는 String 클래스 맞춤으로 오버라이딩 이미 되어있음
String str3 = "hello";
String str4 = "hello";
System.out.println("리터럴 == 비교: " + (str3 == str4));
System.out.println("리터럴 equals 비교: " + (str3.equals(str4)))
리터럴 == 비교: true
리터럴 equals 비교: true
그렇다면 "==" 을 해도 같은 값이라 하는 이유는 뭘까 ?
- 자바는 메모리 효율성과 성능 최적화를 위해 문자열 풀을 사용한다.
- 자바가 실행되는 시점에 클래스에 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 생성
- 이때 같은 문자열이 있으면 만들지 않는다.
Pool 이란 ??
- 공유 자원을 모아둔 곳
- 여러곳에서 함께 사용할 수 있는 객체를 필요할 때마다 생성,제거하는 것은 비효율적
- 따라서 이렇게 풀에 필요한 것들을 미리 만들어두고 여러곳에서 재사용하여 성능, 메모리 최적화
- 참고로 풀은 힙 영역을 사용하고, 문자를 찾을 때는 해시 알고리즘을 사용한다.
String 클래스는 불변 객체
- String은 불변 객체이다.
public class StringImmutable1 {
public static void main(String[] args) {
String str = "hello";
str.concat(" java");
System.out.println("str = " + str);
}
}
str = hello
- 위와 같이 했을때 "hello"와 " java" 문자열이 합쳐지지 않았다.
- 자칫하면 놓치기 쉽다... hello와 java 문자열을 합치는 것은 새로운 객체를 문자열 풀에 만드는 것
- 이유는 String이 불변 객체이기 때문이다.
- 불변 객체 같은 경우, 값 변경이 안되기 떄문에 새로운 객체를 반환한다.
- 위의 코드에서는 반환된 값을 받지 않기때문에 합쳐지지 않는 것이다.
- 문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면, 사이드 이펙트 문제가 발생해서 String을 불변으로 설계 됨
String은 참조형으로 클래스이고, 불변 객체이다.
따라서 값을 바꾸려면 새로운 객체를 생성해야 한다.
StringBuilder - 가변 String
불변인 String 클래스는 사용되지 않는 것들에 대해 새로운 객체들 또한 계속 생성해야 한다는 단점이 있다. 문자를 자주 더하거나 변경해야 하는 상황이면 더 많은 String 객체를 만들고, GC를 해야 하고, 결과적으로 CPU, 메모리 자원을 더 많이 사용하게 된다.
StringBuilder 클래스를 사용하면 가변적으로 효율성을 볼 수 있다.
public final class StringBuilder {
char[] value;// 자바 9 이전
byte[] value;// 자바 9 이후
//여러 메서드
public StringBuilder append(String str) {...}
public int length() {...}
...
}
- StringBuilder는 가변 String을 제공한다. (가변의 경우 사이드 이펙트 주의)
public class StringBuilderMain1_1 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append("A");
sb.append("B");
sb.append("C");
sb.append("D");
System.out.println("sb = " + sb);
sb.insert(4, "Java");
System.out.println("insert = " + sb);
sb.delete(4, 8);
System.out.println("delete = " + sb);
sb.reverse();
System.out.println("reverse = " + sb);
String string = sb.toString();
System.out.println("string = " + string);
}
}
- StringBuilder 사용 예시 (SrtringBuilder는 클래스)
- 문자열의 값이 자주 바뀌므로 처음에는 StringBuilder를 사용하고 후, 결과 기반으로 String을 생성해서 반환
- 문자열 추가, 삭제, 수정에 새로운 객체를 생성할 필요가 없음
- 이로인해 메모리 사용을 줄이고 성능 향상시킬 수 있음
- 보통 문자열 변경하는 동안만 사용하고, 변경 끝나면 안전한 String으로 변환
String은 불변이여서 잘 안쓰는 문자열도 불변 객체로 계속 생성해야 한다.
잠깐 쓰는 것들은 StringBuilder를 통해 해결하자!
메서드 체이닝
- Method Chaning
public class ValueAdder {
private int value;
public ValueAdder add(int addValue) {
value += addValue;
return this;
}
public int getValue() {
return value;
}
}
- ValueAdder 타입의 add 메서드 반환형 주목
- 해당 메서드는 자기 자신을 반환 함
public static void main(String[] args) {
ValueAdder adder = new ValueAdder();
int result = adder.add(1).add(2).add(3).getValue();
System.out.println("result = " + result);
}
//
adder.add(1).add(2).add(3).getValue()
x001.add(1).add(2).add(3).getValue()
x001.add(2).add(3).getValue()
x001.add(3).getValue()
x001.getValue()
- ValueAdder 타입의 add 메서드가 자기 자신을 반환하여 메서드 체이닝 가능
- 반환형이 자기 자신이므로, 반환된 참조값을 사용해 메서드 호출을 계속 이어갈 수 있음
StringBuilder 클래스는 메서드 체이닝 기법 제공한다.
StringBuilder sb = new StringBuilder();
String string = sb.append("A").append("B").append("C").append("D")
.insert(4, "Java")
.delete(4, 8)
.reverse()
.toString();
래퍼 클래스
기본형의 한계
- 자바는 객체지향 언어이지만, int나 double 같은 기본형은 객체가 아니다.
- 기본형의 한계
- 객체가 아니기 때문에, 객체 지향 프로그래밍의 장점을 못살림
- null 값을 가질 수 없음 (때로는 데이터가 "없음"이라는 상태를 나타내야 할 필요가 있음)
자바 래퍼 클래스
위와 같은 문제로 자바는 기본형에 대응하는 래퍼 클래스를 기본으로 제공
- byte → Byte
- short → Short
- int → Integer
- long → Long
- boolean → Boolean
- 기타 등등
기본 래퍼 클래스의 특징은 불변이고, 값을 equals로 비교해야 한다 !
//미래에 삭제 예정, 대신에 valueOf() 사용
Integer newInteger = new Integer(10);
Integer integerObj = Integer.valueOf(10);
Long longObj = Long.valueOf(100);
Double doubleObj = Double.valueOf(10.5);
System.out.println("integerObj = " + integerObj);
System.out.println("longObj = " + longObj);
System.out.println("doubleObj = " + doubleObj);
System.out.println("내부 값 읽기");
int intValue = integerObj.intValue();
System.out.println("intValue = " + intValue);
long longValue = longObj.longValue();
System.out.println("longObj = " + longValue);
System.out.println("비교");
System.out.println("==: " + (newInteger == integerObj));
System.out.println("equals: " + newInteger.equals(integerObj));
- (참고) 보통 이렇게 값은 넣는데 valueOf를 사용
오토 박싱 & 오토 언박싱
int value = 7;
Integer boxedValue = value;
int unboxedValue = boxedValue;
- 박싱은 기본형 → 객체형으로 감싸는 것
- 기본형을 래퍼 클래스, 래퍼 클래스를 기본형으로 변환하는 일이 자주 발생
- 따라서 자바 컴파일러가 개발자 대신 박싱과 언박싱을 제공
래퍼 클래스의 주요 메서드
- valueOf( )
- parseInt( ) → 문자열을 기본형으로 변환
- compareTo( )
- sum( ), min( ), max( )
래퍼 클래스 성능
- 래퍼 클래스는 객체이기 떄문에 기본형보다 더 다양한 기능 제공
- 기본형 연산이 래퍼 클래스보다 더 빠르기는 하지만, 사실 이거는 사막의 모래알 하나 정도의 차이
- 따라서 CPU 연산을 아주 많이 수행하는 특수한 경우는 기본형을 사용해 최적화를 하고, 일반적인 경우라면 코드를 유지보수 측면에서 래퍼 클래스를 사용하는 것이 더 나은 것 선택
유지보수 vs 최적화의 상황이 있다면, 유지보수하기 좋은 코드를 먼저 고민하는 것이 좋다 !
(최신 컴퓨터는 매우 빠르기 때문에 메모리 상에서 발생하는 연산 몇 번 줄인다고해서 큰 도움이 되지 않음)
결론은 왠만하면 래퍼 클래스 사용하자 !!
Class 클래스
- 자바에서 Class 클래스는 클래스의 정보(메타 데이트)를 다루는데 사용
- 필요한 클래스의 속성과 메서드에 대한 정보를 조회 및 조작 가능
- 주요 기능
- 타입 정보 얻기 (클래스 이름, 슈퍼클래스, 인터페이스 접근 제한자 등)
- 리플랙션 (클래스에 정의된 메서드, 필드, 생자 등을 조회 및 객체 생성도 가능)
- 동적 로딩과 생성 (Class.forName() 메서드로 클래스 동적 생성 가능)
- 애노테이션 처리 (클래스에 적용된 애노테이션 조회 및 처리 가능)
Class 객체는 주로 리플렉션 기능을 위해 사용되며,
특정 클래스의 이름, 필드, 메서드, 생성자 같은 구조를
코드로 다룰 수 있게 해주는 클래스이다.
System 클래스
- System.in, System.out, System.err은 각각 표준 입력, 표준 출력, 표준 오류 스트림을 나타냄
- System.currentTimeMillis() 는 현재 시간을 제공
- System.getenv(), System.getProperties(), System.getProperty() 같은 메서드도 있음
- System.arraycopy는 시스템 레벨에서 최적화된 메모리 복사 연산 사용
Math, Random 클래스
필요한 부분은 검색하면서 API 문서 찾아보자 !
열겨형 (ENUM)
열거형 enum은 enumeration의 약자로, 여러 항목을 차례로 나열한 것을 의미한다.
String 사용 시 타입 안정성 부족 문제 발생
- 값의 제한 부족
- String으로 상태나 카테고리를 표현하면, 잘못된 문자열 실수로 입력할가능성 존재
- 컴파일 시 오류 감지 불가
- 이러한 잘못된 값은 컴파일 시에는 감지되지 않고, 런타임에서만 문제가 발경되어 디버깅이 어려움
- String은 어떤 문자열이든 받을 수 있기 때문에 자바 문법 관점에서는 문제 없음
타입 안전 열거형 패턴의 장점 ( Type Safe Enum Pattern)
- 타입 안정성 향상
- 정해진 객체만 사용할 수 있어, 잘못된 값을 입력하는 문제 근복적으로 방지
- 데이터 일관성
- 정해진 객체만 사용하므로 데이터의 일관성 보장
- 제한된 인스턴스 생성
- 클래스는 사전에 정의된 몇 개의 인스턴스만 생성하고, 외부에서는 이 인스턴스들만 사용 할 수 있도록 하여 미리 정의된 값들만 사용하도록 보장
- 타입 안정성
- 잘못된 값이 할당되거나 사용되는 것을 컴파일 시점에 방지 가능
열거형 - Enum Type
- enum은 enumeration의 줄임말로, 어떤 항목을 나열하는 것을 의미
public enum Grade {
BASIC, GOLD, DIAMOND
}
- class 대신에 enum 키워드 사용
- 이렇게 쉽게 원하는 상수의 이름을 나열
enum을 클래스로 구현
public class Grade extends Enum {
public static final Grade BASIC = new Grade();
public static final Grade GOLD = new Grade();
public static final Grade DIAMOND = new Grade();
//private 생성자 추가
private Grade() {}
}
- enum을 사용하는 것이 class에서 구현하는 것보다 비교도 안되게 편리
- 참고로 열거형도 클래스이다.
- 또한 열거형은 자동으로 java.lang.Enum을 상속 받음
- 상속을 받았기 떄문에 추가로 다른 클래스 상속 받을 수 없음
Grade myGrade = new Grade(); //enum 생성 불가
- 바로 위의 코드처럼 외부에서 임의로 생성 불가능
- 열거형은 인터페이스를 구현 할 수 잇음
- 열거형에 추상 메서드 선언 및 구현 가능
열거형의 장점
- 타입 안정성 향상 (미리 정의된 상수들로만 구성되어, 컴파일 오류 발생 (런타임 오류가 최악))
- 간결성 및 일관성 (열거형을 사용하여 코드가 더 간결 및 명확)
- 확장성 ( 새로운 타입 추가 하고 싶을 때에 새로운 상수 추가)
열거형의 주요 메서드
- values ( ) → 모든 ENUM 상수를 포함하는 배열 반환
- name ( ) → 현재 상수 이름 출력
- valueOf ( )
이 외의 여러 메서드들이 있는데 자세한거는 API 문서를 참조하자.
단, ordinal() 상수의 선언 순서를 반환하는 메서드는 사용하지말자 ... (선언 순서 뒤바뀜 주의)
String으로 대체 가능하지만,
타입 안전성 및 확장성, 편리성 등을 위해
열거형의 존재가 필요하다.
열거형 - 리팩토링
public enum Grade {
BASIC(10),GOLD(20),DIAMOND(30);
private final int discountPercent;
Grade(int discountPercent) {
this.discountPercent = discountPercent;
}
public int getDiscountPercent(){
return discountPercent;
}
}
- discountPercent 필드를 추가하고, 생성자를 통해 필드에 값을 저장한다.
- 열거형은 상수로 지정하는 것 외에 일반적인 방법으로는 생성 불가능
- 따라서 생성자에 굳이 접근제어자를 선언할 필요 없음 (private이라 생각)
- BASIC(10)과 같이 방식으로 생성자에 맞는 인수를 전달하여 적절한 생성자가 호출된다.
- 값 조회를 위해 getDiscountPercent( ) 메서드를 추가 (열거형도 클래스이므로 메서드 추가 가능)
개발에서 문제 발생하지 않기 위해 단지 조심한다고 되는게 아니다.
애초에 제약같은 것을 통해 컴파일 에러로 해결 될 수 있도록 하는게 좋다.
열거형도 제약을 걸어서 제약 이외의 값은 애초에 컴파일이 안되게 원천 차단한다 !
날짜와 시간
- 자바 날짜와 시간 라이브러리는 자바 공식 문서가 제공하는 위의 표 하나로 정리 할 수 있다.
기본 날짜와 시간 - LocalDateTime
- 대부분의 국내 개발자는 Local Date를 씀
- 이거를 잘 이해하는게 중요함 !
LocalDate
LocalDate nowDate = LocalDate.now();
LocalDate ofDate = LocalDate.of(2025,2,22);
System.out.println("오늘의 날짜 : " + nowDate );
System.out.println("지정 날짜 : " + ofDate);
// 불변 데이터이기 때문에 반환값 받아줘야 함
ofDate = ofDate.plusDays(10);
System.out.println("지정 날짜 +10d : " + ofDate);
LocalTime
LocalTime nowTime = LocalTime.now();
LocalTime ofTime = LocalTime.of(12,34,56,4556);
System.out.println("오늘의 시간 : " + nowTime );
System.out.println("지정 시간 : " + ofTime);
ofTime = ofTime.plusSeconds(30);
System.out.println("지정 시간 +30s : " + ofTime);
LocalDateTime
LocalDateTime nowDt = LocalDateTime.now();
LocalDateTime ofDt = LocalDateTime.of(1997,12,11,10,20,40);
System.out.println("오늘의 날짜 : " + nowDt );
System.out.println("지정 날짜 : " + ofDt);
System.out.println();
// 이렇게 LocalDate과 LocalTime으로도 나눌 수 있음 (잘 짜여진 설계)
LocalDate localDate = ofDt.toLocalDate();
LocalTime localTime = ofDt.toLocalTime();
System.out.println("localDate = " + localDate);
System.out.println("localTime = " + localTime);
System.out.println();
LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);
System.out.println("localDateTime = " + localDateTime);
System.out.println();
// 불변 데이터이기 때문에 반환값 받아줘야 함
ofDt = ofDt.plusYears(20);
System.out.println("지정 날짜 +20Y : " + ofDt);
System.out.println();
System.out.println("현재 날짜시간이 지정 날짜 시간보다 이전인가 ? " + nowDt.isBefore(ofDt));
System.out.println("현재 날짜시간이 지정 날짜 시간보다 이후인가 ? " + nowDt.isAfter(ofDt));
System.out.println("현재 날짜시간이 지정 날짜 시간과 같은가? " + nowDt.isEqual(ofDt));
- isEqual() 과 equals()의 차이
- isEqual은 단순히 비교 대상이 시간적으로 같으면 true 반환
- 서울의 9시와 UTC의 0시는 시간적으로 같아서 true 반환
- equals는 모든 구성 요소가 같아야 true 반환
- 서울의 9시와 UTC의 0시는 타임존의 데이터가 다르기 때문에 false 반환
- isEqual은 단순히 비교 대상이 시간적으로 같으면 true 반환
타임존 - ZonedDateTime
- "Asia/Seoul" 같이 타임존 안에 일광 절약 시간제에 대한 정보와 UTC+9:00과 같은 UTC로 부터 시간 차이인 오프셋 정보 포함
- ZonedDateTime은 LocalDateTime에 ZoneId가 합쳐진 것
- ex) 2024-02-09T12:02:13.457712+09:00[Asia/Seoul]
OffsetDateTime
- OffsetDateTime은 LocalDateTime에 ZoneOffset이 합쳐진 것
- 타임존은 없고, UTC로부터 시간대 차이인 고정된 오프셋만 포함
- ex) nowOdt = 2024-02-13T15:03:36.422230+09:00
ZonedDateTime은 구체적인 지역 시간대를 다룰 때 사용하며, 일광 절약 시간을 자동으로 처리한다.
OffsetDateTime은 UTC와의 시간 차이만을 나타낼 때 사용하며, 지역 시간대의 복잡성을 고려하지 않는다.
근데 사실 ZonedDateTime이나 OffsetDateTime는 글로벌 서비스를 하지 않으면 잘 사용하지 않는다.
기계 중심의 시간 - Instant
- Instant는 UTC를 기준으로 하는 시간의 한 지점
- 1970년 1월 1일 0시 0분 0초를 기준으로 경과한 시간으로 계산
- 장점
- 시간대 독립성 (UTC 기준으로 하여, 전 세계 어디서나 동일한 시점)
- 고정된 기준점 (시간 계산 및 비교가 명확)
- 단점
- 사용자 친화적이지 않음 (기계적인 시간처리에는 적합, 읽고 이해하기에는 직관적이지 않음)
- 시간대 정보 부재 (특정 지역의 날짜와 시간으로 변환하려면 추가 작업 필요)
- 사용 예시
- 전 세계적인 시간 기준 필요 시
- 시간대 변환 없이 시간 계산 필요 시
- 데이터 저장 및 교환
기간, 시간의 간격 - Duration, Period
Period
Period period = Period.ofDays(10);
System.out.println("period = " + period);
LocalDate currentDate = LocalDate.of(2030,1,1);
LocalDate plusDate = currentDate.plus(period);
System.out.println("currentDate = " + currentDate);
System.out.println("plusDate = " + plusDate);
LocalDate startDate = LocalDate.of(2030,1,1);
LocalDate endDate = LocalDate.of(2030,4,2);
Period between = Period.between(startDate,endDate);
System.out.println("between = " + between) ;
System.out.println(between.getYears() + "년 " + between.getMonths() + "월 " + between.getDays() + "일");
- 두 날짜 사이의 간격을 년,월,일 단위로 나타낸다.
- 주요 메서드
- getYears(), getMonths(), getDays()
Duration
Duration duration = Duration.ofMinutes(30);
System.out.println("duration = " + duration);
LocalTime lt = LocalTime.of(1,0);
System.out.println("lt = " + lt);
LocalTime plusTime = lt.plus(duration);
System.out.println("plusTime = " + plusTime);
LocalTime start = LocalTime.of(9,0);
LocalTime end = LocalTime.of(10,0);
Duration between = Duration.between(start,end);
System.out.println("between = " + between);
System.out.println("차이 : " + between.getSeconds() + "초 ");
System.out.println("근무 시간 : " + between.toHours()+ "시간 " + between.toMinutesPart() + "분");
- 두 시간 사이의 간격을 시,분,초 단위로 나타낸다.
- 주요 메서드
- toHours(), toMinutes(), getSeconds(), getNano()
- get이 붙은거는 duration이 원래 가지고 있는 것을 의미
- to는 get과는 다르게 기존 것을 이용해서 만든 것을 의미
날짜와 시간의 핵심 인터페이스
- 특정 시점의 시간
- LocalDateTime, LocalDate, LocalTime, ZonedDateTime, OffDateTime, Instant 등
- 시간의 간격
- 시간 간격이란 시간의 양 또는 기간을 나타 냄
- 날짜와 시간 객체에 적용하여 그 객체 조정 가능
- Period, Duraition 등
특정 시점 시간/시간 간격은 서로 표현하는게 다르고,
각각의 인터페이스 또한 다르다.
시간 단위와 시간 필드
시간의 단위 (TemporalUnit, ChronoUnit)
System.out.println(ChronoUnit.HOURS);
System.out.println(ChronoUnit.HOURS.getDuration().getSeconds());
System.out.println(ChronoUnit.DAYS);
System.out.println(ChronoUnit.DAYS.getDuration().getSeconds());
LocalTime lt1 = LocalTime.of(1,10,0);
LocalTime lt2 = LocalTime.of(1,20,0);
long secondBetween = ChronoUnit.SECONDS.between(lt1, lt2);
System.out.println("secondBetween = " + secondBetween);
long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);
System.out.println("minutesBetween = " + minutesBetween);
- 날짜와 시간을 측정하는 단위를 나타내는데 사용
- java.time.temporal.ChronoUnit 열거형으로 구현
- ChronoUnit은 다양한 시간 단위를 제공
- Duration 클래스는 Duration 타입으로 반환되지만, ChronoUnit은 간단한 시간 차이 계산으로 적절
시간 필드 (ChronoField)
System.out.println(ChronoField.MONTH_OF_YEAR.range());
System.out.println(ChronoField.DAY_OF_MONTH.range());
- 날짜와 시간의 특정 부분을 나타낸다.
- java.time.temporal.ChronoField 열거형으로 구현
- 여기서 필드라는 뜻이 날짜와 시간 중에 있는 특정 필드들을 뜻한다.
- 시간의 단위 하나하나를 뜻하는 ChoronoUnit과는 다름
- ChronoField를 사용해야 날짜와 시간의 각 필드 중 원하는 데이터 조회 가능
ChronoUnit이나 ChronoField는 보통 단독으로 사용하지 않고,
주로 날짜와 시간을 조회하거나 조작할 때 사용한다 !
날짜와 시간 조회
- 날짜와 시간을 조회하려면 날짜와 시간 항목중 어떤 필드를 조회할 지 선택해야 함
- 이때 시간의 필드를 뜻하는 ChronoField가 사용
System.out.println("YEAR = " + dt.get(ChronoField.YEAR));
System.out.println("YEAR = " + dt.getYear());
- .get(TemporalField field)를 사용하면 코드가 길어지고 번거로움
- 때문에 간편 메서드 getDayOfMonth 같은 메서드 제공
날짜와 시간 조작
- 날짜와 시간을 조작하려면 어떤 시간 단위(Unit)을 변경할 지 선택
- 여기에 ChronoUnit이 사용됨
LocalDateTime plusDt1 = dt.plus(10, ChronoUnit.YEARS);
System.out.println("plusDt1 = " + plusDt1);
LocalDateTime plusDt2 = dt.plusYears(10);
System.out.println("plusDt2 = " + plusDt2);
Period period = Period.ofYears(10);
LocalDateTime plusDt3 = dt.plus(period);
System.out.println("plusDt3 = " + plusDt3);
- plus(long amountToAdd, TemporalUnit unit)으로 조작
- 하지만 다양한 방법으로 조작 가능
- 여기서도 간편 메서드 plusYears 같은 메서드 제공
- 불변이므로 당연히 반환값 받아야 함
TemporalAccessor.get( ), Temporal.plus( ) 와 같은 인터페이스를 통해
특정 구현 클래스와 무관하게 일관성 있는 시간 조회, 조작 기능 제공 !
with( ) 메서드
dt.with(ChronoField.YEAR, 2020)
// 편의 메서드
dt.withYear(2020)
- Temporal.with()를 사용해 날짜와 시간의 특정 필드의 값만 변경 가능
- 자주 사용하는 메서드는 편의 메서드도 제공
- 다음주 금요일과 같은 복잡한 날짜 계산을 하고 싶으면 TemporalAdjuster를 사용 !
DayOfWeek
- 월,화,수,목,금,토,일을 나타내는 열거
날짜와 시간 문자열 파싱과 포매팅
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
// 포맷팅 : 날짜를 문자로
LocalDate date = LocalDate.of(2024,12,31);
String formattedDate = date.format(formatter);
System.out.println("formattedDate = " + formattedDate);
// 파싱 : 문자를 날짜로
String input = "2030년 01월 01일";
LocalDate parseDate = LocalDate.parse(input, formatter);
System.out.println("문자열 파싱 날짜와 시간 : " + parseDate);
- 포맷팅은 날짜와 시간 데이터를 원하는 포맷의 문자열로 변경
- 파싱은 문자열을 날짜와 시간 데이터로 변경
- 날짜 객체를 원하는 형태의 문자로 변경하려면 DateTimeFormater를 사용하면 된다.
- ofPattern( ) 으로 원하는 포맷을 지정
날짜와 시간에서 주의있게 봐야 할 부분은 타입인 것 같다.
주로 사용하는 타입들 LocalDateTime, Period, Duration 같은 것을 잘 익혀두면 될 것 같다.
당연히 외우지는 못하고... 나중에 이를 기반으로 찾을 수 있도록 !
어느정도 기본기들 잡고 검색하면 더 효율적일 것이다.
중첩 클래스, 내부 클래스
중첩 클래스의 분류
- 중첩 클래스는 총 4가지가 있고, 크게 2가지로 분류할 수 있다.
- 정적 중첩 클래스 → 정적 변수와 같은 위치
- 내부 클래스
- 내부 클래스 → 인스턴스 변수와 같은 위치
- 지역 클래스 → 지역 변수와 같은 위치
- 익명 클래스
- 변수 선언 위치
- 정적 변수 (클래스 변수)
- 인스턴스 변수
- 지역 변수
- 중첩이랑 내부라는 말은 다르다.
- 중첩(Nested) : 나의 안에 있지만 내것이 아닌 것
- 내부(Inner) : 나의 내부에 있는 나를 구성하는 요소
- 그리고 중첩 클래스는 바깥 클래서 선언이 꼭 필요!
용어 정리
- 중첩 클래스 : 정적 중첩 클래스 + 내부 클래스 모든 종류
- 정적 중첩 클래스 : 정적 중첩 클래스
- 내부 클래스 : 내부 클래스, 지역 클래스. 익명 클래스
중첩 클래스를 언제 사용해야 하나?
- 내부 클래스를 포함한 모든 중첩 클래스가 다른 하나의 클래스 안에서 아주 긴밀하게 연결되어 있는 경우
- 외부의 여러 클래스가 특정 중첩 클래스를 사용한다면 중첩 클래스로 만들면 안된다.
- 꼭 필요할 때만 사용 !!
중첩 클래스를 사용하는 이유
- 논리적 그룹화 : 특정 클래스가 다른 하나의 클래스 안에서만 사용되는 경우
- 캡슐화 : 중첩 클래스는 바깥 클래스의 private 멤버에 접근 가능하여 긴민하게 연결 가능
정적 중첩 클래스
public class NestedOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
static class Nested{
private int nestedInstanceValue = 1;
public void print(){
// 바깥 클래스의 인스턴스 멤버에는 접근할 수 없다.
// System.out.println(outInstanceValue);
System.out.println(nestedInstanceValue);
System.out.println(outClassValue);
}
}
}
- Nested 클래스는 당연히 밖의 인스턴스 outInstanceValue에 접근 안된다.
- 갑자기 헷갈렸는데, static은 메서드 영역에, 그리고 outerInstanceValue 같은 것은 인스턴스 영역에 있기 때문이다.
- NestedOuter.Nested와 같이 바깥 클래스.중첩클래스로 접근 가능
- 정적 중첩 클래스는 바깥 클래스의 정적 필드에 접근 가능 (사실 static이므로 다른 애들도 접근 가능)
- 하지만 바깥 클래스가 만든 인스턴스 필드에는 접근 불가능
class NestedOuter{
}
class Nested{
}
- 정적 중첩 클래스는 다른 클래스를 그냥 중첩해 둔 것일 뿐, 둘은 아무런 관계가 없음
- 그냥 클래스 2개를 따로 만든것과 같음
정적 중첩 클래스가 필요한 예시
// NetworkMessage
public class NetworkMessage {
private String content;
public NetworkMessage(String content) {
this.content = content;
}
public void print() {
System.out.println(content);
}
}
- NetworkMessage 클래스는 단순 생성하고 출력하는 기능 제공
// Network
public class Network {
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
networkMessage.print();
}
}
public class NetworkMain {
public static void main(String[] args) {
Network network = new Network();
network.sendMessage("hello java");
}
}
- 위와 같은 경우, 해당 패키지를 열어본 개발자는 "Network와 NetworkMessage를 둘다 사용해야 하나 ?" 라고 생각할 수 있다.
중첩 클래스 리팩토링
// Network 리팩토링
public class Network {
public void sendMessage(String text){
NetworkMessage networkMessage = new NetworkMessage(text);
networkMessage.print();
}
private static class NetworkMessage {
private String content;
public NetworkMessage(String content) {
this.content = content;
}
public void print(){
System.out.println(content);
}
}
}
- NetworkMessage 클래스를 Network 클래스 안에 중첩했다.
- 이렇게 하면 다른 개발자가 Network 클래스만 확인하면 되고, 안에 중첩 클래스의 의도를 파악할 수 있다.
- 주로 특정 클래스의 유틸리티 역할에 유용
내부 클래스
// InnerOuter
public class InnerOuter {
private static int outClassValue = 1;
private int outInstanceValue =2;
class Inner {
private int innerInstanceValue = 3;
public void print(){
// 자신의 멤버에 접근
System.out.println(innerInstanceValue);
// 외부 클래스의 인스턴스 멤버에 접근 가능, private도 접근 가능
System.out.println(outInstanceValue);
// 외부 클래스의 클래스 멤버에는 접근 가능. private도 접근 가능
System.out.println(InnerOuter.outClassValue);
}
}
}
// InnerOuterMain
public class InnerOuterMain {
public static void main(String[] args) {
InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner =outer.new Inner();
inner.print();
}
}
- 내부 클래스는 바깥 클래스의 private 접근 제어자에 접근 가능
- 내부 클래스는 바깥 클래스의 인스턴스에 소속
- 내부 인스턴스는 바깥 인스턴스의 참조를 보관하기 때문에 바깥 인스턴스의 멤버에 접근 가능
- 바깥 인스턴스 참조를 해야하기 떄문에, 위에 코드 처럼 해야함
- outer.new Inner() → 바깥참조.new 내부 클래스()
내부 클래스가 필요한 예시
// Car에서만 사용
public class Engine {
private Car car;
public Engine(Car car) {
this.car = car;
}
public void start() {
System.out.println("충전 레벨 확인: " + car.getChargeLevel());
System.out.println(car.getModel() + "의 엔진을 구동합니다.");
}
}
- Engine 클래스는 Car 클래스에서만 사용
- 엔진을 시작하기 위해서는 Car 인스턴스의 참조 필요
public class Car {
private String model;
private int chargeLevel;
private Engine engine;
public Car(String model, int chargeLevel) {
this.model = model;
this.chargeLevel = chargeLevel;
this.engine = new Engine(this);
}
//Engine에서만 사용하는 메서드
public String getModel() {
return model;
}
//Engine에서만 사용하는 메서드
public int getChargeLevel() {
return chargeLevel;
}
public void start() {
engine.start();
System.out.println(model + " 시작 완료");
}
}
- Car 클래스는 엔진에 필요한 메서드 제공
- 결과적으로 Car 클래스는 엔진 사용 기능을 위해 메서드를 외부 노출해야 함
내부 클래스로 리팩토링
- 엔진 클래스를 분리하지말고 차의 내부 클래스로 리팩토링
public class Car {
private String model;
private int chargeLevel;
private Engine engine;
public Car(String model, int chargeLevel) {
this.model = model;
this.chargeLevel = chargeLevel;
this.engine = new Engine();
}
public void start(){
engine.start();
System.out.println(model + " 시작 완료");
}
private class Engine {
public void start(){
System.out.println("충전 레벨 확인 : " + chargeLevel );
System.out.println(model + "의 엔진을 구동합니다. ");
}
}
}
- Engine 클래스는 Car의 필드값 사용 가능
- 꼭 필요한 메서드만 노출하여 Car의 캡슐화를 높힘
정적 중첩 클래스 vs 내부 클래스
"그냥 내부 클래스를 쓰지, 왜 굳이 static을 붙여가면서 정적 중첩 클래스를 사용 할까 ?"
public static class Engine {
public void start() {
System.out.println("Engine started");
}
}
- 위의 정적 중첩 클래스 코드 예시를 통해 이해해보자.
- 정적 중첩 클래스와 내부 클래스는 비슷한 듯 다르다.
- Engine은 Car 인스턴스에 전혀 의존하고 있지 않고 있다.
- 따라서 굳이 정적 중첩 클래스를 인스턴스 내부 클래스로 만들 이유가 없는 것이다.
- 외부 인스턴스 없이 사용하기 때문에, 유연하고 효율적이다.
- 주로 독립적으로 사용
중첩 클래스는 아주 긴밀하게 연결되어 있는 특별한 경우에만 사용해야한다 !
외부 여러곳에서 중첩 클래스를 사용하는 경우 중첩 클래스로 사용하면 안된다.
중첩 클래스로 인해 논리적 그룹화와 캡슐화를 할 수 있음
지역 클래스
public class LocalOuterV1 {
private int outInstanceVar = 3;
public void process(int paramVar){
int localVar = 1;
class LocalPrinter {
int value = 0;
public void printerData() {
System.out.println("value = " + value);
System.out.println("localVar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceVar = " + outInstanceVar);
}
}
LocalPrinter printer = new LocalPrinter();
printer.printerData();
}
public static void main(String[] args) {
LocalOuterV1 localOuterV1 = new LocalOuterV1();
localOuterV1.process(2);
}
}
- 지역 클래스의 접근 범위
- 메서드 내부에 국한되는 클래스
- 자신의 인스턴스 변수인 value 접근 가능
- 지역 변수인 localVar 접근 가능
- 매개변수인 paramVar 접근 가능 (매개변수도 지역변수의 한 종류 !)
- 바깥 클래스의 멤버 변수 outInstanceVar 접근 가능
- 참고로 지역 클래스는 지역 변수처럼 접근 제어자 사용 불가능
- 지역 클래스를 사용하려면, 직접 인스턴스를 생성하고 메서드를 호출 해야 한다.
- process() 메소드 맨 아래 쪽에 printer 객체 생성 후 호출 하듯이
지역 클래스 - 지역 변수 캡처
"먼저 흐릇해진 변수의 생명주기에 대해 복습해보자 !"
- 클래스 변수 (static 변수)
- 프로그램 종료까지 변수가 살아있다. (가장 긴 생명주기)
- 클래스 변수(static 변수)는 메서드 영역에 존재하고, 자바가 클래스 정보 읽어 들이는 순간부터 프로램 종료까지 존재한다.
- 인스턴스 변수
- 힙 영역에 존재
- 인스턴스 변수는 본인이 소속된 인스턴스가 GC 되기 전까지 존재한다. (생명 주기 꽤나 긴 편)
- 지역 변수
- 지역 변수는 스택 영역의 스택 프레임에 존재한다.
- 메서드 호출이 끝나면 사라진다. (스택 영역)
- 메서드 호출이 종료되면 스택 프레임이 제거되어 그 안에 지역 변수 모두 제거된다.
- 생존 주기가 매우 짧은 편이다 !
이제 예시를 통해 본론으로 들어가보자.
public class LocalOuterV1 {
private int outInstanceVar = 3;
public Printer process(int paramVar){
int localVar = 1; // 스택 프레임이 종료되는 순간 함께 제거
class LocalPrinter implements Printer {
int value = 0;
@Override
public void print(){
System.out.println("value = " + value);
// 인스턴스는 지역 변수보다 더 오래 살아남음
System.out.println("localVar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceVar = " + outInstanceVar);
}
}
LocalPrinter printer = new LocalPrinter();
return printer;
}
public static void main(String[] args) {
LocalOuterV1 localOuterV1 = new LocalOuterV1();
Printer printer = localOuterV1.process(2);
//printer.print는 process()의 스택 프레임이 사라진 이후에 실행
printer.print();
}
}
- LocalPrinter 인스턴스 생성 직후 메모리 구조이다.
- 여기에서 주의있게 봐야 할 부분은 paramVar와 localVar 부분이다.
- 이제 process() 메서드가 종료되었다고 해보자.
- 해당 메소드가 종료되면 process()의 스택 프레임이 제거되면서 두 지역변수(paramVar, localVar)도 함께 제거된다.
- 하지만 process() 메서드가 종료되어도 LocalPrinter 인스턴스는 계속 생존 할 수 있다.
"그렇다면 어떻게 process() 메서드가 종료됐는데, LocalPrinter.print() 메서드를 호출 할 수 있을까 ? "
1. LocalPrinter 인스턴스 생성 → paramVar, localVar 지역 변수에 접근
2. 이후 사용하는 지역 변수(paramVar, localVar) 복사
3. 복사한 지역 변수를 인스턴스에 포함
4. 복사한 지역 변수를 포함해서 인스턴스 생성 완료 (이제 복사한 지역 변수를 인스턴스를 통해 접근 가능)
- 이것이 가능한 이유는 자바에서 지역 클래스의 인스턴스를 생성하는 시점에
필요한 지역 변수를 복사하고 생성한 인스턴스에 함께 넣어두기 때문에다. - 이 과정을 캡처라고 한다.
여기에서 한가지 주의 사항이 있다.
지역 클래스가 접근하는 지역 변수는 절대로 절대로 중간에 값이 변하면 안된다.
사실상 해당 지역 변수는 final이라고 보면되고, 이것은 자바 문법이자 규칙이다
캡처 변수의 값이 변경된다면 이로 인해 수 많은 문제들이 파생 될 수 있다.
- 예상하지 못한 곳에 값 변경되어 디버깅을 어렵게 할 수 있음
- 지연 변수와 캡처 변수의 값을 동기화 해야하고, 멀티쓰레드 상황에서 이런 과정이 어렵고 성능에 나쁜 영향을 줄 수 있음
익명 클래스 (Anonymous class)
public void process(int paramVar) {
int localVar = 1;
Printer printer = new Printer() {
int value = 0;
@Override
public void print() {
System.out.println("value = " + value);
// 인스턴스는 지역 변수보다 더 오래 살아남음
System.out.println("localVar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceVar = " + outInstanceVar);
}
};
printer.print();
System.out.println("print.class= " + printer.getClass());
}
- 익명 클래스는 지역 클래스의 특별한 한 종류 (왠만하면 지역 클래스 쓰자 !)
- 익명 클래스는 이름이 없다는 특징을 가짐
- 선언과 생성을 한번에 진행한다.
- 익명 클래스는 클래스의 본문(body)를 정의하면서 동시에 생성한다.
- 위의 코드에서는 Printer를 상속(구현) 하면서 바로 생성된다.
익명 클래의 특징
- 이름 없는 지역 클래스를 선언하면서 동시에 생성
- 익명 클래스는 부모 클래스를 상속 받거나, 또는 인터페이스를 구현해야 한다.
- new 다음 바로 상속 받으면서 구현 할 부모 타입을 입력하면 된다.
- 여기에선 Printer 인터페이스를 구현한 코드를 작성하면 된다.
- 이름을 갖지 않으므로 생성자를 가질 수 없다.
- 클래스를 별도로 정의하지 않고도 인터페이스나 추상 클래스 즉석 구현하여 코드 간결
- 하지만 복잡하거나 재사용이 필요한 경우에는 별도의 클래스 정의하는게 좋음
인스턴스 매개변수로 전달
프로그래밍에서 중복을 제거하고 좋은 코드를 유지하는 핵심은
변하는 부분과 변하지 않는 부분을 분리하는 것이다.
이를 통해 메서드의 재사용성을 높일 수 있다.
변하는 부분을 메서드(함수) 내부에 가지게 하지말고, 외부에서 전달 받도록 해보자 !
public class EX2Main {
public static void helloDice() {
System.out.println("프로그램 시작");
//코드 조각 시작
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
//코드 조각 종료
System.out.println("프로그램 종료");
}
public static void helloSum() {
System.out.println("프로그램 시작");
//코드 조각 시작
for (int i = 1; i <= 3; i++) {
System.out.println("i = " + i);
}
//코드 조각 종료
System.out.println("프로그램 종료");
}
public static void main(String[] args) {
helloDice();
helloSum();
}
}
- "프로그램 시작"과 "프로그램 종료" 부분이 중복이다.
- 여기서 변하는 부분은 dice와 sum의 주요 로직들이다
변하는 문자열 같은 것은 쉽게 외부에서 전달 할 수 있다.
하지만 이것은 문자열 같은 데이터가 아니라 코드 조각을 전달해야 한다.
아래와 같은 코드가 있을때, 어떻게 리팩토링을 하면 좋을까 ?
어떻게 코드를 전달 할 수 있을까 ?
public class Ex1Main {
public static void hello(Process process){
System.out.println("프로그램 시작");
process.run();
System.out.println("프로그램 종료");
}
static class Dice implements Process{
@Override
public void run(){
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
}
}
static class Sum implements Process{
@Override
public void run(){
for (int i = 0; i <= 3; i++) {
System.out.println("i = " + i);
}
}
}
public static void main(String[] args) {
Process dice = new Dice();
Process sum = new Sum();
hello(dice);
hello(sum);
}
}
- 코드 조각은 보통 메서드에서 정의를 하지만, 메서드를 전달 할 수는 없다.
- 대신 인스턴스를 전달하고, 인스턴스에 있는 메서드를 호출하면된다 .
- 그래서 여기 코드에서는 가장 먼저 인터페이스를 정의하고 이후 구현 클래스를 만들었다.
- 참고로 여기 중첩 클래스는 정적 중첩 클래스이다.
정리하면 매서드에 인수로 전달할 수있는 것은 2가지다.
- int, double 같은 기본형 타입
- 참조형 타입 (인스턴스)
- Java 9 이후에는 Lambda(람다)라는 기능이 나와 좀 더 쉽게코드 조각을 전달할 수 있게 되었음
간단히 람다식에 대해 알아보자 .
(매개변수) -> { 실행 코드 }
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
}
// 익명 클래스 방식
Calculator c1 = new Calculator() {
@Override
public int calculate(int a, int b) {
return a + b;
}
};
System.out.println(c1.calculate(3, 5)); // 출력: 8
// 람다 방식
Calculator c2 = (a, b) -> a + b;
System.out.println(c2.calculate(3, 5)); // 출력: 8
- 람다는 자바에서 익명 함수를 작성하는 문법이다.
- 람다식은 함수형 인터페이스를 구현한 "익명 객체" 일 뿐이다.
- 함수형 인터페이스 (@FunctionalInterface) 는 추상 메서드가 딱 하나만 있는 인터페이스를 의미
- 그래서 c2의 타입을 보면 int형이 아니라, 익명 객체에 불과함
- 사실 자바 내부에서는 저렇게 표현된 람다식이 익명 클래스 처럼 변환됨
- 람다는 내부 필드를 가질 수 없다.
- 하지만 지역 변수, 파라미터, 인스턴스 변수는 읽을 수 잇다.
- 그냥 익명 클래스가 구현하던 "일회용 함수 인터페이스 구현" 용도 대체
보통 실무에서 가장 많이 사용되는 것은 정적 중첨 클래스, 익명 클래스이다.
특히 익명 클래스는 GUI 리스너나 콜백 함수 작성 시 주로 사용된다.
요즘에는 익명 클래스보다 람다로 대체되고 있다.
예외 처리
참고로 자바의 경우 GC가 있기 때문에 JVM 메모리에 있는 인스턴스는 자동으로 해제할 수 있지만, 외부 연결 같은 자바 외부의 자원은 자동으로 해제가 되지 않는다. 따라서 외부 자원을 사용한 후에는 연결을 해제해서 외부 자원을 반드시 반납해야 한다.
String connectResult = client.connect();
if(isError(connectResult)){
System.out.println("[네트워크 오류 발생] 오류 코드 : " + connectResult);
}else{
String sendResult = client.send(data);
if(isError(sendResult)){
System.out.println("[네트워크 오류 발생] 오류 코드 : " + sendResult);
}
}
client.disconnect();
- 위와 같이 코드를 작성 할 경우 정상 흐름과 예외 흐름이 섞여 있어 한눈에 이해하기 어려움
- 가중 중요한 정상 흐름이 한눈에 들어오지 않는다.
- 해당 코드는 connectResult 라는 반환 값을 사용해서 예외 상황을 처리하는데, 이게 근본적인 문제 !!
그렇다면 어떻게 정상 흐름과 예외 흐름을 분리할 수 있을까 ?
예외
자바는 프로그램 실행 중에 발생할 수 있는 예상치 못한 예외를 처리하기 위한 메커니즘을 제공한다.
(흡사 폭탄 돌리기 같은 느낌)
예외 주요 키워드
- try, catch, finally, throw, throws
예외 계층
- Object : 자바에서 기본형을 제외한 모든 것은 객체로 가장 최상위 부모는 Object이고, 예외의 부모도 Object이다.
- Throwable : 최상위 예외로 하위에 Exception, Error 가 있다.
- Exception : 체크 예외로 애플리케이션 로직에서 사용할 수 있는 최상위 예외이다.
- RuntimeException : 언체크 예외로 컴파일러가 체크하지 않는 예외이다. (런타임 예외라고도 불림)
- Error : 예외가 아니라 JVM이나 시스템 자체가 망가진 상태
- 따라서 복구하지말고, 프로그램을 그냥 죽여야 한다.
체크 예외는 발생한 예외를 개발자가 명시적으로 처리해야 하고,
언체크 예외는 개발자가 발생한 예외를 명시적으로 처리하지 않아도 된다.
예외 기본 규칙
- 예외는 폭탄 돌리기와 같음
- 예외가 발생하면 잡아서 처리하거나 밖으로 던져야 함
- Main에서 Client 호출 과정에서 Client에서 예외가 발생
- Client에서 예외를 처리하지 못하고 밖으로 던짐 (Service에게 던짐)
- Service에 예외가 전달되고 Service에서 예외 처리
- 다시 정상 흐름 반환
이처럼 예외는 잡아서 처리하거나, 밖으로 던지거나 해야한다.
체크 예외 예시
public class MyCheckedException extends Exception{
public MyCheckedException(String message) {
super(message);
}
}
- 예외 클래스를 만들었다.
public class Client {
public void call() throws MyCheckedException{
// 문제 상황
throw new MyCheckedException("ex");
}
}
- 여기가 문제 상황이다.
- 객체를 생성하고 throw를 통해 예외를 발생 시킨다 (throws랑 다음)
public class Service {
Client client = new Client();
public void callCatch(){
try{
client.call();
}catch(Exception e){
System.out.println("예외 처리, message = " + e.getMessage());
}
System.out.println("정상 흐름");
}
public void catchThrow() throws MyCheckedException{
client.call();
}
}
- callCatch() 메서드와 catchThrow() 메서드 두개를 만들었다
- callCatch() 메서드는 try ~ catch로 예외 상황을 잡는다.
- catchThrow()는 예외를 잡지 못해 throws로 넘겨준다.
위에 코드들이 기본 코드 베이스이다.
이 코드 베이스들로 2가지 예외 상황 살펴보자
CheckedCatchMain()
public class CheckedCatchMain {
public static void main(String[] args) {
Service service = new Service();
service.callCatch();
System.out.println("정상 종료");
}
}
- 먼저 callCatch() 메서드를 호출하는 것을 봐보자
- 해당 main 로직에서 Service의 객체를 만들어 callCatch() 메소드를 호출한다.
- Service 로직에서 Client 객체를 생성하고 call() 메소드를 호출한다.
- Client의 call 메소드에서는 의도적으로 예외 상황을 발생 시켰다.
- 해당 예외는 Service 로직으로 던저지고, Service의 callCatch 메소드가 해당 예외를 처리한다.
그 다음 예외 처리를 하지 못한 경우를 살펴보자 !
CheckedThrowMain()
public class CheckedThrowMain {
public static void main(String[] args) throws MyCheckedException{
Service service = new Service();
service.catchThrow();
System.out.println("정상");
}
}
- 해당 로직도 Client의 call 메소드에서 의도적으로 예외 상황이 발생되는 것 까지는 똑같다.
- 하지만 Service 로직에서 catchThrow 메소드에서 예외를 처리하지 않고 throws로 넘긴다.
- 그리고 main 로직에서 이를 받고, 메인 로직도 throws로 넘긴다.
- 더 이상 넘길 때가 없으니 로그가 출력되고 종료된다.
아래는 실제 발생한 에러 로그
- 이 에러 로그를 예전에는 그냥 무시하고 넘어갔는데, 이제는 각각의 의미를 알 것 같다. (엄청 신기함 !)
- 그야말로 위에서 했던 말 그대로인 것이다.
- 먼저 Clinet.call 에러 → Service.catchThrow 에러 → 마지막 main 에러
- 앞으로 이런 에러를 그냥 무시하고 넘어가면 안되겠다 ... !!
- 이와 같은 스택 트레이스는 정말 개발자에게 소중하다.
체크 예외는 잡아서 직접 처리하거나 또는 밖으로 던지거나 둘중 하나를 개발자가 명시적으로 처리해야한다.
장점으로는 개발자가 실수로 예외를 누락하지 않도록 컴파일러가 잡아주는 것이지만,
단점으로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야해서 번거로움이 발생한다.
언체크 예외 예시
public class Service{
Client client = new Client();
public void callCatch(){
try{
client.call();
}catch (MyUncheckedException e){
System.out.println("예외 처리, message = " + e.getMessage());
}
System.out.println("정상 로직");
}
public void catchThrow(){
client.call();
}
}
- 언체크 예외와 체크 예외는 기본적으로 동일하다.
- 하지만 언체크 예외는 throws를 선언하지 않고 생략 할 수 있다.
- 즉, 예외를 잡아서 처리하지 않아도 throws 키워드 생략이 가능하다. (throws 예외 선언해되 되긴 함)
언체크의 장점으로 신경쓰고 싶지 않은 언체크 예외를 무시 할 수 있지만,
단점으로는 개발자가 실수로 예외를 누락할 수 있다.
반면 체크 예외는 컴파일러를 통해 예외 누락을 잡을 수 있다.
참고로 현대 애플리케이션에서 대부분 체크 예외를 쓰지는 않는다 !
예외 처리 도입
try {
//정상 흐름
} catch {
//예외 흐름
} finally {
//반드시 호출해야 하는 마무리 흐름
}
- try ~ catch ~ finally 구조는 정상 흐름, 예외 흐름, 마무리 흐름을 제공한다.
- 여기서 핵심은 finally로, finally는 반드시 호출된다.
- 주로 finally는 try에서 사용한 자원을 해제할 때 주로 사용된다.
- 인스턴스 같은 것은 GC가 해주는데, 네트워크 자원 같은거는 GC없이 개발자가 처리해줘야 함
- 사용한 자원을 항상 반환할 수 있도록 보장해주는게 핵심
실제 예시를 통해 예외 이해하기
- 위의 예외 구조로 클래스를 만든다.
NetworkClientException 예외 클래스
public class NetworkClientExceptionV3 extends Exception{
public NetworkClientExceptionV3(String message){
super(message);
}
}
ConnectException 예외 클래스
public class ConnectExceptionV3 extends NetworkClientExceptionV3{
private final String address;
public ConnectExceptionV3(String address, String message) {
super(message);
this.address = address;
}
public String getAddress() {
return address;
}
}
SendExceptionV3 예외 클래스
public class SendExceptionV3 extends NetworkClientExceptionV3{
private final String sendData;
public SendExceptionV3(String sendData,String message){
super(message);
this.sendData = sendData;
}
public String getSendData() {
return sendData;
}
}
여기까지는 예외 클래스 정의였고, 아래는 Client, Server, Main을 정의한다.
NetworkClient의 코드
public class NetworkClientV3 {
public final String address;
public boolean connectError;
public boolean sendError;
public NetworkClientV3(String address){
this.address = address;
}
public void send(String data) throws SendExceptionV3 {
if (sendError) {
throw new SendExceptionV3(data, address + " 서버에 데이터 전송 실패 " + data);
}
System.out.println(address + " 서버에 데이터 전송 " + data);
}
public void disconnect() {
System.out.println(address + " 서버 연결 해제");
}
public void initError(String data) {
if (data.contains("error1")){
connectError = true;
}
if(data.contains("error2")){
sendError = true;
}
}
}
- 여기서 주의있게 봐야 할 메소드는 connect와 send이고, 그 안의 예외 처리들을 살펴보자
- connect와 send 메소드에 고의로 예외를 발생(throw)시킨다.
- public void send(String data) throws SendExceptionV3의 의미 (이게 너무 헷갈렸음)
- send 메서드를 호출하면 SendExceptionV3 예외가 발생 할 수 있으니 호출자는 준비하라는 의미
- 즉, throws는 SendExceptionV3로 보내는게 아니라, 이 예외가 날 수 있다는 일종의 경고
- 예외 전파는 항상 호출 순서의 반대로 올라간다 !!
NetworkService의 코드
public class NetworkServiceV3_1 {
public void sendMessage(String data) {
String address = "http://example.com";
NetworkClientV3 client = new NetworkClientV3(address);
client.initError(data);
try {
client.connect();
client.send(data);
} catch (SendExceptionV3 e) {
System.out.println("[네트워크오류] 전송 데이터 : " + e.getSendData() + " , 메시지 : " + e.getMessage());
} catch (NetworkClientExceptionV3 e) {
System.out.println("[연결 오류] 주소 " + ", 메시지 : " + e.getMessage());
} catch (Exception e){
System.out.println("[알수 없는 오류] 주소: " + ", 메시지 : " + e.getMessage());
} finally {
client.disconnect();
}
}
}
- 예외가 발생하면 ConnectExceptionV3 ⊂ NetworkClientExceptionV3 ⊂ Exception 순으로 처리된다.
main 코드
public class MainV3 {
public static void main(String[] args) {
NetworkServiceV3_1 networkServiceV1 = new NetworkServiceV3_1();
Scanner scanner = new Scanner(System.in);
while (true){
System.out.print("전송할 문자 : ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
networkServiceV1.sendMessage(input);
System.out.println();
}
System.out.println("프로그램을 정상 종료합니다.");
}
}
전반적인 로직을 설명하자면,
- 먼저 main에서 networkService 객체를 만들고, 해당 객체는 NetworkClient로 넘어간다.
- Clinet의 connect와 send는 의도적으로 예외를 발생(throw)시킨다. 이때 throws로 어떤 예외가 발생하는지 호출자(networkService)에게 전달한다.
- 그리고 networkService로 예외가 전파된다.
- networkService는 throws를 catch하여 예외 상황을 처리한다.
throw : 예외 발생
throws : 어떤 예외 발생하는지 경고
예외는 호출의 반대 방향으로 올라간다 !
전반적인 예외 처리를 구현한 예이다. 좋은 예시이니 잘 이해해두자.
추가 팁
// ConnectExceptionV3 ⊂ NetworkClientExceptionV3 (임의로 만든 예외 클래스)
try {
} catch (ConnectExceptionV3 e) {
} catch (NetworkClientExceptionV3 e) {
} catch (Exception e) {
}
- 모든 예외를 잡아서 처리하려면 마지막 Exception (예외의 최상위 부모)를 두면 된다.
- 주의할 점은 예외가 발생했을 때, catch를 순서대로 실행하므로 더 디테일한 자식을 먼저 잡는다.
- ConnectExceptionV3 ⊂ NetworkClientExceptionV3 ⊂ Exception 이런순서대로 잡으면 좋음
실무 예외 처리 방안
- 실무에서 처리할 수 없는 예외들이 있다.
- DB 서버 문제, 애플리케이션 연결 오류 등의 예외가 있는데, 이런 것은 시스템 오류이기 때문에 예외를 잡아도 해결할 수 있는 것이 거의 없다.
- 이런 경우는 고객들에게 시스템 문제를 알리고, 오류에 대한 로그를 남기는게 최선이다.
체크 예외의 부담
- 체크 예외는 개발자가 실수로 놓칠 수 있는 예외들을 컴파일러가 체크해주는 장점이 있는데, 프로그램이 점점 복잡해지면서 체크 예외를 사용하는 것이 부담스워졌다.
- 모든 체크 예외를 하나씩 던지면 지저분한 코드가 만들어진다.
최악의 수 Exception
- Exception은 모든 예외의 부모로 throws에 넣기만해도 컴파일 에러가 발생하지 않는다.
- 하지만 이렇게 하면 그 하위 타입의 다른 체크 예외를 체크할 수 있는 기능이 무효화 된다.
- 결국 중요한 체크 예외를 다 놓치게 되는 것이다.
- 따라서 Exception 자체를 밖으로 던지는 것은 좋은 방법이 아니다
그렇다면 어떤 예외 처리 방식이 좋은 처리 방식일까?
- 가장 좋은 방법은 예외들을 중간 여러곳에서 나누어 처리하는 것 보다 예외를 공통으로 처리할 수 있는 곳을 만들어 해결하는 것이다.
public class NetworkClientExceptionV3 extends RuntimeException{
public NetworkClientExceptionV3(String message){
super(message);
}
}
- 그렇게 하기위해서 먼저 체크 예외를 언체크 예외 (RuntimeExcepotion)으로 바꿔준다.
public NetworkClientV3(String address){
this.address = address;
}
public void connect() {
if(connectError){
throw new ConnectExceptionV3(address,address + " 서버 연결 실패");
}
System.out.println(address + " 서버 연결 성공");
}
public void send(String data) {
if (sendError) {
throw new SendExceptionV3(data, address + " 서버에 데이터 전송 실패 " + data);
}
System.out.println(address + " 서버에 데이터 전송 " + data);
}
- RuntimeException 예외는 throws을 작성 할 필요가 없음
public class NetworkServiceV3_1 {
public void sendMessage(String data) {
String address = "http://example.com";
NetworkClientV3 client = new NetworkClientV3(address);
client.initError(data);
try {
client.connect();
client.send(data);
} finally {
client.disconnect();
}
}
}
- 서비스 로직에서의 코드가 깔끔해짐
public class MainV3 {
public static void main(String[] args) {
NetworkServiceV3_1 networkServiceV1 = new NetworkServiceV3_1();
Scanner scanner = new Scanner(System.in);
while (true){
System.out.print("전송할 문자 : ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
try{
networkServiceV1.sendMessage(input);
}catch (Exception e){
exceptionHandler(e);
}
System.out.println();
}
System.out.println("프로그램을 정상 종료합니다.");
}
// 공통 예외 처리
private static void exceptionHandler(Exception e) {
System.out.println("사용자 메세지 : 죄송합니다. 알 수 없는 문제가 발생했습니다.");
System.out.println("== 개발자옹 디버길 메시지 == ");
e.printStackTrace(System.out);
// 필요하면 예외 별로 별도 추가 처리 가능
if (e instanceof SendExceptionV3 sendEx) {
System.out.println("[전송 오류] 전송 데이터 : " + sendEx.getSendData());
}
}
}
- 그리고 main 에서 이렇게 한번에 처리하면 된다.
- 사용자가 디테일한 오류 코드나 오류 상황 모두 이해 할 필요는 없다.
- printStackTrace()와 같은 예외 메시지로 스택 트레이스를 출력 할 수 있다.
참고로, 요즘 대부분의 라이브러리들은 언체크(런타임 에러)를 많이 사용한다 !
try-with-resources
- 애플리케이션에서 외부 자원을 사용하는 경우 반드시 자원을 해제해야 한다.
- 따라서 finally 구문을 사용하는데, 자바에서 "try with resources"의 기능이 도입됐다.
- 해당 구문은 try에서 자원을 함꼐 사용하고 try가 끝나면 반드시 종료해서 반납해야하는 외부 자원을 의미한다.
public class NetworkClientV3 implements AutoCloseable{
. . .
@Override
public void close() {
System.out.println("NetworkClinetV5.close");
disconnect();
}
}
- 먼저 NetworkClient 클래스를 AutoCloseable 클래스 implements 한다.
- 여기서 AutoCloseable은 java.lang 패키지의 내장 인터페이스이다.
- 그리고 AutoCloseable 클래스의 close 메서드를 오더라이딩해준다.
- 해당 메소드는 try가 끝나면 자동으로 실행되는 메소드이다.
public class NetworkServiceV3_1 {
public void sendMessage(String data) {
String address = "http://example.com";
try (NetworkClientV3 client = new NetworkClientV3(address)){
client.initError(data);
client.connect();
client.send(data);
} catch (Exception e) {
System.out.println("[예외 확인]: " + e.getMessage());
throw e;
}
}
}
- 다음으로는 Service 로직이다.
- 앞서 메서드의 지역 변수로 선언했던 객체를 try 구문 안에 선언한다.
- Try with resources 구문은 try 괄호 안에 사용할 자원 명시
- 스코프 범위를 try 블럭 안으로 한정하여 코드 유지보수가 쉬워짐
- try 구문이 끝나면, 무조건 close 메소드가 실행되고, 이후 catch가 되든 throw가 되든 한다.
- 따라서 close 메소드는 반드시 실행시킬 수 있고, 이로 인해 자원 해제를 의무적으로 할 수 있다.
- AutoCloseable이 없으면 try 안에서 객체 생성 불가능 !
Try with resources의 장점
- 리소스 누수 방지 ( 모든 리소스가 제대로 닫히도록 보장)
- 코드 간결성 및 가독성 향상
- 스코프 범위 한정
- 조금 더 빠른 자원 해제
- try → catch → finally로 catch 이후에 자원 반납을 try 블럭이 끝나면 즉시 close()로 호출 됨
'🔖Java' 카테고리의 다른 글
Java 중급 2-1 (제네릭) (0) | 2025.04.17 |
---|---|
Java 기본 (객체 지향 프로그래밍) (0) | 2025.03.16 |
Java 기초 정리 (0) | 2025.03.09 |