JAVA

Java 자주 하는 실수 TOP 10 — NullPointerException부터 리소스 누수까지 실무 트러블슈팅

IT Lab 2026. 3. 3. 20:00

Java 실무에서 반복되는 10가지 실수를 NPE, equals/hashCode, 리소스 누수, 동시성, 시간 API 관점에서 정리하고 바로 적용 가능한 예방 패턴을 소개합니다.

도입 (문제 상황)

코드 리뷰에서 “이거 운영에서 한 번 터졌던 패턴인데요”라는 말을 들어보신 적 있으실 거예요. 로컬에서는 멀쩡한데, 특정 데이터/트래픽/시간대에만 예외가 나거나 메모리가 새는 식으로요. 이번 글에서는 실무에서 특히 자주 반복되는 Java 실수 TOP 10을 짧은 원인-증상-해결 포인트로 정리해 봅니다.

핵심 개념: “실수”는 문법이 아니라 경계 조건과 계약(Contract) 에서 터집니다

Java 실무 실수 예방 체크리스트 흐름도(입력 검증→null 계약→equals/hashCode→리소스→동시성→관측성)

Java에서 큰 장애로 이어지는 실수는 대개 문법을 몰라서가 아니라, API 계약을 잘못 이해하거나 경계 조건(Null/빈값/동시성/자원/시간) 을 놓쳐서 생깁니다. 예를 들어 equals()는 “같다”의 의미를 정의하는 계약인데 hashCode()와 함께 맞춰주지 않으면 HashMap에서 데이터가 “사라진 것처럼” 보일 수 있어요. 또 리소스(파일/소켓/DB 커넥션)는 GC가 알아서 정리해줄 거라고 기대하면, 트래픽이 늘 때 갑자기 “Too many open files” 같은 운영 이슈로 돌아옵니다.

아래 표는 실무에서 자주 터지는 실수와 대표 증상을 한눈에 정리한 것입니다.

TOP 실수 유형 대표 증상(운영에서 보이는 형태) 핵심 예방 포인트
1 NullPointerException 특정 입력에서만 NPE null 계약 명확화, Objects.requireNonNull, Optional은 반환에만
2 equals()/hashCode() 함정 Set/Map에서 중복/조회 실패 둘은 항상 함께, 불변 필드 기반
3 리소스 누수 파일 핸들 고갈, 커넥션 풀 고갈 try-with-resources, close 보장
4 예외 삼키기 장애 감지 지연, 원인 불명 로그+재던지기, 의미 있는 예외로 래핑
5 Stream 오용 느려짐, 디버깅 어려움 side-effect 금지, 루프가 더 명확하면 루프
6 날짜/시간 처리 실수 타임존/서머타임 버그 java.time, Instant/ZonedDateTime 구분
7 동시성 가시성/원자성 간헐적 데이터 깨짐 volatile, Atomic*, 불변 객체
8 컬렉션 수정 중 순회 ConcurrentModificationException Iterator의 remove, 새 리스트에 수집
9 문자열/로그 성능 불필요한 CPU/GC 파라미터 로깅, StringBuilder(루프)
10 BigDecimal/정수 나눗셈 함정 금액 오차, 0으로 떨어짐 valueOf, scale/rounding 명시

아래 흐름도처럼 “입력 → 계약 → 자원/동시성 → 관측(로그/메트릭)” 순서로 체크하면 대부분 예방됩니다.

flowchart TD
  A["Input validation"] --> B["Null/empty contract"]
  B --> C["Equality & hashing contract"]
  C --> D["Resource lifecycle"]
  D --> E["Concurrency safety"]
  E --> F["Observability (logs/metrics)"]

입력 검증부터 관측성까지 체크 포인트를 순서대로 밟으면 실수 재발을 크게 줄일 수 있습니다.

코드 예제: 실수 TOP 10을 한 파일에서 재현/예방하는 Java 17 샘플

아래 코드는 “왜 문제가 되는지”를 짧게 재현하고, 바로 적용 가능한 예방 패턴을 함께 보여줍니다. 그대로 복사해서 MistakesTop10.java로 실행해 보실 수 있어요.

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class MistakesTop10 {

    public static void main(String[] args) throws Exception {
        demo1_NPE();
        demo2_EqualsHashCode();
        demo3_ResourceLeak_TryWithResources();
        demo4_SwallowedException();
        demo5_StreamSideEffect();
        demo6_TimeZone();
        demo7_ConcurrencyVisibility();
        demo8_ModifyWhileIterating();
        demo9_StringAndLogging();
        demo10_BigDecimalAndIntegerDivision();

        System.out.println("\nAll demos finished.");
    }

    // 1) NullPointerException: null 계약을 코드로 고정하기
    static void demo1_NPE() {
        System.out.println("\n1) NPE");
        String input = null;

        // BAD: input.trim() -> NPE
        // GOOD: 계약을 명확히(필수 파라미터면 즉시 실패)
        try {
            Objects.requireNonNull(input, "input must not be null");
        } catch (NullPointerException e) {
            System.out.println("Caught expected: " + e.getMessage());
        }

        // GOOD: null을 허용해야 한다면 기본값/분기 처리
        String safe = (input == null) ? "" : input.trim();
        System.out.println("safe length=" + safe.length());
    }

    // 2) equals/hashCode 함정: HashSet/HashMap에서 조회 실패
    static void demo2_EqualsHashCode() {
        System.out.println("\n2) equals/hashCode");

        record UserId(long value) {}

        // BAD: equals만 있고 hashCode가 없으면 Hash 기반 컬렉션에서 문제
        class BadUser {
            final UserId id;
            BadUser(UserId id) { this.id = id; }
            @Override public boolean equals(Object o) {
                return (o instanceof BadUser other) && Objects.equals(this.id, other.id);
            }
            // hashCode 미구현(= Object#hashCode) -> equals와 불일치
        }

        Set<BadUser> badSet = new HashSet<>();
        badSet.add(new BadUser(new UserId(1)));
        System.out.println("badSet contains same id? " + badSet.contains(new BadUser(new UserId(1)))); // false 가능

        // GOOD: record(불변) 사용하면 equals/hashCode/toString 자동 생성
        record GoodUser(UserId id) {}
        Set<GoodUser> goodSet = new HashSet<>();
        goodSet.add(new GoodUser(new UserId(1)));
        System.out.println("goodSet contains same id? " + goodSet.contains(new GoodUser(new UserId(1))));
    }

    // 3) 리소스 누수: close를 GC에 맡기지 않기
    static void demo3_ResourceLeak_TryWithResources() throws IOException {
        System.out.println("\n3) Resource leak / try-with-resources");

        Path temp = Files.createTempFile("java-mistakes-", ".txt");
        Files.writeString(temp, "hello\nworld", StandardCharsets.UTF_8);

        // GOOD: try-with-resources로 close 보장
        try (BufferedReader br = Files.newBufferedReader(temp, StandardCharsets.UTF_8)) {
            System.out.println("first line=" + br.readLine());
        }

        Files.deleteIfExists(temp);
    }

    // 4) 예외 삼키기: 장애를 “없던 일”로 만들지 않기
    static void demo4_SwallowedException() {
        System.out.println("\n4) Swallowed exception");

        try {
            parseIntOrThrow("not-a-number");
        } catch (IllegalArgumentException e) {
            // GOOD: 의미 있는 메시지로 래핑하거나 상위로 전달
            System.out.println("Handled with context: " + e.getMessage());
        }

        // BAD 예시(주석): catch 후 아무 것도 안 하면 원인 추적이 매우 어려워집니다.
        // try { parseIntOrThrow("x"); } catch (Exception ignored) {}
    }

    static int parseIntOrThrow(String s) {
        try {
            return Integer.parseInt(s);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Invalid integer: '" + s + "'", e);
        }
    }

    // 5) Stream 오용: 부작용(side-effect)으로 외부 상태 변경
    static void demo5_StreamSideEffect() {
        System.out.println("\n5) Stream side-effect");

        List<Integer> nums = List.of(1, 2, 3, 4, 5);

        // BAD: 외부 mutable 상태 변경(병렬 스트림에서 특히 위험)
        List<Integer> sink = new ArrayList<>();
        nums.stream().filter(n -> n % 2 == 0).forEach(sink::add);
        System.out.println("sink=" + sink);

        // GOOD: collect로 수집(의도가 명확)
        List<Integer> evens = nums.stream().filter(n -> n % 2 == 0).toList();
        System.out.println("evens=" + evens);
    }

    // 6) 날짜/시간: LocalDateTime은 타임존이 없다
    static void demo6_TimeZone() {
        System.out.println("\n6) Time zone");

        Instant now = Instant.now(); // 절대 시간(UTC 기반)
        ZonedDateTime seoul = now.atZone(ZoneId.of("Asia/Seoul"));
        ZonedDateTime la = now.atZone(ZoneId.of("America/Los_Angeles"));

        System.out.println("instant=" + now);
        System.out.println("seoul=" + seoul);
        System.out.println("la=" + la);

        // BAD: LocalDateTime만 저장하면 “어느 지역의 시간인지” 정보가 사라집니다.
        LocalDateTime local = LocalDateTime.now();
        System.out.println("localDateTime(no zone)=" + local);
    }

    // 7) 동시성: 가시성(visibility) 문제
    static void demo7_ConcurrencyVisibility() throws InterruptedException {
        System.out.println("\n7) Concurrency visibility");

        class Flag {
            // BAD: volatile 없으면 다른 스레드에서 변경을 못 볼 수 있음(무한 루프 가능)
            // boolean running = true;

            // GOOD: volatile로 가시성 보장
            volatile boolean running = true;
        }

        Flag flag = new Flag();
        Thread worker = new Thread(() -> {
            while (flag.running) {
                // busy loop (demo 목적)
            }
            System.out.println("worker stopped");
        });

        worker.start();
        Thread.sleep(50);
        flag.running = false;
        worker.join(1000);
    }

    // 8) 순회 중 컬렉션 수정: ConcurrentModificationException
    static void demo8_ModifyWhileIterating() {
        System.out.println("\n8) Modify while iterating");

        List<String> list = new ArrayList<>(List.of("a", "remove", "b"));

        // GOOD: Iterator.remove 사용
        for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
            String v = it.next();
            if ("remove".equals(v)) it.remove();
        }
        System.out.println("after remove=" + list);
    }

    // 9) 문자열/로그 성능: 루프에서 +, 불필요한 문자열 생성
    static void demo9_StringAndLogging() {
        System.out.println("\n9) String/logging performance");

        // GOOD: 루프에서는 StringBuilder
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 5; i++) sb.append(i).append(',');
        System.out.println("built=" + sb);

        // 로깅은 보통 프레임워크에서 파라미터 방식 사용:
        // logger.debug("userId={}, status={}", userId, status);
        // 여기서는 표준 출력이라 단순 예시만 둡니다.
    }

    // 10) BigDecimal/정수 나눗셈 함정
    static void demo10_BigDecimalAndIntegerDivision() {
        System.out.println("\n10) BigDecimal & integer division");

        int a = 1, b = 2;
        System.out.println("1/2 with int=" + (a / b)); // 0

        // GOOD: 의도를 명확히(소수점 필요하면 BigDecimal)
        BigDecimal x = BigDecimal.valueOf(1);
        BigDecimal y = BigDecimal.valueOf(2);
        BigDecimal ratio = x.divide(y, 2, RoundingMode.HALF_UP);
        System.out.println("1/2 with BigDecimal=" + ratio);

        // BAD: new BigDecimal(0.1) (이진 부동소수 오차)
        BigDecimal bad = new BigDecimal(0.1);
        BigDecimal good = BigDecimal.valueOf(0.1);
        System.out.println("bad(0.1)=" + bad);
        System.out.println("good(0.1)=" + good);
    }
}

 

실무 팁

💡 실무에서는: “실수 패턴”을 체크리스트로 코드리뷰에 녹여두면 효과가 큽니다

  • PR 템플릿에 Null 계약(Nullable/NonNull), 리소스 close 여부, equals/hashCode 변경 여부, 시간대(Zone) 처리 같은 항목을 넣어 보세요.
  • 특히 equals/hashCode는 “엔티티/값 객체” 경계에서 자주 깨집니다. 값 객체는 record로, 엔티티는 식별자 기반 비교로 규칙을 고정해두면 리뷰 비용이 줄어듭니다.

💡 실무에서는: 장애는 “예외”보다 “관측 불가”에서 커집니다

  • 예외를 잡았다면 (1) 맥락 있는 메시지로 남기고, (2) 재시도/대체 흐름/재던지기 중 하나를 명확히 선택해 보세요.
  • 리소스 누수나 스레드 이슈는 재현이 어렵기 때문에, 운영에서 메트릭(커넥션 풀 사용량, 파일 디스크립터 수, 스레드 수) 을 꼭 같이 보시는 게 좋습니다.

핵심 요약: 실무에서 자주 터지는 실수는 문법이 아니라 계약(Null/equals/시간/자원/동시성)에서 나옵니다.
체크리스트와 불변/명시적 계약(try-with-resources, record, java.time)을 쓰면 재발이 크게 줄어듭니다.
예외를 “삼키지 말고” 맥락과 함께 처리/전파해 관측 가능하게 만드세요.

다음 글: #40 다음 단계로 — Spring과 JVM 생태계 로드맵