[내일배움캠프] Java 문법 종합반
Hello Java! - 자바 특징: 플랫폼 독립성
예전의 컴퓨터 세계는 복잡하고 다루기 어려운 언어들로 가득했다. 당시, C, C++ 같은 언어들은 운영체제마다 코드가 다르게 작동하는 문제가 있었다.

이와 다르게 Java는 플랫폼 독립성이라는 특징을 가지고 있어, 프로그램이 JVM이 설치된 환경이면 어느 환경이던 실행될 수 있다.
Javac(컴파일러)는 자바파일(.java)를 바이트코드(.class)로 변경하고, 해당 바이트코드 파일을 이용해 JVM이 프로그램을 실행시킨다.
*자바 파일을 실행시키면 out 폴더에 .class가 생성되는 것을 볼 수 있다.

여기서 Javac와 JVM을 모두 포함하는, 자바 프로그램을 개발할 때 필요한 개발 도구 모음을 JDK라고 한다.
컴퓨터의 기억 방식
컴퓨터는 정보를 기억하기 위해 주기억장치와 보조기억장치를 이용한다.
- 주기억장치 - 메모리(RAM)
- 컴퓨터의 뇌 역할로 아주 빠르지만, 휘발되는 특징이 있다.
- 컴퓨터의 메모리는 1바이트 단위로 주소가 매겨져 관리된다.
* 1바이트 = 8비트 (1비트는 0 또는 1 저장)

JVM 덕분에 실행 환경에 관계없이 자바 프로그램을 동작시킬 수 있다고 볼 수 있다.
- 보조기억장치 - 하드디스크(HDD), SDD
- 영구적으로 유지되지만, 주기억장치보다 느리다.
JVM 메모리 영역
Java 메모리 구조는 크게 3가지로 나뉜다.

- Method(Static) Area
- 프로그램이 실행될 때 클래스 정보(.class 파일)가 올라가는 곳
- 자바 클래스의 메소드 정보, 상수 풀, static 변수 등이 저장
- JVM 이 종료 시(프로그램이 종료 시) 메모리에서 해제된다.
- Stack Area
- 각 스레드마다 별도로 생성되며, 기본 자료형, 지역 변수, 매개변수가 저장
- 메소드가 호출될 때 메모리에 할당, 메소드 종료 시 메모리에서 해제된다.
- Heap Area
- new 키워드로 인스턴스가 생성될 때 사용되는 메모리 영역
- 참조형 데이터 객체의 실제 데이가 저장되는 공간
- Stack 영역에 생성된 객체에 대한 주소 값(Rerefence)가 저장된다.
* Heap 과 Method Area 는 모든 스레드가 공유하는 반면, Stack은 각 스레드마다 개별적으로 존재한다.
래퍼 클래스(기본형 참조형)
래퍼 클래스는 기본 자료형을 객체로 감싸는 클래스이다. 객체, 배열 등 변수에 객체가 담기면 참조형 변수라고 한다.
래퍼 클래스도 객체이므로, 참조형 변수이다. 하지만 객체, 배열과 다른 특징들을 가지고 있다.
- 출력시 메모리 주소값이 나오지 않는다. 내부적으로 toString()이 오버라이딩되어 있기 때문이다.
- String, Boolean, Integer 같은 래퍼클래스 타입은 불변 객체이기 때문에 참조값이 복사되지만, 값 복사처럼 동작한다. 즉, 원본이 변경되지 않으며, 새로운 객체를 생성하여 사용한다! String 클래스는 Wrapper 클래스가 아니지만, 비슷한 특성을 가진다.
그럼 왜 래퍼클래스를 사용할까? 다음과 같은 기능을 제공할 수 있기 떄문이다.
Integer num = 123; //래퍼클래스
String str = num.toString(); //편리한 기능
int a = 100; //기본형 데이터
String str = a.toString(); //변환 불가
하지만, 래퍼형은 내부적으로 데이터를 감싸고 있기 때문에 연산시 불리하다. 따라서 빠른 작업이 필요한 경우 기본형을 직접 활용하는 것이 좋은 선택이다.
static 과 final
- static 키워드는 모든 객체가 함께 사용하는 변수나 메소드를 만들 때 사용된다.
- 객체(인스턴스)를 만들지 않아도 클래스 이름만으로 바로 사용할 수 있다. *static 변수와 메소드는 Method Area에 한 번만 저장되므로 모든 데이터가 접근 가능하다.
- 하나의 클래스에 static 메소드와 기본 메소드를 동시에 선언할 수 있다.
- static 변수와 메소드는 프로그램이 종료될 때까지 메모리에 유지되므로, 너무 많은 static을 남용하면 메모리 낭비로 이어진다.

- final의 용도는 다음과 같다.
- 변수에 final을 붙이면 변경이 불가능하게 만든다.
- 클래스에 final을 붙이면 상속할 수 없게 만든다.
- 메소드에 final을 붙이면 수정할 수 없게(오버라이딩 불가) 만든다.
- 상수는 변하지 않고 항상 일정한 값을 갖는 수로, static final 키워드를 사용해 선언한다. 예) 원주율
- 상수는 대문자로 표현하는 것이 관례이다.
- static 으로 선언함으로써 메모리에 중복 저장되는 것을 막는다.
- 불변 객체란 생성 후 내부 상태가 변하지 않는 객체이다.
- String과 Wrapper 클래스 등이 있다.
- final 객체를 활용하여 만들 수 있다.
- 단순히 final 객체를 선언하면, 값을 재할당하는 것은 막아주지만, 내부 상태의 변경을 막아주지 않는다.
- 따라서 클래스와 필드를 모두 final 로 선언하고, 필드 값을 초기화하는 생성자만 존재해야 한다. 추가적으로 상태를 변경하는 메서드가 없어야 한다.
인터페이스 - 표준화의 시작
인터페이스를 활용해서 최소한의 규격을 정의하고, 세부 구현은 각 클래스에 맡긴다. 이는 일관성을 유지하면서 클래스가 고유한 특색을 확장할 수 있도록 돕는다.
class LuxuryCar implements Car { //implements 키워드로 활용
@Override
void drive() { // ✅ 인터페이스 규칙 준수
System.out.println("멋지게 이동합니다."); // 구현 내용은 자유롭습니다.
}
@Override
void stop() { // ✅ 인터페이스 규칙 준수
System.out.println("멋지게 정지합니다."); // 구현 내용은 자유롭습니다.
}
void charge() { // 🎉 CarA 만의 기능을 확장 가능합니다.
System.out.println("차량을 충전합니다");
}
}
인터페이스는 다음과 같은 특징을 가진다.
- 다중 구현: implements 키워드로 다수의 인터페이스를 구현할 수 있다.
- 다중 상속: 인터페이스는 extends 키워드로 다른 인터페이스를 다중 상속할 수 있다.
* 인터페이스에 변수 선언 시 형식에 관계 없이 자동으로 public static final(상수)로 선언된다.
⚠️ 추상클래스란?
추상클래스는 상속 관계를 이용한 클래스로, 공통 기능을 제공하면서 하위 클래스에 특정 메서드 구현을 강제하기 위해 사용된다.
- abstract 키워드로 메서드를 선언하면 자식 클래스에서 강제로 구현해야 한다. (인터페이스의 메소드를 추상 메서드라고도 부른다.)
- abstract 키워드로 클래스를 선언하면 추상 클래스이며, 추상 클래스로 객체를 생성할 수 없다.
⚠️ 추상클래스와 인터페이스 차이점
상속이 계층적 구조를 선언하기 적합하고, 인터페이스는 표준을 제공하는 데 적합하다. 또한, 인터페이스는 인스턴스 변수를 선언할 수 없다. 따라서, 계층적 구조를 표현하면서 공통 속성과 기능을 재사용할 때 추상 클래스를 사용하는 것이 적합하다.
객체 지향
객체 지향은 다음과 같은 네 가지 특징을 가진다.
- 캡슐화
- 객체의 정보를 외부에서 직접 접근하지 못하게 보호하는 개념이다.
- 캡슐화가 잘 적용된 클래스는 내부 데이터를 private으로 보호하고, 데이터 조회나 변경이 필요한 경우 안전한 접근 방법을 사용한다
- 상속
- extends 키워드를 사용해서 상속 관계를 구현한다.
- 상속 구조를 통해 재사용성, 확장이 가능하며, 추상화, 다형성을 구현하는 데 잘 활용된다.
- 재정의(메서드 오버라이딩)을 통해 부모 클래스의 기능을 재정의할 수 있다.
- super 키워드는 부모의 변수나 메서드를 명확하게 호출할 때 사용한다.
- 부모가 먼저 생성되어야하므로 자식의 생성자의 첫 줄에 항상 super()가 위치해야한다.
- 추상화
- 추상화란 불필요한 세부 사항을 숨기고 본질적인 특징만 남기는 것을 의미한다.
- 즉, 사용자는 세부 사항을 알 필요없이 기능을 사용할 수 있다.
- 추상 클래스 상속 또는 인터페이스 구현을 통해 추상화를 실현할 수 있다.
- 다형성
- 다형성이란 하나의 인터페이스(또는 메서드)가 여러 가지 구현을 가질 수 있는 능력을 말한다.
- 메서드 오버로딩과 메서드 오버라이딩을 통해 구현할 수 있다.
- 오버로딩은 동일한 이름의 메서드를 매개변수의 개수나 타입에 따라 다르게 정의하는 것이다.
- 오버라이딩은 부모 클래스에 정의된 메서드를 자식 클래스에서 다르게 구현하는 방식이다.
Optional 이란?
null을 if 문을 활용해서 직접 처리할 수 있지만, 모든 코드에서 null이 발생할 가능성을 미리 예측하고 처리하는 것은 현실적으로 어렵다.
Optional 객체는 null을 안전하게 다루게 해주는 객체이다.
Optional 객체는 값이 있을 수도 있고 없을 수도 있는 컨테이너이다.
public Optional<Student> getStudent() {
//반환값이 null일 가능성이 있음을 명확하게 명시
return Optional.ofNullable(student);
//return student;
}
public static void main(String[] args) {
//람다표현식을 활용한 간결한 표현
Student student = camp.getStudent().orElseGet(()-> throw Exception("미등록 학생입니다.));
}
제네릭이란?
제네릭이란, 메서드 등에 사용되는 <T>타입 매개변수로, 타입을 미리 지정하지 않고 사용 시점에 우연하게 결정할 수 있는 문법이다. 제네릭을 활용하면 코드 재사용성(다양한 타입에서 재사용 가능)과 타입 안정성(잘못된 타입 사용을 컴파일 시점에 방지)을 보장받을 수 있다.
아래는 Object의 다형성을 활용하여 코드 재사용성을 구현한 코드이다.
public class ObjectBox {
private Object item; // ⚠️ 다형성: 모든 타입 저장 가능 (하지만 안전하지 않음)
public ObjectBox(Object item) {
this.item = item;
}
public Object getItem() {
return this.item;
}
}
public class Main {
public static void main(String[] args) {
// ✅ ObjectBox 사용
ObjectBox objBox = new ObjectBox("Hello");
// item을 활용하기 위해서는 다운 캐스팅이 필요!
String str = (String) objBox.getItem();
System.out.println("objBox 내용: " + str); // Hello
// ⚠️ 실행 중 오류 발생 (잘못된 다운 캐스팅: ClassCastException)
objBox = new ObjectBox(100); // 정수 저장
String error = (String) objBox.getItem(); // ❌ 오류: Integer -> String
System.out.println("잘못된 변환: " + error);
}
}
하지만 위처럼 데이터를 활용하기 위해서는 다운 캐스팅이 필요하고, 잘못 사용한 경우 컴파일 시점에서 에러를 찾지 못하므로, 타입 안정성이 떨어진다.
제네릭을 사용하면, 재사용성과 타입 안정성 문제를 모두 해결할 수 있다.
// ✅ 제네릭 클래스
public class GenericBox<T> {
private T item;
public GenericBox(T item) {
this.item = item;
}
public T getItem() {
return this.item;
}
}
public class Main {
public static void main(String[] args) {
// 1. ✅ 재사용 가능(컴파일시 타입소거: T -> Object)
GenericBox<String> strGBox = new GenericBox<>("ABC");
GenericBox<Integer> intGBox = new GenericBox<>(100);
GenericBox<Double> doubleGBox = new GenericBox<>(0.1);
// 2. ✅ 타입 안정성 보장(컴파일시 타입소거: 자동으로 다운캐스팅)
String strGBoxItem = strGBox.getItem();
Integer intGBoxItem = intGBox.getItem();
Double doubleGBoxItem = doubleGBox.getItem();
System.out.println("strGBoxItem = " + strGBoxItem);
System.out.println("intGBoxItem = " + intGBoxItem);
System.out.println("doubleGBoxItem = " + doubleGBoxItem);
}
}
컴파일 시점에 제네릭 타입 정보는 Object로 타입이 소거된다. 또한, 필요한 경우 컴파일러가 자동으로 강제 다운캐스팅 코드를 삽입하여 타이 타입 안정성을 보장한다.
또한, 제네릭 메서드는 클래스 제너릭 타입(<T>)과 별개로 독립적인 타입 매개 변수를 가진다. (<S>)
public class GenericBox<T> {
// 속성
private T item;
// 생성자
public GenericBox(T item) {
this.item = item;
}
// 기능
public T getItem() {
return this.item;
}
// ⚠️ 일반 메서드 T item 는 클래스의 <T> 를 따라갑니다.
public void printItem(T item) {
System.out.println(item);
}
// ✅ 제네릭 메서드 <S> 는 <T> 와 별개로 독립적이다.
public <S> void printBoxItem(S item) {
System.out.println(item);
}
}
public class Main {
public static void main(String[] args) {
GenericBox<String> strGBox = new GenericBox<>("ABC");
GenericBox<Integer> intGBox = new GenericBox<>(100);
// ⚠️ 일반메서드: 클래스 타입 매개변수를 따라갑니다.
// String 데이터 타입 기반으로 타입소거가 발생.
// String 타입의 다운캐스팅 코드 삽입!
strGBox.printItem("ABC"); // ✅ String 만 사용가능
strGBox.printItem(100); // ❌ 에러 발생
// ✅ 제네릭 메서드: 독립적인 타입 매개변수를 가집니다.
// String 타입 정보가 제네릭 메서드에 아무런 영향을 주지 못함.
// 다운캐스팅 코드 삽입되지 않음.
strGBox.printBoxItem("ABC"); //✅ 모든 데이터 타입 활용 가능
strGBox.printBoxItem(100); //✅ 모든 데이터 타입 활용 가능
strGBox.printBoxItem(0.1); //✅ 모든 데이터 타입 활용 가능
}
}
따라서 위와 같이 일반 메서드는 클래스 타입 매개변수에 영향을 받지만, 제네릭 메서드는 독립적으로 작동된다.
람다란?
익명 클래스란, 이름이 없는 클래스를 익명 클래스라고 한다. 별도의 클래스 파일을 만들지 않고 코드 내에서 인터페이스를 구현하거나 클래스를 상속할 때 일회성으로 정의해 사용할 수 있다. 이는 코드가 길어진다는 단점이 있다.
람다는 이러한 익명 클래스를 더 간결하게 표현하는 문법이다. 간단하게, 매개 변수 영역과 구현 내용으로 이루어져있다.
하나의 추상 메서드만 가지는 함수형 인터페이스를 통해 구현하는 것을 권장한다. 인터페이스에 두 개 이상의 추상 메서드가 존재하면 컴파일러가 어떤 메서드를 구현하는지 모호해지기 때문이다.
// 익명클래스
Calculator calculator1 = new Calculator() {
@Override
public int sum(int a, int b) {
return a + b;
}
};
// 함수형 인터페이스 선언
@FunctionalInterface
public interface Calculator {
int sum(int a, int b); // ✅ 오직 하나의 추상 메서드만 선언해야합니다.
}
// 람다 표현식
Calculator calculator1 = (a, b) -> a + b;
🚥 오버로딩 vs 오버라이딩
- 오버로딩은 같은 클래스나 인터페이스 내에서 동일한 메서드 이름을 가지지만 매개 변수의 개수나 타입, 순서는 다르게 선언하는 기능이다.
- 오버라이딩은 부모 클래스에 정의된 메서드를 자식 클래스에서 재정의하는 것을 의미한다.
스트림이란?
스트림이란 데이터를 효율적으로 처리할 수 있는 흐름으로, 선언형 스타일로 가독성이 굉장히 뛰어나다. 스트림은 데이터 준비 -> 중간 연산 -> 최종 연산 순으로 처리된다. 이는 컬렉션과 함께 자주 활용된다.
| 1. 데이터 준비 | 컬렉션을 스트림으로 변환 | stream(), parallelStream() |
| 2. 중간 연산 등록 (즉시 실행되지 않음) |
데이터 변환 및 필터링 | map(), filter(), sorted() |
| 3. 최종 연산 | 최종 처리 및 데이터 변환 | collect(), forEach(), count() |
스트림을 사용하는 예시를 보자.
public class Main {
public static void main(String[] args) {
// 예시 1. 모든 요소를 10배로 변환시키는 예제
// ArrayList 선언
List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3, 4, 5));
// 1. stream(): 데이터를 스트림으로 변환하여 연산 흐름을 만들 준비를 함.
// 2. map(): 각 요소를 주어진 함수에 적용해서 변환.
// 3. collect(): 결과를 원하는 형태(List,Set)로 수집.
List<Integer> ret2 = arrayList.stream().map(num -> num * 10).collect(Collectors.toList());
System.out.println("ret2 = " + ret2);
// 예시 2. 짝수만 10배로 변환시키는 예제
List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3, 4, 5));
List<Integer> ret6 = arrayList.stream() // 1. 데이터 준비: 스트림 생성
.filter(num -> num % 2 == 0) // 2. 중간 연산: 짝수만 필터링
.map(num -> num * 10) // 3. 중간 연산: 10배로 변환
.collect(Collectors.toList()); // 4. 최종 연산: 리스트로 변환
System.out.println(ret6); // 출력: [20, 40]
}
}
스트림을 사용하지 않았다면, for문을 이용해 각 요소마다 * 10 처리를 해줘야했을테지만, 스트림을 사용하면 위와 같이 간결하게 처리할 수 있다.
💡 ArrayList를 List로 받는 이유
- 다형성을 활용하여 List 인터페이스로 ArrayList 구현체를 받으면 나중에 다른 구현체(LinkedList, Vector)로 변경할 때 코드 수정을 최소화할 수 있다.
- 실무에서 리스트를 선언할 때 대부분 List 타입으로 받는 것을 권장한다.
🪶 스트림과 람다식의 활용
map 메서드는 아래와 같은 구조로 이루어져 있다.
-> 함수형 인터페이스(Function<T,R>)를 매개변수로 가지고 있다.<R> Stream<R> map(Function<? super T, ? extends R> mapper);
-> 함수형 인터페이스(Function<T,R>)를 구현한 구현체를 매개변수로 받을 수 있다.
즉, 스트림 각 요소에 대해 apply 메서드를 호출하여 새로운 값을 만든다.
아래와 같은 활용도 가능하다.
이와 비슷하게 filter 메서드는 Predicate 함수형 인터페이스를 매개변수로 가지고 있다. 각 요소에 대해 test를 호출하여 true인 요소만 스트림에 남긴다.Function<Integer, Integer> functionLambda = (num -> num * 10); List<Integer> ret4 = arrayList.stream() .map(functionLambda) .collect(Collectors.toList());
@FunctinalInterface public interface Predicate<T> { boolean test(T t); }
쓰레드란?
쓰레드란 프로그램 내에서 독립적으로 실행되는 하나의 작업 단위이다. 싱글 쓰레드는 한 번에 하나의 작업만 처리하지만 멀티 쓰레드는 여러 작업을 동시에 처리할 수 있다. (병렬 처리)
Thread 클래스를 상속받아 쓰레드를 구현할 수 있고,start() 메서드를 호출하면 쓰레드 생성을, Thread.run() 메서드를 오버라이드하면 쓰레드가 수행할 작업을 정의할 수 있다. 또한, join()을 호출하면 main() 스레드가 다른 스레드가 종료될 때까지 기다리게 할 수 있다.
// ✅ Thread 클래스 상속으로 쓰레드 구현
public class MyThread extends Thread {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
System.out.println("::: " + threadName + "쓰레드 시작 :::");
for (int i = 0; i < 10; i++) {
System.out.println("현재 쓰레드: " + threadName + " - " + i);
try {
Thread.sleep(500); // 딜레이 0.5 초
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("::: " + threadName + "쓰레드 종료 :::");
}
}
public class Main {
public static void main(String[] args) {
System.out.println("::: main 쓰레드 시작");
MyThread thread0 = new MyThread();
MyThread thread1 = new MyThread();
// 시작시간 기록
long startTime = System.currentTimeMillis();
// 1. thread0 시작 (run이 아니라 start로 시작 **)
System.out.println("thread0 시작");
thread0.start();
// 2. thread1 시작 (run이 아니라 start로 시작 **)
System.out.println("thread1 시작");
thread1.start();
// ⌛️ main 쓰레드 대기 시키기
// join 메서드가 없다면 thread0과 thread1 수행을 기다리지 않고 main 스레드가 바로 종료됨.
try {
//thread0과 thread1이 끝날 때까지 main 스레드 대기
thread0.join();
thread1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long endTime = System.currentTimeMillis();
long totalTime = endTime - startTime;
System.out.println("총 작업 시간: " + totalTime + "ms");
System.out.println("::: main 쓰레드 종료");
}
}
⚠️ 주의 사항
쓰레드를 실행시킬 때 꼭 start()를 사용해야 한다. start()는 새로운 쓰레드에서 run()을 실행하지만 run()을 직접 호출하면 현재 쓰레드에서 실행된다.
자바에서는 Runnable 인터페이스를 활용해 쓰레드를 구현하는 것을 권장한다. (확장 가능성)
Runnable은 자바에서 멀티스레드를 구현하기 위한 대표적인 인터페이스로, 단 하나의 추상 메서드 run()을 가지고 있는 함수형 인터페이스이다.
Thread를 상속받아 구현하면, 실행 로직과 쓰레드 제어 로직(start 메서드, join 메서드, isAlive 메서드 등)이 결합되어 한 가지 클래스에 두 가지 역할을 담당하게 된다. 이와 다르게 Runnable 을 활용하면 실행 로직을 별도의 구현체로 분리할 수 있다.
- Thread: 쓰레드를 제어하는 역할
- Runnable 구현체: 실행 로직을 관리하는 역할
또한, Thread를 상속받으면 다른 클래스를 상속하지 못하지만, Runnable은 인터페이스므로 기존 클래스의 기능을 유지하면서 상속을 통해 확장할 수 있다.
public class MyRunnable implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
for (int i = 0; i < 10; i++) {
System.out.println("현재 쓰레드: " + threadName + " - " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
MyRunnable task = new MyRunnable(); // ✅ 하나의 작업 객체 선언
// ✅ 하나의 작업을 여러 쓰레드에서 공유
Thread thread0 = new Thread(task); // 작업 객체 공유
Thread thread1 = new Thread(task); // 작업 객체 공유
// 실행
thread0.start();
thread1.start();
}
}