자바에서 개발하다 보면 HashMap이나 HashSet 같은 컬렉션을 활용할 때 사용자 정의 객체를 키로 사용하는 경우가 빈번하게 발생하곤 해요. 그런데 분명히 내용이 똑같은 데이터를 넣었는데도 불구하고 원하는 값을 찾지 못하거나, 중복된 데이터가 저장되는 현상을 마주하면 당황스러울 수 있거든요. 이런 문제는 대부분 해당 객체가 equals와 hashCode 메서드를 제대로 구현하지 않았을 때 발생하는데요. 자바의 HashMap은 내부적으로 해시 테이블 구조를 사용하기 때문에 데이터를 저장할 때 먼저 hash_code()를 호출하여 어느 버킷에 넣을지 결정하고, 그 다음에 equals()를 통해 실제 값이 같은지 확인하는 과정을 거치기 때문이에요.

public record UserKey(
    String id,
    String name
) {}

// 또는 기존 클래스 방식
public class UserKey {
    private final String id;
    private final String name;

    public UserKey(String id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserKey userKey = (UserKey) o;
        return java.util.Objects.equals(id, userKey.id) && 
               java.util.Objects.equals(name, userKey.name);
    }

    @Override
    public int hashCode() {
        return java.util.Objects.hash(id, name);
    }
}

만약 equals를 구현하지 않거나 하나라도 제대로 처리되지 않으면 자바는 기본적으로 객체의 메모리 주소값을 기준으로 비교하게 돼요. 그래서 내용이 같더라도 서로 다른 인스턴스라면 다른 키로 인식해버리는 문제가 생기는 거고요. 특히 실무에서 DTO나 엔티티를 기반으로 데이터를 매핑할 때 이런 실수를 하면 디버깅하기 까다로운 버그가 될 수 있어요. 이 문제를 방지하려면 반드시 두 메서드를 세트로 구현해야 하며, 최근 자바 버전에서는 Record 타입을 활용하면 개발자가 직접 코드를 작성하지 않아도 이 두 메서드가 자동으로 생성되어 안전하게 사용할 수 있거든요. 또한 Lombok 라이브러리를 사용 중이라면 @EqualsAndHashCode 어노테이션을 통해 이를 간편하게 해결할 수도 있어요. 결과적으로 데이터의 일관성을 보장하기 위해 컬렉션의 키로 사용하는 객체는 반드시 적절한 해시 알고리즘과 동등성 비교 로직을 갖추고 있어야 해요.

리스트를 맵으로 변환할 때 키가 중복되면 런타임 예외가 발생합니다. 실무에서 엔티티나 DTO 목록을 특정 필드를 기준으로 매핑해야 할 때 자주 마주치는 상황이거든요. 이때 Collectors.toMap 메서드에 병합 함수를 전달하면 해결할 수 있습니다. 병합 함수는 기존 값과 새 값을 받아 어떻게 처리할지 결정하는 로직입니다. 단순히 새 값으로 덮어쓰거나, 기존 값과 새 값을 조합해서 저장할 수도 있어요.

List<Person> people = List.of(
    new Person("김철수", 30),
    new Person("이영희", 25),
    new Person("박지성", 30)
);

Map<String, Integer> ageMap = people.stream()
    .collect(Collectors.toMap(
        Person::getName,
        Person::getAge,
        (existing, replacement) -> Math.max(existing, replacement)
    ));

병합 함수의 시그니처는 BiFunction<T, T, T> 형태를 따릅니다. 여기서 첫 번째 매개변수는 맵에 이미 저장된 값이고, 두 번째 매개변수는 스트림에서 새로 발견된 값입니다. 이 함수가 반환한 결과만이 최종 맵에 기록되므로, 원본 객체를 직접 수정하지 않도록 주의해야 해요. 성능 면에서는 병렬 스트림과 함께 사용할 때 동기화 오버헤드가 발생할 수 있습니다. 따라서 대용량 데이터 처리 시에는 컬렉션 초기 용량을 지정하거나, 순차 스트림으로 전환하는 것이 안정적입니다.

맵 변환은 컬렉션 처리에서 핵심 작업 중 하나입니다. 병합 전략을 미리 정의해 두면 예외 상황을 깔끔하게 피할 수 있네요. 복잡한 매핑 로직이 필요하면 전통적인 for문이나 Map.computeIfAbsent를 고려하는 것도 좋은 방법입니다. 값이 여러 개일 때는 groupingBy를 사용하고, 단일 값만 저장해야 할 때 toMap을 선택하시면 됩니다. 중복 키가 빈번한 데이터셋이라면 사전에 distinctByKey 필터링을 적용하거나, 순서가 필요한 경우 TreeMap으로 변환하는 과정을 추가하면 더 견고한 코드가 됩니다.

자바는 매해 3월과 9월에 6개월 주기로 기능을 릴리스하거든요. 이 중 일부가 장기 유지보수 LTS로 지정되어 기업 환경에서 안정적으로 쓰이더라고요. 프로젝트에 맞는 버전을 고를 때는 먼저 LTS 정책과 지원 일정을 따져봐야 하네요. 현재 시점의 LTS는 8, 11, 17, 21을 기준으로 운영되고 있으며, 다음 LTS는 25가 9월에 공개될 예정입니다. 버전별 무료 업데이트와 기업용 프리미어, 익스텐디드 지원 정책은 라이선스에 따라 달라지네요. 따라서 장기 유지보수가 필요한 시스템이라면 LTS 버전을 우선으로 검토해야 합니다.

실제 인프라나 클라우드 환경에서 라이브러리를 선택할 때는 호환 범위도 중요하죠. 자바용 클라우드 클라이언트 라이브러리는 8, 11, 17, 21, 25를 대상으로 테스트되었으며, 특히 팔은 적어도 2026년 9월까지 계속 지원됩니다. 따라서 레거시 유지보수 프로젝트라면 팔을 선택해도 무방하지만, 신규 개발이나 장기 확장성을 고려한다면 21이나 다가오는 25를 검토해 보시면 좋겠네요. LTS는 기능보다 안정성과 패치 주기가 핵심이므로, 팀의 운영 역량과 의존성 라이브러리 지원 여력을 먼저 비교해 보면 됩니다.

출처

자바로 개발하다 보면 NullPointerException 때문에 애먹을 때가 참 많은데요, 그래서 자바 8부터 나온 Optional을 쓰기 시작했거든요. 그런데 저도 처음엔 그냥 null 대신 Optional로 감싸기만 하면 되는 줄 알았는데, 알고 보니 잘못 쓰면 오히려 코드가 더 지저분해지더라고요.

가장 많이 헷갈리는 게 orElse랑 orElseGet 차이인데요, 얼핏 보면 똑같아 보여도 동작이 꽤 달라요. orElse(...)에 넣은 값은 Optional 안에 값이 있든 없든 무조건 실행되거든요. 반면 orElseGet은 값이 비어 있을 때만 안에 있는 람다가 실행돼요. 그래서 기본값을 만들 때 새 객체를 생성하거나 DB를 조회하는 것처럼 비용이 큰 연산이라면 반드시 orElseGet을 써야 불필요한 호출을 막을 수 있어요. 아래 예시를 보면 차이가 확 와닿을 거예요.

Optional<User> opt = userRepository.findByEmail(email);

// 값이 있어도 createGuest()가 항상 호출됨 → 불필요한 연산 발생
User u1 = opt.orElse(createGuest());

// 값이 비어 있을 때만 createGuest()가 호출됨 → 이렇게 쓰는 게 맞음
User u2 = opt.orElseGet(() -> createGuest());

// 값이 없으면 예외를 던지고 싶을 때
User u3 = opt.orElseThrow(() -> new IllegalStateException("사용자 없음"));

그리고 또 하나 짚고 싶은 게 있는데요, Optional을 아무 데나 쓰면 안 되더라고요. Optional을 만든 브라이언 괴츠도 이건 결과가 없을 수 있다는 걸 표현하는 메서드 반환 타입 용도로 만들었다고 밝혔거든요. 그래서 필드나 메서드 파라미터, 컬렉션 타입에 Optional을 쓰는 건 권장하지 않아요. 특히 파라미터로 받으면 호출할 때마다 Optional로 감싸서 넘겨야 해서 그냥 null 체크가 더 깔끔하고요.

저도 처음엔 isPresent()로 검사하고 get()으로 꺼내는 식으로 많이 썼는데, 그러면 결국 null 체크랑 다를 게 없더라고요. map이나 filter, orElseGet 같은 메서드로 값을 자연스럽게 흘려보내는 게 Optional을 제대로 쓰는 방법이에요. 정리하면 Optional은 반환 타입에만 쓰기, 기본값 연산이 무거우면 orElseGet 쓰기, 이 두 가지만 기억해도 흔한 실수는 대부분 피할 수 있을 거예요.

멀티스레드 환경에서 공유 자원을 안전하게 다루는 건 실무에서 정말 자주 마주치는 고민 중 하나예요. 처음에 동시성 문제를 해결하려고 단순히 HashMap을 쓰다가 데이터가 꼬이거나, 아니면 모든 메서드에 synchronized를 걸어서 성능이 저하되는 경험을 하곤 하죠. 특히 여러 스레드가 동시에 값을 읽고 쓰는 상황에서는 적절한 자료구조 선택이 정말 중요하게 느껴지더라고요.

이때 가장 먼저 고려하게 되는 것이 바로 ConcurrentHashMap이에요. 예전에는 Hashtable이나 Collections.synchronizedMap을 쓰기도 했지만, 사실 ConcurrentHashMap은 내부적으로 세분화된 잠금(Lock Striping)이나 CAS 알고리즘을 활용해서 훨씬 효율적으로 동작하거든요. 모든 데이터를 한꺼번에 잠그지 않고 필요한 부분만 잠그기 때문에 성능 면에서 훨씬 유리한 구조를 가지고 있어요.

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentExample {
    private static final ConcurrentHashMap<String, Integer> inventory = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        // 원자적 연산을 지원하는 putIfAbsent 활용
        inventory.putIfAbsent("Apple", 10);
        
        // compute 메서드를 사용하면 값을 읽고 수정하는 과정을 한 번에 처리할 수 있어요
        inventory.compute("Apple", (key, value) -> value + 5);
    }
}

단순히 ConcurrentHashMap을 쓰는 것만으로 끝나는 게 아니라, 원자적 연산(Atomic Operation)을 제대로 활용하는 게 핵심이에요. 예를 들어 값을 업데이트할 때 get하고 다시 put하는 과정을 거치면 그 사이에 다른 스레드가 개입할 수 있거든요. 대신 제가 자주 사용하는 방식은 compute나 merge 같은 메서드를 사용하는 거예요. 이 방식들은 내부적으로 원자성을 보장해주기 때문에 별도의 동기화 블록 없이도 안전하게 값을 업데이트할 수 있어서 코드가 훨씬 깔끔해지더라고요.

물론 모든 상황에서 ConcurrentHashMap이 정답은 아닐 수도 있어요. 만약 읽기 작업이 압도적으로 많고 쓰기가 거의 없다면 그냥 ConcurrentHashMap을 쓰는 것만으로 충분하지만, 아주 특수한 동시성 요구사항이 있다면 다른 도구를 고민해봐야 할 때도 있죠. 그래도 실무에서는 대부분의 공유 맵 자료구조로 이 친구를 선택하면 성능과 안전성 사이에서 좋은 타협점을 찾을 수 있을 거예요.

리스트를 for-each로 돌면서 조건에 맞는 원소를 지우려다 ConcurrentModificationException을 만난 적, 다들 한 번쯤 있으실 거예요. 저도 처음엔 "스레드는 하나뿐인데 왜 concurrent 예외가 나지" 싶어서 한참 헤맸거든요. 알고 보니 이름만 concurrent이지 멀티스레드랑은 상관없는 경우가 대부분이더라고요.

- 왜 터지나

ArrayList 같은 컬렉션은 내부에 modCount라는 값을 들고 있어요. 원소를 추가하거나 삭제할 때마다 이 값이 하나씩 올라가죠. 그리고 이터레이터를 만드는 순간 그 시점의 modCount를 expectedModCount라는 이름으로 복사해 둡니다. 이후 next()를 호출할 때마다 checkForComodification()이 두 값을 비교하는데, 달라져 있으면 "내가 돌고 있는 사이에 누가 리스트를 건드렸다"고 판단해서 바로 예외를 던지는 거예요. 이걸 fail-fast 방식이라고 부릅니다.

문제는 for-each가 사실 이터레이터를 감싼 문법 설탕이라는 점이에요. 겉보기엔 그냥 반복문 같지만 내부에서는 iterator를 쓰고 있으니, 루프 안에서 list.remove()를 호출하면 modCount만 올라가고 expectedModCount는 그대로라 다음 next()에서 바로 터집니다.

- 해결 방법

방법은 의외로 간단해요. 리스트를 직접 건드리지 말고, 이터레이터에게 지워달라고 부탁하면 됩니다. Iterator.remove()는 삭제한 뒤에 expectedModCount를 다시 맞춰주기 때문에 예외가 나지 않거든요. 자바 8부터는 removeIf()가 생겨서 한 줄로 끝낼 수도 있고요. 저는 조건 하나로 거르는 상황이면 removeIf가 제일 깔끔한 것 같더라고요.

List<String> names = new ArrayList<>(List.of("kim", "lee", "park"));

// 1) 터지는 코드 - for-each 안에서 리스트를 직접 수정
for (String name : names) {
    if (name.equals("kim")) {
        names.remove(name); // ConcurrentModificationException
    }
}

// 2) Iterator.remove() - 안전
Iterator<String> it = names.iterator();
while (it.hasNext()) {
    if (it.next().equals("kim")) {
        it.remove();
    }
}

// 3) removeIf() - 자바 8부터, 가장 간결
names.removeIf(name -> name.equals("kim"));

- 주의할 점

여기서 진짜 무서운 함정이 하나 있어요. 끝에서 두 번째 원소를 지우면 예외가 안 납니다. remove 후에 size가 1 줄면서 커서 위치와 size가 우연히 같아져 hasNext()가 false를 반환하고, next()를 한 번도 더 안 부르니 검사 자체를 건너뛰거든요. 그래서 테스트 데이터로는 멀쩡히 통과하다가 데이터가 바뀌는 순간 운영에서 터지는 일이 생깁니다. "우리 코드는 잘 도는데?" 싶어도 for-each 안에서의 remove는 그냥 쓰지 않는 게 답인 것 같아요.

그리고 fail-fast는 어디까지나 최선의 노력(best-effort)이라 예외가 반드시 난다고 보장하지는 않아요. 만약 진짜로 여러 스레드가 같은 컬렉션을 만지는 상황이라면 Iterator.remove()로도 부족하니 CopyOnWriteArrayList나 ConcurrentHashMap 같은 동시성 컬렉션을 쓰거나 동기화를 걸어야 합니다. 이름은 같은 예외라도 처방이 완전히 다르니, 우선 "단일 스레드에서 for-each 중 수정"인지부터 확인해 보시면 좋을 것 같아요.

- 참고

Java SE API 문서 - ConcurrentModificationException: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ConcurrentModificationException.html

검색을 해보면 부모클래스에 @SuperBuilder를 설정하라고 되어있고, 자식클래스는 @Builder를 사용하도록 되어있다.

물론 이것도 안되는것은 아니나, 부모클래스의 멤버변수도 같이 자식클래스에서 builder를 이용하여 셋팅하고자 할 때는 동작하지 않는다.

이 때는 자식클래스도 @SuperBuilder를 설정해줘야 한다.


Calendar c = Calendar.getInstance(TimeZone.getTimeZone("GMT"));



시간을 변경했더라도, 출력할 때 사용하는 SimpleDateFormat을 사용하면 자동으로 Local Time으로 변경된다.

SimpleDateFormat에서도 GMT+0기준으로 변경하기 위해서는 아래와 같은 설정을 해야 한다.


SimpleDateFormat formatter = new SimpleDateFormat(form);

formatter.setTimeZone(TimeZone.getTimeZone("GMT"));


+ Recent posts