Java 실무에서 반복되는 10가지 실수를 NPE, equals/hashCode, 리소스 누수, 동시성, 시간 API 관점에서 정리하고 바로 적용 가능한 예방 패턴을 소개합니다.
도입 (문제 상황)
코드 리뷰에서 “이거 운영에서 한 번 터졌던 패턴인데요”라는 말을 들어보신 적 있으실 거예요. 로컬에서는 멀쩡한데, 특정 데이터/트래픽/시간대에만 예외가 나거나 메모리가 새는 식으로요. 이번 글에서는 실무에서 특히 자주 반복되는 Java 실수 TOP 10을 짧은 원인-증상-해결 포인트로 정리해 봅니다.
핵심 개념: “실수”는 문법이 아니라 경계 조건과 계약(Contract) 에서 터집니다

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 생태계 로드맵
'JAVA' 카테고리의 다른 글
| Java 다음 단계로 — Spring과 JVM 생태계 로드맵 (Spring Boot 입문·JVM 튜닝 기초) (0) | 2026.03.04 |
|---|---|
| Java 코딩 컨벤션 정리: Google/Oracle 스타일 가이드 비교와 팀 컨벤션 만드는 법 (2) | 2026.03.03 |
| Java 클린 코드 실천 가이드: 네이밍부터 코드 리뷰 체크리스트까지 (0) | 2026.03.02 |
| Java 의존성 관리 — Maven & Gradle 핵심 (충돌 해결과 멀티 모듈 기초) (1) | 2026.03.02 |
| Java 성능 체크리스트: String 연결부터 메모리 누수 패턴까지 (0) | 2026.03.01 |