한 줄 요약 — 네이버 연동은 문서대로 붙이면 "동작"은 한다. 문제는 그 다음, 데이터를 저장하고 운영에 올리는 순간에 터진다.

CI4로 만든 제품들에 네이버를 세 군데 붙였다. 회원가입용 네이버 로그인, 상품 가격 비교용 쇼핑 검색 API, 그리고 블로그 검색 노출용 서치어드바이저 소유확인이다. 셋 다 공식 문서는 친절하다. 그런데 막힌 지점은 하나같이 문서에 없는 곳이었다.

이 글은 사용법이 아니라 함정 목록이다. 각 항목은 증상 → 원인 → 처리 순서로 적었다.

1. 네이버 로그인 — 인증보다 저장이 어렵다

OAuth 흐름 자체는 30분이면 붙는다. 시간을 잡아먹은 건 프로필 응답을 members 테이블에 넣는 단계였다.

함정 1-1. id는 숫자가 아니고, 앱마다 다르다

프로필 API(/v1/nid/me)의 id는 애플리케이션별 회원 고유 식별자다. 숫자처럼 보여도 문자열이고 길이도 길다. INT나 짧은 VARCHAR에 넣으면 잘리거나 0이 된다.

더 큰 함정은 "앱별"이라는 점이다. 같은 사람이라도 개발용 앱과 운영용 앱에서 id가 다르다. 개발 앱 키로 쌓은 테스트 계정을 운영 키로 바꾸는 순간 전부 "신규 회원"으로 인식된다. 제품마다 앱을 따로 등록했다면 제품 간 회원 매칭에도 id를 쓸 수 없다.

  • 컬럼: provider_uid VARCHAR(100), (provider, provider_uid) 유니크
  • 개발/운영 앱을 나눴다면 운영 전환 시 테스트 소셜 계정은 버린다고 정해 둔다

함정 1-2. email을 회원 키로 쓰면 안 된다

응답의 email은 사용자가 네이버에 등록한 연락처 이메일이다. @naver.com이 아닐 수 있고, 사용자가 언제든 바꾼다. 이메일로 기존 회원과 자동 병합하면 남의 계정에 붙는 사고가 난다.

  • 식별은 provider_uid로만 한다
  • 이메일 일치 시 자동 병합하지 않고, 로그인 후 "기존 계정과 연결"을 사용자가 직접 확인하게 한다

함정 1-3. 선택 동의 항목은 "없을 수 있다"가 아니라 "없다"로 가정한다

개발자센터에서 항목을 "추가(선택)"로 두면 사용자가 동의 화면에서 체크를 해제할 수 있다. 그러면 해당 키가 응답에서 아예 빠진다. null이 아니라 키 자체가 없으니 $profile['mobile']에서 Undefined index가 난다.

나중에 항목을 추가해도 기존 사용자에게는 다시 묻지 않는다. 재동의를 받으려면 인가 요청에 auth_type=reprompt를 붙여야 한다.

함정 1-4. 닉네임에 이모지가 온다

네이버 닉네임에는 이모지가 들어간다. DB나 연결 charset이 utf8(3바이트)이면 INSERT가 깨지거나 물음표로 저장된다. 테이블과 CI4 Database 설정 모두 utf8mb4인지 확인한다.

함정 1-5. 검수 전에는 나와 멤버만 로그인된다

개발 중 상태의 앱은 등록자와 "멤버관리"에 넣은 계정만 로그인할 수 있다. 내 계정으로는 잘 되는데 지인 테스트에서 실패하는 이유가 이것이다. 오픈 전 검수 일정까지 일정표에 넣어야 한다.

함정 1-6. 사용자가 취소하면 code 없이 돌아온다

동의 화면에서 취소하면 콜백에 code 대신 error=access_denied가 온다. 콜백에서 code만 꺼내 토큰 요청을 보내면 네이버 쪽 에러가 한 번 더 나서 원인이 가려진다. error 파라미터를 먼저 본다.

함정 1-7. 프로필은 response 안에 있다

프로필 API는 {"resultcode":"00","message":"success","response":{…}} 형태다. 최상위에서 id를 찾으면 비어 있다. resultcode가 "00"인지 확인한 뒤 response를 꺼낸다.

2. 네이버 로그인 — 위 함정을 반영한 CI4 코드

소셜 계정은 회원 테이블과 분리한다. 한 회원이 구글·카카오·네이버를 모두 연결할 수 있어야 하고, 식별자는 provider마다 규칙이 다르기 때문이다.

CREATE TABLE social_accounts (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    member_id     BIGINT UNSIGNED NOT NULL,
    provider      VARCHAR(20)  NOT NULL,          -- 'naver', 'kakao', 'google'
    provider_uid  VARCHAR(100) NOT NULL,          -- 네이버 앱별 식별자(문자열)
    email         VARCHAR(255) NULL,              -- 참고용. 키로 쓰지 않는다
    nickname      VARCHAR(100) NULL,
    raw_profile   JSON NULL,                      -- 동의 항목 변화 추적용
    created_at    DATETIME NOT NULL,
    updated_at    DATETIME NOT NULL,
    UNIQUE KEY uq_provider_uid (provider, provider_uid),
    KEY idx_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

콜백 처리는 서비스로 뺀다. 컨트롤러는 파라미터만 넘긴다.

<?php

namespace App\Services\Auth;

use App\Exceptions\ApiException;
use CodeIgniter\HTTP\CURLRequest;

final class NaverAuthService
{
    public function __construct(
        private readonly CURLRequest $http,
        private readonly string $clientId,
        private readonly string $clientSecret,
    ) {}

    /** @return array{uid:string, email:?string, nickname:?string, raw:array} */
    public function handleCallback(array $query, string $savedState): array
    {
        // 1-6: 취소는 code 없이 error로 온다
        if (isset($query['error'])) {
            throw new ApiException('AUTH.NAVER.CANCELED', 400);
        }
        if (! hash_equals($savedState, (string) ($query['state'] ?? ''))) {
            throw new ApiException('AUTH.NAVER.STATE_MISMATCH', 400);
        }

        $token = $this->requestToken((string) $query['code'], $savedState);
        $body  = $this->requestProfile($token);

        // 1-7: resultcode 확인 후 response 안을 본다
        if (($body['resultcode'] ?? null) !== '00' || ! isset($body['response']['id'])) {
            throw new ApiException('AUTH.NAVER.PROFILE_FAILED', 502);
        }
        $p = $body['response'];

        // 1-3: 선택 동의 항목은 키 자체가 없을 수 있다
        return [
            'uid'      => (string) $p['id'],          // 1-1: 문자열로 고정
            'email'    => $p['email'] ?? null,         // 1-2: 키로 쓰지 않는다
            'nickname' => $p['nickname'] ?? null,      // 1-4: utf8mb4 필요
            'raw'      => $p,
        ];
    }

    private function requestToken(string $code, string $state): string
    {
        $res = $this->http->post('https://nid.naver.com/oauth2.0/token', [
            'form_params' => [
                'grant_type'    => 'authorization_code',
                'client_id'     => $this->clientId,
                'client_secret' => $this->clientSecret,
                'code'          => $code,
                'state'         => $state,
            ],
            'http_errors' => false,
            'timeout'     => 5,
        ]);
        $json = json_decode($res->getBody(), true) ?? [];

        // 상태 코드만 보지 말고 error 필드를 함께 본다
        if (isset($json['error']) || empty($json['access_token'])) {
            throw new ApiException('AUTH.NAVER.TOKEN_FAILED', 502);
        }
        return $json['access_token'];
    }

    private function requestProfile(string $accessToken): array
    {
        $res = $this->http->get('https://openapi.naver.com/v1/nid/me', [
            'headers'     => ['Authorization' => 'Bearer ' . $accessToken],
            'http_errors' => false,
            'timeout'     => 5,
        ]);
        return json_decode($res->getBody(), true) ?? [];
    }
}

저장 쪽 원칙은 세 줄이다.

  1. (provider, provider_uid)로 찾는다. 있으면 로그인, 없으면 가입.
  2. 이메일이 같은 기존 회원이 있어도 자동으로 붙이지 않는다. "연결하시겠습니까" 화면을 거친다.
  3. 매번 로그인할 때 raw_profile을 갱신한다. 사용자가 동의를 철회하거나 이메일을 바꾼 흔적이 남는다.

로그인 후에는 네이버 토큰을 계속 들고 다니지 않는다. 우리 서비스의 JWT를 새로 발급하고, 네이버 access token(유효 1시간)은 필요할 때만 쓴다.

3. 쇼핑 검색 API — 에러 코드보다 먼저 확인할 것

결론부터: 쇼핑 검색 API에 오류가 나면 코드보다 API의 존속 여부를 먼저 확인한다. 네이버는 검색 API를 네이버클라우드의 NAVER API HUB로 옮기고 있는데, 보도에 따르면 쇼핑·책·전문자료 API는 이관 대상에서 빠지고 종료됐다. 실제로 NAVER API HUB 소개 문서의 검색 API 목록에도 쇼핑이 없다. 내 코드가 아무리 맞아도 엔드포인트가 사라지면 소용없다.

함정 3-1. 이관된 API는 키도, 헤더도, 경로도 바뀐다

쇼핑이 아니라 블로그·뉴스 같은 다른 검색 API를 같이 쓰고 있다면 이것도 확인한다. 이관 가이드에 따르면 기존 개발자센터의 Client ID/Secret은 API HUB에서 쓸 수 없다. 바뀌는 항목은 세 가지다.

항목

개발자센터

NAVER API HUB

ID 헤더

X-Naver-Client-Id

X-NCP-APIGW-API-KEY-ID

Secret 헤더

X-Naver-Client-Secret

X-NCP-APIGW-API-KEY

경로(뉴스 예)

/v1/search/news.json

/search/v1/news

키를 새로 받고 헤더만 바꾸고 경로를 그대로 두면 404, 경로만 바꾸고 옛 키를 쓰면 401이 난다. 셋을 한 번에 바꾼다. API HUB는 일정 사용량을 넘으면 과금되므로 콘솔에서 이용 한도도 걸어 둔다.

함정 3-2. 401과 403은 코드가 아니라 콘솔 문제다

  • 401 — 키 오타, 헤더 이름 오타, 앞뒤 공백. .env에서 값을 읽을 때 따옴표나 개행이 섞이지 않았는지 본다.
  • 403 — 애플리케이션의 "사용 API"에 검색이 추가되지 않은 경우. 로그인용으로 만든 앱 키를 재사용할 때 자주 난다. 제품별·용도별로 앱을 분리하면 덜 헷갈린다.

함정 3-3. SE 코드는 요청 파라미터 문제다

공식 문서의 응답 코드 표(예: 웹문서 검색) 기준으로, 실무에서 실제로 마주치는 것만 추리면 이렇다.

코드

의미

실제 원인

SE01

잘못된 쿼리 요청

query 누락, 빈 검색어

SE02

부적절한 display

100 초과

SE03

부적절한 start

1,000 초과 — 페이지 계산 버그

SE05

존재하지 않는 API

경로 오타, 이관 후 옛 경로

SE06

잘못된 인코딩

검색어를 UTF-8 URL 인코딩하지 않음

SE99

시스템 에러

재시도 대상

SE03은 "다음 페이지" 버튼에서 터진다. start는 1부터 시작하고 상한이 있으므로 total이 수만 건이어도 끝까지 넘길 수 없다. 페이지 수는 min(total, 상한) / display로 자른다. SE06은 CI4 CURLRequest의 query 옵션을 쓰면 인코딩을 맡길 수 있다. URL 문자열을 직접 이어 붙일 때 생긴다.

함정 3-4. 응답 데이터는 그대로 쓰면 안 된다

쇼핑 응답을 쓰던 시절 정리해 둔 처리 규칙이다. 이관된 다른 검색 API의 title·description에도 똑같이 해당된다.

  • title에 <b> 태그가 들어 있다. 검색어 하이라이트다. esc()만 하면 화면에 &lt;b&gt;가 글자로 보이고, 그대로 출력하면 XSS 통로가 된다. strip_tags() 후 html_entity_decode(), 그 다음 출력 시 esc() 순서로 처리한다.
  • 가격이 문자열이다. lprice, hprice는 문자열이고 hprice는 빈 문자열인 경우가 많다. 그대로 정렬하면 사전순이 된다. 저장 전에 int로 바꾸고 빈 값은 null로 둔다.
  • 하루 호출 한도가 있다. 검색 API는 하루 25,000회다. 사용자 입력마다 실시간 호출하면 금방 닿는다. 같은 검색어는 Redis에 짧게 캐시한다.
private function normalizeTitle(string $title): string
{
    // 태그 제거 → 엔티티 복원 → 공백 정리. 출력 시점에 esc()
    $plain = html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    return trim(preg_replace('/\s+/u', ' ', $plain));
}

private function toPrice(?string $v): ?int
{
    return ($v === null || $v === '') ? null : (int) $v;
}

쇼핑 데이터가 꼭 필요하다면

가격 비교가 제품의 핵심이면 대체 경로를 따로 찾아야 한다. 판매자 입장이라면 스마트스토어 커머스 API로 내 상품을 다루는 건 별개 체계다. 어느 쪽이든, 외부 API 하나에 핵심 기능을 묶는 구조 자체를 다시 보는 계기로 삼았다.

4. 서치어드바이저 메타 — 태그는 맞는데 소유확인이 안 될 때

소유확인 방법은 HTML 파일 업로드와 <meta name="naver-site-verification" content="…"> 태그 두 가지다. 메타 방식이 쉬워 보여서 골랐는데, CI4 사이트에서는 오히려 함정이 많았다.

함정 4-1. 메타가 "등록한 URL"의 <head>에 있어야 한다

네이버가 확인하는 건 등록한 그 주소의 HTML이다. 그런데 CI4 사이트는 레이아웃이 여러 개다. 게시판 레이아웃에만 메타를 넣고 메인은 별도 뷰로 렌더링하면, 게시판 페이지 소스에는 태그가 보이는데 확인은 실패한다.

  • 메타는 모든 레이아웃이 공유하는 head 파셜 한 곳에만 둔다
  • 값은 하드코딩하지 않고 .env로 뺀다. 사이트마다 값이 다르기 때문이다
  • 확인은 브라우저 개발자 도구가 아니라 curl -s https://도메인/ | grep naver-site로 한다. JS가 넣은 태그는 개발자 도구에는 보여도 크롤러에는 안 보일 수 있다
<!-- app/Views/partials/head.php -->
<?php if ($v = env('seo.naverVerification')): ?>
<meta name="naver-site-verification" content="<?= esc($v, 'attr') ?>">
<?php endif ?>

함정 4-2. 루트가 리다이렉트되면 실패한다

메인(/)을 /board/...로 301 보내는 구조라면 루트에는 HTML이 없다. 마찬가지로 http→https, non-www→www 리다이렉트가 걸려 있는데 리다이렉트 이전 주소로 등록하면 맞지 않는다. 서치어드바이저에 등록하는 주소는 최종 도착 주소 하나로 정한다. http/https, www 유무가 다르면 별개 사이트로 취급된다.

함정 4-3. 루트가 느리면 확인도 흔들린다

jblove.net 속도를 진단하면서 겪은 일이다. Cloudflare가 국내 요청을 미국 시애틀 엣지로 보내고 있었고, robots.txt까지 PHP 라우팅을 타면서 응답이 몇 초씩 걸렸다. 사람에게도 느린 페이지는 크롤러에게도 느리다. 소유확인이 들쭉날쭉하면 태그보다 응답 시간을 먼저 의심한다.

  • robots.txt와 소유확인 HTML 파일은 public/에 정적 파일로 두어 PHP를 거치지 않게 한다
  • 모든 페이지에서 세션을 시작하면 캐시가 안 된다. 비로그인 요청에는 세션을 열지 않는다

함정 4-4. 메타보다 HTML 파일이 덜 깨진다

결국 나는 HTML 파일 방식을 기본으로 바꿨다. 파일을 public/에 두면 Apache/nginx가 바로 내려주므로 레이아웃, 라우팅, 세션, 필터의 영향을 전혀 받지 않는다. 레이아웃을 리팩토링하다 메타를 지워 버려 소유확인이 풀리는 사고도 없다. 메타는 HTML 파일을 둘 수 없는 호스팅에서만 쓴다.

소유확인 다음에 할 일

소유확인은 출발선이다. 사이트맵과 RSS를 제출해야 수집이 시작되고, 제출해도 색인은 보장되지 않는다. 새 글을 바로 알리고 싶다면 IndexNow로 발행 시점에 핑을 보내는 방법도 있다.

5. 체크리스트와 맺음

네이버 로그인

  • provider_uid는 VARCHAR(100) 문자열, (provider, provider_uid) 유니크
  • 개발 앱과 운영 앱의 id가 다르다는 것을 전제로 테스트 데이터 정리
  • 이메일로 자동 병합하지 않기
  • 선택 동의 항목은 ??로 받기, 재동의는 auth_type=reprompt
  • 테이블·연결 모두 utf8mb4
  • 콜백에서 error 파라미터를 code보다 먼저 확인
  • 오픈 전 검수 일정 확보

검색 API

  • 쓰는 API가 이관 대상인지, 종료 대상인지 먼저 확인
  • 이관 시 키·헤더·경로 세 가지를 한 번에 교체, 이용 한도 설정
  • 401은 키·헤더, 403은 사용 API 설정부터
  • start 상한으로 페이지 수 자르기
  • title은 태그 제거 → 엔티티 복원 → 출력 시 esc()
  • 가격은 int 변환, 같은 검색어는 캐시

서치어드바이저

  • 등록 주소 = 리다이렉트 최종 도착 주소
  • 메타는 공용 head 파셜 한 곳, 값은 .env
  • curl로 서버 HTML에 태그가 있는지 확인
  • 가능하면 public/에 HTML 파일 방식
  • robots.txt는 정적 파일, 루트 응답 시간 점검

세 연동의 공통점이 있다. 실패가 "연결" 단계가 아니라 데이터를 믿는 순간에 났다. 네이버가 준 id를 숫자로 믿었고, email을 고정값으로 믿었고, 검색 결과의 title을 평문으로 믿었고, API가 계속 있을 거라고 믿었다.

26년을 해도 외부 연동에서 배우는 건 같다. 남의 데이터는 경계에서 한 번 정규화하고, 남의 서비스는 언제든 바뀐다고 가정한다. 이 원칙을 CLAUDE.md에 적어 두면 AI가 짜는 연동 코드도 처음부터 그렇게 나온다.

참고