// Developer · Debugging Reports

뉴스도 디버깅이 필요해

마주친 오류를 재현하고, 원인을 추적하고, 기록합니다. Java · Spring · DB · 실무 트러블슈팅.

debug — today.log
$ debug --trace "오늘의 뉴스"
> 헤드라인 파싱 중...
> 원인 추적: 근거 누락 · 출처 불명
> 패치 적용: 사실 확인 + 출처 보강
✓ RESOLVED
TOTAL
TODAY
YESTERDAY

Categories

웹개발/Java

Java 기초 핵심 개념, 실무에서 자주 헷갈리는 부분 정리

자바를잡아2026. 7. 6. 08:00
반응형

Java 코드를 작성하다 보면 변수를 복사했는데 원본까지 바뀌거나, 값을 비교했는데 결과가 예상과 다르거나, 실행하자마자 NullPointerException이 뜨는 상황을 만난다. 이 글은 이런 증상이 왜 생기는지, 코드에서 무엇을 먼저 확인하면 되는지를 기초 개념 단위로 정리한다. 대부분은 새 기능을 몰라서가 아니라 변수·객체·예외가 "어떻게 동작하는지"를 정리하지 않은 채 넘어가서 생기는 문제다.

핵심 결론: "값이 어디에 저장되고 어떻게 전달되는가"를 잡으면 기초에서 막히는 오류 대부분의 원인이 보인다.

확인 순서: 기본형/참조형 → 클래스/인스턴스/static → JVM 실행 흐름 → == 와 equals() → NullPointerException → 프레임워크 설정.

문법만 익히고 실무에 들어가면 자주 막히는 이유

입문 단계에서 보통 연습하는 범위는 좁다.

  • 화면에 값 출력하기
  • 반복문으로 데이터 훑기
  • 메서드 만들어 호출하기

이 범위는 결과가 눈에 바로 보여서 "이해했다"는 느낌을 준다. 하지만 실무 코드는 여러 객체가 서로를 참조하고, 외부 입력이 들어오고, 예외가 섞인다. 이때 필요한 것은 문법 암기가 아니라 "값이 어디에 저장되고 어떻게 전달되는가"라는 동작 모델이다.

이 모델이 없으면 같은 오류를 반복하면서 원인을 매번 처음부터 찾게 된다. 그래서 새 라이브러리를 익히기 전에 변수·객체·예외를 동작 기준으로 한 번 더 정리해 두는 편이 결과적으로 시간을 아낀다.

정리해 두면 좋은 이유가 하나 더 있다. 초급 단계의 버그는 대부분 "코드 자체가 틀린" 경우보다 "값이 예상과 다르게 전달된" 경우가 많다. 그래서 문법을 더 외우는 것보다, 지금 다루는 값이 실제 값인지 참조인지, 언제 복사되고 언제 공유되는지를 한 번 정리해 두면 같은 종류의 버그를 반복하지 않게 된다.

실무 전에 다시 정리하는 Java 핵심 개념

변수와 자료형, 참조라는 개념

Java의 자료형은 기본형과 참조형으로 나뉜다. int, double, boolean 같은 기본형은 변수에 실제 값이 들어간다. 반면 String, 배열, 직접 만든 클래스 같은 참조형은 변수에 객체의 값이 아니라 그 객체를 가리키는 참조가 들어간다.

여기서 헷갈리는 지점은 이 차이를 잡지 않으면 "복사했는데 원본이 같이 바뀐다"는 현상을 설명할 수 없다는 것이다. 이후에 나오는 참조 복사, == 비교, null 문제는 모두 이 구분에서 갈라진다.

실제 디버깅에서 이 차이가 드러나는 상황은 이렇다. 예를 들어 주문 정보를 담은 객체를 리스트에 담아 두고, 화면에 보여줄 목록을 만들려고 그 객체를 그대로 다른 리스트에 다시 담았다고 하자. 이후 화면용 리스트에서 상태 값을 "취소"로 바꿨는데, 원본 리스트의 주문 상태까지 "취소"로 바뀌어 있다. 두 리스트가 서로 다른 객체를 담은 것이 아니라 같은 객체를 가리키는 참조를 나눠 가졌기 때문이다.

또 다른 상황은 설정 값을 담은 객체를 여러 서비스가 공유할 때다. 한 서비스가 편의상 그 객체의 필드를 잠깐 바꿔 쓰고 되돌리지 않으면, 같은 객체를 참조하던 다른 서비스가 바뀐 값을 그대로 읽는다. 이때 "나는 A만 건드렸는데 왜 B가 바뀌지?"라는 질문이 나오면, 두 변수가 같은 객체를 가리키고 있는지부터 확인하는 것이 빠르다.

클래스와 인스턴스를 구분하기

클래스는 설계도이고, 인스턴스는 그 설계도로 실제로 메모리에 만든 객체다. 같은 클래스로 만든 두 인스턴스는 필드 값이 서로 독립적이다.

초급 단계에서 정적(static) 필드와 인스턴스 필드를 섞어 쓰면, 한 곳에서 바꾼 값이 다른 객체에도 영향을 주는 것처럼 보여 혼란이 생긴다. static은 클래스 단위로 공유되는 값이라는 점을 분명히 구분해야 한다.

이럴 때는 카운터나 공통 설정을 static으로 두었을 때 특히 조심한다. 인스턴스마다 따로 세야 할 값을 static으로 선언하면, 객체를 여러 개 만들어도 값이 하나로 합쳐져서 계산이 어긋난다. "인스턴스별로 독립적이어야 하는 값인가, 클래스 전체가 공유해야 하는 값인가"를 먼저 정하면 이런 실수를 줄일 수 있다.

JVM이 코드를 실행하는 흐름

작성한 .java 파일은 컴파일러를 거쳐 바이트코드(.class)가 되고, JVM이 이를 읽어 실행한다. 세 가지를 이렇게 구분하면 된다.

  • JDK: 개발에 필요한 도구 모음(컴파일러 포함)
  • JRE: 작성된 프로그램을 돌리는 실행 환경
  • JVM: 바이트코드를 실제로 실행하는 가상 머신

이럴 때는 "내 PC에서는 되는데 서버에서는 안 된다"는 상황을 만나면, 가장 먼저 양쪽의 Java 버전과 실행 환경이 같은지부터 확인한다. 컴파일에 쓴 버전과 실행 환경의 버전이 다르면, 최신 문법으로 작성한 코드가 서버에서 실행되지 않거나 예상과 다르게 동작할 수 있다. 참고로 화면 단에서 정적 리소스가 안 잡히는 문제는 환경이 아니라 경로 설정이 원인인 경우가 많은데, 이런 정적 리소스 404 상황은 별도의 점검 포인트가 있다.

여기까지가 동작 모델의 뼈대다. 값이 어디 담기는지(기본형/참조형), 누가 그 값을 들고 있는지(인스턴스/static), 어디서 실행되는지(JVM 환경) 세 가지를 잡으면 다음 단계의 오류가 한결 읽기 쉬워진다.

초급 개발자가 자주 헷갈리는 부분

기본형과 참조형을 섞어서 생기는 혼동

참조형 변수를 다른 변수에 대입하면 객체가 복사되는 것이 아니라 같은 객체를 가리키는 참조만 복사된다. 그래서 한쪽에서 필드를 바꾸면 다른 쪽에서도 바뀐 값이 보인다. 리스트나 배열을 메서드에 넘긴 뒤 내부에서 수정했는데 호출한 쪽 데이터까지 변하는 상황도 같은 원리다.

이 혼동은 메서드 인자에서 특히 자주 나온다. 검증용으로 리스트를 넘겼는데 메서드 안에서 정렬하거나 일부 요소를 지우면, 호출한 쪽의 원본 리스트도 그대로 바뀐다. "메서드에 넘겼으니 복사본이 넘어갔겠지"라고 생각하면 원인을 놓치기 쉽다. 넘어간 것은 리스트 자체가 아니라 그 리스트를 가리키는 참조다.

== 와 equals()의 차이

참조형에서 ==는 두 변수가 같은 객체를 가리키는지를 본다. 값(내용)이 같은지는 equals()로 비교한다. 다음 비교가 왜 다르게 나오는지 보면 차이가 분명해진다.

String a = new String("Java");
String b = new String("Java");

System.out.println(a == b);      // false (서로 다른 객체)
System.out.println(a.equals(b)); // true  (내용이 같음)

문자열 비교에서 ==를 썼다가 가끔 맞고 가끔 틀리는 이유가 여기에 있다. 문자열 리터럴은 내부적으로 공유될 수 있어 우연히 같은 참조가 될 때가 있기 때문이다. 내용 비교가 목적이면 항상 equals()를 쓰는 편이 안전하다.

실무에서 이 문제는 조건문 버그로 나타난다. 예를 들어 사용자가 입력한 값과 미리 정해진 문자열을 비교해 분기를 나누는 코드가 있다고 하자.

String input = request.getParameter("status"); // 예: "APPROVED"

// 잘못된 비교: 입력값이 새 String 객체라 참조가 다를 수 있음
if (input == "APPROVED") {
    approve();
}

// 안전한 비교: 내용을 비교
if ("APPROVED".equals(input)) {
    approve();
}

==로 비교하면 로컬 테스트에서는 우연히 통과하다가, 실제 요청 값이 들어오는 순간 분기가 타지 않아 "분명 값이 맞는데 승인이 안 된다"는 버그가 된다. 상수 문자열을 앞에 두고 "APPROVED".equals(input)처럼 쓰면 input이 null이어도 예외 없이 false로 처리되므로 더 안전하다.

NullPointerException이 자주 나오는 위치

참조형 변수의 기본값은 null이다. 값을 넣지 않은 객체의 메서드나 필드에 접근하면 NullPointerException이 발생한다.

먼저 볼 것은 "비어 있을 수 있는 값"이 어디서 들어왔는지다. 대체로 아래 순서로 좁히면 원인이 빠르게 잡힌다.

  1. 예외 메시지에 찍힌 줄 번호를 열어, 그 줄에서 .으로 접근한 참조가 무엇인지 확인한다.
  2. 그 참조가 외부 입력(요청 파라미터, 파일, DB 조회 결과)에서 왔는지 본다.
  3. 컬렉션에서 꺼낸 값(map.get(), 리스트 인덱스)이 없을 때 null이 되는 경우인지 확인한다.
  4. 메서드 반환값이 조건에 따라 null을 돌려줄 수 있는지 확인한다.

DB 조회 결과가 한 건도 없을 때 null이 반환되는 경우가 특히 흔하다. 조회 후 바로 필드에 접근하기 전에, 결과가 비어 있는 상황을 먼저 처리해 두면 같은 예외를 반복하지 않는다.

표로 보는 점검 항목

구분기본형(primitive)참조형(reference)
저장되는 것실제 값객체를 가리키는 참조
대표 예시int, double, booleanString, 배열, 사용자 정의 클래스
비교 방식== 로 값 비교equals()로 내용 비교
초기 기본값0, false 등null
대입(복사) 시값이 복사됨참조가 복사됨

코드로 확인하는 핵심 개념

참조 복사를 확인하는 예제

아래 코드는 참조형 대입이 객체를 복제하지 않는다는 점을 보여준다.

public class ReferenceTest {
    static class User {
        String name;
        User(String name) { this.name = name; }
    }

    public static void main(String[] args) {
        User a = new User("Kim");
        User b = a;          // 같은 객체를 참조
        b.name = "Lee";

        System.out.println(a.name); // Lee 출력
    }
}

b를 바꿨는데 a.name까지 "Lee"로 나온다. 두 변수가 같은 객체를 가리키고 있기 때문이다. 진짜 별도 복사가 필요하면 새 객체를 만들어 값을 옮기거나, 복사용 생성자 같은 방법을 직접 정의해야 한다. 객체를 데이터베이스 조회 결과로 채울 때도 매핑 방식에 따라 결과가 달라지는데, 이 부분은 resultMap과 resultType 매핑 방식을 비교해 보면 감이 잡힌다.

예외를 구분해서 처리하는 예제

입력값이 비어 있거나 형식이 잘못된 경우를 나눠서 다루면, 예외가 나도 호출하는 쪽이 흐름을 이어갈 수 있다.

public int parseCount(String value) {
    if (value == null) {
        return 0; // null 입력을 먼저 방어
    }
    try {
        return Integer.parseInt(value.trim());
    } catch (NumberFormatException e) {
        // 잘못된 형식은 의미 있는 기본값으로 처리
        return 0;
    }
}

모든 예외를 한 번에 잡기보다, 발생할 수 있는 예외를 구분해 처리 방향을 정하는 것이 실무에서 디버깅을 쉽게 만든다. 위 예제에서 null 입력과 형식 오류를 나눠 처리한 것처럼, 어떤 예외에 어떤 기본값이나 대응이 맞는지를 정해 두면 나중에 로그만 봐도 어디서 어떤 이유로 처리가 갈렸는지 추적하기 쉽다.

컬렉션을 순회하며 삭제할 때 주의할 점

리스트를 향상된 for문으로 돌면서 요소를 직접 지우면 ConcurrentModificationException이 발생할 수 있다. 순회 중 구조 변경을 감지하기 때문이다. 삭제가 목적이면 Iterator의 remove를 쓰거나 removeIf를 사용한다.

List<String> names = new ArrayList<>(List.of("Kim", "Lee", "Park"));

// 조건에 맞는 요소만 안전하게 제거
names.removeIf(name -> name.startsWith("L"));

System.out.println(names); // [Kim, Park]

이런 실수는 "리스트를 넘겨받아 필터링하는" 흔한 상황에서 자주 나온다. 여기서 한 번 더 판단할 것은 원본을 그대로 두어야 하는가이다. 다음 기준으로 나누면 된다.

  • 원본을 바꿔도 되는 경우: 넘겨받은 리스트가 이 메서드 안에서만 쓰이고 밖에서 다시 참조하지 않는다면 removeIf로 바로 걸러도 된다.
  • 원본을 보존해야 하는 경우: 호출한 쪽이 원본 리스트를 계속 사용하거나, 여러 곳에서 같은 리스트를 공유한다면 원본을 건드리면 안 된다. 이때는 조건에 맞는 요소만 새 리스트에 담아 반환한다.
// 원본을 보존하고 필터링 결과만 새 리스트로 반환
List<String> filtered = names.stream()
        .filter(name -> !name.startsWith("L"))
        .collect(Collectors.toList());

"내가 받은 리스트가 남의 데이터인가, 내 작업용 복사본인가"를 먼저 구분하면 원본이 함께 바뀌는 사고를 막을 수 있다.

실무 프레임워크로 넘어갈 때 확인 순서

기초 개념이 정리되면 보통 Spring과 MyBatis 같은 프레임워크로 넘어간다. 이 단계에서는 Java 문법 자체보다 설정과 연결에서 막히는 경우가 많다. 그래서 오류를 만났을 때 이게 Java 코드 문제인지 설정 문제인지부터 구분하는 것이 시간을 크게 줄인다.

둘을 가르는 기준은 대체로 이렇다.

  • 컴파일 단계에서 막히면 대개 Java 코드 문제다. 타입이 안 맞거나, 메서드 시그니처가 틀렸거나, import가 빠진 경우가 여기에 해당한다.
  • 컴파일은 되는데 실행 시점에 터지면 설정이나 연결 문제일 가능성이 크다. Bean을 못 찾거나, 데이터소스 연결이 안 되거나, 경로 설정이 어긋난 경우가 대표적이다.

먼저 볼 것은 코드가 아니라 설정이다. 의존성과 기본 설정이 제대로 잡혔는지부터 보는 편이 빠른데, 전체 흐름은 Spring·MyBatis 연동 설정에서 한 번 정리해 두면 도움이 된다.

여기서 헷갈리는 지점은 매퍼 인터페이스가 Bean으로 등록되지 않아 주입이 안 되는 상황이다. 코드는 분명히 맞는데 실행 시점에 "해당 타입의 Bean을 찾을 수 없다"는 메시지가 나온다면, 이건 문법 오류가 아니라 스캔 범위 설정 문제다. 이럴 때는 MapperScan 설정으로 매퍼가 위치한 패키지가 스캔 대상에 들어가는지부터 점검한다. 코드를 고치기 전에 설정을 의심하는 순서가 시간을 줄여준다.

실무에서 바로 쓰는 점검 팁

  • 예외 메시지를 끝까지 읽는다. 스택 트레이스 맨 위 줄과 내 코드가 나오는 첫 줄을 함께 보면 어떤 참조가 비었는지 빠르게 좁혀진다.
  • 비어 있을 수 있는 값은 들어오자마자 방어한다. 메서드 시작부에서 null 여부를 확인하면 같은 검사를 코드 곳곳에 흩뿌리지 않아도 된다.
  • 참조형을 복사할 때 "값 복사인지 참조 복사인지" 한 번 멈춰 확인한다. 컬렉션을 넘길 때 특히 원본이 함께 바뀌는 실수를 줄일 수 있다.
  • 버전 의존적인 동작은 환경을 함께 기록한다. 같은 코드가 다른 결과를 낼 때 Java 버전과 실행 환경을 먼저 비교한다.
  • 오류가 나면 코드보다 설정을 먼저 의심한다. 컴파일은 되는데 실행에서 터지는 오류라면, 코드를 뜯어보기 전에 의존성·스캔 범위·연결 설정부터 확인하는 편이 빠르다.

데이터를 다루는 단계로 더 들어가면 대용량 처리 흐름도 마주친다. 자바 코드로 조건을 걸어 집계하는 방식은 자바 MapReduce 집계 예제에서 날짜·도시 필터와 사용자 합계를 다룬 예제로 확인할 수 있다.

자주 묻는 질문

  • Q. Java에서 기본형과 참조형은 어떻게 구분하나요? A. 변수에 실제 값이 들어가면 기본형, 객체를 가리키는 참조가 들어가면 참조형입니다. int·double·boolean 등은 기본형, String·배열·사용자 정의 클래스는 참조형입니다. 대입할 때 값이 복사되면 기본형, 참조가 복사되면 참조형이라고 기억하면 헷갈리지 않습니다.
  • Q. == 와 equals()는 언제 다르게 동작하나요? A. 참조형에서 ==는 같은 객체인지를, equals()는 내용이 같은지를 비교합니다. 문자열이나 사용자 값 비교처럼 내용이 목적이면 equals()를 쓰고, null 위험이 있으면 "상수".equals(변수) 형태로 상수를 앞에 두는 것이 안전합니다.
  • Q. JVM, JDK, JRE는 무엇이 다른가요? A. JDK는 컴파일러를 포함한 개발 도구 모음, JRE는 프로그램을 돌리는 실행 환경, JVM은 바이트코드를 실제로 실행하는 가상 머신입니다. 개발에는 JDK가 필요하고, "PC에서는 되는데 서버에서 안 된다"면 양쪽 Java 버전부터 비교합니다.
  • Q. 객체지향에서 클래스와 인스턴스는 어떻게 다른가요? A. 클래스는 설계도, 인스턴스는 그 설계도로 만든 실제 객체입니다. 같은 클래스로 만든 인스턴스들은 필드 값이 서로 독립적입니다. 다만 static 필드는 클래스 단위로 공유되므로, 인스턴스마다 따로 관리해야 할 값을 static으로 두지 않도록 주의합니다.
  • Q. 자바에서 String은 왜 불변이라고 하나요? A. String 객체는 한 번 만들어지면 내용이 바뀌지 않고, 수정처럼 보이는 연산은 새 객체를 만듭니다. 그래서 반복문 안에서 문자열을 계속 이어 붙이면 객체가 계속 새로 생기므로, 변경이 잦으면 StringBuilder를 쓰는 편이 좋습니다.
  • Q. 컬렉션을 순회하며 요소를 지우면 왜 오류가 나나요? A. 향상된 for문으로 돌면서 직접 remove를 호출하면 순회 중 구조 변경이 감지되어 ConcurrentModificationException이 날 수 있습니다. 삭제가 목적이면 removeIf나 Iterator의 remove를 쓰고, 원본을 보존해야 하면 조건에 맞는 요소만 새 리스트에 담아 반환합니다.

정리

실무에서 막히는 기초 오류는 대부분 "값이 어디에 저장되고 어떻게 전달되는가"를 정리하면 원인이 드러난다. 변수와 참조, 객체와 인스턴스, 예외 처리 흐름을 동작 기준으로 잡아두면 Spring·MyBatis 단계로 넘어가도 설정 오류와 코드 오류를 구분해 접근할 수 있다.

새 기능을 더 외우기보다, 이미 아는 개념을 동작 단위로 확인하는 순서를 손에 익히는 편이 더 오래 도움이 된다.
반응형