함수형 인터페이스를 알아보기 앞서서 간단한 코드를 만들어보자 요구사항은 이름을 출력하고, 걸린 시간을 출력하는 코드이다.
public class Main {
public static void main(String[] args) {
long start1 = System.nanoTime();
System.out.println("안녕하세요 미선입니다.");
long end1 = System.nanoTime();
System.out.println("time: " + (end1 - start1) + "ns");
long start2 = System.nanoTime();
System.out.println("안녕하세요 짱구입니다.");
long end2 = System.nanoTime();
System.out.println("time: " + (end2 - start2) + "ns");
}
}
// 안녕하세요 홍길동입니다.
// time: 196791ns
// 안녕하세요 둘리입니다.
// time: 17167ns
요구사항에 맞춰서 코드를 작성해고 잘 출력이 되는 것을 확인할 수 있다. 하지만 작성된 코드를 살펴보면 중복되는 코드들이 눈에 보일 것이다. 그럼 이제 코드의 중복을 제거해보자
public class Main {
public static void main(String[] args) {
hello("미선");
hello("짱구");
}
private static void hello(String name) {
long start = System.nanoTime();
System.out.println("안녕하세요 " + name + "입니다.");
long end = System.nanoTime();
System.out.println("time: " + (end - start) + "ns");
}
}
// 안녕하세요 미선입니다.
// time: 5383917ns
// 안녕하세요 짱구입니다.
// time: 23750ns
변하지 않는 부분인 시간 측정과 출력, 변하는 부분인 이름을 분리하여 hello(String name) 메서드를 생성하였다. 이로서 코드의 중복을 줄일 수 있게 되었다. 하지만 아래와 같이 요구사항이 추가되었다.
public class Main {
public static void main(String[] args) {
hello("미선", "짱구의 엄마입니다.");
hello("짱구", "저희 어머니는 미선입니다.");
}
private static void hello(String name) {
long start = System.nanoTime();
System.out.println("안녕하세요 " + name + "입니다.");
long end = System.nanoTime();
System.out.println("time: " + (end - start) + "ns");
}
private static void hello(String name, String description) {
long start = System.nanoTime();
System.out.println("안녕하세요 " + name + "입니다.");
System.out.println(description);
long end = System.nanoTime();
System.out.println("time: " + (end - start) + "ns");
}
}
// 안녕하세요 미선입니다.
// 짱구의 엄마입니다.
// time: 3459542ns
// 안녕하세요 짱구입니다.
// 저희 어머니는 미선입니다.
// time: 50500ns
위 요구사항을 충족하기 위해 메서드 오버로딩을 사용하여 해결하였다. 하지만 요구사항이 추가될 경우 기존 코드를 수정해야한다. 지금까지의 hello() 메서드를 잘 살펴보면, 시간 측정과 시간 출력 부분은 변하지 않고, 그 사이의 로직만 변해왔다. 즉 변하는 부분인 코드 조각만 따로 분리해 파라미터로 넘기고 싶은 상황이다. 하지만 자바에서는 메서드를 직접 파라미터로 넘길 수 없기 때문에 일반적인 방식으로는 이를 추출이 불가능하다.
앞서 말했듯 자바는 메서드를 직접 넘길 수 없다. 하지만 함수형 인터페이스를 사용하면 메서드처럼 보이는 코드 조각을 파라미터로 넘길 수 있다. 이것이 가능한 이유는 함수형 인터페이스가 단 하나의 추상 메서드만을 가지는 인터페이스이기 때문이다. 이 특징 덕분에 마치 메서드를 값처럼 다룰 수 있다.
@FunctionalInterface
interface FuncInterface {
void introduce();
}
이처럼 함수형 인터페이스는 단 하나의 추상 메서드만을 가진 인터페이스이다.
여기서 @FunctionalInterface는 해당 인터페이스가 함수형 인터페이스임을 명시적으로 나타내는 어노테이션이다.
@Override와 마찬가지로 생략해도 동작에는 문제가 없지만 잘못된 사용을 컴파일 타임에 잡아주기 때문에 작성하는 것이 권장된다. 그럼 이제 함수형 인터페이스를 가지고 기존 요구사항에 맞춰 수정해보자
public class Main {
public static void main(String[] args) {
FuncInterface miseon = () -> {
System.out.println("안녕하세요 미선입니다.");
System.out.println("저는 짱구의 엄마입니다.");
};
FuncInterface jjanggu = () -> {
System.out.println("안녕하세요 짱구입니다.");
System.out.println("저희 어머니는 짱구입니다.");
};
hello(miseon);
hello(jjanggu);
}
private static void hello(FuncInterface func) {
long start = System.nanoTime();
func.introduce();
long end = System.nanoTime();
System.out.println("time: " + (end - start) + "ns");
}
@FunctionalInterface
interface FuncInterface {
void introduce();
}
}
위 코드에서는 FuncInterface라는 함수형 인터페이스를 정의하고, 이를 통해 각기 다른 코드 조각을 hello() 메서드에 전달하고 있다. hello() 메서드는 공통 로직인 시간 측정과 출력을 담당하고, 그 안에 들어가는 실제 동작은 파라미터로 전달된 함수형 인터페이스가 처리한다.
이렇게 하면 변하지 않는 부분은 hello()안에 고정하고, 변하는 부분만 따로 빼서 필요할 때마다 유연하게 전달할 수 있다.
FuncInterface를 구현한 miseon, jjanggu를 살펴보면 지금까지 자바에서 보던 클래스나 메서드 선언 방식과는 다르게 코드 블록만을 전달하고 있다는 것을 알 수 있다. 이제 이 코드 불록에 대해 알아보자
함수형 인터페이스를 구현한 코드를 살펴보면 간결하게 코드 블록만을 전달하고 있다는 것을 알 수 있다. 바로 이것이 람다의 핵심이다.
람다는 익명 함수를 표현하는 방식으로, 이름 없는 함수 자체를 하나의 값처럼 전달할 수 있게 해준다. 기존에는 메서드를 전달하기 위해 반드시 클래스를 만들거나 익명 내부 클래스를 사용해야 했지만 람다식을 사용하면 이러한 번거로움을 없애고 코드를 훨씬 간결하게 작성할 수 있다.
// 기존 메서드 정의 방식
반환타입 메서드명(매개변수) {
본문
}
// 람다식
(매개변수) -> { 본문 }
람다를 함수형 인터페이스에 할당할 때는 메서드 시그니처(매개변수의 수와 타입, 반환 타입) 가 일치해야 한다.
public class Main {
public static void main(String[] args) {
MyFunction func = (int a, int b) -> {
return a + b;
};
int sum = func.apply(1, 2);
System.out.println("sum = " + sum);
// 컴파일 오류: 매개변수 타입 불일치
MyFunction func2 = (String a, int b) -> {
return a + b;
};
// 컴파일 오류: 매개변수 수의 불일치
MyFunction func3 = (int a) -> {
return a * a;
};
// 컴파일 오류: 반환 타입 불일치
MyFunction func4 = (int a, int b) -> {
return String.valueOf(a + b);
};
}
@FunctionalInterface
interface MyFunction {
int apply(int a, int b);
}
}
함수형 인터페이스MyFunction을 구현한 func의 경우 MyFunction의 시그니처와 모두 일치하여 정상적으로 컴파일이 진행되지만 func2, func3, func4의 경우 각각 매개변수 타입, 매개변수 수, 반환 타입이 일치하지 않기 때문에 컴파일 오류가 발생한다.
람다는 간결한 코드 작성을 위해 다양한 문법 생략을 지원한다.
1. 중괄호와 return 생략
// 생략 전
MyFunction func = (int a, int b) -> {
return a + b;
};
// 생략 후
MyFunction func = (int a, int b) -> a + b;
// 생략 불가
MyFunction func = (int a, int b) -> {
int sum = a + b;
System.out.println(sum);
return sum;
};
return을 생략할 수 있다. 하지만 단일 표현식이 아닌 경우 중괄호를 생략할 수 없으며, 중괄호를 사용 시 반드시 return을 사용해 반환해야 한다.2. 타입 생략
@FunctionalInterface
interface MyFunction {
int apply(int a, int b);
}
MyFunction을 보면 매개변수의 타입이 정의되어 있다. 따라서 이 정보를 사용하면 타입 추론이 가능함으로 람다에서 타입 정보를 생략할 수 있다.// 생략 전
MyFunction func = (int a, int b) -> a + b;
// 생략 후
MyFunction func = (a, b) -> a + b;
3. 매개변수 괄호 생략
매개변수가 하나일 경우 매개변수의 괄호를 생략할 수 있다. 하지만 매개변수가 없거나 2개 이상일 경우 괄호 생략이 불가능하다.
// 매개변수 괄호 생략 가능
MyConsumer consumer = s -> System.out.println(s);
@FunctionalInterface
interface MyConsumer {
void accept(String str);
}
MyConsumer의 경우 매개변수를 정확히 하나만 받고 있기 때문에 괄호 생략이 가능하다.
MyFunction func = (int a, int b) -> {
int sum = a + b;
System.out.println(sum);
return sum;
};
@FunctionalInterface
interface MyFunction {
int apply(int a, int b);
}
MyFunction은 매개변수를 a, b 하나 이상 받고 있기 때문에 괄호 생략이 불가능하다.
MySupplier supplier = () -> "Hello World";
@FunctionalInterface
interface MySupplier {
String get();
}
MySupplier는 매개변수를 받지 않고 있기 때문에 괄호 생략이 불가능한다.변수 대입
MyFunction func = (a, b) -> a + b; //함수를 func라는 변수에 할당
@FunctionalInterface
interface MyFunction {
int apply(int a, int b);
}
매개변수 할당
public class Main {
public static void main(String[] args) {
MyFunction func = (a, b) -> a + b;
display(func);
display((a,b) -> a + b);
}
static void display(MyFunction func) {
int a = 10;
int b = 20;
int result = func.apply(a, b);
System.out.println("result = " + result);
}
@FunctionalInterface
interface MyFunction {
int apply(int a, int b);
}
}
람다 반환
public class Main {
public static void main(String[] args) {
MyFunction func = get();
}
static MyFunction get() {
return (a, b) -> a + b;
}
@FunctionalInterface
interface MyFunction {
int apply(int a, int b);
}
}
이처럼 람다를 변수에 할당하고, 메서드의 매개변수로 할당하며 반환값으로 사용할 수 있는 이유는 람다가 일급 시민 혹은 일급 객체이기 때문이다.
일급 시민(일급 객체)란?
함수가 숫자, 문자열, 객체(자료구조) 등과 동등한 지위를 가지는 것을 말하며, 다음 세 가지 조건을 모두 만족하는 객체를 일급 시민(일급 객체)이라고 한다.
스트림은 자바 8부터 함수형 인터페이스와 람다와 함께 추가된 기능으로 선헌형 방식으로 배열과 컬렉션의 요소들을 다룰 수 있게 해주는 기능이다.
자바에서 데이터를 다루는 가장 흔한 방법은 for문이나 foreach문을 활용한다. 하지만 이 방식은 "어떻게 처리할 것인가"에 집중해야 하기 때문에, 코드가 점점 복잡하고 장황해진다.
반면 스트림은 "무엇을 할 것인가"에 집중할 수 있게 해준다. 데이터를 필터링하고, 변환하고, 수집하는 과정을 간결하고 가독성 높게 표현할 수 있다.
스트림은 크게 다음 3단계(생성 → 중간 연산 → 최종 연산)로 나뉘어 동작한다.
1. 생성
// 컬랙션으로부터 스트림 생성
List<String> names = List.of("짱구", "철수", "유리");
Stream<String> stream1 = names.stream();
// 배열로부터 스트림 생성
String[] arr = {"apple", "banana"};
Stream<String> stream2 = Arrays.stream(arr);
// 직접 요소를 지정하여 생성
Stream<Integer> stream3 = Stream.of(1, 2, 3, 4);
2. 중간 연산
중간 연산은 스트림을 가공(변형)하는 단계이며 데이터를 변환, 필터링, 정렬 등을 할 수 있다.
List<String> names = List.of("짱구", "철수", "유리");
Stream<String> stream = names.stream() // 생성
.filter(s -> s.startsWith("짱")) // 중간연산
.map(s -> "신짱구"); // 중간연산
대표적인 중간 연산
filter(): 조건에 맞는 요소만 추출map(): 요소를 변환sorted(): 정렬distinct(): 중복 제거limit(), skip(): 자르기, 건너뛰기3. 최종 연산
스트림 파이프라인의 끝에 호출되어 실제 연산을 수행하고 결과를 만들어낸다. 최종 연산이 실행된 후에 스트림은 소모되어 더 이상 사용할 수 없다.
long count = names.stream() // 생성
.filter(name -> name.length() > 2) // 중간 연산
.count(); // 최종 연산
대표적인 최종 연산
collect() : 결과 수집 및 다양한 형태로 변환toList() : 스트림을 불변 리스트로 수집forEach(): 각 요소에 대해 작업 수행collect(): 리스트, 맵 등의 컬렉션으로 변환count(): 요소 개수 반환reduce(): 하나의 값으로 축소중간 연산 중 하나로 map 은 각 요소를 하나의 값으로 변환하지만 flatMap 은 각 요소를 스트림으로 변환한 뒤, 그 결과를 하나의 스트림으로 평탄화해준다.
public class Main {
public static void main(String[] args) {
List<List<Integer>> lists = List.of(
List.of(1, 2, 3),
List.of(4, 5, 6),
List.of(7, 8, 9)
);
List<Stream<Integer>> mapList = lists.stream()
.map(Collection::stream)
.toList();
List<Integer> flatMapList = lists.stream()
.flatMap(Collection::stream)
.toList();
System.out.println("lists = " + lists);
System.out.println("mapList = " + mapList);
System.out.println("flatMapList = " + flatMapList);
}
}
lists = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
mapList = [java.util.stream.ReferencePipeline$Head@52cc8049,
java.util.stream.ReferencePipeline$Head@5b6f7412,
java.util.stream.ReferencePipeline$Head@27973e9b]
flatMapList = [1, 2, 3, 4, 5, 6, 7, 8, 9]
map()을 사용한 mapList의 경우 각 배열 안의 요소가 Stream의 참조 값으로 출력되는 것을 확인할 수 있다. 이는 각 요소를 스트림으로 변환한 뒤 이 스트림들을 다시 리스트로 모았기 때문에 이러한 결과가 나타난 것이다.
반면 flatMap()을 사용한 flatMapList의 경우, 요소들이 1차원 형태로 출력되는 것을 확인할 수 있다. 이는 2차원 리스트의 각 요소를 스트림으로 변환한 뒤 flatMap()이 이 스트림들을 하나의 스트림으로 평탄화해주었기 때문에 가능한 결과이다.
기본형 특화 스트림의 경우 IntStream , LongStream , DoubleStream 세 가지 형태를 제공하여 기본 자료형(int, long, double)에 특화된 기능을 사용할 수 있게 한다.
기본형 특화 스트림 종류
| 스트림 타입 | 대상 원시 타입 | 생성 예시 |
|---|---|---|
IntStream | int | IntStream.of(1, 2, 3)IntStream.range(1, 10)mapToInt(...) |
LongStream | long | LongStream.of(10L, 20L)LongStream.range(1, 10)mapToLong(...) |
DoubleStream | double | DoubleStream.of(3.14, 2.78)DoubleStream.generate(Math::random)mapToDouble(...) |
range(int startInclusive, int endExclusive) : 시작 값 이상, 끝 값 미만rangeClosed(int startInclusive, int endInclusive) : 시작 값 이상, 끝 값 이하range(), rangeClosed()와 같은 메서드를 사용하면 범위를 쉽게 다룰 수 있어 반복문 대신 사용할 수 있다.주요 기능 및 메서드
기본형 특화 스트림은 합계, 평균, 최솟값/최댓값 등 자주 사용하는 숫자 연산을 편리하게 제공하며, 타입 변환 및 박싱/언박싱을 위한 메서드도 함께 지원한다.
| 메서드 / 기능 | 설명 | 예시 |
|---|---|---|
sum() | 모든 요소의 합계 | int total = IntStream.of(1, 2, 3).sum(); |
average() | 평균 계산, OptionalDouble 반환 | double avg = IntStream.range(1, 5).average().getAsDouble(); |
summaryStatistics() | 합계, 평균, 최솟값, 최댓값, 개수 포함된 통계 객체 반환 | IntSummaryStatistics stats = IntStream.range(1, 5).summaryStatistics(); |
mapToLong(), mapToDouble() | 기본형 스트림 간 타입 변환 | LongStream ls = IntStream.of(1, 2).mapToLong(i -> i * 10L); |
mapToObj() | 기본형 스트림 → 참조형 스트림 | Stream<String> s = IntStream.range(1, 5).mapToObj(i -> "No: " + i); |
boxed() | 기본형 스트림 → 박싱된 객체 스트림 | Stream<Integer> si = IntStream.range(1, 5).boxed(); |
min(), max(), count() | 최솟값, 최댓값, 요소 수 계산 | long cnt = LongStream.of(1, 2, 3).count(); |
public class Main {
public static void main(String[] args) {
// == 1부터 10까지의 값을 배열에 저장 == //
ArrayList<Object> forList = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
forList.add(i);
}
List<Integer> streamList = IntStream.rangeClosed(1, 10)
.boxed() // 기본형 스트림을 객체 스트림으로 변경
.toList();
System.out.println("forList = " + forList);
System.out.println("streamList = " + streamList);
System.out.println();
//== list 요소들의 합, 평균 구하기 ==//
int sum = streamList.stream()
.mapToInt(i -> i) //객체 스트림을 기본형(int) 스트림으로 변경
.sum(); // 요소들의 합계
double avg = streamList.stream()
.mapToInt(i -> i)
.average() //요소들의 평균
.getAsDouble();
System.out.println("sum = " + sum);
System.out.println("avg = " + avg);
}
}
// forList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// streamList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
for문을 사용하지 않고도 선언적으로 컬렉션에 값을 넣을 수 있으며, 합계와 평균을 구할 수 있다.mapToInt(), mapToObject(), boxed() 등을 잘 활용하면 기본형 특화 스트림과 객체 스트림을 오가며 다양한 작업들을 할 수 있다.List와 Array 등 원본의 값을 변경하지 않고 결과를 새로 생성한다.1. 원본 데이터소스를 변경하지 않음
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7);
List<Integer> streamList = list.stream()
.map(i -> i * 2)
.toList();
System.out.println("list = " + list);
System.out.println("streamList = " + streamList);
}
}
// list = [1, 2, 3, 4, 5, 6, 7]
// streamList = [2, 4, 6, 8, 10, 12, 14]
list의 값들을 2배씩 증가시켜 streamList를 생성하였다.list의 값은 그대로 유지되며 새로운 list를 생성하는 것을 확인할 수 있다.2. 일회성
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7);
Stream<Integer> stream = list.stream();
stream.forEach(System.out::print);
System.out.println();
stream.forEach(System.out::print);
}
}
// 1234567
// Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
3. 파이프라인 구성
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7);
List<Integer> result = list.stream()
.filter(n -> {
System.out.println("짝수 필터 실행: " + n);
return n % 2 == 0;
})
.map(n -> {
int num = n * n;
System.out.println("필터링된 값을 2배 증가: " + num);
return num;
})
.toList();
System.out.println("result = " + result);
}
}
list의 요소 중 짝수만 필터링하고, 필터링 된 값을 2배 증가시며 그 값들을 다시 list로 모으는 코드이다. 해당 코드가 어떻게 동작했는지 결과를 확인해 보자
짝수 필터 실행: 1
짝수 필터 실행: 2
필터링된 값을 2배 증가: 4
짝수 필터 실행: 3
짝수 필터 실행: 4
필터링된 값을 2배 증가: 16
짝수 필터 실행: 5
짝수 필터 실행: 6
필터링된 값을 2배 증가: 36
짝수 필터 실행: 7
result = [4, 16, 36]
출력된 값을 확인해 보면 list의 요소들을 모두 필터링한 다음 매핑하는 것이 아니라, 하나의 요소를 필터링하고 필터링을 통과하면 즉시 매핑을 수행하는 방식으로 처리된다.
이처럼 데이터가 중간 연산을 하나씩 순차적으로 통과하면서 최종 연산에 도달하는 흐름을 구성하는 방식을 파이프라인 처리라고 한다.
지연 연산
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3);
Stream<Integer> stream = list.stream()
.filter(n -> {
System.out.println("짝수 필터 실행: " + n);
return n % 2 == 0;
})
.map(n -> {
int num = n * n;
System.out.println("필터링된 값을 2배 증가: " + num);
return num;
});
System.out.println("최종연산 실행");
stream.toList();
}
최종연산 실행
짝수 필터 실행: 1
짝수 필터 실행: 2
필터링된 값을 2배 증가: 4
짝수 필터 실행: 3
출력값을 확인해 보면 최종 연산(toList) 전까지 중간 연산(filter, map) 이 실행되지 않는 것을 확인할 수 있다.
병렬 처리
public class Main {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
//병렬 처리 적용 전
System.out.println("==== 병렬 처리 적용 전 ====");
long startBefore = System.currentTimeMillis();
List<Integer> result1 = numbers.stream()
.map(Main::multiply)
.toList();
long endBefore = System.currentTimeMillis();
System.out.println("소요 시간 :" + (endBefore - startBefore) + "ms, result1 = " + result1);
System.out.println();
//병렬처리 적용 후
System.out.println("==== 병렬 처리 적용 후 ====");
long startAfter = System.currentTimeMillis();
List<Integer> result2 = numbers.stream()
.parallel()
.map(Main::multiply)
.toList();
long endAfter = System.currentTimeMillis();
System.out.println("소요 시간 :" + (endAfter - startAfter) + "ms, result2 = " + result2);
System.out.println();
}
static int multiply(int i) {
try {
Thread.sleep(1000);
int value = i * 2;
System.out.println("[" + Thread.currentThread().getName() + "] value: " + value);
return value;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
==== 병렬 처리 적용 전 ====
[main] value: 2
[main] value: 4
[main] value: 6
[main] value: 8
[main] value: 10
소요 시간 :5040ms, result1 = [2, 4, 6, 8, 10]
==== 병렬 처리 적용 후 ====
[ForkJoinPool.commonPool-worker-1] value: 4
[ForkJoinPool.commonPool-worker-2] value: 6
[main] value: 10
[ForkJoinPool.commonPool-worker-4] value: 8
[ForkJoinPool.commonPool-worker-3] value: 2
소요 시간 :1005ms, result2 = [2, 4, 6, 8, 10]
parallel()을 사용하지 않은 스트림의 경우 main 스레드에서 순차적으로 수행되며, 각 요소마다 Thread.sleep(1000)으로 인해 약 5초가 걸린 것을 확인할 수 있다.
parallel()을 사용한 스트림의 경우 ForkJoinPool.commonPool-worker쓰레드와 main 쓰레드에서 병렬로 처리되는 것을 확인할 수 있으며 Thread.sleep(1000)이 있더라도 약 1초 만에 처리가 완료된 것을 확인할 수 있다.
parallel()를 선언함으로서 fork/join 프레임워크와 멀티 스레드에 대해 몰라도 선언적으로 병렬 연산을 수행할 수 있다.
하지만 공용 풀을 공유하므로, I/O 대기 작업이나 동시 요청이 많아지는 상황에서 병목 현상이 발생할 수 있다.
함수형 인터페이스란 단 하나의 추상 메서드만 가지는 인터페이스이며 코드 조각을 값처럼 전달할 수 있게 한다.
람다는 함수형 인터페이스를 간단하게 구현하는 문법으로, 익명 클래스보다 훨씬 간결하고 읽기 쉬운 코드를 작성할 수 있게 해준다.
컬렉션이나 배열의 요소를 하나씩 처리하는 반복 로직을 간결하게 작성할 수 있으며 map(), filter() 등을 활용해 선언형 프로그래밍 스타일을 구현할 수 있다.
스트림은 중간 연산과 최종 연산으로 구성되며 지연 처리와 파이프라인 처리가 특징이다.
제가 공부한 내용을 정리한 것이라 틀린 내용이 있을 수 있습니다. 보시고 틀린 내용을 알려주시면 감사하겠습니다.
참고자료
김영한의 실전 자바 - 고급 3편