지금 제 블로그는 게시물 하나에 카테고리 하나만 지정할 수 있어서, 여러 주제를 다루는 글을 정리하기가 애매합니다. 예를 들어 'Spring Boot로 만드는 TDD 프로젝트' 같은 글은 'Spring' 카테고리에 넣어야 할지, 'TDD'에 넣어야 할지 항상 고민됩니다.
그래서 기존 카테고리 방식을 자유로운 다중 해시태그 시스템으로 바꿔, 내가 글을 분류하기도 편하고, 방문자들이 관련 글들을 쉽게 찾아볼 수 있도록 만들고 싶습니다. 글의 연결성을 높여 더 많은 포스트를 손쉽게 탐색할 수 있게 하는 것이 최종 목표입니다.
최근에 기능 추가를 하면서 게시물 조회수 기능을 추가했습니다.
그 과정속에서 Posts 엔티티에 views 필드를 추가했고, 간단하게 view를 증가시키는 PATCH API 를 추가하여 배포했습니다. 근데 아래와 같은 오류가 발생했습니다.
2025-09-03 04:22:41.364 [main] WARN ... - GenerationTarget encountered exception accepting command : Error executing DDL "alter table posts add views number(19,0) not null" via JDBC [ORA-01758: table must be empty to add mandatory (NOT NULL) column]
2025-09-03 04:22:55.273 [http-nio-8080-exec-1] ERROR ... - ORA-00904: "P1_0"."VIEWS": invalid identifier
사이드 이펙트를 명확하게 정의하고 이를 배포 과정에서 반영하지 못한 저의 불찰이었고, 이후 블로그 페이지에서 아무런 API 호출이 불가능했습니다.
이러한 오류를 대비하지 못하여 롤백이 불가능했고 직접적인 문제 해결을 하기전에는 사용이 불가했습니다.
다행히 문제는 아래의 간단한 쿼리로 해결했습니다.
ALTER TABLE posts ADD (views NUMBER(19,0) DEFAULT 0 NOT NULL);
블루-그린 배포는 무중단 배포의 대표적인 전략으로, 현재 운영 중인 환경(블루)과 새로운 버전의 환경(그린)을 동시에 운영하다가 트래픽을 한 번에 전환하는 방식입니다.
두 개의 동일한 프로덕션 환경을 준비합니다

이렇게 두 환경을 동시에 운영하면서, 그린 환경이 충분히 검증되면 로드밸런서나 라우터의 설정만 변경해서 트래픽을 전환합니다. 만약 문제가 생기면? 다시 블루로 돌아가면 됩니다. 롤백이 즉시 가능하다는 게 가장 큰 장점이죠.
제가 겪은 조회수 기능 배포 실패처럼, 데이터베이스 스키마 변경이나 API 변경은 한 번 배포하면 되돌리기 어렵습니다. 특히 이미 운영 중인 서비스에서는 더욱 그렇죠.
블루-그린 전략을 쓰면:
이번 카테고리 → 태그 전환은 단순한 기능 추가가 아닙니다:
이런 대규모 변경을 한 번에 배포하면 제가 조회수 기능 때 겪은 것처럼 전체 서비스가 마비될 수 있습니다. 그래서 단계적으로, 안전하게 배포하기 위해 블루-그린 전략을 선택했습니다.
제가 계획한 배포 과정은 이렇습니다:
이렇게 하면 각 단계에서 문제가 생겨도 언제든 이전 상태로 돌아갈 수 있고, 사용자들은 서비스가 업그레이드되는 것조차 모르게 됩니다.
배포의 리스크를 최소화하면서도 새로운 기능을 안전하게 적용할 수 있는 거죠.
Github에 새로운 feat/multi-tags 브랜치를 만들어서 작업했습니다.
기능 개발은 TDD 기법을 적용하여 테스트 코드를 작성하고, 그 기능을 충족시키는 함수를 작성하여 완성시켰습니다. 이러한 방식을 사용하면 테스트 코드 커버리지를 확보할 수 있다는 장점이 존재합니다.
Tag 엔티티는 Post 엔티티와 다대다(N:M) 관계를 유지하여 매핑되게 했습니다.
Java의 Set 컬렉션을 사용하여 posts 테이블과 다대다로 매핑되게 설정하였습니다. JPA는 이러한 컬렉션을 지원하며, Set의 특징은 중복을 허용하지않고 순서를 보장하지 않는다는 점이 있습니다.
@Entity @Table(name = "tags") @Builder @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class Tag { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(nullable = false, unique = true) private String name; @Column(nullable = false) private LocalDateTime createdAt; @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY) @Builder.Default private Set<Post> posts = new HashSet<>(); }
TagRepository 이름의 Persistence Layer 클래스도 추가로 작성하여, 태그에 관한 관리들을 할 수 있게 설정했습니다. JpaRepository 인터페이스를 구현하여 파생쿼리(Derived Query)기능을 적극적으로 활용하여 개발 속도를 향상했습니다.
@Repository public interface TagRepository extends JpaRepository<Tag, Integer> { Optional<Tag> findByName(String name); Optional<Tag> findByNameIgnoreCase(String name); List<Tag> findByNameIn(List<String> names); List<Tag> findAllByOrderByNameAsc(); boolean existsByNameIgnoreCase(String name); }
아래는 일부 Business Logic 입니다. 게시물을 등록할 때 태그를 자유롭게 작성하면 서버에서는 이것이 존재하는지 존재하지 않는지를 분석하여 없는 태그들에 대해 Entity를 생성하고 저장하도록 구성했습니다. 이로써 프론트엔드에서 상대적으로 덜 복잡한 로직으로 기능이 동작하도록 개선했습니다.
Set(집합) 컬렉션의 여집합 연산을 활용하여 해당 부분을 구현하여 미세한 성능 개선도 고려하였습니다.
@LogExecutionTime @Transactional public Set<Tag> findOrCreateTags(List<String> tagNames) { List<String> inputNames = safeTagNames(tagNames); if (inputNames.isEmpty()) { return new HashSet<>(); } List<Tag> existingTags = tagRepository.findByNameIn(inputNames); Set<Tag> tagsFound = new HashSet<>(existingTags); Set<String> existingNames = existingTagNameSet(existingTags); List<String> missingNames = findMissingTagNames(inputNames, existingNames); createAndAttachMissingTags(tagsFound, missingNames); return tagsFound; }
기존의 CI 로직은 GitHub main 브랜치에 PR merge 될 때 동작합니다.
하지만 롤백 가능성을 염두에 둔 배포전략 이므로, 93% 테스트 커버리지를 믿고 먼저 새롭게 추가된 기능들을 Docker Hub를 통해 배포했습니다.
이후 SSH 연결을 통해 운영환경에 배포했고, 클라이언트 페이지가 문제없이 동작되는지 확인했습니다.
클라이언트 페이지가 문제 없이 서버에 연결되었음을 확인하고 GitHub에서 PR을 만들어 main 브랜치에 통합했습니다.
프론트엔드는 개발 초기부터 전반적으로 바이브 코딩으로 완성해오다보니, 이번에는 핫하다는 Codex 를 사용했습니다.
Spring Boot에서 제공하는 Swagger UI의 자동 생성 API 문서와 함께 기능 요구사항을 전달해주니 쉽게 구현이 완료되었습니다.
게시물 리스트에 각 아이템마다 태그를 표시하고 우측에 카테고리를 태그로 변경하여 여러 태그가 달려있는 게시물도 분류가 가능하도록 구현했습니다.

완료 후 관련 기능을 테스트 하면서 간단한 QA를 거쳐 구현을 마무리 했습니다.
프론트엔드는 Vercel서비스로 GitHub와 연결되어있어서 Pull Request로 Production 버전 무중단 배포가 가능합니다.
로드밸런서를 사용하지 않는 서버이므로, 저는 이 과정을 블루환경에서 그린환경으로 전환하는 포인트라고 생각하였습니다.
이후 실제 운영 도메인으로 접속하여 모든 기능이 동일하게 동작하는 것을 확인했습니다.
새로운 태그 기반 시스템이 안정적으로 운영되는 것을 확인한 후, 이제 기존 카테고리 시스템을 정리할 차례입니다. 먼저 백엔드에서 기존 카테고리 관련 API들에 @Deprecated 어노테이션을 추가했습니다. 당장 삭제하지 않은 이유는 혹시라도 롤백이 필요한 상황을 대비하기 위함이었죠.
@Deprecated @GetMapping("/categories") public ResponseEntity<List<CategoryDto>> getCategories() { // 기존 카테고리 조회 로직 }
최종적으로 다음과 같은 정리 작업을 수행했습니다
이렇게 해서 블루 환경의 잔재를 완전히 제거하고, 그린 환경만 남은 깔끔한 상태가 되었습니다.
결과적으로 이번 배포 과정에서 큰 문제 없이 대규모 스키마 변경과 API 전환을 성공적으로 완료할 수 있었습니다. 블루-그린 전략 덕분에 각 단계마다 충분한 검증 시간을 가질 수 있었고, 문제가 생겼을 때 언제든 롤백할 수 있다는 심리적 안정감도 컸습니다.
다만 Category를 사용하지 않는 기획 내에서, 기존 API들에 관련 검증로직을 제거하지 못해 Step 3에서 서버의 재배포 과정이 여러번 있었습니다. 서버를 개발할 땐 사이드 이펙트를 철저히 분석하고 이를 기록하여 올바르게 트랙킹하는 역량이 중요하다고 생각했습니다.
이런 배포 시스템을 직접 손으로 관리한 저는 여러모로 번거로운 부분들이 있었는데, 이를 체계적으로 프로세스를 정의하고 자동화를 한다면 안정적인 운영에 크게 도움이 될 것 이라고 생각합니다.