## 7. Java에서 멀티스레딩은 어떻게 동작하나요?
### 프로세스와 스레드의 차이
멀티스레딩을 이해하려면 먼저 프로세스와 스레드의 관계를 알아야 합니다.
- 프로세스 (Process): 운영체제에서 실행 중인 하나의 애플리케이션을 의미합니다. 프로세스는 자신만의 고유한 메모리 공간(코드, 데이터, 힙, 스택)을 할당받습니다. **'독립된 공장'**에 비유할 수 있습니다.
- 스레드 (Thread): 프로세스 내에서 실행되는 개별적인 작업 흐름의 단위입니다. **'공장 안의 일꾼'**에 비유할 수 있습니다.
핵심 특징: 하나의 프로세스에 속한 스레드들은 힙(Heap) 메모리 영역을 공유합니다. 이는 스레드 간에 데이터를 쉽게 주고받을 수 있다는 장점이 됩니다. 하지만 각 스레드는 자신만의 스택(Stack) 메모리를 가지므로, 지역 변수나 메소드 호출 정보는 서로 독립적으로 관리됩니다.
### Java에서 스레드 생성 및 실행
Java에서 새로운 스레드를 만들어 작업을 실행하는 방법은 크게 두 가지입니다.
#### 방법 1: Thread 클래스 상속
class MyThread extends Thread {
@Override
public void run() {
// 이 스레드가 수행할 작업을 여기에 작성합니다.
System.out.println("Thread 클래스를 상속받아 실행");
}
}
// 실행 방법
MyThread t = new MyThread();
t.start(); // start() 메소드를 호출해야 새로운 스레드가 생성되어 run()이 실행됩니다.
#### 방법 2: Runnable 인터페이스 구현 (권장 방식)
class MyRunnable implements Runnable {
@Override
public void run() {
// 이 스레드가 수행할 작업을 여기에 작성합니다.
System.out.println("Runnable 인터페이스를 구현하여 실행");
}
}
// 실행 방법
Thread t = new Thread(new MyRunnable());
t.start();
Runnable 구현이 권장되는 이유: Java는 클래스의 다중 상속을 지원하지 않습니다. 만약 다른 클래스를 상속받아야 하는 클래스를 스레드로 만들고 싶다면, Thread 클래스를 상속할 수 없으므로 Runnable 인터페이스를 구현하는 방식이 훨씬 유연하고 객체 지향적인 설계입니다.
### 동기화 문제와 해결 (Synchronization)
멀티스레딩의 가장 중요하고 어려운 주제는 바로 동기화입니다.
#### 문제점: 경쟁 상태 (Race Condition)
여러 스레드가 **공유 자원(예: 힙 메모리의 객체)**에 동시에 접근하여 값을 변경하려고 할 때, 의도치 않은 결과가 발생하는 것을 경쟁 상태라고 합니다.
예시: 공유 카운터 문제
public class Counter {
private int count = 0;
public void increment() {
count++; // 이 작업은 원자적(atomic)이 아닙니다.
}
}
count++ 연산은 실제로는 (1) count 값을 읽고, (2) 값을 1 증가시키고, (3) 그 결과를 다시 count에 쓰는 세 단계로 이루어집니다.
- 스레드 A가 count 값 10을 읽습니다.
- 이때, OS가 스레드 A를 잠시 멈추고 스레드 B에게 CPU를 할당합니다 (Context Switch).
- 스레드 B가 count 값 10을 읽습니다.
- 스레드 B는 값을 1 증가시킨 11을 count에 씁니다.
- 다시 스레드 A가 실행됩니다. 스레드 A는 자신이 이전에 읽었던 값 10을 기준으로 1을 증가시켜 11을 count에 씁니다.
결과: count는 두 번 증가했지만, 최종 결과는 12가 아닌 11이 되어 데이터의 무결성이 깨집니다.
#### 해결책: synchronized 키워드
Java는 이러한 문제를 해결하기 위해 synchronized 키워드를 제공합니다. synchronized는 특정 메소드나 코드 블록을 **임계 구역(Critical Section)**으로 지정하여, 오직 하나의 스레드만 해당 구역에 접근할 수 있도록 보장합니다(상호 배제, Mutual Exclusion).
public class Counter {
private int count = 0;
// synchronized 키워드를 붙여 이 메소드를 임계 구역으로 만듭니다.
public synchronized void increment() {
count++;
}
}
이제 한 스레드가 increment() 메소드를 실행하는 동안에는, 다른 스레드는 이 메소드에 접근하지 못하고 대기하게 됩니다. 이를 통해 count++ 연산의 원자성을 보장하여 경쟁 상태를 해결할 수 있습니다.
## 8. 가비지 컬렉션(Garbage Collection)이란 무엇인가요?
**가비지 컬렉션(GC)**은 Java의 자동 메모리 관리 기능으로, JVM의 백그라운드 프로세스입니다. 개발자가 직접 해제하지 않은, 더 이상 어떤 참조도 받지 않는 객체(쓰레기, Garbage)를 힙(Heap) 메모리에서 찾아내어 자동으로 제거해 줍니다.
GC의 동작 원리 (간략히) JVM의 힙 영역은 주로 Young Generation과 Old Generation으로 나뉩니다.
- Young Generation: 대부분의 새로운 객체가 생성되는 영역입니다. 여기서 발생하는 Minor GC를 통해 자주 청소되며, 살아남은 객체는 점차 Old Generation으로 이동합니다.
- Old Generation: 오랫동안 살아남은 객체들이 머무는 영역입니다. 이 영역에서 발생하는 Major GC는 더 드물게 일어나지만, 실행 시간이 더 깁니다.
이를 통해 C/C++과 같은 언어에서 발생할 수 있는 메모리 누수(Memory Leak)나 이중 해제(Double Free)와 같은 문제를 방지하여 개발자가 비즈니스 로직에 더 집중할 수 있게 해줍니다.
## 9. Java에서 리플렉션(Reflection)은 무엇이고 언제 사용하나요?
리플렉션은 프로그램이 **실행 시간(Runtime)**에 자기 자신(클래스, 메소드, 필드 등)을 검사하고 동적으로 조작할 수 있게 해주는 강력한 기능입니다.
리플렉션으로 할 수 있는 일
- 런타임에 특정 클래스의 정보를 얻어올 수 있습니다.
- 클래스의 생성자를 호출하여 인스턴스를 동적으로 생성할 수 있습니다.
- 메소드의 이름을 문자열로 받아 동적으로 호출할 수 있습니다.
- private 멤버 변수의 값에 접근하거나 수정할 수 있습니다.
사용되는 곳 리플렉션은 성능 저하를 유발할 수 있어 일반적인 애플리케이션 코드에서는 잘 사용되지 않지만, 프레임워크나 라이브러리를 만들 때 핵심적인 역할을 합니다.
- 스프링 프레임워크: @Autowired 어노테이션을 보고, 런타임에 적절한 타입의 빈(Bean)을 찾아 동적으로 의존성을 주입(DI)할 때 리플렉션을 사용합니다.
- JUnit: @Test 어노테이션이 붙은 메소드를 런타임에 찾아내어 자동으로 실행할 때 사용합니다.
### 1. 일반적인 코드 실행 방식 (컴파일 타임)
우리가 평소에 코드를 작성하는 방식입니다. 개발자는 Member라는 클래스가 있다는 것을 미리 알고 있고, 컴파일러도 이를 확인합니다.
// "Member라는 설계도가 있으니, Member 객체를 만들어줘."
Member member = new Member();
// "만들어진 member 객체에 setName 버튼을 눌러줘."
member.setName("jemini");
### 2. 리플렉션을 사용하는 방식 (런타임)
리플렉션은 클래스의 설계도를 직접 보는 것이 아니라, 설계도에 대한 정보가 담긴 '설명서'를 통해 간접적으로 객체를 제어하는 방식입니다. 우리는 Member라는 클래스가 있다는 사실을 이름(문자열)으로만 알고 있습니다.
#### 1단계: 클래스의 '설명서'를 얻는다 (Class 객체)
먼저 클래스의 이름("com.example.Member")을 가지고, 해당 클래스에 대한 모든 정보(생성자, 메소드, 필드 등)가 담긴 설명서, 즉 Class 객체를 얻습니다.
Class<?> clazz = Class.forName("com.example.Member");
#### 2단계: '설명서'를 보고 객체를 만든다
이제 클래스를 직접 사용하는 대신, 얻어온 설명서(clazz)에게 "기본 생성자를 찾아서 객체 하나 만들어줘"라고 요청합니다.
Object instance = clazz.getConstructor().newInstance();
// 결과는 Member instance = new Member(); 와 같습니다.
#### 3단계: '설명서'에서 메소드를 찾고 호출한다
마찬가지로 설명서에게 "파라미터가 String 하나인 setName이라는 이름의 메소드를 찾아줘"라고 요청한 뒤, "찾아낸 그 메소드를 instance 객체에 대해, 'jemini'라는 값을 넣어서 실행해줘"라고 명령합니다.
// 'setName'이라는 이름과 파라미터 타입을 알려주고 메소드 정보를 찾는다.
Method method = clazz.getMethod("setName", String.class);
// 찾은 메소드를 특정 인스턴스에 대해 실행(invoke)한다.
method.invoke(instance, "jemini");
// 결과는 instance.setName("jemini"); 와 같습니다.
- 비유: 마치 로봇의 **설명서(Class 객체)**를 읽고, **'setName'이라는 이름의 버튼을 찾아서, 'jemini'라는 값을 넣어 눌러달라'**고 원격으로 조종하는 것과 같습니다.
### 3. 그래서, 이걸 언제 사용할까요?
스프링 같은 프레임워크가 개발자가 만든 코드를 알지 못한 채로 동작해야 할 때 사용합니다.
스프링 프레임워크는 세상의 모든 개발자가 어떤 클래스(MemberService, OrderController 등)를 만들지 미리 알 수 없습니다.
스프링은 애플리케이션이 시작될 때, 개발자가 작성한 코드를 쭉 훑어봅니다. 그러다 @Service라는 어노테이션이 붙은 MemberService 클래스를 발견하면, 리플렉션을 사용하여 다음과 같이 동작합니다.
- Class.forName("com.example.MemberService")로 클래스 설명서를 얻습니다.
- 설명서를 보고 new MemberService()처럼 객체를 만들어 스프링 컨테이너에 등록합니다.
- 다시 설명서를 뒤져서 @Autowired가 붙은 필드나 생성자를 찾아내고, 거기에 맞는 다른 객체를 동적으로 주입해 줍니다.
이처럼 리플렉션은 코드가 실행되는 도중에 클래스의 구조를 분석하고 조작해야 하는 매우 유연하고 동적인 상황에서 사용되는 강력한 기술입니다.
## 10. Java 패키지(Package)란 무엇이며 어떻게 사용됩니까?
패키지는 서로 관련된 클래스와 인터페이스들을 하나의 그룹으로 묶어 관리하는 네임스페이스(Namespace) 메커니즘입니다. 파일 시스템의 폴더와 유사한 역할을 합니다.
패키지의 주요 목적
- 이름 충돌 방지: 다른 패키지에 있다면 같은 이름의 클래스를 여러 개 만들 수 있습니다. (예: com.company.a.User vs com.company.b.User)
- 코드 구조화: 관련된 클래스들을 한곳에 모아 코드의 가독성과 유지보수성을 높입니다. (예: com.myapp.controller, com.myapp.service)
- 접근 제어: protected나 default(package-private) 접근 제어자와 함께 사용하여 클래스나 멤버의 접근 범위를 제어할 수 있습니다.
사용 방법
- package 키워드: Java 소스 파일의 가장 첫 줄에 package com.example.myapp;과 같이 선언하여 해당 클래스가 어떤 패키지에 속하는지 명시합니다.
- import 키워드: 다른 패키지에 있는 클래스를 사용할 때, 매번 전체 경로를 쓰는 대신 import java.util.List;와 같이 선언하여 간편하게 사용할 수 있습니다.
'개발 공부 > 자바' 카테고리의 다른 글
| 모든 클래스의 조상: Object 클래스 핵심 정리 (0) | 2025.12.03 |
|---|---|
| Java의 다형성 완전 정복 (0) | 2025.09.25 |
| 자바 면접 질문1 (0) | 2025.09.24 |
| 스프링 부트 개발자를 위한 Java 핵심 정리 (0) | 2025.09.24 |
| 자바빈 규약과 프로퍼티 접근 방식 (0) | 2025.09.14 |