Java 언어 핵심 정리

Java는 객체지향 프로그래밍을 중심으로 설계된 범용 프로그래밍 언어다. 1995년 처음 공개된 이후 웹 서버, 기업용 애플리케이션, Android, 금융 시스템, 대규모 백엔드 등 다양한 분야에서 사용되어 왔다.

Java의 가장 큰 특징은 JVM(Java Virtual Machine)을 중심으로 한 실행 환경이다. Java 소스 코드는 바이트코드로 컴파일되고 JVM이 이를 실행한다.

Java Source -> Java Compiler -> Bytecode -> JVM -> Machine Code

이 구조 덕분에 운영체제에 종속된 네이티브 실행 파일을 직접 만드는 방식과 비교하면 플랫폼 독립성이 높다.

Java를 이해하려면 문법 자체보다 객체지향 구조, 타입 시스템, JVM, 메모리 관리, 예외 처리, 컬렉션, 동시성을 함께 이해하는 것이 중요하다.


Java의 기본 구조

가장 기본적인 Java 프로그램은 다음과 같이 작성한다.

public class Main
{
    public static void main(String[] args)
    {
        int value = 10;

        System.out.println(value);
    }
}

main()이 일반적인 Java 애플리케이션의 시작점이다.

Java 소스 파일은 .java 파일로 작성하고 javac 컴파일러를 사용해 바이트코드로 변환한다.

Main.java -> javac -> Main.class -> JVM

.class 파일에는 JVM이 이해할 수 있는 바이트코드가 들어간다.

JVM은 이 바이트코드를 해석하거나 JIT(Just-In-Time) 컴파일을 통해 네이티브 코드로 변환하여 실행한다.


변수와 타입

Java는 정적 타입 언어다.

int health = 100;
float speed = 3.5f;
double distance = 100.0;
boolean alive = true;
char grade = 'A';
String name = "Player";

대표적인 기본 타입은 다음과 같다.

byte / short / int / long / float / double / char / boolean

이 타입들은 Java의 primitive type이다.

반면 String, 배열, 클래스 등은 객체를 사용하는 reference type이다.


Primitive Type과 Reference Type

Java에서 타입을 이해할 때 중요한 구분이다.

int a = 10;
int b = a;

b = 20;

이 경우 a는 그대로 10이다.

반면 객체를 참조하는 경우에는 참조값이 복사된다.

Player a = new Player();
Player b = a;

b.health = 0;

ab가 같은 Player 객체를 가리키기 때문에 a.health0이 된다.

Java에서는 흔히 primitive type과 reference type을 구분한다.

Primitive Type -> 값 자체를 표현
Reference Type -> 객체에 대한 참조

다만 Java의 메모리 구조를 단순히 primitive = stack, object = heap으로 외우는 것은 정확하지 않다. 실제 JVM의 구현과 최적화에 따라 구체적인 메모리 배치는 달라질 수 있다.


클래스와 객체

Java는 객체지향 프로그래밍을 중심으로 설계되어 있다.

class Player
{
    int health;

    void attack()
    {
        System.out.println("Attack");
    }
}

클래스를 이용해 객체를 생성할 수 있다.

Player player = new Player();

player.health = 100;
player.attack();

클래스는 객체가 가져야 할 상태와 동작을 정의한다.

게임을 예로 들면 다음과 같이 구성할 수 있다.

Player -> health / position / attack() / move()
Monster -> health / position / attack() / move()

실제 객체지향 설계에서는 데이터를 단순히 모아놓는 것보다 객체가 자신의 상태를 어떤 방식으로 변경하고 외부에 어떤 기능을 제공하는지까지 함께 설계한다.


생성자

객체가 생성될 때 호출되는 메서드가 생성자다.

class Player
{
    private int health;

    public Player(int health)
    {
        this.health = health;
    }
}

객체를 생성하면 생성자가 호출된다.

Player player = new Player(100);

this는 현재 객체를 가리킨다.

this.health = health;

여기서는 필드 health와 생성자의 매개변수 health를 구분하기 위해 사용했다.


접근 제어자

Java는 클래스와 멤버의 접근 범위를 제한할 수 있다.

대표적으로 다음이 있다.

public / protected / private / package-private

예를 들어:

class Player
{
    private int health;

    public void damage(int amount)
    {
        health -= amount;
    }
}

외부에서는 health에 직접 접근할 수 없고 damage()를 통해 상태를 변경하도록 만들 수 있다.

이런 방식으로 객체의 내부 구현을 숨기고 외부에 필요한 인터페이스만 제공할 수 있다.


상속

Java는 클래스를 상속할 수 있다.

class Animal
{
    void speak()
    {
        System.out.println("Animal");
    }
}

class Dog extends Animal
{
    @Override
    void speak()
    {
        System.out.println("Dog");
    }
}

이제 DogAnimal의 기능을 상속받는다.

Animal animal = new Dog();

animal.speak();

실행 결과는 Dog가 된다.

이것이 런타임 다형성이다.

Java에서는 클래스의 다중 상속을 허용하지 않는다.

class Dog extends Animal
{
}

하나의 클래스만 상속할 수 있다.

여러 타입의 기능을 조합할 때는 인터페이스를 사용하는 방식이 일반적이다.


인터페이스

인터페이스는 객체가 어떤 기능을 제공해야 하는지를 정의한다.

interface Damageable
{
    void takeDamage(int damage);
}

여러 클래스가 이를 구현할 수 있다.

class Player implements Damageable
{
    @Override
    public void takeDamage(int damage)
    {
    }
}

class Monster implements Damageable
{
    @Override
    public void takeDamage(int damage)
    {
    }
}

그러면 구체적인 클래스가 무엇인지 몰라도 Damageable을 통해 공통 기능을 사용할 수 있다.

void applyDamage(Damageable target)
{
    target.takeDamage(10);
}

상속이 “무엇인가”라는 타입 관계를 표현하는 데 적합하다면 인터페이스는 “무엇을 할 수 있는가”라는 기능적 계약을 표현하는 데 유용하다.


final

final은 변경할 수 없다는 의미를 상황에 따라 표현한다.

변수에서는 값을 다시 할당하지 못하게 한다.

final int MAX_HEALTH = 100;

메서드에 사용하면 오버라이딩할 수 없다.

final void attack()
{
}

클래스에 사용하면 상속할 수 없다.

final class Player
{
}

불변 객체를 설계할 때도 자주 사용한다.


static

static은 특정 객체에 속하지 않고 클래스에 속하는 멤버를 만들 때 사용한다.

class MathUtil
{
    static int add(int a, int b)
    {
        return a + b;
    }
}

객체를 생성하지 않고 사용할 수 있다.

int result = MathUtil.add(10, 20);

main()static이다.

JVM이 Main 객체를 먼저 생성하지 않고 프로그램의 시작 메서드를 호출할 수 있도록 하기 위해서다.


배열

Java의 배열은 같은 타입의 데이터를 고정된 크기로 저장한다.

int[] scores = new int[10];

초기값을 직접 지정할 수도 있다.

int[] scores = {100, 200, 300};

배열의 크기는 생성된 이후 변경할 수 없다.

데이터 개수가 동적으로 변한다면 ArrayList 같은 컬렉션을 사용한다.


컬렉션

Java에는 다양한 자료구조가 표준 라이브러리로 제공된다.

대표적인 컬렉션은 다음과 같다.

ArrayList / LinkedList / HashMap / HashSet / TreeMap / TreeSet / Queue / Deque

예를 들어:

List<Integer> scores = new ArrayList<>();

scores.add(100);
scores.add(200);

키와 값을 저장하려면 Map을 사용할 수 있다.

Map<Integer, Player> players = new HashMap<>();

players.put(1, player);

인터페이스 타입인 List, Map으로 변수를 선언하고 실제 구현체를 ArrayList, HashMap으로 지정하는 방식이 일반적이다.

List<Player> players = new ArrayList<>();

이렇게 하면 사용하는 쪽에서 구체적인 구현에 대한 의존성을 줄일 수 있다.


Generic

Java의 제네릭은 타입을 매개변수처럼 사용할 수 있도록 한다.

class Container<T>
{
    private T value;

    public Container(T value)
    {
        this.value = value;
    }

    public T getValue()
    {
        return value;
    }
}

사용할 때 타입을 지정한다.

Container<Integer> a = new Container<>(100);
Container<String> b = new Container<>("Hello");

컬렉션에서도 제네릭을 사용한다.

List<Integer> scores;
List<Player> players;
Map<Integer, Player> players;

제네릭을 사용하면 여러 타입에 대응하는 코드를 만들면서도 컴파일 단계에서 타입 안정성을 확보할 수 있다.


문자열

Java의 String은 매우 자주 사용하는 참조 타입이다.

String name = "Player";

중요한 특징 중 하나는 String이 immutable, 즉 불변 객체라는 것이다.

String text = "Hello";

text = text + " World";

기존 문자열 객체의 내용을 변경하는 것이 아니라 새로운 문자열 객체가 만들어질 수 있다.

문자열을 반복적으로 수정해야 한다면 StringBuilder를 사용할 수 있다.

StringBuilder builder = new StringBuilder();

builder.append("Hello");
builder.append(" World");

String result = builder.toString();

반복적인 문자열 결합이 많은 코드에서는 이런 방식이 더 적합하다.


예외 처리

Java는 try, catch, finally를 이용해 예외를 처리한다.

try
{
    int value = Integer.parseInt("abc");
}
catch (NumberFormatException e)
{
    System.out.println("Invalid number");
}

finally는 예외 발생 여부와 관계없이 실행해야 하는 코드를 배치할 때 사용할 수 있다.

try
{
    // 작업
}
catch (Exception e)
{
    // 오류 처리
}
finally
{
    // 정리
}

Java의 예외에는 checked exception과 unchecked exception이라는 구분이 있다.

Checked exception은 컴파일러가 처리를 강제한다.

void readFile() throws IOException
{
}

반면 RuntimeException 계열은 컴파일러가 예외 처리를 강제하지 않는다.

Exception -> Checked Exception / RuntimeException -> Unchecked Exception

이 구분은 Java의 예외 설계에서 다른 언어와 비교했을 때 특히 눈에 띄는 특징이다.


try-with-resources

파일이나 네트워크 연결처럼 사용 후 정리가 필요한 리소스는 try-with-resources를 사용할 수 있다.

try (FileInputStream input =
         new FileInputStream("data.txt"))
{
    // 파일 사용
}

블록을 벗어나면 해당 리소스가 자동으로 닫힌다.

이는 C#의 using이나 C++의 RAII와 비슷한 목적을 가진다.

Java의 메모리는 GC가 관리하지만 파일이나 소켓 같은 외부 리소스까지 GC가 즉시 관리해주는 것은 아니다.

그래서 리소스의 수명 관리가 별도로 필요하다.


람다

Java 8부터 람다 표현식을 사용할 수 있다.

(a, b) -> a + b

함수형 인터페이스와 함께 사용한다.

List<Integer> scores = List.of(10, 20, 30);

scores.forEach(
    score -> System.out.println(score)
);

컬렉션 처리나 콜백 등을 간결하게 표현할 수 있다.


Stream API

Java의 Stream API는 컬렉션 데이터를 선언적인 방식으로 처리할 수 있도록 한다.

List<Integer> result =
    scores.stream()
          .filter(score -> score >= 100)
          .sorted()
          .toList();

여기서는 다음 작업을 연결했다.

Collection -> filter -> sort -> List

대표적인 연산은 다음과 같다.

filter / map / sorted / distinct / limit / anyMatch / allMatch / findFirst / collect / toList

Stream은 컬렉션 자체를 변경하는 것이 아니라 데이터를 처리하는 파이프라인을 구성하는 데 적합하다.

다만 모든 코드에 Stream을 사용하는 것이 좋은 것은 아니다. 복잡한 로직에서는 일반적인 반복문이 더 읽기 쉬울 수 있고, 성능이 중요한 부분에서는 실제 비용을 확인해야 한다.


Optional

Java에서는 null로 인한 문제를 줄이기 위한 방법으로 Optional을 제공한다.

Optional<Player> player = findPlayer(10);

값이 존재하는지 확인할 수 있다.

player.ifPresent(
    p -> p.attack()
);

기본값도 지정할 수 있다.

Player result =
    player.orElse(defaultPlayer);

Optional이 모든 null을 없애주는 것은 아니다.

특히 필드나 메서드 인자에 무분별하게 사용하는 것보다는 반환값처럼 “값이 없을 수도 있다”는 의미를 명확하게 전달할 때 사용하는 편이 일반적이다.


스레드

Java는 멀티스레드 프로그래밍을 위한 기능을 제공한다.

기본적인 스레드는 다음처럼 만들 수 있다.

Thread thread = new Thread(() ->
{
    System.out.println("Worker");
});

thread.start();

하지만 실무에서는 직접 Thread 객체를 계속 생성하기보다는 ExecutorServiceCompletableFuture 같은 추상화를 사용하는 경우가 많다.

ExecutorService executor =
    Executors.newFixedThreadPool(4);

executor.submit(() ->
{
    // 작업
});

스레드 풀을 사용하면 작업마다 스레드를 새로 생성하는 비용을 줄이고 스레드 수를 관리할 수 있다.


동기화

여러 스레드가 동일한 데이터를 수정하면 race condition이 발생할 수 있다.

synchronized void addScore(int value)
{
    score += value;
}

synchronized를 사용하면 해당 메서드에 대한 동시 접근을 제어할 수 있다.

그 외에도 Java에서는 다음과 같은 동시성 도구를 제공한다.

synchronized / volatile / AtomicInteger / ConcurrentHashMap / Lock / Semaphore / CountDownLatch / CompletableFuture

멀티스레드 코드에서는 단순히 스레드를 많이 만드는 것보다 공유 상태를 얼마나 줄이고, 필요한 동기화를 어디에 둘 것인지가 중요하다.


JVM 메모리 구조

Java를 이해하려면 JVM의 메모리 구조도 어느 정도 알고 있어야 한다.

대표적으로 다음 영역을 생각할 수 있다.

JVM -> Heap / Java Stack / Method Area / PC Register / Native Method Stack

Heap에는 일반적으로 객체와 배열이 저장된다.

Player player = new Player();

여기서 new Player()로 생성된 객체는 일반적으로 Heap에 존재한다.

Java Stack에는 각 스레드의 메서드 호출 프레임과 지역 변수 등이 관리된다.

Thread -> main() -> local variables
Thread -> update() -> local variables

다만 JVM은 JIT 최적화를 수행하기 때문에 소스 코드 수준의 개념을 실제 물리적 메모리 배치와 1:1로 대응시키면 안 된다.


Garbage Collection

Java의 메모리 관리는 Garbage Collector가 담당한다.

Player player = new Player();

player = null;

이후 해당 객체를 가리키는 다른 참조가 없다면 GC가 회수할 수 있는 상태가 된다.

전체적인 흐름은 다음과 같다.

Object Allocation -> Object Usage -> No Reachable Reference -> GC Candidate -> Garbage Collection -> Memory Reclaimed

개발자가 delete를 호출할 필요는 없다.

하지만 GC가 있다는 이유로 메모리 관리가 필요 없는 것은 아니다.

객체를 계속 생성하면 Heap 사용량이 증가하고 GC가 더 자주 발생할 수 있다.

특히 서버에서 대량의 객체가 생성되는 상황이나 지연 시간이 중요한 시스템에서는 GC의 동작과 allocation pattern을 신경 써야 한다.


JIT 컴파일

Java는 단순히 바이트코드를 한 줄씩 해석해서 실행하는 구조만 사용하는 것이 아니다.

JVM은 프로그램 실행 중 자주 실행되는 코드를 분석하고 네이티브 코드로 컴파일할 수 있다.

Bytecode -> JVM 실행 -> Hot Code Detection -> JIT Compilation -> Native Code

프로그램이 실행되면서 JVM이 런타임 정보를 활용해 최적화를 수행할 수 있다는 것이 JIT의 특징이다.

그래서 Java 성능을 이야기할 때 단순히 “Java는 VM에서 실행되기 때문에 느리다”라고 보는 것은 현재의 JVM을 설명하기에는 부족하다.


Reflection

Java에서는 런타임에 클래스와 메서드 등의 정보를 조회할 수 있다.

Class<?> type = Player.class;

Method[] methods = type.getDeclaredMethods();

이런 기능을 Reflection이라고 한다.

프레임워크에서는 상당히 유용하다.

예를 들어 어떤 클래스에 어떤 필드와 메서드가 있는지 런타임에 분석해서 자동으로 객체를 생성하거나 데이터를 매핑할 수 있다.

Spring 같은 Java 프레임워크가 다양한 기능을 제공할 수 있는 기반 중 하나이기도 하다.

다만 Reflection은 일반적인 직접 호출보다 복잡하고 런타임 오류 가능성도 있기 때문에 성능이나 안정성이 중요한 코드에서 무분별하게 사용하는 것은 적절하지 않다.


Annotation

Java에서는 @를 사용해 메타데이터를 표현할 수 있다.

@Override
public void attack()
{
}

@Override는 해당 메서드가 부모 클래스나 인터페이스의 메서드를 재정의한다는 것을 컴파일러에게 알려준다.

사용자 정의 Annotation도 만들 수 있다.

@interface MyAnnotation
{
}

프레임워크에서는 Annotation을 기반으로 특정 동작을 자동으로 처리하는 경우가 많다.

Spring의 @Controller, @Service, @Autowired 같은 기능도 이런 방식과 연결되어 있다.


Java와 JVM

Java를 공부하면서 Java 언어와 JVM을 분리해서 생각할 필요가 있다.

Java -> Language / Standard Library / Compiler / JVM
JVM -> Class Loader / Runtime Memory / JIT Compiler / Garbage Collector

Java 소스 코드가 실행되는 전체 환경에는 언어 문법 외에도 컴파일러와 JVM, 표준 라이브러리가 함께 관여한다.

그래서 Java 성능이나 메모리 문제를 제대로 분석하려면 Java 문법만 알아서는 부족하다.

Heap 사용량, GC, Thread, JIT, Class Loading 같은 JVM 내부 동작도 어느 정도 이해해야 한다.


Java에서 객체의 생명주기

Java 프로그램을 이해할 때 객체의 생명주기를 보면 여러 개념이 연결된다.

new -> Object Creation -> Reference -> Object Usage -> Reference Lost -> GC -> Memory Reclaimed

예를 들어:

Player player = new Player();

new를 통해 객체가 만들어지고 player가 그 객체를 참조한다.

player = null;

다른 곳에서 해당 객체를 참조하지 않는다면 GC가 회수할 수 있는 상태가 된다.

Java 개발에서 메모리 해제를 직접 하지 않는 대신 객체가 언제까지 참조되는가가 중요한 문제가 된다.


Java에서 자주 사용하는 구조

Java 백엔드 코드를 보면 대략 다음과 같은 계층을 자주 볼 수 있다.

Controller -> Service -> Repository -> Database

각 계층의 역할을 분리하고 객체 간 의존성을 관리한다.

Java 자체가 이런 구조를 강제하는 것은 아니다.

Spring 같은 프레임워크와 함께 사용하면서 이러한 구조가 많이 정착된 것이다.

따라서 Java 언어와 Java 생태계의 프레임워크 구조는 구분해서 보는 것이 좋다.


Java의 장점과 한계

Java의 장점은 안정적인 런타임과 풍부한 생태계, 플랫폼 독립성, 강력한 표준 라이브러리, 다양한 개발 도구에 있다.

특히 대규모 서버 애플리케이션을 개발할 때 오랜 기간 축적된 라이브러리와 프레임워크를 활용할 수 있다는 것이 큰 장점이다.

반면 JVM이라는 실행 환경과 GC가 존재하기 때문에 C++처럼 메모리와 실행 환경을 극단적으로 직접 제어하는 방식과는 차이가 있다.

GC pause나 allocation을 고려해야 하는 시스템에서는 JVM 동작을 이해할 필요가 있고, 매우 낮은 수준의 시스템 제어가 필요한 영역에서는 C++ 같은 언어가 더 적합할 수 있다.


정리

Java를 큰 흐름으로 정리하면 다음과 같다.

Java Syntax -> Type System -> Class / Object -> Inheritance / Interface -> Generic / Collection -> Lambda / Stream -> Exception / Resource Management -> Thread / Concurrency -> JVM -> Heap / Stack -> Garbage Collection -> JIT Compilation

Java를 처음 공부하면 객체지향 문법과 컬렉션, 예외 처리 정도에서 끝나는 것처럼 보인다.

하지만 실제로 Java라는 언어의 특성을 이해하려면 JVM까지 내려가야 한다.

C++이 개발자에게 메모리와 객체의 수명을 직접 관리할 수 있는 권한을 많이 주는 언어라면, Java는 JVM을 통해 메모리 관리와 실행 환경의 상당 부분을 추상화한다.

그 대신 개발자는 객체의 참조 관계와 GC, 스레드, JIT 같은 런타임의 동작을 이해해야 한다.

결국 Java에서 중요한 것은 문법을 많이 외우는 것이 아니라 객체가 어떻게 만들어지고 참조되며 실행되고, JVM이 그것을 어떻게 관리하는지를 연결해서 이해하는 것이다.