이번 글에서는 자바의 기초적인 개념에 대해 다뤄보려 합니다.
자바의 장점이 무엇인가요?
1. 객체지향
그로 인해 캡슐화, 상속, 다형성, 추상화라는 장점이 있습니다.
1. 캡슐화
캡슐화는 객체 내부의 상세 구현을 감추고 외부에서는 인터페이스를 통해 접근할 수 있도록 하는 것입니다.
이를 통해 코드의 유지보수성과 보안을 향상시킬 수 있다는 장점이 있습니다.
오픈소스 기여 활동에서 받은 코멘트를 통해 캡슐화의 중요성을 확인할 수 있습니다.
현재 Matcher는 이름과 객체의 타입을 기반으로 비교 작업을 수행하는 로직을 가지고 있습니다.
그러나 이 비교 작업에 필요한 이름이 MatcherOperator라는 외부 객체에 위치해 있는 상황입니다.
이로 인해 Matcher의 내부 구현 세부 사항이 외부로 노출되는 문제가 발생했습니다.
이러한 문제를 개선하기 위해 정보를 담고 있는 ArbitraryBuilderContext에 이름을 저장하는 방식으로 수정을 하였습니다.
2. 상속
상속은 한 클래스가 다른 클래스의 속성과 메서드를 물려받는 개념입니다.
이때 부모 클래스의 멤버 변수나 메서드가 private 접근자인 경우 자식 클래스에서 접근할 수 없지만, public이나 protected인 경우 자식 클래스에서도 접근이 가능합니다.
상속의 장점으로는 코드 재사용성과 다형성이 있습니다.
공통 코드를 부모 클래스에 작성하면 자식 클래스가 이를 접근할 수 있어 코드의 재사용성이 높아집니다.
또한, 상속을 통해 다형성을 구현하여 여러 자식 클래스가 부모 클래스를 확장하면서 각기 다른 동작을 수행할 수 있습니다.
상속은 위에서 설명했다시피 부모 클래스의 속성과 메서드를 물려받습니다.
이러한 특징은 코드 재사용성이라는 장점이 될 수도 있지만, 부모 클래스의 변경이 모든 자식클래스에 영향을 끼친다는 단점이 되기도 합니다. 또한, 자식 클래스가 부모 클래스의 내부 구현을 알아야 한다는 점에서 캡슐화를 위배할 수 있습니다.
2 - 1. 조합 (Composition)
상속과 조합의 차이에 대해 먼저 설명드리겠습니다.
상속은 맥북은 노트북이다 (is-a)입니다. 그에 비해 구성은 노트북에는 키보드가 있다(has-a)입니다.
상속은 하위 클래스가 상위 클래스에 포함된다는 의미이고, 조합은 하나의 클래스에서 다른 클래스를 포함하고 있다는 의미입니다.
그로 인해 구성은 상속과 다르게 한 객체가 다른 객체를 포함합니다.
이러한 특징으로 인해 구성 또한 상속과 마찬가지로 코드를 재사용할 수 있습니다.
하지만 조합 클래스가 다른 클래스의 객체를 포함하게 되므로 한 클래스의 변경이 다른 클래스에 영향을 미칠 수 있다는 단점이 있습니다.
3. 다형성
다형성은 하나의 객체가 여러 가지 형태로 취급될 수 있다는 것을 의미합니다.
다형성은 주로 두 가지 방법을 통해 구현됩니다.
1. 상속을 통한 다형성
부모 클래스 타입의 참조 변수로 자식 클래스의 인스턴스를 참조할 수 있습니다.
이때 자식 클래스에서 오버라이딩을 통해 부모 클래스의 메서드를 재정의 할 수 있습니다.
2. 인터페이스를 통한 다형성
인터페이스 타입의 참조 변수로 구현 클래스의 인스턴스를 참조할 수 있습니다.
이때 자식 클래스에서 오버라이딩을 통해 인터페이스의 메서드를 구현할 수 있습니다.
두 가지 방법에서 공통적인 단어가 나왔는데 바로 오버라이딩입니다.
위 코드를 보면 @Override라는 어노테이션이 붙어 있습니다.
이 어노테이션은 해당 메서드가 상위 클래스 또는 인터페이스의 메서드를 오버라이드 한다는 것을 명시적으로 나타냅니다.
즉, 오버라이딩은 상속이나 구현 관계에서 메서드를 재정의 하는 것을 의미합니다.
다형성의 장점 중 하나는 객체가 여러 형태로 취급될 수 있다는 점인데, 이는 오버라이딩을 통해 실현됩니다.
하위 클래스에서 메서드를 재정의 하면 부모 클래스의 참조 변수를 통해 자식 클래스 인스턴스를 사용할 때 각기 다른 형태로 동작하게 할 수 있기 때문입니다.
오버라이딩, 오버로딩..? 차이가 뭐야!
오버로딩은 메서드 이름이 같지만 파라미터를 다르게 해서 여러 개의 메서드를 정의하는 것입니다.
이는 오버라이딩과는 전혀 관련이 없고 이름이 비슷해 오해를 할 수 있습니다.
3 - 1. 인터페이스와 추상 클래스
인터페이스는 클래스들이 구현해야 하는 메서드의 공통 명세를 정의하는 추상 자료형입니다.
추상 클래스는 하나 이상의 추상 메서드를 포함하는 클래스입니다.
인터페이스와 추상 클래스는 공통점이 많습니다.
인터페이스의 모든 메서드(default와 static을 제외한)는 추상 메서드라고 볼 수 있습니다.
추상 메서드는 구현 / 상속을 하는 하위 객체가 반드시 오버라이딩을 해야 한다는 특징을 가지고 있습니다.
가장 큰 차이점으로는 인터페이스는 상태를 가지고 있을 수 없습니다.
그에 비해 추상 클래스는 상태를 가지고 있을 수 있습니다.
또한, 인터페이스는 다중 상속이 가능하고 추상 클래스는 다중 상속이 불가능하다는 특징이 있습니다.
이러한 특징을 바탕으로 인터페이스와 추상 클래스의 사용 시기를 말씀드리겠습니다.
인터페이스는 다중 상속 가능, 상태를 가지고 있을 수 없고 하위 클래스가 반드시 구현을 해야 한다는 특징을 가지고 있습니다. 그로 인해 관련 없는 클래스들이 공통 동작을 구현해야 할 때 유용합니다.
제가 기여한 오픈소스 라이브러리를 기준으로 더 쉽게 설명해 보겠습니다.
위에 보이는 인터페이스는 Matcher라는 인터페이스이며, 주요한 기능으로는 match 즉, 비교를 하는 기능입니다.
Match 인터페이스를 보면 관련이 없지만 비교라는 기능을 하는 클래스들이 구현한 것을 볼 수 있습니다.
그에 비해 추상 클래스는 상태를 가질 수 있고, 일반 메서드를 가질 수 있다는 특징이 있습니다.
그로 인해 코드 재사용의 장점이 있고, 변수를 공유하기 때문에 주로 관련된 클래스에서 사용할 때 유용합니다.
4. instanceof
상속과 다형성을 알았다면 여러분은 instanceof를 이해하실 수 있습니다.
우선 instanceof는 객체 타입을 검사하는 연산자입니다.
형 변환이 가능하다면 true, 불가능하다면 false를 반환합니다.
하기의 코드는 블로그에서 발췌한 코드입니다.
class Parent{}
class Child extends Parent{}
public class InstanceofTest {
public static void main(String[] args){
Parent parent = new Parent();
Child child = new Child();
System.out.println( parent instanceof Parent ); // true
System.out.println( child instanceof Parent ); // true
System.out.println( parent instanceof Child ); // false
System.out.println( child instanceof Child ); // true
}
}
출처: https://mine-it-record.tistory.com/120 [나만의 기록들:티스토리]
부모 클래스 parent = (자식 클래스) child 가 불가능 하기 때문에 3번째의 경우는 false가 반환됩니다.
그에 비해 자식 클래스는 부모 클래스로 형 변환이 가능하기 때문에 true가 반환됩니다. instanceof 키워드의 단점은 추상화를 설명한 뒤 설명하겠습니다.
하지만 instanceof도 단점이 있습니다.
하기의 코드는 instanceof를 통해 객체의 타입을 확인한 뒤, 구현을 한 코드입니다.
public void makeAnimalSound(Animal animal) {
if (animal instanceof Dog) {
System.out.println("멍멍");
} else if (animal instanceof Cat) {
System.out.println("야옹");
} else if (animal instanceof Bird) {
System.out.println("짹짹");
} else {
System.out.println("알 수 없는 동물입니다.");
}
}
이때 강아지, 고양이, 새의 울음소리를 직접 구현한 것을 볼 수 있습니다.
이러한 구현을 하려면 코드의 내부 구조에 대해 알아야 하고 이는 캡슐화가 보장되지 않는다는 단점이 생깁니다.
또한 새로운 동물이 추가가 되면 instanceof 관련 코드를 고쳐야 하므로 유지보수성도 떨어집니다.
5. 추상화
추상화는 객체의 공통적인 특성을 추출해서 인터페이스나 추상 클래스로 정의하는 것입니다.
[동물 (Animal)]
^
|
|
+-----+-----+
| | |
[개] [고양이] [새]
하기의 코드와 같이 강아지, 고양이, 새의 공통적인 특성을 추출하나 뒤, 추상 클래스로 정의하는 것입니다.
public abstract class Animal {
public abstract void makeSound();
}
public class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("멍멍!");
}
}
public class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("야옹!");
}
}
public class Bird extends Animal {
@Override
public void makeSound() {
System.out.println("짹짹!");
}
}
추상화를 통해 코드의 재사용성을 높일 수 있으며, 프로그램의 구조를 더 명확하게 만들 수 있습니다.
이때 프로그램의 구조를 더 명확하게 만들 수 있다는 것은 강아지, 고양이, 새의 내부 구현을 몰라도 Animal이라는 클래스를 통해 각각의 클래스를 사용할 수 있는 것을 의미합니다. (다형성과 관련이 있습니다)
2. 플랫폼 독립성
자바는 한 번 작성하면 운영체제와 하드웨어 상관 없이 JVM이 설치되어 있는 공간에서 실행 가능합니다.
1. JVM
JVM (Java Virtual Machine)은 자바 애플리케이션을 실행하기 위한 가상 머신입니다.
JVM은 크게 바이트 코드 실행, 메모리 관리등의 일을 합니다.
1 - 1 실행.
개발자가 자바 코드를 작성하면 자바 컴파일러가 자바 소스코드를 바이트코드로 변환합니다.
그리고 JVM의 클래스로더가 컴파일된 바이트코드를 메모리에 로드합니다. 그 후, 바이트 코드를 실행합니다.
이때 바이트코드를 실행하는 방법에는 두 가지 방법이 있습니다.
- 인터프리터 : 바이트 코드를 한 줄씩 읽어 즉시 실행합니다.
- JIT 컴파일러 : 자주 호출되는 바이트코드를 기계어로 변환하여 성능을 향상시킵니다.
1 - 2. 메모리, 주소를 곁들인..
JVM은 효율적인 메모리 관리를 위해 여러 영역으로 나눠 메모리를 관리합니다.
- Method Area : 모든 스레드가 공유하는 메모리 영역입니다. 클래스, 인터페이스, 필드, Static 변수 등의 바이트코드를 보관합니다.
- Heap Area : 모든 스레드가 공유하며 new 키워드로 생성된 객체와 배열이 생성되는 영역입니다.
- Stack Area : 메서드를 호출할 때 해당 프레임이 생기고 그 지역 변수, 매개 변수 데이터 값이 저장
- PC register : 스레드가 시작될 때 생성된다.
이러한 메모리 구조를 바탕으로, 자바에서는 객체의 실제 메모리 주소를 직접 다루지 않고 객체에 대한 참조를 사용합니다.
이러한 특징은 객체의 비교를 통해 알 수 있습니다.
객체를 비교하는 방식에는 equals와 == 방식이 있습니다.
== 의 경우 기본 타입의 경우 값 자체를 비교하고, 참조 타입인 경우 객체의 메모리 주소를 비교합니다.
equals의 경우 기본적으로 객체의 메모리 주소를 비교합니다. (Object에 정의된 메서드입니다)
String s1 = new String("Test");
String s2 = new String("Test");
System.out.println(s1 == s2); // false
System.out.println(s1.equals(s2)); // true;
출처: https://mangkyu.tistory.com/101 [MangKyu's Diary:티스토리]
위 코드를 보면 문자열 객체를 생성한 것을 볼 수 있습니다.
이때 두 객체 모두 문자열 값은 "Test"로 동일합니다.
그런데 == 로 비교한 것을 보면 false가 나오고 equals로 비교한 것을 보면 true인 것을 알 수 있습니다.
이는 동일성과 동등성이라는 단어를 사용해 설명할 수 있습니다.
우선 동일성이란 객체가 완전히 같은 것을 의미합니다. 즉, 객체의 주소 값까지 같아야 합니다.
동등성은 객체가 가지고 있는 상태 또는 변수의 값이 같은 것을 의미합니다.
다시 == 와 equals로 돌아와 설명하겠습니다.
s1과 s2 객체는 new 연산자를 통해 생성이 되었습니다. 그로 인해 힙 메모리의 다른 위치에 할당이 되었습니다.
분명 위에서 equals를 설명할 때 객체의 주소를 비교한다고 했는데 true가 반환된 것을 볼 수 있습니다.
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
return (anObject instanceof String aString)
&& (!COMPACT_STRINGS || this.coder == aString.coder)
&& StringLatin1.equals(value, aString.value);
}
이는 String 클래스에서 equals를 오버라이드 했기 때문입니다.
실제로 위 코드는 String 클래스에서 재정의 한 equals 메서드입니다.
코드를 보면 만약 같은 객체(메모리까지)면 true를 반환하고, 아닌 경우 String으로 캐스팅을 해서 인코딩 방식과 실제 내용을 비교하는 것을 볼 수 있습니다. 그러므로 s1과 s2가 다른 위치에 저장된 객체여도 실제 내용이 같으므로 true가 반환된 것입니다.
equals를 재정의 할 때 hashCode도 재정의 해야한다고 하던데?
우선, equals를 재정의 할 때 hashCode도 재정의 해야하는 이유부터 말하자면, 자바의 규칙때문에 변경을 해야 합니다.
If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
두 개체가 equals(Object) 메서드에 따라 같으면, 두 개체 각각에 대해 해시코드 메서드를 호출하면 동일한 정수 결과가 생성되어야 합니다.
https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#hashCode--
그러면 왜 자바에서 이러한 규칙을 강제했을까요?
우선 HashCode는 객체를 식별하는 고유한 정수값입니다.
해시 코드는 주소값으로 만든 고유한 숫자값이라고 생각하시면 됩니다.
hashCode 메서드는 객체의 메모리 주소를 기반으로 Hash Code를 생성합니다.
equals와 hashCode는 둘 다 동일성을 비교한다는 공통점이 있지만, hashCode의 반환 값은 정수이고, equals는 boolean을 반환한다.
이때 equals를 동등성을 비교하는 로직으로 재정의하고 hashCode를 재정의하지 않으면 HashMap, HashSet 등의 컬렉션에서 오작동을 할 수 있습니다.
HashMap, HashSet은 객체를 저장할 때 객체의 hashCode를 호출하여 해시 값을 얻습니다.
그리고 이 해시값을 기준으로 특정 버킷에 저장합니다.
조회를 할 때는 찾고자 하는 객체의 hashCode를 호출하고, 해당 해시 값에 해당하는 버킷을 찾습니다.
이후, 버킷 내의 객체들과 equals를 사용해 비교합니다.
이때 equals를 재정의 하지 않고 hashCode를 재정의 하지 않으면 객체를 찾지 못 하거나, 동일한 객체가 여러개 저장될 수 있습니다.
그 외 장점 및 특징
1. Primitive type과 Reference type
Primitive Type을 직독직해하면 원시 타입이다.
원시적으로 값을 변수에 저장했다고 생각하시면 쉽습니다.
byte ,short, boolean, char, int, double, float등이 바로 원시타입입니다.
Reference Type은 객체를 참조하는 타입입니다.
변수에 객체의 주소를 저장한다고 생각하시면 쉽습니다.
실제로 원시 타입의 변수와 참조 타입의 변수를 만들어서 값을 넣어본 결과 하기의 사진과 같이 원시 타입의 경우 값이 조회되고, 참조 타입의 경우 Integer@762 즉, 객체의 참조를 통해 값을 가르키고 있는 것을 볼 수 있습니다.
아 그러면 원시 변수를 제외한 객체들은 참조를 통해 값을 호출하니까 Java는 Call By Reference?
여기까지 읽고 가시면 Java는 Call by Reference라고 생각하신 채 가실 수 있습니다.
어디가서 이 글 읽고 Call by Reference라고 배웠다고 하지 마세요...🗿
우선 간단하게 Call by Value와 Call by Reference에 대해 설명하겠습니다.
Call by Value와 Call by Reference는 함수나 메서드에 인자를 전달하는 두 가지 방법입니다.
Call by Value는 값에 의해 호출이 되는 것입니다.
즉, 값을 복사하여 처리하는 것입니다. 그러므로 함수 내에서 매개 변수 값을 변경해도 원본 변수에는 영향을 주지 않습니다.
그리고 Call by Reference의 경우 함수 호출 시 인자의 메모리 주소가 전달이 됩니다.
그러므로 함수 내에서 매개변수를 통해 원본 데이터를 직접 조작할 수 있습니다.
그러면 다음의 코드 보겠습니다.
public class Main {
static class Dog {
String name;
Dog(String name) { this.name = name; }
}
static void changeDog(Dog d) {
d.name = "새로운 이름"; // 원본 객체 변경됨
}
public static void main(String[] args) {
Dog myDog = new Dog("맥스");
changeDog(myDog);
System.out.println(myDog.name); // "새로운 이름" 출력
}
}
마지막에 출력 함수를 통해 myDog라는 객체의 name 필드를 출력해본 결과 새로운 이름이라는 값이 출력 됐습니다.
분명 Java는 Call by Value이고, 특성 상 값을 복사하여 처리하기 때문에 깊은 복사와 같이 원본 변수에는 영향을 주면 안됩니다.
그러나 실제로 확인을 해보면 값이 변경이 됐는데 그 이유는 Java에서 객체를 인자로 전달할 때는 객체의 주소값을 전달하기 때문에 메서드에서 원본 객체의 값을 변경할 수 있는 것입니다.
디버깅을 통해 d 객체가 전달될 때 주소 값이 전달된 것을 볼 수 있습니다.
2. static, final
static은 클래스가 로드될 때 메모리에 할당 됩니다. 그리고 클래스가 종료될 때까지 유지 됩니다.
주로 클래스간 공유되는 속성이나 메서드를 정의할 때 유용합니다.
final은 변수, 메서드, 클래스에 선언될 수 있으며 선언된 대상을 불변으로 지정합니다.
주로 변하면 안 되는 값에 final 키워드를 붙입니다.
메서드에 final을 붙이면 오버라이딩이 불가능 하고, 클래스에 붙이면 상속이 불가능합니다.
리터럴과 상수의 차이
우선 리터럴은 고정된 값이라는 정의를 가지고 있고 상수는 변하지 않는 값이라는 정의를 가지고 있습니다.
사실 고정된 값 = 변하지 않는 값이라고 생각하실 수 있는데 리터럴과 상수는 큰 차이가 있습니다.
우선 상수는 보통의 경우 static final로 정의를 합니다.
하기의 코드와 같이 숫자뿐만 아니라 변하면 안 되는 값을 static final 키워드를 붙여서 만든 것을 상수라고 합니다.
private static final double PI = 3.141592;
그에 비해 리터럴은 생각보다 쉽습니다.
다시 이 코드를 보면 PI라는 상수에 3.141592라는 값을 할당합니다.
이때 "3.141592"라는 값이 바로 리터럴입니다.
변수에 할당되거나 연산에 사용될 수 있는 데이터라고 생각하시면 됩니다.
private static final double PI = 3.141592;
또한 Java에서는 메인 메서드가 static으로 정의되어 있습니다.
메인 메서드는 프로그램이 시작되면 가장 먼저 호출되는 메서드입니다.
그로 인해 객체 생성 없이 메인 메서드를 호출해야 하므로 static으로 호출해야 됩니다.