Elasticsearch + S3 업로드 — SNS 7편

2026-05-04Spring Boot SNS 마이크로서비스 포트폴리오

Spring Boot SNS 포트폴리오 시리즈 7편(마지막). Elasticsearch nori 한국어 형태소 분석으로 LIKE '%검색어%'의 한계를 넘는 전문 검색, 게시글 저장과 ES 인덱싱을 분리하는 트랜잭션 패턴, S3 Presigned URL로 브라우저가 LocalStack S3에 직접 업로드하는 4단계 흐름과 키 소유권 검증·CORS 설정까지 한 글에 정리합니다.

📚 Spring Boot SNS 마이크로서비스 포트폴리오 · 7편 (마지막) — Elasticsearch nori + S3 Presigned URL

이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 마지막 7편입니다. 1~6편에서 백엔드 부서들과 Kafka·Redis 같은 핵심 인프라를 다 봤다면, 7편에서는 시리즈 마지막 두 인프라 — Elasticsearch nori 한국어 형태소 검색S3 Presigned URL 파일 업로드를 한 글에 정리합니다.

비유는 1~6편을 그대로 이어 가요 — 회사 도서관의 한국어 색인 카드(Elasticsearch)와 외부 창고에 직접 짐을 보내는 자동 등기 시스템(S3 Presigned URL). 게시글의 본문을 한국어 단어로 쪼개서 색인 카드를 만들어 두면 "서울역 맛집"으로 검색해도 "서울역 근처 맛집"이 정확히 나오고, 사용자가 사진을 업로드할 때는 우리 서버를 거치지 않고 외부 창고로 직접 보내서 서버 부담을 0으로 만듭니다.

프로젝트 SNS는 이메일·비밀번호와 구글·깃허브 OAuth2 로그인, 게시글·댓글·좋아요·랭킹·구독·알림·한국어 검색까지 흔한 기능을 한 번씩 다 다뤄 보면서, 그 평범한 기능들을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + CDC · Redisson 분산 락)과 인프라(Kafka·Redis·Elasticsearch·LocalStack S3)로 어떻게 묶어 내는가를 직접 손으로 짜 보는 학습용 포트폴리오입니다.

왜 Elasticsearch + S3가 처음엔 어렵게 느껴질까

처음 ES + S3 코드를 보면 막히는 지점이 두 가지예요.

첫째, 둘 다 "왜 이게 필요한가"가 처음엔 안 보입니다. "검색은 그냥 DB에서 LIKE 쓰면 되지 않나" / "파일 업로드는 그냥 multipart로 받으면 되지 않나" 싶거든요. 답은 둘 다 "맞는데 그게 운영에서 안 통한다" — 한국어 검색은 LIKE로 형태소 처리가 안 되고, 파일 업로드는 서버 메모리·대역폭에 큰 부담이 됩니다. 한 번 운영에서 데이고 나면 ES와 Presigned URL의 가치가 한 번에 보이는데, 그 전에는 추상도가 높게 느껴져요.

둘째, 트랜잭션과 외부 시스템의 결합이 또 등장합니다. 5편에서 본 Kafka·Outbox와 비슷한 문제예요 — "DB는 저장됐는데 ES에는 인덱싱이 안 되면 어떻게 하나" / "DB 트랜잭션이 롤백됐는데 S3에는 파일이 이미 올라가 있으면 어떻게 하나". 이걸 어떻게 분리하는가가 핵심.

해결법은 두 단계예요. 먼저 "DB가 진실의 원천(source of truth), 외부 시스템은 부가 기능" 이라는 마음가짐으로 시작해서, 외부 시스템의 실패가 핵심 흐름을 막지 않게 분리합니다. ES 인덱싱은 실패해도 게시글 저장은 성공하고(검색에서만 안 나타날 뿐), S3 키는 사용자별 prefix로 격리해서 다른 사용자 키를 못 건드리게 해요.

두 인프라가 맡은 역할 한 표로

인프라포트자료구조 / 추상용도실패 시 영향
Elasticsearch9203 (호스트) / 9200 (내부)역색인 + nori게시글 전문 검색검색에서만 안 나타남 (DB 저장 OK)
Kibana5601ES 시각화 도구인덱스 디버깅학습 환경만
LocalStack S34566S3 에뮬레이터미디어 파일 저장새 업로드만 실패 (기존 파일 OK)

두 인프라 모두 DB가 못 하거나 잘 못 하는 일을 보조하는 역할이에요. 검색은 DB도 LIKE로 가능하지만 한국어 형태소·연관도·오타 허용은 무리고, 파일 저장은 DB BLOB도 가능하지만 대용량·CDN·직접 업로드 같은 운영 요구를 못 따라갑니다.

왜 Elasticsearch인가 — LIKE '%검색어%' 의 한계

PostgreSQL의 LIKE '%검색어%'로 검색을 짜 두면 운영에서 세 가지가 동시에 부닥칩니다.

문제원인
느림인덱스를 못 써서 전체 테이블 스캔
한국어 안 통함"자동차"로 검색해도 "자동차 정비"가 안 나옴 (형태소 분석 안 됨)
점수 정렬 안 됨검색어와 얼마나 잘 맞는지(relevance) 기반 정렬 불가

특히 한국어가 문제예요. 영어는 공백으로 단어가 자연 분리되지만, 한국어는 조사·어미가 붙어서 "서울역 근처 맛집"·"서울역에서 맛집 추천" 같은 표현이 형태소 단위로 안 쪼개지면 검색이 자꾸 빠집니다.

Elasticsearch는 이걸 두 단계로 풉니다.

  • 역색인(inverted index) — 단어 → 그 단어가 등장하는 문서 목록을 미리 만들어 두면, 검색이 단순한 집합 연산이 됩니다 (수백만 문서도 밀리초).
  • nori 형태소 분석기 — 한국어 텍스트를 의미 있는 단위로 쪼개 색인. "서울역 근처 맛집" → ["서울역", "근처", "맛집"].

역색인 원리 — 단어 → 문서 목록

역색인이 어떻게 작동하는지 한 그림으로 보면 한 번에 이해돼요.

원본 문서:
문서 1: "자동차 수리 전문점"
문서 2: "자동차 보험 추천"
문서 3: "오토바이 수리"

역색인 (인덱싱 후):
자동차 → [문서1, 문서2]
수리   → [문서1, 문서3]
전문점 → [문서1]
보험   → [문서2]
오토바이→ [문서3]

검색: "자동차 수리"
→ "자동차" 결과: [문서1, 문서2]
→ "수리" 결과:   [문서1, 문서3]
→ 교집합:        [문서1] ← 두 단어가 모두 들어 있는 문서

이 자료구조는 검색어 길이와 무관하게 O(검색어 수 × log N) 정도로 처리돼요. 수백만 문서가 있어도 상위 10개를 꺼내는 데 1ms 안에 끝납니다. DB의 LIKE는 매 요청마다 모든 문서를 한 번씩 다 읽어야 하니 비교가 안 되죠.

nori 형태소 분석기 — 한국어를 의미 단위로

// resources/elasticsearch/settings.json
{
  "analysis": {
    "tokenizer": {
      "nori_tokenizer": { "type": "nori_tokenizer" }
    },
    "analyzer": {
      "korean": {
        "type": "custom",
        "tokenizer": "nori_tokenizer"
      }
    }
  }
}

"서울역 근처 맛집" → nori 토크나이저 → ["서울역", "근처", "맛집"]. 조사("의"·"에서"·"는") 같은 의미 없는 토큰은 자동으로 걸러집니다.

여기서 시험 함정이 하나 있어요. nori는 Elasticsearch 기본 플러그인이 아니라 따로 설치해야 합니다.

# Elasticsearch 컨테이너 안에서 실행
elasticsearch-plugin install analysis-nori
# 그 다음 Elasticsearch 재시작 필요

학습 환경에서는 도커 이미지를 빌드할 때 이 한 줄을 넣어 두면 자동 설치됩니다. 한 번 빠뜨리면 인덱스 생성 시 "analyzer not found" 오류가 떨어져요.

게시글 인덱싱 — afterCommit() 으로 트랜잭션과 분리

Spring Data Elasticsearch가 ES와의 통신을 추상화해 줍니다.

@Document(indexName = "posts")
@Setting(settingPath = "elasticsearch/settings.json")   // analyzer 설정
@Mapping(mappingPath = "elasticsearch/mappings.json")   // 필드 타입 정의
public class PostDocument {
    @Id
    private String id;      // ES 도큐먼트 ID (postId를 String으로)

    private Long postId;
    private Long userId;
    private String title;
    private String content;
    private Long likeCount;
    private Long viewCount;
    private LocalDateTime createdAt;
}

게시글 저장 시 ES에도 같이 인덱싱해야 검색이 가능한데, 이 인덱싱을 트랜잭션과 어떻게 묶을지가 설계 포인트예요.

// 트랜잭션 커밋 후에만 인덱싱
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        try {
            postSearchRepository.save(doc);  // ES 인덱싱
        } catch (Exception e) {
            log.warn("ES 인덱싱 실패: postId={}", post.getId(), e);
            // 실패해도 게시글 저장은 성공 — 검색에서만 안 나타남
        }
    }
});

여기서 정말 중요한 시험 함정 — ES 인덱싱은 afterCommit() 안에서, 그리고 실패해도 throw하지 않고 log만 남깁니다. 두 가지 분리가 핵심:

  1. 트랜잭션 분리 — 커밋 직후에만 인덱싱하니, DB가 롤백되면 ES에 유령 문서가 안 남아요.
  2. 실패 격리 — ES가 잠깐 죽어도 게시글 저장은 그대로 성공합니다. 그 게시글이 검색에서만 잠시 안 나타날 뿐, 사용자는 자기가 쓴 글을 자기 프로필에서 볼 수 있어요. ES가 살아나면 누락된 인덱싱은 별도 동기화 Job으로 채울 수 있고요.

이 패턴은 6편에서 본 Spring Batch와 비슷한 구조예요 — DB가 진실의 원천, 외부 시스템은 자동 동기화로 따라잡기. 외부 시스템의 가용성이 핵심 기능을 막지 않습니다.

// 검색 — Spring Data Elasticsearch가 자동으로 multi_match 쿼리 생성
List<PostDocument> findByTitleContainingOrContentContaining(String title, String content);
🎯 한 줄 정리

ES 인덱싱은 afterCommit + try-catch로 트랜잭션·실패 격리. DB가 진실의 원천, ES 실패해도 핵심 흐름 안 막힘.

S3 Presigned URL — 왜 서버 경유 안 하는가

파일 업로드 방식 두 가지를 비교하면 차이가 한 번에 보여요.

방법 A: 서버 경유 업로드 (전형적이지만 비효율)
  클라이언트 → 파일 전송 → 서버 (메모리에 적재)
                          → 서버가 다시 S3에 업로드
  → 서버 메모리 부담, 두 번 전송, 대용량 파일 어려움

방법 B: Presigned URL 직접 업로드 (이 프로젝트)
  클라이언트 → presigned URL 요청 → 서버
            ← presigned URL 수신    ←
            → S3에 직접 PUT (서버 경유 없음)
  → 서버 부담 0, 한 번 전송, 대용량 OK

Presigned URL은 "이 사용자에게 이 키로 5분 동안 PUT 권한 부여" 라는 임시 토큰이 박힌 URL이에요. 클라이언트가 이 URL로 직접 S3에 업로드하면, S3가 토큰을 검증해서 권한이 있으면 받아 줍니다. 우리 서버는 URL을 한 번 발급해 주는 일만 하면 끝.

장점이 셋이에요.

  • 서버 부담 0 — 파일이 우리 서버 메모리에 안 들어옴. 100MB 파일이든 1GB 파일이든 서버는 똑같이 가벼움.
  • 대역폭 효율 — 서버는 URL 한 줄만 응답, 실제 전송은 클라이언트와 S3 사이.
  • 확장 용이 — 서버 인스턴스를 안 늘려도 동시 업로드 1000건 처리 가능.

단점은 한 가지 — CORS 설정이 필요하고 클라이언트 코드가 좀 복잡해져요. 하지만 이 한 번의 비용으로 운영의 편안함을 얻습니다.

업로드 4단계 + S3 키 소유권 검증

전체 업로드 흐름은 4단계예요.

1. POST /v1/media/presigned-url
   요청: { fileName: "photo.jpg", contentType: "image/jpeg" }

   서버 처리:
   - S3 키 생성: "media/{userId}/{uuid}/photo.jpg"
   - AWS SDK로 Presigned PUT URL 생성 (만료: 5분)

   응답: {
     key: "media/7/abc-uuid/photo.jpg",
     presignedUrl: "http://localhost:4566/sns-media/media/7/abc-uuid/photo.jpg?X-Amz-Signature=...",
     expiresIn: 300
   }

2. PUT {presignedUrl}  (브라우저 → LocalStack 직접)
   헤더: Content-Type: image/jpeg
   바디: 파일 바이너리
   응답: 200 OK

3. POST /v1/media/complete
   요청: { key: "media/7/abc-uuid/photo.jpg", width: 800, height: 600 }

   서버 처리:
   - S3 HeadObject로 파일 존재 및 크기 확인
   - 이미지면 리사이즈 → 썸네일 S3 업로드
   - PostMedia DB 저장

   응답: { mediaId: 3, mediaUrl: "...", thumbnailUrl: "...", ... }

4. POST /v1/posts  (mediaIds에 3 포함)
   → post_media.post_id = 생성된 게시글 ID 로 업데이트

여기서 정말 중요한 시험 함정 — S3 키에 userId를 박아 소유권을 검증합니다.

public PresignedUrlResponse generatePresignedUrl(Long userId, PresignedUrlRequest request) {
    // 키에 userId 포함 → 소유권 검증의 기준
    String key = "media/" + userId + "/" + UUID.randomUUID() + "/" + request.getFileName();
    ...
}

public MediaResponse complete(Long userId, MediaCompleteRequest request) {
    String key = request.getKey();
    // 요청한 키가 자신의 userId로 시작하는지 확인
    if (!key.startsWith("media/" + userId + "/")) {
        throw new ResponseStatusException(HttpStatus.FORBIDDEN, "접근 권한 없음");
    }
    ...
}

만약 사용자 A가 사용자 B의 미디어 키(media/B의id/...)를 자기 게시글에 첨부하려고 시도하면, complete 단계에서 key.startsWith("media/A의id/") 검증에 걸려 403으로 떨어져요. 키 자체에 사용자 ID를 prefix로 박아 두니 별도 권한 테이블 없이도 깔끔한 격리가 됩니다.

AWS SDK v2 — LocalStack 연결 + CORS

AWS SDK v2 설정도 한 가지 디테일이 있어요.

@Bean
public S3Client s3Client() {
    return S3Client.builder()
            .endpointOverride(URI.create("http://localhost:4566"))  // LocalStack
            .region(Region.US_EAST_1)
            .credentialsProvider(StaticCredentialsProvider.create(
                    AwsBasicCredentials.create("test", "test")      // LocalStack은 임의 값 허용
            ))
            .forcePathStyle(true)  // http://localhost:4566/{bucket} 형식 사용
            .build();
}

여기서 시험 함정이 하나 있어요. forcePathStyle(true) 가 빠지면 LocalStack 접근이 안 됩니다. 실제 AWS는 도메인 형식 URL(https://my-bucket.s3.amazonaws.com/...)이 기본인데, LocalStack은 path 형식(http://localhost:4566/my-bucket/...)만 지원해요. SDK 기본값이 도메인 형식이라 LocalStack에서는 명시적으로 path 형식을 강제해야 합니다.

운영 AWS로 전환할 때는 endpointOverride 제거 + credentialsProvider를 IAM Role로 교체하면 끝. 코드 한 줄 차이로 학습/운영을 오갈 수 있어요.

CORS 설정도 잊지 말 것 — 브라우저가 다른 오리진(localhost:3000 → localhost:4566)으로 PUT 요청을 보내려면 S3 버킷에 CORS가 열려 있어야 합니다.

curl -X PUT http://localhost:4566/sns-media?cors \
  -H "Content-Type: application/xml" \
  -d '<CORSConfiguration>
    <CORSRule>
      <AllowedOrigin>*</AllowedOrigin>
      <AllowedMethod>GET</AllowedMethod>
      <AllowedMethod>PUT</AllowedMethod>
      <AllowedMethod>POST</AllowedMethod>
      <AllowedMethod>DELETE</AllowedMethod>
      <AllowedMethod>HEAD</AllowedMethod>
      <AllowedHeader>*</AllowedHeader>
    </CORSRule>
  </CORSConfiguration>'

CORS가 안 열려 있으면 브라우저가 PUT 요청을 자체 차단해서 클라이언트 콘솔에 CORS 에러가 뜹니다. 처음 한 번 데이고 나면 절대 까먹지 않는 디테일.

🎯 한 줄 정리

LocalStack S3 = forcePathStyle(true) 필수. 운영 전환은 endpointOverride 제거 + IAM Role. 브라우저 직접 업로드면 S3 버킷 CORS 설정 필수.

시리즈 마무리 — 7편을 한 줄씩

여기까지가 시리즈의 마지막 편 7편입니다. Elasticsearch nori 한국어 형태소 검색과 S3 Presigned URL 직접 업로드 — 두 인프라 모두 DB가 못 하거나 잘 못 하는 일을 보조하는 역할로 자리 잡았어요.

7편 시리즈를 한 줄씩 돌아보면 이렇게 됩니다.

한 줄 요약
1편 — 아키텍처회사 본사 한 동에 마이크로서비스 4개 + 인프라 7개. Database-per-Service.
2편 — API Gateway정문 보안실에서 JWT 검증 + Redis 블랙리스트 + X-User-Id 주입. WebFlux 이벤트 루프.
3편 — User · OAuth2BCrypt 자동 솔트, Access·Refresh 두 토큰, RefreshToken SHA-256, OAuth2 8단계.
4편 — Post ServiceRedisson RLock은 트랜잭션 바깥, afterCommit 패턴, DB UNIQUE 동시성 방패.
5편 — Kafka · OutboxOutbox + Debezium CDC로 DB·Kafka 원자성 보장. JsonSerializer trusted packages.
6편 — Redis 4가지캐시·Sorted Set·블랙리스트·분산 락 한 인프라. Spring Batch + Pipeline.
7편 — ES + S3nori 한국어 형태소, ES 인덱싱 afterCommit 분리, S3 Presigned URL 직접 업로드.

전체를 관통하는 두 가지 큰 원칙 — (1) 외부 시스템 호출은 모두 트랜잭션 커밋 이후, (2) 외부 시스템 실패가 핵심 흐름을 막지 않게 격리 — 이 두 줄이 마이크로서비스 운영의 거의 모든 함정을 막아 줍니다.

공식 문서: Elasticsearch nori 가이드AWS SDK v2 S3 가이드에 이 글의 코드와 설정이 어디서 왔는지가 자세히 정리돼 있어요.

시리즈 다른 편

시험 직전 한 번 더 — ES + S3 함정 압축 노트

  • Elasticsearch = 역색인 + 한국어 형태소 분석으로 빠른 전문 검색
  • LIKE '%검색어%' 한계 = 인덱스 못 씀 + 한국어 형태소 X + 점수 정렬 X
  • 역색인 = 단어 → 문서 목록. 검색어 길이와 무관하게 빠름
  • nori = Elasticsearch 한국어 형태소 분석 플러그인
  • nori 설치 = elasticsearch-plugin install analysis-nori 후 재시작 필요
  • 학습용 ES 힙 = -Xms128m -Xmx128m (기본 512MB~1GB → OOM exit 137)
  • @Document(indexName = "posts") + @Setting + @Mapping 으로 인덱스 정의
  • ES 인덱싱은 afterCommit() 안에서 — 트랜잭션 일관성
  • ES 인덱싱 실패 = try-catch로 흡수, log만 — 게시글 저장은 그대로 성공
  • "DB는 진실의 원천(source of truth), ES는 부가 검색 기능"
  • Spring Data Elasticsearch = findByTitleContainingOrContent... → multi_match 자동 생성
  • 호스트 포트 9203 → 컨테이너 9200 (기본 9200 충돌 방지)
  • Kibana 5601 = ES 시각화·디버깅 도구
  • S3 Presigned URL = "이 사용자에게 이 키로 N초간 PUT 권한" 임시 토큰 URL
  • 서버 경유 업로드 = 메모리 부담 + 두 번 전송 → Presigned URL로 우회
  • Presigned URL 만료 = 5분 (Duration.ofSeconds(300))
  • 업로드 4단계 = presigned-url 요청 → S3 직접 PUT → complete → 게시글에 첨부
  • S3 키 = media/{userId}/{uuid}/{fileName} — userId prefix로 소유권 격리
  • complete 단계 = key.startsWith("media/{userId}/") 검증 → 다른 사용자 키 차단
  • LocalStack 연결 = endpointOverride("http://localhost:4566") + forcePathStyle(true)
  • forcePathStyle(true) 누락 = LocalStack 접근 실패 (도메인 형식 URL 안 됨)
  • LocalStack 자격증명 = "test"/"test" 임의 값 OK
  • 운영 AWS 전환 = endpointOverride 제거 + credentialsProvider를 IAM Role
  • 브라우저 직접 업로드 = S3 버킷 CORS 설정 필수
  • CORS 누락 = 브라우저가 PUT 자체 차단 (CORS 에러)
  • complete에서 S3 HeadObject = 파일 존재·크기 확인 후 DB 저장
  • 이미지 미디어 = 썸네일 자동 생성 + 별도 S3 키로 저장
  • post_media.post_id = 게시글 생성 후 업데이트로 연결

지금까지 7편을 다 따라와 주셔서 감사합니다. 시리즈를 따라 같은 SNS를 직접 손으로 한 번 짜 보시면, 마이크로서비스 패턴이 훨씬 더 친숙해질 거예요. 코드를 한 줄씩 따라 만들어 보면서 막히는 부분이 생기면 각 편을 다시 펼쳐 봐 주세요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!