블로그 구축기에서 만든 이 블로그의 코드로 블로그를 하나 더 운영하게 됐다. 다른 운영자가 쓰는 개인 경제 블로그 「한눈에 보는 대한민국 경제」(economy.addsoft.co.kr)다.

글 목록, 관리자 화면, 마크다운 편집기, SEO, 방문 통계, 애드센스까지 필요한 기능은 이미 다 있었다. 저장소를 복사해 두 벌로 관리하면 기능을 고칠 때마다 양쪽에 똑같이 손을 대야 한다. 그래서 코드와 게시본은 하나로 두고, 사이트마다 다른 것은 설정 파일 하나로 몰아넣는 쪽을 택했다.

두 사이트에서 다른 것은 무엇인가

먼저 화면과 코드에 박혀 있던 "기술블로그다운" 값을 전부 찾아 목록으로 만들었다.

구분 기술블로그 경제 블로그
이름·설명·메인 제목 애드소프트 기술블로그 한눈에 보는 대한민국 경제
상단 배지 기술블로그 경제블로그
로고·파비콘 회사 로고 이미지 사이트 이름 글자 로고 + 아이콘
소개·푸터 회사 소개, 사업자 정보 개인 운영 표기
초기 카테고리·태그 IIS, ASP.NET 등 금융, 증권, 부동산 등
관리자 계정·관리 API 키 기술블로그용 경제 블로그 운영자용
수익 대시보드 계정 전체·사이트 선택 이 블로그 수익만
데이터베이스 기술블로그 DB 별도 DB

나머지, 즉 코드·뷰·스크립트·서버는 모두 같다. 이 표가 그대로 설계가 됐다. 위쪽 문구들은 설정으로 빼고, 아래쪽 계정·수익·DB는 사이트별 운영 설정에서 나눈다.

사이트 프로필: sites/economy.json

설정 키 SiteProfile 에 프로필 이름을 넣으면 앱이 sites/{이름}.json 을 추가로 읽는다. 기술블로그는 이 값이 비어 있어서 지금까지와 똑같이 동작한다.

var siteProfile = configuration["SiteProfile"];
if (!string.IsNullOrWhiteSpace(siteProfile))
{
    if (!Regex.IsMatch(siteProfile, "^[a-z0-9-]+$"))
        throw new InvalidOperationException($"SiteProfile '{siteProfile}' 은(는) 영문 소문자·숫자·하이픈만 쓸 수 있습니다.");

    var profileSource = new JsonConfigurationSource
    {
        Path = Path.Combine("sites", siteProfile + ".json"),
        Optional = false,
        ReloadOnChange = true,
        FileProvider = new PhysicalFileProvider(contentRoot)
    };
    // 기본값(appsettings.json) 바로 뒤에 넣어 환경별 설정·비밀값이 계속 우선하게 한다
    var baseIndex = /* configuration.Sources 에서 appsettings.json 의 위치 */;
    configuration.Sources.Insert(baseIndex + 1, profileSource);
}

핵심은 끼워 넣는 위치다. ASP.NET Core 구성은 나중에 추가된 소스가 앞의 값을 덮어쓴다. 프로필을 맨 뒤에 붙이면 서버의 appsettings.Production.json 이나 개발 PC의 user-secrets 값까지 프로필이 덮어 버린다. 그래서 appsettings.json 바로 다음에 넣었다.

appsettings.json → sites/economy.json → appsettings.{환경}.json → user-secrets / 운영 설정
   (기술블로그 기본값)   (사이트 프로필)         (환경별)                (비밀값)

이렇게 하면 세 가지가 같이 해결된다.

  • 프로필에 없는 값은 appsettings.json(기술블로그) 값을 그대로 쓴다. 프로필에는 다른 값만 적으면 된다.
  • 연결 문자열, 관리자 해시 같은 비밀값은 여전히 운영 설정이 이긴다.
  • 프로필 파일은 저장소에 커밋되므로 문구를 바꾸는 것도 일반 코드 변경처럼 이력이 남는다.

프로필 이름은 파일 경로에 들어가므로 영문 소문자·숫자·하이픈만 허용했다. 파일이 없으면(Optional = false) 앱이 바로 시작에 실패한다. 엉뚱한 기본값으로 조용히 떠 있는 것보다 낫다.

고정 문구를 Site 설정으로

다음은 뷰 곳곳에 직접 적혀 있던 문구를 Site 설정으로 옮기는 일이었다. 기존의 Name, Description, BaseUrl 에 더해 이런 항목을 추가했다.

키 쓰는 곳
Kind 상단 배지, 메인 머리말 (기술블로그 / 경제블로그)
FullName 개인정보처리방침·고정 페이지 설명 (비면 {Name} {Kind})
Headline, HomeTitle 메인 화면 제목과 <title>
OgImage 썸네일 없는 글·일반 화면의 og:image
About, Disclaimer 소개 페이지 운영자 소개, 글의 성격·면책 안내
ContactTopics, ContactNote 문의 페이지 문의 종류와 사이트별 안내

기술블로그 값은 appsettings.json 으로 옮겼으니 기술블로그 화면은 한 글자도 바뀌지 않는다. 경제 블로그 프로필에는 "투자를 권유하지 않는다"는 면책 문구처럼 그 사이트에만 필요한 내용을 넣었다.

주소가 들어가는 파일도 정리했다. robots.txt 는 정적 파일이었는데, 안에 적힌 사이트맵 주소가 사이트마다 달라야 한다. 정적 파일을 지우고 Site:BaseUrl 로 만들어 응답하게 했다.

[Route("robots.txt")]
public IActionResult RobotsTxt()
{
    var text = "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\n\n" +
               $"Sitemap: {SeoHelper.Absolute(_site.BaseUrl, "/sitemap.xml")}\n";
    return Content(text, "text/plain", new UTF8Encoding(false));
}

web.config 의 HTTP → HTTPS 리디렉션에도 기술블로그 주소가 박혀 있었다. 경제 블로그로 http 요청이 오면 기술블로그로 넘어가는 셈이다. 대상 주소를 요청 호스트로 바꿨다.

<action type="Redirect" url="https://{HTTP_HOST}/{R:1}" redirectType="Permanent" />

게시본이 같으니 web.config 도 같다. 호스트에 따라 달라져야 하는 값은 이렇게 요청에서 꺼내 쓰거나, 사이트 프로필에서 읽는다.

새 DB의 초기 카테고리·태그

경제 블로그 DB는 비어 있는 상태에서 시작한다. 앱이 시작하면서 마이그레이션을 적용하는데, 마이그레이션의 초기 데이터에는 기술블로그 카테고리 6종(IIS, ASP.NET, Docker 등)이 들어 있다. 경제 블로그에 이 카테고리가 생기면 곤란하다.

마이그레이션을 사이트별로 나누는 대신, 프로필에 Seed 항목을 두고 마이그레이션 뒤에 바꿔 넣는 TaxonomySeeder 를 만들었다.

"Seed": {
  "Categories": [
    { "Name": "금융", "Slug": "finance" },
    { "Name": "증권", "Slug": "stock" },
    { "Name": "부동산", "Slug": "real-estate" }
  ],
  "Tags": [
    { "Name": "기준금리", "Slug": "base-rate" },
    { "Name": "환율", "Slug": "exchange-rate" }
  ]
}

실제 프로필에는 카테고리 8개, 태그 39개가 들어 있다. 조건을 좁게 잡은 것이 중요했다.

if (db.Posts.Any()) return;   // 글이 하나라도 있으면 아무것도 하지 않는다

var current = db.Categories.Select(c => c.Slug).ToList();
if (current.Count == BlogDbContext.InitialCategorySlugs.Length
    && !current.Except(BlogDbContext.InitialCategorySlugs).Any())
{
    // 마이그레이션이 넣은 6종 그대로일 때만 프로필 카테고리로 교체
}
  • 글이 있으면 건드리지 않는다. 운영 중인 사이트의 카테고리를 시작할 때마다 덮어쓰는 일은 없어야 한다.
  • 카테고리가 마이그레이션 초기값 그대로일 때만 바꾼다. 관리자 화면에서 한 번이라도 손댔다면 그 상태를 존중한다. 태그는 하나도 없을 때만 넣는다.
  • 그래서 앱이 몇 번을 다시 시작해도 안전하다. 기술블로그는 프로필에 Seed 가 없으니 처음부터 아무 일도 일어나지 않는다.

프로필 값은 이름 길이, 슬러그 형식을 검사하고 슬러그가 겹치면 처음 것만 쓴다. 설정 파일에 오타가 있어도 이상한 카테고리가 생기지 않게 했다.

운영자를 분리하다

경제 블로그는 회사 블로그가 아니라 개인이 운영하는 블로그다. 화면에 회사 정보가 나오면 안 되고, 관리 권한도 기술블로그와 섞이면 안 된다.

개인 운영 표기

Site:IsCompany 설정을 추가했다. 기본값은 true 이고 경제 블로그 프로필에서만 false 로 둔다.

@if (Site.IsCompany)
{
    <text>상호: @Site.BusinessName · 대표: @Site.AuthorName<br /></text>
}
else
{
    <text>운영: @Site.AuthorName<br /></text>
}
&copy; @DateTime.Now.Year @Site.OperatorName. All rights reserved.

OperatorName 은 회사 블로그면 상호, 개인 블로그면 운영자 이름을 돌려준다. 같은 분기를 푸터, 소개 페이지(회사 소개·사업자 정보 표를 통째로 숨김), 개인정보처리방침, 그리고 검색엔진용 구조화 데이터(JSON-LD)의 publisher 에도 적용했다. 화면만 고치고 구조화 데이터를 놓치면 검색 결과에는 여전히 회사 이름이 나갈 수 있어서다.

글자 로고

기술블로그 상단에는 회사 홈페이지 로고 이미지가 들어간다. 경제 블로그는 정사각 아이콘 옆에 사이트 이름을 글자로 쓰는 방식으로 했다. 설정은 세 개다.

  • HeaderTextLogo — 상단 로고를 사이트 이름 글자로
  • LogoImage — 정사각 아이콘 (apple-touch-icon, 관리자 화면, 구조화 데이터 로고)
  • Favicon — 파비콘 경로

글자 로고는 이미지 로고보다 가로 폭을 훨씬 많이 쓴다. 1024px 화면에서 상단 배지까지 나오면 검색창이 눌려 버렸다. 글자 로고일 때는 배지를 xl 이상의 넓은 화면에서만 보이게 하고, 1024px·1280px·모바일에서 상단 메뉴가 넘치지 않는지 확인했다.

별도 관리자 계정과 API 키

처음 구성할 때는 운영 설정을 만드는 스크립트가 기술블로그의 관리자 계정과 API 키 해시를 그대로 복사하게 했었다. 운영자가 따로라는 점이 정해지면서 이 부분을 바꿨다. 이제 기술블로그 설정에서 가져오는 것은 애드센스 값뿐이다.

  • 관리자 비밀번호: 경제 블로그용으로 새로 입력받아 해시만 경제 블로그 운영 설정에 저장한다.
  • 관리 API 키: new-api-key.ps1 -Site economy 로 새로 만든다.

API 키 발급에는 한 가지 차이를 뒀다. 기술블로그 키는 만들자마자 내 PC의 사용자 환경변수에 저장한다. 경제 블로그 키는 내가 쓸 키가 아니므로 환경변수에 넣지 않고 클립보드로만 복사한다. 운영 설정에는 키의 SHA-256 해시만 남는다.

# 다른 운영자에게 줄 키: 이 PC 환경변수에 넣지 않고 운영 설정에 해시만 저장한다
$forOtherOperator = $Site -eq 'economy' -and -not $Dev
...
if ($forOtherOperator) {
    Set-Clipboard -Value $key
    $key = $null
    Write-Host 'API 키를 클립보드에 복사했습니다 (화면·파일에는 남기지 않음).'
}

키를 받는 쪽을 위해서는 단독으로 실행되는 설정 스크립트(economy-api-key-setup.ps1)를 따로 만들었다. 이 파일 하나만 전달하면 된다.

  1. 실행하면 키를 붙여 넣으라고 묻는다. 입력은 화면에 표시되지 않는다(Read-Host -AsSecureString).
  2. 키 형식을 먼저 확인한다.
  3. 경제 블로그의 읽기 전용 API(/api/categories)를 그 키로 실제로 호출해 본다.
  4. 성공했을 때만 사용자 환경변수 ECONOMY_API_KEY 에 저장한다. 401이면 저장하지 않는다.
try {
    $categories = Invoke-RestMethod -Uri ($BaseUrl.TrimEnd('/') + '/api/categories') `
        -Headers @{ Authorization = "Bearer $key" } -TimeoutSec 20
}
catch {
    ...
    if ($status -eq 401) { throw '키가 맞지 않습니다 (401). 키를 다시 받아 실행하세요.' }
}

[Environment]::SetEnvironmentVariable($envName, $key, 'User')

키 값은 스크립트 파일에 들어 있지 않다. 스크립트와 키를 서로 다른 경로로 전달하면, 어느 한쪽만 새어 나가서는 쓸 수 없다. 잘못 복사한 키가 환경변수에 들어가 나중에 원인 모를 401을 보는 일도 3번 단계에서 막힌다. -Remove 를 주면 저장한 키를 지운다.

수익 대시보드: ThisSiteOnly

두 블로그는 같은 애드센스 계정의 광고를 쓴다. 관리자 화면의 수익 대시보드는 애드센스 Management API로 보고서를 가져오는데, 기술블로그에서는 계정 전체 수익, 도메인별 사이트 선택, 사이트별 표, 계정 잔고까지 보여 준다.

경제 블로그 관리자 화면에서도 수익을 볼 수 있게 연결하되, 경제 블로그 운영자에게는 경제 블로그 수익만 보여야 한다. 그래서 AdSense:ThisSiteOnly 를 추가하고 경제 블로그 프로필에서 켰다.

"AdSense": {
  "ThisSiteOnly": true,
  "AdUnitPrefix": "경제"
}

이 값이 켜지면 화면에서 숨기는 데서 그치지 않고, 서버가 애초에 조회하지 않는다.

// 이 블로그만이면 site 값과 관계없이 이 도메인 고정 (다른 도메인 보고서는 조회하지 않는다)
if (!model.ThisSiteOnly)
{
    // 사이트 목록 조회, ?site= 값에 따라 전체/다른 도메인 선택
}
var filter = model.Site == null ? null : "DOMAIN_NAME==" + AdSenseApiClient.FilterValue(model.Site);
...
if (!model.ThisSiteOnly)
{
    // 잔고는 계정 전체 금액이라 이 블로그만 볼 때는 조회하지 않는다
    model.Balance = await GetCachedAsync("balance:" + account, () => FetchBalanceAsync(account, accessToken));
}
  • 사이트 선택 메뉴, 사이트별 표, 계정 잔고 카드는 그리지 않고 "이 블로그 수익만 표시합니다" 안내를 보여 준다.
  • 주소에 ?site=all 이나 다른 도메인을 넣어도 모델의 사이트는 이 블로그 도메인으로 고정된다.
  • 구글에 보내는 요청도 이 도메인으로 거른 일별 보고서와 광고 단위 보고서 두 가지뿐이다.

화면에서만 감추면 주소를 조금 바꿔 다른 데이터를 볼 여지가 남는다. 볼 필요가 없는 데이터는 가져오지 않는 쪽이 확실하다. 확인은 개발 환경에서 구글 응답을 흉내 내는 임시 처리기를 붙여 했다. 경제 블로그는 site 값을 어떻게 바꿔도 경제 블로그 수익만 나오고, 기술블로그는 사이트 선택·전체·잔고가 그대로인 것을 봤다.

반대 방향도 손봤다. 기술블로그의 사이트 선택 목록은 조회 기간 보고서에 실적이 있는 도메인으로 만든다. 아직 광고가 나가지 않은 경제 블로그는 목록에 없었다. AdSense:KnownSites 에 경제 블로그 도메인을 넣어, 실적이 없어도 항상 고를 수 있게 했다.

같은 블록의 AdUnitPrefix 는 광고 단위 이름 규칙이다. 경제_본문상단 처럼 이름을 지어 두면 경제 블로그의 해당 위치에 자동으로 연결된다(기술블로그는 블로그_). 하나의 애드센스 계정에서 광고 단위가 섞이지 않게 사이트별 접두어로 나눈 것이다.

배포: 같은 게시본, 다른 운영 설정

서버에는 IIS 사이트를 하나 더 만들었다. 두 사이트에 올라가는 게시본은 똑같고, 다른 것은 운영 설정 파일과 그 설정이 가리키는 데이터베이스뿐이다. 데이터베이스는 사이트마다 별도로 만들고 앱 계정도 따로 뒀다. 한쪽 앱이 다른 쪽 데이터에 접근할 수 없다.

운영 설정 파일은 개발 PC에 사이트별로 따로 두고(git 제외), 경제 블로그 설정에는 "SiteProfile": "economy" 가 들어간다. 배포 스크립트에는 -Site 옵션을 더했다.

$sites = @{
    blog    = @{ IisSite = '...'; Settings = 'deploy\appsettings.Production.json';         Profile = '' }
    economy = @{ IisSite = '...'; Settings = 'deploy\economy\appsettings.Production.json'; Profile = 'economy' }
}

여기서 가장 신경 쓴 것은 설정을 엉뚱한 사이트에 보내는 사고다. 경제 블로그 IIS 사이트에 기술블로그 설정이 올라가면, 경제 블로그 화면이 기술블로그 DB를 읽고 쓰게 된다. 그래서 설정 파일을 보내기 전에 안에 든 SiteProfile 이 -Site 와 맞는지 확인하고, 다르면 중단한다.

# 다른 사이트 설정을 보내면 그 사이트 DB 에 붙으므로 프로필이 맞는지 확인한다
$profileName = "$($json.SiteProfile)"
if ($profileName -ne $target.Profile) {
    throw "$($target.Settings) 의 SiteProfile 이 '$profileName' 입니다. -Site $Site 에는 '$($target.Profile)' 이어야 합니다."
}

코드를 고친 뒤에는 두 사이트를 모두 배포한다. 게시본이 같으니 두 번째는 빌드를 건너뛴다.

.\scripts\deploy.ps1
.\scripts\deploy.ps1 -Site economy -SkipBuild

비밀번호 해시 스크립트(hash-password.ps1)와 운영 설정 준비 스크립트에도 같은 -Site 옵션을 붙였다. 개발 PC에서는 Database:Name 설정으로 연결 문자열의 데이터베이스 이름만 바꿔 끼워, 하나의 user-secrets 로 두 사이트를 따로 띄운다.

글 올리기: blog-api.ps1 -Site

마크다운 원고를 관리 API로 올리는 blog-api.ps1 에도 -Site 를 더했다. 사이트에 따라 주소와 읽는 키 환경변수가 바뀐다.

$keyName = if ($Dev) { 'ADBLOG_API_KEY_DEV' } elseif ($Site -eq 'economy') { 'ECONOMY_API_KEY' } else { 'ADBLOG_API_KEY' }
.\scripts\blog-api.ps1 categories -Site economy
.\scripts\blog-api.ps1 push .dev\economy\01-rate.md -Site economy -Publish

경제 블로그 운영자는 앞의 설정 스크립트로 ECONOMY_API_KEY 를 넣어 두면 같은 스크립트를 그대로 쓸 수 있다. 다른 도구를 쓰더라도 Authorization: Bearer <키> 헤더만 붙이면 된다. 기술블로그 키와 경제 블로그 키는 환경변수 이름부터 다르기 때문에 한 PC에 둘 다 있어도 섞이지 않는다.

정리하며

  • 복사하지 말고 다른 점만 꺼낸다. 두 사이트의 차이를 표로 만들고 나니, 코드를 두 벌로 만들 이유가 없었다. 다른 값은 sites/*.json 프로필 하나에 모였다.
  • 설정을 끼워 넣는 위치가 설계다. 프로필을 기본값 바로 뒤에 넣었기 때문에 "프로필에 없으면 기본값, 비밀값은 언제나 운영 설정"이라는 규칙이 저절로 지켜진다.
  • 초기 데이터는 조건을 좁게. 글이 없고 초기값 그대로일 때만 바꾸니, 앱을 몇 번 다시 시작해도 운영 데이터는 안전하다.
  • 운영자가 다르면 계정과 데이터 범위부터 나눈다. 관리자 계정·API 키는 따로 만들고, 수익은 화면이 아니라 조회 단계에서 이 사이트로 제한했다.
  • 배포 스크립트가 실수를 막게 한다. 설정 파일의 프로필과 대상 사이트가 다르면 배포가 멈춘다.

이제 기능을 하나 고치면 두 블로그에 같이 들어간다. 경제 블로그 쪽 문구나 카테고리를 바꿀 때는 프로필 파일만 고치면 된다.