[TIL] 12. Java Collection API, Lambda와 Stream

MinseoKim·2026년 8월 18일
post-thumbnail

1. 오늘의 한 줄 요약

오늘은 Java Collection API의 List, Set, Map과 Generics를 배우고 람다식과 함수형 인터페이스를 거쳐 Stream API까지 학습했다.

특히 Collection에 객체를 담는 방법과 Stream을 이용해 컬렉션의 데이터를 필터링하고 변환하는 흐름을 실습했다.


2. 배운 내용

1) Java Collection API

전체 개념

  • List: 중복 허용, 순서 존재, 요소의 타입으로 객체만, 가변 길이
  • Set: 중복 허용 x, 순서 존재 x, 요소의 타입으로 객체만, 가변 길이
  • Map: {key : value}, {key : value}, {key : value}, …

⭐️ 이 셋은 인터페이스로 객체 생성이 불가능함

  • implement를 사용해야함

    • ArrayList, HashSet, HashMap 같은 구현 클래스를 생성해서 인터페이스 타입으로 받는 것
  • 배열과 차이점: 배열 = 고정 길이


Backend와 Collection

Mapping Rule

  • 테이블 = xxxxxDTO → record = instance
  • 인스턴스 여러개가 들어오므로 → list, set, map에 담는 것
  • DTO 변수명 - column 명
  • DTO → instance → collection

Generics 표기

<E> : generics

  • 파라미터 타입

    • T - type

    • E - Element

      • collection 컴파일 시점의 타입의 안정성
      • 불필요한 casting 지양
    • K - key

    • V - value

    • N - number

public static void main(String[] args) {

    List<String> list = new ArrayList<String>();

    list.add("String");

    // list.add(10); --> err
    // list.add(true); --> err

    System.out.println("debug >>>> " + list);
}

Backend 3가지 구성

PL: Presentation Layer

  • generics → 타입 지정 = 표준화
  • front에게 json 형태로 response를 보냄

BL: Business Layer

PL: Persistency Layer

  • collection

2) Collection

  • 데이터의 집합, 그룹

출처: https://gangnam-americano.tistory.com/41

실습 코드 — CollectionApp.java

import java.util.ArrayList;
import java.util.List;
import java.util.Vector;

public class CollectionApp {

    /*
    실무에서는 배열 대신 Collection API (-java util)

    - List
    - Set
    - Map
    */

    public static void main(String[] args) {

        List list = new ArrayList<>();

        // List list = new Vector<>();
        // vector = arraylist

        list.add("String");
        list.add(10);

        // toString method가 오버라이딩
        // -> 주소가 가진 주소값 출력

        System.out.println("debug >>>> " + list);
    }
}

3) List / ArrayList

ArrayList

Boxing, UnBoxing

  • 명시적 타입 변환 없이 필요한 타입으로 변환으로 바꿔주는 현상
  • 기본 데이터 타입은 배열에 담을 수 없음.
  • 적용된거라면 자동 박싱 발생
list.add(10); // Boxing 적용됨

Wrapper Class

primitive type → reference type
Boxing

reference type → primitive type
UnBoxing
  • intInteger
  • charCharacter

순서 존재, 가변 길이

List list = new ArrayList<>(); // 가변길이 배열

출력:

debug >>>> [String, 10, true]

순서를 가진 상태로 출력된다.


객체와 Upcasting

System.out.println(
    "debug >>>> Arraylist Element xxxxDTO"
);

List<PersonDTO> personList
    = new ArrayList<PersonDTO>();

StudentDTO student
    = StudentDTO.builder()
                .name("또치")
                .build();

TeacherDTO teacher
    = TeacherDTO.builder()
                .name("또치")
                .build();

ManagerDTO manager
    = ManagerDTO.builder()
                .name("또치")
                .build();

// Upcasting
personList.add(student);
personList.add(teacher);
personList.add(manager);

// Downcasting
personList.add(student);
personList.add(teacher);
personList.add(manager);

for(int idx = 0; idx < personList.size(); idx++){

    PersonDTO person = personList.get(idx);

    System.out.println(
        person.personInfo()
    );
}
  • 업캐스팅에 의해 list 형식의 dto에 서로 다른 종류의 자식 객체를 담음
  • 불필요한 downcasting, 오버라이드 불러오기가 필요없어짐

4) Generics

배운 내용: generics를 클래스 선언 시 사용하는 방법

  • generics 문법: 타입의 다양성 추구
  • 타입 선언 시 generics 문법을 생략 시 기존 선언이 Object로 변경되어 버리기 때문에 끝까지 유지해야함
  • 유지해야 casting 같은 불필요한 문법을 사용하지 않아도 됨

실습 코드 — GenericsApp.java

import java.util.ArrayList;
import java.util.List;

import features.oop.sub.ManagerDTO;
import features.oop.sub.StudentDTO;
import features.oop.sub.TeacherDTO;
import features.oop.sup.PersonDTO;

public class GenericsApp {

    public static void main(String[] args) {

        System.out.println();
        System.out.println(
            "debug >>> Generics App "
        );

        List<PersonDTO> personList
            = new ArrayList<PersonDTO>();

        // List<? extends PersonDTO> personList
        //     = new ArrayList<PersonDTO>();
        // 읽기 전용이므로 add에 에러가 뜸

        // List<? super PersonDTO> personList
        //     = new ArrayList<PersonDTO>();
        // 쓰기 전용으로 add에 에러가 뜨지 않음

        StudentDTO student
            = StudentDTO.builder()
                        .name("또치")
                        .build();

        TeacherDTO teacher
            = TeacherDTO.builder()
                        .name("또치")
                        .build();

        ManagerDTO manager
            = ManagerDTO.builder()
                        .name("또치")
                        .build();

        personList.add(student);
        personList.add(teacher);
        personList.add(manager);
    }
}

Wildcard — extends vs super

extends

  • 읽기 전용 (T 하위타입)
  • add를 뒤에 사용할 수 없음
List<? extends PersonDTO> personList
    = new ArrayList<PersonDTO>();

personList.add(student);
personList.add(teacher);
personList.add(manager);
// 에러

super

  • 쓰기 전용 (T 상위타입)
  • 메서드의 타입 선언 및 리턴타입을 지정할 때 자주 사용되는 문법
List<? super PersonDTO> personList
    = new ArrayList<PersonDTO>();

@ToString

@ToString(callSuper = true)
  • callSuper가 필요한 이유

5) 람다식

참고자료: https://khj93.tistory.com/entry/JAVA-람다식Rambda란-무엇이고-사용법#google_vignette

  • 익명의 함수를 지칭하는 용어
  • 수학에서 사용하는 함수를 단순하게 표현하는 방법

특징

  • 람다 대수는 이름을 가질 필요가 없다

    • 익명 함수
  • 두 개 이상 입력이 있는 함수는 최종적으로 1개의 입력만 받는 람다 대수로 단순화 될 수 있다.

    • 커링(curing)

익명 함수란?

  • 이름이 없는 함수

  • 일급 객체(first class citizen)

    • 다른 객체들에 적용 가능한 연산을 모두 지원하는 개체
    • 함수를 값으로 사용할 수도 있고
    • 파라미터로 전달 및 변수에 대입하는 연산이 가능

장점

  • 코드의 간결성
  • 불필요한 반복문 삭제 가능

단점

  • 표현식의 복잡함
  • 람다 stream 사용 시 단순 for 문 혹은 while문 사용 시 성능 저하

표현식

  • 매개변수 화살표 (→) 함수 몸체로 이용하여 사용할 수 있음
  • 단일 실행문일 경우 {} 생략 가능
  • return문으로만 구성된 경우 {} 생략 불가
// 정상적인 유형

() -> {}
() -> 1
() -> { return 1; }

(int x) -> x + 1
(x) -> x + 1
x -> x + 1
(int x) -> { return x + 1; }
x -> { return x + 1; }

(int x, int y) -> x + y
(x, y) -> x + y
(x, y) -> { return x + y; }

(String lam) -> lam.length()
lam -> lam.length()

(Thread lamT) -> {
    lamT.start();
}

lamT -> {
    lamT.start();
}

// 잘못된 유형
// 선언된 type과 선언되지 않은 type을
// 같이 사용 할 수 없다.

(x, int y) -> x + y
(x, final y) -> x + y

6) 함수형 인터페이스

  • 인터페이스가 가질 수 있는 메서드가 딱 하나인 것을 의미

  • 종류

    • java.util.function 패키지에 포함
    • 람다식 사용
Supplier
→ 매개변수 X
→ 반환 타입 O

Consumer
→ 매개변수 O
→ 반환 타입 X

Predicate
→ 매개변수 O
→ 반환 타입 boolean

Function
→ 매개변수 O
→ 반환 타입 O

@FunctionalInterface

  • 함수형 인터페이스를 위해 정의하는 Annotation
  • 없이도 동작 가능하지만 함수형 인터페이스 조건에 부합되는지 검사하기 위해 사용
  • 람다 → 함수형 인터페이스로 접근 가능
package features.lambda;

@FunctionalInterface
public interface InspireFunction {

    public int max(int x, int y);
}

실습 코드:

import features.lambda.InspireFunction;

public class StreamApp {

    public static void main(String[] args) {

        InspireFunction func01
            = (x, y) -> x > y ? x : y;

        System.out.println(
            func01.max(100, 200)
        );

        System.out.println();

        InspireFunction func02
            = (x, y) -> x + y;

        System.out.println(
            func02.max(100, 200)
        );
    }
}

Supplier

  • 입력값 없이 결과를 반환하는 연산 수행
  • 새로운 객체 생성 또는 복잡한 계산이나 조건에 따른 결과를 제공할 때 사용
  • 필요한 객체를 요청 시에만 생성하고 싶을 때 사용할 수 있음
Supplier<Double> randomValue
    = () -> Math.random();

Double value = randomValue.get();
  • 호출될 때마다 새로운 또는 일정한 값을 반환

Consumer

  • 하나의 입력을 받아 아무것도 반환하지 않는 연산

  • Supplier와 반대

  • 데이터 처리, 이벤트 리스너, 스트림 연산 등에 사용

  • 데이터 변형 없이 소비하려는 경우

    • 로깅
    • 데이터 전송
    • UI 업데이트 등에 주로 사용
Consumer<Integer> display
    = x -> System.out.println(x);

display.accept(10);

Predicate

  • argument를 받아 boolean 값을 반환하는 함수형 인터페이스

  • 주로 특정 조건을 테스트하는 데 사용

    • 필터링, 매칭 등의 조건부 로직
  • Stream API와 자주 같이 사용

Predicate<Integer> greaterThanTen
    = x -> x > 10;

boolean result
    = greaterThanTen.test(15);

Function

  • 하나의 입력을 받아 어떤 결과를 반환
  • 입력의 형태를 다른 형태로 변환하거나 계산 결과를 수행한 결과를 반환하는 연산 수행
  • 데이터 변환
  • 매핑 연산
  • 메서드 참조
Function<Integer, String> intToString
    = x -> Integer.toString(x);

String result
    = intToString.apply(123);

7) forEach

  • for문과 달리 내부적으로 Iterator를 사용해서 순회하여 개발자가 인덱스를 신경쓰지 않아도 됨
  • index를 통한 특정 범위, 특정 값 조회 등에는 for문을 사용하는 게 좋지만
  • 단순한 순회만 할 경우 forEach문을 사용하는 게 좋음
  • 속도 측면에서는 for문이 조금 더 빠르다.

list.forEach()

실습 내용에 해당하는 개념

  • return을 가지나 break 없이 무조건 끝까지 루프문을 돈다.

    • 람다식에 해당하기 때문
  • 즉 리스트 사이즈 만큼 무조건 돌게 설계되어 있는 함수이다.

List<String> brands
    = Arrays.asList(
        "samsung",
        "lg",
        "lg cns",
        "inspire"
    );

brands.forEach(
    (brand) -> {
        System.out.println(brand);
    }
);

메서드 참조

System.out::println
  • 메서드 참조 방식
  • 언제 쓰는가?

8) Stream API

Stream

  • 배열과 컬렉션을 함수형으로 간단하게 처리 가능
  • 다양한 데이터를 표준화된 방법으로 다루기 위한 API

특징

  • 데이터를 변경하지 않음

    • 읽기 전용
  • 1회용

    • 한번 사용하면 닫혀서 재사용 불가능
  • 최종 연산 전까지 중간 연산을 수행하지 않음

  • 작업을 내부적으로 반복 처리

    • forEach()
  • 병렬 실행 가능

    • 멀티 쓰레드

Stream 종류

종류특징
Stream<T>범용 Stream
IntStream값 타입이 Int인 Stream
LongStream값 타입이 Long인 Stream
DoubleStream값 타입이 Double인 Stream

생성 방법

방법의미
Arrays.stream()배열 Stream
.stream()컬렉션 Stream
Stream.builder()Stream Builder

기본 흐름

컬렉션을 Stream 객체로 바인딩하고 중간 연산(0~N)의 연산 결과를 최종 연산(1)으로 처리한다.

일반적인 방식:

List<String> filteringList
    = new ArrayList<String>();

for(int idx = 0; idx < list.size(); idx++){

    PersonDTO person
        = personList.get(idx);

    if(person.getName().startsWith("j")){

        filteringList.add(
            person.getName().toUpperCase()
        );
    }
}

Stream API:

List<String> filteringList
    = personList.stream()
                .filter(null)
                .map(null)
                .collect(null);

실습:

List<String> filteringList
    = personList.stream()
        .filter(
            s -> s.getName().startsWith("m")
        )
        .map(
            s -> s.getName().toUpperCase()
        )
        .collect(
            Collectors.toList()
        );

System.out.println(filteringList);

또 다른 예:

personList.stream()
    .filter(
        person ->
            person.getName().length() > 5
    )
    .forEach(
        System.out::println
    );

Stream API 흐름

Collection
↓
stream()
↓
중간 연산
filter()
map()
distinct()
sorted()
↓
최종 연산
collect()
forEach()
count()

사용 예제

0~1000까지의 값 중 500 이상이며 짝수이면서 5의 배수인 수의 합

System.out.println(
    IntStream.range(0, 1001)
        .skip(500)
        .filter(i -> i % 2 == 0)
        .filter(i -> i % 5 == 0)
        .sum()
);

실행 결과:

38250

필터링

List<Integer> evens
    = nums.stream()
          .filter(n -> n % 2 == 0)
          .collect(
              Collectors.toList()
          );

매핑

List<String> lengths
    = nums.stream()
          .map(String::valueOf)
          .map(String::length)
          .collect(
              Collectors.toList()
          );

정렬 & 중복 제거

Set<String> unique
    = words.stream()
           .distinct()
           .sorted()
           .collect(
               Collectors.toSet()
           );

List와 Stream의 forEach

  • 코드의 가독성
  • 사용 목적: 가공과 연산
  • thread
List<String> brands
    = Arrays.asList(
        "samsung",
        "lg",
        "lg cns",
        "inspire"
    );

brands.forEach(
    (brand) -> {
        System.out.println(brand);
    }
);

System.out.println();

System.out.println(
    "debug >>>> collection -> Stream"
);

Stream<String> stream
    = brands.stream();

stream.forEach(
    (brand) -> {
        System.out.println(brand);
    }
);
  • 리스트와 스트림 둘 다 forEach를 사용하기는 하지만 수행 속도가 다름

9) Set

  • 자연어를 토큰화시켜 어디에 담아두고 싶을 때

특징

  • 순서 x

    • loop 구문에서 사용하지 않음
    • insert 데이터가 순서대로 저장되지 않음
  • 중복 x

    • 동일한 값이 삽입되면 하나의 값만 저장됨
  • 수정 가능(mutable)

  • FastLookup이 필요할 때 주로 쓰임

import java.util.Set;

Set<String> set = new HashSet<>();

구현 클래스

구현 클래스특징
HashSet중복 X / 순서 보장 X
TreeSet중복 X / 오름차순 데이터 정렬
LinkedHashSet중복 X / 입력 순서대로 데이터 저장

10) Map

  • key-value 방식

    • 키와 값을 하나의 쌍으로 저장하는 방식
  • List나 배열과 차이점

    • 순서가 아닌 key로 value를 찾아냄

특징

  • 요소의 저장 순서를 유지하지 않음
  • key는 중복 X
  • value는 중복 O

3. 실습

오늘 실습에서는 Collection과 Generics를 기존 OOP 코드와 연결했다.

List<PersonDTO> personList
    = new ArrayList<PersonDTO>();

StudentDTO student
    = StudentDTO.builder()
                .name("또치")
                .build();

TeacherDTO teacher
    = TeacherDTO.builder()
                .name("또치")
                .build();

ManagerDTO manager
    = ManagerDTO.builder()
                .name("또치")
                .build();

personList.add(student);
personList.add(teacher);
personList.add(manager);

for(int idx = 0; idx < personList.size(); idx++){

    PersonDTO person
        = personList.get(idx);

    System.out.println(
        person.personInfo()
    );
}

업캐스팅에 의해 하나의 PersonDTO 타입 List에 서로 다른 자식 객체를 담았다.


람다식에서는 함수형 인터페이스를 직접 만들어 사용했다.

@FunctionalInterface
public interface InspireFunction {

    public int max(int x, int y);
}
InspireFunction func01
    = (x, y) -> x > y ? x : y;

System.out.println(
    func01.max(100, 200)
);

InspireFunction func02
    = (x, y) -> x + y;

System.out.println(
    func02.max(100, 200)
);

Stream에서는 기존 반복문으로 작성하던 필터링과 변환을 체이닝 형태로 작성했다.

List<String> filteringList
    = personList.stream()
        .filter(
            s -> s.getName().startsWith("m")
        )
        .map(
            s -> s.getName().toUpperCase()
        )
        .collect(
            Collectors.toList()
        );
personList.stream()
    .filter(
        person ->
            person.getName().length() > 5
    )
    .forEach(
        System.out::println
    );

4. 개념 요약

개념핵심 내용
List중복 허용, 순서 존재, 가변 길이
Set중복 허용 X, 순서 존재 X, 가변 길이
MapKey-Value 형태로 저장
Generics컴파일 시점에 타입을 지정해 타입 안정성을 높임
람다식(매개변수) -> 표현식/블록 형태
함수형 인터페이스한 개의 추상 메서드를 갖는 인터페이스
Supplier인자 X, 반환값 O
Consumer인자 O, 반환값 X
Predicate인자 O, boolean 반환
Function인자 O, 반환값 O
Stream API컬렉션 데이터를 중간 연산과 최종 연산으로 처리
중간 연산filter, map, distinct, sorted
최종 연산collect, forEach, count
메서드 참조System.out::println과 같이 람다 대신 메서드를 직접 참조

핵심 흐름은 다음과 같다.

Collection
↓
Generics로 타입 지정
↓
Lambda
↓
Functional Interface
↓
Stream
↓
filter / map
↓
collect / forEach

핵심: 람다와 스트림을 익숙하게 사용하면 반복문과 컬렉션 조작을 선언적으로 바꾸어 코드가 훨씬 깔끔해진다.


5. 다음 해야 할 일

  • 이해한 개념을 바탕으로 실습 코드 연결 복습

보충 학습 참고자료

0개의 댓글