기록의 공유

잊지않기 위한 기록의 공유

Backend/Problem Solving

코테 대비 1 - List,Map,Comparator,Stream API

backend dev 2025. 12. 10. 11:37

 


List 관련 지식 + Stream API + Comparator + Comparable

불변,가변 List

List<Integer> integerList = List.of(1, 2, 3, 4, 5); // List.of()는 읽기만 가능하고 요소 추가,수정,삭제 불가

List<Integer> integerList2 = Arrays.asList(1, 2, 3, 4, 5); // Arrays.asList()는 읽기와 요소 수정 가능, 추가 삭제 불가하다.

List<Integer> goodIntegerList = new ArrayList<>(); // new ArrayList<>()로 생성된 리스트는 읽기와 요소의 수정,삭제,추가 전부 가능하다.

/**
 * Collection의 요소는 객체여야한다.
 * int[] 또한 하나의 배열 객체이다. int[]라는 참조타입변수에는 배열 객체의 주소값이 저장되어있다.
 * Arrays.asList()에 int[]을 전달해주면 하나의 객체만 전달된거처럼 취급되어서 int[] 리스트가 생성된다.
 */
int[] intArray = {1, 2, 3, 4, 5};
List<int[]> intArrayList = Arrays.asList(intArray); // int 배열을 전달해주면 int배열을 요소로 갖는 리스트가 생성된다.

Integer[] integerArray = {1, 2, 3, 4, 5};
List<Integer> integerList3 = Arrays.asList(integerArray); // Integer 배열을 전달해줘야 Integer를 요소로 가지는 리스트가 생성된다.

// int[] 을 Integer[]로 바꿔서 처리하는방법

List<Integer> list = Arrays.stream(intArray)
        .boxed()
        .collect(Collectors.toList());

 

리스트 정렬

// 리스트.sort()를 하게되면 원본 리스트가 정렬된 결과로 바뀐다

/**
 * Wrapper 타입 객체의 정렬
 */
ArrayList<Integer> list = new ArrayList<>(List.of(1,2,3,4,5));

// 오름차순 정렬
list.sort(Comparator.naturalOrder()); // list 내부 메소드 sort()는 Comparator 전달인자가 필요하다.

// 내림차순 정렬
list.sort(Comparator.reverseOrder());

 

객체를 요소로 가지는 List 정렬

static class Student {
    int age;
    String name;

    public Student(int age, String name) {
        this.age = age;
        this.name = name;
    }
}

 

ArrayList<Student> students = new ArrayList<>(
        List.of(
                new Student(1, "A"),
                new Student(2, "B"),
                new Student(3, "C")
        )
);
// 익명 클래스(= 클래스 정의와 객체화를 동시에. 일회성으로 사용 ) 방식을 사용해서 학생 나이기준 오름차순 정렬 -> 옛날방식
students.sort(new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return Integer.compare(o1.age, o2.age);
        /**
         * o1.age - o2.age 할때 오버플로우 위험이 존재한다.
         * 예를들어
         * int a = Integer.MAX_VALUE;  // 2,147,483,647
         * int b = -1;
         *
         * int result = a - b;  // 2,147,483,648 (int 범위 초과!)
         * System.out.println(result);  // -2,147,483,648 (오버플로우로 음수가 됨)
         *
         * Integer.compare()는 내부적으로 뺄셈을 하지않고 비교연산자를 쓰기 때문에 오버플로우에서 안전하다.
         */
    }
})
// 람다식을 이용한 Comparator 익명 함수 생성해서 학생 나이 기준 오름차순 정렬
// 자바의 람다(Lambda) 표현식에서는 실행할 코드가 단 한 줄일 때 중괄호 {}와 return 키워드를 생략할 수 있도록 지원한다.
students.sort((o1, o2) -> Integer.compare(o1.age, o2.age));

// 학생 나이 기준 내림차순 정렬
students.sort((o1, o2) -> Integer.compare(o2.age, o1.age));

// Stream Api를 이용한 정렬
List<Student> sortedStudents = students.stream()
        .sorted()
        .toList();

 


Stream 주의사항

.stream()은 배열이나 컬렉션을 stream으로 변환하여 반환합니다.

원본을 그대로 두고 변경을 가해서 새로운 결과를 얻을 때 유용하지만, 주의해야 할 점이 있습니다.

 

Wrapper 클래스(Integer, Double 등)나 String의 경우 Stream 연산(map 등)을 진행해도 원본이 변경되지 않습니다.

하지만 클래스 객체의 내부 상태를 직접 변경하려고 하면 원본도 변경됩니다.

코드 예제

원본이 변경되지 않는 경우 (불변 객체)

List<Integer> numbers = Arrays.asList(1, 2, 3);
List<Integer> doubled = numbers.stream()
    .map(n -> n * 2)
    .collect(Collectors.toList());

// numbers는 여전히 [1, 2, 3] - 변경 없음
// doubled는 [2, 4, 6] - 새로운 리스트

 

원본이 변경되는 경우 (가변 객체의 상태 변경)

class Person {
    String name;
    int age;
    // setter 있음
}

List<Person> people = Arrays.asList(new Person("철수", 20));

people.stream()
    .forEach(p -> p.setAge(30)); // ⚠️ 원본 객체가 변경됨!

 

패턴별 정리

// ✅ 원본 안전 - 불변 연산
list.stream()
    .filter(x -> x > 0)
    .map(x -> x * 2)
    .collect(Collectors.toList());

// ⚠️ 원본 변경 가능 - 객체 상태 수정
list.stream()
    .forEach(obj -> obj.setValue(100));

// ❌ ConcurrentModificationException 발생
list.stream()
    .forEach(obj -> list.remove(obj));
// Stream이 내부적으로 Iterator를 사용해서 컬렉션을 순회하는데,
// 순회 중에 컬렉션의 구조가 변경되면 이 예외가 발생합니다.

 

sorted()의 동작 방식

sorted()는 Comparator를 전달해도 되고 안 해도 됩니다.

 

 

Comparator를 전달하지 않아도 되는 경우:

  • 내부적으로 Comparable이 구현되어 있는 경우
    • Wrapper 클래스: Integer, Double 등 (기본 구현됨)
    • String 클래스 (기본 구현됨)
    • 커스텀 클래스에 Comparable 구현된 경우

 

 

Comparator를 전달해야 하는 경우:

  • Comparable이 구현되지 않은 커스텀 클래스
  • 전달하지 않으면 ClassCastException 발생

 

코드 예제

// ✅ Comparator 없이 사용 가능 (Wrapper 클래스)
List<Integer> numbers = Arrays.asList(3, 1, 2);
numbers.stream()
    .sorted()  // Integer는 Comparable 구현됨
    .collect(Collectors.toList());

// ✅ Comparable 구현된 커스텀 클래스
class Student implements Comparable<Student> {
    String name;
    int score;
    
    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.score, other.score);
    }
}

students.stream()
    .sorted()  // Comparable 구현되어 있어서 가능
    .collect(Collectors.toList());

// ✅ Comparator 직접 전달
students.stream()
    .sorted((s1, s2) -> s1.getName().compareTo(s2.getName()))
    .collect(Collectors.toList());

// ❌ Comparable 미구현 시 ClassCastException 발생
class Person {  // Comparable 구현 안 됨
    String name;
}

people.stream()
    .sorted()  // ❌ ClassCastException!
    .collect(Collectors.toList());

 


toList() vs collect(Collectors.toList())

 

toList() (Java 16+)

특징:

  • 불변 리스트 반환
  • null 요소 불가 (NullPointerException 발생)

사용처:

  • 요소에 null이 없고 수정할 필요 없는 읽기 전용으로 적합

collect(Collectors.toList())

특징:

  • 가변 리스트 반환
  • null 요소 허용

사용처:

  • 요소에 null을 허용해야 하고 수정이 필요할 때 적합

 

코드 예제

// toList() - 불변, null 불가
List<Integer> immutableList = Stream.of(1, 2, 3)
    .toList();

// immutableList.add(4);  // ❌ UnsupportedOperationException
// Stream.of(1, null, 3).toList();  // ❌ NullPointerException

// collect(Collectors.toList()) - 가변, null 허용
List<Integer> mutableList = Stream.of(1, 2, 3)
    .collect(Collectors.toList());

mutableList.add(4);  // ✅ 가능

List<Integer> withNull = Stream.of(1, null, 3)
    .collect(Collectors.toList());  // ✅ null 허용

선택 가이드

// 읽기 전용, 안전성 중요 → toList()
List<String> names = users.stream()
    .map(User::getName)
    .toList();

// 이후 수정 필요, null 허용 → collect(Collectors.toList())
List<String> modifiableNames = users.stream()
    .map(User::getName)
    .collect(Collectors.toList());

modifiableNames.add("추가 이름");  // 가능

 

복합정렬

/**
 * 복합 정렬
 */

// 나이 오름차순 정렬을하는데  나이가 같다면 이름 내림차순 정렬

// 람다식을 이용한 익명함수로 Comparator 구현한 방식
students.sort((o1, o2) -> {
    if (o1.age == o2.age) {
        return o2.name.compareTo(o1.name);
    } else {
        return Integer.compare(o1.age, o2.age);
    }
});

// Stream API를 사용한 방식 + getter가 없을떄
List<Student> complicatedSortStudents = students.stream()
        .sorted(
                Comparator.comparing((Student student) -> student.age)
                        .thenComparing((student) -> student.name,Comparator.reverseOrder())
        )
        .collect(Collectors.toList());

/**
 * (Student student)처럼 Student라는 타입을 명시해야하는 이유는
 * Comparing.comparing()에 매개변수로 keyExtractor할 수 있는 익명 함수를 전달해줘야하는데
 * 그 함수안에서 매개변수의 타입을 유추할 수 없기때문이다. 메서드 레퍼런스 방식을 쓰면 타입명시안해도됨
 어떤걸 비교할지를 뽑아내는 keyExtractor할 수있는 함수를 넣고나서, 어떤식으로 비교할지에 대한 Comparator도 매겨변수로 전달할수 있다.
 * .thenComparing()을 쓰지않으면 Student라는 타입을 명시하지않아도된다. .thenComparing()를 써서
 메서드 체이닝이 길어지기때문에 자바 컴파일러는 List였다는걸 망각하고 Comparator.comapring() 먼저 확인하게되고
 그래서 Object라고 오해하기때문에 타입명시가 필요하다.
 
 list.sort()를 하면 매개변수로 Comparator를 전해주고.
 list.stream().sorted()도 매개변수로 Comparator를 전해줘야한다.
 Comparator.comparing()의 반환값은 Comparator이고,
 Comparator.comparing()의 매개변수로는 비교할걸 가져오는 함수(KeyExtractor)만 전달해줘도되고 
 추가로 값의 비교 KeyComparator를 전달해줘도된다.
 */

// Stream API를 사용한 방식 + getter가 있을때
List<Student> complicatedSortStudents = students.stream()
        .sorted(
                Comparator.comparing(Student::getAge)
                        .thenComparing(Student::getName,Comparator.reverseOrder())
        )
        .toList();

 

객체를 요소로 가지는 List 정렬인데 객체가 Comparable을 구현했을때

static class Student implements Comparable<Student> {
    int age;
    String name;

    public Student(int age, String name) {
        this.age = age;
        this.name = name;
    }

    /**
     * Comparable을 구현하여 해당 클래스의 기본 정렬방식을 설정해둘 수 있다.
     * 이제 이 객체가진 배열이나 Collection을 정렬하려고할때 아래 정의해둔 방식을 이용하여 정렬할 것이다.
     * ( = 정렬에 대한 기준으로 Comparator.naturalOrder()를 전달하거나 , Comparator를 전달하지않았을때 아래 정의한 방식대로 정렬한다.)
     * 또한 Comparator.reverseOrder()를 정렬 기준으로 전달한다면 아래 정의한 방식의 반대방식으로 정렬할것이다.
     *
     */
    @Override
    public int compareTo(Student o1) {
        return Integer.compare(this.age, o1.age);
    }
}
ArrayList<Student> students = new ArrayList<>(
        List.of(
                new Student(1, "A"),
                new Student(2, "B"),
                new Student(3, "C")
        )
);
students.sort(Comparator.naturalOrder()); // 구현한 compareTo()대로 정렬된다.
students.sort(Comparator.reverseOrder()); // 구현한 compareTo()의 반대로 정렬된다.

List<Student> sortedList = students.stream()
        .sorted()  // 구현한 compareTo()대로 정렬된다.
        .toList();

List<Student> sortedList2 = students.stream()
        .sorted(Comparator.reverseOrder())  // 구현한 compareTo()의 반대로 정렬된다.
        .toList();

 

 

정의한 compareTo() 방식말고 다른 방식으로 정렬하고 싶다면

람다식을 이용하여 Comparator를 익명함수로 구현해서 전달하면 된다.


Map 관련 지식 + Comparator 사용시 주의사항

Map<Integer, String> map = Map.of(
        1, "A",
        2, "B",
        3, "C",
        4, "D"
); // Map.of()는 불변 맵이 생성된다.

/**
 * Stream API를 이용하여 Map 정렬시 Map의 엔트리 Set을 이용한다.
 * 정렬한후 List 또는 LinkedHashMap으로 반환해줘야 정렬된 순서가 보장된다.
 */
// Map 키 기준 오름차순 정렬 -> Map의 엔트리를 정렬해서 사용한다.
List<Map.Entry<Integer, String>> entryList2 = map.entrySet().stream()
        .sorted(Map.Entry.comparingByKey())
        .toList();

// Map 키 기준 내림차순 정렬  + LinkedHashMap으로 반환받기
LinkedHashMap<Integer, String> linkedHashMap = map.entrySet().stream()
        .sorted(Map.Entry.comparingByKey(Comparator.reverseOrder()))
        .collect(Collectors.toMap(
                Map.Entry::getKey,
                Map.Entry::getValue,
                (existingValue, newValue) -> newValue, // 기존에 있는 키에 대한 값이 들어왔을때 합병전략에 대한 익명함수이다. 다음 매개변수가 어떤 걸 만들지 지정해주는건데  LinkedHashMap로 지정해주기 위해 이 매개변수는 필수로 전달해줘야한다.
                LinkedHashMap::new
        ));

// Map value 기준 오름차순 정렬
List<Map.Entry<Integer, String>> entryList = map.entrySet().stream()
        .sorted(Map.Entry.comparingByValue())
        .toList();

// Map value 기준 내림차순 정렬
List<Map.Entry<Integer, String>> entryList = map.entrySet().stream()
        .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
        .toList();

// Map 복합정렬 -> value 기준 내림차순정렬하고 value가 같으면 key 기준 내림차순 정렬
List<Map.Entry<Integer, String>> entryList = map.entrySet().stream()
        .sorted(
                Map.Entry.<Integer,String>comparingByValue(Comparator.reverseOrder())
                        .thenComparing(Map.Entry.comparingByKey(Comparator.reverseOrder()))
        )
        .toList();

/**
 * 제너릭 메서드에는 함수호출앞에 타입 명시해줘야한다.
 * 그리고 메서드 체이닝에서 첫번째 메서드에서 타입을 명시해줘야 그 이후에서도 타입 추론이 가능해서 <Integer,String>같은 타입명시를 계속안해줘도된다.
 */

// Map 복합정렬 2 -> 이게 좀더 직관적인거 같다. comparingByValue , comapringByKey를 쓰지말자.
List<Map.Entry<Integer, String>> entryList = map.entrySet().stream()
        .sorted(
                Comparator.comparing(Map.Entry<Integer,String>::getKey,Comparator.reverseOrder())
                        .thenComparing(Map.Entry::getValue,Comparator.reverseOrder())
        )
        .toList();

reversed()의 동작 방식 주의사항

마지막에 적용하는 .reversed()는 전체 Comparator를 뒤집습니다.

많은 개발자들이 착각하는 부분인데, thenComparing() 이후에

.reversed()를 붙이면 마지막 비교 기준만 뒤집는 것이 아니라, 지금까지 체이닝된 전체 Comparator를 뒤집습니다.

 

잘못된 방법

Map<Integer, String> map = Map.of(1, "a", 2, "b", 3, "a");

// ❌ 잘못된 사용
// 의도: key 내림차순이고 value 내림차순
// 실제: (key 내림차순이고 value 오름차순)의 전체를 뒤집어서 
//       결과적으로 key 오름차순이고 value 내림차순이 된다.
List<Map.Entry<Integer, String>> result = map.entrySet().stream()
        .sorted(
                Comparator.comparing(Map.Entry<Integer,String>::getKey).reversed()
                        .thenComparing(Map.Entry::getValue).reversed()
                        // ↑ 여기서 전체를 뒤집음!
                        // 마지막 getValue에 대해 reversed()가 적용된게 아니라
                        // 전체 Comparator에 reversed()가 적용됨
        )
        .toList();

// 결과: key 오름차순으로 정렬됨 (의도와 반대!)

 

올바른 방법들

// ✅ 방법 1: 각각 Comparator.reverseOrder() 사용 (가장 명확하고 권장)
map.entrySet().stream()
        .sorted(
                Comparator.comparing(Map.Entry<Integer,String>::getKey, Comparator.reverseOrder())
                        .thenComparing(Map.Entry::getValue, Comparator.reverseOrder())
        )
        .toList();

// ✅ 방법 2: 전체를 먼저 정의하고 마지막에 reversed()
// (key와 value 둘 다 같은 방향으로 뒤집을 때만 사용)
map.entrySet().stream()
        .sorted(
                Comparator.comparing(Map.Entry<Integer,String>::getKey)
                        .thenComparing(Map.Entry::getValue)
                        .reversed()  // 전체를 뒤집음: key 내림차순 → value 내림차순
        )
        .toList();

// ✅ 방법 3: 각 단계에서 reversed() (단, thenComparing 전에)
map.entrySet().stream()
        .sorted(
                Comparator.comparing(Map.Entry<Integer,String>::getKey).reversed()
                        .thenComparing(Map.Entry::getValue, Comparator.reverseOrder())
                        // ↑ 여기는 reverseOrder() 사용
        )
        .toList();

 

각각 Comparator.reverseOrder()를 사용하는 것이 가장 명확하고 안전합니다.

 


comparing() vs comparingInt() 성능 비교

Comparator.comparing()과 Comparator.comparingInt() (및 comparingLong, comparingDouble)의 차이는

오토박싱 발생 여부입니다.

 

comparing() - 오토박싱 발생

Comparator.comparing(Student::getAge)
// getAge() → int → Integer 변환 (박싱) → 비교

 

comparingInt() - 오토박싱 없음

Comparator.comparingInt(Student::getAge)
// getAge() → int → 직접 비교

 

하지만 박싱 비용은 생각보다 크지 않습니다. 다른 부분에서 최적화하는 것이 더 효과적입니다.

 

 

가독성 vs 성능

class Student {
    private int age;
    private int score;
    
    public int getAge() { return age; }
    public int getScore() { return score; }
}

// 😕 성능 우선 (극한 최적화 필요 시)
Comparator.comparingInt(Student::getAge)
        .thenComparingInt(e -> -e.getScore())  // 부호 반전으로 내림차순 구현
        // 가독성이 떨어지고 의도가 불명확함

// 😊 가독성 우선 (대부분의 경우 권장)
Comparator.comparing(Student::getAge)
        .thenComparing(Student::getScore, Comparator.reverseOrder())  // 명확한 의도

 

실제 성능 차이

// 100만개를 정렬해도 차이는 약 10ms 정도
List<Student> students = generateStudents(1_000_000);

// comparingInt: ~150ms
students.stream()
        .sorted(Comparator.comparingInt(Student::getAge))
        .toList();

// comparing: ~160ms
students.stream()
        .sorted(Comparator.comparing(Student::getAge))
        .toList();

 

선택 가이드

 

// ✅ 일반적인 경우: comparing() 사용 (가독성 우선)
students.stream()
        .sorted(
                Comparator.comparing(Student::getName)
                        .thenComparing(Student::getAge, Comparator.reverseOrder())
        )
        .toList();

// ✅ 극한의 성능이 필요한 경우만 comparingInt() 고려
// (예: 실시간 시스템, 초당 수백만 건 정렬)
students.stream()
        .sorted(Comparator.comparingInt(Student::getAge))
        .toList();

 

대부분의 경우 comparing()을 사용하는 것이 가독성과 유지보수성 측면에서 유리합니다.


 

Stream API 조건 검색

Stream에 해당 값이 있는지없는지 판단할때

 

1. anyMatch()  사용시

boolean hasNonZero = temp.values()
    .stream()
    .anyMatch(count -> count != 0);
    
2. noneMatch() 또는 allMatch() 사용시

// 모든 값이 0인지 체크 (0이 아닌 값이 없는지)
boolean allZero = temp.values()
    .stream()
    .allMatch(count -> count == 0);
    
    
3. findAny() 사용시

boolean hasNonZero = temp.values()
    .stream()
    .filter(count -> count != 0)
    .findAny()
    .isPresent();

 

 

Stream API   Map 만들기

기본: List로 그룹화 (default)

public static void main(String[] args) {
    List<A> aList = List.of(new A(1, "A"), new A(2, "B"), new A(1, "C"));
    Map<Integer,List<A>> map = aList.stream()
            .collect(Collectors.groupingBy(A::getAge));
    System.out.println("map = "+map);


}

@Getter
static class A {
    int age;
    String name;

    public A(int age,String name) {
        this.age = age;
        this.name = name;
    }

    @Override
    public String toString() {
        return "age : %d, name : %s".formatted(age, name);
    }
}

 

결과

map = {1=[age : 1, name : A, age : 1, name : C], 2=[age : 2, name : B]}

 

  • age별로 그룹화
  • value는 기본적으로 List<A>
  • downstream collector를 생략하면 Collectors.toList()가 자동 적용

 

 

counting(): 개수만 세기

Map<Integer, Long> countByAge = aList.stream()
    .collect(Collectors.groupingBy(
        A::getAge,
        Collectors.counting()
    ));

System.out.println(countByAge);
```

**출력:**
```
{1=2, 2=1}

 

  • age 1인 사람: 2명
  • age 2인 사람: 1명
  • value 타입: Long (객체 개수)

 

 

mapping(): 특정 필드만 추출

Map<Integer, List<String>> namesByAge = aList.stream()
    .collect(Collectors.groupingBy(
        A::getAge,
        Collectors.mapping(A::getName, Collectors.toList())
    ));

System.out.println(namesByAge);
```

**출력:**
```
{1=[A, C], 2=[B]}

 

 

  • age별로 그룹화
  • 객체 전체가 아닌 이름(name)만 추출
  • value 타입: List<String>

 

 

...등등 Value를 원하는대로 설정가능