
줄이는 방향은 하나입니다. 컴파일러가 이미 아는 것을 지웁니다.
3편에서 System.out::println을 보고 "람다를 더 줄인 표기"라고만 하고 넘어갔습니다. 4편에서는 Comparator.comparing(Product::getPrice)가, 5편에서는 Collectors.toList()와 함께 이 표기가 계속 나왔죠.
미뤄 둔 숙제를 오늘 끝냅니다. 콜론 두 개가 정확히 무엇이고, 언제 쓸 수 있고 언제 못 쓰는지를 정리합니다. 그리고 람다를 배울 때 한 번은 걸려 넘어지는 "이 변수는 사실상 final이어야 합니다" 오류도 함께 풀어 보겠습니다.
부르기만 한다면 이름만 적으면 됩니다
1편에서 익명 클래스가 람다로 줄어드는 것을 봤습니다. 메서드 참조는 그 뒤에 오는 한 걸음 더입니다.
list.forEach(s -> System.out.println(s)); // 람다
list.forEach(System.out::println); // 메서드 참조
두 줄은 같은 일을 합니다. 차이를 보시면, 위 코드에서 람다가 하는 일은 받은 값을 그대로 println에 넘기는 것뿐입니다. s를 받아서 s를 넘깁니다. 중간에 가공하는 것도, 조건을 따지는 것도 없죠.
이럴 때 자바는 이렇게 말합니다. "그냥 넘기기만 할 거면 누구에게 넘길지만 알려 달라." 그래서 받는 변수와 넘기는 과정을 지우고 System.out::println만 남깁니다.
쉽게 — 택배를 대신 부쳐 주는 상황과 같습니다. "물건을 받아서 우체국에 가져다 주세요"라고 말할 수도 있지만, 하는 일이 전달뿐이라면 "우체국" 한 단어면 충분합니다. 받고 넘기는 동작은 어차피 정해져 있으니까요.
핵심 조건은 하나입니다. 람다 본문이 기존 메서드를 그대로 호출하기만 할 때, 그때만 줄일 수 있습니다.
네 가지 형태가 있습니다
:: 앞에 무엇이 오느냐에 따라 네 가지로 나뉩니다. 외우기보다 각각이 원래 람다로는 어떻게 생겼는지를 짝지어 보는 편이 빠릅니다.
| 형태 | 메서드 참조 | 같은 뜻의 람다 |
|---|---|---|
| 정적 메서드 | Integer::parseInt | s -> Integer.parseInt(s) |
| 특정 객체의 메서드 | System.out::println | s -> System.out.println(s) |
| 임의 객체의 메서드 | String::toUpperCase | s -> s.toUpperCase() |
| 생성자 | Person::new | () -> new Person() |
세 번째가 유독 헷갈립니다. 앞의 둘은 :: 앞이 호출할 대상인데, 세 번째는 :: 앞이 받게 될 값의 타입입니다. String::toUpperCase는 "String 클래스의 toUpperCase를 부른다"가 아니라 "받은 그 문자열의 toUpperCase를 부른다" 는 뜻이죠.
구분하는 요령이 있습니다. :: 앞이 클래스 이름인데 그 메서드가 정적 메서드가 아니라면, 받은 값이 주인공이 됩니다.
Integer::parseInt // parseInt는 정적 → 받은 값은 인자로 들어감
String::toUpperCase // toUpperCase는 인스턴스 메서드 → 받은 값이 주체
네 번째 생성자 참조는 5편의 collect와 자주 붙어 다닙니다. Collectors.toCollection(ArrayList::new)처럼 "담을 그릇을 새로 만들어라"를 넘길 때 쓰죠.
이럴 때는 못 줄입니다
메서드 참조가 만능은 아닙니다. 본문이 단순 호출을 넘어서는 순간 쓸 수 없습니다.
.map(s -> s.toUpperCase()) // String::toUpperCase 로 가능
.map(s -> s.toUpperCase() + "!") // 불가능 — 뒤에 붙이는 일이 더 있음
.map(s -> s.trim().toUpperCase()) // 불가능 — 두 번 부름
.filter(n -> n > 10) // 불가능 — 비교는 기존 메서드 호출이 아님
인자 순서를 바꾸는 경우도 안 됩니다.
(a, b) -> compare(b, a) // 순서를 뒤집으므로 메서드 참조 불가
그래서 실무에서는 되면 쓰고 안 되면 그냥 람다로 둡니다. 억지로 메서드 참조로 만들려고 코드를 비트는 것은 오히려 읽기 나쁩니다. 짧게 쓰는 것이 목적이 아니라 읽기 쉬운 쪽을 고르는 것이 목적입니다.
람다는 바깥 값을 붙잡습니다
이제 다른 이야기로 넘어갑니다. 람다 안에서 바깥 변수를 쓸 수 있다는 것, 눈치채셨을 겁니다.
String prefix = "안녕, ";
list.forEach(name -> System.out.println(prefix + name));
prefix는 람다 밖에서 만든 변수인데 람다 안에서 잘 쓰입니다. 이렇게 람다가 바깥 변수를 안으로 끌어안는 것을 캡처라고 하고, 값을 끌어안은 이 람다를 클로저라고 부릅니다.
쓸모가 분명한 자리가 있습니다. 아래처럼 값을 심어 둔 람다를 찍어내는 방식이죠.
static Greeter makeGreeter(String name) {
return () -> "안녕하세요, " + name + "님";
}
Greeter a = makeGreeter("철수"); // "철수"를 품은 람다
Greeter b = makeGreeter("영희"); // "영희"를 품은 람다
makeGreeter가 끝나면 name은 사라질 것 같은데, 돌려받은 람다는 그 값을 계속 기억합니다. 람다가 값을 복사해 들고 갔기 때문입니다.
단골 함정 — 붙잡은 변수는 바꿀 수 없습니다
여기서 초보자가 반드시 한 번 만나는 오류가 나옵니다.
int n = 100;
Greeter g = () -> "값: " + n;
n = 200; // 컴파일 오류
오류 메시지는 이렇게 나옵니다. "local variables referenced from a lambda expression must be final or effectively final." 람다가 참조하는 지역변수는 final이거나 사실상 final이어야 한다는 뜻입니다.

그림 1. 값을 복사했느냐 주소를 들고 갔느냐가 갈림길입니다.
왜 막을까요. 람다는 그 변수의 값을 복사해서 들고 갑니다. 원본을 200으로 바꿔도 람다 속 복사본은 100이므로, 둘이 어긋나 버립니다. 자바는 이 혼란을 피하려고 애초에 바꾸지 못하게 막았습니다.
여기서 "사실상 final(effectively final)"이라는 말이 나옵니다. final 키워드를 직접 붙이지 않았더라도 한 번 값을 넣은 뒤 다시 바꾸지 않았다면 자바가 final로 취급해 준다는 뜻입니다. 그래서 위 코드에서 n = 200; 한 줄만 지우면 오류가 사라집니다.
그런데 예외처럼 보이는 경우가 있습니다.
int[] box = {0};
Runnable r = () -> box[0]++; // 정상 동작합니다
배열은 왜 될까요. 람다가 붙잡은 것은 배열 자체가 아니라 배열이 있는 주소입니다. box라는 변수(주소)는 바뀌지 않고, 바뀌는 것은 그 주소가 가리키는 상자 안의 값이죠. 그래서 규칙을 어기지 않습니다. 클래스의 필드나 static 변수도 같은 이유로 자유롭게 바꿀 수 있습니다.
정리하면 "바꾸지 못하는 것은 변수 자체이지 그 안에 든 내용물이 아니다" 입니다.
직접 실행해 보기
메서드 참조 네 형태와 클로저를 한 번에 확인합니다. 마지막 블록의 주석을 풀면 위에서 본 컴파일 오류를 직접 보실 수 있습니다.
import java.util.List;
import java.util.stream.Collectors;
interface Greeter {
String say();
}
public class Main {
static Greeter makeGreeter(String name) {
return () -> "안녕하세요, " + name + "님"; // name 을 품은 클로저
}
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "cat");
// 1) 임의 객체의 메서드 — 받은 값이 주체
System.out.println("대문자: " + words.stream()
.map(String::toUpperCase)
.collect(Collectors.toList()));
// 2) 정적 메서드 — 받은 값이 인자
System.out.println("숫자로: " + List.of("10", "20", "30").stream()
.map(Integer::parseInt)
.collect(Collectors.toList()));
// 3) 특정 객체의 메서드
System.out.print("하나씩: ");
words.forEach(System.out::print);
System.out.println();
// 4) 클로저 — 값을 심어 둔 람다 찍어내기
Greeter a = makeGreeter("철수");
Greeter b = makeGreeter("영희");
System.out.println(a.say());
System.out.println(b.say());
// 5) 배열은 바꿀 수 있습니다 (주소를 캡처)
int[] box = {0};
Runnable count = () -> box[0]++;
count.run(); count.run(); count.run();
System.out.println("센 횟수: " + box[0]);
// 6) 지역변수는 못 바꿉니다
int n = 100;
Greeter g = () -> "값: " + n;
// n = 200; // 주석을 풀면 컴파일 오류
System.out.println(g.say());
}
}📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.
한 장 정리
| 항목 | 내용 |
|---|---|
| 메서드 참조 | 람다가 기존 메서드를 부르기만 할 때 쓰는 축약 표기 |
| 정적 메서드 | Integer::parseInt — 받은 값이 인자로 들어감 |
| 임의 객체 | String::toUpperCase — 받은 값이 주체가 됨 |
| 생성자 | Person::new — 새로 만들어 돌려줌 |
| 못 쓰는 경우 | 가공이 더 붙거나, 두 번 부르거나, 인자 순서를 바꿀 때 |
| 클로저 | 람다가 바깥 변수를 붙잡아 기억하는 것 |
| 사실상 final | 캡처한 지역변수는 수정 불가. 배열·필드는 내용물 변경 가능 |
이것만 기억하면 됩니다. 메서드 참조는 "그냥 부르기만 할 때" 쓰는 축약입니다. 조건에 안 맞으면 람다로 두는 편이 낫습니다. 그리고 람다가 붙잡은 지역변수는 바꿀 수 없는데, 이는 값을 복사해 갔기 때문입니다. 배열이나 필드가 되는 이유는 복사한 것이 주소라서, 바뀌는 대상이 변수가 아니라 그 안의 내용물이기 때문입니다.
미니 퀴즈
String::toUpperCase 형태와 캡처 규칙에서 많이 틀립니다. 정답은 눌러야 나옵니다.
미니 퀴즈
포켓코딩(JAVA4) 7편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: JAVA4 총정리 — 모던 자바 한 번에 복습.