
'김영한의 실전 자바 - 중급 1편' 강의를 들으면서 복습할만한 내용을 정리하였다.
자바는 기본으로 제공하는 라이브러리(클래스 모음) 중에 가장 기본이 되는 것이 바로 java.lang 패키지이다. 여기서 lang 은 language (언어)의 줄임말이다. 쉽게 이야기해서 자바 언어를 이루는 가장 기본이 되는 클래스들을 보관하는 패키지를 뜻한다.
java.lang 패키지의 대표적인 클래스들
Object : 모든 자바 객체의 부모 클래스
String : 문자열
Integer, Long, Double : 래퍼 타입, 기본형 데이터 타입을 객체로 만든 것
Class : 클래스 메타 정보
System : 시스템과 관련된 기본 기능들을 제공
java.lang 패키지는 모든 자바 애플리케이션에 자동으로 임포트(import)된다. 따라서 임포트 구문을 사용하지 않아도 된다.
자바에서 모든 클래스의 최상위 부모 클래스는 항상 Object 클래스이다.

package lang.object;
// 부모가 없으면 묵시적으로 Object 클래스를 상속받는다.
public class Parent {
public void parentMethod() {
System.out.println("Parent.parentMethod");
}
}
Object 클래스를 상속 받는다.extends Object 코드를 넣어준다.extends Object는 생략하는 것을 권장한다.Object 를 상속 받지 않는다.extends Object 코드를 넣지 않는다.묵시적(Implicit) vs 명시적(Explicit)
묵시적 : 개발자가 코드에 직접 기술하지 않아도 시스템 또는 컴파일러에 의해 자동으로 수행되는 것을 의미
명시적 : 개발자가 코드에 직접 기술해서 작동하는 것을 의미

Parent 는 Object 를 묵시적으로 상속 받았기 때문에 메모리에도 함께 생성된다.
모든 클래스가 Object 클래스를 상속 받는 이유는 다음과 같다.
공통 기능 제공
다형성의 기본 구현
객체의 정보를 제공하고, 이 객체가 다른 객체와 같은지 비교하고, 객체가 어떤 클래스로 만들어졌는지 확인하는 기능은 모두 객체에게 필요한 기본 기능이다. 이런 기능을 객체를 만들 때 마다 항상 새로운 메서드를 정의해서 만들어야 한다면 상당히 번거로울 것이다.
그리고 막상 만든다고 해도 개발자마다 서로 다른 이름의 메서드를 만들어서 일관성이 없을 것이다. 예를 들어서 객체의 정보를 제공하는 기능을 만든다고 하면 어떤 개발자는 toString() 으로 또 어떤 개발자는 objectInfo() 와 같이 서로 다른 이름으로 만들 수 있다.
Object 는 모든 객체에 필요한 공통 기능을 제공한다. Object 는 최상위 부모 클래스이기 때문에 모든 객체는 공통 기능을 편리하게 제공(상속) 받을 수 있다.
Object 가 제공하는 기능
toString()equals()getClass()부모는 자식을 담을 수 있다. Object 는 모든 클래스의 부모 클래스이다. 따라서 모든 객체를 참조할 수 있다.
Object 클래스는 다형성을 지원하는 기본적인 메커니즘을 제공한다. 모든 자바 객체는 Object 타입으로 처리될 수 있으며, 이는 다양한 타입의 객체를 통합적으로 처리할 수 있게 해준다.
쉽게 이야기 해서 Object 는 모든 객체를 다 담을 수 있다. 타입이 다른 객체들을 어딘가에 보관해야 한다면 바로 Object 에 보관하면 된다.
Object 는 모든 클래스의 부모 클래스이다. 따라서 Object 는 모든 객체를 참조할 수 있다.

Dog 와 Car 는 서로 아무런 관련이 없는 클래스이다. 둘다 부모가 없으므로 Object 를 자동으로 상속 받는다.
action 메서드
private static void action(Object obj) {
//obj.sound(); //컴파일 오류, Object는 sound()가 없다.
//obj.move(); //컴파일 오류, Object는 move()가 없다.
//객체에 맞는 다운캐스팅 필요
if (obj instanceof Dog dog) {
dog.sound();
}
else if (obj instanceof Car car) {
car.move();
}
}
이 메서드는 Object 타입의 매개변수를 사용한다. 그런데 Object 는 모든 객체의 부모다. 따라서 어떤 객체든지 인자로 전달할 수 있다.
action(dog) //main에서 dog 전달
private static void action(Object obj) {
obj.sound(); //컴파일 오류, Object는 sound()가 없다.
}
action() 메서드안에서 obj.sound() 를 호출하면 오류가 발생한다. 왜냐하면 매개변수인 obj 는 Object 타입이기 때문이다. Object 에는 sound() 메서드가 없다.

Dog 인스턴스의 sound() 를 호출하려면 다음과 같이 다운캐스팅을 해야한다.
if (obj instanceof Dog dog) {
dog.sound();
}
Object 는 모든 객체를 대상으로 다형적 참조를 할 수 있다.
Object 는 모든 객체의 부모이므로 모든 객체를 담을 수 있다.Object 를 통해 전달 받은 객체를 호출하려면 각 객체에 맞는 다운캐스팅 과정이 필요하다.
Object 가 세상의 모든 메서드를 알고 있는 것이 아니다.다형성을 제대로 활용하려면 자바 기본편에서 배운 것 처럼 다형적 참조 + 메서드 오버라이딩을 함께 사용해야 한다. 그러면에서 Object 를 사용한 다형성에는 한계가 있다.
Object 는 모든 객체의 부모이므로 모든 객체를 대상으로 다형적 참조를 할 수 있따. 하지만 Object 에는 Dog.sound() 와 같은 다른 객체의 메서드가 정의되어 있지 않다. 따라서 메서드 오버라이딩을 활용 할 수 없다. 결국 각 객체의 기능을 호출하려면 다운캐스팅을 해야 한다.
결과적으로 다형적 참조는 가능하지만, 메서드 오버라이딩이 안되기 때문에 다형성을 활용하기 에는 한계가 있다.
Object[] 을 만들면 세상의 모든 객체를 담을 수 있는 배열을 만들 수 있다.
...
Object objects[0] = new Dog();
Object objects[1] = new Car();
Object objects[2] = new Object();
size(objects);
}
private static void size(Object[] objects) {
System.out.println("전달된 객체의 수는: " + objects.length);
}

size() 메서드
size(Object[] objects) 메서드는 배열에 담긴 객체의 수를 세는 역할을 담당한다.
이 메서드는 Object 타입만 사용한다. Object 타입의 배열은 세상의 모든 객체를 담을 수 있기 때문에, 새로운 클래스가 추가되거나 변경되어도 이 메서드를 수정하지 않아도 된다. 지금 만든 size() 메서드는 자바를 사용하는 곳이라면 어디든지 사용될 수 있다.
만약 Object 와 같은 개념이 없다면 어떻게 될까?
void action(Object obj) 과 같이 모든 객체를 받을 수 있는 메서드를 만들 수 없다.
Object[] objects 처럼 모든 객체를 저장할 수 있는 배열을 만들 수 없다.
물론
Object가 없어도 직접MyObject와 같이 클래스를 만들고 모든 클래스에서 직접 정의한MyObject를 상속 받으면 된다. 하지만 하나의 프로젝트를 넘어서 전세계 모든 개발자가 비슷한 클래스르 만들 것이고, 서로 호환되지 않는 수 많은XxxObject들이 넘쳐날 것이다.
Object.toString() 메서드는 객체의 정보를 문자열 형태로 제공한다. 그래서 디버깅과 로깅에 유용하게 사용된다.
이 메서드는 Object 클래스에 정의되므로 모든 클래스에서 상속받아 사용할 수 있다.
Object.toString()
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
실행결과
java.lang.Object@a09ee92
Object 가 제공하는 toString() 메서드는 기본적으로 패키지를 포함한 객체의 이름과 객체의 참조값(해시코드)를 16진수로 제공한다.println()과 toString()System.out.println() 메서드느 사실 내부에서 toString() 을 호출한다. Object 타입(자식 포함)이 println() 에 인수로 전달되면 내부에서 obj.toString() 메서드를 호출해서 결과를 출력한다.
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
Object.toString() 메서드가 클래스 정보와 참조값을 제공하지만 이 정보만으로는 객체의 상태를 적절히 나타내지 못한다. 그래서 보통 toString() 을 재정의(오버라이딩)해서 보다 유용한 정볼르 제공하는 것이 일반적이다.
Dog
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 +
'}';
}
}
Dog 는 toString() 을 재정의했다.
toString() 메서드는 IDE의 도움을 받아서 작성하는 것이 매우 편리하다.
ObjectPrinter
public class ObjectPrinter {
public static void print(Object obj) {
String string = "객체 정보 출력: " + obj.toString();
System.out.println(string);
}
}
"객체 정보 출력:" 이라는 문자와 객체의 toString() 결과를 합해서 출력하는 단순한 기능을 제공한다.
Object obj의 인수로 dog(Dog)가 전달 된다.obj.toString() 을 호출한다.obj 는 Object 타입이다. 따라서 Object 에 있는 toString() 을 찾는다.Dog에 재정의된 메서드가 있다.Dog.toString() 을 실행한다.참조 - 객체의 참조값 직접 출력
toString()은 기본으로 객체의 참조값을 출력한다. 그런데 오버라이딩하면 객체의 참조값을 출력할 수 없다. 이때는 다음 코드를 사용하면 된다.String refValue = Integer.toHexString(System.identityHashCode(dog1)); System.out.println("refValue = " + refValue);실행결과
refValue = 72ea2f77
만약 Object 가 없고, 또 Object 가 제공하는 toString() 이 없다면 서로 아무 관계가 없는 객체의 정보를 출력하기 어려울 것이다. 여기서 아무 관계가 없다는 것은 공통의 부모가 없다는 뜻이다. 아마도 다음의 BadObjectPrinter 클래스와 같이 각각의 클래스마다 별도의 메서드를 작성해야 할 것이다.
BadObjectPrinter
public class BadObjectPrinter {
public static void print(Car car) { //Car 전용 메서드
String string = "객체 정보 출력: " + car.carInfo(); //carInfo() 메서드 만듬
System.out.println(string);
}
public static void print(Dog dog) { //Dog 전용 메서드
String string = "객체 정보 출력: " + dog.dogInfo(); //dogInfo() 메서드 만듬
System.out.println(string);
}
}
BadObjectPrinter 는 구체적인 타입인 Car, Dog 를 사용한다. 따라서 이후에 출력해야 할 구체적인 클래스가 10개로 늘어나면 구체적인 클래스에 맞추어 메서드도 10개로 계속 늘어나게 된다. 이렇게 BadObjectPrinter 클래스가 구체적인 특정 클래스인 Car, Dog 를 사용하는 것을 BadObjectPrinter 는 Car, Dog 에 의존한다고 표현한다.
다행히도 자바에는 객체의 정보를 사용할 때, 다형적 참조 문제를 해결해줄 Object 클래스와 메서드 오버라이딩 문제를 해결해줄 Object.toString() 메서드가 있다.
위에서 만든 ObjectPrinter 클래스는 Car, Dog 같은 구체적인 클래스를 사용하는 것이 아니라, 추상적인 Object 클래스를 사용한다. 이렇게 ObjectPrinter 클래스가 Object 클래스를 사용하는 것을 ObjectPrinter 클래스가 Object 클래스에 의존한다고 표현한다.
추상적 : 여기서 말하는 추상적이라는 뜻은 단순히 추상 클래스나 인터페이스만 뜻하는 것은 아니다.
Animal과Dog, Cat의 관계를 떠올려보자.Animal같은 부모 타입으로 올라갈수록 개념은 더 추상적이게 되고,Dog, Cat과 같이 하위 타입으로 내려갈 수록 개념은 더 구체적이게 된다.

ObjectPrinter 와 Object 를 사용하는 구조는 다형성을 매우 잘 활용하고 있다. 다형성을 잘 활용한다는 것은 다형적 참조와 메서드 오버라이딩을 적절하게 사용한다는 뜻이다.
Open : 새로운 클래스를 추가하고, toString() 을 오버라이딩해서 기능을 확장할 수 있다.
Closed : 새로운 클래스를 추가해도 Object 와 toString() 을 사용하는 클라이언트 코드인 ObjectPrinter 는 변경하지 않아도 된다.
다형적 참조, 메서드 오버라이딩, 그리고 클라이언트 코드가 구체적인 Car, Dog 에 의존하는 것이 아니라 추상적인 Object 에 의존하면서 OCP 원칙을 지킬 수 있었다. 덕분에 새로운 클래스를 추가하고 toString() 메서드를 새롭게 오버라이딩해서 기능을 확장할 수 있다. 그리고 이러한 변화에도 불구하고 클라이언트 코드인 ObjectPrinter 는 변경할 필요가 없다.
ObjectPrinter 는 모든 타입의 부모인 Object 를 사용하고, Object 가 제공하는 toString() 메서드만 사용한다. 따라서 ObjectPrinter 를 사용하면 세상의 모든 객체의 정보(toString())를 편리하게 출력할 수 있다.
지금까지 설명한 ObjectPrinter 는 사실 System.out.println() 의 작동 방식을 설명하기 위해 만든 것이다.

자바 언어는 객체지향 언어 답게 언어 스스로도 객체지향의 특징을 매우 잘 활용한다.
참고 - 정적 의존관계 vs 동적 의존관계
정적 의존관계는 컴파일 시간에 결정되며, 주로 클래스 간의 관계를 의미한다. 앞서 보여준 클래스 의존 관계 그림이 바로 정적 의조관계이다. 쉽게 이야기해서 프로그램을 실행하지 않고, 클래스 내에서 사용하는 타입들만 보면 쉽게 의존관계를 파악할 수 있다.
동적 의존과계는 프로그램을 실행하는 런타임에 확인할 수 있는 의존관계이다. 앞서
ObjectPrinter.print(Object obj)에 인자로 어떤 객체가 전달 될 지는 프로그램을 실행해봐야 알 수 있다. 이렇게 런타임에 어떤 인스턴스를 사용하는지를 나타내는 것이 동적 의존관계이다.
Object 는 동등성 비교를 위한 equals() 메서드를 제공한다.
자바는 두 객체가 같다는 표현을 2가지로 분리해서 제공한다.
동일성(Identity) : == 연산자를 사용해서 두 객체가 참조가 동일한 객체를 가리키고 있는지 확인 (둘이 완전히 같은 객체 인스턴스 인지)
동등성(Equality) : equals() 메서드를 사용하여 두 객체가 논리적으로 동등한지 확인
"동일"은 완전히 같음을 의미한다. 반면 "동등"은 같은 가치나 수준을 의미하지만 그 형태나 외관 등이 완전히 같지는 않을 수 있다.
쉽게 이야기해서 동일성은 물리적으로 같은 메모리에 있는 객체 인스턴스인지 참조값을 확인하는 것이고, 동등성은 논리적으로 같은지 확인하는 것이다.
동일성은 자바 머신 기준이고 메모리의 참조가 기준이므로 물리적이다. 반면 동등성은 보통 사람이 생각하는 논리적인 기준에 맞추어 비교한다.
Object.equals()
public boolean equals(Object obj) {
return (this == obj);
}
Object 가 기본으로 제공하는 equals() 는 == 으로 동일성 비교를 제공한다.
동등성이라는 개념은 각각의 클래스마다 다르다. 어떤 클래스는 주민등록번호를 기반으로 동등성을 처리할 수 있고, 어떤 클래스는 고객의 연락처를 기반으로 동등성을 처리할 수 있다.
따라서 동등성 비교를 사용하고 싶으면 equals() 메서드를 재정의해야 한다. 그렇지 않으면 Object 는 동일성 비교를 기본으로 제공한다.
IntelliJ를 포함한 대부분의 IDE는 정확한 equals() 코드를 자동으로 만들어준다.
// User 클래스의 id를 기반으로 동등성을 비교.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
clone() : 객체를 복사할 때 사용
hashCode() : equals() 와 hasCode() 는 종종 함께 사용된다.
getClass()
notify(), notifyAll(), wait() : 멀티쓰레드용 메서드이다.
자바의 데이터 타입을 가장 크게 보면 기본형(Primitive Type)과 참조형(Reference Type)으로 나눌 수 있다.
기본형 : 하나의 값을 여러 변수에서 절대로 공유하지 않는다.
참조형 : 하나의 객체를 참조값을 통해 여러 변수에서 공유할 수 있다.
하나의 값을 공유하거나 또는 공유하지 않는 다는 것이 무슨 뜻인지 모르겠으면 이 링크의 글을 읽어보자
사이드 이펙트(Side Effect)는 프로그래밍에서 어떤 계산이 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 말한다.
Address
public class Address {
private String value;
public Address(String value) {
this.value = value;
}
public void setValue(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
Main
Address a = new Address("서울");
Address b = a;
b.setValue("부산");
System.out.println("부산 -> b");
System.out.println("a = " + a); //사이드 이펙트 발생
System.out.println("b = " + b);
실행 결과
부산 -> b
a = Address{value='부산'}
b = Address{value='부산'}

개발자는 b 의 주소값을 서울에서 부산으로 변경할 의도로 값 변경을 시도했다. 하지만 a, b 는 같은 인스턴스를 참조한다. 따라서 a 의 값도 함께 부산으로 변경되어 버린다.
이렇게 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 사이드 이펙트라 한다. 프로그래밍에서 사이드 이펙트는 보통 부정적인 의미로 사용되는데, 사이드 이펙트는 프로그램의 특정 부분에서 발생한 변경이 의도치 않게 다른 부분에 영향을 미치는 경우에 발생한다. 이로 인해 디버깅이 어려워지고 코드의 안정성이 저하될 수 있다.
문제의 해결방안은 아주 단순하다. a 와 b 가 처음부터 서로 다른 인스턴스를 참조하면 된다.
Address a = new Address("서울");
Address b = new Address("서울");
쉽게 이야기해서 여러 변수가 하나의 객체를 공유하지 않으면 사이드 이펙트 문제가 발생하지 않는다. 그런데 여기서 문제가 있다. 하나의 객체를 여러 변수가 공유하지 않도록 강제로 막을 수 있는 방법이 없다는 것이다.
Address a = new Address("서울");
Address b = a; //참조값 대입을 막을 수 있는 방법이 없다.
b = a 로 여러 변수가 하나의 참조값을 공유하는 코드를 작성해도 아무런 문제가 없다. 자바 문법상 문제가 없기 때문에 컴파일 오류도 발생하지 않는다.
객체의 공유를 막을 수 있는 방법이 없다!
사이드 이펙트의 더 근본적인 원인을 고려해보면, 객체를 공유하는 것 자체는 문제가 되지 않는다. 문제의 직접적인 원인은 공유된 객체의 값을 변경한 것에 있다.
객체의 상태(객체 내부의 값, 필드, 멤버 변수)가 변하지 않는 객체를 불변 객체(Immutable Object)라 한다.
ImmutableAddress
public class ImmutableAddress {
private final String value;
public ImmutableAddress(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
내부의 값이 변경되면 안되므로 value 의 필드를 final 로 선언했다.
값을 변경할 수 있는 setValue() 를 제거했다.
이 클래스는 생성자를 통해서만 값을 설정할 수 있고, 이후에는 값을 변경하는 것이 불가능하다.
ImmutableMain
ImmutableAddress a = new ImmutableAddress("서울");
ImmutableAddress b = a; //참조값 대입을 막을 수 있는 방법이 없다.
b = new ImmutableAddress("부산");
System.out.println("부산 -> b");
System.out.println("a = " + a);
System.out.println("b = " + b);
실행 결과
부산 -> b
a = Address{value='서울'}
b = Address{value='부산'}

ImmutableAddress 은 불변 객체이므로 b 가 참조하는 인스턴스의 값을 서울에서 부산으로 변경하려면 새로운 인스턴스를 할당해야 한다.
객체의 공유 참조는 막을 수 없다. 그래서 객체의 값을 변경하면 다른 곳에서 참조하는 변수의 값도 함께 변경되는 사이드 이펙트가 발생한다. 사이드 이펙트가 발생하면 안되는 상황이라면 불변 객체를 만들어서 사용하면 된다. 불변 객체는 값을 변경할 수 없기 때문에 사이드 이펙트가 원천 차단된다.
불변 객체는 값을 변경할 수 없다. 따라서 불변 객체의 값을 변경하고 싶다면 변경하고 싶은 값으로 새로운 불변 객체를 생성해야 한다. 이렇게 하면 기존 변수들이 참조하는 값에는 영향을 주지 않는다.
참고 - 가변(Mutable) 객체 vs 불변(Immutable) 객체
가변은 이름 그대로 처음 만든 이후 상태가 변할 수 있다는 뜻이다. (사전적으로 사물의 모양이나 성질이 달라질 수 있다는 뜻이다.)
불변은 이름 그대로 처음 만든 이후 상태가 변하지 않는다는 뜻이다. (사전적으로 사물의 모양이나 성질이 달라질 수 없다는 뜻이다.)
불변 객체를 사용하지만 그래도 값을 변경해야 하는 메서드가 필요하면 어떻게 해야할까?
예를 들어서 기존 값에 새로운 값을 더하는 add() 와 같은 메서드가 있다.
ImmutableObj
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;
}
}
ImmutableObjMain
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

add(20) 을 호출한다.
기존 객체에 있는 10 과 인수로 입력한 20 을 더한다. 이때 기존 객체의 값을 변경하면 안되므로 계산 결과를 기반으로 새로운 객체를 만들어서 반환한다.
새로운 객체는 x002 참조를 가진다. 새로운 객체의 참조값을 obj2 에 대입한다.
꼭! 반환 값을 받아야 한다.
지금까지 왜 이렇게 불변 객체 이야기를 많이 했을까?
자바에서 가장 많이 사용되는 String 클래스가 바로 불변 객체이기 때문이다. 뿐만 아니라 자바가 기본으로 제공하는 Integer, LocalDate 등 수 많은 클래스가 불변으로 설계되어 있다.
우리가 만드는 대부분의 클래스는 값을 변경할 수 있게 만들어진다. 예를 들어서 회원 클래스의 경우 회원의 여러 속성을 변경할 수 있어야 한다. 가변 클래스가 더 일반적이고, 불변 클래스는 값을 변경하면 안되는 특별한 경우에 만들어서 사용한다고 생각하면 된다. 때로는 같은 기능을 하는 클래스를 하나는 불변으로 하나는 가변으로 각각 만드는 경우도 있따.
클래스를 불변으로 설계하는 이유는 더 많다.
자바에서 문자를 다루는 대표적인 타입은 char, String 2가지가 있다.
기본형인 char 는 문자 하나를 다룰 때 사용한다. char 를 사용해서 여러 문자를 나열하려면 char[] 를 사용해야 한다. 하지만 이렇게 char[] 을 직접 다루는 방법은 매우 불편하기 때문에 자바는 문자열을 매우 편리하게 다룰 수 있는 String 클래스를 제공한다.
String 클래스를 통해 문자열을 생성하는 방법String str1 = "hello";
String str2 = new String("hello");
String 은 클래스다. int, boolean 같은 기본형이 아니라 참조형이다. 따라서 str1 변수에는 String 인스턴스의 참조값만 들어갈 수 있다.
문자열은 매우 자주 사용된다. 그래서 편의상 쌍따옴표로 문자열을 감싸면 자바 언어에서 new String("hello") 와 같이 변경 해준다.
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() {...}
...
}
private final char[] value;
여기에는 String 의 실제 문자열 값이 보관된다. 문자 데이터 자체는 char[] 에 보관된다.
String 클래스는 개발자가 직접 다루기 불편한 char[] 을 내부에 감추고 String 클래스를 사용하는 개발자가 편리하게 문자열을 다룰 수 있도록 다양한 기능을 제공한다. 그리고 메서드 제공을 넘어서 자바 언어 차원에서도 여러 편의 문법을 제공한다.
참고 : 자바 9 이후
String클래스 변경 사항자바 9부터는
String클래스에서char[]대신에byte[]을 사용한다.private final byte[] value;자바에서 문자 하나를 표현하는
char는2byte를 차지한다. 그런데 영어, 숫자는 보통1byte로 표현이 가능하다. 그래서 단순 영어, 숫자로만 표현된 경우1byte를 사용하고, 그렇지 않은 나머지의 경우2byte인 UTF-16 인코딩을 사용한다. 따라서 메모리를 더 효율적으로 사용할 수 있게 변경되었다.
String 클래스는 문자열로 처리할 수 있는 다양한 기능을 제공한다.
length() : 문자열의 길이를 반환한다.
charAt(int index) : 특정 index 의 문자를 반환한다.
subString(int beginIndex, int endIndex) : 문자열의 부분 문자열을 반환한다.
indexOf(String str) : 특정 문자열이 시작되는 인덱스를 반환한다.
toLowerCase(), toUpperCase() : 문자열을 소문자 또는 대문자로 변환한다.
trim() : 문자열 양 끝의 공백을 제거한다.
concat(String str) : 문자열을 더한다.
String 클래스와 참조형String 은 클래스이다. 따라서 기본형이 아니라 참조형이다.
참조형은 변수에 계산할 수 있는 값이 들어있는 것이 아니라 x001 과 같이 계산할 수 없는 참조값이 들어있다. 따라서 원칙적으로 + 같은 연산을 사용할 수 없다.
자바에서 문자열을 더할 때는 String 이 제공하는 concat() 과 같은 메서드를 사용해야 한다. 하지만 문자열은 너무 자주 다루어지기 때문에 자바 언어에서 특별히 + 연산을 제공한다.
String 클래스 - 비교String 클래스 비교할 때는 == 비교가 아니라 항상 equals() 비교를 해야 한다.
동일성(Indentity) : == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인
동등성(Equality) : equals() 메서드를 사용하여 두 객체가 논리적으로 같은지 확인
Main
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println("new String() == 비교: " + (str1 == str2));
System.out.println("new String() equals 비교: " + (str1.equals(str2)));
String str3 = "hello";
String str4 = "hello";
System.out.println("리터럴 == 비교: " + (str3 == str4));
System.out.println("리터럴 equals 비교: " + (str3.equals(str4)));
실행 결과
new String() == 비교: false
new String() equals 비교: true
리터럴 == 비교: true
리터럴 equals 비교: true
String 은 클래스라고 하였다. 그러므로 참조값이 들어가 있을 것이다. 하지만 str3 == str4 의 결과 값을 보면 true 가 나온다. 위에서 설명한대로면 쌍따옴표로 문자열을 감싸면 자바에서 new String("hello") 와 같이 변경해준다고 하였으므로 둘의 동일성을 비교하는 == 은 결과값이 false 가 나와야 맞다고 생각할 수 있다. 지금까지 공부한 내용대로 라면 이 생각이 만지만 여기에는 함정이 있다.

String str3 = "hello" 와 같이 문자열 리터널을 사용하는 경우 자바는 메모리 효율성과 성능 최적화를 위해 문자열 풀을 사용한다.
자바가 실행되는 시점에 클래스에 문자열 리터널이 있으면 문자열 풀에 String 인스턴스를 미리 만들어둔다. 이때 같은 문자열이 있으면 만들지 않는다.
String str3 = "hello" 와 같이 문자열 리터널을 사용하면 문자열 풀에서 "hello" 라는 문자를 가진 String 인스턴스를 찾는다. 그리고 찾은 인스턴스의 참조(x003)을 반환한다.
String str4 = "hello" 의 경우 "hello" 문자열 리터널을 사용하므로 문자열 풀에서 str3 과 같은 x003 참조를 사용한다.
문자열 풀 덕분에 같은 문자를 사용하는 경우 메모리 사용을 줄이고 문자를 만드는 시간도 줄어들기 때문에 성능도 최적화 할 수 있다.
따라서 문자열 리터널의 경우 같은 참조값을 가지므로 == 비교에 성공한다.
참고
풀(Pool)은 자원이 모여있는 곳을 의미한다. 프로그래밍에서 풀(Pool)은 공용 자원을 모아둔 곳을 뜻한다. 여러 곳에서 함께 사용할 수 있는 객체를 필요할 때 마다 생성하고, 제거하는 것은 비효율적이다. 대신에 이렇게 문자열 풀에 필요한
String인스턴스를 미리 만들어두고 여러곳에서 재사용할 수 있다면 성능과 메모리를 더 최적화 할 수 있다.참고로 문자열 풀은 힙 영역을 사용한다. 그리고 문자열 풀에서 문자를 찾을 때는 해시 알고리즘을 사용하기 때문에 매우 빠른 속도로 원하는
String인스턴스를 찾을 수 있다.
문자열 리터럴을 사용하면 == 비교를 하고, new String() 을 직접 사용하는 경우에만 equals() 비교를 사용하면 되지 않을까?
이제 String 클래스를 생성하는 개발자와 생성된 String 을 사용하는 개발자가 다르다고 생각해보자. 그러면 String 을 사용하는 개발자는 비교를 하려고 할때 이 String 이 리터널로 생성됐는지 new String() 으로 생성됐는지 확인할 수 있는 방법이 없다. 따라서 문자열 비교는 항상 equals() 를 사용해서 동등성 비교를 해야 한다.
String 클래스 - 불변 객체String 은 불변 객체이다. 따라서 생성 이후에 절대로 내부의 문자열 값을 변경할 수 없다.
Main
String str = "hello";
str.concat(" java");
System.out.println("str = " + str);
실행 결과
str = hello
실행 결과를 보면 문자가 전혀 합쳐지지 않았다.
String str2 = str1.concat(" java");
String 은 불변 객체이다. 따라서 변경이 필요한 경우 기존 값을 변경하지 않고, 대신에 새로운 결과를 만들어서 반환한다.
String.concat() 은 내부에서 새로운 String 객체를 만들어서 반환한다.
따라서 불변과 기존 객체의 값을 유지한다.
String 이 불변으로 설계된 이유가 뭘까?String 이 불변으로 설계된 이유는 앞서 불변 객체에서 배운 내용에 추가로 다음과 같은 이유가 있다.
문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면 같은 문자열을 참고하는 다른 변수의 값도 함께 변경된다.

String 은 자바 내부에서 문자열 풀을 통해 최적화를 한다.
만약 String 내부의 값을 변경할 수 있다면, 기존에 문자열 풀에서 같은 문자를 참조하는 변수의 모든 문자가 함께 변경되어 버리는 문제가 발생한다. str3 이 참조하는 문자를 변경하면 str4 의 문자도 함께 변경되는 사이드 이펙트 문제가 발생한다.
String 클래스 - 주요 메서드String 클래스는 문자열을 편리하게 다루기 위한 다양한 메서드를 제공한다
length() : 문자열의 길이를 반환한다.isEmpty() : 문자열이 비어 있는지 확인한다. (길이가 0)isBlank() : 문자열이 비어 있는지 확인한다. (길이가 0이거나 공백(Whitespace)만 있는 경우), 자바 11charAt(int index) : 지정된 인덱스에 있는 문자를 반환한다.equals(Object anObject) : 두 문자열이 동일한지 비교한다.equalsIgnoreCase(String anotherString) : 두 문자열을 대소문자 구분 없이 비교한다.compareTo(String anotherString) : 두 문자열을 사전 순으로 비교한다.compareToIgnoreCase(String str) : 두 문자열을 대소문자 구분 없이 사전적으로 비교한다.startsWith(String prefix) : 문자열이 특정 접두사로 시작하는지 확인한다.endsWith(String suffix) : 문자열이 특정 접미사로 끝나는지 확인한다.contains(CharSequence s) : 문자열이 특정 문자열을 포함하고 있는지 확인한다.indexOf(String ch) / indexOf(String ch, int fromIndex) : 문자열이 처음 등장하는 위치를 반환한다.lastIndexOf(String ch) : 문자열이 마지막으로 등장하는 위치를 반환한다.substring(int beginIndex) / substring(int beginIndex, int endIndex) : 문자열의 부분 문자열을 반환한다.concat(String str) : 문자열의 끝에 다른 문자열을 붙인다.replace(CharSequence target, CharSequence replacement) : 특정 문자열을 새 문자열로 대체한다.replaceAll(String regex, String replacement) : 문자열에서 정규 표현식과 일치하는 부분을 새 문자열로 대체한다.replaceFirst(String regex, String replacement) : 문자열에서 정규 표현식과 일치하는 첫 번째 부분을 새 문자열로 대체한다.toLowerCase() / toUpperCase() : 문자열을 소문자나 대문자로 변환한다.trim() : 문자열 양쪽 끝의 공백을 제거한다. 단순 Whitespace 만 제거할 수 있다.strip() : Whitespace 와 유니코드 공백을 포함해서 제거한다. 자바 11split(String regex) : 문자열을 정규 표현식을 기준으로 분할한다.join(CharSequence delimiter, CharSequence... elements) : 주어진 구분자로 여러 문자열을 결합한다.valueOf(Object obj) : 다양한 타입을 문자열로 변환한다.toCharArray() : 문자열을 문자 배열로 변환한다.format(String format, Object... args) : 형식 문자열과 인자를 사용하여 새로운 문자열을 생성한다.matches(String regex) : 문자열이 주어진 정규 표현식과 일치하는지 확인한다.참고 :
CharSequence는String, StringBuilder의 상위 타입이다. 문자열을 처리하는 다양한 객체를 받을 수 있다.
StringBuilder - 가변 StringString 클래스의 단점문자를 더하는 경우
String str = "A" + "B" + "C" + "D";
String str = String("A") + String("B") + String("C") + String("D");
String str = new String("AB") + String("C") + String("D");
String str = new String("ABC") + String("D");
String str = new String("ABCD");
이 경우 총 3 개의 String 클래스가 추가로 생성된다.
그런데 문제는 중간에 만들어진 new String("AB"), new String("ABC") 는 사용되지 않는다. 최종적으로 만들어진 new String("ABCD") 만 사용된다.
결과적으로 중간에 만들어진 new String("AB"), new String("ABC") 는 사용되지도 않고, 이후에 GC의 대상이 된다.
불변인 String 클래스의 단점은 문자를 더하거나 변경할 때 마다 계속해서 새로운 객체를 생성해야 한다는 점이다. 문자를 자주 더하거나 변경해야 하는 상황이라면 더 많은 String 객체를 만들고, GC 해야 한다. 결과적으로 컴퓨터의 CPU, 메모리 자원을 더 많이 사용하게 된다. 그리고 문자열의 크기가 클수록, 문자열을 더 자주 변경할수록 시스템의 자원을 더 많이 소모한다.
StringBuilder이 문제를 해결하는 방법은 단순한다. 바로 불변이 아닌 가변 String 이 존재하면 된다. 가변은 내부의 값을 바로 변경하면 되기 때문에 새로운 객체를 생성할 필요가 없다. 따라서 성능과 메모리 사용면에서 불변보다 더 효율적이다.
이런 문제를 해결하기 위해 자바는 StringBuilder 라는 가변 String 을 제공한다. 물론 가변의 경우 사이드 이펙트에 주의해서 사용해야 한다.
StringBuilder
public final class StringBuilder {
char[] value;// 자바 9 이전
byte[] value;// 자바 9 이후
//여러 메서드
public StringBuilder append(String str) {...}
public int length() {...}
...
}
StringBuilder 는 내부에 final 이 아닌 변경할 수 있는 byte[] 을 가지고 있다.
Main
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);
//StringBuilder -> String
String string = sb.toString();
System.out.println("string = " + string);
StringBuilder 객체를 생성한다.append() 메서드를 사용해 여러 문자열을 추가한다.insert() 메서드로 특정 위치에 문자열을 삽입한다.delete() 메서드로 특정 범위의 문자열을 삭제한다.reverse() 메서드로 문자열을 뒤집는다.toString() 메소드를 사용해 StringBuilder 의 결과를 기반으로 String 을 생성해서 반환한다.실행 결과
sb = ABCD
insert = ABCDJava
delete = ABCD
reverse = DCBA
string = DCBA
String 은 불변하다. 즉, 한 번 생성되면 그 내용을 변경할 수 없다. 따라서 문자열에 변화를 주려고 할 때마다 새로운 String 객체가 생성되고, 기존 객체는 버려진다. 이 과정에서 메모리와 처리 시간을 더 많이 소모한다.
반면에, StringBuilder 는 가변적이다. 하나의 StringBuilder 객체 안에서 문자열을 추가, 삭제, 수정할 수 있으며, 이때마다 새로운 객체를 생성하지 않는다. 이로 인해 메모리 사용을 줄이고 성능을 향상시킬 수 있다. 단 사이드 이펙트를 주의해야 한다.
StringBuilder 는 보통 문자열을 변경하는 동안만 사용하다가 문자열 변경이 끝나면 안전한(불변) String 으로 변환하는 것이 좋다.
String 최적화String 최적화컴파일 전
String helloWorld = "Hello, " + "World!";
컴파일 후
String helloWorld = "Hello, World!";
자바는 문자열 리터널을 더하는 부분을 자동으로 합쳐준다. 따라서 런타임에 별도의 문자열 결합 연산을 수행하지 않기 때문에 성능이 향상된다.
String 변수 최적화문자열 변수의 경우 그 안에 어떤 값이 들어있는지 컴파일 시점에는 알 수 없기 때문에 단순하게 합칠 수 없다.
String result = str1 + str2;
-> 최적화
String result = new StringBuilder().append(str1).append(str2).toString();
자바에서 StringBuillder 로 변환하여 문자열을 더한뒤 다시 불변 String 으로 변환하여 최적화를 수행한다.
이렇듯 자바가 최적화를 처리해주기 때문에 지금처럼 간단한 경우에는 StringBuilder 를 사용하지 않아도 된다. 대신에 문자열 더하기(+)을 사용하면 충분한다.
String 최적화가 어려운 경우루프안에서 문자열을 더하는 경우에는 최적화가 이루어지지 않는다.
String result = "";
for (int i = 0; i < 100000; i++) {
result += "Hello Java ";
}
----> 최적화
String result = "";
for (int i = 0; i < 100000; i++) {
result = new StringBuilder().append(result).append("Hello Java").toString();
}
자바에서의 최적화는 위의 코드까지만 최적화된다.
반복문의 루프 내부에서는 최적화가 되는 것처럼 보이지만, 반복 횟수만큼 객체를 생성해야 한다.
반복문 내에서의 문자열 연결은, 런타임에 연결할 문자열의 개수와 내용이 결정된다. 이런 경우, 컴파일러는 얼마나 많은 반복이 일어날지, 각 반복에서 문자열이 어떻게 변할지 예측할 수 없다. 따라서, 이런 상황에서는 최적화가 어렵다.
이럴 때 직접 StringBuilder 를 사용해서 최적화를 해준다.
StringBuilder sb = new StringBuiler();
for (int i = 0; i < 100000; i++) {
sb.append("Hello Java ");
}
String result = sb.toString();
반복문에서 반복해서 문자를 연결할 때
조건문을 통해 동적으로 문자열을 조합할 때
복잡한 문자열의 특정 부분을 변경해야 할 때
매우 긴 대용량 문자열을 다룰 때
참고:
StringBuildervsStringBuffer
StrinBuilder와 똑같은 기능을 수행하는StringBuffer클래스도 있다.
StringBuffer는 내부에 동기화가 되어 있어서, 멀티 스레드 상황에서 안전하지만 동기화 오버헤드로 인해 성능이 느리다.
StringBuilder는 멀티 쓰레드 상황에서 안전하지 않지만 동기화 오버헤드가 없으므로 속도가 빠르다.
ValueAdder
public class ValueAdder {
private int value;
public ValueAdder add(int addValue) {
value += addValue;
return this;
}
public int getValue() {
return value;
}
}
add() 메서드를 보면 자기 자신(this)의 참조값을 반환한다.Main
ValueAdder adder = new ValueAdder();
adder.add(1);
adder.add(2);
adder.add(3);
add() 메서드의 반환값을 사용하는 코드로 변경해보자.ValueAdder adder = new ValueAdder();
int result = adder.add(1).add(2).add(3).getValue();
실행 순서
add() 메서드를 호출하면 ValueAdder 인스턴스 자신의 참조값(x001)이 반환된다. 이 반환된 참조값을 변수에 담아두지 않아도 된다. 대신에 반환된 참조값을 즉시 사용해서 바로 메서드를 호출할 수 있다.
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()
메서드 호출의 결과로 자기 자신의 참조값을 반환하면, 반환되 참조값을 사용해서 메서드 호출을 계속 이어갈 수 있다. 코드를 보면 . 을 찍고 메서드를 계속 연결해서 사용한다. 마치 메서드가 체인으로 연결된 것 처럼 보인다. 이러한 기법을 메서드 체이닝이라고 한다.
메서드 체이닝 기법은 코드를 간결하고 읽기 쉽게 만들어준다.
StringBuilder 와 메서드 체인(Chain)StringBuilder 는 메서드 체이닝 기법을 제공한다.
String string = sb.append("A").append("B").append("C").append("D")
.insert(4, "Java")
.delete(4, 8)
.reverse()
.toString();
"만드는 사람이 수고로우면 쓰는 사람이 편하고, 만드는 사람이 편하면 쓰는 사람이 수고롭다"
문제 - 검색 count
문제 설명
str에서key로 주어지는 문자를 찾고, 찾은 문자의 수를 출력해라.
indexOf()를 반복문과 함께 풀면 된다.String str = "start hello java, hello spring, hello jpa"; String key = "hello"; int count = 0; int index = str.indexOf(key); while (index >= 0) { index = str.indexOf(key, index + 1); count++; } System.out.println("count = " + count);
기본형의 한계
자바는 객체 지향 언어이다. 그런데 자바 안에 객체가 아닌 것이 있다. 바로 int, double 같은 기본형(Primitive Type)이다.
기본형은 객체가 아니기 때문에 다음과 같은 한계가 있다.
객체가 아님 : 기본형 데이터는 객체가 아니기 때문에, 객체 지향 프로그래밍의 장점을 살릴 수 없다. 예를 들어 객체는 유용한 메서드를 제공할 수 있는데, 기본형은 객체가 아니므로 메서드를 제공할 수 없다.
null 값을 가질 수 없음 : 기본형 데이터 타입은 null 값을 가질 수 없다. 때로는 데이터가 없음 이라는 상태를 나타내야 할 필요가 있는데, 기본형은 항상 값을 가지기 때문에 이런 표현을 할 수 없다.
기본형은 항상 값이 존재해야 한다. 숫자의 경우 0, 1 같은 값이라도 항상 존재해야 한다. 반면에 객체인 참조형은 값이 없다는 null 을 사용할 수 있다. 물론 null 값을 반환하는 경우 잘못하면 NullPointerException 이 발생할 수 있기 때문에 주의해서 사용해야 한다.
래퍼 클래스는 기본형의 객체 버전이다.
자바는 기본형에 대응하는 래퍼 클래스를 기본으로 제공한다.
byte -> Byteshort -> Shortint -> Integerlong -> Longfloat -> Floatdouble -> Doublechar -> Characterboolean -> Boolean불변이다.
equals 로 비교해야 한다.
Integer newInteger = new Integer(10); //미래에 삭제 예정, 대신에 valueOf() 사용
Integer integerObj = Integer.valueOf(10); //-128 ~ 127 자주 사용하는 숫자 값 재사용, 불변
Long longObj = Long.valueOf(100);
Double doubleObj = Double.valueOf(10.5);
기본형을 래퍼 클래스로 변경하는 것을 마치 박스에 물건을 넣은 것 같다고 해서 박싱(Boxing)이라 한다.
new Integer(10) 은 직접 사용하면 안된다. 작동은 하지만, 향후 자바에서 제거될 예정이다.
대신에 Integer.valuOf(10) 를 사용하면 된다.
new Integer(10)을 사용해서 객체를 생성하고 돌려준다.추가로 Integer.valueOf() 에는 성능 최적화 기능이 있다. 개발자들이 일반적으로 자주 사용하는 -128 ~ 127 범위의 Integer 클래스를 미리 생성해준다. 해당 범위의 값을 조회하면 미리 생성된 Integer 객체를 반환한다. 해당 범위의 값이 없으면 new Integer() 를 호출한다.
마치 문자열 풀과 비슷하게 자주 사용하는 숫자를 미리 생성해두고 재사용한다.
참고로 이런 최적화 방식은 미래에 더 나은 방식으로 변경될 수 있다.
== 비교를 하면 인스턴스의 참조값을 비교한다.equals() 를 재정의 해두었다. 따라서 값을 비교하려면 equals() 를 사용해야 한다.개발자들이 오랜기간 개발을 하다 보니 기본형을 래퍼 클래스로 변환하거나 또는 래퍼 클래스를 기본형으로 변환하는 일이 자주 발생했다. 그래서 많은 개발자들이 불편함을 호소했다.
자바는 이런 문제를 해결하기 위해 자바 1.5부터 오토 박싱(Auto-boxing), 오토 언박싱(Auto-Unboxing)을 지원한다.
Integer boxedValue = value; //오토 박싱(Auto-boxing)
Integer boxedValue = Integer.valueOf(value); //컴파일 단계에서 추가
int unboxedValue = boxedValue; //오토 언박싱(Auto-Unboxing)
int unboxedValue = boxedValue.intValue(); //컴파일 단계에서 추가
Integer i1 = Integer.valueOf(10);//숫자, 래퍼 객체 반환
Integer i2 = Integer.valueOf("10");//문자열, 래퍼 객체 반환
int intValue = Integer.parseInt("10");//문자열 전용, 기본형 반환
//비교
int compareResult = i1.compareTo(20);
System.out.println("compareResult = " + compareResult);
//산술 연산
System.out.println("sum: " + Integer.sum(10, 20));
System.out.println("min: " + Integer.min(10, 20));
System.out.println("max: " + Integer.max(10, 20));
valueOf() : 래퍼 타입을 반환한다. 숫자, 문자열을 모두 지원한다.
parseInt() : 문자열을 기본형으로 변환한다.
compareTo() : 내 값과 인수로 넘어온 값을 비교한다. 내 값이 크면 1, 같으면 0, 작으면 -1을 반환한다.
Integer.sum(), Integer.min(), Integer.max() : static 메서드이다. 간단한 덧셈, 작은 값, 큰 값 연산을 수행한다.
원하는 타입에 맞는 메서드를 사용하면 된다.
valueOf("10") 는 래퍼 타입을 반환한다.parseInt("10") 는 기본형을 반환한다.그렇다면 더 좋은 래퍼 클래스만 제공하면 되지 기본형을 제공하는 이유는 무엇일까?
기본형은 메모리에서 단순히 그 크기만큼의 공간을 차지한다.
래퍼 클래스의 인스턴스는 내부에 필드로 가지고 있는 기본형의 값 뿐만 아니라 자바에서 객체 자체를 다루는데 필요한 객체 메타데이터를 포함하므로 더 많은 메모리를 사용한다.
유지보수 vs 최적화를 고려해야 하는 상황이라면 유지보수하기 좋은 코드를 먼저 고민해야 한다. 특히 최신 컴퓨터는 매우 빠르기 때문에 메모리 상에서 발생하는 연산을 몇 번 줄인다고해도 실질적인 도움이 되지 않는 경우가 많다.
코드 변경 없이 성능 최적화를 하면 가장 좋겠지만, 성능 최적화는 대부분 단순함 보다는 복잡함을 요구하고, 더 많은 코드들을 추가로 만들어야 한다. 최적화를 위해 유지보수 해야 하는 코드가 더 늘어나는 것이다. 그런데 진짜 문제는 최적화를 한다고 했지만 전체 애플리케이션의 성능 관점에서 보면 불필요한 최적화를 할 가능성이 있다.
특히 웹 애플리케이션의 경우 메모리 안에서 발생하는 연산 하나보다 네트워크 호출 한 번이 많게는 수십만배 더 오래 걸린다. 자바 메모리 내부에서 발생하는 연산을 수천번에서 한 번으로 줄이는 것 보다, 네트워크 호출 한 번을 더 줄이는 것이 더 효과적인 경우가 많다.
권장하는 방법은 개발 이후에 성능 테스트를 해보고 정말 문제가 되는 부분을 찾아서 최적화 하는 것이다.
자바에서 Class 클래스는 클래스의 정보(메타데이터)를 다루는데 사용된다. Class 클래스를 통해 개발자는 실행중인 자바 어플리케이션 내에서 필요한 클래스의 속성과 메서드에 대한 정보를 조회하고 조작할 수 있다.
타입 정보 얻기 : 클래스의 이름, 슈퍼클래스, 인터페이스, 접근 제한자 등과 같은 정보를 조회할 수 있다.
리플렉션 : 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고, 이들을 통해 객체 인스턴스를 생성하거나 메서드를 호출하는 등의 작업을 할 수 있다.
동적 로딩과 생성: Class.forName() 메서드를 사용하여 클래스를 동적으로 로드하고, newInstance() 메서드를 통해 새로운 인스턴스를 생성할 수 있다.
애노테이션 처리: 클래스에 적용된 애노테이션(annotation)을 조회하고 처리하는 기능을 제공한다.
//Class 조회
Class clazz = String.class; // 1.클래스에서 조회
//Class clazz = new String().getClass();// 2.인스턴스에서 조회
//Class clazz = Class.forName("java.lang.String"); // 3.문자열로 조회
// 모든 필드 출력
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
System.out.println("Field: " + field.getType() + " " + field.getName());
}
// 모든 메서드 출력
Method[] methods = clazz.getDeclaredMethods();
for (Method method : methods) {
System.out.println("Method: " + method);
}
// 상위 클래스 정보 출력
System.out.println("Superclass: " + clazz.getSuperclass().getName());
// 인터페이스 정보 출력
Class[] interfaces = clazz.getInterfaces();
for (Class i : interfaces) {
System.out.println("Interface: " + i.getName());
}
실행 결과
Field: class [B value
...
Method: public boolean java.lang.String.equals(java.lang.Object)
Method: public int java.lang.String.length()
...
Superclass: java.lang.Object
Interface: java.io.Serializable
Interface: java.lang.Comparable
...
Class 클래스의 주요 기능
Class를 사용하면 클래스의 메타 정보를 기반으로 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고, 이들을 통해 객체 인스턴스를 생성하거나 메서드를 호출하는 작업을 할 수 있다. 이러한 작업을 리플렉션이라 한다. 추가로 애노테이션 정보를 읽어서 특별한 기능을 수행할 수도 있다. 최신 프레임워크들은 이런 기능을 적극 활용한다.
System 클래스는 시스템과 관련된 기본 기능들을 제공한다.
// 현재 시간(밀리초)를 가져온다.
long currentTimeMillis = System.currentTimeMillis();
System.out.println("currentTimeMillis: " + currentTimeMillis);
// 현재 시간(나노초)를 가져온다.
long currentTimeNano = System.nanoTime();
System.out.println("currentTimeNano: " + currentTimeNano);
// 환경 변수를 읽는다.
System.out.println("getenv = " + System.getenv());
// 시스템 속성을 읽는다.
System.out.println("properties = " + System.getProperties());
System.out.println("Java version: " +
System.getProperty("java.version"));
// 배열을 고속으로 복사한다.
char[] originalArray = new char[]{'h', 'e', 'l', 'l', 'o'};
char[] copiedArray = new char[5];
System.arraycopy(originalArray, 0, copiedArray, 0, originalArray.length);
// 배열 출력
System.out.println("copiedArray = " + copiedArray);
System.out.println("Arrays.toString = " + Arrays.toString(copiedArray));
//프로그램 종료
System.exit(0);
실행 결과
currentTimeMillis: 1703570732276
currentTimeNano: 286372106104583
getenv = {IDEA_INITIAL_DIRECTORY=/, COMMAND_MODE=unix2003, LC_CTYPE=ko_KR.UTF-8,
SHELL=/bin/zsh, HOME=/Users/yh, PATH=/opt/homebrew/bin:/usr/local/bin: ...}
properties = {java.specification.version=21, java.version=21.0.1, sun.jnu.encoding=UTF-8, os.name=Mac OS X, file.encoding=UTF-8 ...}
Java version: 21.0.1
copiedArray = [C@77459877
Arrays.toString = [h, e, l, l, o]
표준 입력, 출력, 오류 스트림 : System.in, System.out, System.err 은 각각 표준 입력, 표준 출력, 표준 오류 스트림을 나타낸다.
시간 측정 : System.currentTimeMillis() 와 System.nanoTime() 은 현재 시간을 밀리초 또는 나노초 단위로 제공한다.
환경 변수 : System.getenv() 메서드를 사용하여 OS에서 설정한 환경 변수의 값을 얻을 수 있다.
시스템 속성 : System.getProperties() 를 사용해 현재 시스템 속성을 얻거나 System.getProperty(String key) 로 특정 속성을 얻을 수 있다. 시스템 속성은 자바에서 사용하는 설정 값이다.
시스템 종료 : System.exit(int status) 메서드는 프로그램을 종료하고, OS에 프로그램 종료의 상태 코드를 전달한다.
0 : 정상 종료0 이 아님 : 오류나 예외적인 종료배열 고속 복사 : System.arraycopy 는 시스템 레벨에서 최적화된 메모리 복사 연산을 사용한다. 직접 반복문을 사용해서 배열을 복사할 때 보다 수 배 이상 빠른 성능을 제공한다.
고객은 3등급으로 나누고, 상품 구매시 등급별로 할인을 적용한다.
BASIC -> 10% 할인
GOLD -> 20% 할인
DIAMOND -> 30% 할인
회원 등급과 가격을 입력하면 할인 금액을 계산해주는 클래스를 만들어보자.
public int discount(String grade, int price) {
int discountPercent = 0;
if (grade.equals("BASIC")) {
discountPercent = 10;
} else if (grade.equals("GOLD")) {
discountPercent = 20;
} else if (grade.equals("DIAMOND")) {
discountPercent = 30;
} else {
System.out.println(grade + ": 할인X");
}
return price * discountPercent / 100;
}
}
위의 discount 메서드는 문자열을 입력받아 각각의 회원 등급에 맞는 할인을 적용한다.
지금과 같이 단순히 문자열을 입력하는 방식은, 오타가 발생하기 쉽고, 유효하지 않는 값이 입력될 수 있다.
타입 안정성 부족 : 문자열은 오타가 발생하기 쉽고, 유요하지 않은 값이 입력될 수 있다.
데이터 일관성 : "GOLD", "gold", "Gold" 등 다양한 형식으로 문자열을 입력할 수 있어 일관성이 떨어진다.
값의 제한 부족 : String 으로 상태나 카테고리를 표현하면, 잘못된 문자열을 실수로 입력할 가능성이 있다. (예 오타)
컴파일 시 오류 감지 불가 : 이러한 잘못된 값은 컴파일 시에는 감지되지 않고, 런타임에서만 문제가 발견되기 때문에 디버깅이 어려워질 수 있다.
public class StringGrade {
public static final String BASIC = "BASIC";
public static final String GOLD = "GOLD";
public static final String DIAMOND = "DIAMOND";
}
상수는 미리 정의한 변수명을 사용할 수 있기 때문에 문자열을 직접 사용하는 것 보다는 더 안전하다.
이제 discount 메서드에 회원 등급을 보낼때 StringGrade.BASIC 처럼 상수로 보내면 된다.
하지만 문자열 상수를 사용해도, 지금까지 발생한 문제들을 근본적으로 해결할 수는 없다. 왜냐하면 String 타입은 어떤 문자열이든 입력할 수 있기 때문이다. 즉 StringGrade 에 정의된 문자열 상수를 사용하지 않고 직접 문자열을 사용해도 막을 수 있는 방법은 없다.
타입 안전 열거형 패턴 - Type-Safe Enum Pattern
타입 안전 열거형 패턴을 사용하여 회원 등급 BASIC, GOLD, DIAMOND 를 나열하면 이렇게 나열한 항목만 사용할 수 있다.
public class ClassGrade {
public static final ClassGrade BASIC = new ClassGrade();
public static final ClassGrade GOLD = new ClassGrade();
public static final ClassGrade DIAMOND = new ClassGrade();
}

static 이므로 애플리케이션 로딩 시점에 ClassGrade 인스턴스가 3개 생성되고, 각각의 상수는 같은 ClassGrade 타입의 서로 다른 인스턴스의 참조값을 가진다.
public int discount(ClassGrade classGrade, int price) {
int discountPercent = 0;
if (classGrade == ClassGrade.BASIC) {
discountPercent = 10;
}
else if (classGrade == ClassGrade.GOLD) {
discountPercent = 20;
}
else if (classGrade == ClassGrade.DIAMOND) {
discountPercent = 30;
}
else {
System.out.println("할인X");
}
return price * discountPercent / 100;
}
private 생성자
그런데 이 방식은 외부에서 임의로 ClassGrade 의 인스턴스를 생성할 수 있다는 문제가 있다.
ClassGrade newClassGrade = new ClassGrade(); // 생성자 private을 막아야 함
discount(newClassGrade, price);
public class ClassGrade {
public static final ClassGrade BASIC = new ClassGrade();
public static final ClassGrade GOLD = new ClassGrade();
public static final ClassGrade DIAMOND = new ClassGrade();
// private 생성자 추가
private ClassGrade() {}
}
private 생성자 사용해서 외부에서 ClassGrade 를 임의로 생성하지 못하게 막았다.
private 생성자 덕분에 ClassGrade 의 인스턴스를 생성하는 것은 ClassGrade 클래스 내부에서만 할 수 있다. 앞서 우리가 정의한 상수들은 ClassGrade 클래스 내부에서 ClassGrade 객체를 생성한다.
이제 ClassGrade 인스턴스를 사용할 때는 ClassGrade 내부에 정의한 상수를 사용해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
쉽게 이야기해서 ClassGrade 타입에 값을 전달할 때는 우리가 앞서 열거한 BASIC, GOLD, DIAMOND 만 사용할 수 있다.
타입 안정성 향상 : 정해진 객체만 사용할 수 있기 때문에, 잘못된 값을 입력하는 문제를 근본적으로 방지할 수 있다.
데이터 일관성 : 정해진 객체만 사용하므로 데이터의 일관성이 보장된다.
제한된 인스턴스 생성 : 클래스 사전에 정의된 몇 개의 인스턴스만 생성하고, 외부에서는 이 인스턴스들만 사용할 수 있도록 한다. 이를 통해 미리 정의된 값들만 사용하도록 보장한다.
타입 안정성 : 이 패턴을 사용하면, 잘못된 값이 할당되거나 사용되는 것을 컴파일 시점에 방지할 수 있다.
단점
이 패턴을 구현하려면 많은 코드를 작성해야 한다. 그리고 private 생성자를 추가하는 등 유의해야 하는 부분들도 있다.
자바는 타입 안전 열거형 패턴을 매우 편리하게 사용할 수 있는 열거형(Enum Type)을 제공한다.
public enum Grade {
BASIC, GOLD, DIAMOND
}
class 대신에 enum 을 사용한다.java.lang.Enum을 상속 받는다.public int discount(Grade grade, int price) {
int discountPercent = 0;
if (grade == Grade.BASIC) {
discountPercent = 10;
}
else if (grade == Grade.GOLD) {
discountPercent = 20;
}
else if (grade == Grade.DIAMOND) {
discountPercent = 30;
}
else {
System.out.println("할인X");
}
return price * discountPercent / 100;
}
열거형(ENUM)의 장점
타입 안정성 향상 : 열거형은 사전에 정의된 상수들로만 구성되므로, 유요하지 않은 값이 입력될 가능성이 없다. 이런 경우 컴파일 오류가 발생한다.
간결성 및 일관성 : 열거형을 사용하면 코드가 더 간결하고 명확해지며, 데이터의 일관성이 보장된다.
확장성 : 새로운 회원 등급을 타입을 추가하고 싶을 때, ENUM에 새로운 상수를 추가하지만 하면 된다.
모든 열거형은 java.lang.Enum 클래스를 자동으로 상속 받는다. 따라서 해당 클래스가 제공하는 기능들을 사용할 수 있다.
//모든 ENUM 반환
Grade[] values = Grade.values();
System.out.println("values = " + Arrays.toString(values));
for (Grade value : values) {
System.out.println("name=" + value.name() + ", ordinal=" + value.ordinal());
}
//String -> ENUM 변환, 잘못된 문자면 IllegalArgumentException 발생
String input = "GOLD";
Grade gold = Grade.valueOf(input);
System.out.println("gold = " + gold); //toString() 오버라이딩 가능
실행 결과
values = [BASIC, GOLD, DIAMOND]
name=BASIC, ordinal=0
name=GOLD, ordinal=1
name=DIAMOND, ordinal=2
gold = GOLD
ENUM - 주요 메서드
values() : 모든 ENUM 상수를 포함하는 배열을 반환한다.
valueOf(String name) : 주어진 이름과 일치하는 ENUM 상수를 반환한다.
name() : ENUM 상수의 이름을 문자열로 반환한다.
toString() : ENUM 상수의 이름을 문자열로 반환한다. name() 메서드와 유사하지만, toString() 은 직접 오버라이드 할 수 있다.
discount 메서드에서 할인율(discountPercent)은 각각의 회원 등급별로 판단된다. 할인율은 결국 회원 등급을 따라간다. 따라서 회원 등급 클래스가 할인율을 가지고 관리하도록 변경하자.
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() 메서드를 추가했다. 열거형도 클래스이므로 메서드를 추가할 수 있다.public int discount(Grade grade, int price) {
return price * grade.getDiscountPercent() / 100;
}
이제 단순히 할인율 계산만 남았다.
객체 지향 관점에서 이렇게 Grade 의 데이터인 discountPercent를 외부에 노출하는 것보다는, Grade 클래스가 자신의 할인율을 어떻게 계산하는지 스스로 관리하는 것이 캡슐화 원칙에 더 맞다.
public enum Grade {
BASIC(10), GOLD(20), DIAMOND(30);
private final int discountPercent;
Grade(int discountPercent) {
this.discountPercent = discountPercent;
}
public int getDiscountPercent() {
return discountPercent;
}
//추가
public int discount(int price) {
return price * discountPercent / 100;
}
}
날짜와 시간 차이 계산
윤년 계산
일광 절약 시간 변환
타임존 계산
LocalDate : 날짜만 표현할 때 사용한다.
LocalTime : 시간만을 표현할 때 사용한다.
LocalDateTime : LocalDate와 LocalTime을 합한 개념이다.
2013-11-21T08:20:30.213+9:00[Asia/Seoul]Asia/Seoul 은 타임존이라 한다. 이 타임존을 알면 오프셋과 일광 절약 시간제에 대한 정보를 알 수 있다.2013-11-21T08:20:30.213+9:00년, 월, 년월, 달일을 각각 다룰 때 사용한다.
DayofWeek 와 같이 월, 화, 수, 목, 금, 토, 일을 나타내는 클래스도 있다.
Instant 는 UTC를 기준으로 하는, 시간의 한 지점을 나타낸다. Instant 는 날짜와 시간을 나노초 정밀도로 표현하며, 1970년 1월 1일 0시 0분 0초(UTC)를 기준으로 경과한 시간으로 계산된다.
Instant 내부에는 초 데이터만 들어있다.(나노초 포함)
시간의 개념은 크게 2가지로 표현할 수 있다.
특정 시점의 시간(시각)
시간의 간격(기간)
Period, Duration 은 시간의 간격(기간)을 표현하는데 사용된다.
두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다.
두 시간 사이의 간견을 시, 분, 초(나노초) 단위로 나타낸다
LocalDate : 날짜만 표현할 때 사용한다.
LocalTime : 시간만을 표현할 때 사용한다.
LocalDateTime : LocalDate와 LocalTime을 합한 개념이다.
앞에 Local 이 붙는 이유는 세계 시간대를 고려하지 않아서 타임존이 적용되지 않기 때문이다.
LocalDate nowDate = LocalDate.now();
LocalDate ofDate = LocalDate.of(2013, 11, 21);
System.out.println("오늘 날짜 = " + nowDate);
System.out.println("지정 날짜 = " + ofDate);
// 계산(불변)
LocalDate plusDays = ofDate.plusDays(10);
System.out.println("지정 날짜+10d = " + plusDays);
실행 결과
오늘 날짜 = 2024-02-09
지정 날짜 = 2013-11-21
지정 날짜+10d = 2013-12-01
now() : 현재 시간을 기준으로 생성한다.
of(...) : 특정 날짜를 기준으로 생성한다.
plusDays() : 특정 일을 더한다. 다양한 plusXxx() 메서드가 존재한다.
LocalTime nowTime = LocalTime.now();
LocalTime ofTime = LocalTime.of(9, 10, 30);
System.out.println("현재 시간 = " + nowTime);
System.out.println("지정 시간 = " + ofTime);
// 계산(불변)
LocalTime ofTimePlus = ofTime.plusSeconds(30);
System.out.println("지정 시간+30s = " + ofTimePlus);
실행 결과
현재 시간 = 11:52:51.219602
지정 시간 = 09:10:30
지정 시간+30s = 09:11:00
now() : 현재 시간을 기준으로 생성한다.
of(...) : 특정 시간을 기준으로 생성한다. 시, 분, 초, 나노초를 입력할 수 있다.
plusSeconds() : 특정 초를 더한다. 다양한 plusXxx() 메서드가 존재한다.
주의! - 불변
모든 날짜 클래스는 불변이다. 따라서 변경이 발생하는 경우 새로운 객체를 생성해서 반환하므로 반환값을 꼭 받아야 한다.
LocalDateTime 은 LocalDate 와 LocalTime 을 내부에 가지고 날짜와 시간을 모두 표현한다.
LocalDateTime nowDt = LocalDateTime.now();
LocalDateTime ofDt = LocalDateTime.of(2016, 8, 16, 8, 10, 1);
System.out.println("현재 날짜시간 = " + nowDt);
System.out.println("지정 날짜시간 = " + ofDt);
// 날짜와 시간 분리
LocalDate localDate = ofDt.toLocalDate();
LocalTime localTime = ofDt.toLocalTime();
System.out.println("localDate = " + localDate);
System.out.println("localTime = " + localTime);
// 날짜와 시간 합체
LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);
System.out.println("localDateTime = " + localDateTime);
// 계산(불변)
LocalDateTime ofDtPlus = ofDt.plusDays(1000);
System.out.println("지정 날짜시간+1000d = " + ofDtPlus);
LocalDateTime ofDtPlus1Year = ofDt.plusYears(1);
System.out.println("지정 날짜시간+1년 = " + ofDtPlus1Year);
// 비교
System.out.println("현재 날짜시간이 지정 날짜시간보다 이전인가? " + nowDt.isBefore(ofDt));
System.out.println("현재 날짜시간이 지정 날짜시간보다 이후인가? " + nowDt.isAfter(ofDt));
System.out.println("현재 날짜시간과 지정 날짜시간이 같은가? " + nowDt.isEqual(ofDt));
실행 결과
현재 날짜시간 = 2024-02-09T11:54:54.389163
지정 날짜시간 = 2016-08-16T08:10:01
localDate = 2016-08-16
localTime = 08:10:01
localDateTime = 2016-08-16T08:10:01
지정 날짜시간+1000d = 2019-05-13T08:10:01
지정 날짜시간+1년 = 2017-08-16T08:10:01
현재 날짜시간이 지정 날짜시간보다 이전인가? false
현재 날짜시간이 지정 날짜시간보다 이후인가? true
현재 날짜시간과 지정 날짜시간이 같은가? false
생성
now() : 현재 날짜와 시간을 기준으로 생성한다.of(...) : 특정 날짜와 시간을 기준으로 생성한다.분리
날짜(LocalDate)와 시간(LocalTime) 을 toXxx() 메서드로 분리할 수 있다.
합체
LocalDateTime.of(LocalDate, LocalTime) : 날짜와 시간을 사용해서 LocalDateTime을 만든다.비교
isBefore() : 다른 날짜시간과 비교한다. 현재 날짜와 시간이 이전이라면 true 를 반환한다.
isAfter() : 다른 날짜시간과 비교한다. 현재 날짜와 시간이 이후이라면 true 를 반환한다.
isEqual() : 다른 날짜시간과 시간적으로 동일한지 비교한다. 시간이 같으면 true 를 반환한다.
isEqual() vs equals()
isEqual() 는 단순히 비교 대상이 시간적으로 같으면 true 를 반환한다. 객체가 다르고, 타임존이 달라도 시간적으로 같으면 true 를 반환한다.
true 를 반환한다.equals() 객체의 타입, 타임존 등등 내부 데이터의 모든 구성요소가 같아야 true 를 반환한다.
false 를 반환한다."Asia/Seoul" 같은 타임존 안에는 일광 절약 시간제에 대한 정보와 UTC+9:00 와 같은 UTC로 부터 시간 차이인 오프셋 정보를 모두 포함하고 있다.
타임존 목록 예시
자바는 타임존을 ZoneId 클래스로 제공한다.
for (String availableZoneId : ZoneId.getAvailableZoneIds()) {
ZoneId zoneId = ZoneId.of(availableZoneId);
System.out.println(zoneId + " | " + zoneId.getRules());
}
ZoneId zoneId = ZoneId.systemDefault();
System.out.println("ZoneId.systemDefault = " + zoneId);
ZoneId seoulZoneId = ZoneId.of("Asia/Seoul");
System.out.println("seoulZoneId = " + seoulZoneId);
실행 결과
Europe/London | ZoneRules[currentStandardOffset=Z]
UTC | ZoneRules[currentStandardOffset=Z]
GMT | ZoneRules[currentStandardOffset=Z]
Asia/Seoul | ZoneRules[currentStandardOffset=+09:00]
Asia/Dubai | ZoneRules[currentStandardOffset=+04:00]
US/Arizona | ZoneRules[currentStandardOffset=-07:00]
Asia/Istanbul | ZoneRules[currentStandardOffset=+03:00]
Asia/Shanghai | ZoneRules[currentStandardOffset=+08:00]
...
Europe/Paris | ZoneRules[currentStandardOffset=+01:00]
ZoneId.systemDefault = Asia/Seoul
seoulZoneId = Asia/Seoul
생성
ZoneId.systemDefault() : 시스템이 사용하는 기본 ZoneId 를 반환한다.ZoneId.of() : 타임존을 직접 제공해서 ZoneId를 반환한다.ZonedDateTime 은 LocalDateTime 에 시간대 정보인 ZoneId 가 합쳐진 것이다.
ZonedDateTime nowZdt = ZonedDateTime.now();
System.out.println("nowZdt = " + nowZdt);
LocalDateTime ldt = LocalDateTime.of(2030, 1, 1, 13, 30, 50);
ZonedDateTime zdt1 = ZonedDateTime.of(ldt, ZoneId.of("Asia/Seoul"));
System.out.println("zdt1 = " + zdt1);
ZonedDateTime zdt2 = ZonedDateTime.of(2030, 1, 1, 13, 30, 50, 0, ZoneId.of("Asia/Seoul"));
System.out.println("zdt2 = " + zdt2);
ZonedDateTime utcZdt = zdt2.withZoneSameInstant(ZoneId.of("UTC"));
System.out.println("utcZdt = " + utcZdt);
실행 결과
nowZdt = 2024-02-09T12:02:13.457712+09:00[Asia/Seoul]
zdt1 = 2030-01-01T13:30:50+09:00[Asia/Seoul]
zdt2 = 2030-01-01T13:30:50+09:00[Asia/Seoul]
utcZdt = 2030-01-01T04:30:50Z[UTC]
타임존 변경
withZoneSameInstant(ZoneId) : 타임존을 변경한다. 타임존에 맞추어 시간도 함께 변경된다. 이 메서드를 사용하면 지금 다른 나라는 몇 시인지 확인할 수 있다.
OffsetDateTime 은 LocalDateTime 에 UTC 오프셋 정보인 ZoneOffset 이 합쳐진 것이다.
OffsetDateTime nowOdt = OffsetDateTime.now();
System.out.println("nowOdt = " + nowOdt);
LocalDateTime ldt = LocalDateTime.of(2030, 1, 1, 13, 30, 50);
System.out.println("ldt = " + ldt);
OffsetDateTime odt = OffsetDateTime.of(ldt, ZoneOffset.of("+01:00"));
System.out.println("odt = " + odt);
실행 결과
nowOdt = 2024-02-13T15:03:36.422230+09:00
ldt = 2030-01-01T13:30:50
odt = 2030-01-01T13:30:50+01:00
Instant 는 UTC를 기준으로 하는, 시간의 한 지점을 나타낸다. Instant 는 날짜와 시간을 나노초 정밀도로 표현하며, 1970년 1월 1일 0시 0분 0초(UTC)를 기준으로 경과한 시간으로 계산된다.
장점
시간대 독립성 : Instant는 UTC를 기준으로 하므로, 시간대에 영향을 받지 않는다. 이는 전 세계 어디서나 동일한 시점을 가리키는데 유용한다.
고정된 기준점 : 모든 Instant 는 1970년 1월 1일 UTC를 기준으로 하기 때문에, 시간 계산 및 비교가 명확하고 일관된다.
단점
사용자 친화적이지 않음 : Instant 는 기계적인 시간 처리에는 적합하지만, 사람이 읽고 이해하기에는 직관적이지 않다.
시간대 정보 부재 : Instant 에는 시간대 정보가 포함되어 있지 않아, 특정 지역의 날짜와 시간으로 변환하려면 추가적인 작업이 필요하다.
Period, Duration 은 시간의 간격(기간)을 표현하는데 사용된다.
//생성
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);
System.out.println("더한 날짜: " + plusDate);
//기간 차이
LocalDate startDate = LocalDate.of(2023, 1, 1);
LocalDate endDate = LocalDate.of(2023, 4, 2);
Period between = Period.between(startDate, endDate);
System.out.println("기간: " + between.getMonths() + "개월 " + between.getDays() + "일");
실행 결과
period = P10D
현재 날짜: 2030-01-01
더한 날짜: 2030-01-11
기간: 3개월 1일
//생성
Duration duration = Duration.ofMinutes(30);
System.out.println("duration = " + duration);
LocalTime lt = LocalTime.of(1, 0);
System.out.println("기준 시간 = " + lt);
//계산에 사용
LocalTime plusTime = lt.plus(duration);
System.out.println("더한 시간 = " + plusTime);
//시간 차이
LocalTime start = LocalTime.of(9, 0);
LocalTime end = LocalTime.of(10, 0);
Duration between = Duration.between(start, end);
System.out.println("차이: " + between.getSeconds() + "초");
System.out.println("근무 시간: " + between.toHours() + "시간 " + between.toMinutesPart() + "분");
실행 결과
duration = PT30M
기준 시간 = 01:00
더한 시간 = 01:30
차이: 3600초
근무 시간: 1시간 0분
날짜와 시간은 특정 시점의 시간(시각)과 시간의 간격(기간)으로 나눌 수 있다.

TemporalAccessor 인터페이스
날짜와 시간을 읽기 위한 기본 인터페이스
이 인터페이스는 특정 시점의 날짜와 시간 정보를 읽을 수 있는 최소환의 기능을 제공한다.
Temporal 인터페이스
TemporalAccessor 의 하위 인터페이스로, 날짜와 시간을 조작(추가, 빼기 등)하기 위한 기능을 제공한다. 이를 통해 날짜와 시간을 변경하거나 조작할 수 있다.TemporalAmount 인터페이스
TemporalUnit : 시간의 단위
TemporalField : 시간 필드

TemporalUnit 인터페이스는 날짜와 시간을 측정하는 단위를 나타내며, 주로 사용되는 구현체는 java.time.temporal.ChronoUint 열거형으로 구현되어 있다.ChronoUnit 은 다양한 시간 단위를 제공한다.ChronoUnit[] values = ChronoUnit.values();
for (ChronoUnit value : values) {
System.out.println("value = " + value);
}
System.out.println("HOURS = " + ChronoUnit.HOURS);
System.out.println("HOURS.duration = " + ChronoUnit.HOURS.getDuration().getSeconds());
System.out.println("DAYS = " + ChronoUnit.DAYS);
System.out.println("DAYS.duration = " + ChronoUnit.DAYS.getDuration().getSeconds());
//차이 구하기
LocalTime lt1 = LocalTime.of(1, 10, 0);
LocalTime lt2 = LocalTime.of(1, 20, 0);
long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
System.out.println("secondsBetween = " + secondsBetween);
long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);
System.out.println("minutesBetween = " + minutesBetween);
실행 결과
value = Nanos
value = Micros
value = Millis
value = Seconds
value = Minutes
value = Hours
value = HalfDays
value = Days
value = Weeks
value = Months
value = Years
value = Decades
value = Centuries
value = Millennia
value = Eras
value = Forever
HOURS = Hours
HOURS.duration = 3600
DAYS = Days
DAYS.duration = 86400
secondsBetween = 600
minutesBetween = 10
ChronoField 는 날짜 및 시간을 나타내느 데 사용되는 열거형이다. 이 열거형은 다양한 필드를 통해 날짜와 시간의 특정 부분을 나타낸다. 여기에는 연도, 월, 일, 시간, 분 등이 포함된다.
ChronoField[] values = ChronoField.values();
for (ChronoField value : values) {
System.out.println(value + ", range = " + value.range());
}
System.out.println("MONTH_OF_YEAR.range() = " + ChronoField.MONTH_OF_YEAR.range());
System.out.println("DAY_OF_MONTH.range() = " + ChronoField.DAY_OF_MONTH.range());
실행 결과
NanoOfSecond, range = 0 - 999999999
NanoOfDay, range = 0 - 86399999999999
MicroOfSecond, range = 0 - 999999
MicroOfDay, range = 0 - 86399999999
MilliOfSecond, range = 0 - 999
MilliOfDay, range = 0 - 86399999
SecondOfMinute, range = 0 - 59
SecondOfDay, range = 0 - 86399
MinuteOfHour, range = 0 - 59
MinuteOfDay, range = 0 - 1439
HourOfAmPm, range = 0 - 11
ClockHourOfAmPm, range = 1 - 12
HourOfDay, range = 0 - 23
ClockHourOfDay, range = 1 - 24
AmPmOfDay, range = 0 - 1
DayOfWeek, range = 1 - 7
AlignedDayOfWeekInMonth, range = 1 - 7
AlignedDayOfWeekInYear, range = 1 - 7
DayOfMonth, range = 1 - 28/31
DayOfYear, range = 1 - 365/366
EpochDay, range = -365243219162 - 365241780471
AlignedWeekOfMonth, range = 1 - 4/5
AlignedWeekOfYear, range = 1 - 53
MonthOfYear, range = 1 - 12
ProlepticMonth, range = -11999999988 - 11999999999
YearOfEra, range = 1 - 999999999/1000000000
Year, range = -999999999 - 999999999
Era, range = 0 - 1
InstantSeconds, range = -9223372036854775808 - 9223372036854775807
OffsetSeconds, range = -64800 - 64800
MONTH_OF_YEAR.range() = 1 - 12
DAY_OF_MONTH.range() = 1 - 28/31
날짜와 시간을 조회하려면 날짜와 시간 항목 중에 어떤 필드를 조회할지 선택해야 한다. 이때 날짜와 시간의 필드를 뜻하는 ChronoField 가 사용된다.
LocalDateTime dt = LocalDateTime.of(2030, 1, 1, 13, 30, 59);
System.out.println("YEAR = " + dt.get(ChronoField.YEAR));
System.out.println("MONTH_OF_YEAR = " + dt.get(ChronoField.MONTH_OF_YEAR));
System.out.println("DAY_OF_MONTH = " + dt.get(ChronoField.DAY_OF_MONTH));
System.out.println("HOUR_OF_DAY = " + dt.get(ChronoField.HOUR_OF_DAY));
System.out.println("MINUTE_OF_HOUR = " + dt.get(ChronoField.MINUTE_OF_HOUR));
System.out.println("SECOND_OF_MINUTE = " + dt.get(ChronoField.SECOND_OF_MINUTE));
System.out.println("편의 메서드 사용");
System.out.println("YEAR = " + dt.getYear());
System.out.println("MONTH_OF_YEAR = " + dt.getMonthValue());
System.out.println("DAY_OF_MONTH = " + dt.getDayOfMonth());
System.out.println("HOUR_OF_DAY = " + dt.getHour());
System.out.println("MINUTE_OF_HOUR = " + dt.getMinute());
System.out.println("SECOND_OF_MINUTE = " + dt.getSecond());
System.out.println("편의 메서드에 없음");
System.out.println("MINUTE_OF_DAY = " + dt.get(ChronoField.MINUTE_OF_DAY));
System.out.println("SECOND_OF_DAY = " + dt.get(ChronoField.SECOND_OF_DAY));
실행 결과
YEAR = 2030
MONTH_OF_YEAR = 1
DAY_OF_MONTH = 1
HOUR_OF_DAY = 13
MINUTE_OF_HOUR = 30
SECOND_OF_MINUTE = 59
편의 메서드 사용
YEAR = 2030
MONTH_OF_YEAR = 1
DAY_OF_MONTH = 1
HOUR_OF_DAY = 13
MINUTE_OF_HOUR = 30
SECOND_OF_MINUTE = 59
편의 메서드에 없음
MINUTE_OF_DAY = 810
SECOND_OF_DAY = 48659
TemporalAccessor.get(TemporalField field)
LocalDateTime 을 포함한 특정 시점의 시간을 제공하는 클래스는 모두 TemporalAccessor 인터페이스를 구현한다.
TemporalAccessor 는 특정 시점의 시간을 조회하는 기능을 제공한다.
get(TemporalField field) 을 호출할 때 어떤 날짜와 시간 필드를 조회할 지 TemporalField 의 구현체인 ChronoField 를 인수로 전달하면 된다.
날짜와 시간을 조작하려면 어떤 시간 단위(Unit)를 변경할 지 선택해야 한다. 이때 날짜와 시간의 단위를 뜻하는 ChronoUnit 이 사용된다.
LocalDateTime dt = LocalDateTime.of(2018, 1, 1, 13, 30, 59);
System.out.println("dt = " + dt);
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);
실행 결과
dt = 2018-01-01T13:30:59
plusDt1 = 2028-01-01T13:30:59
plusDt2 = 2028-01-01T13:30:59
plusDt3 = 2028-01-01T13:30:59
Temporal plus(long amountToAdd, TemporalUnit unit)
LocalDateTime 을 포함한 특정 시점의 시간을 제공하는 클래스는 모두 Temporal 인터페이스를 구현한다.
Temporal 은 특정 시점의 시간을 조작하는 기능을 제공한다.
plus(long amountToAdd, TemporalUnit unit) 를 호출할 때 더하기 할 숫자와 시간의 단위(Unit)를 전달하면 된다. 이때 TemporalUnit 의 구현인 ChronoUnit 을 인수로 전달하면 된다.
불변이므로 반환 값을 받아야 한다.
참고로 minus() 도 존재한다.
with()LocalDateTime dt = LocalDateTime.of(2018, 1, 1, 13, 30, 59);
System.out.println("dt = " + dt);
LocalDateTime changedDt1 = dt.with(ChronoField.YEAR, 2020);
System.out.println("changedDt1 = " + changedDt1);
LocalDateTime changedDt2 = dt.withYear(2020);
System.out.println("changedDt2 = " + changedDt2);
//TemporalAdjuster 사용
//다음주 금요일
LocalDateTime with1 = dt.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));
System.out.println("기준 날짜: " + dt);
System.out.println("다음 금요일: " + with1);
//이번 달의 마지막 일요일
LocalDateTime with2 = dt.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY));
System.out.println("같은 달의 마지막 일요일 = " + with2);
실행 결과
dt = 2018-01-01T13:30:59
changedDt1 = 2020-01-01T13:30:59
changedDt2 = 2020-01-01T13:30:59
기준 날짜: 2018-01-01T13:30:59
다음 금요일: 2018-01-05T13:30:59
같은 달의 마지막 일요일 = 2018-01-28T13:30:59
Temporal with(TemporalField field, long newValue)
Temporal.with() 를 사용하면 날짜와 시간의 특정 필드의 값만 변경할 수 있다.
불변이므로 반환 값을 받아야 한다.
TemporalAdjuster 사용
with() 는 아주 단순한 날짜만 변경할 수 있다. 다음 주 금요일, 이번 달의 마지막 일요일 같은 복잡한 날짜를 계산하고 싶다면 TemporalAdjuster 를 사용하면 된다.포맷팅 : 날짜와 시간 데이터를 원하는 포맷의 문자열로 변경하는 것 Date -> String
파싱 : 문자열을 날짜와 시간 데이터로 변경하는 것 String -> Date
// 포맷팅: 날짜를 문자로
LocalDate date = LocalDate.of(2024, 12, 31);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
String formattedDate = date.format(formatter);
System.out.println("날짜와 시간 포맷팅: " + formattedDate);
// 파싱: 문자를 날짜로
String input = "2030년 01월 01일";
LocalDate parsedDate = LocalDate.parse(input, formatter);
System.out.println("문자열 파싱 날짜와 시간: " + parsedDate);
실행 결과
현재 날짜와 시간 포맷팅: 2024년 12월 31일
문자열 파싱 날짜와 시간: 2030-01-01
LocalDate 과 같은 날짜 객체를 원하는 형태의 문자로 변경하려면 DateTimeFormatter 를 사용하면 된다. 여기에 ofPattern() 으로 원하는 포맷을 지정하면 된다. 여기서는 yyyy년 MM월 dd일 포맷을 지정했다.
LocalDate.parse(input, formatter)
// 포맷팅: 날짜와 시간을 문자로
LocalDateTime now = LocalDateTime.of(2024, 12, 31, 13, 30, 59);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedDateTime = now.format(formatter);
System.out.println("날짜와 시간 포맷팅: " + formattedDateTime);
// 파싱: 문자를 날짜와 시간으로
String dateTimeString = "2030-01-01 11:30:00";
LocalDateTime parsedDateTime = LocalDateTime.parse(dateTimeString, formatter);
System.out.println("문자열 파싱 날짜와 시간: " + parsedDateTime);
실행 결과
현재 날짜와 시간 포맷팅: 2024-12-31 13:30:59
문자열 파싱 날짜와 시간: 2030-01-01T11:30







중첩 클래스는 총 4가지가 있고, 크게 2가지로 분류할 수 있다.
변수의 선언 위치
중첩 클래스의 선언 위치
정적 중첩 클래스 -> 정적 변수와 같은 위치
내부 클래스 -> 인스턴스 변수와 같은 위치
지역 클래스 -> 지역 변수와 같은 위치
class Outer {
...
//정적 중첩 클래스
static class StaticNested {
...
}
//내부 클래스
class Inner {
...
}
}
정적 중첩 클래스는 정적 변수와 같이 앞에 static 이 붙어있다.
내부 클래스는 인스턴스 변수와 같이 앞에 static 이 붙어있지 않다.
class Outer {
public void process() {
//지역 변수
int lcoalVar = 0;
//지역 클래스
class Local {...}
Local local = new Local();
}
}
중첩 vs 내부
중첩(Nested) : 어떤 다른 것이 내부에 위치하거나 포함되는 구조적인 관계
내부(Inner) : 나의 내부에 있는 나를 구성하는 요소
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(nestedInstanceValue);
// 바깥 클래스의 인스턴스 멤버에는 접근할 수 없다.
//System.out.println(outInstanceValue);
// 바깥 클래스의 클래스 멤버에는 접근할 수 있다. private도 접근 가능
System.out.println(outClassValue);
}
}
}
정적 중첩 클래스 앞에는 static이 붙는다.
바깥 클래스의 인스턴스에 소속되지 않는다.
바깥 클래스의 인스턴스 멤버에는 접근할 수 없다.
바깥 클래스의 클래스 멤버에는 접근할 수 있다.
(다른 외부 클래스에서도 바깥 클래스의 클래스 멤버에 접근할 수 있지만, 정적 중첩 클래스는 바깥 클래스의 클래스 멤버의 접근 제어자가 private 인 경우에도 접근할 수 있다는 뜻)
정적 중첩 클래스 예시
public class NestedOuterMain {
public static void main(String[] args) {
NestedOuter outer = new NestedOuter();
NestedOuter.Nested nested = new NestedOuter.Nested();
nested.print();
System.out.println("nested.getClass() = " + nested.getClass());
}
}
new 바깥클래스.중첩클래스() 로 생성할 수 있다.NestedOuter.Nested 와 같이 바깥클래스.중첩클래스 로 접근할 수 있다.new NestedOuter() 로 만든 바깥 클래스의 인스턴스와 new NestedOuter.Nested() 로 만든 정적 중첩 클래스의 인스턴스는 서로 아무 관계가 없는 인스턴스이다. 단지 클래스 구조상 중첩해 두었을 뿐이다.인스턴스가 생성된 상태

바깥 클래스의 멤버에 접근

Nested.print() 를 살펴보자
정적 중첩 클래스는 바깥 클래스의 정적 필드에는 접근할 수 있다. 하지만 바깥 클래스가 만든 인스턴스 필드에는 바로 접근할 수 없다. 바깥 인스턴스의 참조가 없기 때문이다.
정리
정적 중첩 클래스는 사실 다른 클래스를 그냥 중첩해 둔 것일 뿐이다!! 쉽게 이야기해서 둘은 아무런 관계가 없다. 그냥 클래스 2개를 따로 만든 것에서 private 접근 제어자에 접근할 수 있다는 정도가 차이점이다.
정적 중첩 클래스의 활용
중첩 클래스(내부 클래스 포함)는 그 용도가 자신이 소속된 바깥 클래스 안에서 사용되는 것이다.
public class InnerOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
class Inner {
private int innerInstanceValue = 1;
public void print() {
// 자기 자신에 접근
System.out.println(innerInstanceValue);
// 외부 클래스의 인스턴스 멤버에 접근 가능, private 접근 가능
System.out.println(outInstanceValue);
// 외부 클래스의 클래스 멤버에 접근 가능, private 접근 가능
System.out.println(outClassValue);
}
}
}
내부 클래스 앞에는 static이 붙지 않는다.
바깥 클래스의 인스턴스에 소속된다.
내부 클래스 예시
public class InnerOuterMain {
public static void main(String[] args) {
InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner = outer.new Inner();
inner.print();
System.out.println("inner.getClass() = " + inner.getClass());
}
}
내부 클래스는 바깥 클래스의 인스턴스에 소속된다. 따라서 바깥 클래스의 인스턴스 정보를 알아야 생성할 수 있다.
내부 클래스는 new 바깥클래스의 인스턴스 참조.내부클래스() 로 생성할 수 있다.
내부 클래스는 바깥 클래스의 인스턴스에 소속되어야 한다. 따라서 내부 클래스를 생성할 때, 바깥 클래스의 인스턴스 참조가 필요하다.
outer.new Inner() 에서 outer 는 바깥 클래스의 인스턴스 참조를 가진다.
outer.new Inner() 로 생성한 내부 클래스는 개념상 바깥 클래스의 인스턴스 내부에 생성된다.
따라서 바깥 클래스의 인스턴스를 먼저 생성해야 내부 클래스의 인스턴스를 생성할 수 있다.
개념 - 내부 클래스의 생성

개념상 바깥 클래스의 인스턴스 내부에서 내부 클래스의 인스턴스가 생성된다.
따라서 내부 인스턴스는 바깥 인스턴스를 알기 때문에 바깥 인스턴스의 멤버에 접근할 수 있다.
실제 - 내부 클래스의 생성

실제로 내부 인스턴스가 바깥 인스턴스 안에 생성되는 것은 아니다. 하지만 개념상 인스턴스 안에 생성된다고 이해하면 충분하다.
실제 내부 인스턴스는 바깥 인스턴스의 참조를 보관한다. 이 참조를 통해 바깥 인스턴스의 멤버에 접근할 수 있다.
논리적 그룹화 : 특정 클래스가 다른 하나의 클래스 안에서만 사용되는 경우 해당 클래스 안에 포함하는 것이 논리적으로 더 그룹화 된다. 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는 장점도 있다.
캡슐화 : 중첩 클래스는 바깥 클래스의 private 멤버에 접근할 수 있다. 이렇게 해서 둘을 긴밀하게 연결하고 불필요한 public 메서드를 제거할 수 있다. 이는 곧 캡슐화를 높인다고 할 수 있다.
같은 이름의 바깥 변수 접근
변수의 이름이 같기 때문에 어떤 변수를 먼저 사용할지 우선 순위가 필요하다.
프로그래밍에서 우선 순위는 대부분 더 가깝거나, 더 구체적인 것이 우선권을 가진다. 쉽게 이야기해서 사람이 직관적으로 이해하기 쉬운 방향으로 우선 순위를 설계한다.프로그래밍에서 가장 중요한 것은 명확성이다. 이름이 같은 경우 처음부터 이름을 서로 다르게 지어서 명확하게 구분하는 것이 더 나은 방법이다.
지역 클래스(Local Class)는 내부 클래스의 특별한 종류의 하나이다. 따라서 내부 클래스의 특징을 그대로 가진다. 예를 들어서 지역 클래스도 내부 클래스이므로 바깥 클래스의 인스턴스 멤버에 접근할 수 있다.
지역 클래스는 지역 변수와 같이 코드 블럭 안에서 정의된다.
public class LocalOuterV1 {
private int outInstanceValue = 3;
public void process(int paramVar) {
int localVar = 1;
class LocalPrinter {
int value = 0;
public void printData() {
System.out.println("value=" + value);
System.out.println("localVar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceValue = " + outInstanceValue);
}
}
LocalPrinter printer = new LocalPrinter();
printer.printData();
}
public static void main(String[] args) {
LocalOuterV1 localOuter = new LocalOuterV1();
localOuter.process(2);
}
}
value)에 접근할 수 있다.localVar)에 접근할 수 있다.paramVar)에 접근할 수 있다.outInstanceValue)에 접근할 수 있다.
클래스 변수 : 프로그램 종료까지, 가장 길다(메서드 영역)
인스턴스 변수 : 인스턴스의 생존 기간(힙 영역)
지역 변수 : 메서드 호출이 끝나면 사라짐(스택 영역)
지역 클래스 예제
public class LocalOuterV3 {
private int outInstanceValue = 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("outInstanceValue = " + outInstanceValue);
}
}
LocalPrinter printer = new LocalPrinter();
// printer.print(); 를 여기서 실행하지 않고 Printer 인스턴스만 반환한다.
return printer;
}
public static void main(String[] args) {
LocalOuterV3 localOuter = new LocalOuterV3();
Printer printer = localOuter.process(2);
// Prnter.print()를 나중에 실행한다. process()의 스택 프레임이 사라진 이후에 실행
printer.print();
}
}
실행 결과
value=0
localVar=1
paramVar=2
outInstanceVar=3
process() 는 Printer 타입을 반환한다. 여기서는 LocalPrinter 인스턴스를 반환한다.
process() 메서드가 종료된 이후에 printer.print() 메서드를 실행한다.
LocalPrinter 인스턴스 생성 직후 메모리 그림

지역 클래스 인스턴스의 생존 범위
지역 클래스로 만든 객체도 인스턴스이기 때문에 힙 영역에 존재한다. 따라서 GC 전까지 생존한다.
LocalPrinter 인스턴스는 porcess() 메서드 안에서 생성된다. 그리고 process() 에서 main() 으로 생성한 LocalPrinter 인스턴스를 반환하고 printer 변수에 LocalPrinter 인스턴스의 참조값을 보관한다. 따라서 LocalPrinter 인스턴스는 main() 이 종료될 때까지 생존한다.paramVar, localVar 와 같은 지역 변수는 process() 메서드를 실행하는 동안에만 스택 영역에서 생존한다. process() 메서드가 종료되면 process() 스택 프레임이 스택 영역에서 제거 되면서 함께 제거된다.
LocalPrinter.print() 접근 메모리 그림

LocalPrinter 인스턴스는 print() 메서드를 통해 힙 영역에 존재하는 바깥 인스턴스의 변수인 outInstanceVar 에 접근한다. 이 부분은 인스턴스의 필드를 참조하는 것이기 때문에 특별한 문제가 없다.
LocalPrinter 인스턴스는 print() 메서드를 통해 스택 영역에 존재하는 지역 변수도 접근하는 것처럼 보인다. 하지만 스택 영역에 존재하는 지역 변수를 힙 영역에 있는 인스턴스가 접근하는 것은 생걱처럼 단순하지 않다.
process() 메서드의 종료

지역 변수의 생명주기는 매우 짧다. 반면에 인스턴스의 생명주기는 GC 전까지 생존할 수 있다.
지역 변수인 paramVarl, localVar 는 porcess() 메서드가 실행되는 동안에만 생존할 수 있다. process() 메서드가 종료되면 process() 의 스택 프레임이 제거되면서 두 지역 변수도 함께 제거된다.
여기서 문제는 process() 메서드가 종료되어도 LocalPrinter 인스턴스는 계속 생존할 수 있다는 점이다.
process() 메서드가 종료된 이후에 지역 변수 접근

process() 메서드가 종료된 이후에 main() 메서드 안에서 LocalPrinter.print() 메서드를 호출한다.
LocalPrinter 인스턴스에 있는 print() 메서드는 지역 변수인 paramVar, localVar 에 접근해야 한다. 하지만 process() 메서드가 이미 종료되었으므로 해당 지역 변수들도 이미 제거된 상태이다.
하지만 실행 결과를 보면 모두 정상적으로 출력되는 것을 확인할 수 있다.
자바는 이런 문제를 해결하기 위해 지역 클래스의 인스턴스를 생성하는 시점에 필요한 지역 변수를 복사해서 생성한 인스턴스에 함께 넣어둔다. 이런 과정을 변수 캡처(Capture)라 한다. 접근이 필요한 지역 변수만 캡처한다.
지역 클래스의 인스턴스 생성과 지역 변수 캡처 과정1

1. LocalPrinter 인스턴스 생성 시도 : 지역 클래스의 인스턴스를 생성할 때 지역 클래스가 접근하는 지역 변수를 확인한다.
LocalPrinter 클래스는 paramVar, localVar 지역 변수에 접근한다.2. 사용하는 지역 변수 복사 : 지역 클래스가 사용하는 지역 변수를 복사한다. (매개변수도 지역 변수의 한 종류이다.)
paramVar, localVar 지역 변수를 복사한다.지역 클래스의 인스턴스 생성과 지역 변수 캡처 과정2

3. 지역 변수 복사 완료 : 복사한 지역 변수를 인스턴스에 포함한다.
4. 인스턴스 생성 완료 : 복사한 지역 변수를 포함해서 인스턴스 생성이 완료된다. 이제 복사한 지역 변수를 인스턴스를 통해 접근할 수 있다.
캡처 변수 접근

LocalPrinter 인스턴스에서 print() 메서드를 통해 paramVar, localVar 에 접근하면 사실은 스택 영역에 있는 지역 변수에 접근하는 것이 아니다. 대신에 인스턴스에 있는 캡처한 변수에 접근한다.
캡처한 paramVar, localVar 의 생명주기는 LocalPrinter 인스턴스의 생명주기와 같다. 따라서 LocalPrinter 인스턴스는 지역 변수의 생명주기와 무관하게 언제든지 paramVar, localVar 캡처 변수에 접근할 수 있다.
이렇게 해서 지역 변수와 지역 클래스를 통해 생성한 인스턴스의 생명주기가 달라서 생기는 문제를 해결한다.
코드로 캡처 변수 확인
public static void main(String[] args) {
LocalOuterV3 localOuter = new LocalOuterV3();
Printer printer = localOuter.process(2);
// Prnter.print()를 나중에 실행한다. process()의 스택 프레임이 사라진 이후에 실행
printer.print();
//추가
System.out.println("필드 확인");
Field[] fields = printer.getClass().getDeclaredFields();
for (Field field : fields) {
System.out.println(field);
}
}
실행 결과
필드 확인
//인스턴스 변수
field = int nested.local.LocalOuterV3$1LocalPrinter.value
//캡처 변수
field = final int nested.local.LocalOuterV3$1LocalPrinter.val$localVar
field = final int nested.local.LocalOuterV3$1LocalPrinter.val$paramVar
//바깥 클래스 참조
field = final nested.local.LocalOuterV3
nested.local.LocalOuterV3$1LocalPrinter.this$0
정리
지역 클래스는 인스턴스를 생성할 때 필요한 지역 변수를 먼저 캡처해서 인스턴스에 보관한다. 그리고 지역 클래스의 인스턴스를 통해 지역 변수에 접근하면, 실제로는 지역 변수에 접근하는 것이 아니라 인스턴스에 있는 캡처한 캡처 변수에 접근한다.
지역 클래스가 접근하는 지역 변수는 절대로 중간에 값이 변하면 안된다. 따라서 final 로 선언하거나 또는 사실상 final 이어야 한다. 이것은 자바 문법이고 규칙이다.
사실상 final
영어로 effectively final이라 한다. 사실상
final지역 변수는 지역 변수에final키워드를 사용하지는 않았지만, 값을 변경하지 않는 지역 변수를 뜻한다.final키워드를 넣지 않았을 뿐이지, 실제로는final키워드를 넣은 것처럼 중간에 값을 변경하지 않은 지역 변수이다. 따라서 사실상final지역 변수는final키워드를 넣어도 동일하게 작동해야 한다.
중간에 값이 변하면 안되는 이유
지역 변수의 값을 변경하면 인스턴스에 캡처한 변수의 값도 변경해야 한다.
반대로 인스턴스에 있는 캡처 변수의 값을 변경하면 해당 지역 변수의 값도 다시 변경해야 한다.
지역 변수의 값과 인스턴스에 있는 캡처 변수의 값을 서로 동기화 해야 하는데, 멀티 쓰레드 상황에서 이런 동기화는 매우 어렵고, 성능에 나쁜 영향을 줄 수 있다.
익명 클래스(anonymous class)는 지역 클래스의 특별한 종류의 하나이다.
익명 클래스는 지역 클래스인데, 클래스의 이름이 없다는 특징이 있다.
익명 클래스 - 지역 클래스의 선언과 생성을 한번에
Printer printer = new Printer(){
//body
}
new Printer() { body }
익명 클래스는 클래스의 본문(body)을 정의하면서 동시에 생성한다. new 다음에 바로 상속 받으면서 구현할 부모 타입을 입력하면 된다.
이 코드는 마치 인터페이스 Printer 를 생성하는 것처럼 보인다. 하지만 자바에서 인터페이스를 생성하는 것은 불가능하다. 이 코드는 인터페이스를 생성하는 것이 아니고, Printer 라는 이름의 인터페이스를 구현한 익명 클래스를 생성하는 것이다. {body} 부분에 Printer 인터페이스를 구현한 코드를 작성하면 된다. 이 부분이 바로 익명 클래스의 본문이 된다. 쉽게 이야기해서 Printer 를 상속(구현)하면서 바로 생성하는 것이다.
익명 클래스 특징
익명 클래스는 이름 없는 지역 클래스를 선언하면서 동시에 생서한다.
익명 클래스는 부모 클래스를 상속 받거나, 또는 인터페이스를 구현해야 한다. 익명 클래스를 사용할 때는 상위 클래스나 인터페이스가 필요하다.
익명 클래스는 말 그대로 이름이 없다. 이름을 가지지 않으므로, 생성자를 가질 수 없다.(기본 생성자만 사용됨)
익명 클래스는 바깥 클래스 이름 $ 숫자 로 정의된다. 익명 클래스가 여러개면 숫자가 증가하면서 구분된다.
익명 클래스의 장점
익명 클래스를 사용하면 클래스르 별도로 정의하지 않고도 인터페이스나 추상 클래스를 즉석에서 구현할 수 있어 코드가 더 간결해진다.
어떻게 외부에서 코드 조각을 전달할 수 있을까?
코드 조각은 보통 메서드(함수)에 정의한다. 따라서 코드 조각을 전달하기 위해서는 메서드가 필요하다. 대신에 인스턴스를 전달하고, 인스턴스에 있는 메서드를 호출하면 된다.
Process 인터페이스
public interface Process {
void run();
}
인스턴스를 구현하여 익명 클래스를 정의하고 그 안에 전달하고 싶은 메서드를 구현하여 넘겨준다.
public class Ex1RefMainV3 {
public static void main(String[] args) {
hello(new Process() {
@Override
public void run() {
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
}
});
hello(new Process() {
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println("i = " + i);
}
}
});
}
public static void hello(Process process) {
System.out.println("프로그램 시작");
process.run();
System.out.println("프로그램 종료");
}
}
자바8에 들어서면서 큰 변화가 있었는데 바로 메서드(더 정확히는 함수)를 인수로 전달할 수 있게 되었다.
public class Ex1RefMainV4 {
public static void main(String[] args) {
hello(() -> {
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
});
hello(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("i = " + i);
}
});
}
public static void hello(Process process) {
System.out.println("프로그램 시작");
process.run();
System.out.println("프로그램 종료");
}
}
처리할 수 없는 예외
예를 들어서 상대 네트워크 서버에 문제가 발생해서 통신이 불가능하거나, 데이터베이스 서버에 문제가 발생해서 접속이 안되면, 애플리케이션에서 연결 오류, 데이터베이스 접속 실패와 같은 예외가 발생한다.
이렇게 시스템 오류 때문에 발생한 예외들은 대부분 예외를 잡아도 해결할 수 있는 것이 거의 없다. 예외를 잡아서 다시 호출을 시도해도 같은 오류가 반복될 뿐이다.
이런 경우 고객에게는 "현재 시스템에 문제가 있습니다."라는 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 그리고 내부 개발자가 문제 상황을 빠르게 인지할 수 있도록, 오류에 대한 로그를 남겨두어야 한다.
체크 예외의 부담
체크 예외는 개발자가 실수로 놓칠 수 있는 예외들을 컴파일러가 체크해주기 때문에 오래전부터 많이 사용되었다. 그런데 처리할 수 없는 예외가 많아지고, 또 프로그램이 점점 복잡해지면서 체크 예외를 사용하는 것이 점점 더 부담스러워졌다.
체크 예외의 이런 문제점 때문에 최근 라이브러리들은 대부분 런타임 예외를 기본으로 제공한다. 런타임 예외도 필요하면 잡을 수 있기 때문에 필요한 경우에는 잡아서 처리하고, 그렇지 않으면 자연스럽게 던지도록 둔다. 그리고 처리할 수 없는 예외는 예외를 공통으로 처리하는 부분을 만들어서 해결하면 된다.
public class MainV4 {
public static void main(String[] args) {
NetworkServiceV4 networkService = new NetworkServiceV4();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("전송할 문자: ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
try {
networkService.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); // 스택 트레이스 출력
// e.printStackTrace();
// 필요하면 예외 별로 별도의 추가 처리 기능
if (e instanceof SendExceptionV4 sendEx) {
System.out.println("[전송 오류] 전송 데이터: " + sendEx.getSendData());
}
}
}
networkService.sendMessage(input) 부분에서 생기는 모든 예외(Exception)를 잡아서 exceptionHandler 라는 공통 예외 처리 메서드로 처리한다.exceptionHandler()
instanceof 와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.e.printStackTrace()
e.printStackTrace(System.out) 을 사용해서 표준 출력으로 보냈다.e.printStackTrace() 를 사용하면 System.err 이라는 표준 오류에 결과를 출력한다.System.err 로 출력하면 출력 결과를 빨간색으로 보여준다.
System.out, System.err둘 다 결국 콘솔에 출력되지만, 서로 다른 흐름을 통해서 출력된다. 따라서 둘을 함께 사용하면 출력 순서를 보장하지 않는다. 출력 순서가 꼬여서 보일 수 있다.
실무에서는
System.out이나System.err을 통해 콘솔에 무언가를 출력하기 보다는, 주로 Slf4J, logback 같은 별도의 로그 라이브러리를 사용해서 콘솔과 특정 파일에 함께 결과를 출력한다. 그런데e.printStackTrace()를 직접 호출하면 결과가 콘솔에만 출력된다. 이렇게 되면 서버에서 로그를 확인하기 어렵다. 서버에서는 파일로 로그를 확인해야 한다. 따라서 콘솔에 바로 결과를 출력하는e.printStackTrace()는 잘 사용하지 않는다. 대신에 로그 라이브러리를 통해서 예외 스택 트레이스를 출력한다.
애플리케이션에서 외부 자원을 사용하는 경우 반드시 외부 자원을 해제해야 한다. 따라서 finally 구문을 반드시 사용해야 한다.
try {
//정상 흐름
} catch {
//예외 흐름
} finally {
//반드시 호출해야 하는 마무리 흐름
}
try 에서 외부 자원을 사용하고, try 가 끝나면 외부 자원을 반납하는 패턴이 반복되면서 자바에서는 Try with resources 라는 편의 기능을 자바 7에서 도입했다. 이름 그대로 try 에서 자원을 함께 사용한다는 뜻이다. 여기서 자원은 try 가 끝나면 반드시 종료해서 반납해야 하는 외부 자원을 뜻한다.
AutoCloseable 인터페이스
public interface AutoCloseable {
void close() throws Exception;
}
이 기능을 사용하려면 AutoCloseable 인터페이스를 구현해야 한다. 이 인터페이스를 구현하면 Try with resources를 사용할 떄 try 가 끝나는 시점에 close() 가 자동으로 호출된다.
try (Resource resource = new Resource()) {
// 리소스를 사용하는 코드
}
AutoCloseable 구현 예제
public class NetworkClientV5 implements AutoCloseable {
...
public void disconnect() {
System.out.println(address + " 서버 연결 해제");
}
@Override
public void close() {
System.out.println("NetworkClientV5.close");
disconnect();
}
implements 로 AutoCloseable 인터페이스를 구현한다고 명시한다.close() 메서드를 구현한다. 여기서는 disconnect() 메서드가 실행되도록 하였다.사용 예제
try (NetworkClientV5 client = new NetworkClientV5(address)) {
client.initError(data);
client.connect();
client.send(data);
} catch (Exception e) {
System.out.println("[예외 확인]: " + e.getMessage());
throw e;
}
try 괄호 안에 사용할 자원을 명시한다.try 블럭이 끝나면 자동으로 AutoCloseable.close() 를 호출해서 자원을 해제한다.try 구문 안에서 예외가 발생하여 catch 구문을 실행하게 될 때 catch 구문보다 AutoCloseable.close() 를 먼저 호출하여 finally 를 사용했을 때보다 더 빠르게 자원을 해제한다.Try with resources 장점
리소스 누수 방지 : 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나, finally 블럭 안에서 자원 해제 코드를 누락하는 문제들을 예방할 수 있다.
코드 간결성 및 가독성 향상 : 명시적인 close() 호출이 필요 없어 코드가 더 간결하고 읽기 쉬워진다.
스코프 범위 한점 : 예를 들어 리소스로 사용되는 client 변수의 스코프가 try 블럭 안으로 한정된다. 따라서 코드 유지보수가 더 쉬워진다.
조금 더 빠른 자원 해제 : 기존에는 try -> catch -> finally 로 catch 이후에 자원을 반납했다. Try with resources 구문은 try 블럭이 끝나면 즉시 close() 를 호출한다.