[Java] Java 기본 개념, 객체 지향, 버전

sion·2024년 10월 30일
0

자바 스터디

목록 보기
1/7
post-thumbnail

자바 기본

Java의 특징

굉장히 많지만.. 개인적으로 중요하다고 생각하는 특징은 1) OOP 2) 플랫폼 독립적 3) GC 세 가지이다.

자바는 OOP를 가장 잘 활용할 수 있는 언어이고,
platform-dependent의 WORA(Write Once, Run Anywhere)의 철학으로 자바가 탄생 하였고,
또한, GC는 자바 성능 관련 대규모 애플리케이션에서 빼놓을 수 없는 키워드이기 때문입니다.

  1. Object-Oriented (객체지향 프로그래밍 언어)
    객체 간 협력 관계를 설계하는 것.

    객체지향 프로그래밍의 4가지 특징
    1) 추상화: 객체의 공통적인 속성과 기능을 뽑아 정의하는 것 - abstract class, interface
    2) 상속: 기존 클래스의 기능을 사용하면서도 확장해서 사용하는 것. 부모 클래스의 모든 기능을 물려받는 자식 클래스를 정의하는 것. (단, 생각보다 상속은 꽤 위험하다.. why?)
    3) 다형성: 객체지향 프로그래밍의 꽃! 핵심!
    구현 클래스에 직접 의존하는 것이 아니라, 상위 클래스(인터페이스)에 의존하여 객체 간 의존도를 낮출 때 자주 사용한다. 확장에 열려있고, 변경에 닫혀있는 OCP 원칙도 지킬 수 있다. 또한, 구현체만 갈아끼우면 되기 때문에 테스트에도 용이하다.
    4) 캡슐화: 외부로부터의 접근을 보호하고, 필요한 것만 외부에 노출시키는 것. (ex. 접근제어자, getter/setter)

  2. Portable (이식성)
    자바 바이트코드를 모든 플랫폼에 옮길 수 있음
  3. Platform independent (플랫폼 독립)
    자바는 OS가 아닌 JVM 과 상호작용하기 때문에 OS와는 독립적입니다.
    결국에는 자바 소스코드를 기계가 해석하기 위해서는 기계어로 변환하는 과정이 필요한데, 이를 JVM이 실행하므로 JVM은 OS와 종속적입니다.

    참고: 자바 컴파일러(javac)에 의해 컴파일된 바이트 코드(.class)는 JVM 실행엔진의 인터프리터 + JIT 컴파일러 과정을 통해 기계어로 변환

  1. Secured
    포인터가 없기 때문에 강제로 메모리 주소를 가리킬 수 없음
    GC에 의해 메모리 자동 관리되어 OOM 방지
  2. 이외
    Simple (단순), Robust, Architecture neutral, Interpreted, High Performance, Multithreaded, Distributed, Dynamic(클래스의 동적 로딩을 지원. 네이티브 C 코드 import 가능)

Java의 단점

  • JVM이 인터프리터 방식으로 바이트코드를 해석하기 때문에 컴파일 언어(c, c++)보다 느립니다. (JIT 컴파일러로 성능이 많이 개선되긴 하였음)
  • GC가 메모리 관리를 하기 때문에 편리하지만, 예기치 못한 full gc로 인한 모든 애플리케이션이 멈추는 stop-the-world 성능저하, 장애가 발생할 수 있습니다.

Java 실행 과정에 대해서 설명해주세요.

자바 소스코드(.java) -> [javac 자바 컴파일러] -> 바이트코드(.class) -> [JVM] -> 기계어

  1. JVM이 OS로부터 메모리 할당 받는다.
  2. 자바 컴파일러(javac)가 소스코드를 바이트코드(.class)로 컴파일한다.
    [여기서부터 JVM]
  3. Class Loader가 바이트코드(.class)를 동적으로 로딩해서, Runtime Data Area에 올린다.
  4. Execution Engine이 로딩된 바이트 코드를 해석한다. (인터프리터 + JNI 컴파일러)
    • 또한, GC도 실행엔진에서 동작합니다.

JVM 동작 과정

Class Loader

Class Loader가 클래스들을 동적으로 로딩해서, Runtime Data Area에 배치한다.
로딩 -> 링킹 -> 초기화
1. Loading: 바이트코드를 JVM의 메모리로 동적 로드
2. Linking: 레퍼런스 연결

  • 1) Verifying(검증): 클래스파일이 유효한지(JVM 제약조건에 어긋나지 않은지) 검사. 중간에 조작된 경우 에러가 발생한다.
  • 2) Preparing(준비): 클래스 static 변수, 기본값을 위한 메모리 할당
  • 3) Resolving(분석): 참조변수가 Heap에 저장된 인스턴스를 가리키도록 연결
  1. Initialization: static 값들 초기화

Runtime Data Area

JVM이 OS에게 할당받은 메모리 영역이다.
1. 공유 영역

  • 코드(Method Area) (=Class Area, Static Area): 클래스, 메소드, static 변수
  • 힙(Heap Area): 인스턴스 저장 영역, new 키워드! (레퍼런스 타입). 단, heap의 오브젝트를 가리키는 참조변수는 stack에 위치.
  1. 스레드별 영역
  • 스택(Stack Area): primitve 타입의 지역변수, 매개변수 등
    • 메소드를 실행하면 스택 프레임에 하나씩 쌓이고, 종료되면 스택 프레임이 제거된다.
    • 스택 프레임 데이터: 메소드의 매개변수, 지역변수, 리턴값
  • Native Method Area: 자바 이외 언어인 네이티브 코드를 위한 스택 영역.
  • PC Register: 실행할 명령어 주소값

Execution Engine

Runtime Data Area 영역에 배치된 바이트코드를 명령어 단위로 읽어서 실행한다.
JVM은 바이트코드를 읽어 기계어로 변환하는데, 이때 인터프리터와 JIT 컴파일러 두 가지 방식을 혼합하여 바이트 코드를 실행한다.

  • 인터프리터 (Interpreter): 바이트코드 명령어를 하나씩 해석한다.
  • JIT 컴파일러 (Just-In-Time Compiler): 반복되는 코드를 컴파일하여 기계어(네이티브 코드)로 변경하고, 캐싱해두고 인터프리팅하지 않고 직접 실행한다.

참고: https://velog.io/@impala/JAVA-JVM-Class-Loader

GC

JVM은 가비지 컬렉터가 Heap 영역에 더 이상 참조되지 않은 메모리를 자동으로 회수한다.
참조되지 않은지는 어떻게 판단하냐면? Mark and Sweep.

  • Mark: Root Space로부터 참조된 연결된 객체를 찾아서 마킹한다. (그래프 순회)
    • Root Space: Heap에 위치하지 않으면서 직접 Heap 영역을 참조하는 모든 것
  • Sweep: 마킹되지 않은 unreachable 객체들을 heap에서 제거한다.
  • Compact: 제거 이후, 곳곳에 흩어진 객체들을 한 곳으로 모은다.

GC 동작 과정

1. Young Generation
Eden, Survival 0, Survival 1 영역으로 나누어져 있다.

  • Eden: 새로 생성된 객체
  • Survivor0/1: minor GC로 살아남은 객체가 존재하는 영역.

<Minor GC 과정>
1. Eden 영역이 가득 차면 minor GC 발생!
2. reachable한 객체를 찾아서, 한 곳의 Survivor 영역으로 이동시킨다. (Mark)
3. unreachable한 객체를 메모리에서 해제한다. (Sweep)
4. 살아남은 객체는 age가 1씩 증가.
1~4 과정 반복.. 그러다 minor GC로부터 살아남은 객체 중 age가 임계값(31)을 넘으면, Old Generation으로 넘어간다. (Promotion이라 불림)

2. Old Generation
Old Generation 영역도 부족하면 Major GC(=Full GC) 발생!
큰 공간을 가지고 있어서 오래 걸린다. Full GC가 일어나는 동안은 애플리케이션이 멈추기 때문에 CPU 부하가 가고, 멈추는 등 장애가 발생할 수 있다.

GC 알고리즘 종류

  1. Serial GC: GC를 싱글스레드로 처리하는 방식
  • minor gc에는 Mark-Sweep, major gc에는 Mark-Sweep-Compact 사용
  • -XX:+UseSerialGC
  1. Parallel GC: minor gc를 멀티스레드로 처리한다.
  • Java8의 Default GC
  • -XX:+UseParallelGC -XX:ParallelGCThreads=N
  1. Parallel Old GC: Parallel GC의 개선 버전
  • old 영역에서도 멀티 스레드를 사용해서 gc를 수행한다.
  • Mark-Summary-Compact 방식 사용
  • -XX:+UseParallelOldGC -XX:ParallelGCThreads=N
  1. CMS GC (Concurrent Mark Sweep)
  • 애플리케이션 스레드와 GC 스레드 동시에 실행하여 stop-the-world 시간을 줄이기 위해 고안된 GC
  • 문제가 많음: 메모리 파편화 등
  • java9부터 deprecated, java14부터 사용 불가
  1. G1 GC (Garbage First)
  • java9+ 버전의 Default GC
  • 4GB 이상의 힙메모리 사용시 권장
  • Young / Old 영역으로 나누는게 아닌, 전체 Heap 영역을 Region으로 분할
    • 이전과 달리, Eden -> Survivor0/1 로 단계적으로 이동하지 않고 객체를 상황에 따라 Region을 이동/재할당(reallocation)시킨다. 즉, Suvivor0 영역에 있던 객체가 Eden 영역으로 이동할 수 있음.
  • Region별로 Eden, Survivor, Old 등으로 나눈다.
  • Heap 영역 전체가 아닌 Region 별로 GC가 일어난다.
  • -XX:+UseG1GC
  1. Shenandoah GC
  • 기존 CMS GC가 가진 단편화, G1 GC가 가진 pause 이슈를 해결
  • -XX:+UseShenandoahGC
  1. ZGC (Z Garbage Collector)
  • 대량의 메모리를 잘 처리하기 위해 디자인된 GC
  • G1의 Region과 비슷하게 ZPage라는 영역을 사용한다. Region은 고정크기이지만, ZPage는 2MB 배수로 동적으로 운영된다.
  • 힙 크기가 증가해도 stop-the-world 시간이 절대 10ms를 넘지 않는다는 최대 장점이 있다.
  • -XX:+UnlockExperimentalVMOptions -XX:+UseZGC

참고

Java Bytecode

자바 소스코드를 자바 컴파일러에 의해 컴파일된 중간 단계의 코드입니다. JVM이 이 바이트코드를 해석하여 애플리케이션이 구동됩니다.

Java의 인터프리터(interpreter) 방식과 JIT 컴파일(compile) 방식

JVM의 Class Loader가 바이트코드를 Runtime Data Area 영역에 배치시키면, Execution Engine이 해당 바이트 코드를 기계어로 해석하여 구동합니다. 이때, 바이트코드를 한 줄씩 실행하는 인터프리터 방식과 동적으로 최적화하여 기계어로 컴파일하여 캐싱해두는 JIT 컴파일 방식 두 가지로 진행됩니다.

실제 면접 스터디 중 멘토님이 해주신 추가 질문

Q. JIT 컴파일러에 의해 컴파일된 네이티브 코드는 코드 캐시라는 메모리 영역에 저장된다고 하셨는데요, 만약 여기가 꽉 찰 경우 어떻게 될까요?
A. 코드 캐시 영역은 JVM이 시작될 때 설정된 크기로 고정되므로 확장이 불가능합니다. 따라서 코드캐시가 꽉 차면 더이상 JIT 컴파일은 이루어지지 않고 새로운 코드는 모두 인터프리터 모드로만 실행됩니다.
즉, 남은 코드가 모두 인터프리터로 동작되므로 성능 저하가 발생할 수 있습니다.
<추가>
-XX:ReservedCodeCacheSize= 옵션을 통해 코드 캐시의 최대 크기를 지정할 수 있기 때문에 적절히 크기를 관리하는 것이 중요.
참고: https://velog.io/@ddangle/Java-JIT-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC-sfbp9dtu

Java 8, 11, 17, 21 버전의 특징과 차이점

  1. 8: 모던 자바
  • 람다 표현식: 함수형 인터페이스에 정의된 추상메소드를 익명함수로 짧게 나타낸 것. (리턴타입, 이름, 매개변수 생략 가능. 한줄이라면 중괄호, 리턴문도 생략 가능).
  • 함수형 인터페이스: 단 한개의 추상메소드를 갖는 인터페이스. 람다 표현식을 통해 함수형 프로그래밍을 구현하기 위함.
  • 스트림: 람다를 활용해 컬렉션, 배열을 표준화된 방법이자 함수형으로 간단하게 다루기 위한 것
    • List, Set, Map, Array -> Stream으로 만들게 되면 타입이 달라도 똑같이 처리 가능.
    • 특징
      • 원본을 변경하지 않는다.
      • 일회용이어서 한 번 사용하면 재사용 불가
      • 지연 연산 (최종 연산 전까지는 중간연산 수행 X)
      • 내부 반복. forEach의 경우, 내부적으로 for문을 수행한다.
      • 병렬스트림 가능: ParallelStream
        • 개발자가 스레드를 직접 관리하지 않아도 parallelStream(), parallel()을 사용하면 알아서 ForkJoinPool 방식을 이용하여 분할처리한다. Work-Stealing 메커니즘: 스레드의 task가 없으면 다른 스레드의 task를 steal해와서 처리해서 CPU 자원 낭비없이 최적의 성능 발휘.
        • 참고: https://dev-coco.tistory.com/183
      • 기본형 스트림(오토박싱&언박싱 비효율 제거할 수 있음. Stream<Integer> 대신 IntStream 사용하면 됨)
    • 스트림 생성 -> 중간 연산(0~N번 가능) -> 최종 연산(0~1번만 가능)
  • Optional: NPE 방지
    - Optional<T>는 null이 올 수 있는 값을 감싸는 Wrapper 클래스. 즉, null이 들어간 객체 접근시 NPE가 발생했지만, Optional로 감싸면 NPE가 바로 발생하지 않는다.
    • 단, Wapper로 감싸고 푸는 과정에서 오버헤드가 있으므로 nullable한 상황에서만 사용되어야 한다.
  • CompletableFuture: Future + CompletionStage 인터페이스 (Future의 개선 버전)
    • Java5 Future로 비동기 작업 결과값을 반환받을 수 있게 되었지만, 한계점 존재.
      • Future 한계: 외부에서 완료 불가, 완료는 get 호출시 타임아웃으로만 가능 (get은 blocking 호출). 여러 Future 조합 불가, 예외처리 불가
    • 외부에서 완료시키기 가능. Future 작업 중첩 가능. 완료 후 콜백 가능.
    • 사족을 달자면.. 아직 Reactive Programming 개발을 해보지 않아, 정확히 이해하지는 못 함 😂
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
	System.out.println("Thread " + Thread.currentThread().getName());
});
future.get();
  1. 11
  • HTTP Client API가 정식 출시 (전에는 HttpUrlConnection이나 Apache 외부 라이브러리를 많이 사용했음)
  • String 클래스에 strip(), isBlank() 등 편의 메소드 추가
  • 타입추론 방식인 var: 람다 표현식 내에서 사용 가능
  1. 17
  • switch 패턴매칭 프리뷰
  • sealed class: 클래스 정의할 때, 상속이 가능한 클래스들을 제한할 수 있다. (sealed, final, non-sealed)
    permits로 지정된 클래스는 final 혹은 non-sealed 키워드로 선언되어야 한다.
public sealed interface CarBrand permits Hyundai, Kia {}

public final class Hyundai implements CarBrand {}
public non-sealed class Kia implements CarBrand {}
  1. 21
  • Virtual Threads: OS 스레드를 사용하지 않고 JVM 내부 스케쥴링을 통해 더 많은 스레드 사용 가능
    - 플랫폼 스레드: 네이티브스레드와 1대1 맵핑, 생성개수 제한, 생성비용 비쌈 (그래서 스레드 풀을 무조건 사용했고, 경량 스레드에서는 사용하지 않는게 좋음.)
    • 스프링부트에서 spring.threads.virtual.enabled=true 적용하면 WAS 워커스레드를 가상스레드로 사용가능
    • 가상스레드는 Heap 메모리를 사용하기 때문에, ThreadLocal을 사용하지 않는 게 좋음 (OOM 발생 가능)
    • 참고: https://youtu.be/vQP6Rs-ywlQ?si=kceMcAi0XXj-U6Mk
  • Record Patterns: instaceof에 타입 패턴 적용해서 바로 변수 선언 가능. 타입 캐스팅하지 않아도 됨.
  • Pattern Matching for Switch
  • Sequenceed Collections
  • KEM (Key Encapsulation Mechanism) API 도입: 패딩이 필요없는 대칭키 도출

JDK와 JRE

JDK(Java Development Kit)는 개발에 필요한 라이브러리와 javac, debugger 등의 개발 툴이 포함되어 있는 sdk입니다. 또한, JRE까지 포함되어 있습니다.

JRE(Java Runtime Environment)는 자바 프로그램을 실행시킬 때 필요한 라이브러리와 JVM이 묶여 있는 패키지입니다.

간단히 Java로 프로그램을 개발하려면 jdk가 필요하고, 컴파일된 Java 프로그램을 실행시키려면 JRE가 필요합니다.

동일성과 동등성, equals()와 ==

동일성은 두 객체가 물리적으로 가리키는 메모리 주소 값이 같다는 것을 의미합니다. 이때, == 를 통해 검증합니다. 메모리 주소가 같다면 true, 다르면 false를 리턴합니다.

동등성은 두 객체가 논리적으로 동등한 경우를 의미합니다. 이때 equals()hashCode() 를 통해 검증합니다. equals() 는 기본적으로 Object에 == 로 정의되어 있지만, 언제든지 논리적 상황에 따라 equals()를 재정의할 수 있습니다.

HashCode와 equals(), hashCode()

HashCode는 객체의 메모리 주소값을 정수로 변환한 고유한 값을 의미합니다. 따라서, 두 객체가 동일한지 판단할 때 사용할 수 있습니다.
equals()는 두 객체가 논리적으로 동등한지 판단할 때 사용하고, 리턴값이 boolean 입니다.

equals()hashCode() 를 같이 재정의해야 하는 이유?

객체에 equals()를 재정의했다 해도 객체를 HashTable의 Key로 사용하려는 경우, 문제가 발생할 수 있습니다.

예를 들어, 클래스 내 필드값이 같으면 같은 객체로 간주하도록 equals()만 재정의했다고 가정해보겠습니다.
동일한 필드값으로 생성한 인스턴스 두 개를 HashSet에 추가하면, 중복을 허용하지 않는 HashSet의 특성상 하나만 저장될 것으로 예상하겠지만, 실제로는 두 개 모두 저장됩니다.

이는 HashSet이 내부적으로 HashMap으로 구현되어 있어, 키값(객체)의 해시코드를 통해 접근하기 때문입니다. 두 객체의 해시코드가 다르므로 서로 다른 객체로 인식되는 것입니다. 따라서, 논리적 동등성을 보장하기 위해서는 equals()와 hashCode() 모두를 재정의해야 합니다.

toString()이란?

기본적으로 Object의 toString()클래스이름@주소값 이지만, 객체 주소값은 사실 개발하면서 크게 확인할 일이 없기에, 재정의해서 디버깅에 사용합니다. 또한, print 출력 메소드 사용시에 자동으로 toString() 값으로 출력됩니다.
비슷한 메소드로 String.valueOf()가 존재. 내부적으로 toString()을 호출하지만 NPE가 일어나지 않습니다. "null"로 출력.

자바에서 메인 메서드가 static인 이유

main이 시작점이기 때문에, 클래스 인스턴스를 생성하지 않고 곧바로 호출 가능하도록 되어 있습니다. 또한, GC에 의해서 메모리에서 해제되는 것을 방지해줍니다.
시그니처: public static void main(String[] args)

상수(Constant)와 리터럴(Literal)

상수는 변경 불가한 데이터를 의미합니다. final 키워드로 선언하고, 의미있는 문자로 네이밍해야 하고 대문자와 언더스코어 규칙을 따릅니다.

리터럴은 소스코드에 직접 표현되는 상수 값입니다.

  • 정수 리터럴
    - int: 10진수, 8진수(0~), 16진수(0x~), 2진수(0b~)
    - long: long a = 100L;
  • 실수 리터럴
    - double: double d = 0.11d; d는 생략 가능
    • float: float f = 0.11f; f는 생략 불가
  • 문자 리터럴
    - 작은따옴표로 나타낸다.
    • char: '\\'(백슬래시), '\t'(탭), '\n'(라인피드) 등
  • 문자열 리터럴
    - 큰따옴표로 나타낸다.
    • String: String literal = "literal";
  • 이외
boolean a = true;
boolean b = 1 > 0;
Integer c = null;

Primitive Type과 Reference Type

primitive type은 정수, 실수, 문자, 불리언 값을 직접 저장하는 타입입니다.
byte, short, int, long, float, double, char, boolean
Stack 영역에 저장되고, 메모리 사용과 접근속도에서 유리합니다. null이 불가능하고 모두 default값을 가집니다. 제너릭 타입에서 사용이 불가능합니다.

reference type은 객체의 메모리 주소를 저장하는 타입입니다.
Heap 영역에 저장되고, null이 존재합니다.

primitive에 대응되는 wrapper class가 있기 때문에 서로 형변환이 가능한데, 이걸 자동으로 처리하는 기능인 오토박싱/오토언박싱을 제공합니다. 박싱(long->Long), 언박싱(Long->long)
하지만 cpu 사용량과 메모리 사용량을 증가시키는 성능 저하 여지가 있습니다. 특히, 반복문을 통해 많이 발생한다면 위험하기 때문에, 람다를 사용할 때 IntStream과 같은 스트림 API를 잘 활용하는 것이 중요합니다.

개인적으로 null이 꼭 필요하지 않은 경우, primitive type을 사용해서 성능을 높이는 것이 좋다고 생각합니다. 예를 들어, int의 경우 0과 null을 꼭 다르게 처리해야 하는 게 아니면 primitive 사용 권장 !

Java는 Call by Value? Call by Reference?

자바에서는 모든 것이 Call by Value로 동작합니다. primitive type의 경우 실제 값이 복사되어 전달되고, reference type의 경우 객체의 참조값(메모리 주소)이 복사되어 전달되기 때문입니다.

  • Call by Value: 함수 호출 시 복사된 을 넘겨주기 때문에, 매개변수로 받은 데이터를 수정해도, 기존 데이터가 변경되지 않는다.
  • Call by Reference: 함수 호출 시 메모리 주소 자체를 넘겨준다. 따라서, 매개변수에 새로운 값을 할당하면 원본 데이터도 변경된다.

Q: 자바에서도 int[] arr를 받아서, arr[0] = 1을 실행하면 원본 데이터가 수정되던데.. Call by Reference가 아닌가요?
A: Java에서는 모든 것이 값으로 전달됩니다. 메소드 내에서 참조를 통해 객체의 내용을 변경하면 원본 데이터에 영향을 줍니다. 그러나 매개변수에 새로운 객체를 할당해도 원본 데이터는 변경되지 않습니다. (내부 값 수정은 가능하지만, 변수 자체를 변경할 수는 없다.)

Java 직렬화(Serialization)

자바에서 사용되는 객체를 외부에 내보내서 다른 자바 시스템에서 사용할 수 있도록 최소 단위인 바이트스트림 형태로 연속적인 데이터로 변환하는 포맷 변환 기술입니다.
그 반대인 역직렬화(Deserialization)은 바이트스트림 형태를 자바 Object로 변환하는 기술입니다.

하지만, 자바 직렬화의 문제점이 많습니다.. 따라서 Java 객체와 Json 간 변환을 하는 ser/deser로 많이 사용합니다. Spring MVC에서는 MappingJackson2HttpMessageConverter를 사용하여 HTTP 요청과 응답에서 JSON 데이터를 처리합니다.

  • 참고
    Serializable 인터페이스를 구현한 모든 직렬화된 클래스는 serialVersionUID라는 고유 식별번호를 가집니다. 따라서, 직렬화 시점과 역직렬화 시점의 클래스 정보가 다르면 오류가 발생합니다. 또한, suid를 생성하는데 시간도 오래 걸리기 때문에 클래스 버전을 직접 명시해서 관리하는 것이 권장됩니다.
    private static final long seralVersionUID = 10L
    transient 키워드로 직렬화 대상에서 제외시킬 수 있습니다.
// 직렬화할 객체 정의
import java.io.Serializable;

class A implements Serializable {
	// fields..
}

// Serialization 과정
A a = new A();
// 필드 setter..

try (
	FileOutputStream fos = new FileOutputStream("sample.csv");
    ObjectOutputStream out = new ObjectOutputStream(fos)
) {
	out.writeObject(a); // 바이트스트림으로 변환하고 파일에 저장
} catch (IOException e) {}

// Deserialization 과정
try(
	FileInputStream fis = new FileInputStream("sample.csv");
    ObjectInputStream in = new ObjectInputStream(fis)
) {
    A a = (A) in.readObject(); // 바이트 스트림을 자바 객체로 변환
} catch (IOException | ClassNotFoundException e) {}

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A7%81%EB%A0%AC%ED%99%94Serializable-%EC%99%84%EB%B2%BD-%EB%A7%88%EC%8A%A4%ED%84%B0%ED%95%98%EA%B8%B0#%EC%9E%90%EB%B0%94%EC%9D%98_%EC%A7%81%EB%A0%AC%ED%99%94__%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94

자바 객체 지향

오버로딩과 오버라이딩

오버로딩은 메소드 이름은 같되 매개변수 타입과 개수가 다르게 메소드를 추가하는 것을 의미합니다. 메소드 리턴 타입이 다른 것은 불가합니다.

오버라이딩은 상위 클래스의 메소드를 하위 클래스가 재정의해서 사용하는 것을 의미합니다.

다형성이란?

하나의 객체가 여러 가지 타입을 가질 수 있는 것을 말합니다. 이를 잘 활용하면 유연하고 확장성 있는 코드를 만들 수 있기 때문입니다.
예를 들어, 인터페이스 타입으로만 바라보도록 하면 내부 구현체가 바뀌어도 코드를 수정하지 않아도 되는 OCP 원칙을 잘 지킬 수 있게 됩니다. 특히, Spring에서는 이러한 다형성을 잘 활용할 수 있도록 제공해줍니다. DI 컨테이너와 빈 설정클래스를 통해서 구현체를 지정하여, 비즈니스 로직에서는 구현체에 신경쓰지 않아도 됩니다.

상속이란?

기존 클래스에서 사용되는 기능과 요소를 그대로 물려 받으면서(재사용), 확장해서 사용하는 것을 의미합니다.

상속의 단점

강하게 결합되어 있기 때문에, 상위 클래스에 따라 하위 클래스에 이상이 생길 수 있습니다. 따라서, 상속을 하려면 상위 클래스의 내부 구현을 잘 알아야 합니다. 잘 모르고 사용하다가 예상치 못한 버그를 발생시킬 수 있기 때문입니다. 이러한 관점에서 내부 동작을 몰라도 되는 캡슐화를 망가뜨릴 수 있습니다.

상속과 조합의 차이

조합(Composition)은 기존 클래스를 확장하지 않고, private 내부 필드로 사용하는 방식입니다. 이를 통해 강한 결합에서 약한 결합으로 바뀌게 됩니다. 기존 클래스에서 필요한 메소드들만 사용하여, 상위 클래스의 캡슐화도 잘 유지할 수 있습니다.

instanceof 키워드

판단하고자 하는 객체가 특정 클래스의 타입인지를 판단하는 키워드입니다. 이때, 정확히 자기의 타입과 일치하지 않아도 상위클래스인 경우(즉, 형변환이 가능한 경우)에는 true를 리턴합니다.

instanceof 키워드의 문제점

일부러 인스턴스 혹은 추상 클래스로 추상화 및 캡슐화를 적용했는데, instanceof를 사용함으로써 클라이언트가 하위 클래스를 직접 사용하여 특정 클래스일 경우에만 처리하는 로직이 들어가 위 두 가지 원칙이 깨지게 됩니다.

새로운 구현체가 생기면, instanceof로 비교하는 곳에도 추가해줘야 할 수 있습니다. (OCP 위반)
불필요한 instanceof 검사로 성능이 저하될 수 있습니다.

따라서, instanceof 대신 boolean 리턴타입의 추상 메소드를 사용해서 특정 클래스에서만 구현을 다르게 동작시키거나 마커 인터페이스를 이용하면 됩니다.

interface란?

구현 스펙과 같이 기본 구조를 제공하고, 인터페이스를 매개체로 구현 클래스들 간 통신이 이루어집니다. 그 자체로 인스턴스를 생성할 수 없습니다. 또한, 사용하는 입장에서는 기본 구조(매개변수, 리턴타입)만 맞춰 요청하면 되므로, 내부 구현에 대해 신경쓰지 않아도 됩니다. 접근제어자는 모두 public이라는 특징이 있습니다.

원래는 상수와 추상메소드만 제공되었지만, java8부터 디폴트메소드와 정적메소드가 추가되었습니다.

  • 상수: 이 값을 그대로 사용해야 한다.
  • 추상메소드: 무조건 오버라이딩해서 사용해야 한다.
  • 디폴트메소드: 기본 메소드 줄건데, 구현을 바꾸고 싶으면 바꿔서 사용해도 된다.
    • why? 스트림, 람다를 Collection, Map에서 사용하기 위해, 기존의 모든 구현 클래스들에서 기능을 추가해야 했다. (ArrayList, HashSet, HashMap 등) 이때, 인터페이스에 디폴트 메소드를 추가해서 쉽게 해결한 것이다. 따라서 구현체 수정 없이 모두 적용시키고 싶을 때 사용하면 좋다.
  • 정적메소드: 일반 클래스 static method와 동일

interface와 abstract class의 차이

인터페이스는 다중 상속이 되고, 추상 클래스는 불가합니다.
추상클래스는 생성자를 가질 수 있지만, 인터페이스는 불가합니다.

그러면 언제 interface 사용하고, 언제 abstract class 사용하는지?

인터페이스는 필요에 따라 여러 기능을 자유롭게 붙였다 뗐다 할 수 있기에 클래스끼리 관련성이 크게 없을 때, 추상클래스는 설계 단계에서 미리 상속 구조가 확실할 때 사용하면 좋습니다.

인터페이스 (implements)

  • 예를 들어, 추상 클래스로 상속받은 구현체는 무조건 추상 메소드를 구현해야 하지만, 몇몇 클래스에만 적용되어야 하는 기능이 필요하다고 해봅시다. 그러면 이러한 기능을 새로운 인터페이스로 정의하고, 필요한 클래스들만 해당 인터페이스를 구현해서
  • 따라서, 상속보다 조금 더 자유로운 확장이 가능합니다.

추상 클래스 (extends)

  • 계층 구조가 확실
  • 중복되는 멤버(필드, 메소드) 많을 때
  • public 접근자 이외가 필요한 경우

참고: https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-vs-%EC%B6%94%EC%83%81%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4_vs_%EC%B6%94%EC%83%81%ED%81%B4%EB%9E%98%EC%8A%A4_%EB%B9%84%EA%B5%90

final 키워드

변수, 메소드, 클래스에 대한 변경을 금지하는 키워드입니다. 이를 잘 활용하면 코드의 안정성을 높일 수 있습니다.

변수에 final을 사용하면, 상수가 되어 예상치 못한 값의 변경을 막을 수 있습니다.
메소드에 final을 사용하면, 오버라이딩이 불가하여 설계 의도를 명확하게 전달할 수 있습니다.
클래스에 final을 사용하면, 상속이 불가하여 확장이나 변경이 불가능합니다. 자바의 String 클래스도 final로 선언되어 있습니다.

특히, 상수를 사용할때는 static final을 함께 활용합니다. 프로그램 전체에서 공유되고, 변경 불가하기 때문에 핵심 상수를 정의할 때 사용하면 좋습니다.

0개의 댓글

관련 채용 정보