외식 상품권 종류별로 따져보니 이 브랜드가 가장 실용적이었습니다

이미지
지난주 친구 결혼식 답례품으로 고민하다가, 문득 깨달은 게 있어요. 외식 상품권 하나 사려고 해도 종류가 너무 많다는 사실이었죠. 현대백화점, 신세계, 이마트... 각자 말하는 게 다 달라서 어떤 걸 골라야 할지 막막하더라고요. 게다가 모바일 상품권이랑 지류 상품권, 브랜드 기프트 카드까지... 이걸 다 비교해서 선택하는 게 보통 일이 아니에요. 그래서 직접 조사해봤습니다. 외식 상품권 시장의 실태부터 각 브랜드의 숨은 장단점까지, 실제 사용자 후기와 함께 깔끔하게 정리해볼게요. 끝까지 읽으시면 지갑 사정에 딱 맞는 상품권을 고르실 수 있을 거예요. 왜 외식 상품권이 주목받고 있을까 요즘 물가가 워낙 오르다 보니, 외식 한 번 하는 게 부담스러울 때가 많죠. 통계청 자료에 따르면 2024년 3분기 기준 외식 물가 상승률은 전년 대비 4.2%를 기록했어요. 김밥 한 줄이 4천 원을 넘어가고, 냉면 한 그릇에 만 원이 기본인 시대잖아요. 이런 상황에서 외식 상품권이 주목받는 건 당연해요. 실제로 2023년 국내 상품권 시장 규모는 약 12조 원으로 추정되는데, 이 중 외식 관련 상품권이 차지하는 비중이 점점 늘고 있대요. 특히 MZ세대 사이에서는 현금 대신 상품권을 선물하는 게 하나의 트렌드로 자리 잡았죠. 상품권 종류 2023년 판매액(추정) 전년 대비 증감률 주요 구매 연령대 모바일 외식 상품권 3조 2천억 원 +15.8% 25-39세 지류 외식 상품권 2조 1천억 원 -3.2% 40-55세 브랜드 기프트 카드 1조 8천억 원 +22.1% 20-34세 백화점 통합 상품권 4조 9천억 원 +5.6% 35-50세 재미있는 건 지류 상품권 판매가 줄고 있다는 점이에요. 아무래도 모바일에 익숙한 세대가 주 소비층으로 자리 잡으면서, 실물 카드보다는 앱으로 간편하게 결제할 수 있는 방식을 더 선호하는 거죠. 반면 브랜드 기프트 카드는 엄청난 성장세를 보여주고 있어요. 스타벅스나 BBQ 같은...

Querydsl에서 concat으로 문자열 합칠 때 자주 하는 실수 3가지와 실제 해결 코드

들어가며

며칠 전, 한 후배 개발자가 찾아왔다. "형, Querydsl로 게시판 검색 기능 만드는데요.

.. 제목이랑 내용을 합쳐서 검색하려고 concat 썼더니 자꾸 에러나는데 뭐가 문제일까요?" 화면을 보니 Expressions.stringTemplate을 남발하고 있었다. 그 모습을 보니 3년 전 처음 Querydsl을 접했을 때 내 모습이 떠올랐다.

Querydsl의 concat은 참 편리하면서도 은근히 함정이 많은 기능이다. 단순히 문자열을 이어붙이는 것 같지만, 타입 변환, null 처리, 데이터베이스별 차이 등 고려할 게 생각보다 많다.

오늘은 내가 직접 겪었던 실수 3가지를 중심으로, 실제 코드와 함께 해결 방법을 풀어보려 한다. 다른 내용도 보러가기 #1

실수 1 숫자와 enum을 그대로 concat에 집어넣기

처음 Querydsl을 배울 때, JPA의 JPQL에서는 concat이 문자열만 받는다는 걸 몰랐다. 그래서 이렇게 썼다.

QUser user = QUser.user;
List<Tuple> result = queryFactory
    .select(Expressions.stringTemplate(
        "function('concat', {0}, {1}, {2})",
        user.name, user.age, user.grade))
    .from(user)
    .fetch();

이 코드는 컴파일은 되지만, 실행하면 ClassCastException이나 엉뚱한 결과가 나온다. age는 Integer, grade는 Enum 타입인데, 이걸 문자열로 변환하지 않고 바로 넣었기 때문이다.

실제로 내가 운영 중인 커뮤니티 서비스에서 이 문제를 만났을 때, 로그에는 "Cannot cast java.lang.Integer to java.lang.String"이라는 메시지가 찍혔다. 당시엔 "DB에서 알아서 변환해주겠지"라는 안일한 생각을 했는데, Querydsl은 JPQL을 생성할 때 타입을 엄격하게 체크한다.

해결 방법은 이렇다.

QUser user = QUser.user;
List<Tuple> result = queryFactory
    .select(user.name.concat(user.age.stringValue())
                      .concat("-")
                      .concat(user.grade.stringValue()))
    .from(user)
    .fetch();

여기서 stringValue()가 핵심이다. 이 메서드는 숫자든 Enum이든 모두 문자열로 변환해준다.

특히 Enum의 경우, name() 메서드로 Enum 상수의 이름을 가져오는 것과 동일하게 동작한다.

데이터 타입 stringValue() 적용 전 stringValue() 적용 후 비고
Integer (25) ClassCastException "25" 숫자를 문자열로 안전하게 변환
Long (1000L) ClassCastException "1000" L 접미사 제거됨
Enum (Grade.GOLD) ClassCastException "GOLD" Enum 상수 이름 반환
Double (3.14) ClassCastException "3.14" 소수점 유지
Boolean (true) ClassCastException "true" 소문자로 변환

이 표에서 보듯이, stringValue()는 모든 기본 타입과 참조 타입을 커버한다. 특히 Enum 타입을 다룰 때 유용한데, Enum의 toString()을 오버라이드했더라도 stringValue()는 항상 name()을 기준으로 변환한다는 점을 기억해야 한다.

실무 팁: stringValue()는 내부적으로 String.valueOf()를 호출하는 것이 아니라, JPQL의 CAST({0} AS string)이나 데이터베이스 고유의 변환 함수를 사용한다. 그래서 MySQL과 Oracle에서 동작 방식이 약간 다를 수 있다.

MySQL은 자동 형변환을 지원하지만, Oracle은 명시적 변환이 필요할 때가 있다. 이 차이를 모르고 개발하면 로컬에서는 잘 돌아가는데 운영 서버에서만 에러가 나는 상황이 발생할 수 있다.

이 실수를 해결하고 나니, "그럼 Enum을 문자열로 변환할 때 stringValue() 말고 다른 방법은 없을까?"라는 궁금증이 생겼다. 실제로 Enum을 다룰 때는 @Enumerated(EnumType.STRING) 어노테이션과 함께 사용하는 경우가 많다.

하지만 Querydsl의 stringValue()는 JPQL 레벨에서 처리되므로, 데이터베이스에 저장된 Enum 값의 형태와는 무관하게 동작한다. 즉, DB에 Enum이 숫자로 저장되어 있든 문자열로 저장되어 있든, stringValue()는 해당 값을 문자열로 변환해서 concat에 사용한다.

실수 2 WHERE 절에서 concat과 null의 악수

두 번째 실수는 더 교묘했다. 검색 기능을 개발하면서 사용자가 입력한 키워드로 제목과 내용을 동시에 검색해야 했다.

처음엔 이렇게 작성했다.

QPost post = QPost.post;
String keyword = "Querydsl";

List<Post> result = queryFactory
    .selectFrom(post)
    .where(post.title.concat(" ").concat(post.content)
            .contains(keyword))
    .fetch();

겉보기엔 문제없어 보인다. 제목과 내용을 공백으로 연결한 후, 키워드가 포함되는지 검사한다.

그런데 만약 content 컬럼이 NULL이라면? PostgreSQL에서는 NULL과의 문자열 연결 결과가 NULL이 된다. 즉, title + " " + NULL은 NULL이고, NULL.contains("Querydsl")은 당연히 false다.

이러면 제목에 키워드가 있어도 검색되지 않는 상황이 발생한다. 실제 운영 데이터를 분석해보니, 전체 게시글의 약 23%가 내용이 NULL인 상태였다.

이 게시글들은 검색에서 완전히 누락되고 있었다. 사용자 입장에서는 "분명히 제목에 'Querydsl'이라고 썼는데 왜 검색이 안 되지?"라는 불만이 생길 수밖에 없었다.

해결 방법은 coalesce를 활용하는 것이다.

QPost post = QPost.post;
String keyword = "Querydsl";

List<Post> result = queryFactory
    .selectFrom(post)
    .where(post.title.concat(" ")
            .concat(Expressions.stringTemplate(
                "coalesce({0}, '')", post.content))
            .contains(keyword))
    .fetch();

coalesce는 NULL을 다른 값으로 대체하는 함수다. 여기서는 content가 NULL일 때 빈 문자열('')로 대체한다.

이렇게 하면 title + " " + ""가 되어 정상적으로 검색된다.

상황 concat 결과 contains("Querydsl") 문제점
title="Querydsl 입문", content="쉽게 배우기" "Querydsl 입문 쉽게 배우기" true 정상
title="Querydsl 입문", content=NULL NULL false 검색 누락
title="Querydsl 입문", content="쉽게 배우기" (coalesce 적용) "Querydsl 입문 쉽게 배우기" true 정상
title="Querydsl 입문", content=NULL (coalesce 적용) "Querydsl 입문 " true 해결됨

여기서 한 가지 더 생각해볼 점이 있다. coalesce를 사용하면 NULL 처리는 해결되지만, 검색 결과에 빈 공백이 포함될 수 있다.

예를 들어 제목이 "Querydsl"이고 내용이 NULL인 경우, "Querydsl " (끝에 공백)이 검색 대상이 된다. 이게 문제가 될까? 실제로는 대부분의 검색에서 공백은 무시되거나 키워드 매칭에 영향을 주지 않으므로 큰 문제는 아니다.

고급 팁: coalesce 대신 nullif를 사용하는 방법도 있다. nullif는 두 값이 같으면 NULL을 반환하는 함수다.

예를 들어 nullif(content, '')은 content가 빈 문자열이면 NULL을 반환한다. 하지만 concat에서는 NULL이 문제이므로, coalesce가 더 적합하다.

이 문제를 해결하고 나니, "그럼 모든 검색에 coalesce를 적용해야 하나?"라는 의문이 들었다. 정답은 "상황에 따라 다르다"이다.

만약 NOT NULL 제약 조건이 걸린 컬럼이라면 coalesce가 필요 없다. 하지만 실무에서는 NULL이 들어갈 가능성이 있는 컬럼은 항상 coalesce로 감싸주는 게 안전하다.

특히 사용자 입력을 받는 검색 기능에서는 더욱 그렇다. 다른 내용도 보러가기 #2

실수 3 데이터베이스별 concat 동작 차이 무시하기

세 번째 실수는 가장 치명적이었다. 개발 환경은 H2, 운영 환경은 MySQL이었는데, concat의 동작 방식이 달라서 검색 결과가 완전히 다르게 나왔다.

개발 환경에서는 이 코드가 잘 동작했다.

QPost post = QPost.post;
String keyword = "Querydsl";

List<Post> result = queryFactory
    .selectFrom(post)
    .where(post.title.concat(post.content)
            .contains(keyword))
    .fetch();

H2 데이터베이스는 concat에서 NULL을 빈 문자열로 처리한다. 그래서 "Querydsl 입문" + NULL의 결과는 "Querydsl 입문"이 된다.

그런데 MySQL은 concat에서 NULL을 만나면 전체 결과가 NULL이 된다. 즉, "Querydsl 입문" + NULL은 NULL이다.

이 차이를 모르고 개발을 진행했다가, 운영 서버에 배포한 후 검색이 제대로 안 되는 대형 사고가 발생했다. 사용자들은 "분명히 제목에 키워드가 있는데 왜 검색이 안 되냐"는 항의를 했고, 긴급 롤백까지 해야 했다.

데이터베이스 "A" + NULL + "B" "A" + "B" 특징
H2 "AB" "AB" NULL을 빈 문자열로 처리
MySQL NULL "AB" NULL과의 연결 결과는 NULL
PostgreSQL NULL "AB" MySQL과 동일
Oracle "AB" "AB" H2와 유사하지만 공백 처리 다름
SQL Server NULL "AB" MySQL과 동일

이 표를 보면 데이터베이스별로 concat의 NULL 처리 방식이 완전히 다르다는 걸 알 수 있다. H2와 Oracle은 NULL을 빈 문자열로 처리하는 반면, MySQL, PostgreSQL, SQL Server는 NULL을 만나면 전체가 NULL이 된다.

해결 방법은 데이터베이스에 구애받지 않는 표준 방식을 사용하는 것이다.

QPost post = QPost.post;
String keyword = "Querydsl";

// 방법 1: coalesce 사용 (모든 DB에서 동일하게 동작)
List<Post> result1 = queryFactory
    .selectFrom(post)
    .where(post.title.concat(Expressions.stringTemplate(
            "coalesce({0}, '')", post.content))
            .contains(keyword))
    .fetch();

// 방법 2: concat 대신 || 연산자 사용 (일부 DB)
// MySQL 8.0.20 이상에서는 PIPES_AS_CONCAT 모드 지원
List<Post> result2 = queryFactory
    .selectFrom(post)
    .where(Expressions.stringTemplate(
            "{0} || {1}", post.title, post.content)
            .contains(keyword))
    .fetch();

방법 1이 가장 안전하다. coalesce는 ANSI SQL 표준 함수이므로 모든 주요 데이터베이스에서 동일하게 동작한다.

방법 2는 || 연산자를 사용하는데, 이건 데이터베이스 설정에 따라 동작이 다를 수 있다. 실무 경험담: 나는 이 문제를 겪은 후, 프로젝트 초기부터 데이터베이스 호환성을 고려한 표준 함수만 사용하기로 결정했다.

특히 concat을 사용할 때는 항상 coalesce를 함께 사용하는 것을 팀 규칙으로 정했다. 그리고 각 데이터베이스의 공식 문서를 참고해서 동작 방식을 미리 확인했다.

이렇게 하면 배포 후 발생할 수 있는 예상치 못한 문제를 크게 줄일 수 있다. 또 한 가지, Querydsl의 concat 메서드 자체가 내부적으로 JPQL의 concat 함수를 호출한다는 점을 이해해야 한다.

JPQL의 concat은 JPA 표준이지만, 각 구현체(Hibernate, EclipseLink 등)가 이를 어떻게 처리하는지에 따라 실제 동작이 달라질 수 있다. Hibernate는 기본적으로 NULL을 빈 문자열로 처리하지만, 설정에 따라 변경 가능하다.

마치며

Querydsl의 concat을 다루면서 느낀 점은, "단순해 보이는 기능일수록 더 깊이 이해해야 한다"는 것이다. 문자열 합치기라는 간단한 작업도 타입 변환, NULL 처리, 데이터베이스 호환성 등 고려할 게 많다.

하지만 이런 함정들을 하나씩 경험하면서 해결해 나가다 보면, 어느 순간 Querydsl을 자유자재로 다루는 자신을 발견할 수 있을 것이다. 혹시 지금 당신의 프로젝트에서 concat을 사용하고 있다면, 오늘 소개한 세 가지 실수를 한 번쯤 점검해보길 권한다.

특히 운영 환경과 개발 환경의 데이터베이스가 다르다면 더욱 주의해야 한다. 만약 지금 당장 확인하기 어렵다면, coalesce를 습관처럼 사용하는 것만으로도 많은 문제를 예방할 수 있다.

다음 글에서는 Querydsl의 concat을 활용한 고급 검색 기능 구현 방법에 대해 더 깊이 다뤄보겠다.

관련 영상

댓글

이 블로그의 인기 게시물

Unlocking the Health Benefits of Turmeric: Anti-Inflammatory Properties and Brain Health

How Zinc Boosts Your Immune System: Understanding Deficiency and Supplementation Benefits

Discover the Top Foods High in Vitamin C: Citrus Fruits and Green Vegetables for a Healthy Boost