Java Stream API에서 collect() 메서드는 스트림의 요소들을 컬렉션으로 집계하거나, 다양한 방식으로 결과를 처리할 때 사용됩니다. 주로 Collectors 유틸리티 클래스와 함께 사용되며, 리스트 변환, 그룹화, 조인, 맵핑 등의 기능을 제공합니다. 가장 많이 사용되는 컬렉는 Collectors.toList(), Collectors.toSet(), Collectors.toMap() 등이 있습니다.


주요 Collectors 메소드

1. 리스트(List)나 집합(Set)으로 변환 - toList(), toSet(), toMap()

스트림 결과를 List, Set, Map 등으로 변환 할 수 있습니다.

import java.util.*;
import java.util.stream.Collectors;

public class CollectExample {
    public static void main(String[] args) {
        List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Apple");

        // List로 수집
        List<String> fruitList = fruits.stream()
                .collect(Collectors.toList());
        System.out.println("List: " + fruitList);  // List: [Apple, Banana, Cherry, Apple]

        // Set으로 수집 (중복 제거)
        Set<String> fruitSet = fruits.stream()
                .collect(Collectors.toSet());
        System.out.println("Set: " + fruitSet);  // Set: [Apple, Cherry, Banana]
    }
}

 

스트림의 요소들을 Map으로 변환 하는 예제 입니다. Map은 키 중복시 에러가 발생합니다. 중복 발생 시 mergeFunction을 추가하여 해결 할 수 있습니다.

public class CollectExample {
    public static void main(String[] args) {
        List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "melon");
        
        // 과일 이름을 길이로 매핑하여 Map으로 변환(키값 중복시 에러)
        Map<String, Integer> fruitLengthMap = fruits.stream()
         .collect(Collectors.toMap(fruit -> fruit, String::length)); // 이름을 키, 길이를 값으로

        System.out.println("Map :" + fruitLengthMap); // Map :{Apple=5, Cherry=6, melon=5, Banana=6}
        
    }
}
class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return name;
    }
}

public class CollectExample {
    public static void main(String[] args) {
    	List<Person> people = Arrays.asList(
                new Person("Alice", 30),
                new Person("Bob", 25),
                new Person("Charlie", 30),
                new Person("David", 25)
        );

    	Map<String, Integer> nameToAgeMap = people.stream()
    	        .collect(Collectors.toMap(person -> person.name, person -> person.age));

    	System.out.println(nameToAgeMap); // {Alice=30, Bob=25, Charlie=30, David=25}
    	
    }
}

 

다음 예제는 mergeFunction을 추가하여 중복 문제를 해결 하는 예제입니다.

class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return name;
    }
}

public class CollectExample {
    public static void main(String[] args) {
    	List<Person> people = Arrays.asList(
                new Person("Alice", 30),
                new Person("Bob", 25),
                new Person("Charlie", 30),
                new Person("Alice", 25)
        );

    	Map<Integer, String> ageToNames = people.stream()
    	        .collect(Collectors.toMap(
    	                person -> person.age,
    	                person -> person.name,
    	                (existing, newValue) -> existing + ", " + newValue // 중복 Key 처리
    	        ));

    	System.out.println(ageToNames); // {25=Bob, Alice, 30=Alice, Charlie}	
    }
}

 

아래와 같이 List -> Map -> LinkedHashMap로 결과 값을 변환 할 수도 있다.

class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return name;
    }
}

public class CollectExample {
    public static void main(String[] args) {
    	List<Person> people = Arrays.asList(
                new Person("Alice", 30),
                new Person("Bob", 25),
                new Person("Charlie", 30),
                new Person("David", 25)
        );

    	Map<String, Integer> nameToAgeMap = people.stream()
    	        .collect(Collectors.toMap(person -> person.name, person -> person.age));
    	
    	LinkedHashMap<String, Integer> changeLinkedHashMap = nameToAgeMap.entrySet().stream()
                .sorted((e1, e2) -> e1.getKey().compareTo(e2.getKey()))
                .collect(LinkedHashMap::new, (m, e) -> m.put(e.getKey(), e.getValue()), Map::putAll);

    	System.out.println(changeLinkedHashMap); // {Alice=30, Bob=25, Charlie=30, David=25}
    	
    }
}

 

2. 특정 타입의 컬렉션으로 변환 - toCollection()

public class CollectExample {
    public static void main(String[] args) {
        List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Apple");

        // ArrayList로 변환
        ArrayList<String> arrayList = fruits.stream()
                .collect(Collectors.toCollection(ArrayList::new));
        System.out.println("arrayList: " + arrayList);  // arrayList: [Apple, Banana, Cherry, Apple]

        // treeSet으로 변환
        TreeSet<String> treeSet = fruits.stream()
                .collect(Collectors.toCollection(TreeSet::new));
        System.out.println("treeSet: " + treeSet);  // treeSet: [Apple, Banana, Cherry]

    }
}

 

3.문자열 결합 - joining()

스트림 요소를 하나의 문자열로 합칩니다.

public class CollectExample {
    public static void main(String[] args) {
    	List<String> words = Arrays.asList("Java", "Stream", "API");

    	String result = words.stream()
    	        .collect(Collectors.joining(", "));

    	System.out.println(result); // Java, Stream, API
    }
}

 

4. 값 합산, 평균, 통계 - summingInt(), averagingInt(), summarizingInt()

public class CollectExample {
    public static void main(String[] args) {
    	List<Integer> numbers = Arrays.asList(3, 5, 8, 10);

    	int sum = numbers.stream()
    	        .collect(Collectors.summingInt(Integer::intValue));

    	double avg = numbers.stream()
    	        .collect(Collectors.averagingInt(Integer::intValue));

    	IntSummaryStatistics stats = numbers.stream()
    	        .collect(Collectors.summarizingInt(Integer::intValue));

    	// Sum: 26
    	System.out.println("Sum: " + sum); 
    	// Avg: 6.5
    	System.out.println("Avg: " + avg); 
    	// Stats: IntSummaryStatistics{count=4, sum=26, min=3, average=6.500000, max=10}
    	System.out.println("Stats: " + stats);
    }
}

 

5. 그룹화 - groupingBy()

public class CollectExample {
    public static void main(String[] args) {
    	List<String> names = Arrays.asList("Alice", "Ava", "Bob", "Charlie", "Chan", "David", "Eva");

        // 첫 글자를 기준으로 이름 그룹화
        Map<Character, List<String>> groupedByFirstLetter = names.stream()
            .collect(Collectors.groupingBy(name -> name.charAt(0))); // 첫 글자를 기준으로 그룹화

        System.out.println(groupedByFirstLetter);
        // {A=[Alice, Ava], B=[Bob], C=[Charlie, Chan], D=[David], E=[Eva]}
    }
}
class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return name;
    }
}

public class CollectExample {
    public static void main(String[] args) {
    	List<Person> people = Arrays.asList(
                new Person("Alice", 30),
                new Person("Bob", 25),
                new Person("Charlie", 30),
                new Person("David", 25)
        );

        // 나이별 그룹화
        Map<Integer, List<Person>> groupedByAge = people.stream()
                .collect(Collectors.groupingBy(person -> person.age));
        
        // {25=[Bob, David], 30=[Alice, Charlie]}
        System.out.println(groupedByAge);
    }
}

 

6. 그룹화 + 개수 세기 - groupingBy() + counting()

class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return name;
    }
}

public class CollectExample {
    public static void main(String[] args) {
    	List<Person> people = Arrays.asList(
                new Person("Alice", 30),
                new Person("Bob", 25),
                new Person("Charlie", 30),
                new Person("David", 25)
        );

    	Map<Integer, Long> countByAge = people.stream()
    	        .collect(Collectors.groupingBy(person -> person.age, Collectors.counting()));

    	System.out.println(countByAge); // {30=2, 25=2}
    }
}

 

7. 조건에 따른 분할  - partitioningBy()

class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return name;
    }
}

public class CollectExample {
    public static void main(String[] args) {
    	List<Person> people = Arrays.asList(
                new Person("Alice", 30),
                new Person("Bob", 25),
                new Person("Charlie", 30),
                new Person("David", 25)
        );

    	Map<Boolean, List<Person>> partitioned = people.stream()
    	        .collect(Collectors.partitioningBy(person -> person.age >= 30));

    	System.out.println(partitioned);
    }
}

정리

collect()는 Java Stream API에서 매우 중요한 메서드로, 다양한 방식으로 데이터를 집계하는 데 사용됩니다. Collectors.toList(), Collectors.toSet(), Collectors.toMap() 외에도 그룹화, 합산, 평균 계산, 요소 결합 등 여러 가지 유용한 방법들이 제공되므로 필요에 따라 적절히 활용할 수 있습니다.

 

자바에서 예외(Exception)는 크게 Checked Exception과 Unchecked Exception으로 나뉩니다.

1. Checked Exception

개념

- 컴파일 시점에 반드시 예외 처리를 해야 하는 예외
- try-catch 문으로 처리하거나 throws 키워드를 사용하여 호출자에게 예외 처리를 위임해야 함
- 예외 처리를 하지 않으면 컴파일 오류 발생

 

대표적인 Checked Exception

- IOException → 파일 입출력 시 발생
- SQLException → 데이터베이스 관련 예외
- ClassNotFoundException → 클래스 로드 실패
- InterruptedException → 스레드 인터럽트 발생

 

예제

import java.io.File;
import java.io.FileReader;
import java.io.IOException;

public class CheckedExceptionExample {
    public static void main(String[] args) {
        try {
            File file = new File("test.txt");
            FileReader fr = new FileReader(file); // FileNotFoundException 발생 가능
        } catch (IOException e) { // IOException을 반드시 처리해야 함
            System.out.println("파일을 찾을 수 없습니다: " + e.getMessage());
        }
    }
}

 


2. Unchecked Exception

개념

- 런타임(Runtime)에서 발생하는 예외
- 컴파일 시점에서는 예외 처리를 강제하지 않음
- 대부분 프로그래머의 실수로 인해 발생

 

대표적인 Unchecked Exception

- NullPointerException → null 객체에 접근
- ArrayIndexOutOfBoundsException → 배열의 인덱스를 초과
- ArithmeticException → 0으로 나누기
- ClassCastException → 잘못된 형 변환

 

예제

public class UncheckedExceptionExample {
    public static void main(String[] args) {
        String text = null;
        System.out.println(text.length()); // NullPointerException 발생
    }
}

 


3. Checked vs Unchecked 차이점 비교

 

구분 Checked Exception Unchecked Exception
예외 처리 여부 반드시 처리해야 함 (try-catch 또는 throws) 강제되지 않음 (개발자가 직접 처리 가능)
컴파일 단계 예외 처리를 하지 않으면 컴파일 오류 발생 컴파일 단계에서 예외 검사 없음
발생 시점 파일 I/O, 네트워크, DB 작업 등 외부 시스템과 연관된 예외 null, 배열 범위 초과, 0 나누기 등 코드 문제로 발생
예제 IOException, SQLException, ClassNotFoundException NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException

 


4. 언제 Checked vs Unchecked를 사용해야 할까?

1) Checked Exception을 사용할 때

- 외부 시스템(파일, DB, 네트워크 등)과 관련된 오류를 예상할 수 있는 경우
- 개발자가 반드시 예외 처리를 강제해야 하는 경우

 

2) Unchecked Exception을 사용할 때

- 프로그래머의 실수로 인해 발생할 가능성이 높은 경우
- NullPointerException, IndexOutOfBoundsException 등 논리적인 오류가 주된 원인인 경우

 


5. 요약

- Checked Exception → 개발자가 반드시 처리해야 하는 예외, 컴파일 할 때 예외에 대한 처리를 강제.
- Unchecked Exception → 프로그램 로직 오류로 발생하는 예외, 강제 예외 처리는 필요 없음, 예외에 대한 처리를 강제하지 않음.

 

Checked Exception은 외부 시스템과의 통신 오류, Unchecked Exception은 개발자가 잘못된 코드를 작성했을 때 발생한다고 기억하면 됩니다!

'BackEnd > JAVA' 카테고리의 다른 글

[JAVA] 정규식 사용 정리  (0) 2025.02.20
[JAVA] Stream API Collect 메소드  (0) 2025.02.20
[JAVA] Stream API 생성과 사용법 정리  (2) 2025.02.15

REST API Level3을 위한 HATEOAS 설정

 

Hateoas란?

HATEOAS(Hypermedia As The Engine of Application State)는 웹 API를 실제로 "RESTful"로 만드는 REST 애플리케이션 아키텍처의 제약 조건입니다. 기본적으로 요청에 대해 서버는 데이터만 클라이언트에 보냅니다. HATEOAS를 사용하면 응답에 데이터뿐만 아니라 해당 데이터와 관련된 가능한 작업도 링크 형식으로 포함됩니다.

 

Leonard Richardson이 제시한 REST 성숙도 모델

출처: https://grapeup.com/blog/how-to-build-hypermedia-api-with-spring-hateoas

 

- 레벨 0

API 구현은 HTTP 프로토콜을 사용하지만 전체 기능을 활용하지는 않습니다. 또한 리소스에 대한 고유 주소가 제공되지 않습니다.

method : POST / URI : /movie

 

- 레벨 1

리소스에 대한 고유 식별자가 있지만 리소스에 대한 각 작업자에는 고유한 URL이 있습니다

method : POST / URI : /movie/1/delete

 

- 레벨 2

동작을 설명하는 동사 대신 HTTP 메소드를 사용합니다. 예를 들어 레벨 1처럼 URI에 delete를 표기하여 작업자를 나타내지않고, 대신 delete 메소드를 사용합니다.

method : DELETE / URI : /movie/1

 

- 레벨 3

HATEOAS라는 용어가 도입됨. 간단히 리소스에 하이퍼미디어를 도입합니다. 이를 통해 가능한 작업에 대해 알려주는 응답에 링크를 배치할 수 있으므로 API를 통해 탐색할 수 있는 가능성이 추가됩니다.

method : DELETE / URI : /movie/1

 

 

Level2에서는 단순히 데이터 영역만을 표기하여 응답해줍니다. Level3의 Hypermedia Controls 부터는 데이터 영역 뿐만 아니라 링크 영역을 통해 자원에 호출 가능한 API 정보를 반영하여 표현합니다. 링크영역에서 반영 되는 개념이 바로 HATEOAS 입니다. 

{
    ----- 데이터 영역 -----
    "id": 1,
    "name": "Kenneth",
    "joinDate": "2024-08-17T12:58:26.575+00:00",
    ----- 데이터 영역 -----
    
    ----- 링크 영역 -----
    "_links": {
        "self": {
            "href": "http://localhost:8088/users/1"
        },
        "all-users": {
            "href": "http://localhost:8088/users"
        }
    }
    ----- 링크 영역 -----
}

 

HATEOAS 프로젝트 설정 및 구현

 

 

▶ pom.xml (build.gradle)

## Maven
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-hateoas</artifactId>  
</dependency>

## Gradle
implementation 'org.springframework.boot:spring-boot-starter-hateoas'

 

먼저 프로젝트에 Hateoas를 추가해 줍니다.

 

▶ UserController.java

@GetMapping("/users/{id}")
    public ResponseEntity<EntityModel<User>> retrieveUser(
           @Parameter(description = "사용자 ID", required = true, example = "1") @PathVariable int id
    ) {
        User user = userDaoService.findOne(id);
		
        // 사용자가 없을 경우, 예외를 발생시킨다.
        if( user == null ) {
            throw new UserNotFoundException(String.format("ID[%s] not found", id));
        }

        // 단일로 link를 만들때
        // EntityModel entityModel = EntityModel.of(user);
        // WebMvcLinkBuilder linTo = linkTo(methodOn(this.getClass()).retrieveAllUsers());
        // entityModel.add(linTo.withRel("all-users"));    // http://localhost:8080/users -> all-users

		// 다수로 link를 만들때
        return ResponseEntity.ok().body(
                EntityModel.of(user)
                        .add(linkTo(methodOn(this.getClass()).retrieveUser(id)).withSelfRel()) // http://localhost:8080/users/1 -> self
                        .add(linkTo(methodOn(this.getClass()).retrieveAllUsers()).withRel("all-users")) // http://localhost:8080/users -> all-users
        );
    }

 

EntityModel<T> 클래스를 이용하고, Static Method로 객체를 만들기 때문에 EntityModel.of()를 통해서 객체르 생성합니다.

add()메소드를 통해서 link를 추가할 수 있습니다. linkTo(methodOn(Controller.class).method(argument)) 이런 형식으로 추가하면 API의 URI가 매핑됩니다.

link에 대한 이름은 withSelfRel()과 withRel()이 있습니다.

withSelfRel() 메소드는 self로 지정되는데 호출되는 자기 자신에 대한 정보를 표현합니다.

withRel()는 withRel("명칭") 형태로 사용하며 매개변수로 지정된 값이 이름으로 표현됩니다.

 

▶ 실행결과

{
    "id": 1,
    "name": "Kenneth",
    "joinDate": "2024-08-20T14:37:49.307+00:00",
    "_links": {
        "self": {
            "href": "http://localhost:8088/users/1"
        },
        "all-users": {
            "href": "http://localhost:8088/users"
        }
    }
}

 

Hateoas를 적용하여 실행한 결과 입니다. 다음과 같이 _links에 호출 가능한  API 정보를 반영 되었습니다. 

이번 포스팅은 간단하게 Hateoas 적용 방법을 정리해 봤습니다.

해당 Full Source는 아래 Git 주소를 통해서 확인 가능합니다. 여기까지 지루한 글 읽어주셔서 감사합니다!

 

https://github.com/LuckyStrike1989/restful-web-service

 

 

'BackEnd > Spring&SpringBoot' 카테고리의 다른 글

[Spring] SiteMesh란?  (0) 2025.04.05
[Spring] Validation API 유효성 체크  (0) 2024.08.04

+ Recent posts