[작성자:] seok_root

  • META TAG (SEO, OPEN GRAPH)

    SEO와 SNS 공유를 위한 메타태그 총정리: title, canonical, Open Graph, Twitter Card

    웹페이지를 만들 때 <title><meta name="description">만 넣으면 SEO 설정이 끝난다고 생각하기 쉽습니다.

    하지만 실제로는 검색엔진에 표시되는 정보와 카카오톡, 페이스북, X 같은 SNS에서 링크를 공유할 때 표시되는 정보가 서로 다릅니다.

    검색엔진은 주로 다음 정보를 참고합니다.

    <title>
    <meta name="description">
    <link rel="canonical">
    <meta name="robots">

    SNS와 메신저는 Open Graph 또는 Twitter Card 메타태그를 사용합니다.

    og:title
    og:description
    og:image
    og:url
    twitter:card
    twitter:title
    twitter:description
    twitter:image

    이 글에서는 검색엔진용 메타태그와 SNS 공유용 메타태그의 차이, HTML 작성 예제, Next.js App Router 적용 방법, WordPress 설정 방법까지 정리합니다.

    기본 메타태그 예제

    가장 기본적인 HTML 메타태그 구성은 다음과 같습니다.

    <!DOCTYPE html>
    <html lang="ko">
    <head>
      <meta charset="UTF-8">
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1"
      >
    
      <title>페이지 제목 | 사이트 이름</title>
    
      <meta
        name="description"
        content="검색 결과와 SNS 공유 화면에 사용할 페이지 설명입니다."
      >
    
      <link
        rel="canonical"
        href="https://example.com/posts/meta-tags"
      >
    
      <meta property="og:type" content="article">
      <meta property="og:title" content="페이지 제목">
      <meta
        property="og:description"
        content="SNS에 공유할 때 표시할 페이지 설명입니다."
      >
      <meta
        property="og:image"
        content="https://example.com/images/meta-tags-og.jpg"
      >
      <meta
        property="og:url"
        content="https://example.com/posts/meta-tags"
      >
      <meta property="og:site_name" content="사이트 이름">
      <meta property="og:locale" content="ko_KR">
    
      <meta
        name="twitter:card"
        content="summary_large_image"
      >
      <meta name="twitter:title" content="페이지 제목">
      <meta
        name="twitter:description"
        content="X에서 공유할 때 표시할 페이지 설명입니다."
      >
      <meta
        name="twitter:image"
        content="https://example.com/images/meta-tags-og.jpg"
      >
    </head>
    <body>
      ...
    </body>
    </html>

    각 태그는 비슷해 보이지만 역할이 다릅니다.

    HTML title과 og:title 차이

    <title>og:title은 모두 페이지 제목을 표현하지만 사용되는 위치가 다릅니다.

    HTML title

    <title>SEO와 SNS 공유를 위한 메타태그 총정리</title>

    <title>은 브라우저 탭 제목과 검색엔진의 검색 결과 제목 후보로 사용됩니다.

    일반적으로 다음 위치에 영향을 줍니다.

    브라우저 탭
    브라우저 방문 기록
    즐겨찾기
    검색엔진 검색 결과

    페이지마다 내용을 정확하게 설명하는 고유한 제목을 작성하는 것이 좋습니다.

    잘못된 예시는 다음과 같습니다.

    <title>홈</title>
    <title>게시물</title>
    <title>사이트 이름</title>

    모든 페이지가 같은 제목을 사용하면 검색엔진과 사용자가 각 페이지의 차이를 파악하기 어렵습니다.

    권장 예시는 다음과 같습니다.

    <title>리눅스 tar 압축과 해제 명령어 정리 | CheckVly</title>
    <title>macOS에서 SVN 설치하는 방법 | CheckVly</title>

    og:title

    <meta
      property="og:title"
      content="SEO와 SNS 공유를 위한 메타태그 총정리"
    >

    og:title은 링크를 카카오톡, 페이스북, 디스코드, 슬랙 같은 서비스에 공유할 때 미리보기 제목으로 사용됩니다.

    <title>과 동일하게 작성해도 되지만, SNS 공유 화면에 맞게 조금 더 짧게 작성할 수도 있습니다.

    예를 들어 HTML 제목은 사이트 이름까지 포함할 수 있습니다.

    <title>
      SEO와 SNS 공유를 위한 메타태그 총정리 | CheckVly
    </title>

    반면 Open Graph 제목에서는 사이트 이름을 제외할 수 있습니다.

    <meta
      property="og:title"
      content="SEO와 SNS 공유를 위한 메타태그 총정리"
    >

    사이트 이름은 별도의 og:site_name으로 지정할 수 있습니다.

    <meta
      property="og:site_name"
      content="CheckVly"
    >

    title 작성 시 주의할 점

    제목은 페이지의 실제 내용을 정확하게 설명해야 합니다.

    검색어를 많이 넣기 위해 같은 단어를 반복하는 방식은 피하는 것이 좋습니다.

    좋지 않은 예시는 다음과 같습니다.

    <title>
      SEO 메타태그 SEO 설정 SEO 최적화 Open Graph SEO
    </title>

    자연스럽고 명확한 예시는 다음과 같습니다.

    <title>
      SEO와 SNS 공유를 위한 메타태그 총정리
    </title>

    검색엔진은 항상 <title> 내용을 그대로 검색 결과에 표시하는 것은 아닙니다.

    페이지 본문, 제목 요소, 링크 텍스트, 사이트 구조 등을 참고하여 검색 결과 제목을 다르게 구성할 수도 있습니다.

    따라서 <title>은 검색 결과에 강제로 표시할 문구라기보다, 페이지 제목을 검색엔진에 명확하게 전달하는 중요한 신호로 이해하는 것이 좋습니다.

    meta description의 역할

    메타 설명은 다음과 같이 작성합니다.

    <meta
      name="description"
      content="SEO 기본 메타태그와 Open Graph, Twitter Card 설정 방법을 HTML, Next.js, WordPress 예제로 정리합니다."
    >

    description은 페이지 내용을 요약하는 설명입니다.

    검색엔진은 이 내용을 검색 결과의 설명 문구로 사용할 수 있습니다.

    하지만 항상 작성한 문구가 그대로 표시되는 것은 아닙니다.

    사용자의 검색어와 더 관련 있는 본문 내용이 있다면 검색엔진이 페이지 본문 일부를 검색 결과 설명으로 표시할 수 있습니다.

    description은 검색 순위를 직접 보장하지 않는다

    메타 설명에 검색어를 많이 넣는다고 해서 검색 순위가 직접 올라가는 것은 아닙니다.

    다음과 같은 방식은 피해야 합니다.

    <meta
      name="description"
      content="SEO SEO 최적화 SEO 설정 메타태그 SEO 검색 노출 SEO 방법"
    >

    메타 설명의 중요한 역할은 검색 결과에서 페이지의 내용을 잘 설명하고 사용자가 클릭할지 판단하도록 돕는 것입니다.

    좋은 메타 설명은 다음 조건을 갖추는 것이 좋습니다.

    페이지 내용을 정확하게 요약한다.
    다른 페이지와 중복되지 않는다.
    핵심 주제를 자연스럽게 포함한다.
    과장된 표현을 사용하지 않는다.
    본문에 없는 내용을 약속하지 않는다.

    예시는 다음과 같습니다.

    <meta
      name="description"
      content="HTML title, canonical, Open Graph와 Twitter Card의 차이와 설정 방법을 Next.js와 WordPress 예제로 설명합니다."
    >

    홈페이지와 게시물 페이지는 서로 다른 설명을 사용해야 합니다.

    홈페이지 예시는 다음과 같습니다.

    <meta
      name="description"
      content="웹 개발, Linux 서버 운영, 데이터베이스와 실무 문제 해결 과정을 기록하는 개발 블로그입니다."
    >

    게시물 예시는 다음과 같습니다.

    <meta
      name="description"
      content="리눅스 tar 명령어의 압축과 해제, 특정 파일 제외, 권한 보존과 운영 서버 백업 방법을 정리합니다."
    >

    meta keywords는 사용해야 할까?

    과거에는 다음과 같은 태그를 많이 사용했습니다.

    <meta
      name="keywords"
      content="SEO, 메타태그, Open Graph, 검색엔진"
    >

    현재 일반적인 Google 검색 최적화에서는 meta keywords를 사용할 필요가 없습니다.

    키워드는 별도의 메타태그에 나열하기보다 다음 위치에 자연스럽게 포함하는 것이 중요합니다.

    페이지 제목
    본문 제목
    본문 내용
    이미지 대체 텍스트
    URL
    내부 링크

    단순히 키워드 태그를 많이 작성한다고 검색 노출에 도움이 되는 것은 아닙니다.

    canonical 태그란?

    canonical은 동일하거나 매우 비슷한 페이지가 여러 URL로 접근될 때 대표 URL을 알려주는 태그입니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >

    예를 들어 같은 콘텐츠가 다음 주소로 접근될 수 있다고 가정해 보겠습니다.

    https://example.com/posts/meta-tags
    https://www.example.com/posts/meta-tags
    https://example.com/posts/meta-tags?utm_source=kakao
    https://example.com/posts/meta-tags?ref=main

    내용은 같지만 URL은 서로 다릅니다.

    이때 대표 URL을 다음과 같이 지정할 수 있습니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >

    canonical은 검색엔진에 다음 의미를 전달합니다.

    이 페이지와 비슷한 URL이 여러 개 있더라도
    대표 페이지는 이 URL로 판단해 주세요.

    canonical 태그를 사용하는 이유

    같은 콘텐츠가 여러 URL로 노출되면 검색엔진이 어떤 URL을 대표로 수집해야 할지 판단해야 합니다.

    canonical을 사용하면 다음과 같은 상황을 정리하는 데 도움이 됩니다.

    www와 non-www 주소 중복
    HTTP와 HTTPS 주소 중복
    추적 파라미터가 붙은 URL
    정렬 또는 필터 파라미터 URL
    인쇄용 페이지
    동일 콘텐츠의 여러 경로

    예를 들어 다음 주소가 있다고 가정합니다.

    https://example.com/product/100
    https://example.com/product/100?color=black
    https://example.com/product/100?utm_source=instagram

    페이지 내용이 사실상 같다면 대표 URL을 다음과 같이 지정할 수 있습니다.

    <link
      rel="canonical"
      href="https://example.com/product/100"
    >

    자기 자신을 가리키는 canonical

    대표 페이지 자체에도 자기 자신을 가리키는 canonical을 넣을 수 있습니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >

    이를 self-referencing canonical이라고 합니다.

    워드프레스나 Next.js 같은 시스템에서는 각 게시물에 자기 자신의 정식 URL을 canonical로 출력하는 방식이 일반적입니다.

    canonical 작성 시 주의할 점

    canonical에는 완전한 절대 URL을 사용하는 것이 좋습니다.

    권장 예시는 다음과 같습니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >

    상대경로 방식은 피하는 것이 좋습니다.

    <link
      rel="canonical"
      href="/posts/meta-tags"
    >

    canonical URL은 실제로 정상 접근이 가능한 대표 페이지여야 합니다.

    다음과 같은 URL을 canonical로 지정하면 안 됩니다.

    404 페이지
    리디렉션이 반복되는 URL
    로그인이 필요한 페이지
    noindex가 설정된 페이지
    관련 없는 다른 게시물

    페이지 A가 페이지 B를 canonical로 지정하고, 페이지 B가 다시 페이지 A를 canonical로 지정하는 것도 피해야 합니다.

    페이지 A → 페이지 B
    페이지 B → 페이지 A

    canonical은 중복 페이지를 무조건 삭제하거나 검색엔진의 선택을 강제하는 명령이 아니라 대표 URL에 대한 강한 힌트입니다.

    검색엔진이 페이지 내용과 내부 링크 구조를 검토한 결과 다른 URL을 대표 페이지로 선택할 수도 있습니다.

    Open Graph란?

    Open Graph는 웹페이지 링크를 SNS와 메신저에 공유할 때 제목, 설명, 이미지 등을 전달하기 위한 메타데이터 규격입니다.

    기본 구성은 다음 네 가지입니다.

    <meta property="og:title" content="페이지 제목">
    <meta property="og:type" content="article">
    <meta
      property="og:image"
      content="https://example.com/images/og-image.jpg"
    >
    <meta
      property="og:url"
      content="https://example.com/posts/meta-tags"
    >

    실제 사용 시에는 설명과 사이트 이름도 함께 넣는 것이 좋습니다.

    <meta
      property="og:description"
      content="페이지 내용을 간단하게 설명하는 문구입니다."
    >
    <meta
      property="og:site_name"
      content="CheckVly"
    >
    <meta property="og:locale" content="ko_KR">

    Open Graph 주요 속성

    속성역할
    og:title공유 미리보기 제목
    og:description공유 미리보기 설명
    og:type콘텐츠 유형
    og:url페이지 대표 URL
    og:image공유 대표 이미지
    og:site_name사이트 이름
    og:locale콘텐츠 언어와 지역
    og:image:width이미지 너비
    og:image:height이미지 높이
    og:image:alt이미지 대체 설명

    예시는 다음과 같습니다.

    <meta
      property="og:title"
      content="SEO와 SNS 공유를 위한 메타태그 총정리"
    >
    <meta
      property="og:description"
      content="title, canonical, Open Graph와 Twitter Card 설정 방법을 정리합니다."
    >
    <meta property="og:type" content="article">
    <meta
      property="og:url"
      content="https://example.com/posts/meta-tags"
    >
    <meta
      property="og:image"
      content="https://example.com/images/meta-tags-og.jpg"
    >
    <meta
      property="og:image:width"
      content="1200"
    >
    <meta
      property="og:image:height"
      content="630"
    >
    <meta
      property="og:image:alt"
      content="SEO와 SNS 공유를 위한 메타태그 구성"
    >
    <meta property="og:site_name" content="CheckVly">
    <meta property="og:locale" content="ko_KR">

    og:type 선택 방법

    og:type은 페이지의 콘텐츠 유형을 나타냅니다.

    대표적으로 다음 값을 사용합니다.

    website
    article

    홈페이지

    사이트의 메인 페이지나 일반 소개 페이지에는 website를 사용할 수 있습니다.

    <meta property="og:type" content="website">

    예시는 다음과 같습니다.

    홈페이지
    회사 소개
    서비스 소개
    랜딩 페이지

    블로그 게시물

    개별 블로그 글이나 기사에는 article을 사용할 수 있습니다.

    <meta property="og:type" content="article">

    게시물에는 다음 속성을 추가할 수도 있습니다.

    <meta
      property="article:published_time"
      content="2026-08-01T09:00:00+09:00"
    >
    <meta
      property="article:modified_time"
      content="2026-08-02T12:30:00+09:00"
    >
    <meta
      property="article:author"
      content="https://example.com/about"
    >
    <meta property="article:section" content="개발">
    <meta property="article:tag" content="SEO">
    <meta property="article:tag" content="Open Graph">

    날짜는 ISO 8601 형식으로 작성하는 것이 좋습니다.

    2026-08-01T09:00:00+09:00

    og:url과 canonical의 차이

    og:url과 canonical은 대체로 같은 대표 URL을 사용하지만 목적은 다릅니다.

    canonical은 검색엔진에 대표 URL을 알려주는 용도입니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >

    og:url은 SNS 공유 객체의 대표 URL을 지정합니다.

    <meta
      property="og:url"
      content="https://example.com/posts/meta-tags"
    >

    보통 두 값은 동일하게 설정합니다.

    canonical:
    https://example.com/posts/meta-tags
    
    og:url:
    https://example.com/posts/meta-tags

    두 값이 서로 다르면 검색엔진과 SNS가 페이지를 서로 다른 URL로 인식할 수 있으므로 특별한 이유가 없다면 동일하게 맞추는 것이 좋습니다.

    Open Graph 이미지는 절대 URL을 사용해야 한다

    잘못된 예시는 다음과 같습니다.

    <meta
      property="og:image"
      content="/images/meta-tags-og.jpg"
    >

    SNS 크롤러가 상대경로를 올바르게 처리하지 못할 수 있으므로 완전한 절대 URL을 사용하는 것이 안전합니다.

    권장 예시는 다음과 같습니다.

    <meta
      property="og:image"
      content="https://example.com/images/meta-tags-og.jpg"
    >

    다음 조건도 확인해야 합니다.

    외부에서 로그인 없이 접근 가능해야 한다.
    robots.txt나 방화벽으로 차단되어 있지 않아야 한다.
    HTTPS 인증서가 정상이어야 한다.
    이미지 응답 상태가 200이어야 한다.
    HTML 페이지가 아닌 실제 이미지가 반환되어야 한다.

    curl로 확인할 수 있습니다.

    curl -I https://example.com/images/meta-tags-og.jpg

    정상적인 응답 예시는 다음과 같습니다.

    HTTP/2 200
    content-type: image/jpeg

    Open Graph 이미지 권장 크기

    일반적으로 많이 사용하는 Open Graph 이미지 크기는 다음과 같습니다.

    1200 × 630px

    비율은 약 1.91:1입니다.

    이 크기는 여러 SNS와 메신저에서 가로형 미리보기 이미지로 사용하기에 무난합니다.

    이미지에는 다음 내용을 포함할 수 있습니다.

    게시물 핵심 제목
    관련 아이콘 또는 일러스트
    사이트 로고
    카테고리 정보

    다만 이미지 가장자리 가까이에 중요한 글자를 배치하면 서비스별 크롭 과정에서 잘릴 수 있습니다.

    제목과 로고는 이미지 중앙 영역에 배치하는 것이 안전합니다.

    다음 태그를 함께 제공하면 이미지 정보를 더 명확하게 전달할 수 있습니다.

    <meta
      property="og:image"
      content="https://example.com/images/meta-tags-og.jpg"
    >
    <meta
      property="og:image:width"
      content="1200"
    >
    <meta
      property="og:image:height"
      content="630"
    >
    <meta
      property="og:image:type"
      content="image/jpeg"
    >
    <meta
      property="og:image:alt"
      content="SEO 메타태그 구성 방법"
    >

    og:image에 캐시 방지 파라미터를 붙여도 될까?

    SNS 서비스가 이전 이미지를 계속 표시할 때 이미지 URL을 변경하면 새 이미지로 인식될 수 있습니다.

    예시는 다음과 같습니다.

    <meta
      property="og:image"
      content="https://example.com/images/meta-tags-og.jpg?v=2"
    >

    하지만 매 요청마다 값이 바뀌는 파라미터를 사용하면 캐시가 제대로 작동하지 않을 수 있습니다.

    좋지 않은 예시는 다음과 같습니다.

    <meta
      property="og:image"
      content="https://example.com/images/meta-tags-og.jpg?t=현재시간"
    >

    이미지를 수정했을 때만 버전을 변경하는 방식이 좋습니다.

    meta-tags-og-v2.jpg

    또는 다음처럼 사용할 수 있습니다.

    meta-tags-og.jpg?v=2

    Twitter Card란?

    Twitter Card는 X에서 링크를 공유할 때 제목, 설명, 이미지를 지정하기 위한 메타태그입니다.

    서비스 이름은 X로 변경되었지만 메타태그 이름은 여전히 twitter:* 형식을 사용합니다.

    가장 많이 사용하는 설정은 다음과 같습니다.

    <meta
      name="twitter:card"
      content="summary_large_image"
    >
    <meta
      name="twitter:title"
      content="SEO와 SNS 공유를 위한 메타태그 총정리"
    >
    <meta
      name="twitter:description"
      content="title, canonical, Open Graph와 Twitter Card 설정 방법을 정리합니다."
    >
    <meta
      name="twitter:image"
      content="https://example.com/images/meta-tags-og.jpg"
    >

    twitter:card 종류

    대표적인 카드 유형은 다음과 같습니다.

    summary
    summary_large_image

    summary

    작은 썸네일과 제목, 설명을 표시할 때 사용합니다.

    <meta name="twitter:card" content="summary">

    summary_large_image

    가로형 대형 이미지를 중심으로 보여줄 때 사용합니다.

    <meta
      name="twitter:card"
      content="summary_large_image"
    >

    블로그 게시물에는 일반적으로 summary_large_image가 잘 어울립니다.

    Twitter Card 전체 예제

    <meta
      name="twitter:card"
      content="summary_large_image"
    >
    <meta
      name="twitter:title"
      content="SEO와 SNS 공유를 위한 메타태그 총정리"
    >
    <meta
      name="twitter:description"
      content="검색엔진과 SNS 공유에 필요한 메타태그 설정 방법을 설명합니다."
    >
    <meta
      name="twitter:image"
      content="https://example.com/images/meta-tags-og.jpg"
    >
    <meta
      name="twitter:image:alt"
      content="SEO와 SNS 공유 메타태그 구성"
    >

    사이트나 작성자의 X 계정이 있다면 다음 값을 추가할 수도 있습니다.

    <meta
      name="twitter:site"
      content="@site_account"
    >
    <meta
      name="twitter:creator"
      content="@author_account"
    >

    계정이 없다면 억지로 추가할 필요는 없습니다.

    Open Graph와 Twitter Card를 둘 다 작성해야 할까?

    두 규격을 함께 작성하는 것이 가장 명확합니다.

    <meta property="og:title" content="페이지 제목">
    <meta property="og:description" content="페이지 설명">
    <meta
      property="og:image"
      content="https://example.com/images/og.jpg"
    >
    
    <meta
      name="twitter:card"
      content="summary_large_image"
    >
    <meta name="twitter:title" content="페이지 제목">
    <meta name="twitter:description" content="페이지 설명">
    <meta
      name="twitter:image"
      content="https://example.com/images/og.jpg"
    >

    일부 서비스는 Twitter Card가 없으면 Open Graph 정보를 대신 사용할 수 있지만, 각 플랫폼에 전달할 값을 명확하게 제어하려면 둘 다 설정하는 편이 좋습니다.

    제목, 설명, 이미지를 동일하게 사용해도 됩니다.

    og:title = twitter:title
    og:description = twitter:description
    og:image = twitter:image

    홈페이지 메타태그 전체 예제

    홈페이지는 사이트 전체를 설명해야 하므로 og:typewebsite로 설정합니다.

    <!DOCTYPE html>
    <html lang="ko">
    <head>
      <meta charset="UTF-8">
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1"
      >
    
      <title>
        CheckVly | 웹 개발과 서버 운영 실무 기록
      </title>
    
      <meta
        name="description"
        content="웹 개발, Linux 서버 운영, 데이터베이스와 업무 자동화 과정에서 직접 겪은 문제와 해결 방법을 기록합니다."
      >
    
      <link
        rel="canonical"
        href="https://example.com/"
      >
    
      <meta property="og:type" content="website">
      <meta
        property="og:title"
        content="CheckVly | 웹 개발과 서버 운영 실무 기록"
      >
      <meta
        property="og:description"
        content="웹 개발과 서버 운영 과정에서 직접 겪은 문제와 해결 방법을 기록하는 개발 블로그입니다."
      >
      <meta
        property="og:url"
        content="https://example.com/"
      >
      <meta
        property="og:image"
        content="https://example.com/images/site-og.jpg"
      >
      <meta property="og:image:width" content="1200">
      <meta property="og:image:height" content="630">
      <meta
        property="og:image:alt"
        content="CheckVly 개발 블로그"
      >
      <meta property="og:site_name" content="CheckVly">
      <meta property="og:locale" content="ko_KR">
    
      <meta
        name="twitter:card"
        content="summary_large_image"
      >
      <meta
        name="twitter:title"
        content="CheckVly | 웹 개발과 서버 운영 실무 기록"
      >
      <meta
        name="twitter:description"
        content="웹 개발과 서버 운영 과정에서 직접 겪은 문제와 해결 방법을 기록합니다."
      >
      <meta
        name="twitter:image"
        content="https://example.com/images/site-og.jpg"
      >
    </head>
    <body>
      ...
    </body>
    </html>

    블로그 게시물 메타태그 전체 예제

    개별 게시물에는 article 유형을 사용합니다.

    <!DOCTYPE html>
    <html lang="ko">
    <head>
      <meta charset="UTF-8">
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1"
      >
    
      <title>
        SEO와 SNS 공유를 위한 메타태그 총정리 | CheckVly
      </title>
    
      <meta
        name="description"
        content="HTML title, canonical, Open Graph와 Twitter Card의 차이와 설정 방법을 Next.js와 WordPress 예제로 설명합니다."
      >
    
      <link
        rel="canonical"
        href="https://example.com/posts/meta-tags"
      >
    
      <meta property="og:type" content="article">
      <meta
        property="og:title"
        content="SEO와 SNS 공유를 위한 메타태그 총정리"
      >
      <meta
        property="og:description"
        content="title, canonical, Open Graph와 Twitter Card의 차이와 설정 방법을 정리합니다."
      >
      <meta
        property="og:url"
        content="https://example.com/posts/meta-tags"
      >
      <meta
        property="og:image"
        content="https://example.com/images/meta-tags-og.jpg"
      >
      <meta property="og:image:width" content="1200">
      <meta property="og:image:height" content="630">
      <meta
        property="og:image:alt"
        content="SEO와 SNS 공유를 위한 메타태그 구성"
      >
      <meta property="og:site_name" content="CheckVly">
      <meta property="og:locale" content="ko_KR">
    
      <meta
        property="article:published_time"
        content="2026-08-01T09:00:00+09:00"
      >
      <meta
        property="article:modified_time"
        content="2026-08-02T12:30:00+09:00"
      >
      <meta property="article:section" content="개발">
      <meta property="article:tag" content="SEO">
      <meta property="article:tag" content="Open Graph">
    
      <meta
        name="twitter:card"
        content="summary_large_image"
      >
      <meta
        name="twitter:title"
        content="SEO와 SNS 공유를 위한 메타태그 총정리"
      >
      <meta
        name="twitter:description"
        content="검색엔진과 SNS 공유에 필요한 메타태그 설정 방법을 설명합니다."
      >
      <meta
        name="twitter:image"
        content="https://example.com/images/meta-tags-og.jpg"
      >
      <meta
        name="twitter:image:alt"
        content="SEO와 SNS 공유를 위한 메타태그 구성"
      >
    </head>
    <body>
      ...
    </body>
    </html>

    게시물마다 메타태그를 다르게 설정해야 한다

    모든 게시물에 같은 제목, 설명, 이미지가 출력되면 각 페이지의 차이를 제대로 전달할 수 없습니다.

    잘못된 구성은 다음과 같습니다.

    <title>CheckVly</title>
    <meta
      name="description"
      content="개발 정보를 제공하는 블로그입니다."
    >
    <meta
      property="og:title"
      content="CheckVly"
    >
    <meta
      property="og:image"
      content="https://example.com/default.jpg"
    >

    모든 게시물이 동일한 값을 사용하면 공유 미리보기도 모두 같아집니다.

    게시물마다 최소한 다음 값은 다르게 생성하는 것이 좋습니다.

    title
    description
    canonical
    og:title
    og:description
    og:url
    og:image
    twitter:title
    twitter:description
    twitter:image

    Next.js App Router 기본 메타데이터 설정

    Next.js App Router에서는 metadata 객체를 사용하여 정적인 메타데이터를 설정할 수 있습니다.

    app/layout.tsx 예시는 다음과 같습니다.

    import type { Metadata } from 'next';
    
    export const metadata: Metadata = {
      metadataBase: new URL('https://example.com'),
      title: {
        default: 'CheckVly',
        template: '%s | CheckVly',
      },
      description:
        '웹 개발과 서버 운영 과정에서 직접 겪은 문제와 해결 방법을 기록합니다.',
      alternates: {
        canonical: '/',
      },
      openGraph: {
        type: 'website',
        locale: 'ko_KR',
        url: '/',
        siteName: 'CheckVly',
        title: 'CheckVly',
        description:
          '웹 개발과 서버 운영 과정에서 직접 겪은 문제와 해결 방법을 기록합니다.',
        images: [
          {
            url: '/images/site-og.jpg',
            width: 1200,
            height: 630,
            alt: 'CheckVly 개발 블로그',
          },
        ],
      },
      twitter: {
        card: 'summary_large_image',
        title: 'CheckVly',
        description:
          '웹 개발과 서버 운영 과정에서 직접 겪은 문제와 해결 방법을 기록합니다.',
        images: ['/images/site-og.jpg'],
      },
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="ko">
          <body>{children}</body>
        </html>
      );
    }

    metadataBase를 설정하면 상대경로로 작성한 canonical과 이미지 URL을 절대 URL로 변환할 수 있습니다.

    metadataBase: new URL('https://example.com')

    다음 값은 최종적으로 절대 URL로 처리됩니다.

    alternates: {
      canonical: '/posts/meta-tags',
    }
    images: [
      {
        url: '/images/meta-tags-og.jpg',
      },
    ]

    Next.js 정적 게시물 메타데이터 예제

    정적인 페이지라면 page.tsx에서 metadata 객체를 내보낼 수 있습니다.

    import type { Metadata } from 'next';
    
    export const metadata: Metadata = {
      title: 'SEO와 SNS 공유를 위한 메타태그 총정리',
      description:
        'HTML title, canonical, Open Graph와 Twitter Card의 차이와 설정 방법을 설명합니다.',
      alternates: {
        canonical: '/posts/meta-tags',
      },
      openGraph: {
        type: 'article',
        url: '/posts/meta-tags',
        title: 'SEO와 SNS 공유를 위한 메타태그 총정리',
        description:
          'title, canonical, Open Graph와 Twitter Card의 차이를 정리합니다.',
        publishedTime: '2026-08-01T09:00:00+09:00',
        modifiedTime: '2026-08-02T12:30:00+09:00',
        section: '개발',
        tags: ['SEO', 'Open Graph'],
        images: [
          {
            url: '/images/meta-tags-og.jpg',
            width: 1200,
            height: 630,
            alt: 'SEO와 SNS 공유를 위한 메타태그 구성',
          },
        ],
      },
      twitter: {
        card: 'summary_large_image',
        title: 'SEO와 SNS 공유를 위한 메타태그 총정리',
        description:
          '검색엔진과 SNS 공유에 필요한 메타태그 설정 방법을 설명합니다.',
        images: ['/images/meta-tags-og.jpg'],
      },
    };
    
    export default function Page() {
      return (
        <main>
          <h1>
            SEO와 SNS 공유를 위한 메타태그 총정리
          </h1>
        </main>
      );
    }

    Next.js 동적 게시물 generateMetadata 예제

    게시물 데이터에 따라 제목과 설명이 달라진다면 generateMetadata를 사용합니다.

    import type { Metadata } from 'next';
    import { notFound } from 'next/navigation';
    
    interface Post {
      slug: string;
      title: string;
      description: string;
      content: string;
      imageUrl: string;
      imageAlt: string;
      publishedAt: string;
      modifiedAt: string;
      category: string;
      tags: string[];
    }
    
    interface PageProps {
      params: Promise<{
        slug: string;
      }>;
    }
    
    async function getPost(slug: string): Promise<Post | null> {
      const response = await fetch(
        `https://api.example.com/posts/${encodeURIComponent(slug)}`,
        {
          next: {
            revalidate: 3600,
          },
        },
      );
    
      if (response.status === 404) {
        return null;
      }
    
      if (!response.ok) {
        throw new Error(
          `게시물 조회 실패: ${response.status}`,
        );
      }
    
      return response.json() as Promise<Post>;
    }
    
    export async function generateMetadata({
      params,
    }: PageProps): Promise<Metadata> {
      const { slug } = await params;
      const post = await getPost(slug);
    
      if (!post) {
        return {
          title: '게시물을 찾을 수 없습니다',
          robots: {
            index: false,
            follow: false,
          },
        };
      }
    
      const canonicalUrl = `/posts/${post.slug}`;
    
      return {
        title: post.title,
        description: post.description,
        alternates: {
          canonical: canonicalUrl,
        },
        openGraph: {
          type: 'article',
          url: canonicalUrl,
          title: post.title,
          description: post.description,
          publishedTime: post.publishedAt,
          modifiedTime: post.modifiedAt,
          section: post.category,
          tags: post.tags,
          images: [
            {
              url: post.imageUrl,
              width: 1200,
              height: 630,
              alt: post.imageAlt,
            },
          ],
        },
        twitter: {
          card: 'summary_large_image',
          title: post.title,
          description: post.description,
          images: [post.imageUrl],
        },
      };
    }
    
    export default async function PostPage({
      params,
    }: PageProps) {
      const { slug } = await params;
      const post = await getPost(slug);
    
      if (!post) {
        notFound();
      }
    
      return (
        <article>
          <h1>{post.title}</h1>
          <div>{post.content}</div>
        </article>
      );
    }

    metadata 객체와 generateMetadata는 Server Component에서 사용해야 합니다.

    다음과 같이 클라이언트 컴포넌트로 선언한 파일에서는 함께 사용할 수 없습니다.

    'use client';
    
    export const metadata = {
      title: '페이지 제목',
    };

    페이지 자체에 클라이언트 기능이 필요하다면 메타데이터는 서버 페이지에 두고, 실제 인터랙션 부분만 별도의 Client Component로 분리하는 것이 좋습니다.

    Next.js OG 이미지 파일 규칙 사용

    Next.js App Router에서는 파일 이름으로 Open Graph 이미지를 지정할 수도 있습니다.

    app/
    ├── layout.tsx
    ├── page.tsx
    ├── opengraph-image.jpg
    └── twitter-image.jpg

    루트 app 디렉터리에 다음 파일을 두면 사이트 기본 공유 이미지로 사용할 수 있습니다.

    app/opengraph-image.jpg
    app/twitter-image.jpg

    특정 게시물 경로에만 다른 이미지를 적용할 수도 있습니다.

    app/
    └── posts/
        └── meta-tags/
            ├── page.tsx
            ├── opengraph-image.jpg
            └── twitter-image.jpg

    이미지 대체 텍스트 파일도 추가할 수 있습니다.

    opengraph-image.alt.txt
    twitter-image.alt.txt

    파일 내용 예시는 다음과 같습니다.

    SEO와 SNS 공유를 위한 메타태그 구성

    Next.js가 해당 이미지 정보를 기반으로 필요한 메타태그를 자동 생성합니다.

    Next.js에서 자주 발생하는 실수

    metadataBase 누락

    상대경로 이미지를 사용하면서 metadataBase를 설정하지 않으면 URL 처리 과정에서 문제가 발생할 수 있습니다.

    images: ['/images/og.jpg']

    루트 레이아웃에 다음 설정을 추가합니다.

    metadataBase: new URL('https://example.com')

    부모 openGraph 설정이 자동 병합될 것이라고 생각하는 경우

    하위 페이지에서 openGraph를 다시 정의하면 상위 레이아웃의 관련 설정이 교체될 수 있습니다.

    따라서 하위 페이지에서도 필요한 값을 명확히 설정하는 편이 안전합니다.

    openGraph: {
      type: 'article',
      siteName: 'CheckVly',
      locale: 'ko_KR',
      title: post.title,
      description: post.description,
      images: [post.imageUrl],
    }

    클라이언트 컴포넌트에서 metadata 사용

    다음 구성은 피해야 합니다.

    'use client';
    
    import type { Metadata } from 'next';
    
    export const metadata: Metadata = {
      title: '제목',
    };

    메타데이터는 서버 컴포넌트인 layout.tsx 또는 page.tsx에서 설정합니다.

    WordPress에서 메타태그 적용하기

    WordPress는 테마와 플러그인 구성에 따라 메타태그 출력 방식이 달라집니다.

    일반적으로 다음 두 가지 방식이 있습니다.

    SEO 플러그인을 사용한다.
    테마 또는 커스텀 플러그인에서 직접 출력한다.

    대부분의 운영 사이트에서는 SEO 플러그인을 사용하는 방식이 관리하기 쉽습니다.

    대표적으로 다음과 같은 플러그인이 사용됩니다.

    Yoast SEO
    Rank Math
    All in One SEO

    한 사이트에서 여러 SEO 플러그인을 동시에 활성화하면 동일한 메타태그가 중복 출력될 수 있습니다.

    SEO 플러그인은 하나만 사용하는 것이 좋습니다.

    WordPress SEO 플러그인으로 설정할 항목

    게시물 편집 화면에서 일반적으로 다음 값을 설정할 수 있습니다.

    SEO 제목
    메타 설명
    canonical URL
    Facebook 제목
    Facebook 설명
    Facebook 이미지
    Twitter 제목
    Twitter 설명
    Twitter 이미지

    플러그인에 따라 Facebook 항목이 Open Graph 설정을 의미합니다.

    게시물별 대표 이미지를 설정하면 해당 이미지가 og:image로 사용될 수 있습니다.

    하지만 플러그인마다 우선순위가 다를 수 있습니다.

    게시물별 SNS 이미지
    대표 이미지
    사이트 기본 Open Graph 이미지
    플러그인 기본 이미지

    설정 후 실제 HTML 출력을 확인해야 합니다.

    WordPress 실제 메타태그 확인

    브라우저에서 게시물을 연 다음 페이지 소스를 확인합니다.

    마우스 오른쪽 버튼
    → 페이지 소스 보기

    다음 문자열을 검색합니다.

    <title>
    description
    canonical
    og:title
    og:image
    twitter:card

    터미널에서도 확인할 수 있습니다.

    curl -s https://example.com/posts/meta-tags |
    grep -Ei \
      '<title|description|canonical|og:|twitter:'

    중복 태그가 있는지 확인합니다.

    잘못된 예시는 다음과 같습니다.

    <meta
      name="description"
      content="플러그인에서 만든 설명"
    >
    <meta
      name="description"
      content="테마에서 만든 설명"
    >

    canonical도 하나만 출력되는 것이 좋습니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >

    다음처럼 여러 개가 출력되면 설정 충돌을 확인해야 합니다.

    <link
      rel="canonical"
      href="https://example.com/posts/meta-tags"
    >
    <link
      rel="canonical"
      href="https://example.com/?p=100"
    >

    WordPress 테마에서 직접 출력하는 방법

    SEO 플러그인을 사용하지 않고 직접 구현해야 한다면 wp_head 훅을 사용할 수 있습니다.

    다만 테마를 업데이트하면 코드가 사라질 수 있으므로 자식 테마 또는 별도의 커스텀 플러그인에 작성하는 것이 좋습니다.

    예시는 다음과 같습니다.

    <?php
    
    declare(strict_types=1);
    
    add_action('wp_head', function (): void {
        if (!is_singular('post')) {
            return;
        }
    
        $postId = get_queried_object_id();
    
        if ($postId <= 0) {
            return;
        }
    
        $title = get_the_title($postId);
        $description = get_the_excerpt($postId);
        $canonicalUrl = get_permalink($postId);
        $imageUrl = get_the_post_thumbnail_url(
            $postId,
            'full'
        );
    
        if ($description === '') {
            $content = get_post_field(
                'post_content',
                $postId
            );
    
            $description = wp_trim_words(
                wp_strip_all_tags($content),
                35,
                '...'
            );
        }
    
        if ($imageUrl === false) {
            $imageUrl = get_site_icon_url(512);
        }
    
        echo PHP_EOL;
        echo '<meta property="og:type" content="article">' .
            PHP_EOL;
    
        printf(
            '<meta property="og:title" content="%s">' .
            PHP_EOL,
            esc_attr($title)
        );
    
        printf(
            '<meta property="og:description" content="%s">' .
            PHP_EOL,
            esc_attr($description)
        );
    
        printf(
            '<meta property="og:url" content="%s">' .
            PHP_EOL,
            esc_url($canonicalUrl)
        );
    
        if ($imageUrl !== '') {
            printf(
                '<meta property="og:image" content="%s">' .
                PHP_EOL,
                esc_url($imageUrl)
            );
        }
    
        printf(
            '<meta property="og:site_name" content="%s">' .
            PHP_EOL,
            esc_attr(get_bloginfo('name'))
        );
    
        echo '<meta name="twitter:card" ' .
            'content="summary_large_image">' .
            PHP_EOL;
    
        printf(
            '<meta name="twitter:title" content="%s">' .
            PHP_EOL,
            esc_attr($title)
        );
    
        printf(
            '<meta name="twitter:description" content="%s">' .
            PHP_EOL,
            esc_attr($description)
        );
    
        if ($imageUrl !== '') {
            printf(
                '<meta name="twitter:image" content="%s">' .
                PHP_EOL,
                esc_url($imageUrl)
            );
        }
    }, 20);

    이 코드는 예시이며 운영 사이트에서는 다음 사항을 추가로 고려해야 합니다.

    SEO 플러그인과 중복 출력 여부
    홈페이지와 페이지 유형 처리
    카테고리와 태그 아카이브 처리
    기본 공유 이미지
    다국어 사이트 locale 처리
    이미지 크기
    canonical 처리
    캐시 플러그인

    SEO 플러그인을 사용 중이라면 위 코드를 함께 추가하지 않는 것이 좋습니다.

    WordPress 대표 이미지 설정

    게시물 편집 화면에서 대표 이미지를 설정합니다.

    게시물 편집
    → 오른쪽 설정 패널
    → 대표 이미지
    → 이미지 선택

    대표 이미지는 가능하면 다음 조건으로 제작합니다.

    권장 크기: 1200 × 630px
    가로형 이미지
    게시물 제목이 잘 보이는 구성
    텍스트가 가장자리와 너무 가깝지 않음
    파일 용량 최적화

    이미지 대체 텍스트도 입력합니다.

    SEO와 SNS 공유를 위한 메타태그 설정 방법

    대표 이미지가 너무 세로형이거나 작은 경우 SNS에서 확대되거나 잘릴 수 있습니다.

    카카오톡 공유 미리보기 확인

    카카오톡에 URL을 공유하면 페이지의 Open Graph 메타태그를 기반으로 제목, 설명, 이미지를 구성합니다.

    다음 태그를 우선 확인합니다.

    <meta property="og:title" content="...">
    <meta property="og:description" content="...">
    <meta property="og:image" content="...">
    <meta property="og:url" content="...">

    카카오톡에서 이미지가 나오지 않는다면 다음 항목을 확인합니다.

    og:image가 절대 URL인지
    이미지가 외부에서 접근 가능한지
    HTTPS 인증서가 정상인지
    이미지 응답이 200인지
    이미지 파일 크기가 지나치게 크지 않은지
    방화벽이나 CDN이 크롤러를 차단하는지
    이전 정보가 캐시되어 있는지

    브라우저 시크릿 모드에서 이미지를 직접 열어 봅니다.

    https://example.com/images/meta-tags-og.jpg

    로그인 없이 이미지가 보여야 합니다.

    카카오톡 캐시 갱신

    Open Graph 태그나 대표 이미지를 수정해도 카카오톡에는 이전 정보가 계속 보일 수 있습니다.

    이는 카카오가 URL의 공유 정보를 캐시했기 때문일 수 있습니다.

    카카오 개발자 도구에서 URL의 캐시 초기화 기능을 사용하여 다시 수집하도록 요청할 수 있습니다.

    캐시를 초기화한 뒤에도 바로 반영되지 않을 수 있으므로 다음 항목을 다시 확인합니다.

    페이지 HTML에 새 og:title이 출력되는지
    새 og:description이 출력되는지
    og:image URL이 변경되었는지
    새 이미지가 200으로 응답하는지
    WordPress 캐시가 삭제되었는지
    CDN 캐시가 삭제되었는지

    WordPress 캐시 플러그인을 사용 중이라면 게시물 캐시도 삭제합니다.

    예시는 다음과 같습니다.

    LiteSpeed Cache
    WP Rocket
    W3 Total Cache
    WP Super Cache

    CDN을 사용한다면 CDN 캐시도 갱신해야 할 수 있습니다.

    페이스북 공유 디버거 확인

    페이스북에서 공유 미리보기가 잘못 표시되면 Sharing Debugger를 사용하여 페이지를 다시 수집할 수 있습니다.

    디버거에서 URL을 입력하면 다음 정보를 확인할 수 있습니다.

    수집된 og:title
    수집된 og:description
    수집된 og:image
    응답 오류
    이미지 크기
    리디렉션 URL
    Open Graph 경고

    정보를 수정한 후 다시 스크랩하는 기능을 사용하면 새 메타데이터를 다시 수집하도록 요청할 수 있습니다.

    다만 실제 HTML이 변경되지 않았다면 다시 수집해도 같은 정보가 표시됩니다.

    먼저 브라우저 소스에서 메타태그가 제대로 출력되는지 확인해야 합니다.

    SNS 캐시가 갱신되지 않을 때 점검 순서

    다음 순서로 확인합니다.

    1. 페이지 소스에서 실제 메타태그 확인
    2. canonical과 og:url 확인
    3. og:image 절대 URL 확인
    4. 이미지 직접 접근 확인
    5. 이미지 Content-Type 확인
    6. WordPress 페이지 캐시 삭제
    7. CDN 캐시 삭제
    8. SNS 디버거로 다시 수집
    9. 공유할 URL이 정확한지 확인

    curl로 메타태그를 확인할 수 있습니다.

    curl -L -s \
      https://example.com/posts/meta-tags |
    grep -Ei \
      'og:title|og:description|og:image|og:url|twitter:'

    이미지 응답을 확인합니다.

    curl -I \
      https://example.com/images/meta-tags-og.jpg

    리디렉션도 확인합니다.

    curl -IL \
      http://www.example.com/posts/meta-tags

    최종 주소가 canonical과 같은 대표 URL로 이동하는 것이 좋습니다.

    JavaScript로 메타태그를 나중에 넣어도 될까?

    일부 검색엔진은 JavaScript 실행 후 생성된 메타태그를 처리할 수 있습니다.

    하지만 SNS와 메신저 크롤러는 브라우저처럼 JavaScript를 완전히 실행하지 않을 수 있습니다.

    따라서 Open Graph와 Twitter Card 태그는 가능한 한 초기 HTML 응답의 <head>에 포함하는 것이 안전합니다.

    좋지 않은 예시는 다음과 같습니다.

    <script>
      const meta = document.createElement('meta');
      meta.setAttribute('property', 'og:title');
      meta.setAttribute('content', '페이지 제목');
    
      document.head.appendChild(meta);
    </script>

    서버 렌더링이나 정적 생성 단계에서 미리 출력하는 것이 좋습니다.

    Next.js App Router에서는 metadata 또는 generateMetadata를 사용하면 초기 HTML에 필요한 메타태그를 생성할 수 있습니다.

    메타태그 적용 후 확인하는 방법

    브라우저 개발자 도구의 Elements 탭보다 페이지 소스 보기를 먼저 확인하는 것이 좋습니다.

    Elements 탭에는 JavaScript 실행 후 변경된 내용이 표시될 수 있습니다.

    실제 서버가 보낸 초기 HTML을 확인하려면 다음 방법을 사용합니다.

    마우스 오른쪽 버튼
    → 페이지 소스 보기

    또는 curl을 사용합니다.

    curl -L -s https://example.com/posts/meta-tags

    필요한 태그만 검색합니다.

    curl -L -s \
      https://example.com/posts/meta-tags |
    grep -Ei \
      '<title|description|canonical|og:|twitter:'

    자주 발생하는 메타태그 오류

    title이 모든 페이지에서 동일함

    <title>사이트 이름</title>

    게시물마다 고유한 제목을 생성해야 합니다.

    description이 비어 있음

    <meta name="description" content="">

    페이지별 요약 내용을 작성합니다.

    canonical이 다른 게시물을 가리킴

    <link
      rel="canonical"
      href="https://example.com/wrong-post"
    >

    현재 게시물의 대표 URL을 지정해야 합니다.

    og:image가 상대경로임

    <meta
      property="og:image"
      content="/images/og.jpg"
    >

    절대 URL을 사용합니다.

    <meta
      property="og:image"
      content="https://example.com/images/og.jpg"
    >

    HTTP 이미지 사용

    <meta
      property="og:image"
      content="http://example.com/images/og.jpg"
    >

    페이지가 HTTPS라면 이미지도 HTTPS로 제공하는 것이 좋습니다.

    이미지가 로그인 후에만 보임

    SNS 크롤러는 로그인할 수 없으므로 공개 이미지 URL을 사용해야 합니다.

    메타태그가 body에 있음

    잘못된 예시는 다음과 같습니다.

    <body>
      <meta property="og:title" content="제목">
    </body>

    메타태그는 <head> 안에 작성합니다.

    SEO 플러그인이 여러 개 활성화됨

    여러 플러그인이 다음 태그를 중복 출력할 수 있습니다.

    description
    canonical
    og:title
    og:image
    twitter:card

    SEO 플러그인은 하나만 사용하는 것이 좋습니다.

    이미지 수정 후에도 이전 이미지가 표시됨

    다음 캐시를 확인합니다.

    WordPress 페이지 캐시
    CDN 캐시
    카카오 공유 캐시
    페이스북 공유 캐시
    브라우저 캐시

    이미지 파일명을 변경하는 것도 도움이 될 수 있습니다.

    meta-tags-og-v2.jpg

    메타태그 최종 체크리스트

    [ ] 페이지마다 고유한 title이 있다.
    [ ] title이 실제 페이지 내용을 정확히 설명한다.
    [ ] 페이지마다 고유한 description이 있다.
    [ ] description에 검색어를 반복해서 넣지 않았다.
    [ ] canonical이 현재 페이지의 대표 URL을 가리킨다.
    [ ] canonical은 절대 URL이다.
    [ ] canonical URL이 정상적으로 200 응답을 반환한다.
    [ ] 홈페이지 og:type은 website로 설정했다.
    [ ] 게시물 og:type은 article로 설정했다.
    [ ] og:title과 og:description이 페이지 내용과 일치한다.
    [ ] og:url과 canonical이 같은 대표 URL을 사용한다.
    [ ] og:image는 HTTPS 절대 URL이다.
    [ ] og:image를 로그인 없이 열 수 있다.
    [ ] og:image가 실제 이미지 Content-Type을 반환한다.
    [ ] Open Graph 이미지 크기가 공유에 적합하다.
    [ ] og:image:alt를 설정했다.
    [ ] twitter:card를 설정했다.
    [ ] Twitter Card 제목, 설명, 이미지를 확인했다.
    [ ] 홈페이지와 게시물 메타태그를 구분했다.
    [ ] Next.js에서는 metadata 또는 generateMetadata를 사용했다.
    [ ] Next.js metadataBase를 설정했다.
    [ ] WordPress SEO 플러그인은 하나만 활성화했다.
    [ ] 페이지 소스에서 중복 메타태그가 없는지 확인했다.
    [ ] 카카오톡 공유 캐시를 확인했다.
    [ ] 페이스북 공유 디버거로 수집 결과를 확인했다.
    [ ] WordPress와 CDN 캐시를 삭제한 후 다시 테스트했다.

    정리

    검색엔진용 메타태그와 SNS 공유용 메타태그는 역할이 다릅니다.

    <title>description은 검색 결과에서 페이지의 제목과 설명을 전달하는 데 사용됩니다.

    canonical은 같은 콘텐츠가 여러 URL로 접근될 때 대표 URL을 알려주는 역할을 합니다.

    Open Graph는 카카오톡, 페이스북, 디스코드와 같은 서비스에서 공유 미리보기를 구성하는 데 사용됩니다.

    Twitter Card는 X에서 링크를 공유할 때 카드 형태의 미리보기를 구성합니다.

    블로그 게시물에서는 최소한 다음 항목을 페이지마다 다르게 설정하는 것이 좋습니다.

    title
    description
    canonical
    og:title
    og:description
    og:url
    og:image
    twitter:title
    twitter:description
    twitter:image

    Next.js App Router에서는 metadatagenerateMetadata를 사용하여 서버 렌더링 단계에서 메타태그를 생성할 수 있습니다.

    WordPress에서는 SEO 플러그인을 하나만 사용하고, 게시물별 SEO 제목과 설명, 대표 이미지를 설정하는 방식이 가장 관리하기 쉽습니다.

    메타태그를 수정한 뒤에는 브라우저 화면만 확인하지 말고 페이지 소스에서 실제 출력 결과를 확인해야 합니다.

    카카오톡이나 페이스북에 이전 이미지가 계속 표시된다면 WordPress와 CDN 캐시를 먼저 삭제하고, 각 서비스의 공유 디버거나 캐시 초기화 기능을 사용하여 다시 수집해야 합니다.


    SEO 제목: SEO와 SNS 공유를 위한 메타태그 총정리: Open Graph, Twitter Card

    슬러그: seo-open-graph-meta-tags

    메타 설명: HTML title, description, canonical, Open Graph와 Twitter Card의 차이와 설정 방법을 정리합니다. Next.js App Router와 WordPress 적용 예제, 카카오톡과 페이스북 공유 캐시 확인 방법도 설명합니다.

  • 리눅스 tar 압축과 해제 명령어 정리: tar.gz, tar.bz2, tar.xz 차이까지

    리눅스 서버에서 파일이나 디렉터리를 백업할 때 자주 사용하는 명령어가 tar입니다.

    보통 tar.gz 파일을 보고 “tar로 압축했다”고 표현하지만, 정확히는 targzip의 역할이 다릅니다.

    • tar: 여러 파일과 디렉터리를 하나의 파일로 묶는 아카이브 도구
    • gzip, bzip2, xz: 아카이브 파일의 크기를 줄이는 압축 도구

    즉, backup.tar.gz 파일은 여러 파일을 backup.tar로 묶은 다음 gzip으로 압축한 결과입니다.

    이 글에서는 tar의 기본 개념부터 압축과 해제, 특정 파일 제외, 경로 지정, 권한 보존, 운영 서버 백업 사례까지 정리합니다.

    tar는 압축 형식이 아니라 아카이브 형식이다

    tarTape Archive의 약자입니다.

    원래 여러 파일을 테이프 장치에 순서대로 저장하기 위해 만들어졌으며, 현재는 여러 파일과 디렉터리를 하나의 아카이브 파일로 묶는 용도로 사용합니다.

    예를 들어 다음과 같은 프로젝트 디렉터리가 있다고 가정해 보겠습니다.

    project/
    ├── src/
    ├── public/
    ├── package.json
    └── README.md

    다음 명령어를 실행하면 project 디렉터리 전체를 하나의 project.tar 파일로 묶습니다.

    tar -cvf project.tar project/

    이 상태에서는 파일들을 하나로 묶었을 뿐, 일반적으로 데이터 크기는 거의 줄어들지 않습니다.

    gzip 압축까지 함께 적용하면 다음과 같이 실행합니다.

    tar -czvf project.tar.gz project/

    처리 과정은 다음과 같습니다.

    project 디렉터리
            ↓
    tar로 하나의 파일로 묶기
            ↓
    project.tar
            ↓
    gzip으로 압축
            ↓
    project.tar.gz

    tar에서 자주 사용하는 옵션

    tar 명령어에서 자주 사용하는 옵션은 다음과 같습니다.

    옵션의미
    -c새로운 아카이브 생성
    -x아카이브 해제
    -t아카이브 내부 목록 조회
    -v처리 중인 파일명 출력
    -f아카이브 파일명 지정
    -zgzip 압축 사용
    -jbzip2 압축 사용
    -Jxz 압축 사용
    -C작업 기준 디렉터리 지정
    -p파일 권한 보존
    --exclude특정 파일이나 디렉터리 제외

    가장 기본이 되는 옵션은 c, x, t입니다.

    c: create
    x: extract
    t: list

    또한 -f 옵션 뒤에는 아카이브 파일명이 와야 합니다.

    tar -cvf backup.tar project/

    위 명령어에서 backup.tar가 생성할 아카이브 파일이고, project/가 묶을 대상입니다.

    tar, tar.gz, tar.bz2, tar.xz 차이

    tar

    확장자는 .tar입니다.

    tar -cvf backup.tar project/

    특징은 다음과 같습니다.

    • 여러 파일을 하나로 묶음
    • 별도의 데이터 압축은 하지 않음
    • 생성과 해제가 빠름
    • 파일 크기가 거의 줄어들지 않음
    • 파일 전달이나 임시 묶음에 적합

    tar.gz

    확장자는 .tar.gz 또는 .tgz입니다.

    tar -czvf backup.tar.gz project/

    tar로 묶은 파일을 gzip으로 압축합니다.

    특징은 다음과 같습니다.

    • 압축과 해제 속도가 빠른 편
    • 압축률도 무난함
    • 대부분의 리눅스 환경에서 지원
    • 일반적인 서버 백업과 배포 파일에 적합

    특별한 이유가 없다면 일반적인 서버 백업에는 tar.gz가 가장 무난합니다.

    tar.bz2

    확장자는 .tar.bz2 또는 .tbz2입니다.

    tar -cjvf backup.tar.bz2 project/

    tar로 묶은 파일을 bzip2로 압축합니다.

    특징은 다음과 같습니다.

    • gzip보다 압축률이 좋은 경우가 있음
    • gzip보다 압축과 해제가 느린 편
    • 기존 리눅스 환경에서 비교적 널리 지원

    tar.xz

    확장자는 .tar.xz 또는 .txz입니다.

    tar -cJvf backup.tar.xz project/

    J는 대문자입니다.

    특징은 다음과 같습니다.

    • gzip보다 압축률이 높은 경우가 많음
    • 압축 시간이 오래 걸릴 수 있음
    • CPU와 메모리 사용량이 더 높을 수 있음
    • 배포 패키지나 장기 보관용 파일에 적합

    형식별 특징을 간단히 비교하면 다음과 같습니다.

    형식압축 속도압축률일반적인 용도
    .tar매우 빠름압축 없음빠른 파일 묶음
    .tar.gz빠름보통일반 백업 및 배포
    .tar.bz2느림보통 이상기존 환경 및 보관
    .tar.xz느림높음장기 보관 및 배포 패키지

    실제 압축률은 파일 종류에 따라 달라집니다.

    텍스트 파일이나 로그 파일은 압축이 잘되지만, JPEG, MP4, ZIP처럼 이미 압축된 파일은 다시 압축해도 크기가 크게 줄지 않을 수 있습니다.

    tar 압축 명령어

    디렉터리를 tar 파일로 묶기

    tar -cvf backup.tar project/

    옵션의 의미는 다음과 같습니다.

    -c: 새로운 아카이브 생성
    -v: 처리 중인 파일 출력
    -f: 아카이브 파일명 지정

    처리되는 파일 목록을 출력할 필요가 없다면 v를 제외할 수 있습니다.

    tar -cf backup.tar project/

    파일 수가 많은 운영 서버에서는 -v를 사용하면 터미널 출력이 지나치게 많아질 수 있으므로 생략하는 편이 좋습니다.

    gzip으로 압축하기

    tar -czvf backup.tar.gz project/

    파일 목록을 출력하지 않으려면 다음과 같이 실행합니다.

    tar -czf backup.tar.gz project/

    bzip2로 압축하기

    tar -cjvf backup.tar.bz2 project/

    xz로 압축하기

    tar -cJvf backup.tar.xz project/

    여러 파일과 디렉터리를 함께 압축하기

    여러 개의 파일과 디렉터리를 하나의 아카이브에 포함할 수 있습니다.

    tar -czf backup.tar.gz project/ config/ README.md

    위 명령어를 실행하면 다음 항목이 하나의 파일로 압축됩니다.

    project/
    config/
    README.md

    로그 파일만 압축하려면 와일드카드를 사용할 수도 있습니다.

    tar -czf logs.tar.gz *.log

    다만 *는 tar가 아니라 셸에서 먼저 확장합니다.

    운영 서버에서는 예상하지 못한 파일이 포함되지 않도록 가능한 한 대상 경로를 명확하게 지정하는 것이 안전합니다.

    대상 경로를 지정하여 압축하기

    /var/www/project 디렉터리를 압축한다고 가정해 보겠습니다.

    다음처럼 절대경로를 직접 지정할 수 있습니다.

    tar -czf project.tar.gz /var/www/project

    그러나 이 방식보다 -C 옵션을 사용하는 것이 좋습니다.

    tar -czf project.tar.gz -C /var/www project

    위 명령어는 다음 순서로 동작합니다.

    1. 작업 기준 디렉터리를 /var/www로 변경
    2. 그 안에 있는 project 디렉터리를 압축
    3. 현재 명령어를 실행한 위치에 project.tar.gz 생성

    아카이브 내부에는 다음과 같은 상대경로가 저장됩니다.

    project/
    project/src/
    project/public/
    project/package.json

    경로가 깔끔하게 저장되기 때문에 다른 서버나 다른 디렉터리에 복원하기도 편합니다.

    원하는 위치에 압축 파일 만들기

    압축 결과 파일을 /backup 디렉터리에 생성하려면 다음과 같이 실행합니다.

    tar -czf /backup/project.tar.gz -C /var/www project

    각 경로의 역할은 다음과 같습니다.

    /backup/project.tar.gz
    └── 생성되는 압축 파일
    
    /var/www/project
    └── 압축할 대상 디렉터리

    백업 디렉터리가 없다면 먼저 생성해야 합니다.

    mkdir -p /backup

    tar 해제 명령어

    tar 파일 해제

    tar -xvf backup.tar

    파일 목록 출력을 생략하려면 다음과 같이 실행합니다.

    tar -xf backup.tar

    tar.gz 파일 해제

    tar -xzvf backup.tar.gz

    간단하게는 다음과 같이 사용할 수 있습니다.

    tar -xzf backup.tar.gz

    tar.bz2 파일 해제

    tar -xjvf backup.tar.bz2

    tar.xz 파일 해제

    tar -xJvf backup.tar.xz

    최근 GNU tar 환경에서는 파일 확장자를 확인하여 다음과 같이 해제할 수도 있습니다.

    tar -xf backup.tar.gz
    tar -xf backup.tar.xz

    다만 스크립트의 동작을 명확하게 표현하려면 -z, -j, -J 옵션을 직접 지정하는 편이 알아보기 쉽습니다.

    원하는 디렉터리에 해제하기

    -C 옵션을 사용하면 원하는 위치에 압축을 해제할 수 있습니다.

    mkdir -p /restore/project
    
    tar -xzf backup.tar.gz -C /restore/project

    주의할 점은 -C로 지정한 디렉터리가 먼저 존재해야 한다는 것입니다.

    셸 스크립트에서는 다음과 같이 사용할 수 있습니다.

    RESTORE_DIR="/restore/project"
    
    mkdir -p "$RESTORE_DIR"
    tar -xzf backup.tar.gz -C "$RESTORE_DIR"

    경로에 공백이 포함될 가능성을 고려하여 변수는 큰따옴표로 감싸는 것이 안전합니다.

    압축 파일 내부 목록 확인하기

    압축 파일을 실제로 해제하지 않고 내부에 어떤 파일이 들어 있는지 확인할 수 있습니다.

    tar 파일 목록 확인

    tar -tvf backup.tar

    tar.gz 파일 목록 확인

    tar -tzvf backup.tar.gz

    파일명만 간단히 확인하려면 다음 명령어를 사용합니다.

    tar -tzf backup.tar.gz

    최근 GNU tar에서는 다음과 같이 사용할 수도 있습니다.

    tar -tf backup.tar.gz

    출력 예시는 다음과 같습니다.

    project/
    project/src/
    project/src/index.js
    project/package.json
    project/README.md

    압축을 해제하기 전에는 다음 항목을 확인하는 것이 좋습니다.

    • 최상위 디렉터리가 포함되어 있는지
    • 파일이 현재 디렉터리에 바로 풀리는 구조인지
    • 예상하지 못한 파일이 포함되어 있지 않은지
    • 기존 파일을 덮어쓸 가능성이 있는지
    • 심볼릭 링크가 포함되어 있는지

    다음과 같이 최상위 디렉터리가 포함되어 있다면 비교적 관리하기 쉽습니다.

    project/
    project/config/
    project/config/app.yml

    반면 다음처럼 파일들이 바로 들어 있다면 현재 디렉터리에 파일이 흩어질 수 있습니다.

    config.yml
    index.js
    package.json
    README.md

    이런 파일은 임시 디렉터리에 먼저 해제하는 것이 안전합니다.

    mkdir -p /tmp/archive-check
    
    tar -xzf backup.tar.gz -C /tmp/archive-check

    특정 파일이나 디렉터리 제외하기

    --exclude 옵션을 사용하면 특정 파일이나 디렉터리를 압축 대상에서 제외할 수 있습니다.

    node_modules 제외

    tar -czf project.tar.gz \
      --exclude='project/node_modules' \
      project/

    로그 디렉터리 제외

    tar -czf project.tar.gz \
      --exclude='project/logs' \
      project/

    특정 확장자 제외

    tar -czf project.tar.gz \
      --exclude='*.log' \
      project/

    여러 항목 제외

    tar -czf project.tar.gz \
      --exclude='project/node_modules' \
      --exclude='project/.git' \
      --exclude='project/logs' \
      --exclude='project/tmp' \
      --exclude='*.log' \
      project/

    --exclude 패턴은 아카이브 내부에 저장되는 경로를 기준으로 작성하는 것이 좋습니다.

    다음 명령어를 실행한다고 가정해 보겠습니다.

    tar -czf project.tar.gz project/

    아카이브 내부 경로는 다음처럼 저장됩니다.

    project/node_modules/

    따라서 제외 패턴도 다음처럼 작성합니다.

    --exclude='project/node_modules'

    압축이 끝난 후에는 제외 항목이 실제로 빠졌는지 확인할 수 있습니다.

    tar -tzf project.tar.gz | grep node_modules

    출력이 없다면 node_modules가 제외된 것입니다.

    제외 목록을 파일로 관리하기

    제외 대상이 많다면 별도의 파일로 관리할 수 있습니다.

    exclude.txt 파일을 다음과 같이 작성합니다.

    project/node_modules
    project/.git
    project/logs
    project/tmp
    *.log
    *.tmp

    다음 명령어로 제외 목록을 적용합니다.

    tar -czf project.tar.gz \
      --exclude-from=exclude.txt \
      project/

    짧은 옵션인 -X를 사용할 수도 있습니다.

    tar -czf project.tar.gz -X exclude.txt project/

    백업 스크립트에서는 제외 목록을 별도 파일로 관리하면 수정과 검토가 편리합니다.

    Removing leading slash 경고의 의미

    절대경로를 직접 지정해 압축하면 다음과 같은 경고가 나타날 수 있습니다.

    tar -czf backup.tar.gz /var/www/project

    출력 메시지는 다음과 같습니다.

    tar: Removing leading '/' from member names

    이 메시지는 일반적으로 오류가 아닙니다.

    tar가 절대경로의 맨 앞 /를 제거하여 상대경로로 저장했다는 의미입니다.

    원래 경로
    /var/www/project
    
    아카이브 내부 경로
    var/www/project

    tar가 맨 앞의 /를 제거하는 이유는 아카이브를 해제했을 때 시스템의 실제 경로를 바로 덮어쓰는 위험을 줄이기 위해서입니다.

    예를 들어 아카이브 안에 다음 경로가 그대로 저장되어 있다고 가정해 보겠습니다.

    /etc/passwd

    절대경로를 유지한 채 관리자 권한으로 해제하면 실제 시스템의 /etc/passwd 파일에 영향을 줄 수 있습니다.

    tar는 기본적으로 다음과 같은 상대경로로 저장합니다.

    etc/passwd

    현재 디렉터리에서 해제하면 다음 위치에 파일이 생성됩니다.

    ./etc/passwd

    이 경고를 피하면서 깔끔하게 압축하려면 -C 옵션을 사용합니다.

    tar -czf backup.tar.gz -C /var/www project

    절대경로를 유지하는 방법

    GNU tar에서는 -P 또는 --absolute-names 옵션으로 절대경로를 유지할 수 있습니다.

    tar -czPf backup.tar.gz /var/www/project

    다만 일반적인 애플리케이션 백업에서는 권장하지 않습니다.

    특히 root 권한으로 절대경로 아카이브를 해제하면 실제 시스템 경로의 파일을 덮어쓸 수 있습니다.

    sudo tar -xzPf backup.tar.gz

    특별한 목적이 없다면 절대경로를 저장하기보다 -C 옵션을 사용하는 것이 안전합니다.

    소유권과 권한 보존

    tar 아카이브에는 일반적으로 다음 정보가 함께 저장됩니다.

    • 파일 권한
    • 수정 시간
    • 사용자 소유자
    • 그룹 소유자
    • 심볼릭 링크 정보

    다만 실제 복원 결과는 tar를 실행하는 사용자의 권한과 옵션에 따라 달라질 수 있습니다.

    권한을 보존하여 해제하기

    tar -xzpf backup.tar.gz

    -p는 아카이브에 기록된 파일 권한을 최대한 복원할 때 사용하는 옵션입니다.

    -p
    --preserve-permissions
    --same-permissions

    일반 사용자로 해제할 때

    일반 사용자는 다른 UID나 GID의 소유권을 임의로 설정할 수 없습니다.

    아카이브 안의 파일 소유자가 mysql:mysql로 기록되어 있어도 일반 계정으로 해제하면 현재 사용자 소유로 생성될 수 있습니다.

    root로 해제할 때

    root 권한으로 해제하면 아카이브에 저장된 소유권이 복원될 수 있습니다.

    sudo tar -xzpf backup.tar.gz -C /restore

    하지만 다른 서버에서는 사용자 이름이 같아도 UID와 GID가 다를 수 있습니다.

    예를 들어 다음과 같은 차이가 있을 수 있습니다.

    기존 서버 mysql UID: 27
    신규 서버 mysql UID: 999

    복원 후에는 파일 소유권을 반드시 확인해야 합니다.

    ls -ln /restore/project

    소유권이 잘못되었다면 다음과 같이 수정할 수 있습니다.

    sudo chown -R www-data:www-data /restore/project

    현재 사용자 소유로 해제하기

    아카이브에 저장된 소유권을 무시하려면 다음 옵션을 사용할 수 있습니다.

    tar -xzf backup.tar.gz \
      --no-same-owner \
      -C /restore/project

    일반 사용자 계정으로 애플리케이션 소스 파일을 복원할 때 유용합니다.

    운영 서버 백업 예시

    운영 서버의 /var/www/project 애플리케이션을 백업한다고 가정해 보겠습니다.

    백업에서 제외할 대상은 다음과 같습니다.

    node_modules
    .git
    logs
    tmp

    백업 파일은 /backup 디렉터리에 저장합니다.

    mkdir -p /backup

    다음 명령어로 백업할 수 있습니다.

    tar -czf /backup/project-backup.tar.gz \
      --exclude='project/node_modules' \
      --exclude='project/.git' \
      --exclude='project/logs' \
      --exclude='project/tmp' \
      -C /var/www \
      project

    생성된 아카이브의 내부 구조는 다음과 같습니다.

    project/
    project/src/
    project/public/
    project/package.json

    날짜와 시간을 포함한 백업 파일 만들기

    백업 파일명에 날짜와 시간을 포함하면 여러 백업을 구분하기 쉽습니다.

    BACKUP_DATE=$(date '+%Y%m%d_%H%M%S')
    
    tar -czf "/backup/project_${BACKUP_DATE}.tar.gz" \
      --exclude='project/node_modules' \
      --exclude='project/.git' \
      --exclude='project/logs' \
      --exclude='project/tmp' \
      -C /var/www \
      project

    생성되는 파일명은 다음과 같습니다.

    project_20260801_003000.tar.gz

    운영 서버용 백업 스크립트 예시

    다음은 오류 발생 시 작업을 중단하고, 생성된 아카이브를 확인하는 간단한 백업 스크립트입니다.

    #!/usr/bin/env bash
    
    set -euo pipefail
    
    BACKUP_ROOT="/backup"
    SOURCE_PARENT="/var/www"
    SOURCE_NAME="project"
    BACKUP_DATE="$(date '+%Y%m%d_%H%M%S')"
    BACKUP_FILE="${BACKUP_ROOT}/${SOURCE_NAME}_${BACKUP_DATE}.tar.gz"
    
    mkdir -p "$BACKUP_ROOT"
    
    tar -czf "$BACKUP_FILE" \
      --exclude="${SOURCE_NAME}/node_modules" \
      --exclude="${SOURCE_NAME}/.git" \
      --exclude="${SOURCE_NAME}/logs" \
      --exclude="${SOURCE_NAME}/tmp" \
      -C "$SOURCE_PARENT" \
      "$SOURCE_NAME"
    
    tar -tzf "$BACKUP_FILE" > /dev/null
    
    echo "백업 완료: $BACKUP_FILE"

    set -euo pipefail의 의미는 다음과 같습니다.

    • -e: 명령어가 실패하면 스크립트 중단
    • -u: 정의되지 않은 변수를 사용하면 오류 발생
    • pipefail: 파이프라인 중간 명령의 실패도 감지

    다음 명령어는 생성된 아카이브의 목록을 정상적으로 읽을 수 있는지 확인합니다.

    tar -tzf "$BACKUP_FILE" > /dev/null

    다만 이 검사는 아카이브 파일을 읽을 수 있는지만 확인합니다.

    백업한 애플리케이션이나 데이터가 논리적으로 정상인지까지 보장하는 것은 아니므로, 가능하면 별도의 디렉터리나 테스트 서버에서 복원 테스트를 진행해야 합니다.

    데이터베이스 디렉터리를 tar로 백업할 때 주의할 점

    MySQL이나 MariaDB가 실행 중인 상태에서 데이터 디렉터리를 그대로 tar로 압축하는 것은 주의해야 합니다.

    예를 들어 다음 방식입니다.

    tar -czf mysql-data.tar.gz /var/lib/mysql

    데이터베이스가 파일을 계속 수정하고 있다면 다음과 같은 문제가 발생할 수 있습니다.

    • 백업 도중 데이터 파일 변경
    • 테이블 간 백업 시점 불일치
    • InnoDB 로그와 데이터 파일 불일치
    • 복구 후 데이터베이스 기동 실패

    일반적인 MySQL 또는 MariaDB 논리 백업은 mysqldump와 같은 전용 도구를 사용하는 것이 안전합니다.

    mysqldump \
      --single-transaction \
      --routines \
      --triggers \
      --events \
      -u backup_user \
      -p \
      app_db > /backup/app_db.sql

    덤프 파일이 생성되면 tar.gz로 압축할 수 있습니다.

    tar -czf "/backup/app_db_$(date '+%Y%m%d').tar.gz" \
      -C /backup \
      app_db.sql

    tar는 데이터베이스 전용 백업 도구를 대체하는 것이 아니라, 이미 생성한 백업 파일을 묶고 압축하는 용도로 사용하는 것이 좋습니다.

    백업 파일 검증하기

    백업 명령어가 오류 없이 종료되었다고 해서 실제 복구가 가능하다고 단정할 수는 없습니다.

    최소한 다음 항목은 확인하는 것이 좋습니다.

    백업 파일 존재 여부 확인

    ls -lh /backup/project_20260801_003000.tar.gz

    아카이브 내부 목록 확인

    tar -tzf /backup/project_20260801_003000.tar.gz | head

    임시 디렉터리에 실제 복원

    RESTORE_TEST_DIR="$(mktemp -d)"
    
    tar -xzf /backup/project_20260801_003000.tar.gz \
      -C "$RESTORE_TEST_DIR"
    
    find "$RESTORE_TEST_DIR" -maxdepth 2 -type f | head
    
    rm -rf "$RESTORE_TEST_DIR"

    가장 확실한 검증 방법은 별도의 테스트 환경에 백업 파일을 실제로 복원해 보는 것입니다.

    잘못된 경로에 압축을 해제했을 때

    tar 파일을 잘못된 디렉터리에서 해제하면 파일이 기존 파일과 섞이거나 덮어써질 수 있습니다.

    예를 들어 홈 디렉터리에서 다음 명령어를 실행했다고 가정해 보겠습니다.

    tar -xzf project.tar.gz

    아카이브 안에 최상위 project/ 디렉터리가 포함되어 있다면 다음처럼 한 디렉터리 안에 생성됩니다.

    ~/project/

    이 경우에는 비교적 정리하기 쉽습니다.

    rm -rf ~/project

    하지만 아카이브 내부가 다음과 같이 구성되어 있다면 문제가 복잡해집니다.

    package.json
    src/
    public/
    README.md

    현재 디렉터리에 파일과 디렉터리가 바로 섞이기 때문입니다.

    먼저 아카이브 내부 목록 확인

    tar -tzf project.tar.gz

    출력된 목록은 압축 해제 과정에서 생성되거나 덮어써졌을 가능성이 있는 파일입니다.

    다만 목록을 기반으로 다음과 같이 무조건 삭제해서는 안 됩니다.

    tar -tzf project.tar.gz | xargs rm -rf

    이 방식은 다음과 같은 문제가 있습니다.

    • 기존에 존재하던 같은 이름의 파일까지 삭제할 수 있음
    • 공백과 특수문자가 있는 파일명을 잘못 처리할 수 있음
    • 디렉터리 안에 원래 존재하던 파일까지 함께 삭제할 수 있음
    • 예상하지 못한 경로가 포함된 아카이브에서 위험할 수 있음

    Git 프로젝트라면 변경 사항 확인

    Git으로 관리되는 프로젝트라면 먼저 상태를 확인합니다.

    git status

    기존 파일이 변경되었는지 확인합니다.

    git diff

    새로 생성된 추적되지 않은 파일을 미리 확인합니다.

    git clean -nd

    git clean -nd는 실제로 삭제하지 않고 삭제 대상을 보여줍니다.

    목록을 확인한 후 추적되지 않은 파일을 삭제하려면 다음 명령어를 사용할 수 있습니다.

    git clean -fd

    다만 .env, 업로드 파일, 로그처럼 Git에서 추적하지 않는 중요한 파일도 삭제될 수 있으므로 반드시 미리보기 결과를 확인해야 합니다.

    Git에서 추적하는 파일이 덮어써졌다면 다음 명령어로 복원할 수 있습니다.

    git restore .

    이 명령어는 정상적으로 작업 중이던 변경 사항도 함께 되돌릴 수 있으므로 실행 전에 git diff로 내용을 확인해야 합니다.

    시스템 경로에 잘못 해제한 경우

    /, /etc, /var, /home 같은 시스템 경로에 root 권한으로 잘못 해제했다면 임의로 파일을 삭제하지 않는 것이 좋습니다.

    먼저 다음 정보를 확보해야 합니다.

    • 실행한 tar 명령어
    • 아카이브 내부 목록
    • 명령어 실행 시각
    • 셸 히스토리
    • 시스템 백업이나 스냅샷 존재 여부
    • 변경된 파일 목록
    history | tail -n 30
    tar -tvf backup.tar

    운영 서버라면 개별 파일을 임의로 삭제하기보다 최근 스냅샷이나 정상 백업에서 복원하는 것이 더 안전할 수 있습니다.

    임시 디렉터리에 먼저 해제하기

    잘못된 위치에 해제하는 문제를 예방하는 가장 좋은 방법은 항상 임시 디렉터리에 먼저 압축을 해제하는 것입니다.

    mkdir -p /tmp/project-restore
    
    tar -xzf project.tar.gz -C /tmp/project-restore

    압축 해제 결과를 확인합니다.

    find /tmp/project-restore -maxdepth 3 -print

    이후 필요한 위치로 파일을 복사합니다.

    rsync -av \
      /tmp/project-restore/project/ \
      /var/www/project/

    실제 반영 전에 변경될 내용을 확인하려면 --dry-run 옵션을 사용합니다.

    rsync -av --dry-run \
      /tmp/project-restore/project/ \
      /var/www/project/

    출력 결과를 확인한 다음 실제로 반영합니다.

    rsync -av \
      /tmp/project-restore/project/ \
      /var/www/project/

    rsync--delete 옵션은 원본에 없는 대상 파일을 삭제할 수 있으므로 충분한 검토 없이 사용하면 안 됩니다.

    자주 사용하는 tar 명령어 모음

    tar 파일 생성

    tar -cvf backup.tar directory/

    tar.gz 파일 생성

    tar -czvf backup.tar.gz directory/

    tar.bz2 파일 생성

    tar -cjvf backup.tar.bz2 directory/

    tar.xz 파일 생성

    tar -cJvf backup.tar.xz directory/

    tar.gz 파일 해제

    tar -xzvf backup.tar.gz

    특정 위치에 해제

    tar -xzf backup.tar.gz -C /target/directory

    압축 파일 내부 목록 확인

    tar -tzf backup.tar.gz

    특정 디렉터리 제외

    tar -czf backup.tar.gz \
      --exclude='directory/node_modules' \
      directory/

    기준 디렉터리를 지정하여 압축

    tar -czf backup.tar.gz -C /var/www project

    실무에서 기억할 점

    tar 명령어 자체는 어렵지 않지만 운영 서버에서는 압축보다 복원 과정이 더 중요합니다.

    압축할 때는 포함할 파일과 제외할 파일을 명확하게 정해야 합니다. 절대경로를 직접 저장하기보다는 -C 옵션을 사용해 아카이브 내부 경로를 단순하게 만드는 것이 좋습니다.

    생성한 백업 파일은 tar -tf 또는 tar -tzf 명령어로 내부 목록을 확인해야 합니다. 중요한 백업이라면 임시 디렉터리나 별도의 테스트 서버에 실제로 복원해 보는 것이 가장 확실합니다.

    또한 MySQL이나 MariaDB가 실행 중인 상태에서 데이터 디렉터리를 그대로 tar로 압축하는 방식은 데이터 일관성을 보장하지 못할 수 있습니다. 데이터베이스는 전용 백업 도구로 백업한 뒤 생성된 결과물을 tar로 압축하는 방식이 안전합니다.

    운영 서버에서 tar 파일을 해제할 때는 바로 실제 서비스 경로에 해제하지 말고, 임시 디렉터리에 먼저 해제하여 내부 구조와 권한을 확인한 후 반영하는 습관이 필요합니다.


    SEO 제목: 리눅스 tar 압축과 해제 명령어 정리: tar.gz, tar.bz2, tar.xz 차이

    슬러그: linux-tar-command

    메타 설명: 리눅스 tar 명령어의 기본 개념과 tar.gz, tar.bz2, tar.xz 차이, 압축 및 해제, 특정 파일 제외, 권한 보존, 운영 서버 백업 방법을 정리합니다.

    카테고리: 개발 > Linux

  • 리눅스에서 HPE 서버 물리 디스크 확인하기: ssacli로 RAID·디스크 상태 조회

    리눅스 서버에서 디스크 상태를 확인할 때 보통 df, lsblk, fdisk 같은 명령어를 먼저 사용한다.

    일반적인 서버라면 이 정도만으로도 어느 디스크가 연결되어 있고 파티션이 어떻게 구성되어 있는지 확인할 수 있다.

    하지만 HPE Smart Array RAID 컨트롤러를 사용하는 서버에서는 이야기가 조금 달라진다.

    RAID 컨트롤러가 여러 개의 물리 디스크를 하나의 논리 드라이브로 구성해 운영체제에 제공하기 때문에, Linux에서는 실제 장착된 물리 디스크가 그대로 보이지 않을 수 있다.

    나도 실제 서버의 디스크를 증설하거나 교체하기 전에 원격으로 현재 디스크 구성을 확인해야 하는 일이 있었다.

    서버에는 여러 개의 SSD가 장착되어 있었고 RAID1으로 구성되어 있었기 때문에 단순히 dflsblk를 보는 것만으로는 어느 물리 디스크가 어떤 RAID에 포함되어 있는지 확인하기 어려웠다.

    이럴 때 HPE 서버에서는 **Smart Storage Administrator CLI인 ssacli**를 이용하면 RAID 컨트롤러와 논리 드라이브, 실제 물리 디스크 상태를 확인할 수 있다.

    df나 lsblk만으로 실제 디스크를 확인하기 어려운 이유

    먼저 일반적인 Linux 명령으로 디스크 상태를 확인해보자.

    파일시스템 사용량은 다음 명령으로 확인할 수 있다.

    df -h

    블록 디바이스는 다음 명령으로 확인한다.

    lsblk

    디스크와 파티션 정보는 다음 명령으로 확인할 수도 있다.

    fdisk -l

    이 명령들은 운영체제가 인식하고 있는 디스크와 파티션을 확인하는 데 매우 유용하다.

    하지만 하드웨어 RAID가 적용된 서버라면 Linux는 RAID 컨트롤러가 만들어준 Logical Drive를 하나의 디스크처럼 인식한다.

    예를 들어 실제 서버에 SSD가 4개 있다고 해보자.

    물리적으로는 다음과 같이 구성되어 있을 수 있다.

    SSD 1 + SSD 2 → RAID1

    SSD 3 + SSD 4 → RAID1

    하지만 운영체제에서는 이 네 개의 SSD가 그대로 보이는 것이 아니라 RAID 컨트롤러에서 만들어진 두 개의 Logical Drive만 보일 수 있다.

    lsblk만 보고는

    • 실제 SSD가 몇 개인지
    • 어느 SSD가 어느 RAID에 속해 있는지
    • 특정 디스크가 장애 상태인지
    • 디스크 모델명이 무엇인지
    • 디스크 시리얼 번호가 무엇인지

    같은 정보를 정확히 확인하기 어렵다.

    이런 경우 RAID 컨트롤러 자체의 정보를 조회해야 한다.

    ssacli란?

    ssacli는 HPE Smart Storage Administrator의 Command Line Interface다.

    HPE Smart Array 또는 SmartRAID 컨트롤러를 명령줄에서 조회하고 관리할 수 있다.

    주로 다음과 같은 정보를 확인할 수 있다.

    • Smart Array 컨트롤러
    • RAID 구성
    • Array
    • Logical Drive
    • Physical Drive
    • 물리 디스크 상태
    • 디스크 용량
    • 인터페이스 타입
    • 모델명
    • 시리얼 번호
    • 디스크 장애 상태

    서버 운영 중 RAID 구성을 확인하거나 장애가 발생한 실제 디스크를 찾을 때 유용하다.

    ssacli가 설치되어 있는지 확인하기

    먼저 서버에 ssacli가 설치되어 있는지 확인한다.

    which ssacli

    정상적으로 설치되어 있다면 실행 파일 경로가 출력된다.

    환경에 따라 다음 위치에 설치되어 있을 수도 있다.

    /opt/smartstorageadmin/ssacli/bin/ssacli

    버전도 확인할 수 있다.

    ssacli version

    만약 command not found가 나온다면 SSACLI를 별도로 설치해야 한다.

    HPE에서는 Linux용 Smart Storage Administrator CLI 패키지를 제공하고 있다.

    다만 서버의 OS 버전과 HPE 서버 세대, Smart Array 컨트롤러에 따라 지원되는 SSACLI 버전이 다를 수 있으므로 인터넷에서 임의의 RPM 파일 주소를 복사해서 설치하기보다는 HPE 공식 지원 페이지에서 해당 서버와 운영체제에 맞는 버전을 확인한 뒤 설치하는 것이 좋다.

    특히 오래된 글에서 특정 버전의 RPM 다운로드 주소를 그대로 사용하는 것은 권하지 않는다.

    패키지를 직접 다운로드했다면 RHEL 또는 CentOS 계열에서는 환경에 따라 다음과 같은 방식으로 설치할 수 있다.

    sudo yum localinstall -y 패키지파일.rpm

    또는 최근 배포판이라면 다음과 같이 사용할 수도 있다.

    sudo dnf install ./패키지파일.rpm

    여기서 예전 글에 종종 보이는

    yum localinstall-y

    형태는 잘못된 명령이다.

    localinstall-y 사이에는 공백이 있어야 한다.

    Smart Array 컨트롤러 확인하기

    SSACLI가 설치되어 있다면 가장 먼저 RAID 컨트롤러를 확인한다.

    sudo ssacli ctrl all show

    환경에 따라 다음과 비슷한 결과가 나온다.

    Smart Array P440ar in Slot 0 (Embedded)

    여기서 중요한 것은 컨트롤러의 Slot 번호다.

    위 예제에서는 Slot 0이다.

    이후 특정 컨트롤러를 조회할 때 이 번호를 사용한다.

    서버에 RAID 컨트롤러가 여러 개 있다면 여러 줄이 표시될 수도 있다.

    RAID 전체 구성 확인하기

    컨트롤러, Array, Logical Drive, Physical Drive 구성을 한 번에 확인하려면 다음 명령을 사용한다.

    sudo ssacli ctrl all show config

    HPE 공식 문서에서도 이 명령을 컨트롤러와 그 아래의 논리 드라이브 및 물리 드라이브 구성을 확인하는 용도로 안내한다.

    출력은 대략 다음과 같은 구조로 나타난다.

    Smart Array P440ar in Slot 0

    array A

    logicaldrive 1 (447 GB, RAID 1, OK)

    physicaldrive 1I:1:1 (port 1I:box 1:bay 1, SSD, 480 GB, OK)

    physicaldrive 1I:1:2 (port 1I:box 1:bay 2, SSD, 480 GB, OK)

    array B

    logicaldrive 2 (447 GB, RAID 1, OK)

    physicaldrive 1I:1:3 (port 1I:box 1:bay 3, SSD, 480 GB, OK)

    physicaldrive 1I:1:4 (port 1I:box 1:bay 4, SSD, 480 GB, OK)

    이 결과를 보면 실제로 SSD가 네 개 설치되어 있고 두 개씩 RAID1을 구성하고 있다는 것을 알 수 있다.

    출력 결과에서 Array, Logical Drive, Physical Drive 구분하기

    SSACLI 출력에는 몇 가지 용어가 반복해서 나온다.

    처음 보면 조금 헷갈릴 수 있다.

    Array

    여러 개의 물리 디스크를 묶어놓은 그룹이다.

    예를 들어

    array A

    라고 표시되어 있다면 해당 Array 아래의 물리 디스크들이 하나의 RAID 그룹을 구성하고 있다고 이해하면 된다.

    Logical Drive

    RAID를 이용해 만들어진 논리적인 디스크다.

    예를 들어 다음처럼 표시될 수 있다.

    logicaldrive 1 (447 GB, RAID 1, OK)

    이 값은

    • Logical Drive 번호: 1
    • 용량: 447GB
    • RAID 방식: RAID1
    • 상태: OK

    라는 의미다.

    Linux의 lsblk에서 보이는 디스크는 실제 물리 SSD가 아니라 이런 Logical Drive일 수 있다.

    Physical Drive

    실제 서버에 장착되어 있는 HDD나 SSD다.

    예를 들어 다음과 같이 표시될 수 있다.

    physicaldrive 1I:1:1

    physicaldrive 1I:1:2

    이 값은 실제 디스크의 위치를 나타내는 HPE Smart Array 식별자다.

    또한 출력에 bay 1, bay 2 같은 정보가 있으면 서버의 어느 드라이브 베이에 장착되어 있는지도 확인할 수 있다.

    특정 컨트롤러의 물리 디스크 전체 확인하기

    컨트롤러의 Slot 번호를 확인했다면 해당 컨트롤러에 연결된 물리 디스크만 조회할 수 있다.

    예를 들어 컨트롤러가 Slot 0이라면 다음 명령을 사용한다.

    sudo ssacli ctrl slot=0 pd all show

    여기서 pd는 Physical Drive를 의미한다.

    출력에는 각 디스크의 위치와 타입, 용량, 상태 등이 나타난다.

    예를 들면 다음과 같다.

    physicaldrive 1I:1:1 (port 1I:box 1:bay 1, SATA SSD, 480 GB, OK)

    physicaldrive 1I:1:2 (port 1I:box 1:bay 2, SATA SSD, 480 GB, OK)

    가장 먼저 확인해야 할 부분은 마지막의 상태 값이다.

    정상적인 디스크라면 일반적으로 OK로 표시된다.

    물리 디스크 상세 정보 확인하기

    디스크 모델이나 시리얼 번호 등 더 자세한 정보가 필요하다면 show detail을 사용한다.

    모든 물리 디스크의 상세 정보는 다음과 같이 조회할 수 있다.

    sudo ssacli ctrl slot=0 pd all show detail

    HPE에서도 실제 물리 드라이브의 상태 확인에 이 명령 형태를 사용한다.

    출력되는 항목은 컨트롤러와 디스크 종류에 따라 조금씩 다르지만 일반적으로 다음과 같은 정보를 확인할 수 있다.

    • Status
    • Size
    • Drive Type
    • Interface Type
    • Model
    • Serial Number
    • Firmware Revision
    • Current Temperature
    • Maximum Temperature
    • PHY Count
    • Drive Authentication Status

    SSD라면 장비나 컨트롤러에 따라 SSD 수명과 관련된 항목이 표시될 수도 있다.

    실제 디스크 교체를 준비하고 있다면 단순히 용량만 보는 것보다 Model과 Serial Number까지 기록해두는 것이 좋다.

    특정 물리 디스크 하나만 자세히 확인하기

    전체 디스크가 아니라 특정 디스크만 확인하고 싶다면 Physical Drive ID를 지정한다.

    예를 들어 다음 디스크가 있다고 하자.

    physicaldrive 1I:1:1

    상세 정보는 다음 명령으로 확인할 수 있다.

    sudo ssacli ctrl slot=0 pd 1I:1:1 show detail

    여기서 Slot 번호와 Physical Drive ID는 실제 서버의 출력 결과에 맞춰 사용해야 한다.

    인터넷 예제를 그대로 복사해서 slot=0, 1I:1:1을 사용하는 것은 의미가 없다.

    먼저 자신의 서버에서 다음 명령으로 실제 값을 확인해야 한다.

    sudo ssacli ctrl all show config

    논리 드라이브만 확인하기

    Physical Drive가 아니라 RAID로 만들어진 Logical Drive 상태만 보고 싶다면 다음 명령을 사용한다.

    sudo ssacli ctrl slot=0 ld all show

    여기서 ld는 Logical Drive다.

    더 자세한 정보가 필요하다면 다음처럼 사용할 수 있다.

    sudo ssacli ctrl slot=0 ld all show detail

    Logical Drive의 RAID 방식과 용량, 상태를 확인할 때 유용하다.

    컨트롤러 자체의 상세 상태 확인하기

    RAID 컨트롤러 자체의 상태도 확인할 수 있다.

    sudo ssacli ctrl slot=0 show detail

    또는 전체 설정 정보를 자세히 보고 싶다면 다음과 같이 사용할 수 있다.

    sudo ssacli ctrl all show config detail

    환경에 따라 다음과 같은 항목을 확인할 수 있다.

    • Controller Status
    • Cache Status
    • Battery 또는 Capacitor Status
    • Firmware Version
    • Cache Ratio
    • Drive Write Cache
    • PCI Address

    RAID 컨트롤러 캐시용 배터리나 캐패시터에 문제가 생긴 경우에도 성능이나 안정성에 영향을 줄 수 있기 때문에 디스크뿐 아니라 컨트롤러 상태를 함께 확인하는 것이 좋다.

    디스크 장애가 의심될 때 무엇을 봐야 할까?

    운영 서버에서 디스크 장애가 의심된다면 가장 먼저 전체 구성을 확인한다.

    sudo ssacli ctrl all show config

    여기서 Logical Drive와 Physical Drive의 상태를 확인한다.

    정상적인 경우 일반적으로 OK가 표시된다.

    이상이 발견되면 문제가 있는 물리 디스크의 상세 정보를 조회한다.

    sudo ssacli ctrl slot=0 pd all show detail

    특히 다음 항목을 확인한다.

    • 디스크 Status
    • Slot 또는 Bay 위치
    • Model
    • Serial Number
    • Firmware
    • SSD 관련 상태 정보

    HPE 서버에서는 iLO에서도 스토리지 상태를 같이 확인할 수 있으므로 운영 장비라면 SSACLI 결과뿐 아니라 iLO의 Integrated Management Log와 Storage 상태도 함께 확인하는 편이 좋다.

    물리 디스크 위치를 반드시 확인해야 하는 이유

    RAID 서버에서 디스크를 교체할 때 가장 위험한 실수 중 하나는 정상 디스크를 잘못 제거하는 것이다.

    특히 RAID1에서 이미 디스크 하나가 장애 상태인데 정상적인 나머지 디스크까지 제거하면 RAID 자체를 복구하기 어려워질 수 있다.

    따라서 디스크를 교체하기 전에는 최소한 다음 정보를 확인해야 한다.

    • 컨트롤러 Slot
    • Array
    • Logical Drive
    • Physical Drive ID
    • Port
    • Box
    • Bay
    • Serial Number
    • 현재 상태

    예를 들어 SSACLI에서 문제가 있는 디스크가

    physicaldrive 1I:1:3

    이고

    bay 3

    으로 확인된다면 실제 서버에서도 정확히 해당 베이의 디스크인지 다시 확인해야 한다.

    가능하다면 iLO 정보와 서버 전면의 디스크 LED 상태까지 함께 대조하는 것이 안전하다.

    RAID 상태 확인과 디스크 교체는 다른 작업이다

    ssacli는 RAID 설정을 변경할 수도 있는 강력한 도구다.

    하지만 단순 조회와 실제 변경 작업은 완전히 다르게 접근해야 한다.

    이 글에서 사용하는

    show

    명령은 상태를 확인하는 조회 명령이다.

    반면 Array 생성, Logical Drive 삭제, RAID 변경 같은 작업은 데이터에 직접 영향을 줄 수 있다.

    운영 서버에서는 명령어를 인터넷에서 복사해서 바로 실행하지 말고, 특히 delete, modify, create 같은 명령을 사용할 때는 현재 RAID 구조와 백업 상태를 먼저 확인해야 한다.

    단순히 디스크 상태를 확인하려는 목적이라면 우선 show 계열 명령만 사용하는 것이 안전하다.

    내가 실제 서버에서 확인하는 순서

    HPE RAID 서버에서 물리 디스크 구성을 확인해야 한다면 나는 다음 순서로 보는 편이다.

    먼저 Linux에서 운영체제가 보고 있는 디스크를 확인한다.

    lsblk

    그리고 파일시스템 사용량도 확인한다.

    df -h

    다음으로 Smart Array 컨트롤러를 확인한다.

    sudo ssacli ctrl all show

    전체 RAID 구성을 확인한다.

    sudo ssacli ctrl all show config

    그다음 실제 물리 디스크를 확인한다.

    sudo ssacli ctrl slot=0 pd all show

    교체나 장애 확인이 필요한 경우 상세 정보까지 조회한다.

    sudo ssacli ctrl slot=0 pd all show detail

    필요하다면 Logical Drive도 따로 확인한다.

    sudo ssacli ctrl slot=0 ld all show

    마지막으로 iLO에서도 동일한 스토리지 상태를 확인한다.

    이렇게 보면 Linux에서 인식하는 논리 디스크부터 실제 RAID 구성과 물리 디스크까지 연결해서 파악하기 쉽다.

    자주 사용하는 ssacli 조회 명령 정리

    컨트롤러 확인

    sudo ssacli ctrl all show

    전체 RAID 구성 확인

    sudo ssacli ctrl all show config

    전체 RAID 구성 상세 확인

    sudo ssacli ctrl all show config detail

    특정 컨트롤러 상세 확인

    sudo ssacli ctrl slot=0 show detail

    물리 디스크 확인

    sudo ssacli ctrl slot=0 pd all show

    물리 디스크 상세 확인

    sudo ssacli ctrl slot=0 pd all show detail

    특정 물리 디스크 상세 확인

    sudo ssacli ctrl slot=0 pd 1I:1:1 show detail

    논리 드라이브 확인

    sudo ssacli ctrl slot=0 ld all show

    논리 드라이브 상세 확인

    sudo ssacli ctrl slot=0 ld all show detail

    여기에서 slot=0이나 1I:1:1은 예제 값이다.

    실제 서버에서는 반드시 ssacli ctrl all showssacli ctrl all show config를 먼저 실행하고 자신의 서버에 표시되는 값을 사용해야 한다.

    Linux에서 RAID 서버의 물리 디스크를 확인할 때

    일반적인 Linux 서버라면 lsblk, fdisk, smartctl 등으로 많은 정보를 확인할 수 있다.

    하지만 HPE Smart Array처럼 하드웨어 RAID 컨트롤러 뒤에 디스크가 연결되어 있다면 운영체제가 실제 물리 디스크를 직접 보고 있지 않을 수 있다.

    이 경우에는 OS 관점의 디스크 정보와 RAID 컨트롤러 관점의 디스크 정보를 구분해서 봐야 한다.

    lsblkdf는 Linux가 사용하는 Logical Drive와 파일시스템을 확인하고,

    ssacli는 HPE Smart Array 내부의 Array, Logical Drive, Physical Drive를 확인하는 용도로 생각하면 이해하기 쉽다.

    특히 디스크 장애나 교체 작업을 준비할 때는 단순히 “480GB SSD가 네 개 있다” 정도만 확인하면 부족하다.

    어느 Array에 포함되어 있는지, RAID 방식은 무엇인지, 어느 Bay에 장착되어 있는지, 현재 상태와 Serial Number가 무엇인지까지 확인해야 실제 장비 작업에서 실수를 줄일 수 있다.

    내 경우에도 원격으로 서버의 실제 디스크 구성을 확인해야 했을 때 lsblk만으로는 부족했고, SSACLI를 이용해 물리 SSD와 RAID1 구성을 확인하는 것이 가장 확실했다.

  • AWS Lightsail Bitnami에서 여러 도메인에 SSL 인증서 적용하기

    AWS Lightsail에서 Bitnami 기반 서버를 운영하다 보면 하나의 인스턴스에 여러 도메인을 연결해야 할 때가 있습니다.

    예를 들면 다음과 같은 구성입니다.

    example.com
    www.example.com
    example.net
    www.example.net

    또는 하나의 서버에서 서로 다른 웹사이트를 VirtualHost로 분리하여 운영할 수도 있습니다.

    site-a.com  → /opt/bitnami/projects/site-a/public
    site-b.com  → /opt/bitnami/projects/site-b/public

    이 글에서는 AWS Lightsail의 Bitnami Apache 환경을 기준으로 다음 내용을 정리합니다.

    • Lightsail 및 Bitnami 환경 확인
    • 정적 IP와 DNS A 레코드 설정
    • 루트 도메인과 www 도메인 처리
    • 하나의 사이트에 여러 도메인 연결
    • 여러 VirtualHost 구성
    • Let’s Encrypt 인증서 발급
    • 인증서 저장 경로 확인
    • HTTPS VirtualHost 설정
    • 인증서 자동 갱신
    • 갱신 테스트
    • Apache 설정 검사
    • 오류 로그 확인

    Bitnami 버전에 따라 Apache 설정 경로가 다를 수 있으므로, 명령어를 그대로 실행하기 전에 현재 서버의 디렉터리 구조를 반드시 확인해야 합니다.

    먼저 확인할 서버 구성

    이 글은 다음과 같은 환경을 전제로 합니다.

    호스팅: Amazon Lightsail
    운영체제: Linux
    스택: Bitnami
    웹 서버: Apache
    인증서: Let's Encrypt
    인증서 도구: bncert

    다만 AWS Lightsail에서 생성한 시점과 이미지 종류에 따라 다음 항목은 달라질 수 있습니다.

    • Ubuntu 또는 Debian 버전
    • WordPress, LAMP 등 Bitnami 이미지 종류
    • Apache 설정 디렉터리
    • 인증서 파일명
    • bncert 설치 여부
    • system package 기반 Bitnami인지 기존 자체 패키지 구조인지

    따라서 먼저 실제 환경을 확인해야 합니다.

    운영체제 확인

    cat /etc/os-release

    출력 예시는 다음과 같습니다.

    PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
    NAME="Debian GNU/Linux"
    VERSION_ID="12"

    Bitnami 설치 여부 확인

    ls -al /opt/bitnami

    다음과 같은 디렉터리가 확인된다면 Bitnami 스택일 가능성이 높습니다.

    apache
    apps
    bncert-tool
    ctlscript.sh
    letsencrypt
    php
    wordpress

    실제 구성은 이미지 버전에 따라 다를 수 있습니다.

    웹 서버 종류 확인

    sudo /opt/bitnami/ctlscript.sh status

    출력에 Apache가 표시되는지 확인합니다.

    apache already running

    NGINX가 표시된다면 이 글의 Apache VirtualHost 설정을 그대로 적용하면 안 됩니다.

    Apache 버전 확인

    sudo /opt/bitnami/apache/bin/httpd -v

    일부 환경에서는 다음 명령어를 사용할 수도 있습니다.

    apache2 -v

    Bitnami Apache 설정 경로 확인

    최근 Bitnami 환경에서는 일반적으로 다음 경로를 사용합니다.

    /opt/bitnami/apache/conf
    /opt/bitnami/apache/conf/vhosts
    /opt/bitnami/apache/conf/bitnami

    확인 명령어는 다음과 같습니다.

    sudo find /opt/bitnami/apache/conf -maxdepth 3 -type f | sort

    VirtualHost 설정 파일만 확인하려면 다음과 같이 실행합니다.

    sudo find /opt/bitnami/apache/conf/vhosts -type f -maxdepth 1 -print

    구형 Bitnami 환경에서는 다음 경로가 사용될 수도 있습니다.

    /opt/bitnami/apache2/conf
    /opt/bitnami/apache2/conf/vhosts
    /opt/bitnami/apps/APPNAME/conf

    따라서 인터넷에서 찾은 설정 파일 경로를 바로 사용하지 말고 실제 서버에서 파일 존재 여부를 먼저 확인해야 합니다.

    단일 사이트에 여러 도메인을 연결하는 경우

    먼저 여러 도메인이 같은 사이트를 보여주는 경우를 구분해야 합니다.

    예를 들어 다음 네 주소가 모두 같은 사이트로 연결되는 경우입니다.

    example.com
    www.example.com
    example.net
    www.example.net

    이 경우 하나의 VirtualHost에서 ServerAlias로 묶을 수 있습니다.

    <VirtualHost *:80>
        ServerName example.com
        ServerAlias www.example.com example.net www.example.net
    
        DocumentRoot "/opt/bitnami/wordpress"
    
        <Directory "/opt/bitnami/wordpress">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    </VirtualHost>

    인증서에도 네 개의 도메인이 모두 포함되어야 합니다.

    example.com
    www.example.com
    example.net
    www.example.net

    인증서에 포함되지 않은 도메인으로 HTTPS 접속하면 인증서 이름 불일치 오류가 발생할 수 있습니다.

    여러 VirtualHost를 운영하는 경우

    다음과 같이 각 도메인이 서로 다른 사이트를 보여준다면 VirtualHost를 분리해야 합니다.

    site-a.com → /opt/bitnami/projects/site-a/public
    site-b.com → /opt/bitnami/projects/site-b/public

    HTTP VirtualHost 예시는 다음과 같습니다.

    <VirtualHost *:80>
        ServerName site-a.com
        ServerAlias www.site-a.com
    
        DocumentRoot "/opt/bitnami/projects/site-a/public"
    
        <Directory "/opt/bitnami/projects/site-a/public">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    
        ErrorLog "/opt/bitnami/apache/logs/site-a-error.log"
        CustomLog "/opt/bitnami/apache/logs/site-a-access.log" combined
    </VirtualHost>
    
    <VirtualHost *:80>
        ServerName site-b.com
        ServerAlias www.site-b.com
    
        DocumentRoot "/opt/bitnami/projects/site-b/public"
    
        <Directory "/opt/bitnami/projects/site-b/public">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    
        ErrorLog "/opt/bitnami/apache/logs/site-b-error.log"
        CustomLog "/opt/bitnami/apache/logs/site-b-access.log" combined
    </VirtualHost>

    이 경우 각 VirtualHost가 서로 다른 DocumentRoot를 사용합니다.

    인증서는 다음 두 방식 중 하나로 구성할 수 있습니다.

    1. 모든 도메인을 포함한 인증서 하나 사용
    2. 각 사이트별 인증서 분리

    관리 편의성은 인증서 하나가 좋을 수 있지만, 사이트가 서로 독립되어 있다면 인증서를 분리하는 방식이 더 명확합니다.

    Lightsail 정적 IP 연결

    Lightsail 인스턴스의 기본 공인 IP는 인스턴스를 중지했다가 시작하면 변경될 수 있습니다.

    도메인과 SSL 인증서를 안정적으로 사용하려면 먼저 정적 IP를 생성하여 인스턴스에 연결하는 것이 좋습니다.

    Lightsail 콘솔에서 다음 순서로 설정합니다.

    Lightsail 콘솔
    → 네트워킹
    → 정적 IP 생성
    → 대상 인스턴스 선택
    → 정적 IP 연결

    정적 IP가 연결되었는지 서버에서 확인할 수 있습니다.

    curl -4 ifconfig.me

    또는 Lightsail 콘솔의 인스턴스 네트워킹 화면에서 확인합니다.

    DNS에는 기본 동적 IP가 아니라 연결된 정적 IP를 사용해야 합니다.

    방화벽 포트 확인

    Let’s Encrypt 도메인 검증과 HTTPS 서비스에 필요한 포트를 확인합니다.

    Lightsail 네트워킹 방화벽에서 일반적으로 다음 TCP 포트가 열려 있어야 합니다.

    22  SSH
    80  HTTP
    443 HTTPS

    최소한 인증서 발급 과정에서는 외부에서 80번 포트로 접근할 수 있어야 합니다.

    서버에서 Apache가 실제로 80번과 443번 포트를 사용하고 있는지 확인합니다.

    sudo ss -lntp | grep -E ':80|:443'

    출력 예시는 다음과 같습니다.

    LISTEN 0 511 0.0.0.0:80
    LISTEN 0 511 0.0.0.0:443

    DNS A 레코드 사전 설정

    인증서를 발급하기 전에 인증서에 포함할 모든 도메인이 Lightsail 인스턴스의 정적 IP를 가리키고 있어야 합니다.

    정적 IP가 다음과 같다고 가정해 보겠습니다.

    203.0.113.10

    DNS 레코드는 다음과 같이 설정할 수 있습니다.

    유형: A
    호스트: @
    값: 203.0.113.10

    www 도메인도 A 레코드로 설정할 수 있습니다.

    유형: A
    호스트: www
    값: 203.0.113.10

    또는 www를 루트 도메인의 CNAME으로 설정할 수 있습니다.

    유형: CNAME
    호스트: www
    값: example.com

    여러 도메인을 사용할 경우 각 도메인의 DNS 설정을 모두 확인해야 합니다.

    example.com      → 203.0.113.10
    www.example.com  → 203.0.113.10
    example.net      → 203.0.113.10
    www.example.net  → 203.0.113.10

    DNS가 다른 서버를 가리키고 있다면 인증서 발급 과정에서 도메인 검증에 실패할 수 있습니다.

    DNS 적용 여부 확인

    서버 또는 로컬 PC에서 다음 명령어로 확인합니다.

    dig +short example.com
    dig +short www.example.com

    또는 다음 명령어를 사용할 수 있습니다.

    nslookup example.com
    nslookup www.example.com

    출력된 IP가 Lightsail 정적 IP와 일치해야 합니다.

    203.0.113.10

    IPv4 A 레코드를 직접 확인하려면 다음과 같이 실행합니다.

    dig A example.com +short

    여러 DNS 서버를 통해 확인할 수도 있습니다.

    dig @8.8.8.8 example.com +short
    dig @1.1.1.1 example.com +short

    DNS 변경 내용은 즉시 적용되지 않을 수 있습니다.

    기존 TTL 값과 DNS 제공업체에 따라 반영에 시간이 걸릴 수 있으므로, 인증서 발급 전에 모든 도메인의 조회 결과가 정적 IP와 일치하는지 확인해야 합니다.

    루트 도메인과 www 도메인 처리

    다음 두 주소는 DNS와 인증서 관점에서 서로 다른 도메인입니다.

    example.com
    www.example.com

    따라서 두 주소를 모두 사용할 예정이라면 다음 작업이 모두 필요합니다.

    • 두 도메인의 DNS 설정
    • 인증서에 두 도메인 포함
    • Apache의 ServerName 또는 ServerAlias 설정
    • 대표 주소로 리디렉션 설정

    대표 주소는 둘 중 하나로 통일하는 것이 좋습니다.

    https://example.com

    또는 다음 주소를 대표 주소로 사용할 수 있습니다.

    https://www.example.com

    이 글에서는 루트 도메인을 대표 주소로 사용한다고 가정합니다.

    대표 주소: https://example.com
    리디렉션 대상: https://www.example.com → https://example.com

    검색엔진, WordPress 주소, canonical URL도 같은 기준으로 맞추는 것이 좋습니다.

    인증서 발급 전 Apache VirtualHost 확인

    인증서 발급 전에 HTTP VirtualHost가 정상적으로 도메인을 처리하는지 확인합니다.

    현재 Apache가 인식한 VirtualHost 목록은 다음 명령어로 확인할 수 있습니다.

    sudo /opt/bitnami/apache/bin/httpd -S

    출력 예시는 다음과 같습니다.

    *:80 is a NameVirtualHost
    default server example.com
    port 80 namevhost example.com
    alias www.example.com

    설정 문법도 검사합니다.

    sudo /opt/bitnami/apache/bin/apachectl -t

    또는 다음 명령어를 사용할 수 있습니다.

    sudo /opt/bitnami/apache/bin/httpd -t

    정상이라면 다음과 같이 출력됩니다.

    Syntax OK

    문법 오류가 있다면 Apache를 재시작하기 전에 먼저 수정해야 합니다.

    기존 설정 백업

    bncert 실행이나 VirtualHost 수정 전에 인스턴스 스냅샷을 생성하거나 설정 파일을 백업하는 것이 좋습니다.

    Apache 설정 디렉터리를 백업합니다.

    BACKUP_DATE="$(date '+%Y%m%d_%H%M%S')"
    
    sudo tar -czf "/home/bitnami/apache-conf_${BACKUP_DATE}.tar.gz" \
      -C /opt/bitnami/apache \
      conf

    백업 파일이 생성되었는지 확인합니다.

    ls -lh /home/bitnami/apache-conf_*.tar.gz

    VirtualHost 파일 하나만 수정한다면 파일 단위로 백업할 수도 있습니다.

    sudo cp \
      /opt/bitnami/apache/conf/vhosts/example-vhost.conf \
      /opt/bitnami/apache/conf/vhosts/example-vhost.conf.bak

    bncert 설치 여부 확인

    Bitnami 기반 Lightsail WordPress 이미지에서는 bncert 도구를 사용할 수 있습니다.

    먼저 실행해 봅니다.

    sudo /opt/bitnami/bncert-tool

    다음과 같은 화면이 나오면 설치되어 있는 것입니다.

    Welcome to the Bitnami HTTPS configuration tool

    명령어를 찾을 수 없다는 메시지가 나오면 설치 여부를 확인해야 합니다.

    ls -al /opt/bitnami/bncert-tool

    오래된 Bitnami 이미지에는 bncert가 기본 설치되어 있지 않을 수 있습니다.

    bncert를 설치해야 한다면 현재 AWS와 Bitnami 공식 문서의 최신 설치 절차를 확인한 후 진행하는 것이 좋습니다.

    과거 블로그의 오래된 다운로드 경로나 오래된 Certbot 설치 명령어를 그대로 사용하는 것은 피해야 합니다.

    bncert로 인증서 발급하기

    bncert를 실행합니다.

    sudo /opt/bitnami/bncert-tool

    도메인 입력 화면이 나타나면 인증서에 포함할 도메인을 공백으로 구분하여 입력합니다.

    example.com www.example.com example.net www.example.net

    입력한 모든 도메인은 인증서 발급 시점에 현재 Lightsail 인스턴스의 정적 IP를 가리키고 있어야 합니다.

    일반적으로 bncert는 다음 항목을 확인합니다.

    HTTP를 HTTPS로 리디렉션할지
    루트 도메인을 www로 리디렉션할지
    www를 루트 도메인으로 리디렉션할지
    Let's Encrypt 알림용 이메일 주소
    이용약관 동의

    루트 도메인을 대표 주소로 사용할 경우 다음과 같은 방향으로 설정합니다.

    HTTP → HTTPS: 활성화
    www → non-www: 활성화
    non-www → www: 비활성화

    반대로 www.example.com을 대표 주소로 사용하려면 방향을 반대로 선택합니다.

    두 방향을 동시에 활성화하면 리디렉션 루프가 발생할 수 있으므로 하나만 선택해야 합니다.

    인증서에 여러 도메인을 포함할 때 주의점

    하나의 인증서에 다음 네 도메인을 포함할 수 있습니다.

    example.com
    www.example.com
    example.net
    www.example.net

    다만 이것은 네 도메인이 같은 인증서를 사용한다는 뜻입니다.

    각 도메인이 서로 다른 VirtualHost를 사용하더라도 인증서의 SAN 목록에 모든 도메인이 들어 있다면 동일 인증서 파일을 참조할 수 있습니다.

    다음 명령어로 인증서에 포함된 도메인을 확인할 수 있습니다.

    openssl x509 \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.crt \
      -noout \
      -text | grep -A1 "Subject Alternative Name"

    출력 예시는 다음과 같습니다.

    X509v3 Subject Alternative Name:
        DNS:example.com, DNS:www.example.com, DNS:example.net, DNS:www.example.net

    요청한 도메인 중 하나라도 누락되어 있다면 해당 도메인으로 HTTPS 접속할 때 인증서 오류가 발생할 수 있습니다.

    인증서 저장 경로 확인

    최근 Bitnami system package 기반 Apache 환경에서는 인증서가 다음 경로에 위치하는 경우가 많습니다.

    /opt/bitnami/apache/conf/bitnami/certs/tls.crt
    /opt/bitnami/apache/conf/bitnami/certs/tls.key

    확인 명령어는 다음과 같습니다.

    sudo ls -al /opt/bitnami/apache/conf/bitnami/certs

    일부 기존 Bitnami 이미지에서는 다음과 같은 경로 또는 파일명이 사용될 수 있습니다.

    /opt/bitnami/apache/conf/tls.crt
    /opt/bitnami/apache/conf/tls.key
    
    /opt/bitnami/apache2/conf/server.crt
    /opt/bitnami/apache2/conf/server.key

    실제로 Apache가 사용하는 인증서 경로는 설정 파일에서 확인하는 것이 가장 정확합니다.

    sudo grep -R \
      -E 'SSLCertificateFile|SSLCertificateKeyFile' \
      /opt/bitnami/apache/conf

    출력 예시는 다음과 같습니다.

    SSLCertificateFile "/opt/bitnami/apache/conf/bitnami/certs/tls.crt"
    SSLCertificateKeyFile "/opt/bitnami/apache/conf/bitnami/certs/tls.key"

    인터넷 문서의 경로와 현재 서버 경로가 다르다면 현재 Apache 설정에 선언된 경로를 기준으로 판단해야 합니다.

    인증서 만료일 확인

    인증서 파일의 발급자와 만료일을 확인합니다.

    sudo openssl x509 \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.crt \
      -noout \
      -issuer \
      -subject \
      -dates

    출력 예시는 다음과 같습니다.

    issuer=C=US, O=Let's Encrypt, CN=...
    subject=CN=example.com
    notBefore=...
    notAfter=...

    남은 유효기간을 확인할 수도 있습니다.

    sudo openssl x509 \
      -checkend $((30 * 24 * 60 * 60)) \
      -noout \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.crt

    30일 이상 남아 있으면 다음과 같이 출력될 수 있습니다.

    Certificate will not expire

    30일 이내 만료 예정이면 종료 코드가 실패로 반환됩니다.

    단일 사이트용 VirtualHost 설정 예제

    하나의 WordPress 사이트에서 여러 도메인을 받고, example.com을 대표 주소로 사용한다고 가정하겠습니다.

    HTTP VirtualHost 파일을 생성하거나 수정합니다.

    sudo nano /opt/bitnami/apache/conf/vhosts/example-vhost.conf

    내용은 다음과 같습니다.

    <VirtualHost *:80>
        ServerName example.com
        ServerAlias www.example.com example.net www.example.net
    
        DocumentRoot "/opt/bitnami/wordpress"
    
        <Directory "/opt/bitnami/wordpress">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    
        RewriteEngine On
        RewriteCond %{HTTP_HOST} !^example\.com$ [NC]
        RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
    
        RewriteCond %{HTTPS} !=on
        RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
    
        ErrorLog "/opt/bitnami/apache/logs/example-error.log"
        CustomLog "/opt/bitnami/apache/logs/example-access.log" combined
    </VirtualHost>

    다만 첫 번째 리디렉션 규칙이 이미 HTTPS 대표 도메인으로 보내므로 HTTP에서 규칙을 더 단순하게 작성할 수도 있습니다.

    <VirtualHost *:80>
        ServerName example.com
        ServerAlias www.example.com example.net www.example.net
    
        RewriteEngine On
        RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
    </VirtualHost>

    HTTPS VirtualHost 파일을 생성하거나 수정합니다.

    sudo nano /opt/bitnami/apache/conf/vhosts/example-https-vhost.conf

    내용은 다음과 같습니다.

    <VirtualHost *:443>
        ServerName example.com
        ServerAlias www.example.com example.net www.example.net
    
        DocumentRoot "/opt/bitnami/wordpress"
    
        SSLEngine on
        SSLCertificateFile "/opt/bitnami/apache/conf/bitnami/certs/tls.crt"
        SSLCertificateKeyFile "/opt/bitnami/apache/conf/bitnami/certs/tls.key"
    
        <Directory "/opt/bitnami/wordpress">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    
        RewriteEngine On
        RewriteCond %{HTTP_HOST} !^example\.com$ [NC]
        RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
    
        ErrorLog "/opt/bitnami/apache/logs/example-ssl-error.log"
        CustomLog "/opt/bitnami/apache/logs/example-ssl-access.log" combined
    </VirtualHost>

    이 설정은 다음 주소를 대표 주소로 통일합니다.

    http://example.com       → https://example.com
    http://www.example.com   → https://example.com
    https://www.example.com  → https://example.com
    https://example.net      → https://example.com

    HTTPS 리디렉션이 실행되기 전에 브라우저가 인증서를 검증하므로, 리디렉션 출발점으로 사용하는 모든 HTTPS 도메인은 인증서에 포함되어 있어야 합니다.

    여러 사이트용 VirtualHost 설정 예제

    site-a.comsite-b.com이 서로 다른 사이트라고 가정하겠습니다.

    site-a HTTP 설정

    <VirtualHost *:80>
        ServerName site-a.com
        ServerAlias www.site-a.com
    
        RewriteEngine On
        RewriteRule ^ https://site-a.com%{REQUEST_URI} [R=301,L]
    </VirtualHost>

    site-a HTTPS 설정

    <VirtualHost *:443>
        ServerName site-a.com
        ServerAlias www.site-a.com
    
        DocumentRoot "/opt/bitnami/projects/site-a/public"
    
        SSLEngine on
        SSLCertificateFile "/opt/bitnami/apache/conf/bitnami/certs/tls.crt"
        SSLCertificateKeyFile "/opt/bitnami/apache/conf/bitnami/certs/tls.key"
    
        <Directory "/opt/bitnami/projects/site-a/public">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    
        RewriteEngine On
        RewriteCond %{HTTP_HOST} ^www\.site-a\.com$ [NC]
        RewriteRule ^ https://site-a.com%{REQUEST_URI} [R=301,L]
    
        ErrorLog "/opt/bitnami/apache/logs/site-a-ssl-error.log"
        CustomLog "/opt/bitnami/apache/logs/site-a-ssl-access.log" combined
    </VirtualHost>

    site-b HTTP 설정

    <VirtualHost *:80>
        ServerName site-b.com
        ServerAlias www.site-b.com
    
        RewriteEngine On
        RewriteRule ^ https://site-b.com%{REQUEST_URI} [R=301,L]
    </VirtualHost>

    site-b HTTPS 설정

    <VirtualHost *:443>
        ServerName site-b.com
        ServerAlias www.site-b.com
    
        DocumentRoot "/opt/bitnami/projects/site-b/public"
    
        SSLEngine on
        SSLCertificateFile "/opt/bitnami/apache/conf/bitnami/certs/tls.crt"
        SSLCertificateKeyFile "/opt/bitnami/apache/conf/bitnami/certs/tls.key"
    
        <Directory "/opt/bitnami/projects/site-b/public">
            Options -Indexes +FollowSymLinks
            AllowOverride All
            Require all granted
        </Directory>
    
        RewriteEngine On
        RewriteCond %{HTTP_HOST} ^www\.site-b\.com$ [NC]
        RewriteRule ^ https://site-b.com%{REQUEST_URI} [R=301,L]
    
        ErrorLog "/opt/bitnami/apache/logs/site-b-ssl-error.log"
        CustomLog "/opt/bitnami/apache/logs/site-b-ssl-access.log" combined
    </VirtualHost>

    두 사이트가 같은 인증서 파일을 참조하려면 인증서에 다음 도메인이 모두 포함되어 있어야 합니다.

    site-a.com
    www.site-a.com
    site-b.com
    www.site-b.com

    사이트별로 인증서를 분리했다면 각 HTTPS VirtualHost의 인증서 경로도 사이트별 파일로 지정해야 합니다.

    Apache 설정 검사

    설정 파일을 수정한 후 바로 Apache를 재시작하지 말고 먼저 문법을 검사합니다.

    sudo /opt/bitnami/apache/bin/apachectl -t

    정상 출력은 다음과 같습니다.

    Syntax OK

    VirtualHost 매핑도 확인합니다.

    sudo /opt/bitnami/apache/bin/httpd -S

    다음 내용을 확인합니다.

    • 각 도메인이 올바른 VirtualHost에 연결되는지
    • 80번과 443번 VirtualHost가 모두 있는지
    • 기본 VirtualHost가 의도한 사이트인지
    • ServerNameServerAlias가 중복되지 않는지
    • 설정 파일이 실제로 Apache에 포함되어 있는지

    설정 파일의 Include 구문도 확인할 수 있습니다.

    sudo grep -R "Include" /opt/bitnami/apache/conf/httpd.conf

    최근 Bitnami 환경에서는 /opt/bitnami/apache/conf/vhosts/ 아래 설정 파일이 자동으로 포함될 수 있지만, 이미지 구조에 따라 다를 수 있으므로 실제 httpd.conf를 확인해야 합니다.

    Apache 재시작

    설정 검사가 정상적으로 끝났다면 Apache를 재시작합니다.

    sudo /opt/bitnami/ctlscript.sh restart apache

    상태를 확인합니다.

    sudo /opt/bitnami/ctlscript.sh status apache

    Apache가 시작되지 않는다면 전체 서비스를 반복해서 재시작하지 말고 먼저 오류 로그를 확인해야 합니다.

    HTTPS 접속 테스트

    각 도메인을 브라우저에서 확인합니다.

    https://example.com
    https://www.example.com
    https://example.net
    https://www.example.net

    명령어로도 확인할 수 있습니다.

    curl -I http://example.com
    curl -I https://example.com
    curl -I https://www.example.com

    리디렉션 응답 예시는 다음과 같습니다.

    HTTP/1.1 301 Moved Permanently
    Location: https://example.com/

    최종 HTTPS 응답은 다음과 같이 확인합니다.

    curl -IL http://www.example.com

    인증서 정보까지 검사하려면 다음 명령어를 사용할 수 있습니다.

    openssl s_client \
      -connect example.com:443 \
      -servername example.com \
      </dev/null 2>/dev/null |
    openssl x509 -noout -subject -issuer -dates

    SNI를 사용하는 VirtualHost 환경에서는 -servername 옵션을 반드시 지정하는 것이 좋습니다.

    실제 서버가 제공하는 인증서 확인

    파일에 저장된 인증서와 Apache가 실제로 제공하는 인증서가 다를 수 있습니다.

    서버가 외부에 제공하는 인증서를 확인합니다.

    openssl s_client \
      -connect example.com:443 \
      -servername example.com \
      </dev/null 2>/dev/null |
    openssl x509 -noout -subject -issuer -serial -dates

    로컬 인증서 파일도 확인합니다.

    sudo openssl x509 \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.crt \
      -noout \
      -subject \
      -issuer \
      -serial \
      -dates

    두 결과의 serial 값이 다르다면 다음 가능성을 확인해야 합니다.

    • Apache가 다른 인증서 파일을 참조하고 있음
    • 설정 변경 후 Apache를 재시작하지 않음
    • CDN이나 로드 밸런서에서 TLS를 종료하고 있음
    • DNS가 다른 서버를 가리키고 있음
    • 다른 VirtualHost가 요청을 처리하고 있음

    인증서 자동 갱신 확인

    bncert로 인증서를 설정했다면 자동 갱신 작업이 함께 구성되는 경우가 많습니다.

    root crontab을 확인합니다.

    sudo crontab -l

    일반 사용자 crontab도 확인할 수 있습니다.

    crontab -l

    systemd timer가 사용되는지도 확인합니다.

    systemctl list-timers --all | grep -Ei 'cert|letsencrypt|renew'

    Bitnami 관련 갱신 파일을 찾습니다.

    sudo find /opt/bitnami \
      -type f \
      \( -iname '*renew*' -o -iname '*letsencrypt*' -o -iname '*certbot*' \) \
      2>/dev/null

    Lightsail의 비교적 최근 WordPress 설정 마법사를 사용한 환경에서는 관련 스크립트가 다음 경로에 있을 수 있습니다.

    /opt/bitnami/lightsail/scripts/

    확인합니다.

    sudo find /opt/bitnami/lightsail/scripts -maxdepth 2 -type f -print

    bncert, Certbot, lego 방식은 자동 갱신 구조가 서로 다르므로 여러 방식을 중복으로 설정하면 안 됩니다.

    먼저 현재 인증서를 어떤 도구가 관리하는지 확인한 다음 해당 방식만 유지해야 합니다.

    갱신 방식 확인

    다음 경로가 있다면 Bitnami의 lego 기반 구성을 사용하고 있을 가능성이 있습니다.

    sudo ls -al /opt/bitnami/letsencrypt

    Certbot 설정이 있다면 다음 경로를 확인합니다.

    sudo ls -al /etc/letsencrypt

    관련 프로세스와 명령어를 검색합니다.

    sudo grep -R \
      -E 'bncert|certbot|lego|letsencrypt' \
      /etc/cron* \
      /var/spool/cron \
      /opt/bitnami \
      2>/dev/null

    검색 결과가 매우 많을 수 있으므로 실제 운영 환경에서는 대상 디렉터리를 좁혀서 확인하는 것이 좋습니다.

    Certbot 환경의 갱신 테스트

    현재 인증서가 Certbot으로 관리되고 있다면 일반적으로 다음 명령어로 갱신 시뮬레이션을 수행할 수 있습니다.

    sudo certbot renew --dry-run

    다만 bncert나 Bitnami lego로 발급한 인증서에 Certbot 명령어를 임의로 사용하면 안 됩니다.

    certbot renew --dry-run은 Certbot이 관리하는 갱신 설정을 시험하는 명령어입니다.

    다음 경로에 갱신 설정이 있는지 확인합니다.

    sudo ls -al /etc/letsencrypt/renewal

    파일이 없다면 현재 인증서가 Certbot 관리 대상이 아닐 수 있습니다.

    bncert 환경의 갱신 확인

    bncert는 인증서 발급과 Apache 설정, 리디렉션, 자동 갱신을 함께 구성할 수 있습니다.

    다만 bncert 환경에서 Certbot의 renew --dry-run과 동일한 테스트 명령어를 무조건 사용할 수 있는 것은 아닙니다.

    다음 항목을 확인합니다.

    sudo crontab -l
    sudo find /opt/bitnami -type f -iname '*renew*' 2>/dev/null
    sudo grep -R 'lego' /opt/bitnami 2>/dev/null

    갱신 스크립트가 확인되면 내용을 먼저 확인합니다.

    sudo sed -n '1,240p' /opt/bitnami/letsencrypt/scripts/renew-certificate.sh

    실제 경로는 환경마다 다를 수 있습니다.

    갱신 스크립트를 직접 실행하면 실제 인증서 갱신 요청이나 Apache 중지가 발생할 수 있으므로, 내용을 검토하지 않은 상태에서 실행해서는 안 됩니다.

    Let’s Encrypt에는 요청 횟수 제한이 있으므로 테스트 목적으로 실인증서 발급이나 강제 갱신을 반복하는 것도 피해야 합니다.

    인증서 갱신 후 확인할 항목

    자동 갱신이 실행되었다면 다음 항목을 확인합니다.

    인증서 파일 수정 시간

    sudo ls -l \
      /opt/bitnami/apache/conf/bitnami/certs/tls.crt \
      /opt/bitnami/apache/conf/bitnami/certs/tls.key

    인증서 만료일

    sudo openssl x509 \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.crt \
      -noout \
      -dates

    Apache 설정 문법

    sudo /opt/bitnami/apache/bin/apachectl -t

    Apache 상태

    sudo /opt/bitnami/ctlscript.sh status apache

    외부에서 제공되는 인증서

    openssl s_client \
      -connect example.com:443 \
      -servername example.com \
      </dev/null 2>/dev/null |
    openssl x509 -noout -dates -serial

    인증서 파일은 갱신되었지만 외부에서 보이는 인증서가 그대로라면 Apache reload 또는 restart가 누락되었을 수 있습니다.

    Apache 오류 로그 확인

    Bitnami Apache의 기본 오류 로그는 일반적으로 다음 경로에 있습니다.

    /opt/bitnami/apache/logs/error_log

    최근 오류를 확인합니다.

    sudo tail -n 200 /opt/bitnami/apache/logs/error_log

    실시간으로 확인합니다.

    sudo tail -f /opt/bitnami/apache/logs/error_log

    VirtualHost별 ErrorLog를 따로 지정했다면 해당 파일을 확인합니다.

    sudo tail -n 200 /opt/bitnami/apache/logs/site-a-error.log
    sudo tail -n 200 /opt/bitnami/apache/logs/site-a-ssl-error.log

    Apache 시작 오류만 필터링할 수 있습니다.

    sudo grep -Ei \
      'error|ssl|certificate|virtualhost|syntax|failed' \
      /opt/bitnami/apache/logs/error_log |
    tail -n 100

    Let’s Encrypt 관련 로그 확인

    Certbot을 사용한다면 일반적으로 다음 로그를 확인합니다.

    sudo tail -n 200 /var/log/letsencrypt/letsencrypt.log

    로그 파일이 없다면 현재 인증서가 Certbot으로 관리되지 않을 수 있습니다.

    Bitnami lego 또는 자체 갱신 스크립트를 사용하는 경우에는 cron 출력이나 스크립트에서 지정한 로그 파일을 확인해야 합니다.

    갱신 스크립트가 출력을 /dev/null로 버리고 있다면 장애를 확인하기 어렵습니다.

    가능하면 다음처럼 로그를 남기는 편이 좋습니다.

    /var/log/bitnami-certificate-renew.log

    cron 예시는 다음과 같습니다.

    0 0 1 * * /opt/bitnami/letsencrypt/scripts/renew-certificate.sh >> /var/log/bitnami-certificate-renew.log 2>&1

    다만 기존 자동 갱신 작업이 이미 있다면 중복 cron을 추가해서는 안 됩니다.

    인증서 발급 실패 시 확인 순서

    인증서 발급이 실패한다면 다음 순서로 확인합니다.

    1. DNS 확인

    dig +short example.com
    dig +short www.example.com

    모든 도메인이 Lightsail 정적 IP를 가리켜야 합니다.

    2. 80번 포트 확인

    sudo ss -lntp | grep ':80'

    외부에서도 접속 가능한지 확인합니다.

    curl -I http://example.com

    3. Lightsail 방화벽 확인

    TCP 80 허용
    TCP 443 허용

    4. Apache 상태 확인

    sudo /opt/bitnami/ctlscript.sh status apache

    5. Apache 문법 검사

    sudo /opt/bitnami/apache/bin/apachectl -t

    6. VirtualHost 확인

    sudo /opt/bitnami/apache/bin/httpd -S

    7. 인증서 로그 확인

    sudo tail -n 200 /var/log/letsencrypt/letsencrypt.log

    Certbot을 사용하지 않는 환경이라면 bncert 또는 lego 관련 로그와 스크립트를 확인해야 합니다.

    인증서와 개인키 불일치 확인

    Apache 시작 과정에서 인증서와 개인키가 일치하지 않는다는 오류가 발생할 수 있습니다.

    인증서 공개키 해시를 확인합니다.

    sudo openssl x509 \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.crt \
      -pubkey \
      -noout |
    openssl sha256

    개인키 공개키 해시를 확인합니다.

    sudo openssl pkey \
      -in /opt/bitnami/apache/conf/bitnami/certs/tls.key \
      -pubout |
    openssl sha256

    두 결과가 같아야 합니다.

    다르다면 인증서와 개인키가 서로 다른 쌍입니다.

    개인키 파일의 내용이나 파일 자체를 외부에 공개해서는 안 됩니다.

    인증서 파일 권한 확인

    인증서와 개인키 권한을 확인합니다.

    sudo ls -l /opt/bitnami/apache/conf/bitnami/certs

    개인키는 불필요하게 일반 사용자에게 읽기 권한을 주면 안 됩니다.

    다만 권한을 무조건 600으로 변경하기 전에 Apache 실행 사용자와 Bitnami의 기존 심볼릭 링크 구조를 확인해야 합니다.

    bncert가 관리하는 인증서 파일이나 심볼릭 링크를 임의로 이동하거나 이름을 변경하면 자동 갱신이 끊길 수 있습니다.

    잘못된 설정을 복구하는 방법

    Apache 설정 변경 후 서버가 시작되지 않는다면 먼저 설정 검사를 수행합니다.

    sudo /opt/bitnami/apache/bin/apachectl -t

    수정 전 백업 파일로 되돌립니다.

    sudo cp \
      /opt/bitnami/apache/conf/vhosts/example-vhost.conf.bak \
      /opt/bitnami/apache/conf/vhosts/example-vhost.conf

    다시 검사합니다.

    sudo /opt/bitnami/apache/bin/apachectl -t

    정상이라면 Apache를 재시작합니다.

    sudo /opt/bitnami/ctlscript.sh restart apache

    인증서 파일을 삭제하거나 /opt/bitnami/letsencrypt 디렉터리를 통째로 지우는 방식은 마지막 수단으로만 고려해야 합니다.

    기존 인증서와 자동 갱신 설정까지 함께 사라질 수 있으므로 스냅샷과 설정 백업이 없는 상태에서는 실행하면 안 됩니다.

    WordPress 주소 확인

    SSL 적용 후 WordPress 주소가 HTTP로 남아 있으면 다음 문제가 발생할 수 있습니다.

    • 관리자 페이지 리디렉션 반복
    • 이미지와 CSS의 혼합 콘텐츠
    • 로그인 쿠키 문제
    • canonical URL 불일치
    • 사이트맵 주소 불일치

    WordPress 관리자에서 다음 값을 확인합니다.

    설정
    → 일반
    → 워드프레스 주소
    → 사이트 주소

    대표 주소를 루트 도메인으로 정했다면 두 값 모두 다음과 같이 맞춥니다.

    https://example.com

    wp-cli를 사용할 수 있다면 다음과 같이 확인할 수 있습니다.

    sudo /opt/bitnami/wp-cli/bin/wp \
      option get home \
      --path=/opt/bitnami/wordpress
    sudo /opt/bitnami/wp-cli/bin/wp \
      option get siteurl \
      --path=/opt/bitnami/wordpress

    환경에 따라 wp-cli 경로와 WordPress 설치 경로는 다를 수 있습니다.

    변경이 필요하다면 백업 후 수행합니다.

    sudo /opt/bitnami/wp-cli/bin/wp \
      option update home 'https://example.com' \
      --path=/opt/bitnami/wordpress
    sudo /opt/bitnami/wp-cli/bin/wp \
      option update siteurl 'https://example.com' \
      --path=/opt/bitnami/wordpress

    혼합 콘텐츠 확인

    HTTPS 접속은 되지만 브라우저에서 완전히 안전한 연결로 표시되지 않는다면 HTTP 리소스가 남아 있을 수 있습니다.

    브라우저 개발자 도구의 Console과 Network 탭에서 다음 형태의 요청을 확인합니다.

    http://example.com/wp-content/...

    WordPress 데이터베이스에 기존 HTTP 주소가 저장되어 있다면 안전한 검색·치환이 필요할 수 있습니다.

    wp-cli를 사용할 경우 먼저 dry run으로 확인합니다.

    sudo /opt/bitnami/wp-cli/bin/wp \
      search-replace \
      'http://example.com' \
      'https://example.com' \
      --all-tables \
      --dry-run \
      --path=/opt/bitnami/wordpress

    결과를 충분히 검토한 후 실제로 적용합니다.

    sudo /opt/bitnami/wp-cli/bin/wp \
      search-replace \
      'http://example.com' \
      'https://example.com' \
      --all-tables \
      --path=/opt/bitnami/wordpress

    실행 전 데이터베이스 백업이 필요합니다.

    단순 SQL REPLACE()로 WordPress 데이터베이스를 일괄 변경하면 직렬화된 데이터가 손상될 수 있으므로 피하는 것이 좋습니다.

    현재 Bitnami 환경에서 주의할 점

    Bitnami 관련 글을 검색하면 서로 다른 시기의 경로와 명령어가 혼재되어 있습니다.

    대표적으로 다음 경로들이 함께 검색될 수 있습니다.

    /opt/bitnami/apache/conf/vhosts
    /opt/bitnami/apache/conf/bitnami
    /opt/bitnami/apache2/conf
    /opt/bitnami/apps/wordpress/conf

    이 중 어느 경로가 맞는지는 현재 인스턴스 이미지와 Bitnami 패키징 방식에 따라 달라집니다.

    따라서 다음 순서로 판단해야 합니다.

    1. /opt/bitnami 디렉터리 구조 확인
    2. ctlscript.sh status로 웹 서버 종류 확인
    3. httpd -S로 실제 VirtualHost 확인
    4. grep으로 실제 인증서 참조 경로 확인
    5. 설정 백업
    6. Apache 문법 검사
    7. 재시작
    8. 외부 HTTPS 테스트

    Lightsail에서 최근 생성한 WordPress 인스턴스라면 콘솔의 웹사이트 설정 마법사로 DNS, 정적 IP, HTTPS를 구성할 수 있습니다.

    기본적인 단일 WordPress 사이트라면 수동 VirtualHost 설정보다 Lightsail 설정 마법사나 bncert를 우선 사용하는 편이 관리하기 쉽습니다.

    반면 하나의 인스턴스에서 서로 다른 DocumentRoot를 사용하는 여러 사이트를 운영한다면 VirtualHost 구성을 직접 관리해야 할 수 있습니다.

    bncert를 실행하면 기존 Apache 설정과 리디렉션 구성이 변경될 수 있으므로, 수동으로 여러 VirtualHost를 구성한 서버에서는 반드시 스냅샷과 설정 파일 백업을 먼저 생성해야 합니다.

    적용 후 최종 점검 목록

    SSL 설정을 완료한 뒤 다음 항목을 확인합니다.

    [ ] Lightsail 정적 IP가 인스턴스에 연결되어 있다.
    [ ] 모든 루트 도메인의 A 레코드가 정적 IP를 가리킨다.
    [ ] 모든 www 도메인의 A 또는 CNAME 레코드가 올바르다.
    [ ] 80번과 443번 포트가 열려 있다.
    [ ] 모든 인증서 대상 도메인이 외부에서 조회된다.
    [ ] Apache 설정 검사가 Syntax OK로 끝난다.
    [ ] httpd -S에서 VirtualHost 매핑이 올바르다.
    [ ] 인증서 SAN에 모든 도메인이 포함되어 있다.
    [ ] HTTP 요청이 대표 HTTPS 주소로 이동한다.
    [ ] www와 루트 도메인 리디렉션 방향이 하나로 통일되어 있다.
    [ ] 각 VirtualHost가 올바른 DocumentRoot를 사용한다.
    [ ] 인증서 자동 갱신 작업이 존재한다.
    [ ] 자동 갱신 방식이 중복 설정되어 있지 않다.
    [ ] 인증서 만료일을 확인했다.
    [ ] Apache 오류 로그에 SSL 관련 오류가 없다.
    [ ] WordPress의 home과 siteurl이 HTTPS 주소로 설정되어 있다.
    [ ] 혼합 콘텐츠 오류가 없다.
    [ ] 설정 변경 전 스냅샷 또는 백업이 존재한다.

    정리

    AWS Lightsail Bitnami 환경에서 SSL을 적용할 때 중요한 것은 인증서 발급 명령어 하나가 아닙니다.

    먼저 도메인을 Lightsail 정적 IP에 연결하고, 루트 도메인과 www 도메인을 모두 인증서에 포함해야 합니다.

    하나의 사이트에서 여러 도메인을 사용한다면 ServerAlias로 묶을 수 있습니다. 서로 다른 사이트를 운영한다면 각각의 VirtualHost와 DocumentRoot를 분리해야 합니다.

    인증서 발급 후에는 Apache가 실제로 어떤 인증서 파일을 참조하는지 확인하고, 설정 검사 후 재시작해야 합니다. 또한 자동 갱신 작업이 존재하는지와 실제 외부 서비스에서 새 인증서를 제공하고 있는지도 확인해야 합니다.

    Bitnami 설정 경로는 이미지 생성 시점과 패키지 구조에 따라 달라질 수 있습니다. 따라서 다른 글의 명령어를 그대로 실행하기보다 현재 서버의 디렉터리와 Apache 설정을 먼저 확인하는 것이 가장 중요합니다.


    SEO 제목: AWS Lightsail Bitnami 여러 도메인 SSL 적용과 VirtualHost 설정

    슬러그: lightsail-bitnami-multiple-domain-ssl

    메타 설명: AWS Lightsail Bitnami Apache 환경에서 여러 도메인과 VirtualHost에 Let’s Encrypt SSL 인증서를 적용하는 방법을 정리합니다. DNS 설정, bncert, 인증서 경로, 자동 갱신과 오류 확인 방법을 설명합니다.

    카테고리: 개발 > 서버

  • PHP cURL로 GET·POST JSON 요청 보내기: 오류 처리와 타임아웃까지

    PHP에서 외부 API를 호출할 때 가장 많이 사용하는 방법 중 하나가 cURL입니다.

    cURL을 사용하면 다음과 같은 HTTP 요청을 처리할 수 있습니다.

    • GET 요청과 쿼리스트링 생성
    • 일반 form POST 요청
    • JSON POST 요청
    • 요청 헤더 설정
    • Bearer Token 인증
    • 연결 및 응답 타임아웃 설정
    • HTTP 상태 코드 확인
    • 네트워크 오류 처리
    • JSON 응답 파싱
    • SSL 인증서 검증

    단순히 curl_exec()만 호출하면 정상 응답과 오류 상황을 구분하기 어렵습니다.

    운영 환경에서는 최소한 다음 세 가지를 구분해야 합니다.

    1. 네트워크 또는 cURL 실행 오류
    2. HTTP 4xx·5xx 응답
    3. 정상 응답이지만 JSON 형식이 잘못된 경우

    이 글에서는 PHP 8 이상을 기준으로 GET, form POST, JSON POST 요청을 안전하게 작성하는 방법을 정리합니다.

    PHP cURL 확장 설치 여부 확인

    PHP에서 cURL 함수를 사용하려면 cURL 확장이 활성화되어 있어야 합니다.

    터미널에서 다음 명령어로 확인할 수 있습니다.

    php -m | grep curl

    다음과 같이 출력되면 cURL 확장이 활성화된 것입니다.

    curl

    PHP 코드에서도 확인할 수 있습니다.

    <?php
    
    if (!extension_loaded('curl')) {
        throw new RuntimeException('PHP cURL 확장이 설치되어 있지 않습니다.');
    }
    
    echo 'PHP cURL 확장이 활성화되어 있습니다.';

    cURL 버전과 SSL 정보를 확인하려면 다음 코드를 실행합니다.

    <?php
    
    if (!extension_loaded('curl')) {
        throw new RuntimeException('PHP cURL 확장이 설치되어 있지 않습니다.');
    }
    
    $version = curl_version();
    
    echo 'cURL version: ' . ($version['version'] ?? 'unknown') . PHP_EOL;
    echo 'SSL version: ' . ($version['ssl_version'] ?? 'unknown') . PHP_EOL;

    리눅스 배포판에 따라 cURL 확장 설치 명령은 다를 수 있습니다.

    Ubuntu 또는 Debian 계열의 예시는 다음과 같습니다.

    sudo apt update
    sudo apt install php-curl

    특정 PHP 버전을 사용하고 있다면 버전에 맞는 패키지가 필요할 수 있습니다.

    sudo apt install php8.3-curl

    설치 후 PHP-FPM이나 Apache를 사용하고 있다면 해당 서비스를 재시작해야 할 수 있습니다.

    현재 PHP 버전은 다음 명령어로 확인합니다.

    php -v

    PHP cURL의 기본 처리 순서

    cURL 요청은 일반적으로 다음 순서로 작성합니다.

    curl_init()
        ↓
    curl_setopt_array()
        ↓
    curl_exec()
        ↓
    curl_errno() 또는 curl_error()
        ↓
    curl_getinfo()
        ↓
    curl_close()

    가장 단순한 GET 요청은 다음과 같습니다.

    <?php
    
    $curl = curl_init('https://api.example.com/users');
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorMessage = curl_error($curl);
        curl_close($curl);
    
        throw new RuntimeException('HTTP 요청 실패: ' . $errorMessage);
    }
    
    $statusCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    
    curl_close($curl);
    
    echo 'HTTP 상태 코드: ' . $statusCode . PHP_EOL;
    echo $response;

    CURLOPT_RETURNTRANSFERtrue로 설정하면 응답 내용을 화면에 바로 출력하지 않고 문자열로 반환받을 수 있습니다.

    CURLOPT_RETURNTRANSFER => true

    GET 요청 보내기

    GET 요청은 서버에서 데이터를 조회할 때 주로 사용합니다.

    기본 GET 요청은 다음과 같습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/users';
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorNumber = curl_errno($curl);
        $errorMessage = curl_error($curl);
    
        curl_close($curl);
    
        throw new RuntimeException(
            sprintf(
                'GET 요청 실패 [%d]: %s',
                $errorNumber,
                $errorMessage
            )
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                'GET 요청이 실패했습니다. HTTP 상태 코드: %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    echo $response;

    쿼리스트링이 있는 GET 요청

    GET 요청에 검색 조건이나 페이지 번호를 전달할 때는 URL 뒤에 쿼리스트링을 붙입니다.

    예를 들어 다음 요청을 만든다고 가정해 보겠습니다.

    https://api.example.com/users?page=1&size=20&keyword=kim

    문자열을 직접 연결하기보다 http_build_query()를 사용하는 것이 안전합니다.

    <?php
    
    declare(strict_types=1);
    
    $baseUrl = 'https://api.example.com/users';
    
    $query = [
        'page' => 1,
        'size' => 20,
        'keyword' => 'kim',
    ];
    
    $queryString = http_build_query(
        $query,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    $url = $baseUrl . '?' . $queryString;
    
    echo $url;

    출력 결과는 다음과 같습니다.

    https://api.example.com/users?page=1&size=20&keyword=kim

    한글이나 공백이 포함된 값도 URL에 맞게 인코딩됩니다.

    <?php
    
    $query = [
        'keyword' => '홍길동 개발자',
    ];
    
    $queryString = http_build_query(
        $query,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    echo $queryString;

    출력 예시는 다음과 같습니다.

    keyword=%ED%99%8D%EA%B8%B8%EB%8F%99%20%EA%B0%9C%EB%B0%9C%EC%9E%90

    재사용 가능한 GET 요청 함수

    GET 요청을 함수로 분리하면 여러 API에서 재사용할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    /**
     * @param array<string, scalar|null> $query
     * @param list<string> $headers
     *
     * @return array{
     *     statusCode: int,
     *     headers: array<string, mixed>,
     *     body: string
     * }
     */
    function sendGetRequest(
        string $baseUrl,
        array $query = [],
        array $headers = []
    ): array {
        $url = $baseUrl;
    
        if ($query !== []) {
            $queryString = http_build_query(
                $query,
                '',
                '&',
                PHP_QUERY_RFC3986
            );
    
            $url .= '?' . $queryString;
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new RuntimeException('cURL 초기화에 실패했습니다.');
        }
    
        $requestHeaders = array_merge(
            [
                'Accept: application/json',
            ],
            $headers
        );
    
        curl_setopt_array($curl, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
    
        $response = curl_exec($curl);
    
        if ($response === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new RuntimeException(
                sprintf(
                    'GET 요청 실패 [%d]: %s',
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        $responseInfo = curl_getinfo($curl);
    
        curl_close($curl);
    
        return [
            'statusCode' => $statusCode,
            'headers' => $responseInfo,
            'body' => $response,
        ];
    }

    사용 예시는 다음과 같습니다.

    <?php
    
    $result = sendGetRequest(
        'https://api.example.com/users',
        [
            'page' => 1,
            'size' => 20,
            'keyword' => 'kim',
        ]
    );
    
    if (
        $result['statusCode'] < 200 ||
        $result['statusCode'] >= 300
    ) {
        throw new RuntimeException(
            sprintf(
                'API 요청 실패: HTTP %d, 응답: %s',
                $result['statusCode'],
                $result['body']
            )
        );
    }
    
    echo $result['body'];

    GET JSON 응답 파싱하기

    API가 JSON을 반환한다면 json_decode()로 변환할 수 있습니다.

    <?php
    
    $result = sendGetRequest(
        'https://api.example.com/users',
        [
            'page' => 1,
            'size' => 20,
        ]
    );
    
    if (
        $result['statusCode'] < 200 ||
        $result['statusCode'] >= 300
    ) {
        throw new RuntimeException(
            sprintf(
                'API 요청 실패: HTTP %d, 응답: %s',
                $result['statusCode'],
                $result['body']
            )
        );
    }
    
    try {
        $data = json_decode(
            $result['body'],
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            'JSON 응답 파싱 실패: ' . $exception->getMessage(),
            previous: $exception
        );
    }
    
    print_r($data);

    두 번째 인자를 true로 지정하면 JSON 객체를 PHP 연관 배열로 변환합니다.

    json_decode($json, true);

    JSON_THROW_ON_ERROR를 사용하면 잘못된 JSON을 조용히 null로 처리하지 않고 JsonException을 발생시킬 수 있습니다.

    일반 form POST 요청

    HTML 폼과 비슷한 방식으로 데이터를 전송할 때는 보통 다음 Content-Type을 사용합니다.

    application/x-www-form-urlencoded

    PHP 배열을 http_build_query()로 변환하여 전송할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/login';
    
    $formData = [
        'username' => 'test-user',
        'password' => 'test-password',
    ];
    
    $postBody = http_build_query(
        $formData,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $postBody,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
            'Content-Type: application/x-www-form-urlencoded',
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorNumber = curl_errno($curl);
        $errorMessage = curl_error($curl);
    
        curl_close($curl);
    
        throw new RuntimeException(
            sprintf(
                'POST 요청 실패 [%d]: %s',
                $errorNumber,
                $errorMessage
            )
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                'POST 요청 실패: HTTP %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    echo $response;

    실제 서비스 코드에서는 비밀번호를 소스코드에 직접 작성하지 말고 환경변수나 안전한 비밀정보 저장소에서 가져와야 합니다.

    JSON POST 요청

    REST API에서는 JSON 형식으로 데이터를 전송하는 경우가 많습니다.

    JSON POST 요청은 다음 두 가지가 중요합니다.

    요청 본문을 JSON 문자열로 변환
    Content-Type을 application/json으로 지정

    전체 예시는 다음과 같습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/users';
    
    $requestData = [
        'name' => '홍길동',
        'email' => 'hong@example.com',
        'roles' => [
            'USER',
        ],
    ];
    
    try {
        $jsonBody = json_encode(
            $requestData,
            JSON_THROW_ON_ERROR |
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            '요청 데이터를 JSON으로 변환하지 못했습니다.',
            previous: $exception
        );
    }
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $jsonBody,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
            'Content-Type: application/json',
            'Content-Length: ' . strlen($jsonBody),
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorNumber = curl_errno($curl);
        $errorMessage = curl_error($curl);
    
        curl_close($curl);
    
        throw new RuntimeException(
            sprintf(
                'JSON POST 요청 실패 [%d]: %s',
                $errorNumber,
                $errorMessage
            )
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                'JSON POST 요청 실패: HTTP %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    try {
        $responseData = json_decode(
            $response,
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            'JSON 응답 파싱 실패: ' . $exception->getMessage(),
            previous: $exception
        );
    }
    
    print_r($responseData);

    Content-Length는 cURL이 자동으로 처리할 수 있으므로 반드시 직접 지정해야 하는 값은 아닙니다.

    다음처럼 생략해도 됩니다.

    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'Content-Type: application/json',
    ],

    재사용 가능한 JSON POST 함수

    JSON API 호출을 재사용 가능한 함수로 분리한 예시입니다.

    <?php
    
    declare(strict_types=1);
    
    /**
     * @param array<string, mixed> $data
     * @param list<string> $headers
     *
     * @return array{
     *     statusCode: int,
     *     body: string
     * }
     */
    function sendJsonPostRequest(
        string $url,
        array $data,
        array $headers = []
    ): array {
        try {
            $jsonBody = json_encode(
                $data,
                JSON_THROW_ON_ERROR |
                JSON_UNESCAPED_UNICODE |
                JSON_UNESCAPED_SLASHES
            );
        } catch (JsonException $exception) {
            throw new RuntimeException(
                '요청 데이터를 JSON으로 변환하지 못했습니다.',
                previous: $exception
            );
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new RuntimeException('cURL 초기화에 실패했습니다.');
        }
    
        $requestHeaders = array_merge(
            [
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            $headers
        );
    
        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $jsonBody,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
    
        $response = curl_exec($curl);
    
        if ($response === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new RuntimeException(
                sprintf(
                    'JSON POST 요청 실패 [%d]: %s',
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        curl_close($curl);
    
        return [
            'statusCode' => $statusCode,
            'body' => $response,
        ];
    }

    사용 예시는 다음과 같습니다.

    <?php
    
    $result = sendJsonPostRequest(
        'https://api.example.com/users',
        [
            'name' => '홍길동',
            'email' => 'hong@example.com',
        ]
    );
    
    if (
        $result['statusCode'] < 200 ||
        $result['statusCode'] >= 300
    ) {
        throw new RuntimeException(
            sprintf(
                'API 응답 오류: HTTP %d, 응답: %s',
                $result['statusCode'],
                $result['body']
            )
        );
    }
    
    try {
        $responseData = json_decode(
            $result['body'],
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            'API 응답을 JSON으로 변환하지 못했습니다.',
            previous: $exception
        );
    }
    
    print_r($responseData);

    Bearer Token을 포함한 요청

    OAuth 2.0이나 JWT 기반 API에서는 Authorization 헤더에 Bearer Token을 전달하는 경우가 많습니다.

    <?php
    
    $accessToken = getenv('API_ACCESS_TOKEN');
    
    if (
        $accessToken === false ||
        $accessToken === ''
    ) {
        throw new RuntimeException(
            'API_ACCESS_TOKEN 환경변수가 설정되어 있지 않습니다.'
        );
    }
    
    $result = sendJsonPostRequest(
        'https://api.example.com/orders',
        [
            'productId' => 100,
            'quantity' => 2,
        ],
        [
            'Authorization: Bearer ' . $accessToken,
        ]
    );

    토큰을 PHP 소스에 직접 작성하지 않는 것이 좋습니다.

    잘못된 예시는 다음과 같습니다.

    $accessToken = '실제 운영 토큰';

    환경변수에서 가져오는 예시는 다음과 같습니다.

    $accessToken = getenv('API_ACCESS_TOKEN');

    서버 로그를 남길 때도 Authorization 헤더 전체를 출력하지 않도록 주의해야 합니다.

    공통 HTTP 요청 함수로 통합하기

    GET과 POST를 하나의 함수로 처리하고 싶다면 HTTP 메서드와 요청 본문을 인자로 받을 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    final class HttpResponse
    {
        /**
         * @param array<string, mixed> $info
         */
        public function __construct(
            public readonly int $statusCode,
            public readonly string $body,
            public readonly array $info
        ) {
        }
    
        public function isSuccessful(): bool
        {
            return $this->statusCode >= 200
                && $this->statusCode < 300;
        }
    }
    
    /**
     * @param array<string, scalar|null> $query
     * @param array<string, mixed>|null $jsonBody
     * @param list<string> $headers
     */
    function sendHttpRequest(
        string $method,
        string $url,
        array $query = [],
        ?array $jsonBody = null,
        array $headers = [],
        int $connectTimeoutSeconds = 5,
        int $timeoutSeconds = 15
    ): HttpResponse {
        $normalizedMethod = strtoupper($method);
    
        if ($query !== []) {
            $queryString = http_build_query(
                $query,
                '',
                '&',
                PHP_QUERY_RFC3986
            );
    
            $separator = str_contains($url, '?')
                ? '&'
                : '?';
    
            $url .= $separator . $queryString;
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new RuntimeException('cURL 초기화에 실패했습니다.');
        }
    
        $requestHeaders = array_merge(
            [
                'Accept: application/json',
            ],
            $headers
        );
    
        $options = [
            CURLOPT_CUSTOMREQUEST => $normalizedMethod,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeoutSeconds,
            CURLOPT_TIMEOUT => $timeoutSeconds,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ];
    
        if ($jsonBody !== null) {
            try {
                $encodedBody = json_encode(
                    $jsonBody,
                    JSON_THROW_ON_ERROR |
                    JSON_UNESCAPED_UNICODE |
                    JSON_UNESCAPED_SLASHES
                );
            } catch (JsonException $exception) {
                curl_close($curl);
    
                throw new RuntimeException(
                    '요청 데이터를 JSON으로 변환하지 못했습니다.',
                    previous: $exception
                );
            }
    
            $options[CURLOPT_POSTFIELDS] = $encodedBody;
            $options[CURLOPT_HTTPHEADER] = array_merge(
                $requestHeaders,
                [
                    'Content-Type: application/json',
                ]
            );
        }
    
        if (!curl_setopt_array($curl, $options)) {
            curl_close($curl);
    
            throw new RuntimeException(
                'cURL 옵션 설정에 실패했습니다.'
            );
        }
    
        $response = curl_exec($curl);
    
        if ($response === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new RuntimeException(
                sprintf(
                    '%s 요청 실패 [%d]: %s',
                    $normalizedMethod,
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        $info = curl_getinfo($curl);
    
        curl_close($curl);
    
        return new HttpResponse(
            statusCode: $statusCode,
            body: $response,
            info: $info
        );
    }

    GET 요청 예시는 다음과 같습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'GET',
        url: 'https://api.example.com/users',
        query: [
            'page' => 1,
            'size' => 20,
        ]
    );
    
    if (!$response->isSuccessful()) {
        throw new RuntimeException(
            sprintf(
                '사용자 조회 실패: HTTP %d, 응답: %s',
                $response->statusCode,
                $response->body
            )
        );
    }
    
    echo $response->body;

    JSON POST 요청 예시는 다음과 같습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'POST',
        url: 'https://api.example.com/users',
        jsonBody: [
            'name' => '홍길동',
            'email' => 'hong@example.com',
        ]
    );
    
    if (!$response->isSuccessful()) {
        throw new RuntimeException(
            sprintf(
                '사용자 등록 실패: HTTP %d, 응답: %s',
                $response->statusCode,
                $response->body
            )
        );
    }
    
    echo $response->body;

    PUT 요청도 같은 함수로 처리할 수 있습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'PUT',
        url: 'https://api.example.com/users/100',
        jsonBody: [
            'name' => '김개발',
        ]
    );

    DELETE 요청 예시는 다음과 같습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'DELETE',
        url: 'https://api.example.com/users/100'
    );

    연결 타임아웃과 전체 타임아웃 차이

    cURL에서는 연결 타임아웃과 전체 요청 타임아웃을 구분할 수 있습니다.

    CURLOPT_CONNECTTIMEOUT

    서버와 연결을 맺을 때까지 기다리는 최대 시간입니다.

    CURLOPT_CONNECTTIMEOUT => 5

    예를 들어 DNS 조회, TCP 연결, TLS 연결 과정이 지나치게 오래 걸리면 지정한 시간 이후 요청을 중단합니다.

    CURLOPT_TIMEOUT

    요청 전체가 완료될 때까지 기다리는 최대 시간입니다.

    CURLOPT_TIMEOUT => 15

    연결이 성공했더라도 서버가 응답을 늦게 보내면 전체 타임아웃에 걸릴 수 있습니다.

    일반적인 API 호출 예시는 다음과 같습니다.

    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 15,

    API의 특성에 따라 값을 조정해야 합니다.

    대용량 파일 업로드나 오래 걸리는 배치 API에 무조건 15초를 적용하면 정상 요청도 중단될 수 있습니다.

    반대로 타임아웃을 지나치게 크게 설정하면 장애가 발생했을 때 PHP 프로세스가 오랫동안 점유될 수 있습니다.

    밀리초 단위가 필요하다면 다음 옵션을 사용할 수 있습니다.

    CURLOPT_CONNECTTIMEOUT_MS => 3000,
    CURLOPT_TIMEOUT_MS => 10000,

    초 단위와 밀리초 단위 옵션을 동시에 중복 설정하지 말고 하나의 기준으로 통일하는 것이 좋습니다.

    cURL 오류와 HTTP 오류는 다르다

    다음 두 오류는 서로 다른 상황입니다.

    cURL 실행 오류
    HTTP 상태 코드 오류

    cURL 실행 오류

    다음과 같은 경우입니다.

    DNS 조회 실패
    연결 거부
    연결 시간 초과
    SSL 인증서 검증 실패
    네트워크 연결 중단

    이 경우 curl_exec()false를 반환할 수 있습니다.

    $response = curl_exec($curl);
    
    if ($response === false) {
        echo curl_errno($curl);
        echo curl_error($curl);
    }

    HTTP 상태 코드 오류

    서버와 통신은 성공했지만 서버가 오류 상태 코드를 반환한 경우입니다.

    400 Bad Request
    401 Unauthorized
    403 Forbidden
    404 Not Found
    409 Conflict
    422 Unprocessable Content
    429 Too Many Requests
    500 Internal Server Error
    503 Service Unavailable

    이 경우 curl_exec()가 문자열 응답을 정상적으로 반환할 수 있습니다.

    따라서 HTTP 상태 코드를 별도로 확인해야 합니다.

    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    if ($statusCode < 200 || $statusCode >= 300) {
        // HTTP 오류 처리
    }

    HTTP 상태 코드 처리 예제

    <?php
    
    $response = sendHttpRequest(
        method: 'GET',
        url: 'https://api.example.com/users/100'
    );
    
    switch ($response->statusCode) {
        case 200:
            echo $response->body;
            break;
    
        case 401:
            throw new RuntimeException(
                '인증이 필요하거나 토큰이 만료되었습니다.'
            );
    
        case 403:
            throw new RuntimeException(
                '해당 요청을 실행할 권한이 없습니다.'
            );
    
        case 404:
            throw new RuntimeException(
                '요청한 사용자를 찾을 수 없습니다.'
            );
    
        case 429:
            throw new RuntimeException(
                'API 요청 횟수 제한을 초과했습니다.'
            );
    
        default:
            throw new RuntimeException(
                sprintf(
                    'API 요청 실패: HTTP %d, 응답: %s',
                    $response->statusCode,
                    $response->body
                )
            );
    }

    실제 운영 코드에서는 전체 응답 본문에 개인정보나 토큰이 포함될 수 있으므로 그대로 로그에 남기지 않도록 주의해야 합니다.

    CURLOPT_FAILONERROR를 사용할 때 주의점

    다음 옵션을 사용하면 HTTP 상태 코드가 400 이상인 응답을 cURL 실패로 처리할 수 있습니다.

    CURLOPT_FAILONERROR => true

    그러나 이 옵션을 사용하면 HTTP 오류 응답 본문을 처리하기 어려워질 수 있습니다.

    API 서버가 다음과 같은 JSON 오류 메시지를 반환한다고 가정해 보겠습니다.

    {
      "code": "INVALID_EMAIL",
      "message": "이메일 형식이 올바르지 않습니다."
    }

    오류 응답 본문을 애플리케이션에서 직접 분석해야 한다면 CURLOPT_FAILONERROR를 사용하지 않고 상태 코드를 검사하는 방식이 더 명확합니다.

    CURLOPT_FAILONERROR => false

    또는 옵션 자체를 생략합니다.

    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    if ($statusCode < 200 || $statusCode >= 300) {
        // 응답 본문과 상태 코드를 함께 처리
    }

    SSL 인증서 검증을 끄면 안 되는 이유

    인터넷의 오래된 PHP cURL 예제에는 다음 설정이 자주 포함되어 있습니다.

    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,

    이 설정은 HTTPS 인증서 검증을 비활성화합니다.

    운영 환경에서는 사용하지 않는 것이 좋습니다.

    SSL 인증서 검증을 끄면 현재 접속한 서버가 실제 대상 서버인지 확인하기 어려워지고, 중간자 공격이나 잘못된 서버 연결을 감지하지 못할 수 있습니다.

    권장 설정은 다음과 같습니다.

    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,

    대부분의 정상적인 서버 환경에서는 기본 CA 인증서 저장소를 통해 검증할 수 있습니다.

    cURL error 60 해결 방법

    SSL 인증서 검증 과정에서 다음 오류가 발생할 수 있습니다.

    cURL error 60:
    SSL certificate problem:
    unable to get local issuer certificate

    이 오류가 발생했다고 해서 인증서 검증을 바로 비활성화하면 안 됩니다.

    먼저 다음 항목을 확인합니다.

    서버 인증서가 만료되지 않았는지
    접속 도메인과 인증서 도메인이 일치하는지
    서버가 중간 인증서를 제대로 제공하는지
    PHP 또는 운영체제의 CA 인증서가 최신인지
    사내 프록시가 인증서를 교체하고 있는지

    리눅스의 CA 인증서 패키지를 확인하거나 갱신할 수 있습니다.

    Ubuntu 또는 Debian 계열 예시는 다음과 같습니다.

    sudo apt update
    sudo apt install --reinstall ca-certificates
    sudo update-ca-certificates

    PHP가 사용하는 설정 파일 위치를 확인합니다.

    php --ini

    cURL 관련 설정을 확인합니다.

    php -i | grep -i curl.cainfo

    OpenSSL CA 설정도 확인할 수 있습니다.

    php -i | grep -i openssl.cafile

    별도의 CA 파일을 사용해야 한다면 php.ini에서 지정할 수 있습니다.

    curl.cainfo="/path/to/cacert.pem"
    openssl.cafile="/path/to/cacert.pem"

    또는 요청별로 CA 파일을 지정할 수 있습니다.

    CURLOPT_CAINFO => '/path/to/company-ca.pem',

    사내 CA 인증서를 사용할 때는 출처와 인증서 지문을 관리자에게 확인해야 합니다.

    인터넷에서 임의로 내려받은 인증서를 신뢰하도록 등록하면 안 됩니다.

    리디렉션 처리

    API나 웹페이지가 다른 주소로 리디렉션될 수 있습니다.

    리디렉션을 따라가려면 다음 옵션을 사용합니다.

    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS => 5,

    전체 예시는 다음과 같습니다.

    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 5,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
    ]);

    모든 API 요청에서 리디렉션을 무조건 허용할 필요는 없습니다.

    특히 Authorization 헤더나 민감한 데이터가 포함된 요청에서는 예상하지 못한 다른 호스트로 이동하지 않는지 확인해야 합니다.

    최종 요청 URL은 다음과 같이 확인할 수 있습니다.

    $effectiveUrl = curl_getinfo(
        $curl,
        CURLINFO_EFFECTIVE_URL
    );

    응답 헤더까지 가져오기

    응답 본문뿐 아니라 응답 헤더가 필요하다면 헤더 콜백을 사용할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/users';
    
    $responseHeaders = [];
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
        CURLOPT_HEADERFUNCTION => static function (
            CurlHandle $curlHandle,
            string $headerLine
        ) use (&$responseHeaders): int {
            $length = strlen($headerLine);
            $trimmedLine = trim($headerLine);
    
            if (
                $trimmedLine === '' ||
                !str_contains($trimmedLine, ':')
            ) {
                return $length;
            }
    
            [$name, $value] = explode(
                ':',
                $trimmedLine,
                2
            );
    
            $normalizedName = strtolower(trim($name));
            $normalizedValue = trim($value);
    
            $responseHeaders[$normalizedName][] =
                $normalizedValue;
    
            return $length;
        },
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorMessage = curl_error($curl);
        curl_close($curl);
    
        throw new RuntimeException(
            'HTTP 요청 실패: ' . $errorMessage
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    print_r($responseHeaders);
    echo $response;

    API 요청 횟수 제한을 확인할 때 응답 헤더를 사용할 수 있습니다.

    Retry-After
    X-RateLimit-Limit
    X-RateLimit-Remaining
    X-RateLimit-Reset

    실제 헤더 이름은 API 제공업체마다 다릅니다.

    요청 시간 측정

    curl_getinfo()를 사용하면 요청 처리 시간을 확인할 수 있습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'GET',
        url: 'https://api.example.com/users'
    );
    
    $totalTime = $response->info['total_time'] ?? null;
    $connectTime = $response->info['connect_time'] ?? null;
    $nameLookupTime =
        $response->info['namelookup_time'] ?? null;
    
    echo 'DNS 조회 시간: '
        . (string) $nameLookupTime
        . PHP_EOL;
    
    echo '연결 시간: '
        . (string) $connectTime
        . PHP_EOL;
    
    echo '전체 요청 시간: '
        . (string) $totalTime
        . PHP_EOL;

    다음 정보를 장애 분석에 활용할 수 있습니다.

    namelookup_time
    connect_time
    appconnect_time
    starttransfer_time
    total_time
    primary_ip
    http_code

    운영 로그에 응답 시간을 남기면 DNS 지연, 연결 지연, 서버 응답 지연을 구분하는 데 도움이 됩니다.

    API 요청 로그에 남기면 안 되는 정보

    디버깅을 위해 요청 정보를 로그에 남길 때는 민감정보를 제거해야 합니다.

    다음 값은 그대로 기록하지 않는 것이 좋습니다.

    Authorization 헤더
    Bearer Token
    API Key
    비밀번호
    주민등록번호
    카드번호
    계좌번호
    세션 쿠키
    개인정보가 포함된 요청 본문

    잘못된 예시는 다음과 같습니다.

    error_log(
        json_encode([
            'headers' => $headers,
            'body' => $requestData,
        ])
    );

    필요한 값만 선별해 기록합니다.

    <?php
    
    $logContext = [
        'method' => 'POST',
        'url' => 'https://api.example.com/orders',
        'statusCode' => $response->statusCode,
        'totalTime' =>
            $response->info['total_time'] ?? null,
    ];
    
    error_log(
        json_encode(
            $logContext,
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        )
    );

    URL의 쿼리스트링에도 토큰이나 개인정보가 포함될 수 있으므로 전체 URL을 그대로 기록하기 전에 확인해야 합니다.

    재시도 처리 시 주의점

    네트워크 오류나 일시적인 503 응답이 발생하면 재시도를 고려할 수 있습니다.

    하지만 모든 요청을 자동으로 재시도하면 안 됩니다.

    특히 다음 요청은 중복 실행 위험이 있습니다.

    결제 승인
    주문 생성
    포인트 차감
    계좌 이체
    회원 등록
    파일 업로드

    POST 요청이 서버에서 처리된 후 응답만 유실된 경우, 클라이언트가 다시 요청하면 동일 작업이 두 번 실행될 수 있습니다.

    재시도가 필요한 API라면 다음을 함께 고려해야 합니다.

    멱등키 Idempotency-Key
    고유 요청 ID
    서버의 중복 처리 방지
    재시도 가능한 상태 코드 제한
    최대 재시도 횟수
    지수 백오프

    멱등키 헤더 예시는 다음과 같습니다.

    <?php
    
    $idempotencyKey = bin2hex(
        random_bytes(16)
    );
    
    $response = sendJsonPostRequest(
        'https://api.example.com/payments',
        [
            'orderId' => 'ORDER-20260801-001',
            'amount' => 10000,
        ],
        [
            'Idempotency-Key: ' . $idempotencyKey,
        ]
    );

    실제로 Idempotency-Key를 지원하는지는 API 제공업체의 명세를 확인해야 합니다.

    파일 업로드는 JSON POST와 다르다

    파일 업로드는 일반적으로 다음 Content-Type을 사용합니다.

    multipart/form-data

    CURLFile 객체를 사용할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    $filePath = __DIR__ . '/sample.jpg';
    
    if (!is_file($filePath)) {
        throw new RuntimeException(
            '업로드할 파일을 찾을 수 없습니다.'
        );
    }
    
    $curlFile = new CURLFile(
        $filePath,
        'image/jpeg',
        basename($filePath)
    );
    
    $postData = [
        'title' => '샘플 이미지',
        'file' => $curlFile,
    ];
    
    $curl = curl_init(
        'https://api.example.com/files'
    );
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $postData,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorMessage = curl_error($curl);
        curl_close($curl);
    
        throw new RuntimeException(
            '파일 업로드 실패: ' . $errorMessage
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                '파일 업로드 실패: HTTP %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    echo $response;

    CURLOPT_POSTFIELDS에 배열과 CURLFile을 전달하면 cURL이 multipart 요청을 구성합니다.

    이 경우 Content-Type: multipart/form-data 헤더를 직접 작성하지 않는 편이 좋습니다.

    cURL이 boundary 값을 포함한 올바른 Content-Type을 자동으로 생성해야 하기 때문입니다.

    자주 발생하는 코드 오류

    URL에 꺾쇠괄호를 포함한 경우

    잘못된 예시는 다음과 같습니다.

    $url = '<https://api.example.com/users>';

    마크다운 링크 표기를 PHP 문자열에 그대로 복사한 형태입니다.

    올바른 URL은 다음과 같습니다.

    $url = 'https://api.example.com/users';

    문자열 연결에 쉼표를 사용한 경우

    잘못된 예시는 다음과 같습니다.

    $url = 'https://api.example.com/users'
        . '?',
        http_build_query($query);

    PHP 문자열 연결에는 마침표 연산자 .를 사용해야 합니다.

    올바른 예시는 다음과 같습니다.

    $url = 'https://api.example.com/users'
        . '?'
        . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

    더 명확하게 나누면 다음과 같습니다.

    $queryString = http_build_query(
        $query,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    $url = $baseUrl . '?' . $queryString;

    GET 요청에 CURLOPT_POST를 사용한 경우

    GET 요청에는 다음 옵션이 필요하지 않습니다.

    CURLOPT_POST => true

    GET 요청은 URL에 쿼리스트링을 포함하고 기본 요청 방식으로 실행하면 됩니다.

    $curl = curl_init($url);

    JSON 요청에 배열을 그대로 전달한 경우

    다음 코드는 JSON 요청이 아니라 multipart 또는 form 형태로 처리될 수 있습니다.

    CURLOPT_POSTFIELDS => [
        'name' => '홍길동',
        'email' => 'hong@example.com',
    ],

    JSON 요청은 먼저 JSON 문자열로 변환해야 합니다.

    $jsonBody = json_encode(
        $data,
        JSON_THROW_ON_ERROR
    );
    CURLOPT_POSTFIELDS => $jsonBody,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
    ],

    Content-Type만 JSON이고 본문은 form 형식인 경우

    잘못된 예시는 다음과 같습니다.

    CURLOPT_POSTFIELDS => http_build_query($data),
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
    ],

    헤더는 JSON인데 실제 본문은 다음 형태가 됩니다.

    name=hong&email=hong%40example.com

    JSON API라면 본문도 JSON으로 만들어야 합니다.

    CURLOPT_POSTFIELDS => json_encode(
        $data,
        JSON_THROW_ON_ERROR
    ),

    curl_exec 결과를 검사하지 않은 경우

    잘못된 예시는 다음과 같습니다.

    $response = curl_exec($curl);
    
    $data = json_decode($response, true);

    curl_exec()false를 반환할 수 있으므로 먼저 확인합니다.

    $response = curl_exec($curl);
    
    if ($response === false) {
        throw new RuntimeException(
            curl_error($curl)
        );
    }

    HTTP 상태 코드를 확인하지 않은 경우

    응답 본문이 반환되었다고 요청이 성공한 것은 아닙니다.

    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    if ($statusCode < 200 || $statusCode >= 300) {
        // 오류 처리
    }

    타임아웃을 설정하지 않은 경우

    외부 API가 응답하지 않으면 PHP 요청이 장시간 대기할 수 있습니다.

    최소한 연결 타임아웃과 전체 타임아웃을 설정하는 것이 좋습니다.

    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 15,

    SSL 검증을 비활성화한 경우

    다음 설정은 운영 환경에서 피해야 합니다.

    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,

    권장 설정은 다음과 같습니다.

    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,

    최종 JSON API 요청 예제

    다음 코드는 JSON POST 요청에서 필요한 핵심 처리를 포함한 예제입니다.

    <?php
    
    declare(strict_types=1);
    
    final class ApiException extends RuntimeException
    {
        public function __construct(
            string $message,
            public readonly ?int $statusCode = null,
            public readonly ?string $responseBody = null,
            ?Throwable $previous = null
        ) {
            parent::__construct(
                $message,
                previous: $previous
            );
        }
    }
    
    /**
     * @param array<string, mixed> $payload
     * @param list<string> $additionalHeaders
     *
     * @return array<string, mixed>
     */
    function postJson(
        string $url,
        array $payload,
        array $additionalHeaders = []
    ): array {
        try {
            $requestBody = json_encode(
                $payload,
                JSON_THROW_ON_ERROR |
                JSON_UNESCAPED_UNICODE |
                JSON_UNESCAPED_SLASHES
            );
        } catch (JsonException $exception) {
            throw new ApiException(
                message: '요청 데이터 JSON 변환 실패',
                previous: $exception
            );
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new ApiException(
                'cURL 초기화에 실패했습니다.'
            );
        }
    
        $headers = array_merge(
            [
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            $additionalHeaders
        );
    
        $optionResult = curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $requestBody,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
    
        if ($optionResult === false) {
            curl_close($curl);
    
            throw new ApiException(
                'cURL 옵션 설정에 실패했습니다.'
            );
        }
    
        $responseBody = curl_exec($curl);
    
        if ($responseBody === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new ApiException(
                sprintf(
                    'API 연결 실패 [%d]: %s',
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        curl_close($curl);
    
        if ($statusCode < 200 || $statusCode >= 300) {
            throw new ApiException(
                message: sprintf(
                    'API가 오류 상태 코드를 반환했습니다: HTTP %d',
                    $statusCode
                ),
                statusCode: $statusCode,
                responseBody: $responseBody
            );
        }
    
        if ($responseBody === '') {
            return [];
        }
    
        try {
            $decodedResponse = json_decode(
                $responseBody,
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (JsonException $exception) {
            throw new ApiException(
                message: 'API 응답 JSON 파싱 실패',
                statusCode: $statusCode,
                responseBody: $responseBody,
                previous: $exception
            );
        }
    
        if (!is_array($decodedResponse)) {
            throw new ApiException(
                message: 'API 응답이 예상한 객체 또는 배열 형식이 아닙니다.',
                statusCode: $statusCode,
                responseBody: $responseBody
            );
        }
    
        return $decodedResponse;
    }

    호출 예시는 다음과 같습니다.

    <?php
    
    try {
        $result = postJson(
            'https://api.example.com/users',
            [
                'name' => '홍길동',
                'email' => 'hong@example.com',
            ]
        );
    
        print_r($result);
    } catch (ApiException $exception) {
        error_log(
            sprintf(
                'API 요청 오류: %s, HTTP 상태: %s',
                $exception->getMessage(),
                $exception->statusCode !== null
                    ? (string) $exception->statusCode
                    : '없음'
            )
        );
    
        echo '외부 API 요청 중 오류가 발생했습니다.';
    }

    운영 환경에서는 $exception->responseBody 전체를 그대로 로그에 기록하기 전에 개인정보나 민감정보가 포함되어 있는지 확인해야 합니다.

    적용 전 체크리스트

    [ ] PHP cURL 확장이 활성화되어 있다.
    [ ] URL에 < > 문자가 포함되어 있지 않다.
    [ ] GET 쿼리스트링은 http_build_query로 생성한다.
    [ ] PHP_QUERY_RFC3986 옵션을 검토했다.
    [ ] CURLOPT_RETURNTRANSFER를 true로 설정했다.
    [ ] 연결 타임아웃을 설정했다.
    [ ] 전체 요청 타임아웃을 설정했다.
    [ ] curl_exec가 false인지 확인한다.
    [ ] curl_errno와 curl_error를 함께 확인한다.
    [ ] HTTP 상태 코드를 별도로 확인한다.
    [ ] JSON 요청 본문은 json_encode로 생성한다.
    [ ] JSON Content-Type 헤더를 지정했다.
    [ ] JSON 응답은 JSON_THROW_ON_ERROR로 파싱한다.
    [ ] CURLOPT_SSL_VERIFYPEER를 false로 설정하지 않았다.
    [ ] CURLOPT_SSL_VERIFYHOST는 2로 설정했다.
    [ ] SSL 오류 발생 시 CA 인증서와 서버 인증서를 점검한다.
    [ ] Authorization 토큰을 소스코드에 직접 작성하지 않았다.
    [ ] 로그에 토큰과 개인정보를 남기지 않는다.
    [ ] POST 재시도 전에 중복 처리 위험을 검토한다.
    [ ] 결제·주문 API는 멱등성 처리를 확인한다.

    정리

    PHP cURL 요청은 curl_exec()를 호출하는 것만으로 끝나지 않습니다.

    GET 요청에서는 http_build_query()를 사용해 쿼리스트링을 안전하게 생성하고, JSON POST 요청에서는 데이터를 json_encode()로 변환한 뒤 Content-Type: application/json 헤더를 지정해야 합니다.

    네트워크 요청은 다음 세 가지 오류를 구분해 처리하는 것이 중요합니다.

    cURL 네트워크 오류
    HTTP 4xx·5xx 오류
    JSON 인코딩 또는 디코딩 오류

    연결 타임아웃과 전체 타임아웃도 반드시 구분하여 설정해야 합니다.

    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 15,

    HTTPS 요청에서 인증서 오류가 발생하더라도 다음 설정으로 검증을 끄는 것은 피해야 합니다.

    CURLOPT_SSL_VERIFYPEER => false

    인증서 문제는 서버 인증서 체인, 접속 도메인, 인증서 만료일, 운영체제의 CA 인증서 저장소를 확인하여 해결하는 것이 안전합니다.

    또한 결제나 주문 생성처럼 중복 실행되면 문제가 되는 POST 요청은 단순 자동 재시도보다 멱등키와 서버 측 중복 방지 처리를 먼저 검토해야 합니다.


  • Ubuntu와 CentOS에서 Apache 명령어가 다른 이유: apache2, httpd, apachectl 정리

    요즘은 nginx를 많이 쓰다보니 거의 까먹고 있었는데

    아파치 기본 명령어들 정리해보았다

    기본은 systemctl, httpd 또는 apachectl 중 하나를 사용.

    os 에 따라선 apache2 를 사용하기도 한다.

    service apache2 restart 이런식으로 말이다

    Apache 상태 확인

    # systemctl status httpd
    
    # service httpd status

    apache start

    # systemctl start httpd
    
    # service httpd start
    
    # apachectl start

    apache stop

    # systemctl stop httpd
    
    # service httpd stop
    
    # apachectl stop

    apache restart

    # systemctl restart httpd
    
    # service httpd restart
    
    # apachectl restart
  • MAC – svn 설치

    macOS에서 SVN 설치하고 IntelliJ·VS Code에서 사용하는 방법

    macOS에서 SVN 저장소를 사용하려면 먼저 Subversion 명령줄 클라이언트를 설치해야 합니다.

    과거에는 macOS나 Xcode Command Line Tools에 SVN이 포함된 경우가 있었지만, 현재는 기본 명령어로 제공되지 않는 환경이 많습니다. 따라서 Homebrew를 이용해 Subversion을 별도로 설치하는 방법이 가장 간단합니다.

    이 글에서는 다음 내용을 정리합니다.

    • Intel Mac과 Apple Silicon Mac 구분
    • Homebrew 설치 경로 차이
    • Subversion 설치
    • SVN 버전과 실행 경로 확인
    • 저장소 체크아웃
    • 인증정보 저장 위치와 캐시 삭제
    • IntelliJ IDEA에 SVN 실행 파일 연결
    • VS Code에서 SVN 확장 사용
    • 사설 인증서 오류 대응
    • Finder에서 사용할 수 있는 SnailSVN

    내 Mac이 Intel인지 Apple Silicon인지 확인하기

    Mac의 CPU 아키텍처에 따라 Homebrew 설치 경로가 달라집니다.

    터미널에서 다음 명령어를 실행합니다.

    uname -m

    Apple Silicon Mac에서는 일반적으로 다음과 같이 출력됩니다.

    arm64

    Intel Mac에서는 다음과 같이 출력됩니다.

    x86_64

    macOS 화면에서도 확인할 수 있습니다.

    화면 왼쪽 위 Apple 메뉴
    → 이 Mac에 관하여
    → 칩 또는 프로세서 확인

    다음과 같이 표시되면 Apple Silicon입니다.

    Apple M1
    Apple M2
    Apple M3
    Apple M4

    다음과 같이 Intel 프로세서가 표시되면 Intel Mac입니다.

    Intel Core i5
    Intel Core i7
    Intel Core i9

    Intel Mac과 Apple Silicon의 Homebrew 경로 차이

    Homebrew의 기본 설치 경로는 CPU 아키텍처에 따라 다릅니다.

    Mac 종류Homebrew 기본 경로SVN 실행 파일 경로
    Apple Silicon/opt/homebrew/opt/homebrew/bin/svn
    Intel Mac/usr/local/usr/local/bin/svn

    Apple Silicon에서 Homebrew를 설치하면 일반적으로 다음 경로를 사용합니다.

    /opt/homebrew

    Intel Mac에서는 일반적으로 다음 경로를 사용합니다.

    /usr/local

    현재 Homebrew가 실제로 설치된 경로는 다음 명령어로 확인할 수 있습니다.

    brew --prefix

    Apple Silicon 출력 예시입니다.

    /opt/homebrew

    Intel Mac 출력 예시입니다.

    /usr/local

    SVN 실행 파일의 실제 경로는 다음 명령어로 확인합니다.

    which svn

    Apple Silicon에서는 보통 다음과 같이 출력됩니다.

    /opt/homebrew/bin/svn

    Intel Mac에서는 보통 다음과 같이 출력됩니다.

    /usr/local/bin/svn

    단순히 Mac의 CPU 종류만 보고 경로를 확정하기보다 which svn 명령어로 실제 경로를 확인하는 것이 가장 정확합니다.

    Apple Silicon Mac에서도 Rosetta 터미널을 사용하거나 Intel용 Homebrew를 별도로 설치했다면 /usr/local/bin/svn이 표시될 수 있습니다.

    Homebrew 설치 여부 확인

    먼저 Homebrew가 설치되어 있는지 확인합니다.

    brew --version

    정상적으로 설치되어 있다면 다음과 같이 버전이 출력됩니다.

    Homebrew 4.x.x

    다음과 같이 명령어를 찾을 수 없다는 메시지가 나오면 Homebrew를 먼저 설치해야 합니다.

    zsh: command not found: brew

    Homebrew 설치 명령어는 다음과 같습니다.

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

    설치가 끝나면 터미널에 안내되는 shellenv 명령어를 실행해야 할 수 있습니다.

    Apple Silicon에서는 일반적으로 다음과 같이 설정합니다.

    echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
    eval "$(/opt/homebrew/bin/brew shellenv)"

    Intel Mac에서는 보통 /usr/local/bin/brew가 기본 PATH에 포함되므로 별도 설정 없이 동작하는 경우가 많습니다.

    현재 셸이 zsh인지 확인하려면 다음 명령어를 사용합니다.

    echo "$SHELL"

    출력 예시는 다음과 같습니다.

    /bin/zsh

    설정 후 Homebrew가 정상적으로 인식되는지 다시 확인합니다.

    brew --version
    brew --prefix

    Homebrew로 Subversion 설치하기

    Homebrew가 준비되었다면 다음 명령어로 Subversion을 설치합니다.

    brew install subversion

    이미 설치되어 있다면 다음과 같은 메시지가 나타날 수 있습니다.

    subversion is already installed and up-to-date

    설치된 패키지를 다시 설치하려면 다음과 같이 실행할 수 있습니다.

    brew reinstall subversion

    패키지 정보를 확인하려면 다음 명령어를 사용합니다.

    brew info subversion

    과거 글에서 사용하던 다음 명령어는 현재 설치 과정에서는 필수적이지 않습니다.

    brew options subversion

    현재는 보통 brew install subversion만 실행하면 됩니다.

    SVN 설치 확인

    설치가 끝나면 SVN 버전을 확인합니다.

    svn --version

    출력 예시는 다음과 같습니다.

    svn, version 1.14.x
       compiled ...

    간단히 버전 번호만 확인하려면 다음 명령어를 사용합니다.

    svn --version --quiet

    출력 예시는 다음과 같습니다.

    1.14.x

    SVN 실행 파일 위치도 확인합니다.

    which svn

    또는 다음 명령어를 사용할 수 있습니다.

    command -v svn

    Apple Silicon 예시입니다.

    /opt/homebrew/bin/svn

    Intel Mac 예시입니다.

    /usr/local/bin/svn

    심볼릭 링크가 연결된 실제 파일을 확인하려면 다음 명령어를 사용할 수 있습니다.

    ls -l "$(which svn)"

    brew로 설치했는데 svn 명령어를 찾지 못하는 경우

    Subversion을 설치했는데 다음 오류가 발생할 수 있습니다.

    zsh: command not found: svn

    먼저 Homebrew 경로를 확인합니다.

    brew --prefix

    Subversion 설치 여부를 확인합니다.

    brew list subversion

    실행 파일이 있는지 확인합니다.

    ls -l "$(brew --prefix)/bin/svn"

    Apple Silicon에서 Homebrew PATH가 설정되지 않았다면 다음 내용을 ~/.zprofile에 추가합니다.

    echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile

    현재 터미널에도 즉시 반영합니다.

    eval "$(/opt/homebrew/bin/brew shellenv)"

    그다음 다시 확인합니다.

    which brew
    which svn
    svn --version

    터미널에서는 동작하지만 IntelliJ나 VS Code에서 SVN을 찾지 못하는 경우에는 IDE를 완전히 종료한 다음 다시 실행해 봅니다.

    macOS Dock에서 실행한 GUI 애플리케이션은 터미널과 PATH 구성이 다르게 적용될 수 있으므로, IDE 설정에 SVN 실행 파일의 절대경로를 직접 지정하는 방법이 더 확실합니다.

    SVN 저장소 연결 확인

    저장소 전체를 체크아웃하기 전에 접속 여부를 먼저 확인할 수 있습니다.

    svn list https://svn.example.com/repos/project

    인증이 필요하면 사용자 이름과 비밀번호를 입력하라는 메시지가 나타납니다.

    사용자 이름을 명시하려면 다음과 같이 실행합니다.

    svn list \
      --username my-user \
      https://svn.example.com/repos/project

    비밀번호를 명령어에 직접 작성하는 방식은 셸 히스토리에 남을 수 있으므로 권장하지 않습니다.

    svn list \
      --username my-user \
      --password 'my-password' \
      https://svn.example.com/repos/project

    가능하면 비밀번호 옵션을 생략하고 대화형 입력을 사용합니다.

    svn list \
      --username my-user \
      https://svn.example.com/repos/project

    SVN 저장소 체크아웃

    SVN 저장소의 작업 사본을 내려받을 때는 svn checkout 명령어를 사용합니다.

    svn checkout 저장소_URL 로컬_디렉터리

    예시는 다음과 같습니다.

    svn checkout \
      https://svn.example.com/repos/project/trunk \
      project

    짧은 명령어인 co를 사용할 수도 있습니다.

    svn co \
      https://svn.example.com/repos/project/trunk \
      project

    명령어의 의미는 다음과 같습니다.

    https://svn.example.com/repos/project/trunk
    └── 체크아웃할 SVN 저장소 경로
    
    project
    └── 로컬에 생성할 작업 디렉터리

    로컬 디렉터리를 생략하면 저장소 URL의 마지막 경로를 기준으로 디렉터리가 생성됩니다.

    svn checkout https://svn.example.com/repos/project/trunk

    체크아웃이 끝나면 작업 디렉터리로 이동합니다.

    cd project

    현재 작업 사본 정보를 확인합니다.

    svn info

    변경 상태를 확인합니다.

    svn status

    원격 저장소의 최신 내용을 가져옵니다.

    svn update

    trunk, branches, tags 중 필요한 경로만 체크아웃하기

    일반적인 SVN 저장소는 다음과 같은 구조를 사용합니다.

    project/
    ├── trunk/
    ├── branches/
    └── tags/

    전체 저장소를 내려받기보다 실제 작업할 경로만 체크아웃하는 것이 좋습니다.

    trunk를 체크아웃하는 예시입니다.

    svn checkout \
      https://svn.example.com/repos/project/trunk \
      project

    특정 브랜치를 체크아웃하는 예시입니다.

    svn checkout \
      https://svn.example.com/repos/project/branches/develop \
      project-develop

    태그는 일반적으로 배포 버전이나 특정 시점 보관용으로 사용합니다.

    svn checkout \
      https://svn.example.com/repos/project/tags/release-1.0 \
      project-release-1.0

    저장소 전체를 무조건 체크아웃하면 관련 없는 프로젝트와 브랜치까지 함께 내려받을 수 있으므로 필요한 경로만 지정하는 것이 좋습니다.

    SVN 인증정보 저장 위치

    Subversion의 사용자별 설정과 인증 캐시는 일반적으로 다음 경로에 저장됩니다.

    ~/.subversion

    디렉터리 내용을 확인합니다.

    ls -al ~/.subversion

    인증정보 관련 파일은 다음 디렉터리에 저장될 수 있습니다.

    ~/.subversion/auth

    하위 디렉터리 예시는 다음과 같습니다.

    ~/.subversion/auth/svn.simple
    ~/.subversion/auth/svn.ssl.server
    ~/.subversion/auth/svn.username

    각 디렉터리의 용도는 대략 다음과 같습니다.

    경로용도
    svn.simple사용자 이름과 비밀번호 인증 캐시
    svn.ssl.serverHTTPS 서버 인증서 신뢰 정보
    svn.username사용자 이름 관련 캐시

    현재 저장된 인증 캐시를 확인합니다.

    find ~/.subversion/auth -type f -maxdepth 2 -print

    인증 파일 내용에는 서버 주소나 사용자 이름 등의 정보가 포함될 수 있습니다.

    cat ~/.subversion/auth/svn.simple/*

    비밀번호 또는 인증정보가 노출될 수 있으므로 화면 공유, 로그 첨부, 블로그 캡처 과정에서 파일 내용을 공개하지 않도록 주의해야 합니다.

    SVN 인증 캐시 삭제

    비밀번호가 변경되었거나 잘못된 계정이 계속 사용된다면 인증 캐시를 삭제해야 할 수 있습니다.

    전체 인증 캐시를 삭제하기 전에 먼저 백업합니다.

    cp -R ~/.subversion/auth ~/.subversion/auth.backup

    사용자 이름과 비밀번호 캐시를 삭제하려면 다음 디렉터리의 파일을 제거합니다.

    rm -f ~/.subversion/auth/svn.simple/*

    사용자 이름 캐시도 삭제하려면 다음과 같이 실행합니다.

    rm -f ~/.subversion/auth/svn.username/*

    SSL 인증서 신뢰 캐시를 삭제하려면 다음 명령어를 사용합니다.

    rm -f ~/.subversion/auth/svn.ssl.server/*

    모든 인증 캐시를 한 번에 초기화하려면 다음과 같이 할 수 있습니다.

    rm -rf ~/.subversion/auth

    이 방법은 저장된 사용자 인증과 SSL 신뢰 정보를 모두 삭제하므로 필요한 캐시만 삭제하는 편이 안전합니다.

    삭제 후 다시 저장소에 접근하면 인증정보를 다시 묻습니다.

    svn list https://svn.example.com/repos/project

    인증정보를 저장하지 않고 한 번만 사용하려면 --no-auth-cache 옵션을 사용할 수 있습니다.

    svn list \
      --no-auth-cache \
      https://svn.example.com/repos/project

    자동화 스크립트에서는 사용자 입력을 기다리지 않도록 --non-interactive 옵션을 함께 사용하기도 합니다.

    svn list \
      --non-interactive \
      --no-auth-cache \
      https://svn.example.com/repos/project

    단, 인증정보가 제공되지 않은 상태에서 인증이 필요하면 명령어가 실패합니다.

    macOS Keychain에 저장된 인증정보 확인

    SVN 클라이언트 구성에 따라 비밀번호가 ~/.subversion/auth 파일에 직접 저장되지 않고 macOS 키체인에 저장될 수 있습니다.

    키체인 접근 앱을 엽니다.

    응용 프로그램
    → 유틸리티
    → 키체인 접근

    저장소 도메인이나 SVN 서버 주소를 검색합니다.

    svn.example.com

    잘못된 인증정보가 있다면 해당 항목을 삭제한 후 SVN 명령어를 다시 실행합니다.

    터미널 인증 캐시를 삭제했는데도 기존 비밀번호가 계속 사용된다면 macOS 키체인과 IDE 자체 비밀번호 저장소도 함께 확인해야 합니다.

    IntelliJ IDEA에서 SVN 사용하기

    최근 IntelliJ IDEA에서는 Subversion 지원이 기본으로 포함되지 않고 플러그인 설치가 필요할 수 있습니다.

    플러그인 설정으로 이동합니다.

    IntelliJ IDEA
    → Settings 또는 Preferences
    → Plugins
    → Marketplace
    → Subversion 검색
    → 설치

    플러인을 설치한 후 IntelliJ IDEA를 다시 시작합니다.

    버전에 따라 Subversion 플러그인이 이미 활성화되어 있을 수도 있습니다.

    다음 메뉴가 보이는지 확인합니다.

    Settings 또는 Preferences
    → Version Control
    → Subversion

    IntelliJ IDEA는 시스템에 설치된 SVN 명령줄 클라이언트를 사용하므로 svn 실행 파일 경로를 정확히 지정해야 합니다.

    Intel Mac의 IntelliJ SVN 경로 설정

    Intel Mac에서 Homebrew로 설치한 SVN 실행 파일은 일반적으로 다음 경로에 있습니다.

    /usr/local/bin/svn

    IntelliJ IDEA에서 다음 메뉴로 이동합니다.

    Settings 또는 Preferences
    → Version Control
    → Subversion
    → Path to Subversion executable

    다음 경로를 입력합니다.

    /usr/local/bin/svn

    기존 Intel Mac IntelliJ 설정 이미지 유지

    기존 이미지 바로 아래에 다음 설명을 배치합니다.

    Intel Mac에서 Homebrew를 기본 경로에 설치했다면 SVN 실행 파일은 일반적으로 `/usr/local/bin/svn`에 있습니다. 다만 실제 경로는 터미널에서 `which svn` 명령어로 확인하는 것이 가장 정확합니다.

    Apple Silicon Mac의 IntelliJ SVN 경로 설정

    Apple Silicon Mac에서 Homebrew로 설치한 SVN 실행 파일은 일반적으로 다음 경로에 있습니다.

    /opt/homebrew/bin/svn

    IntelliJ IDEA에서 다음 메뉴로 이동합니다.

    Settings 또는 Preferences
    → Version Control
    → Subversion
    → Path to Subversion executable

    다음 경로를 입력합니다.

    /opt/homebrew/bin/svn

    기존 Apple Silicon IntelliJ 설정 이미지 유지

    기존 이미지 바로 아래에 다음 설명을 배치합니다.

    M1 이후 Apple Silicon Mac에서 Homebrew를 기본 경로에 설치했다면 SVN 실행 파일은 일반적으로 `/opt/homebrew/bin/svn`에 있습니다. Rosetta 기반 Intel용 Homebrew를 사용 중이라면 경로가 `/usr/local/bin/svn`일 수 있으므로 `which svn` 결과를 기준으로 설정해야 합니다.

    IntelliJ에서 SVN 실행 파일을 찾지 못하는 경우

    다음과 같은 오류가 나타날 수 있습니다.

    Can't use Subversion command line client

    또는 다음과 같이 표시될 수 있습니다.

    The path to the Subversion executable is probably wrong

    터미널에서 먼저 확인합니다.

    which svn
    svn --version

    예를 들어 다음과 같이 출력되었다면 IntelliJ에도 동일한 경로를 입력합니다.

    /opt/homebrew/bin/svn

    경로에 실행 권한이 있는지 확인합니다.

    ls -l "$(which svn)"

    IntelliJ를 터미널에서 실행했을 때만 동작한다면 GUI 애플리케이션의 PATH 문제일 가능성이 있습니다.

    이 경우 PATH에 의존하지 말고 IntelliJ 설정에 절대경로를 직접 입력합니다.

    Homebrew 업그레이드 후 경로가 바뀌었다고 의심된다면 다시 확인합니다.

    brew --prefix
    which svn
    brew info subversion

    IntelliJ에서 저장소 체크아웃

    IntelliJ 시작 화면에서 다음 메뉴를 선택합니다.

    Get from Version Control

    버전에 따라 다음과 같이 표시될 수 있습니다.

    VCS
    → Get from Version Control

    Version Control 종류에서 Subversion을 선택하고 저장소 URL을 입력합니다.

    https://svn.example.com/repos/project/trunk

    저장할 로컬 경로를 지정한 후 체크아웃합니다.

    Subversion 항목이 나타나지 않는다면 다음 항목을 확인합니다.

    • Subversion 플러그인이 설치되어 있는지
    • 플러그인이 비활성화되어 있지 않은지
    • IntelliJ를 재시작했는지
    • SVN CLI 실행 경로가 올바른지
    • svn --version이 터미널에서 정상 동작하는지

    이미 체크아웃한 프로젝트를 IntelliJ로 열었다면 프로젝트 디렉터리 안의 .svn 메타데이터를 감지하여 SVN 프로젝트로 인식할 수 있습니다.

    다음 명령어로 작업 사본인지 확인할 수 있습니다.

    svn info

    VS Code에서 SVN 사용하기

    VS Code는 기본적으로 Git 통합 기능을 제공하지만 SVN은 확장 프로그램을 설치해야 합니다.

    확장 프로그램 화면을 엽니다.

    VS Code
    → Extensions

    다음 이름으로 검색합니다.

    SVN

    기존 글에서 사용하던 확장은 다음 Marketplace 확장입니다.

    SVN
    Publisher 또는 Extension ID: johnstoncode.svn-scm

    명령 팔레트로 설치하려면 다음 명령어를 사용할 수 있습니다.

    ext install johnstoncode.svn-scm

    터미널에서 VS Code CLI를 사용할 수 있다면 다음과 같이 설치할 수도 있습니다.

    code --install-extension johnstoncode.svn-scm

    이 확장 프로그램은 자체 SVN 클라이언트를 포함하지 않고 Mac에 설치된 SVN 명령어를 사용합니다.

    따라서 확장을 설치하기 전에 다음 명령어가 정상 동작해야 합니다.

    svn --version

    VS Code에 SVN 실행 경로 설정

    VS Code가 자동으로 SVN 실행 파일을 찾지 못하면 settings.json에 경로를 직접 지정합니다.

    Apple Silicon Mac 예시입니다.

    {
      "svn.path": "/opt/homebrew/bin/svn"
    }

    Intel Mac 예시입니다.

    {
      "svn.path": "/usr/local/bin/svn"
    }

    설정 파일을 열려면 명령 팔레트에서 다음 항목을 선택합니다.

    Preferences: Open User Settings (JSON)

    현재 SVN 경로는 터미널에서 확인합니다.

    which svn

    기존 settings.json에 다른 설정이 있다면 JSON 객체 안에 svn.path만 추가해야 합니다.

    잘못된 예시입니다.

    {
      "editor.formatOnSave": true
    }
    {
      "svn.path": "/opt/homebrew/bin/svn"
    }

    JSON 최상위 객체는 하나만 있어야 합니다.

    올바른 예시는 다음과 같습니다.

    {
      "editor.formatOnSave": true,
      "svn.path": "/opt/homebrew/bin/svn"
    }

    VS Code에서 SVN 저장소 체크아웃

    명령 팔레트를 엽니다.

    Command + Shift + P

    다음 명령을 검색합니다.

    SVN: Checkout

    저장소 URL을 입력합니다.

    https://svn.example.com/repos/project/trunk

    프로젝트를 생성할 상위 디렉터리를 선택합니다.

    명령어로 먼저 체크아웃한 후 VS Code에서 폴더를 열어도 됩니다.

    svn checkout \
      https://svn.example.com/repos/project/trunk \
      project
    
    code project

    VS Code에서 SVN 작업 사본을 열면 왼쪽 Source Control 화면에서 변경 파일을 확인할 수 있습니다.

    확장이 SVN 저장소를 인식하지 못한다면 다음을 확인합니다.

    cd project
    svn info
    svn status

    프로젝트 내부에 .svn 디렉터리가 있는지도 확인합니다.

    ls -la

    VS Code에서 SVN 명령어를 찾지 못하는 경우

    VS Code에서 다음과 같은 오류가 나타날 수 있습니다.

    SVN not found

    또는 Source Control에 SVN 저장소가 표시되지 않을 수 있습니다.

    터미널에서 먼저 확인합니다.

    which svn
    svn --version

    settings.json에 실제 경로를 설정합니다.

    Apple Silicon 예시입니다.

    {
      "svn.path": "/opt/homebrew/bin/svn"
    }

    Intel Mac 예시입니다.

    {
      "svn.path": "/usr/local/bin/svn"
    }

    설정을 변경한 후 VS Code를 완전히 종료하고 다시 실행합니다.

    확장의 Output 로그도 확인합니다.

    View
    → Output
    → 출력 채널에서 SVN 선택

    로그에서 다음 내용을 확인합니다.

    • SVN 실행 파일을 찾지 못했는지
    • 저장소 인증에 실패했는지
    • 인증서 신뢰 오류가 발생했는지
    • 현재 폴더가 SVN 작업 사본이 아닌지
    • 작업 사본 형식을 지원하지 않는지

    SVN HTTPS 인증서 오류

    사내 SVN 서버나 자체 서명 인증서를 사용하는 서버에 접속하면 인증서 오류가 발생할 수 있습니다.

    대표적인 오류는 다음과 같습니다.

    Error validating server certificate

    또는 다음과 비슷한 내용이 표시됩니다.

    The certificate is not issued by a trusted authority
    The certificate hostname does not match
    The certificate has expired

    오류 원인은 크게 세 가지입니다.

    1. 사설 인증기관 또는 자체 서명 인증서 사용
    2. 인증서의 도메인과 접속 URL 불일치
    3. 인증서 유효기간 만료

    인증서 오류가 발생했을 때 먼저 확인할 것

    저장소 URL의 도메인을 확인합니다.

    svn info https://svn.example.com/repos/project

    브라우저에서 같은 주소의 인증서를 확인합니다.

    https://svn.example.com

    터미널에서 인증서 정보를 확인할 수도 있습니다.

    openssl s_client \
      -connect svn.example.com:443 \
      -servername svn.example.com \
      </dev/null

    인증서의 발급 대상과 만료일을 간단히 확인합니다.

    openssl s_client \
      -connect svn.example.com:443 \
      -servername svn.example.com \
      </dev/null 2>/dev/null |
    openssl x509 -noout -subject -issuer -dates

    다음 항목을 확인합니다.

    • 접속 URL과 인증서 도메인이 일치하는지
    • 인증서가 만료되지 않았는지
    • 중간 인증서 체인이 정상인지
    • 회사 내부 인증기관을 사용하는지

    인증서를 일시적으로 신뢰하는 방법

    SVN 명령어를 처음 실행하면 다음과 같은 선택지가 표시될 수 있습니다.

    (R)eject
    accept (t)emporarily
    accept (p)ermanently

    각 선택의 의미는 다음과 같습니다.

    R: 인증서를 거부
    t: 현재 실행에서만 임시로 신뢰
    p: 인증서를 인증 캐시에 저장하고 계속 신뢰

    테스트 목적이라면 먼저 임시 신뢰를 선택하는 것이 좋습니다.

    t

    인증서의 도메인과 지문을 확인했고 회사에서 사용하는 정상 인증서임이 확인되었다면 영구 신뢰를 선택할 수 있습니다.

    p

    검증되지 않은 외부 서버의 인증서를 무조건 영구 신뢰해서는 안 됩니다.

    인증서가 저장되는 위치는 일반적으로 다음과 같습니다.

    ~/.subversion/auth/svn.ssl.server

    저장된 인증서 신뢰 캐시를 초기화하려면 다음과 같이 실행합니다.

    rm -f ~/.subversion/auth/svn.ssl.server/*

    다음 접속 시 인증서 신뢰 여부를 다시 묻게 됩니다.

    svn list https://svn.example.com/repos/project

    인증서 오류를 무시하는 옵션 사용 시 주의점

    일부 SVN 명령어에서는 인증서 실패 유형을 무시하도록 옵션을 지정할 수 있습니다.

    예시는 다음과 같습니다.

    svn list \
      --non-interactive \
      --trust-server-cert \
      https://svn.example.com/repos/project

    하지만 이 방식은 서버 인증서 검증을 약화할 수 있으므로 일상적인 사용 방법으로 권장하지 않습니다.

    특히 자동화 스크립트에서 다음과 같이 인증서 확인을 무조건 우회하면 중간자 공격이나 잘못된 서버 접속을 식별하지 못할 수 있습니다.

    svn checkout \
      --non-interactive \
      --trust-server-cert \
      https://svn.example.com/repos/project

    가장 좋은 방법은 다음 중 하나입니다.

    • SVN 서버에 공인 인증서 적용
    • 회사 내부 CA 인증서를 macOS에서 신뢰하도록 등록
    • 인증서의 도메인 불일치 수정
    • 만료된 인증서 갱신
    • 누락된 중간 인증서 체인 수정

    사내 CA 인증서를 macOS 키체인에 등록하기

    회사 내부 인증기관에서 발급한 인증서를 사용하는 경우에는 루트 또는 중간 CA 인증서를 macOS 키체인에 등록할 수 있습니다.

    인증서 파일을 준비합니다.

    company-root-ca.crt

    키체인 접근 앱을 엽니다.

    응용 프로그램
    → 유틸리티
    → 키체인 접근

    회사 정책에 따라 다음 키체인 중 하나를 선택합니다.

    로그인
    시스템

    인증서 파일을 키체인으로 가져온 후 신뢰 설정을 확인합니다.

    다만 시스템 키체인에 인증서를 추가하거나 신뢰 상태를 변경하는 작업은 보안에 영향을 줄 수 있습니다.

    인증서의 출처와 지문을 회사 서버 관리자에게 확인한 뒤 적용해야 합니다.

    단순히 SVN 오류를 없애기 위해 출처가 불명확한 인증서를 신뢰하면 안 됩니다.

    인증정보가 계속 틀리게 입력되는 경우

    SVN CLI에서는 정상인데 IntelliJ나 VS Code에서만 인증 오류가 발생할 수 있습니다.

    다음 위치를 순서대로 확인합니다.

    1. ~/.subversion/auth/svn.simple
    2. macOS 키체인 접근
    3. IntelliJ Password Safe
    4. VS Code 확장 또는 시스템 인증 저장소

    Subversion 인증 캐시를 백업한 후 삭제합니다.

    cp -R ~/.subversion/auth ~/.subversion/auth.backup
    rm -f ~/.subversion/auth/svn.simple/*

    macOS 키체인 접근에서 SVN 서버 주소를 검색하여 오래된 항목을 제거합니다.

    IntelliJ에서는 다음 설정을 확인할 수 있습니다.

    Settings 또는 Preferences
    → Appearance & Behavior
    → System Settings
    → Passwords

    설정 메뉴는 IntelliJ 버전에 따라 다르게 표시될 수 있습니다.

    그다음 IDE를 다시 시작하고 SVN 저장소에 접속하여 인증정보를 다시 입력합니다.

    기본 SVN 명령어

    현재 상태를 확인합니다.

    svn status

    원격 저장소의 최신 변경 사항을 가져옵니다.

    svn update

    새 파일을 버전 관리에 추가합니다.

    svn add src/new-file.js

    파일 변경 내역을 확인합니다.

    svn diff

    변경 사항을 커밋합니다.

    svn commit -m "기능 수정"

    파일 변경을 되돌립니다.

    svn revert src/example.js

    작업 사본 정보를 확인합니다.

    svn info

    저장소 목록을 조회합니다.

    svn list https://svn.example.com/repos/project

    로그를 확인합니다.

    svn log

    최근 로그 일부만 확인합니다.

    svn log -l 10

    SnailSVN은 선택 사항

    macOS Finder에서 TortoiseSVN과 비슷한 방식으로 SVN을 사용하고 싶다면 SnailSVN을 선택할 수 있습니다.

    SnailSVN을 설치하면 Finder의 컨텍스트 메뉴와 아이콘 오버레이를 통해 다음 작업을 할 수 있습니다.

    • SVN Update
    • SVN Commit
    • Revert
    • Log 확인
    • 저장소 체크아웃
    • 파일 상태 확인

    다만 SnailSVN은 SVN을 사용하기 위한 필수 프로그램이 아닙니다.

    터미널, IntelliJ IDEA 또는 VS Code에서 SVN을 사용할 수 있다면 설치하지 않아도 됩니다.

    SnailSVN은 다음과 같은 경우에 고려할 수 있습니다.

    • Finder에서 파일 상태를 바로 확인하고 싶은 경우
    • 명령어보다 GUI 사용이 편한 경우
    • IDE 밖에서도 SVN 작업을 자주 하는 경우

    다음과 같은 경우에는 굳이 설치할 필요가 없습니다.

    • IntelliJ의 SVN 기능만 사용하는 경우
    • VS Code SVN 확장으로 충분한 경우
    • 터미널 명령어 사용이 익숙한 경우
    • 추가 유료 애플리케이션을 사용하고 싶지 않은 경우

    기존 글에 있던 SnailSVN 관련 내용은 본문의 중심이 아니라 선택 가능한 GUI 도구로 분리하는 것이 좋습니다.

    기존 이미지 배치 방법

    기존 글의 이미지는 다음 순서로 유지합니다.

    1. Intel Mac의 IntelliJ SVN 경로 설명
    2. 기존 Intel Mac 설정 이미지
    3. 이미지 아래 경로 설명
    
    4. Apple Silicon Mac의 IntelliJ SVN 경로 설명
    5. 기존 Apple Silicon 설정 이미지
    6. 이미지 아래 경로 설명

    Intel Mac 이미지의 대체 텍스트는 다음과 같이 설정합니다.

    Intel Mac IntelliJ IDEA SVN 실행 파일 경로 설정

    Apple Silicon 이미지의 대체 텍스트는 다음과 같이 설정합니다.

    Apple Silicon Mac IntelliJ IDEA SVN 실행 파일 경로 설정

    이미지 설명 문구는 다음과 같이 사용할 수 있습니다.

    Intel Mac에서는 Homebrew로 설치한 SVN 실행 파일이 일반적으로 `/usr/local/bin/svn`에 위치합니다.
    Apple Silicon Mac에서는 Homebrew로 설치한 SVN 실행 파일이 일반적으로 `/opt/homebrew/bin/svn`에 위치합니다.

    기존 이미지에 현재 사용 중인 도메인, 계정명, 로컬 사용자 이름 또는 회사 저장소 주소가 표시되어 있다면 게시 전에 모자이크 처리해야 합니다.

    설치 및 설정 확인 순서

    설정이 완료되었다면 다음 순서로 점검합니다.

    uname -m
    brew --prefix
    brew list subversion
    which svn
    svn --version

    저장소 접속을 확인합니다.

    svn list https://svn.example.com/repos/project

    작업 사본을 체크아웃합니다.

    svn checkout \
      https://svn.example.com/repos/project/trunk \
      project

    작업 사본 정보를 확인합니다.

    cd project
    svn info
    svn status

    IntelliJ 또는 VS Code에서 SVN을 찾지 못하면 which svn 결과를 IDE 설정에 절대경로로 입력합니다.

    Apple Silicon 기본 경로는 다음과 같습니다.

    /opt/homebrew/bin/svn

    Intel Mac 기본 경로는 다음과 같습니다.

    /usr/local/bin/svn

    확인 체크리스트

    [ ] uname -m으로 Intel 또는 Apple Silicon 여부를 확인했다.
    [ ] brew --prefix로 Homebrew 설치 경로를 확인했다.
    [ ] brew install subversion으로 SVN을 설치했다.
    [ ] svn --version이 정상적으로 출력된다.
    [ ] which svn으로 실제 실행 파일 경로를 확인했다.
    [ ] svn list로 저장소 접속 여부를 확인했다.
    [ ] 필요한 trunk 또는 branch 경로만 체크아웃했다.
    [ ] IntelliJ에 Subversion 플러그인이 설치되어 있다.
    [ ] IntelliJ에 실제 svn 실행 파일 경로를 입력했다.
    [ ] VS Code에 신뢰할 수 있는 SVN 확장을 설치했다.
    [ ] VS Code에서 필요하면 svn.path를 지정했다.
    [ ] 기존 인증 캐시와 macOS 키체인을 확인했다.
    [ ] 인증서 오류의 도메인, 만료일, 발급자를 확인했다.
    [ ] 검증되지 않은 인증서를 무조건 신뢰하지 않았다.
    [ ] 기존 Intel 및 Apple Silicon 설정 이미지를 적절한 위치에 유지했다.

    정리

    macOS에서 SVN을 사용하려면 먼저 Homebrew를 통해 Subversion 명령줄 클라이언트를 설치하는 것이 가장 간단합니다.

    설치 명령어는 Intel Mac과 Apple Silicon Mac에서 동일합니다.

    brew install subversion

    하지만 Homebrew와 SVN 실행 파일의 기본 경로는 다릅니다.

    Intel Mac: /usr/local/bin/svn
    Apple Silicon Mac: /opt/homebrew/bin/svn

    실제 환경에서는 CPU 종류만 보고 경로를 추측하지 말고 다음 명령어로 확인해야 합니다.

    which svn

    IntelliJ와 VS Code는 Mac에 설치된 SVN 명령줄 클라이언트를 사용하므로, 자동으로 경로를 찾지 못한다면 which svn 결과를 직접 입력하면 됩니다.

    인증 오류가 반복되면 ~/.subversion/auth와 macOS 키체인을 확인합니다. HTTPS 인증서 오류는 무조건 우회하기보다 인증서 도메인, 유효기간, 발급기관을 먼저 검증해야 합니다.

    Finder에서 GUI 방식으로 사용하고 싶다면 SnailSVN을 추가로 선택할 수 있지만, SVN 사용에 반드시 필요한 도구는 아닙니다.


    SEO 제목: macOS에서 SVN 설치하고 IntelliJ·VS Code에서 사용하는 방법

    슬러그: macos-svn-install-intellij-vscode

    메타 설명: Intel Mac과 Apple Silicon Mac에서 Homebrew로 SVN을 설치하고 IntelliJ IDEA와 VS Code에 연결하는 방법을 정리합니다. 체크아웃, 인증 캐시 삭제, 인증서 오류와 SnailSVN 사용법도 설명합니다.

    카테고리: 개발 > macOS

  • 맥에서 안드로이드 스마트폰 USB 디버깅하기: ADB와 Chrome 원격 디버깅 설정

    맥에서 안드로이드 스마트폰을 연결해 웹페이지나 WebView를 디버깅해야 할 때가 있다.

    나도 모바일 웹이나 WebView 기반 기능을 개발하면서 실제 안드로이드 기기에서 동작을 확인해야 하는 경우가 있었는데, 처음에는 단순히 USB 케이블만 연결하면 바로 Chrome 개발자 도구에서 기기가 보일 것이라고 생각했다.

    하지만 실제로는 안드로이드 개발자 옵션과 USB 디버깅을 활성화해야 하고, Mac에서는 ADB(Android Debug Bridge)가 정상적으로 기기를 인식하는지도 확인해야 한다.

    설정이 제대로 되어 있다면 Chrome의 원격 디버깅 기능을 이용해 안드로이드 Chrome에서 열려 있는 웹페이지나 디버깅이 허용된 WebView를 Mac의 Chrome DevTools에서 직접 확인할 수 있다.

    이 글에서는 Mac과 안드로이드 스마트폰을 USB로 연결하고 ADB를 이용해 기기 연결 상태를 확인한 뒤 Chrome 원격 디버깅까지 사용하는 과정을 정리해본다.

    먼저 준비할 것

    기본적으로 다음 환경이 필요하다.

    • Mac
    • Android 스마트폰 또는 태블릿
    • 데이터 통신이 가능한 USB 케이블
    • Mac의 Chrome 브라우저
    • Android 기기의 Chrome
    • ADB가 포함된 Android SDK Platform Tools

    여기서 의외로 중요한 것이 USB 케이블이다.

    충전만 가능한 케이블을 사용하면 스마트폰 충전은 정상적으로 되지만 ADB에서는 기기가 전혀 인식되지 않을 수 있다.

    기기가 잡히지 않는다면 설정만 확인하지 말고 케이블도 같이 의심해보는 것이 좋다.

    안드로이드 개발자 옵션 활성화하기

    ADB를 사용하려면 먼저 안드로이드의 개발자 옵션을 활성화해야 한다.

    기종과 Android 버전에 따라 메뉴 이름은 조금 다를 수 있지만 일반적으로 다음 경로에서 설정할 수 있다.

    설정 → 휴대전화 정보 → 소프트웨어 정보 → 빌드 번호

    빌드 번호를 여러 번 연속해서 터치하면 개발자 모드가 활성화된다.

    대부분의 기기에서는 7번 정도 연속으로 누르면 된다.

    화면 잠금 비밀번호나 PIN 입력을 요구할 수도 있다.

    개발자 모드가 활성화되면 설정 화면에 개발자 옵션 메뉴가 추가된다.

    삼성 갤럭시의 경우 일반적으로 설정 화면 아래쪽에서 개발자 옵션을 찾을 수 있다.

    USB 디버깅 켜기

    개발자 옵션에 들어간 뒤 USB 디버깅을 활성화한다.

    경로는 보통 다음과 같다.

    설정 → 개발자 옵션 → USB 디버깅

    USB 디버깅을 켜면 USB를 통해 연결된 컴퓨터에서 ADB 명령을 사용할 수 있다.

    처음 Mac과 연결하면 스마트폰 화면에 다음과 비슷한 메시지가 표시될 수 있다.

    이 컴퓨터에서 USB 디버깅을 허용하시겠습니까?

    Mac의 RSA 키 지문도 함께 표시된다.

    개인적으로 사용하는 Mac이라면 이 컴퓨터에서 항상 허용을 선택하고 허용하면 이후 연결할 때마다 승인하지 않아도 된다.

    공용 컴퓨터나 신뢰할 수 없는 PC에서는 항상 허용을 선택하지 않는 것이 좋다.

    Mac에 ADB 설치하기

    ADB는 Android SDK Platform Tools에 포함되어 있다.

    Android Studio를 사용하고 있다면 이미 SDK와 함께 설치되어 있을 가능성이 높다.

    Android Studio를 설치하지 않고 ADB만 사용하고 싶다면 Homebrew를 이용해서 Platform Tools를 설치하는 방법도 편하다.

    터미널에서 다음 명령을 실행한다.

    brew install --cask android-platform-tools

    설치가 끝나면 다음 명령으로 ADB가 정상적으로 실행되는지 확인한다.

    adb version

    정상적으로 설치되었다면 Android Debug Bridge 버전 정보가 표시된다.

    만약 다음과 같은 오류가 발생한다면

    zsh: command not found: adb

    ADB가 설치되지 않았거나 실행 경로가 PATH에 등록되지 않은 상태일 수 있다.

    Homebrew 설치 상태와 PATH를 먼저 확인해야 한다.

    USB로 스마트폰 연결하기

    이제 Android 스마트폰과 Mac을 USB 케이블로 연결한다.

    연결한 뒤 터미널에서 다음 명령을 실행한다.

    adb devices

    정상적으로 연결되었다면 다음과 비슷한 결과가 나타난다.

    List of devices attached

    R3XXXXXXXXX device

    왼쪽 값은 연결된 Android 기기의 식별자이고 오른쪽의 device는 정상적으로 ADB 연결이 완료된 상태라는 의미다.

    device라고 표시된다면 기본적인 USB 디버깅 연결은 성공한 것이다.

    adb devices에서 unauthorized가 표시될 때

    다음처럼 표시되는 경우가 있다.

    R3XXXXXXXXX unauthorized

    이 경우 Mac에서는 스마트폰을 찾았지만 스마트폰에서 이 컴퓨터의 USB 디버깅 권한을 아직 승인하지 않은 상태다.

    스마트폰 화면을 확인하면 USB 디버깅 허용 팝업이 떠 있을 가능성이 높다.

    허용을 누른 뒤 다시 실행한다.

    adb devices

    정상적으로 device가 표시되는지 확인한다.

    팝업이 보이지 않는다면 개발자 옵션에서 USB 디버깅 권한 승인 취소와 비슷한 항목을 찾아 기존 권한을 초기화한 뒤 USB를 다시 연결해볼 수 있다.

    ADB 서버를 다시 시작하는 것도 방법이다.

    adb kill-server

    adb start-server

    그리고 다시 확인한다.

    adb devices

    기기가 아예 표시되지 않을 때

    adb devices를 실행했는데

    List of devices attached

    아래에 아무것도 표시되지 않는다면 Mac이 Android 기기를 ADB 장치로 인식하지 못하고 있는 상태다.

    이럴 때는 다음 항목을 확인한다.

    첫 번째는 USB 케이블이다.

    충전 전용 케이블이면 데이터 통신 자체가 되지 않는다.

    다른 USB 케이블로 교체해서 테스트하는 것이 가장 빠르다.

    두 번째는 스마트폰의 USB 연결 모드다.

    USB 연결 후 Android 알림창에서 USB 설정을 열어 파일 전송 또는 데이터 연결이 가능한 모드로 변경해본다.

    세 번째는 USB 디버깅 상태다.

    개발자 옵션에서 USB 디버깅이 실제로 켜져 있는지 다시 확인한다.

    네 번째는 USB 포트나 허브다.

    USB 허브나 젠더를 사용하는 경우 기기 연결이 불안정할 수 있다.

    가능하다면 Mac에 직접 연결해보는 것이 좋다.

    마지막으로 ADB 서버를 다시 실행한다.

    adb kill-server

    adb start-server

    adb devices

    이 순서로 다시 확인한다.

    Chrome 원격 디버깅 열기

    ADB에서 기기가 정상적으로 device 상태로 표시된다면 Chrome 원격 디버깅을 사용할 수 있다.

    Mac의 Chrome 주소창에서 다음 주소를 입력한다.

    chrome://inspect/#devices

    그러면 Remote Target 영역에서 USB로 연결된 Android 기기를 확인할 수 있다.

    Android 스마트폰의 Chrome에서 웹페이지를 하나 열어둔 뒤 Mac의 chrome://inspect/#devices 화면을 보면 현재 열려 있는 탭이 표시된다.

    디버깅하고 싶은 페이지 아래의 inspect를 클릭하면 Chrome DevTools가 열린다.

    이제 데스크톱 웹페이지를 디버깅할 때와 비슷하게 사용할 수 있다.

    예를 들어 다음 항목을 확인할 수 있다.

    • Elements
    • Console
    • Network
    • Sources
    • Application
    • JavaScript 오류
    • API 요청과 응답
    • DOM 상태
    • CSS 적용 상태

    실제 스마트폰에서 발생하는 문제를 Mac의 DevTools에서 직접 확인할 수 있기 때문에 모바일 웹 개발할 때 상당히 유용하다.

    모바일에서만 발생하는 문제 확인하기

    개발하다 보면 PC Chrome에서는 정상인데 실제 Android Chrome에서는 문제가 발생하는 경우가 있다.

    예를 들면 다음과 같은 경우다.

    • 모바일 브라우저에서만 레이아웃이 깨짐
    • 터치 이벤트가 정상적으로 동작하지 않음
    • 특정 API 요청이 실패함
    • 모바일 Chrome에서 JavaScript 오류가 발생함
    • viewport 설정 때문에 화면 크기가 이상함
    • 파일 업로드가 예상과 다르게 동작함
    • 모바일 브라우저의 쿠키나 세션 문제
    • 특정 CSS 속성의 동작 차이

    이런 문제를 단순히 스마트폰 화면만 보고 해결하려면 상당히 불편하다.

    Chrome 원격 디버깅을 사용하면 실제 스마트폰에서 웹페이지를 실행하면서 Mac에서 Console과 Network 탭을 동시에 볼 수 있다.

    개인적으로 모바일 웹 문제를 확인할 때 가장 많이 사용하는 기능 중 하나다.

    Android WebView도 디버깅할 수 있다

    Android 앱 내부에 WebView로 웹페이지를 표시하고 있다면 WebView 역시 Chrome DevTools에서 확인할 수 있다.

    다만 Chrome 페이지와 달리 앱에서 WebView 디버깅을 허용하도록 설정되어 있어야 한다.

    Android 네이티브 코드에서는 일반적으로 다음 설정을 사용한다.

    WebView.setWebContentsDebuggingEnabled(true)

    보통 디버그 빌드에서만 활성화하는 것이 좋다.

    예를 들어 Kotlin에서는 다음과 같은 형태로 사용할 수 있다.

    if (BuildConfig.DEBUG) {

    WebView.setWebContentsDebuggingEnabled(true)

    }

    앱에서 WebView 디버깅이 활성화된 상태에서 스마트폰을 USB로 연결하면 Chrome의

    chrome://inspect/#devices

    화면에 해당 WebView가 표시될 수 있다.

    inspect를 누르면 일반 Chrome 페이지와 마찬가지로 Console, Network, Elements 등을 확인할 수 있다.

    WebView 기반 앱을 개발할 때 특히 유용하다.

    웹에서는 정상인데 앱 WebView에서만 문제가 발생하는 경우가 있기 때문이다.

    WebView가 chrome://inspect에 표시되지 않을 때

    스마트폰은 ADB에서 정상적으로 연결되는데 WebView만 Chrome inspect 화면에 나타나지 않는다면 몇 가지를 확인해야 한다.

    먼저 앱에서 WebView 디버깅이 활성화되어 있는지 확인한다.

    WebView.setWebContentsDebuggingEnabled(true)

    설정되어 있지 않다면 Chrome에서 WebView를 확인할 수 없다.

    그리고 실제 앱 화면에서 해당 WebView가 현재 생성되어 실행 중인지 확인해야 한다.

    WebView가 아직 생성되지 않았거나 해당 화면을 열지 않았다면 목록에 나타나지 않을 수 있다.

    스마트폰의 Chrome과 Mac의 Chrome 버전이 지나치게 오래된 경우도 문제가 될 수 있으므로 브라우저 업데이트 상태도 확인하는 편이 좋다.

    ADB로 연결된 기기 정보 확인하기

    ADB는 단순히 Chrome 디버깅을 연결하는 용도로만 사용하는 것은 아니다.

    연결된 기기의 여러 정보를 터미널에서 확인할 수도 있다.

    예를 들어 Android 버전을 확인하려면 다음 명령을 사용할 수 있다.

    adb shell getprop ro.build.version.release

    기기 모델을 확인하려면 다음처럼 사용할 수 있다.

    adb shell getprop ro.product.model

    기기 제조사는 다음 명령으로 확인할 수 있다.

    adb shell getprop ro.product.manufacturer

    연결된 Android 기기에서 직접 shell을 실행할 수도 있다.

    adb shell

    테스트 기기에서 시스템 상태를 확인하거나 파일 경로 등을 확인할 때 유용하다.

    여러 Android 기기를 연결한 경우

    Android 스마트폰과 태블릿 등 여러 기기를 동시에 연결할 수도 있다.

    다시 다음 명령으로 확인한다.

    adb devices

    예를 들어 다음처럼 두 개의 기기가 표시될 수 있다.

    R3AAAAAAA device

    R3BBBBBBB device

    이 상태에서 단순히 adb shell을 실행하면 어떤 기기를 대상으로 실행해야 하는지 알 수 없어 오류가 발생할 수 있다.

    이때는 -s 옵션으로 기기를 지정한다.

    adb -s R3AAAAAAA shell

    테스트 기기를 여러 대 사용하는 경우 알아두면 편하다.

    디버깅이 끝나면 USB 디버깅을 계속 켜둘 필요는 없다

    USB 디버깅은 개발할 때는 매우 편리하지만 일반적인 스마트폰 사용에 항상 필요한 기능은 아니다.

    개발이나 테스트가 끝났다면 필요에 따라 개발자 옵션에서 USB 디버깅을 꺼도 된다.

    특히 자신의 장비가 아닌 컴퓨터에서 USB 디버깅을 허용했다면 해당 컴퓨터의 권한을 계속 유지하지 않는 것이 좋다.

    개발자 옵션에는 기존에 승인한 USB 디버깅 컴퓨터의 권한을 취소하는 기능도 있으므로 필요하면 함께 초기화할 수 있다.

    실제로 사용할 때는 이 순서만 기억하면 된다

    맥에서 Android 기기를 디버깅할 때 전체 과정을 간단하게 정리하면 다음 순서다.

    1. Android 개발자 옵션을 활성화한다.
    2. USB 디버깅을 켠다.
    3. Mac에 Android SDK Platform Tools를 설치한다.
    4. USB 데이터 케이블로 스마트폰을 연결한다.
    5. 스마트폰에서 Mac의 USB 디버깅 권한을 허용한다.
    6. Mac 터미널에서 adb devices를 실행한다.
    7. 기기 상태가 device인지 확인한다.
    8. Mac Chrome에서 chrome://inspect/#devices를 연다.
    9. Android Chrome 또는 WebView의 inspect를 선택한다.
    10. DevTools의 Console과 Network 등을 이용해 문제를 확인한다.

    내 경우에는 단순히 스마트폰 화면만 보면서 모바일 웹이나 WebView 문제를 찾는 것보다 이 방법을 사용하는 것이 훨씬 효율적이었다.

    특히 API 요청 실패나 JavaScript 오류처럼 화면만 봐서는 원인을 알기 어려운 문제는 실제 Android 기기를 연결하고 Chrome DevTools의 Network와 Console을 보는 것이 가장 빠른 경우가 많았다.

    처음에는 설정 과정이 조금 번거롭게 느껴질 수 있지만 ADB와 USB 디버깅 설정을 한 번 제대로 해두면 이후에는 스마트폰을 연결하고 adb deviceschrome://inspect/#devices 정도만 확인하면 바로 디버깅을 시작할 수 있다.

  • 맥북 한영 전환이 불편할 때: 기본 설정부터 Karabiner까지 직접 써본 방법

    맥북을 처음 사용했을 때 생각보다 적응하기 어려웠던 것 중 하나가 한영 전환이었다.

    윈도우에서는 키보드 오른쪽의 한/영 키를 거의 의식하지 않고 사용했는데, 맥에서는 기본적으로 Caps Lock이나 입력 소스 단축키를 사용하다 보니 빠르게 코딩하거나 문서를 작성할 때 입력 전환이 한 박자 늦는 느낌이 있었다.

    특히 IntelliJ IDEA나 DBeaver처럼 개발 도구를 사용할 때 한글과 영어를 자주 오가다 보면 원하는 순간에 전환되지 않아 꽤 불편했다.

    나도 이 문제 때문에 한동안 macOS 기본 설정부터 구름 입력기(Gureum), Karabiner-Elements까지 여러 방법을 사용해봤다.

    현재는 M3 Max 맥북에서 macOS 기본 입력기를 사용하고 있다.

    그동안 직접 사용해본 방법과 각각의 장단점을 정리해본다.

    먼저 macOS 기본 한영 전환 설정부터 확인하기

    별도의 프로그램을 설치하기 전에 macOS의 기본 입력 소스 설정부터 확인하는 것이 좋다.

    macOS에서는 다음 경로에서 입력 소스 관련 단축키를 확인할 수 있다.

    시스템 설정 → 키보드 → 키보드 단축키 → 입력 소스

    맥북에서는 일반적으로 Caps Lock을 이용한 한영 전환, Control + Space를 이용한 입력 소스 전환, 직접 지정한 입력 소스 단축키 등을 사용할 수 있다.

    나는 처음에는 Caps Lock을 한영 전환 키로 사용했다.

    문제는 빠르게 타이핑할 때 간혹 내가 기대한 타이밍과 입력 전환 시점이 맞지 않는 경우가 있었다는 것이다.

    특히 개발을 하면서 영어 변수명이나 코드를 입력한 뒤 바로 한글 설명을 작성하고 다시 영어 코드로 돌아가는 식으로 입력 언어를 계속 전환하다 보면 작은 지연도 상당히 신경 쓰였다.

    예를 들어 const userName = ‘홍길동’; 같은 코드를 입력하면서 한글과 영어를 반복해서 전환하다 보면 한영 전환 반응 속도가 작업 흐름에 영향을 주는 경우가 있었다.

    구름 입력기도 사용해봤다

    기본 입력 방식이 불편해서 한동안 구름 입력기(Gureum)도 사용했다.

    구름 입력기는 macOS에서 사용할 수 있는 한글 입력기다. 예전에는 macOS 기본 입력기의 한영 전환 방식이 불편한 사용자들이 대안으로 많이 사용하기도 했다.

    당시에는 Homebrew를 이용해 brew install –cask gureumkim 명령으로 설치해서 사용했다.

    설치 후 macOS 입력 소스에 구름의 한글과 영문 입력기를 추가할 수 있었다.

    처음에는 기본 입력기보다 한영 전환이 조금 더 편하게 느껴져 한동안 사용했다.

    하지만 시간이 지나면서 macOS 기본 입력기, 구름 입력기, 다른 키보드 설정 프로그램이 함께 사용되기 시작했고 설정이 점점 복잡해졌다.

    입력 소스에도 ABC, 두벌식, 구름 두벌식, 구름 로마자처럼 여러 입력기가 등록되면서 현재 어떤 입력기가 선택되어 있는지 신경 써야 하는 상황도 생겼다.

    처음에는 문제를 해결하기 위해 설치했지만 결과적으로 관리해야 할 설정이 늘어난 셈이다.

    결국 나는 구름 입력기를 제거하고 다시 macOS 기본 입력기로 돌아왔다.

    한영 전환 키 위치가 불편하다면 Karabiner-Elements

    한영 전환 속도 자체보다는 한영 전환 키의 위치가 불편한 경우라면 Karabiner-Elements를 사용하는 방법도 있다.

    Karabiner-Elements는 macOS에서 특정 키 입력을 다른 키로 변경할 수 있는 키보드 커스터마이징 프로그램이다.

    예를 들어 오른쪽 Command 키를 F19 같은 거의 사용하지 않는 키로 변경하고, macOS에서 F19를 입력 소스 전환 단축키로 지정하는 방식으로 사용할 수 있다.

    흐름은 다음과 같다.

    오른쪽 Command → F19 → macOS 입력 소스 전환

    F19 같은 키를 사용하는 이유는 실제 키보드에서 직접 사용할 일이 거의 없어 다른 프로그램의 단축키와 충돌할 가능성이 낮기 때문이다.

    Karabiner-Elements에서는 단순하게 하나의 키를 다른 키로 변경하는 Simple Modifications뿐 아니라 조건에 따라 키 입력을 처리하는 Complex Modifications 기능도 사용할 수 있다.

    설정 파일은 일반적으로 ~/.config/karabiner/ 경로에서 관리된다.

    나도 예전에는 Complex Modification을 만들어 한영 전환을 직접 설정해서 사용했다.

    한영 전환 자체는 꽤 만족스러웠다.

    오른쪽 Command 같은 익숙한 위치의 키를 사실상 한/영 키처럼 사용할 수 있기 때문에 윈도우 키보드에 익숙한 사람이라면 편하게 느낄 수 있다.

    하지만 단점도 있었다.

    키보드 하나를 사용하기 위해 별도의 프로그램과 설정을 계속 관리해야 했고, macOS 업데이트나 프로그램 업데이트 이후에는 권한이나 설정 상태를 다시 확인해야 하는 경우도 있었다.

    그래서 시스템을 최대한 단순하게 유지하고 싶은 사용자라면 일단 macOS 기본 입력기를 먼저 충분히 사용해보는 것을 권한다.

    Logi Options+와 Karabiner를 같이 사용할 때 생기는 문제

    로지텍 키보드나 마우스를 사용하는 경우 Logi Options+와 Karabiner-Elements를 함께 사용하는 경우도 많다.

    나 역시 로지텍 장비를 사용하면서 두 프로그램을 동시에 설정해본 적이 있다.

    문제는 키 하나를 여러 프로그램에서 동시에 변경하기 시작하면 실제 키 입력이 어디에서 변경되고 있는지 확인하기 어려워진다는 것이다.

    예를 들어 같은 키에 대해 macOS 자체 설정, Karabiner-Elements, Logi Options+에서 각각 다른 기능을 설정해두면 예상하지 못한 키 동작이 발생할 수 있다.

    문제가 생겼을 때는 여러 설정을 한 번에 변경하기보다는 하나씩 확인하는 것이 좋다.

    먼저 macOS 기본 키보드 설정을 확인하고, 그다음 Logi Options+의 키 매핑을 확인한다.

    이후 Karabiner-Elements의 Simple Modifications와 Complex Modifications를 차례대로 확인한다.

    그래도 원인을 찾기 어렵다면 외장 키보드를 제거하고 맥북 자체 키보드에서도 동일한 문제가 발생하는지 확인해보는 것이 좋다.

    이렇게 범위를 하나씩 줄이면 문제 원인을 훨씬 쉽게 찾을 수 있다.

    com.apple.HIToolbox.plist 삭제는 마지막 방법으로

    예전에 맥북 한영 전환 문제를 검색하다 보면 ~/Library/Preferences/com.apple.HIToolbox.plist 파일을 삭제한 뒤 재부팅하라는 해결 방법을 자주 볼 수 있었다.

    나 역시 한영 전환 문제를 해결하면서 이 방법까지 찾아서 테스트해본 적이 있다.

    하지만 이 파일은 macOS 입력 소스 설정과 관련된 파일이기 때문에 단순히 한영 전환이 조금 느리다는 이유만으로 처음부터 삭제하는 것은 권하지 않는다.

    먼저 입력 소스를 제거했다가 다시 추가해보고, 키보드 단축키와 입력 소스 설정을 확인하는 것이 우선이다.

    그럼에도 입력 소스 설정 자체가 꼬였다고 판단되는 경우에만 마지막 방법 중 하나로 고려하는 것이 좋다.

    시스템 설정과 관련된 파일을 직접 수정하거나 삭제할 때는 기존 파일을 백업해두는 것도 필요하다.

    여러 방법을 사용했지만 결국 기본 입력기로 돌아왔다

    몇 년 동안 여러 방법을 사용해본 결과 현재는 M3 Max 맥북에서 macOS 기본 입력기를 그대로 사용하고 있다.

    구름 입력기도 오랫동안 사용해봤고, Karabiner-Elements의 Complex Modification을 이용해 한영 전환 키를 직접 만들어 사용하기도 했다.

    여러 방법을 거쳐 다시 기본 입력기로 돌아온 가장 큰 이유는 단순하다.

    설정이 적을수록 관리해야 할 문제도 줄어들기 때문이다.

    현재 내 기준으로는 일반적인 맥 사용이라면 macOS 기본 입력기를 먼저 사용하는 것이 가장 좋다고 생각한다.

    Caps Lock을 이용한 한영 전환이 크게 불편하지 않다면 굳이 별도의 프로그램을 설치할 이유는 없다.

    반대로 오른쪽 Command 같은 키를 윈도우의 한/영 키처럼 사용하고 싶다면 Karabiner-Elements가 좋은 대안이 될 수 있다.

    여러 조건에 따라 키 동작을 세밀하게 변경해야 한다면 Complex Modifications를 활용할 수도 있다.

    개발자처럼 하루 종일 키보드를 사용하는 사람에게는 한영 전환의 작은 불편도 생각보다 크게 느껴질 수 있다.

    하지만 문제를 해결한다고 인터넷에서 찾은 입력기와 키보드 설정을 하나씩 추가하다 보면 어느 순간 왜 현재 키가 이렇게 동작하는지 본인도 알기 어려운 상태가 될 수 있다.

    나처럼 여러 입력기와 키 매핑 프로그램을 오랫동안 돌아다니기보다는, 먼저 macOS 기본 설정을 충분히 사용해본 뒤 정말 필요한 부분만 Karabiner 같은 도구로 보완하는 방식이 가장 관리하기 편했다.

  • 맥북 터미널 PATH 환경변수 설정: zsh·bash 차이와 적용 방법

    맥북에서 개발 환경을 설정하다 보면 한 번쯤 command not found 오류를 만나게 된다.

    프로그램을 분명 설치했는데 터미널에서는 명령어를 찾지 못하거나, 직접 설치한 실행 파일을 매번 전체 경로로 입력해야 하는 경우가 있다.

    이럴 때 가장 먼저 확인하게 되는 것이 PATH 환경변수다.

    나 역시 예전에는 맥북에서 환경변수를 설정할 때 ~/.bash_profile에 PATH를 추가하고 source ~/.bash_profile을 실행하는 방식으로 사용했다.

    하지만 현재 macOS의 기본 셸은 bash가 아니라 zsh다.

    그래서 예전에 사용하던 .bash_profile 설정을 그대로 복사해서 사용하는 것보다, 현재 사용 중인 셸을 먼저 확인하고 그에 맞는 설정 파일을 사용하는 것이 좋다.

    이 글에서는 macOS에서 현재 사용 중인 셸을 확인하는 방법부터 zsh에서 PATH를 등록하고 제대로 적용됐는지 확인하는 방법까지 정리해본다.

    PATH 환경변수란?

    터미널에서 다음처럼 명령어를 실행한다고 해보자.

    mysql

    우리는 mysql이라는 이름만 입력하지만 실제로는 운영체제가 실행 가능한 mysql 파일이 어디에 있는지 찾아야 한다.

    이때 검색하는 디렉터리 목록이 PATH 환경변수에 들어 있다.

    현재 PATH 값은 다음 명령으로 확인할 수 있다.

    echo $PATH

    실행하면 다음과 비슷한 결과가 나온다.

    /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

    각 경로는 : 문자로 구분된다.

    터미널에서 명령어를 입력하면 셸은 PATH에 등록된 경로를 앞에서부터 확인하면서 해당 실행 파일을 찾는다.

    예를 들어 /usr/local/mysql/bin/mysql이라는 실행 파일이 있는데 /usr/local/mysql/bin이 PATH에 등록되어 있지 않다면 다음처럼 전체 경로를 입력해야 한다.

    /usr/local/mysql/bin/mysql

    반대로 /usr/local/mysql/bin을 PATH에 등록하면 어디에서든 다음처럼 실행할 수 있다.

    mysql

    먼저 현재 사용 중인 셸 확인하기

    환경변수를 설정하기 전에 가장 먼저 확인해야 할 것은 현재 사용 중인 셸이다.

    터미널에서 다음 명령을 실행한다.

    echo $SHELL

    일반적인 최신 macOS 환경이라면 다음과 같이 표시된다.

    /bin/zsh

    이 경우 zsh를 사용하고 있는 것이다.

    bash를 사용하도록 별도로 변경한 환경이라면 다음처럼 표시될 수 있다.

    /bin/bash

    현재 macOS의 기본 셸은 zsh이므로 특별히 변경하지 않았다면 대부분 /bin/zsh가 출력될 것이다.

    zsh에서 PATH 설정하기

    zsh를 사용하는 경우 사용자 설정 파일로 주로 다음 파일을 접하게 된다.

    ~/.zprofile

    ~/.zshrc

    둘 다 zsh 설정 파일이지만 실행되는 시점에는 차이가 있다.

    간단하게 보면 다음과 같이 구분할 수 있다.

    • .zprofile : 로그인 셸이 시작될 때 실행
    • .zshrc : 인터랙티브 셸이 시작될 때 실행

    Terminal을 이용한 일반적인 개발 환경에서는 PATH를 어느 파일에 넣어야 하는지 헷갈릴 수 있다.

    개인적으로 PATH처럼 로그인 환경 전체에서 사용할 환경변수라면 .zprofile에 두는 편이 구조상 이해하기 쉽다.

    반면 alias, 프롬프트 설정, 개발할 때 사용하는 셸 옵션처럼 터미널과 직접 관련된 설정은 .zshrc에서 관리하는 편이 좋다.

    예를 들어 /usr/local/mysql/bin을 PATH에 추가한다면 다음과 같이 설정할 수 있다.

    먼저 .zprofile을 연다.

    vi ~/.zprofile

    그리고 다음 내용을 추가한다.

    export PATH="/usr/local/mysql/bin:$PATH"

    파일을 저장한 뒤 현재 터미널 세션에 바로 적용하려면 다음 명령을 실행한다.

    source ~/.zprofile

    이후 PATH를 다시 확인한다.

    echo $PATH

    앞부분에 /usr/local/mysql/bin이 추가되어 있다면 정상적으로 적용된 것이다.

    PATH를 추가할 때 $PATH를 같이 쓰는 이유

    PATH를 설정하면서 다음과 같은 코드를 많이 볼 수 있다.

    export PATH="/usr/local/mysql/bin:$PATH"

    여기서 $PATH를 뒤에 붙이는 것이 중요하다.

    기존 PATH 값을 유지하면서 새로운 경로를 앞에 추가한다는 의미이기 때문이다.

    만약 다음처럼 작성하면 문제가 생길 수 있다.

    export PATH="/usr/local/mysql/bin"

    기존 PATH 값이 모두 사라지고 /usr/local/mysql/bin만 남게 된다.

    그러면 ls, cat 등 기존에 정상적으로 사용하던 명령어까지 찾지 못하는 상황이 생길 수 있다.

    따라서 기존 PATH에 경로를 추가하려면 일반적으로 다음 형태로 작성하는 것이 안전하다.

    export PATH="추가할경로:$PATH"

    반대로 새 경로의 우선순위를 기존 경로보다 낮게 두고 싶다면 다음처럼 작성할 수도 있다.

    export PATH="$PATH:추가할경로"

    차이는 명령어 검색 순서에 있다.

    PATH 앞에 넣으면 새로운 경로를 먼저 검색하고, 뒤에 넣으면 기존 경로를 먼저 검색한다.

    같은 이름의 프로그램이 여러 버전 설치되어 있는 개발 환경에서는 이 순서가 상당히 중요할 수 있다.

    설정 파일이 없다면 직접 만들어도 된다

    처음 설정하는 맥에서는 .zprofile이나 .zshrc 파일 자체가 없을 수도 있다.

    확인하려면 다음 명령을 사용한다.

    ls -la ~

    .zprofile이 없다면 새로 만들어도 된다.

    touch ~/.zprofile

    이후 편집기로 연다.

    vi ~/.zprofile

    또는 nano를 사용한다면 다음처럼 작성할 수 있다.

    nano ~/.zprofile

    PATH 설정을 추가하고 저장하면 된다.

    source 명령은 왜 사용하는가?

    설정 파일을 수정한 뒤 흔히 다음 명령을 실행한다.

    source ~/.zprofile

    source는 지정한 파일의 내용을 현재 셸에서 다시 실행한다.

    .zprofile을 수정했다고 해서 이미 실행 중인 터미널의 환경변수가 자동으로 바뀌는 것은 아니다.

    설정 파일을 변경한 뒤 새로운 터미널 창을 열면 다시 설정 파일을 읽기 때문에 변경 내용이 적용될 수 있다.

    하지만 터미널을 닫았다가 다시 열지 않고 현재 창에서 바로 적용하고 싶다면 source 명령을 사용하면 된다.

    .zshrc를 수정했다면 다음과 같이 적용한다.

    source ~/.zshrc

    .zprofile과 .zshrc 중 어디에 설정해야 할까?

    이 부분이 처음 맥 개발 환경을 설정할 때 가장 헷갈렸던 부분이다.

    zsh에는 여러 개의 시작 파일이 있고 각각 실행되는 조건이 다르다.

    실제로 자주 사용하는 파일만 놓고 보면 다음처럼 이해하면 편하다.

    .zprofile

    로그인 셸에서 실행된다.

    PATH 같은 환경변수를 설정할 때 사용할 수 있다.

    예:

    export PATH="/usr/local/mysql/bin:$PATH"

    .zshrc

    인터랙티브 셸을 실행할 때 읽힌다.

    alias, 프롬프트, 개발용 단축 명령 등을 설정하기 좋다.

    예를 들어 다음처럼 사용할 수 있다.

    alias ll="ls -al"

    alias gst="git status"

    반드시 PATH는 .zprofile, alias는 .zshrc처럼 강제되는 규칙은 아니다.

    다만 역할을 어느 정도 분리해두면 나중에 개발 환경을 관리하기가 편해진다.

    예전 .bash_profile 설정은 어떻게 해야 할까?

    예전 macOS 환경이나 기존 개발 문서를 보면 다음과 같은 설정을 쉽게 볼 수 있다.

    echo 'export PATH="/usr/local/mysql/bin:$PATH"' >> ~/.bash_profile

    그리고 다음 명령으로 적용한다.

    source ~/.bash_profile

    bash를 실제로 사용하고 있다면 문제가 없는 방식이다.

    하지만 echo $SHELL 결과가 /bin/zsh인 환경에서 무조건 .bash_profile에 설정하는 것은 적절하지 않다.

    특히 예전에 작성했던 설정 중에는 .zprofile에서 다시 .bash_profile.bashrc를 불러오는 방식도 있었다.

    예를 들면 이런 형태다.

    source ~/.bash_profile

    source ~/.bashrc

    기존 bash 설정을 임시로 그대로 사용해야 하는 특별한 이유가 있다면 가능할 수 있지만, 새롭게 개발 환경을 구성하는 상황이라면 굳이 이런 구조로 만들 필요는 없다.

    zsh를 사용한다면 zsh의 설정 파일인 .zprofile.zshrc에서 직접 관리하는 것이 훨씬 단순하다.

    Apple Silicon과 Intel Mac의 Homebrew 경로 차이

    맥에서 개발 환경을 구성할 때 Homebrew를 사용하는 경우가 많다.

    이때 Apple Silicon Mac과 Intel Mac은 Homebrew의 기본 설치 경로가 다르다.

    Apple Silicon Mac은 기본적으로 다음 경로를 사용한다.

    /opt/homebrew

    Intel Mac은 기본적으로 다음 경로를 사용한다.

    /usr/local

    따라서 다른 사람의 블로그에서 Homebrew PATH 설정을 그대로 복사했는데 동작하지 않는다면 사용 중인 Mac의 CPU 아키텍처가 다른 것은 아닌지 확인해야 한다.

    현재 아키텍처는 다음 명령으로 확인할 수 있다.

    uname -m

    Apple Silicon Mac에서는 일반적으로 다음과 같이 나온다.

    arm64

    Intel Mac에서는 다음과 같이 표시된다.

    x86_64

    Apple Silicon에서 Homebrew를 설치하면 설치 과정에서 brew shellenv를 이용한 환경 설정 안내가 표시될 수 있다.

    예를 들면 다음과 같은 방식이다.

    eval "$(/opt/homebrew/bin/brew shellenv)"

    Homebrew를 설치했다면 인터넷의 오래된 PATH 예제를 그대로 복사하기보다는 설치 과정에서 Homebrew가 출력한 안내를 확인하는 것이 가장 안전하다.

    특정 프로그램의 실제 위치 확인하기

    PATH가 제대로 설정되었는지 확인할 때 유용한 명령이 which다.

    예를 들어 Node.js의 위치를 확인하려면 다음과 같이 실행한다.

    which node

    결과는 환경에 따라 다음처럼 나올 수 있다.

    /opt/homebrew/bin/node

    MySQL은 다음처럼 확인할 수 있다.

    which mysql

    Git은 다음과 같이 확인한다.

    which git

    결과가 아무것도 나오지 않는다면 해당 프로그램이 설치되어 있지 않거나 실행 파일이 있는 디렉터리가 PATH에 포함되어 있지 않을 가능성이 있다.

    PATH를 수정했는데 적용되지 않을 때 확인할 것

    환경변수를 등록했는데도 명령어가 실행되지 않는다면 몇 가지를 순서대로 확인해보면 된다.

    먼저 현재 사용하는 셸부터 확인한다.

    echo $SHELL

    zsh를 사용하고 있는데 .bash_profile만 수정했다면 설정이 적용되지 않을 수 있다.

    그다음 설정 파일에 원하는 내용이 실제로 들어 있는지 확인한다.

    cat ~/.zprofile

    또는

    cat ~/.zshrc

    현재 PATH도 확인한다.

    echo $PATH

    추가한 경로가 PATH에 실제로 포함되어 있는지 확인한다.

    실행 파일이 실제로 존재하는지도 확인해야 한다.

    예를 들어 MySQL이라면 다음과 같이 확인할 수 있다.

    ls -l /usr/local/mysql/bin/mysql

    그리고 마지막으로 which 명령을 확인한다.

    which mysql

    PATH에는 정상적으로 추가되어 있는데 예상과 다른 프로그램이 실행된다면 같은 이름의 프로그램이 다른 경로에도 설치되어 있을 가능성이 있다.

    이 경우 다음처럼 확인해볼 수도 있다.

    which -a mysql

    여러 경로가 나온다면 PATH에 등록된 순서에 따라 앞쪽 프로그램이 먼저 실행된다.

    PATH를 계속 추가하다 보면 중복될 수도 있다

    개발 환경을 여러 번 설정하다 보면 .zprofile이나 .zshrc에 같은 PATH 설정이 여러 번 들어가는 경우가 있다.

    예를 들어 다음 내용이 반복되어 있을 수 있다.

    export PATH="/usr/local/mysql/bin:$PATH"

    export PATH="/usr/local/mysql/bin:$PATH"

    당장 프로그램이 실행되지 않는 것은 아니지만 설정 파일이 점점 지저분해지고 문제를 확인하기 어려워진다.

    환경을 설정할 때는 새로운 줄을 무조건 추가하기보다 기존 설정이 있는지 먼저 확인하는 편이 좋다.

    예를 들어 다음 명령으로 PATH 관련 설정을 찾아볼 수 있다.

    grep PATH ~/.zprofile

    .zshrc도 함께 확인한다.

    grep PATH ~/.zshrc

    Homebrew, Node.js 버전 관리자, Java, MySQL 등을 설치하다 보면 여러 프로그램에서 PATH를 수정하기 때문에 한 번씩 정리해두는 것이 좋다.

    내가 지금 맥에서 PATH를 설정한다면

    예전에는 맥에서 환경변수를 설정하면 무조건 .bash_profile부터 수정했다.

    당시에는 다음과 같은 형태를 자주 사용했다.

    echo 'export PATH="원하는경로:$PATH"' >> ~/.bash_profile

    그리고

    source ~/.bash_profile

    을 실행했다.

    지금은 먼저 현재 셸부터 확인한다.

    echo $SHELL

    zsh를 사용하고 있다면 PATH 성격의 환경변수는 .zprofile에서 관리하고, alias나 터미널에서만 사용하는 설정은 .zshrc에서 관리하는 편이다.

    그리고 프로그램을 설치했는데 명령어가 실행되지 않을 때는 무작정 PATH부터 수정하지 않는다.

    먼저 다음 순서로 확인한다.

    1. 프로그램이 실제로 설치되어 있는지 확인한다.
    2. which 명령어로 현재 실행 파일 위치를 확인한다.
    3. echo $PATH로 실행 파일의 디렉터리가 등록되어 있는지 확인한다.
    4. 현재 셸이 zsh인지 bash인지 확인한다.
    5. 해당 셸의 설정 파일을 수정한다.
    6. source로 설정을 다시 적용하거나 새 터미널을 연다.

    맥 개발 환경을 오래 사용하다 보면 PATH에는 Homebrew, Node.js, Java, MySQL 등 여러 프로그램의 경로가 계속 추가된다.

    처음부터 어느 설정 파일에 무엇을 넣었는지 구분해두면 나중에 개발 환경을 옮기거나 문제가 발생했을 때 훨씬 쉽게 원인을 찾을 수 있다.