Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// Togglz for Feature Flags
implementation 'org.togglz:togglz-spring-boot-starter:4.4.0'
implementation 'org.togglz:togglz-console:4.4.0'
implementation 'org.togglz:togglz-core:4.4.0'
implementation 'org.togglz:togglz-servlet:4.4.0'

runtimeOnly 'com.mysql:mysql-connector-j'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
111 changes: 111 additions & 0 deletions migration-notes/step4-read-conversion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Migration Progress

## 현재 단계: 4단계 - Read Conversion (읽기 전환)

### 수행 작업
- Feature Flag를 사용한 점진적 읽기 전환 구현
- 새로운 스키마(`full_name`)로 읽기 시작
- 기존 스키마는 백업/대체 역할 유지
- Dual Write는 계속 유지

#### 사용자 식별 및 일관된 경험 보장
- **헤더 기반 사용자 식별**: `userId` 헤더로 사용자를 구분
- **해시 기반 일관성**: Togglz `GradualActivationStrategy`가 사용자 ID를 해싱하여 동일 사용자는 항상 동일한 결과 보장
- 한 번 새로운 스키마로 분기된 사용자는 설정 변경 전까지 계속 새로운 스키마 사용

```java
// TogglzConfig.java - 헤더 기반 사용자 식별
@Bean
public UserProvider userProvider() {
return () -> {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

// userId 헤더로 사용자 식별
String userId = request.getHeader("userId");
if (userId != null && !userId.trim().isEmpty()) {
return new SimpleFeatureUser(userId.trim(), false);
}
return new SimpleFeatureUser("anonymous", false);
};
}
```

#### User 도메인 수정
```java
public class User {
// 기존 필드들...

@Column(name = "full_name")
private String fullName;

// Feature Flag 기반 읽기 로직
public String getFullName() {
// Togglz가 현재 HTTP 요청의 사용자 컨텍스트에서 자동으로 판단
boolean useNewSchema = FeatureFlags.USE_NEW_SCHEMA.isActive();

if (useNewSchema && fullName != null) {
return fullName;
}
if (fullName == null) {
if (useNewSchema) {
// 이미 읽기 전환되었는데 신규 스키마가 null인 경우 로그 남기기
log.warn("Full name is null, falling back to firstName + lastName");
}
return firstName + " " + lastName;
}
return fullName;
}
}
```

### 데이터 안정성
- ✅ **Dual Write 유지**: 모든 쓰기는 양쪽 스키마에 저장
- ✅ **Fallback 보장**: 새로운 스키마 실패 시 기존 스키마로 대체
- ✅ **무중단 전환**: Feature Flag로 읽기 전환 비율 무중단 조정 가능
- ✅ **롤백 가능**: 언제든 Feature Flag로 기존 스키마 복귀 가능

### 설정 변경 방법

#### 1. Togglz 관리 콘솔 (권장)
```
http://localhost:8080/togglz-console
```

- Use New Schema for Reading의 Activation Strategy를 Gradual Rollout으로 설정
- Enabled를 활성화하고 Percentage를 원하는 비율(예: 30)로 설정
- 웹 UI를 통한 실시간 Feature Flag 관리
- 재배포 없이 즉시 적용
- GradualActivationStrategy로 0-100% 점진적 롤아웃 가능

#### 2. REST API를 통한 확인
```bash
# 현재 상태 조회
curl -X GET http://localhost:8080/api/feature-flags/USE_NEW_SCHEMA

# 특정 사용자의 분기 상태 확인
curl -X GET http://localhost:8080/api/feature-flags/current-user \
-H "userId: user123"
```

#### 3. 사용자별 분기 테스트
```bash
# 30% 설정 시 동일 사용자는 항상 동일한 결과
for i in {1..5}; do
curl -H "userId: user123" http://localhost:8080/api/feature-flags/current-user
done

# 다른 사용자들로 분포 확인
for user in user001 user002 user003 user004 user005; do
curl -H "userId: $user" http://localhost:8080/api/feature-flags/current-user
done
```

### 테스트 결과
- ✅ 모든 기존 테스트 통과
- ✅ Feature Flag false: 기존 동작 유지
- ✅ Feature Flag true: 새로운 스키마 사용 (3단계 Back Fill 후)
- ✅ Fallback 로직 정상 작동

### 다음 단계 준비사항
- 5단계: Cleanup (코드 정리)
- 새로운 스키마만 사용하도록 코드 정리
10 changes: 10 additions & 0 deletions src/main/java/io/spring/dbmigration/config/FeatureFlags.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.spring.dbmigration.config;

import org.togglz.core.Feature;
import org.togglz.core.annotation.Label;

public enum FeatureFlags implements Feature {

@Label("Use New Schema for Reading")
USE_NEW_SCHEMA;
}
42 changes: 42 additions & 0 deletions src/main/java/io/spring/dbmigration/config/TogglzConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.spring.dbmigration.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.togglz.core.manager.EnumBasedFeatureProvider;
import org.togglz.core.spi.FeatureProvider;
import org.togglz.core.user.SimpleFeatureUser;
import org.togglz.core.user.UserProvider;

import jakarta.servlet.http.HttpServletRequest;

@Configuration
public class TogglzConfig {

@Bean
public FeatureProvider featureProvider() {
return new EnumBasedFeatureProvider(FeatureFlags.class);
}

@Bean
public UserProvider userProvider() {
return () -> {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();

// userId 헤더로 사용자 식별
String userId = request.getHeader("userId");
if (userId != null && !userId.trim().isEmpty()) {
return new SimpleFeatureUser(userId.trim(), false);
}
}
} catch (Exception e) {
// 무시하고 기본값 사용
}
return new SimpleFeatureUser("anonymous", false);
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.spring.dbmigration.controller;

import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.togglz.core.manager.FeatureManager;
import org.togglz.core.repository.FeatureState;
import org.togglz.core.user.UserProvider;

import io.spring.dbmigration.config.FeatureFlags;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api/feature-flags")
@RequiredArgsConstructor
public class FeatureFlagController {

private final FeatureManager featureManager;
private final UserProvider userProvider;

// Feature Flag 상태 조회
@GetMapping("/USE_NEW_SCHEMA")
public ResponseEntity<Map<String, Object>> getFeatureFlag() {
boolean isActive = FeatureFlags.USE_NEW_SCHEMA.isActive();
FeatureState state = featureManager.getFeatureState(FeatureFlags.USE_NEW_SCHEMA);

return ResponseEntity.ok(Map.of(
"isActive", isActive,
"enabled", state.isEnabled(),
"feature", FeatureFlags.USE_NEW_SCHEMA.name(),
"strategy", state.getStrategyId() != null ? state.getStrategyId() : "None",
"percentage", state.getParameter("percentage") != null ?
state.getParameter("percentage") + "%" : "Not set",
"consoleUrl", "http://localhost:8083/togglz-console/index"
));
}

// 사용자별 분기 처리 확인
@GetMapping("/current-user")
public ResponseEntity<Map<String, Object>> getCurrentUser(
// FeatureFlag 사용자 식별용 헤더
@RequestHeader String userId
) {
var currentUser = userProvider.getCurrentUser();
boolean isActive = FeatureFlags.USE_NEW_SCHEMA.isActive();

return ResponseEntity.ok(Map.of(
"currentUser", currentUser.getName(),
"isFeatureUser", currentUser.isFeatureAdmin(),
"featureActive", isActive,
"userSpecificActivation", "User '" + currentUser.getName() + "' sees feature as: " + (isActive ? "ACTIVE" : "INACTIVE")
));
}
}
26 changes: 16 additions & 10 deletions src/main/java/io/spring/dbmigration/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package io.spring.dbmigration.controller;

import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.spring.dbmigration.domain.User;
import io.spring.dbmigration.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;

@Tag(name = "사용자 API", description = "사용자 생성, 조회, 수정 기능을 제공하는 API")
@RestController
Expand Down Expand Up @@ -46,11 +52,11 @@ public ResponseEntity<User> getUser(@Parameter(description = "사용자 ID", exa
@Operation(summary = "사용자 표시명 조회",
description = "1단계: 기존 스키마(firstName + lastName)로만 표시명을 조회합니다.")
@GetMapping("/{id}/display-name")
public ResponseEntity<Map<String, String>> getDisplayName(
public ResponseEntity<Map<String, String>> getFullName(
@Parameter(description = "사용자 ID", example = "1") @PathVariable Long id) {
try {
String displayName = userService.getDisplayName(id);
return ResponseEntity.ok(Map.of("displayName", displayName));
String fullName = userService.getFullName(id);
return ResponseEntity.ok(Map.of("fullName", fullName));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
Expand All @@ -68,4 +74,4 @@ public ResponseEntity<User> updateUserName(

public record CreateUserRequest(String firstName, String lastName, String email) {}
public record UpdateNameRequest(String firstName, String lastName) {}
}
}
18 changes: 17 additions & 1 deletion src/main/java/io/spring/dbmigration/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.time.LocalDateTime;

import io.spring.dbmigration.config.FeatureFlags;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
Expand All @@ -12,7 +13,9 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Entity
@Table(name = "users")
@Getter
Expand Down Expand Up @@ -52,7 +55,20 @@ public User(String firstName, String lastName, String email) {
}

public String getFullName() {
return firstName + " " + lastName;
// Togglz가 현재 HTTP 요청의 사용자 컨텍스트에서 자동으로 판단
boolean useNewSchema = FeatureFlags.USE_NEW_SCHEMA.isActive();

if (useNewSchema && fullName != null) {
return fullName;
}
if (fullName == null) {
if (useNewSchema) {
// 이미 읽기 전환되었는데 신규 스키마가 null인 경우 로그 남기기
log.warn("Full name is null, falling back to firstName + lastName");
}
return firstName + " " + lastName;
}
return fullName;
}

public void updateName(String firstName, String lastName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public User updateUserName(Long id, String firstName, String lastName) {
return userRepository.save(user);
}

public String getDisplayName(Long userId) {
public String getFullName(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));

Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,13 @@ springdoc:
- group: 'default'
display-name: 'DB Migration API'
packages-to-scan: io.spring.dbmigration.controller

# Togglz Configuration
togglz:
console:
enabled: true
path: /togglz-console
secured: false
features:
USE_NEW_SCHEMA:
enabled: false # 기본값: 기존 스키마 사용