
네이버 OAuth 로그인을 구현하던 중 인증 자체는 정상적으로 끝났는데, 신규 사용자를 저장하는 과정에서 INSERT가 실패했습니다.
처음에는 OAuth 처리나 OTP 로직을 의심했지만, 실제 원인은 훨씬 단순했습니다.
Boolean 필드의 null 값이 NOT NULL 컬럼까지 그대로 전달되고 있었습니다.
이를 해결하면서 매퍼에 다음 코드가 들어갔습니다.
.otpVerified(boolOr(user.getOtpVerified(), false))
그리고 boolOr 자체는 아주 단순합니다.
private static boolean boolOr(Boolean value, boolean fallback) {
return value != null ? value : fallback;
}
겉으로 보면 단순한 null 처리입니다.
하지만 여기서 중요한 것은 Boolean을 boolean으로 바꿨다는 사실보다,
값이 없을 때 왜
false를 선택했는가
였습니다.
결국 이 한 줄은 null-safety 처리이면서 동시에 도메인 규칙과 보안 정책을 저장 경계에 명시한 코드가 되었습니다.
Boolean과 boolean은 null을 다루는 방식이 다릅니다Java에는 비슷해 보이지만 성격이 다른 두 타입이 있습니다.
boolean
Boolean
primitive 타입인 boolean은 두 가지 상태만 가질 수 있습니다.
true
false
반면 Wrapper 타입인 Boolean은 세 가지 상태를 가질 수 있습니다.
true
false
null
문제는 이 세 번째 상태인 null입니다.
예를 들어 JPA Entity에 다음과 같은 필드가 있다고 가정하겠습니다.
@Column(name = "otp_verified", nullable = false)
@Builder.Default
private Boolean otpVerified = false;
코드만 보면 기본값이 false이므로 문제가 없어 보입니다.
하지만 실제 애플리케이션에서는 값이 여러 경로를 통해 만들어집니다.
예를 들어 다음과 같은 상황이 있을 수 있습니다.
null을 전달한 경우특히 마지막 경우가 중요합니다.
UserJpaEntity.builder()
.otpVerified(user.getOtpVerified())
.build();
여기서
user.getOtpVerified()
가 null이라면 @Builder.Default가 있다고 해도 false가 들어가지 않습니다.
Builder에 값을 명시적으로 전달했기 때문입니다.
결과적으로 Hibernate까지 null이 전달될 수 있습니다.
DB 컬럼이 NOT NULL이라면 INSERT 과정에서 실패하게 됩니다.
예를 들어 명시적으로 NULL을 넣으려고 하면 MySQL에서는 다음과 같은 오류가 발생할 수 있습니다.
Column 'otp_verified' cannot be null
반대로 기본값이 없는 NOT NULL 컬럼 자체를 INSERT 대상에서 빼버린 경우에는 다음과 같은 오류가 발생할 수 있습니다.
Field 'otp_verified' doesn't have a default value
둘은 비슷해 보이지만 원인은 조금 다릅니다.
핵심은 같습니다.
애플리케이션과 DB 사이에서 해당 필드의 상태 계약이 일치하지 않았습니다.
boolOr를 추가했습니다해결 방법은 어렵지 않았습니다.
private static boolean boolOr(Boolean value, boolean fallback) {
return value != null ? value : fallback;
}
그리고 Domain 객체를 JPA Entity로 변환하는 시점에 사용했습니다.
.otpEnabled(boolOr(user.getOtpEnabled(), false))
.otpVerified(boolOr(user.getOtpVerified(), false))
동작은 단순합니다.
true → true
false → false
null → fallback
예를 들어 다음 코드는
boolOr(user.getOtpVerified(), false)
다음 의미가 됩니다.
OTP 인증 여부가 명확하게 존재하면 기존 값을 사용하고, 상태가 정의되지 않았다면 미인증 상태로 처리합니다.
중요한 점은 이미 true인 값을 무조건 false로 바꾸는 코드가 아니라는 것입니다.
value != null ? value : fallback
즉 값이 존재하지 않는 경우에만 정책을 적용합니다.
처음에는 여러 방법을 고민했습니다.
예를 들어 신규 회원 생성 시점에 다음과 같이 넣을 수 있습니다.
.otpEnabled(false)
.otpVerified(false)
실제로 SNS 신규 가입 로직에서는 이렇게 초기값을 지정하고 있습니다.
문제는 저장 로직이 신규 가입에서만 사용되는 것이 아니라는 점입니다.
같은 Mapper가 이후에 다음 상황에서도 사용될 수 있습니다.
한 생성 경로만 수정하면 다른 경로에서 다시 같은 문제가 발생할 수 있습니다.
DEFAULT에 맡기기DB 컬럼에 기본값을 둘 수도 있습니다.
ALTER TABLE users
MODIFY otp_verified BOOLEAN NOT NULL DEFAULT FALSE;
DB 기본값은 좋은 최후의 안전망입니다.
하지만 애플리케이션에서 다음과 같이 명시적으로 NULL을 보내면 이야기가 달라집니다.
INSERT INTO users (
otp_verified
) VALUES (
NULL
);
이 경우 DB의 DEFAULT FALSE는 사용되지 않습니다.
DEFAULT는 기본적으로 해당 값을 지정하지 않았을 때 사용할 값이지, 명시적인 NULL을 자동으로 false로 변환하는 기능이 아닙니다.
따라서 DB DEFAULT만 믿는 것으로는 충분하지 않았습니다.
FinSight에서는 Domain 모델과 JPA Entity를 분리하고 있습니다.
User
↓
UserJpaMapper
↓
UserJpaEntity
↓
Database
여기에서 각 계층의 계약이 조금 다릅니다.
Domain에서는 상황에 따라 아직 정해지지 않은 상태를 null로 표현할 수 있지만,
DB에서는
otp_verified NOT NULL
이라는 훨씬 강한 계약을 가지고 있습니다.
그렇다면 Domain의 값을 Persistence 규칙에 맞게 변환하는 책임은 어디에 두는 것이 자연스러울까요?
저는 그 지점을 Mapper로 판단했습니다.
Domain
Boolean(true / false / null)
↓
Mapper
↓
Persistence
boolean(true / false)
Mapper는 단순히 필드 이름을 복사하는 코드가 아니라,
서로 다른 두 모델의 계약을 번역하는 경계
라고 생각했기 때문입니다.
false를 선택한 이유였습니다다음 코드에서 중요한 것은 boolOr 자체가 아닙니다.
boolOr(user.getOtpVerified(), false)
정말 중요한 부분은 두 번째 인자인
false
입니다.
왜 true가 아니라 false일까요?
먼저 두 필드의 의미를 분리할 필요가 있습니다.
otpEnabled
→ 사용자가 OTP 기반 2FA를 사용하도록 설정했는가?
otpVerified
→ 사용자가 OTP 코드를 실제로 입력하고 검증을 완료했는가?
네이버 OAuth로 처음 가입한 사용자를 생각해 보겠습니다.
아직 사용자는 OTP를 설정하지 않았습니다.
따라서
otpEnabled = false
가 맞습니다.
OTP 코드를 입력하고 검증한 적도 없습니다.
따라서
otpVerified = false
가 맞습니다.
반대로 값이 없다는 이유로 다음과 같이 처리한다면 문제가 생깁니다.
.otpVerified(boolOr(user.getOtpVerified(), true))
이 코드는 사실상 다음과 같은 의미가 됩니다.
OTP 검증 여부를 알 수 없으면 검증을 완료했다고 간주합니다.
보안 상태에서는 상당히 위험한 판단입니다.
otpVerified는 일반적인 UI 옵션과 조금 다릅니다.
사용자의 특정 행동이 완료되었다는 사실을 나타냅니다.
OTP 등록
↓
QR 또는 Secret 생성
↓
사용자가 OTP 코드 입력
↓
서버가 코드 검증
↓
otpVerified = true
즉 true가 되려면 검증을 완료했다는 명확한 사건이 존재해야 합니다.
그 사건이 없거나 상태를 알 수 없다면
null → true
가 아니라
null → false
로 처리하는 것이 자연스럽습니다.
이를 코드로 표현하면 다음과 같습니다.
.otpVerified(boolOr(user.getOtpVerified(), false))
이것은 단순히 DB 오류를 막기 위한 임시 처리라기보다,
검증되었다는 증거가 없다면 검증되지 않은 것으로 판단한다
라는 인증 도메인의 규칙입니다.
보안 분야에서 이야기하는 Fail-Safe Default와도 비슷한 방향입니다.
다만 모든 보안 플래그를 무조건 false로 만들면 된다는 뜻은 아닙니다.
예를 들어
accessAllowed
accountLocked
permissionGranted
authenticationSucceeded
같은 값은 각각 의미가 다릅니다.
중요한 것은 true/false 중 어느 쪽이 기술적으로 편한지가 아니라,
불확실한 상태에서 어느 값을 선택해야 권한이나 인증을 과도하게 부여하지 않는가
입니다.
@Builder.Default가 있는데 왜 동작하지 않았을까요?Entity에는 이미 다음 코드가 있었습니다.
@Builder.Default
private Boolean otpVerified = false;
처음에는 이것만으로 충분하다고 생각하기 쉽습니다.
예를 들어 다음 코드에서는 정상적으로 기본값이 적용됩니다.
UserJpaEntity.builder()
.email(email)
.build();
otpVerified() 자체를 호출하지 않았기 때문에
false
가 기본값으로 사용됩니다.
하지만 다음 코드는 다릅니다.
UserJpaEntity.builder()
.email(email)
.otpVerified(user.getOtpVerified())
.build();
그리고
user.getOtpVerified()
가 null이라면 Builder 입장에서는 이미 값이 전달된 것입니다.
.otpVerified(null)
을 호출한 것과 같습니다.
따라서 @Builder.Default는 개입하지 않습니다.
이 차이가 중요합니다.
값 자체를 전달하지 않음
→ @Builder.Default 적용
null을 명시적으로 전달
→ null 저장
그래서 Mapper에서 한 번 더 정규화했습니다.
.otpVerified(
boolOr(user.getOtpVerified(), false)
)
이제 Entity Builder에는 절대로 null이 전달되지 않습니다.
처음에는 다음과 같은 형태로 작성할 수도 있습니다.
.otpVerified(
user.getOtpVerified() != null
? user.getOtpVerified()
: false
)
한두 개라면 큰 문제가 없습니다.
그런데 Boolean 옵션이 늘어나면 코드가 반복됩니다.
user.getOtpEnabled() != null
? user.getOtpEnabled()
: false
user.getOtpVerified() != null
? user.getOtpVerified()
: false
user.getPushNotificationEnabled() != null
? user.getPushNotificationEnabled()
: true
여기서 실제로 중요한 정보는 null 체크 문법이 아닙니다.
각 필드가 어떤 fallback 정책을 가지고 있느냐입니다.
그래서 다음처럼 바꿨습니다.
.otpEnabled(
boolOr(user.getOtpEnabled(), false)
)
.otpVerified(
boolOr(user.getOtpVerified(), false)
)
.pushNotificationEnabled(
boolOr(user.getPushNotificationEnabled(), true)
)
코드를 읽는 사람에게도 의도가 더 잘 보입니다.
OTP 활성화 → 기본 OFF
OTP 검증 → 기본 OFF
Push 알림 → 프로젝트 정책상 기본 ON
같은 Boolean 타입이지만 기본값은 서로 다릅니다.
왜냐하면 기본값은 Java 타입이 결정하는 것이 아니라 해당 기능의 도메인 규칙이 결정하기 때문입니다.
false여야 하는 것은 아닙니다프로젝트에서는 다음과 같이 각각 다른 정책을 사용할 수 있습니다.
| 필드 | fallback | 의미 |
|---|---|---|
otpEnabled | false | 사용자가 OTP 사용을 설정하기 전에는 비활성화 |
otpVerified | false | 실제 검증 전에는 미검증 |
smsNotificationEnabled | false | SMS 발송 정책상 기본 비활성화 |
emailNotificationEnabled | 프로젝트 정책에 따라 결정 | 동의 정책과 서비스 요구사항 확인 필요 |
pushNotificationEnabled | 프로젝트 정책에 따라 결정 | 서비스 정책과 사용자 동의 방식에 따라 결정 |
특히 알림 기능은 개인정보·마케팅 수신 동의 정책과 연결될 수 있기 때문에 단순히 기술적으로 true를 기본값으로 정해서는 안 됩니다.
결국 다음 코드는
boolOr(value, fallback)
단순한 null 처리 함수처럼 보이지만,
실제로 중요한 것은 항상
fallback
입니다.
이번 문제를 해결하면서 애플리케이션 한 곳만 수정하지 않았습니다.
신규 SNS 회원에게 의미가 명확한 초기값을 줍니다.
.otpEnabled(false)
.otpVerified(false)
Persistence Layer로 넘어가기 전에 null을 제거합니다.
.otpEnabled(boolOr(user.getOtpEnabled(), false))
.otpVerified(boolOr(user.getOtpVerified(), false))
Flyway Migration을 통해 DB에도 기본값과 NOT NULL 제약을 유지합니다.
ALTER TABLE users
MODIFY otp_enabled BOOLEAN NOT NULL DEFAULT FALSE,
MODIFY otp_verified BOOLEAN NOT NULL DEFAULT FALSE;
결과적으로 구조는 다음과 같습니다.
Domain 생성 규칙
↓
Mapper 정규화
↓
Entity 제약
↓
Database NOT NULL + DEFAULT
한 군데에서 모든 책임을 떠안는 대신 각 경계마다 자신이 지켜야 할 계약을 두었습니다.
DB DEFAULT는 애플리케이션 로직을 대신하지 않습니다.
Mapper 역시 DB Constraint를 대신하지 않습니다.
각각 다른 종류의 실수를 막습니다.
이런 코드를 추가했다면 단순히 INSERT가 성공하는지만 확인하는 것으로 끝내기보다 도메인 규칙을 테스트하는 편이 좋습니다.
예를 들면 다음과 같습니다.
@Test
void otpVerified가_null이면_false로_매핑한다() {
User user = createUser();
user.setOtpVerified(null);
UserJpaEntity entity = mapper.toEntity(user);
assertThat(entity.getOtpVerified()).isFalse();
}
기존 값이 보존되는지도 확인할 수 있습니다.
@Test
void otpVerified가_true이면_true를_유지한다() {
User user = createUser();
user.setOtpVerified(true);
UserJpaEntity entity = mapper.toEntity(user);
assertThat(entity.getOtpVerified()).isTrue();
}
이 두 테스트가 함께 있어야
null → false
true → true
false → false
라는 Mapper의 계약이 명확해집니다.
boolOr의 구현 자체를 테스트하는 것보다 OTP 상태가 어떻게 변환되는지를 테스트하는 것이 더 의미 있습니다.
처음 문제만 보면 단순한 SQLException이었습니다.
Boolean이 null이었다.
그래서 다음과 같은 한 줄을 추가했습니다.
.otpVerified(boolOr(user.getOtpVerified(), false))
하지만 실제로 고민해야 했던 문제는 조금 더 깊었습니다.
null이 들어올 수 있는가?null을 허용하면 안 되는가?null을 만났을 때 왜 false여야 하는가?결국 이번에 사용한 boolOr는 단순한 null-safety 유틸리티가 아닙니다.
private static boolean boolOr(Boolean value, boolean fallback) {
return value != null ? value : fallback;
}
Domain과 Persistence 사이의 불일치를 닫고,
.otpEnabled(boolOr(user.getOtpEnabled(), false))
.otpVerified(boolOr(user.getOtpVerified(), false))
.pushNotificationEnabled(
boolOr(user.getPushNotificationEnabled(), true)
)
각 필드의 기본 정책을 코드에 명시하는 역할을 합니다.
특히 인증 상태에서는
검증되었는지 모르겠다
와
검증되었다
를 같은 상태로 취급해서는 안 됩니다.
otpVerified가 true가 되려면 실제 OTP 검증이라는 사건이 존재해야 합니다.
그 증거가 없다면 false입니다.
결국 Boolean의 기본값을 정하는 문제는 단순히 true와 false 중 하나를 고르는 문제가 아니었습니다.
불확실한 상태를 만났을 때 시스템이 어떤 의미를 선택해야 하는가를 결정하는 도메인 설계의 문제였습니다.
그리고 OTP에서는 그 한 줄이 꽤 중요했습니다.
boolOr(user.getOtpVerified(), false)
같은 함수라도 두 번째 인자 하나가 시스템의 정책을 결정합니다.