기초

스프링 전에 다지는 자바 문법

자바를 한 번 배웠더라도, 스프링 코드에는 비교적 최신 자바 문법이 가득하다. 람다·스트림·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 x; (X) — 우변이 있어야 타입 추론 가능

못 쓰는 곳

필드, 메서드 매개변수·반환 타입

남용 주의

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=Long

4. 람다식과 함수형 인터페이스

함수형 인터페이스는 추상 메서드가 딱 하나뿐인 인터페이스다. 그 구현을 통째로 클래스로 만드는 대신, 람다식으로 "메서드 본문만" 짧게 전달할 수 있다.

// 기존 — 익명 클래스로 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());   // 나이 오름차순

자주 쓰는 함수형 인터페이스는 정해져 있다.

인터페이스

형태

의미

Runnable

() -> void

입력·반환 없이 실행만

Supplier<T>

() -> T

입력 없이 값을 공급

Consumer<T>

T -> void

값을 받아 소비(출력 등)

Function<T,R>

T -> R

값을 받아 변환해 반환

Predicate<T>

T -> boolean

조건 판단(참/거짓)

왜 이런 이름들이 따로 있나 — '람다의 모양'에 붙인 이름이다

람다는 그 자체로는 타입이 없다. () -> 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" -> 5
  • Predicate<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);

종류

예

같은 람다

정적 메서드

Integer::parseInt

s -> Integer.parseInt(s)

인스턴스 메서드

Member::getName

m -> m.getName()

생성자

Member::new

() -> new Member()


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/>(모으기)"]

연산은 두 종류다. 중간 연산은 스트림을 반환해 계속 이어 붙이고, 최종 연산에서 결과가 나온다.

구분

연산

하는 일

중간

filter

조건에 맞는 것만 남김

중간

map

각 요소를 다른 값으로 변환

중간

sorted / distinct / limit

정렬 / 중복 제거 / 개수 제한

최종

toList / collect

결과를 컬렉션으로 모음

최종

forEach

각 요소에 동작 실행

최종

count / anyMatch

개수 / 조건 만족 여부

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 해 준다.

"하고 싶은 것 → 연산"으로 외우면 빠르다.

하고 싶은 것

연산

조건으로 거르기

filter

다른 값으로 바꾸기

map (숫자면 mapToInt)

정렬

sorted(Comparator...)

개수/합계/평균

count / sum / average

하나로 합치기

collect(joining) / reduce

그룹·맵으로 모으기

collect(groupingBy) / collect(toMap)

존재 여부

anyMatch / allMatch / noneMatch

하나만 꺼내기

findFirst / findAny

주의할 점

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("회원 없음"));

자주 쓰는 처리 메서드:

메서드

의미

orElse(기본값)

값이 없으면 기본값 반환

orElseThrow(...)

값이 없으면 예외를 던짐

ifPresent(소비)

값이 있을 때만 동작 실행

map(변환)

값이 있으면 변환, 없으면 그대로 빈 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 식과 텍스트 블록은 코드를 더 간결하게 한다.

마지막 수정 2026년 10월 22일