자바를 한 번 배웠더라도, 스프링 코드에는 비교적 최신 자바 문법이 가득하다. 람다·스트림·record·애노테이션을 모르면 이후 챕터의 예제가 "마법"처럼 보인다. 이 장은 스프링 학습에 꼭 필요한 자바 문법만 골라 "기존 방식 → 새 문법" 으로 빠르게 짚는다.
읽는 법
이미 익숙한 절은 건너뛰어도 좋다. 각 절 끝의 "스프링 어디서 쓰나" 만 훑어도 도움이 된다. (이 강의는 Java 21 기준이다.)
1. 애노테이션 (Annotation)
애노테이션은 코드에 붙이는 "메타데이터(부가 정보)" 다. @로 시작하며, 그 자체로는 아무 동작도 하지 않는다. 대신 컴파일러나 프레임워크가 읽어서 무언가를 해 준다.
가장 익숙한 예가 @Override다.
class Cat extends Animal {
@Override // "이 메서드는 부모를 재정의한다"고 컴파일러에 알림
public String sound() { // 메서드 이름을 오타 내면 컴파일 에러로 즉시 발견
return "야옹";
}
}@Override는 실행 동작을 바꾸지 않는다. 단지 컴파일러에게 정보를 줄 뿐이다. 스프링도 똑같은 원리로, 애노테이션을 보고 일을 한다.
@Component // 스프링: "이 클래스를 빈으로 등록해"
public class MemberService { ... }
@Autowired // 스프링: "여기에 의존성을 주입해"
private MemberRepository repository;스프링이 '애노테이션 천지'인 이유
과거에는 이런 설정을 모두 XML 파일에 따로 적었다. 애노테이션을 쓰면 설정을 코드 바로 옆에 선언할 수 있어 한눈에 보이고 관리가 쉽다. 그래서 스프링 코드 곳곳에 @가 등장한다. → 스프링 빈
2. var — 지역변수 타입 추론
우변만 봐도 타입이 분명하면, 좌변 타입을 var로 줄일 수 있다. (Java 10+)
// 기존 — 타입을 양쪽에 길게 반복
Map<String, List<Member>> groups = new HashMap<>();
// var — 컴파일러가 우변을 보고 타입을 추론
var groups = new HashMap<String, List<Member>>();
var name = "kim"; // String
var count = 10; // int규칙 | 내용 |
|---|---|
쓸 수 있는 곳 | 지역 변수(메서드 안)에서만 |
초기화 필수 |
|
못 쓰는 곳 | 필드, 메서드 매개변수·반환 타입 |
남용 주의
var result = service.process(); 처럼 타입이 안 보이면 오히려 읽기 어렵다. 타입이 자명할 때만 쓰고, 애매하면 명시한다.
3. 제네릭 (Generics)
제네릭은 "어떤 타입을 다루는지"를 <> 안에 적어 알려 주는 문법이다. 컴파일 시점에 타입을 검사해 주고, 꺼낼 때 형변환(캐스팅)을 없애 준다.
// 기존 — Object로 담으면 타입 안전성이 없다
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 직접 캐스팅, 실수하면 런타임 오류
// 제네릭 — 담을 타입을 명시
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // 캐스팅 불필요, 컴파일러가 타입 보장
list.add(123); // 💥 컴파일 에러 — String만 허용<T>의 T는 "여기 들어올 타입"을 가리키는 타입 매개변수다. 스프링·자바 API는 제네릭으로 가득하다.
List<Member> members; // Member를 담는 리스트
Optional<Member> found; // Member가 있을 수도 없을 수도
ResponseEntity<MemberDto> response; // 응답 바디 타입이 MemberDto
public interface MemberRepository
extends JpaRepository<Member, Long> {} // 엔티티=Member, ID=Long4. 람다식과 함수형 인터페이스
함수형 인터페이스는 추상 메서드가 딱 하나뿐인 인터페이스다. 그 구현을 통째로 클래스로 만드는 대신, 람다식으로 "메서드 본문만" 짧게 전달할 수 있다.
// 기존 — 익명 클래스로 Runnable 구현 (군더더기가 많다)
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("실행");
}
};
// 람다 — 매개변수와 본문만 남긴다
Runnable task = () -> System.out.println("실행");// 정렬 기준(Comparator)도 람다로
list.sort((a, b) -> a.getAge() - b.getAge()); // 나이 오름차순자주 쓰는 함수형 인터페이스는 정해져 있다.
인터페이스 | 형태 | 의미 |
|---|---|---|
|
| 입력·반환 없이 실행만 |
|
| 입력 없이 값을 공급 |
|
| 값을 받아 소비(출력 등) |
|
| 값을 받아 변환해 반환 |
|
| 조건 판단(참/거짓) |
왜 이런 이름들이 따로 있나 — '람다의 모양'에 붙인 이름이다
람다는 그 자체로는 타입이 없다. () -> 42를 변수에 담거나 메서드에 넘기려면 "입력 0개, 값 1개 반환"이라는 모양을 가리킬 타입 이름이 필요하다. 그 이름이 바로 Supplier다. 즉 위 표는 새로운 문법이 아니라, 람다의 입력·출력 모양마다 미리 정해둔 이름표일 뿐이다.
Supplier<T>(공급자) — 받는 건 없고, 부르면 값을 만들어 준다. "필요할 때 꺼내 쓰는 값 공장"이라고 생각하면 된다.Supplier<String> now = () -> LocalDate.now().toString(); String today = now.get(); // 호출하는 순간 값을 만들어 반환Consumer<T>(소비자) — 값을 받아서 무언가 한다(출력·저장 등). 돌려주는 값은 없다.Consumer<String> printer = msg -> System.out.println("로그: " + msg); printer.accept("회원가입 완료"); // 받은 값으로 출력만 수행Function<T,R>(변환기) — 값을 받아 다른 값으로 바꿔서 돌려준다. 입력 타입T, 출력 타입R.Function<String, Integer> length = s -> s.length(); int len = length.apply("hello"); // "hello" -> 5Predicate<T>(판별자) — 값을 받아 참/거짓만 답한다. 필터 조건에 쓴다.Predicate<Integer> isAdult = age -> age >= 19; boolean ok = isAdult.test(20); // true
호출 메서드 이름도 역할에 맞춰 다르다: 공급은 get(), 소비는 accept(), 변환은 apply(), 판별은 test(). 다음 절의 스트림(map, filter, forEach …)이 바로 이 인터페이스들을 매개변수로 받기 때문에, 여기서 모양만 익혀두면 스트림이 한결 쉬워진다.
스프링 어디서 쓰나 — 람다 DSL
스프링 시큐리티 설정이 대표적이다.
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());여기서 DSL은 Domain-Specific Language(도메인 특화 언어), 즉 "특정 목적에 맞춰 읽기 좋게 만든 작은 문법"을 뜻한다. 스프링이 설정 객체(auth)를 람다의 매개변수로 넘겨주면, 개발자는 그 객체의 메서드만 호출해 설정 내용만 채운다. 객체를 직접 new로 만들 필요가 없고, 람다 { } 블록이 설정 묶음을 시각적으로 구분해 줘서 구조가 한눈에 들어온다. 이렇게 람다를 설정 블록으로 활용해 설정 전용 문법처럼 보이게 한 것이 람다 DSL이다.
http
.authorizeHttpRequests(auth -> auth // ── 인가 설정 묶음
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.loginPage("/login")); // ── 로그인 설정 묶음바로 다음의 스트림도 람다 위에서 돌아간다. 람다 DSL은 Spring Security 챕터에서 본격적으로 다룬다.
5. 메서드 참조 (Method Reference)
람다가 하는 일이 "이미 있는 메서드 하나를 호출" 하는 게 전부라면, 클래스::메서드 형태로 더 짧게 줄일 수 있다.
// 람다
stream.map(member -> member.getName());
// 메서드 참조 — 같은 의미
stream.map(Member::getName);종류 | 예 | 같은 람다 |
|---|---|---|
정적 메서드 |
|
|
인스턴스 메서드 |
|
|
생성자 |
|
|
6. 스트림 (Stream)
스트림은 컬렉션을 "선언적으로" 처리하는 문법이다. for 문으로 "어떻게" 도는지 일일이 쓰는 대신, filter·map 같은 연산을 체인으로 연결해 "무엇을" 할지 표현한다.
// 기존 for문 — 성인 회원의 이름만 모으기
List<String> names = new ArrayList<>();
for (Member m : members) {
if (m.getAge() >= 20) {
names.add(m.getName());
}
}
// 스트림 — 흐름이 그대로 읽힌다
List<String> names = members.stream()
.filter(m -> m.getAge() >= 20) // 성인만 거르고
.map(Member::getName) // 이름으로 바꿔서
.toList(); // 리스트로 모은다flowchart LR
A[회원 목록] -->|stream| B["filter<br/>(성인만)"] --> C["map<br/>(이름만)"] --> D["toList<br/>(모으기)"]연산은 두 종류다. 중간 연산은 스트림을 반환해 계속 이어 붙이고, 최종 연산에서 결과가 나온다.
구분 | 연산 | 하는 일 |
|---|---|---|
중간 |
| 조건에 맞는 것만 남김 |
중간 |
| 각 요소를 다른 값으로 변환 |
중간 |
| 정렬 / 중복 제거 / 개수 제한 |
최종 |
| 결과를 컬렉션으로 모음 |
최종 |
| 각 요소에 동작 실행 |
최종 |
| 개수 / 조건 만족 여부 |
toList() 와 collect(), 뭐가 다를까
collect(...)가 원조다.Collector를 받아 결과를 모으는 범용 최종 연산으로, 리스트뿐 아니라 Set·Map·문자열·그룹 등 무엇이든 만들 수 있다. (Collectors.toSet(),Collectors.joining(),Collectors.groupingBy()…)toList()(Java 16+)는 그중 가장 흔한 "리스트로 모으기"를 짧게 쓴 축약형이다.collect(Collectors.toList())와 거의 같지만 두 가지가 다르다.더 짧다.
반환된 리스트가 불변(수정 불가) 이다 —
add()하면 예외가 난다. (Collectors.toList()는 수정 가능한ArrayList를 준다.)
정리: 단순히 리스트로 모을 땐 toList(), 그 외(Set·Map·문자열·그룹 등)는 collect(Collectors.xxx).
자주 쓰는 패턴 예제
아래 예제는 모두 List<Member> members(필드: id, name, age, city)를 다룬다고 하자.
// ① 조건에 맞는 개수 세기 — 성인 회원 수
long adultCount = members.stream()
.filter(m -> m.getAge() >= 20)
.count();
// ② 통계 — 나이 합계·평균 (mapToInt로 숫자 전용 스트림(IntStream)으로 전환)
int totalAge = members.stream().mapToInt(Member::getAge).sum();
double avgAge = members.stream().mapToInt(Member::getAge).average().orElse(0);
// ③ 정렬 후 상위 N개 — 나이 많은 순 3명
List<Member> top3 = members.stream()
.sorted(Comparator.comparingInt(Member::getAge).reversed()) // 내림차순
.limit(3)
.toList();
// ④ 문자열로 잇기 — 이름을 ", "로 연결 → "kim, lee, park"
String names = members.stream()
.map(Member::getName)
.collect(Collectors.joining(", "));
// ⑤ 그룹으로 묶기 — 도시별 회원 목록 (Map<String, List<Member>>)
Map<String, List<Member>> byCity = members.stream()
.collect(Collectors.groupingBy(Member::getCity));
// ⑥ Map으로 모으기 — id를 키로 (Map<Long, Member>)
Map<Long, Member> byId = members.stream()
.collect(Collectors.toMap(Member::getId, m -> m));
// ⑦ 조건 검사 — 미성년자가 한 명이라도 있는가?
boolean hasMinor = members.stream().anyMatch(m -> m.getAge() < 20);
// ⑧ 첫 요소 찾기 — 서울에 사는 첫 회원(없을 수 있으므로 Optional)
Optional<Member> firstSeoul = members.stream()
.filter(m -> m.getCity().equals("서울"))
.findFirst();Collectors·Comparator는 어디서 오나
Collectors.joining·groupingBy·toMap은 java.util.stream.Collectors, Comparator.comparingInt 등은 java.util.Comparator에 있다. IDE가 자동 import 해 준다.
"하고 싶은 것 → 연산"으로 외우면 빠르다.
하고 싶은 것 | 연산 |
|---|---|
조건으로 거르기 |
|
다른 값으로 바꾸기 |
|
정렬 |
|
개수/합계/평균 |
|
하나로 합치기 |
|
그룹·맵으로 모으기 |
|
존재 여부 |
|
하나만 꺼내기 |
|
주의할 점
Stream<Member> s = members.stream();
s.count();
s.forEach(...); // 💥 IllegalStateException — 스트림은 한 번 소비하면 재사용 불가일회용: 최종 연산을 한 번 실행하면 그 스트림은 끝이다. 다시 쓰려면
members.stream()으로 새로 만든다.원본 불변: 스트림은 원본 컬렉션을 바꾸지 않고 새 결과를 반환한다. (
filter해도members는 그대로)지연 평가: 중간 연산(
filter·map)은 쌓아 두기만 하고, 최종 연산이 호출돼야 실제로 실행된다. 그래서 최종 연산이 없으면 아무 일도 일어나지 않는다.
스프링 어디서 쓰나
서비스 계층에서 엔티티 목록을 DTO 목록으로 변환할 때 거의 항상 등장한다.
public List<MemberDto> findMembers() {
return memberRepository.findAll().stream() // List<Member>
.map(MemberDto::from) // 각 엔티티를 DTO로
.toList(); // List<MemberDto>
}7. Optional
null을 그냥 반환하면, 받는 쪽에서 null 검사를 깜빡해 NullPointerException(NPE) 이 터지기 쉽다. Optional은 "값이 있을 수도, 없을 수도"를 명시적으로 감싸는 상자다.
// 기존 — null을 반환하면 호출부가 방심한다
Member m = repository.findById(1L);
System.out.println(m.getName()); // m이 null이면 💥 NPE
// Optional — "없을 수 있음"이 타입에 드러난다
Optional<Member> found = repository.findById(1L);
Member m = found.orElseThrow(() -> new IllegalArgumentException("회원 없음"));자주 쓰는 처리 메서드:
메서드 | 의미 |
|---|---|
| 값이 없으면 기본값 반환 |
| 값이 없으면 예외를 던짐 |
| 값이 있을 때만 동작 실행 |
| 값이 있으면 변환, 없으면 그대로 빈 Optional |
스프링 어디서 쓰나
JpaRepository.findById(id) 의 반환 타입이 바로 Optional<T> 다. 그래서 조회 후 .orElseThrow(...) 패턴을 자주 본다.
8. record
DTO나 값 객체는 보통 "데이터를 담기만" 한다. 그런데 그걸 일반 클래스로 만들면 필드·생성자·getter·equals·hashCode·toString까지 수십 줄을 써야 한다. record는 이 모든 걸 한 줄로 만들어 준다. (Java 16+)
// 기존 — 데이터 하나 담으려고 장황한 코드
public class MemberDto {
private final String name;
private final int age;
public MemberDto(String name, int age) { this.name = name; this.age = age; }
public String getName() { return name; }
public int getAge() { return age; }
// equals, hashCode, toString ... 까지 추가로
}
// record — 위 전체와 거의 같은 일을 한 줄로
public record MemberDto(String name, int age) {}MemberDto dto = new MemberDto("kim", 20);
dto.name(); // 접근자는 getName()이 아니라 name() — 필드명 그대로
dto.age(); // 20값이 불변(final) 이라 한 번 만들면 바뀌지 않는다.
생성자·접근자·
equals/hashCode/toString이 자동 생성된다.
record는 DTO에, 엔티티에는 쓰지 않는다
JPA 엔티티는 기본 생성자가 필요하고 값이 변할 수 있어야 해서 record와 맞지 않는다. 요청·응답 DTO처럼 "한 번 만들고 읽기만 하는" 데이터에 쓴다. → 엔티티와 DTO 분리
9. 향상된 switch와 텍스트 블록
switch 식 (Java 14+)
기존 switch는 break를 빠뜨리면 다음 case로 흘러가는(fall-through) 함정이 있었다. 화살표(->) switch는 그 위험이 없고, 값을 바로 반환할 수 있다.
// 기존 — break 누락 위험, 값 대입이 번거로움
String label;
switch (grade) {
case 1: label = "브론즈"; break;
case 2: label = "실버"; break;
default: label = "기타";
}
// switch 식 — 화살표로 간결하게, 결과를 바로 반환
String label = switch (grade) {
case 1 -> "브론즈";
case 2 -> "실버";
default -> "기타";
};텍스트 블록 (Java 15+)
여러 줄 문자열을 """ 로 감싸면 줄바꿈·따옴표를 그대로 쓸 수 있다.
// 기존 — \n과 + 로 지저분
String json = "{\n \"name\": \"kim\",\n \"age\": 20\n}";
// 텍스트 블록 — 보이는 그대로
String json = """
{
"name": "kim",
"age": 20
}
""";스프링 어디서 쓰나
테스트에서 기대하는 JSON 응답, @Query에 적는 JPQL·SQL처럼 여러 줄 문자열을 쓸 때 텍스트 블록이 깔끔하다.
정리
애노테이션은 코드에 붙이는 메타데이터다. 스프링은 이를 읽어 빈 등록·주입 등을 처리한다.
var는 지역변수 타입을 추론한다(자명할 때만). 제네릭(<T>) 은 다루는 타입을 명시해 타입 안전을 준다.람다식은 함수형 인터페이스 구현을 짧게 쓴 것이고, 호출만 할 땐 메서드 참조(
::) 로 더 줄인다.스트림은
filter·map·collect로 컬렉션을 선언적으로 처리한다.Optional은 "없을 수 있음"을 타입으로 드러내 NPE를 막는다.record는 불변 데이터 클래스를 한 줄로 만든다(주로 DTO). switch 식과 텍스트 블록은 코드를 더 간결하게 한다.