◆ TURNING · DB

카멜 ↔ 스네이크 함정

데이터가 null 이 되는 가장 흔한 이유

학습 목표

  • DB 와 자바의 이름 규칙 차이 를 안다
  • 매핑 실패가 일어나는 정확한 메커니즘을 안다
  • 2 가지 해법 (AS / mapUnderscoreToCamelCase) 을 안다
  • 앞으로 같은 함정을 만났을 때 3 초 만에 진단할 수 있다

⚠️ 가장 답답한 증상

학생들의 외침

"SELECT 는 분명히 성공했어요. 콘솔에 SQL 도 정상으로 보이고요. 근데 객체에 데이터가 다 null 이에요!"

이 증상은 본 과정에서 가장 자주, 가장 오래 학생을 헤매게 하는 함정입니다. 한 번 만나면 평생 기억합니다.

🛠️ 두 세계의 이름 규칙

DB 와 자바는 다른 컨벤션을 따른다

DB 는 스네이크 케이스 (snake_case), 자바는 카멜 케이스 (camelCase) 를 씁니다. 둘이 자동으로 매핑되지 않아서 이 함정이 생깁니다.

DB 컬럼명 (스네이크): user_id created_at is_active ↕ ↕↕ 자동 매핑 안 됨 자바 필드명 (카멜): userId createdAt isActive

왜 이름 규칙이 다른가

규칙이유사용처
스네이크 케이스
user_id
SQL 은 대소문자 구별 안 함. 단어 구분에 _ 가 자연스러움DB 컬럼·테이블, Python, Ruby
카멜 케이스
userId
자바·자바스크립트의 표준 컨벤션자바 변수·메서드

👉 두 진영이 각자 자기 컨벤션을 따라간 결과. 둘 다 옳음.

함정의 메커니즘 — 왜 null 이 되나

1) DB 에 데이터: sample 테이블의 한 행 ┌────┬─────────┬───────────┐ │ id │ user_id │ created_at│ ├────┼─────────┼───────────┤ │ 1 │ "hong" │ 2024-... │ └────┴─────────┴───────────┘ 2) MyBatis SELECT 결과: Map { "id"=1, "user_id"="hong", "created_at"=... } ↑ MyBatis 가 컬럼명 그대로 받음 3) Sample 객체에 매핑: MyBatis 가 setUserId() 호출하려고 보니 → "user_id" 라는 키를 가지고 있음 → setUser_id() 메서드가 없음 (자바엔 setUserId) → 매핑 실패 → 필드는 초기값(null) 그대로 결과: sample.getUserId() == null

증상 재현 — DB 와 VO 정의 (일반 예시)


-- 일반 예시 (어떤 테이블이든 _ 가 들어간 컬럼이면 같은 함정)
CREATE TABLE sample (
    id          INT,
    user_id     VARCHAR(50),
    created_at  TIMESTAMP
);
INSERT INTO sample VALUES (1, 'hong', NOW());

// VO
public class Sample {
    private int id;
    private String userId;            // 카멜
    private LocalDateTime createdAt;  // 카멜
}

// Mapper XML
<select id="selectOne" resultType="Sample">
    SELECT * FROM sample WHERE id = #{id}
</select>

증상 재현 — 호출 결과


Sample s = mapper.selectOne(1);
System.out.println(s.getId());          // 1     ✓
System.out.println(s.getUserId());      // null  ✗
System.out.println(s.getCreatedAt());   // null  ✗

같은 SELECT 한 줄인데, id 만 정상이고 나머지는 모두 null. 다음 슬라이드에서 그 이유를 풀어봅니다.

왜 id 만 정상인가?

위 코드에서 id 만 정상으로 매핑된 이유:

  • DB 컬럼: id
  • 자바 필드: id
  • setter: setId(int)
  • 이름이 똑같으면 매핑 성공 — _ 가 없어서 변환 필요 없음

_ 가 들어간 컬럼만 함정에 빠집니다.

해법 ① — 글로벌 옵션 (권장)


<!-- mybatis-config.xml -->
<configuration>
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true" />
    </settings>
</configuration>

👉 한 번만 켜두면 — 이후 모든 매핑에서 user_iduserId 가 자동 변환.

변환 규칙: _ 다음 글자를 대문자로. 첫 단어는 소문자.


user_id        → userId
created_at     → createdAt
is_active      → isActive
last_login_at  → lastLoginAt
USER_NAME      → userName    (대소문자 구분 없이)

해법 ② — SELECT 에서 AS 로 별칭


<select id="selectOne" resultType="Sample">
    SELECT
        id,
        user_id    AS userId,
        created_at AS createdAt
    FROM sample
    WHERE id = #{id}
</select>

👉 SQL 자체에서 컬럼명을 자바 카멜로 변경.

단점

매 SELECT 에서 반복 작성. 컬럼이 많을수록 길어짐. 해법 ① 이 더 깔끔.

해법 ③ — resultMap (고급, 참고)


<resultMap id="sampleMap" type="Sample">
    <id     property="id"        column="id" />
    <result property="userId"    column="user_id" />
    <result property="createdAt" column="created_at" />
</resultMap>

<select id="selectOne" resultMap="sampleMap">
    SELECT * FROM sample WHERE id = #{id}
</select>

👉 명시적 매핑. 복잡한 객체(중첩, 컬렉션) 매핑 시 필수. 본 과정에선 해법 ① 만 쓰면 충분.

3 가지 해법 비교

해법위치장점단점
① 글로벌 옵션mybatis-config.xml한 번만 설정. 모든 SELECT 자동명시적이지 않음
② AS 별칭각 SELECT명시적. 한눈에 보임반복 작성
③ resultMap각 mapper완전 통제. 복잡 매핑장황

👉 본 과정 권장: 해법 ① + 필요 시 ③ 조합.

실험 — 함정 직접 체험

  1. mybatis-config.xml 의 mapUnderscoreToCamelCasefalse 로 (또는 삭제)
  2. SELECT 실행 → 모든 _ 들어간 필드가 null 확인
  3. 콘솔의 SQL 로그를 보면 — SELECT 는 정상 (혼란 가중)
  4. 옵션 다시 true → 즉시 정상

👉 한 번 깨뜨리고 고쳐보면 평생 안 잊습니다.

비슷한 함정 — INSERT 의 반대 방향


<insert id="insert">
    INSERT INTO sample(user_id, created_at)
    VALUES(#{userId}, #{createdAt})
</insert>

INSERT 의 #{userId} 는 자바 객체의 getter(getUserId()) 를 호출. 이 방향은 자바 카멜이 그대로.

SELECT 만 매핑 함정이고, INSERT 는 자바 → DB 방향이라 다른 규칙.

이 함정을 만났을 때의 진단 알고리즘

증상: 객체 필드가 null ① SELECT 가 실행됐는가? → SQL 로그 확인 → 안 됐으면 → 다른 문제 ② SQL 결과에 데이터 있는가? → DB 도구로 같은 SQL 직접 실행 → 결과 없음 → WHERE 조건 의심 → 결과 있음 → 다음 단계로 ③ 컬럼명에 _ 가 있는가? → 있으면 → 카멜·스네이크 함정 의심! → mapUnderscoreToCamelCase 옵션 확인 ④ 자바 필드명·setter 가 정확한가? → IDE 의 「Generate getter/setter」 다시 실행 대부분 ③ 에서 끝남

다른 ORM 의 같은 문제 — 같은 해법

도구옵션
MyBatismapUnderscoreToCamelCase=true
JPA / HibernateSpringPhysicalNamingStrategy (기본값)
Spring Data JDBCNamingStrategy 빈 등록
Jackson (JSON)@JsonNaming(SnakeCaseStrategy.class)

👉 JS / Kotlin / Python 어떤 언어로 가도 같은 함정. 같은 발상의 해법.

본 과정의 컨벤션 — 권장 사항

  • DB: 스네이크 케이스 통일 (user_id, created_at)
  • 자바: 카멜 케이스 통일 (userId, createdAt)
  • MyBatis: mapUnderscoreToCamelCase=true 항상 켜기
  • SELECT 에 별칭(AS) 가급적 안 씀 — 글로벌 옵션이 처리
  • resultMap 은 복잡한 JOIN 결과 매핑 시에만

예외 — 매핑되지 않는 컬럼

옵션 켜도 매핑 안 되는 케이스
  • VO 에 해당 필드가 없음 — setter 없으면 매핑 자체 불가
  • setter 시그니처 다름 — setUserId(Long) 인데 컬럼은 String
  • 대문자만 있는 컬럼 — USER_IDuserId 로 변환 (대부분 OK)
  • 특이한 글자 — user-id (하이픈) 은 변환 안 됨

🔄 Before / After

전 차시 끝

Mapper XML 의 SELECT 가 동작은 한다. 가끔 null 이 나오는 이유는 모름.

이번 차시 끝

DB ↔ 자바의 이름 규칙 차이를 알고, 객체 필드가 null 일 때 3 초 만에 진단한다.

📊 한 그림 정리

이번 차시의 데이터 흐름

DB
컬럼명 (스네이크)
MyBatis 자동 변환
자바 필드 (카멜)
VO 객체
이름 규칙 변환이라는 「투명한 다리」가 생김 — 옵션이 그 다리를 만든다

정리

오늘 들고 가는 것

  • DB 스네이크 ↔ 자바 카멜 의 규칙 차이
  • 객체 필드가 null 일 때 3 초 진단: 컬럼에 _ 있는가? → 옵션 확인
  • 해법 ① mapUnderscoreToCamelCase=true (권장)
  • 해법 ② AS 별칭, 해법 ③ resultMap (필요 시)
  • 같은 발상이 JPA·JSON 등 다른 도구에도 적용

다음: ◆ SQL 인젝션 직접 시연 — #{} vs ${}.