목차

  1. 문제 상황
  2. Spring OAuth2 토큰 생성 메커니즘 이해
  3. OAuth2TokenGenerator 커스터마이징
  4. SSO 로그인에 적용하기
  5. 테스트 전략
  6. 트러블슈팅

문제 상황

SSO(Single Sign-On) 로그인 기능을 구현하던 중, 다음과 같은 문제를 발견했습니다:

  • OAuth2 표준 플로우: Access Token(JWT), Refresh Token(Opaque) 생성 및 DB 저장 ✅
  • SSO 로그인: Access Token(JWT), Refresh Token(JWT) 생성, DB 저장 안 됨

두 플로우가 서로 다른 방식으로 토큰을 생성하고 있었고, SSO로 생성된 Refresh Token은 JWT 형식이라 DB에 저장되지 않아 갱신이 불가능했습니다.

목표: SSO 로그인도 OAuth2 표준 플로우와 동일하게 Opaque Refresh Token을 생성하고 DB에 저장하도록 통일


Spring OAuth2 토큰 생성 메커니즘 이해

1. Spring이 자동으로 생성하는 토큰 생성기

Spring OAuth2 Authorization Server는 내부적으로 토큰 생성기를 자동 구성합니다:

// Spring 내부에서 자동 생성 (OAuth2AuthorizationServerConfiguration)
@Bean
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(...) {
    JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);
    jwtGenerator.setJwtCustomizer(jwtCustomizer);
    
    OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
    OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
    
    return new DelegatingOAuth2TokenGenerator(
        jwtGenerator,           // 1순위: JWT 생성 (Access Token용)
        accessTokenGenerator,   // 2순위: Opaque Access Token (fallback)
        refreshTokenGenerator   // 3순위: Opaque Refresh Token
    );
}

2. DelegatingOAuth2TokenGenerator의 동작 원리

DelegatingOAuth2TokenGenerator는 여러 토큰 생성기를 순회하며 첫 번째로 성공한 생성기를 사용합니다:

public class DelegatingOAuth2TokenGenerator implements OAuth2TokenGenerator<OAuth2Token> {
    
    @Override
    public OAuth2Token generate(OAuth2TokenContext context) {
        for (OAuth2TokenGenerator<OAuth2Token> tokenGenerator : this.tokenGenerators) {
            OAuth2Token token = tokenGenerator.generate(context);
            if (token != null) {
                return token;  // 첫 번째 성공한 생성기의 결과 반환
            }
        }
        return null;
    }
}

토큰 타입별 생성 과정:

  • Access Token (OAuth2TokenType.ACCESS_TOKEN):
    1. JwtGenerator 시도 → JWT 형식 Access Token 생성 성공 ✅
    2. 나머지 생성기는 실행되지 않음
  • Refresh Token (OAuth2TokenType.REFRESH_TOKEN):
    1. JwtGenerator 시도 → Refresh Token은 생성하지 않음, null 반환
    2. OAuth2AccessTokenGenerator 시도 → Access Token만 생성, null 반환
    3. OAuth2RefreshTokenGenerator 시도 → Opaque Refresh Token 생성 성공 ✅

3. 왜 내부 생성기는 주입받을 수 없나?

Spring이 자동 생성한 토큰 생성기는 OAuth2 엔드포인트 내부에서만 사용되며, Application Context에 Bean으로 등록되지 않습니다:

// Spring 내부 설정
public class OAuth2AuthorizationServerConfiguration {
    
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)  // 인프라 Bean (주입 불가)
    SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
        OAuth2TokenGenerator<?> tokenGenerator = getTokenGenerator(http);
        // 이 tokenGenerator는 HttpSecurity SharedObjects에만 저장됨
    }
}

SharedObjects vs Application Context Bean:

  • SharedObjects: HttpSecurity 내부에서만 공유되는 객체 저장소
  • Application Context Bean: @Autowired로 주입 가능한 Bean

따라서 커스텀 서비스에서 토큰 생성이 필요하면 직접 Bean을 생성해야 합니다.


OAuth2TokenGenerator 커스터마이징

1. JwtConfig에 Bean 등록

@Configuration
public class JwtConfig {

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer(
            LoginUserDetailsService userDetailsService) {
        return (jwtEncodingContext) -> {
            OAuth2TokenType tokenType = jwtEncodingContext.getTokenType();
            if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
                try {
                    String username = jwtEncodingContext.getPrincipal().getName();
                    LoginUserDetails user = (LoginUserDetails) userDetailsService
                            .loadUserByUsername(username);

                    jwtEncodingContext.getClaims().claims((claims) -> {
                        List<String> roles = user.getAuthorities().stream()
                                .map(GrantedAuthority::getAuthority)
                                .collect(Collectors.toList());
                        claims.put("roles", roles);
                    });
                } catch(UsernameNotFoundException e) {
                    String clientId = jwtEncodingContext.getRegisteredClient().getClientId();
                    jwtEncodingContext.getClaims().claims(claims -> {
                        claims.put("client_id", clientId);
                    });
                }
            }
        };
    }

    /**
     * OAuth2 토큰 생성기 Bean 등록
     * - JwtGenerator: Access Token을 JWT 형식으로 생성 (1순위)
     * - OAuth2AccessTokenGenerator: Access Token을 Opaque 형식으로 생성 (fallback)
     * - OAuth2RefreshTokenGenerator: Refresh Token을 Opaque 형식으로 생성
     */
    @Bean
    public OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(
            JwtEncoder jwtEncoder,
            OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer) {
        
        JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);
        jwtGenerator.setJwtCustomizer(jwtTokenCustomizer);
        
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        
        return new DelegatingOAuth2TokenGenerator(
            jwtGenerator,
            accessTokenGenerator,
            refreshTokenGenerator
        );
    }
}

2. 토큰 생성 순서 및 형식

토큰 타입생성기 우선순위최종 형식용도

Access Token JwtGenerator → OAuth2AccessTokenGenerator JWT API 인증
Refresh Token JwtGeneratorOAuth2AccessTokenGenerator → OAuth2RefreshTokenGenerator Opaque (UUID) 토큰 갱신

Opaque Token 예시: a1b2c3d4-e5f6-7890-abcd-ef1234567890


SSO 로그인에 적용하기

1. SsoTokenService 구현

@Service
public class SsoTokenService {

    private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
    private final RegisteredClientRepository registeredClientRepository;
    private final OAuth2AuthorizationService authorizationService;
    private final AuthorizationServerSettings authorizationServerSettings;

    @Autowired
    public SsoTokenService(
            OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
            RegisteredClientRepository registeredClientRepository,
            OAuth2AuthorizationService authorizationService,
            AuthorizationServerSettings authorizationServerSettings) {
        this.tokenGenerator = tokenGenerator;
        this.registeredClientRepository = registeredClientRepository;
        this.authorizationService = authorizationService;
        this.authorizationServerSettings = authorizationServerSettings;
    }

    /**
     * SSO 로그인 완료 후 Access Token과 Refresh Token을 생성하고 DB에 저장
     * OAuth2 표준 방식 사용 (Access Token: JWT, Refresh Token: Opaque)
     */
    public TokenResponse createAndSaveTokens(LoginUserDetails userDetails) {
        RegisteredClient registeredClient = registeredClientRepository
                .findByClientId("your-client");
        if (registeredClient == null) {
            throw new IllegalStateException("RegisteredClient를 찾을 수 없습니다");
        }

        Authentication authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());

        // Access Token 생성 (JWT)
        OAuth2TokenContext accessTokenContext = DefaultOAuth2TokenContext.builder()
                .registeredClient(registeredClient)
                .principal(authentication)
                .tokenType(OAuth2TokenType.ACCESS_TOKEN)
                .authorizedScopes(Collections.emptySet())
                .authorizationServerContext(createAuthorizationServerContext())
                .build();

        OAuth2Token generatedAccessToken = tokenGenerator.generate(accessTokenContext);
        if (generatedAccessToken == null) {
            throw new IllegalStateException("Access Token 생성에 실패했습니다");
        }
        OAuth2AccessToken accessToken = (OAuth2AccessToken) generatedAccessToken;

        // Refresh Token 생성 (Opaque)
        OAuth2TokenContext refreshTokenContext = DefaultOAuth2TokenContext.builder()
                .registeredClient(registeredClient)
                .principal(authentication)
                .tokenType(OAuth2TokenType.REFRESH_TOKEN)
                .authorizedScopes(Collections.emptySet())
                .authorizationServerContext(createAuthorizationServerContext())
                .build();

        OAuth2Token generatedRefreshToken = tokenGenerator.generate(refreshTokenContext);
        if (generatedRefreshToken == null) {
            throw new IllegalStateException("Refresh Token 생성에 실패했습니다");
        }
        OAuth2RefreshToken refreshToken = (OAuth2RefreshToken) generatedRefreshToken;

        // OAuth2Authorization 생성 및 DB 저장
        OAuth2Authorization authorization = OAuth2Authorization
                .withRegisteredClient(registeredClient)
                .id(UUID.randomUUID().toString())
                .principalName(userDetails.getUsername())
                .authorizationGrantType(AuthorizationGrantType.PASSWORD)
                .authorizedScopes(Collections.emptySet())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .attribute("java.security.Principal", authentication)
                .build();

        authorizationService.save(authorization);
        log.info("OAuth2Authorization saved for SSO user: {}", userDetails.getUsername());

        return new TokenResponse(accessToken, refreshToken);
    }

    /**
     * AuthorizationServerContext 생성 헬퍼 메서드
     * JWT 토큰에 issuer를 포함시키기 위해 필요
     */
    private AuthorizationServerContext createAuthorizationServerContext() {
        return new AuthorizationServerContext() {
            @Override
            public String getIssuer() {
                return authorizationServerSettings.getIssuer();
            }

            @Override
            public AuthorizationServerSettings getAuthorizationServerSettings() {
                return authorizationServerSettings;
            }
        };
    }

    @Getter
    public static class TokenResponse {
        private final OAuth2AccessToken accessToken;
        private final OAuth2RefreshToken refreshToken;

        public TokenResponse(OAuth2AccessToken accessToken, 
                           OAuth2RefreshToken refreshToken) {
            this.accessToken = accessToken;
            this.refreshToken = refreshToken;
        }
    }
}

2. SsoController에서 사용

@RestController
public class SsoController {

    private final SsoTokenService ssoTokenService;
    private final LoginUserDetailsService loginUserDetailsService;

    @RequestMapping(value = "/sso-login", method = {RequestMethod.POST})
    public void loginSSOCallback(@ModelAttribute SsoDto ssoDto, 
                                 HttpServletRequest request, 
                                 HttpServletResponse response) throws IOException {
        // SSO 세션 검증 및 사용자 조회 로직...
        
        String userName = request.getHeader("user");
        LoginUserDetails userDetails = (LoginUserDetails) 
                loginUserDetailsService.loadUserByUsername(userName);

        // OAuth2 표준 방식으로 토큰 생성 및 DB 저장
        SsoTokenService.TokenResponse tokenResponse = 
                ssoTokenService.createAndSaveTokens(userDetails);
        
        String token = tokenResponse.getAccessToken().getTokenValue();
        String refreshToken = tokenResponse.getRefreshToken().getTokenValue();

        // 쿠키 설정
        Cookie tokenCookie = new Cookie("token", token);
        tokenCookie.setDomain(ssoProperties.getCookieDomain());
        tokenCookie.setPath("/");
        response.addCookie(tokenCookie);

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setDomain(ssoProperties.getCookieDomain());
        refreshTokenCookie.setPath("/");
        response.addCookie(refreshTokenCookie);

        response.sendRedirect(baseUri);
    }
}

3. 동작 플로우

[SSO 서버] → [SsoController]
                    ↓
            사용자 정보 검증
                    ↓
          [SsoTokenService]
                    ↓
        OAuth2TokenGenerator
         ┌──────────┴──────────┐
         ↓                     ↓
    Access Token          Refresh Token
     (JWT 형식)            (Opaque 형식)
         └──────────┬──────────┘
                    ↓
         OAuth2AuthorizationService
                    ↓
              DB에 저장 (oauth2_authorization 테이블)
                    ↓
            쿠키로 클라이언트 전송

테스트 전략

1. 단위 테스트: SsoTokenServiceTest

@RunWith(MockitoJUnitRunner.class)
public class SsoTokenServiceTest {

    @Mock
    private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;

    @Mock
    private RegisteredClientRepository registeredClientRepository;

    @Mock
    private OAuth2AuthorizationService authorizationService;

    @Mock
    private RegisteredClient registeredClient;

    private AuthorizationServerSettings authorizationServerSettings;
    private SsoTokenService ssoTokenService;

    @Before
    public void setUp() {
        authorizationServerSettings = AuthorizationServerSettings.builder()
                .issuer("http://localhost:8900/api/user")
                .build();

        ssoTokenService = new SsoTokenService(
                tokenGenerator,
                registeredClientRepository,
                authorizationService,
                authorizationServerSettings
        );
    }

    @Test
    public void createAndSaveTokens_shouldGenerateAndSaveTokens() {
        // Given
        LoginUserDetails userDetails = new LoginUserDetails(
                1, "encodedPassword", "1100001", "dev-team",
                true, true, true, true,
                Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))
        );

        given(registeredClientRepository.findByClientId("your-client"))
                .willReturn(registeredClient);

        OAuth2AccessToken mockAccessToken = new OAuth2AccessToken(
                OAuth2AccessToken.TokenType.BEARER,
                "mock-access-token",
                Instant.now(),
                Instant.now().plus(1, ChronoUnit.HOURS)
        );

        OAuth2RefreshToken mockRefreshToken = new OAuth2RefreshToken(
                "mock-refresh-token",
                Instant.now(),
                Instant.now().plus(7, ChronoUnit.DAYS)
        );

        given(tokenGenerator.generate(any(OAuth2TokenContext.class)))
                .willReturn(mockAccessToken)
                .willReturn(mockRefreshToken);

        // When
        SsoTokenService.TokenResponse response = 
                ssoTokenService.createAndSaveTokens(userDetails);

        // Then
        assertNotNull("TokenResponse should not be null", response);
        assertNotNull("Access token should not be null", response.getAccessToken());
        assertNotNull("Refresh token should not be null", response.getRefreshToken());
        assertEquals("mock-access-token", response.getAccessToken().getTokenValue());
        assertEquals("mock-refresh-token", response.getRefreshToken().getTokenValue());

        verify(registeredClientRepository).findByClientId("your-client");
        verify(authorizationService).save(any(OAuth2Authorization.class));
    }
}

2. WebMvcTest 문제와 해결

문제: WebMvcTest 환경에서 SecurityConfig를 Import하면 OAuth2 관련 Bean들을 모두 로드해야 함

  • AuthorizationServerSettings
  • CustomUserInfoMapper
  • JdbcOperations
  • RegisteredClientRepository
  • 등등...

해결: 테스트용 간소화된 SecurityConfig 생성

@TestConfiguration
@EnableWebSecurity
public class TestSecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .cors(cors -> cors.disable())
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize ->
                        authorize
                                .requestMatchers("/sso-login", "/sso-login2").permitAll()
                                .requestMatchers("/users/**").hasAuthority("ROLE_ADMIN")
                                .requestMatchers("/authorities/**").hasAuthority("ROLE_ADMIN")
                                .anyRequest().authenticated()
                )
                .build();
    }
}
@RunWith(SpringRunner.class)
@WebMvcTest(SsoController.class)
@Import(TestSecurityConfig.class)  // SecurityConfig 대신 TestSecurityConfig 사용
@ContextConfiguration(classes = SpringSecurityWebAuthTestConfig.class)
public class SsoControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private SsoTokenService ssoTokenService;

    @Test
    public void loginSSOCallback_shouldRedirectWithTokens() throws Exception {
        // Given
        TokenResponse tokenResponse = new TokenResponse(accessToken, refreshToken);
        given(ssoTokenService.createAndSaveTokens(any())).willReturn(tokenResponse);

        // When & Then
        this.mvc.perform(post("/sso-login")
                        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                        .param("authinform", "session123")
                        .header("serversessionid", "session123")
                        .header("user", "testuser"))
                .andExpect(status().is3xxRedirection())
                .andExpect(cookie().exists("token"))
                .andExpect(cookie().exists("refreshToken"));
    }
}

3. 테스트 결과

✅ SsoTokenServiceTest: 2 tests passed
✅ SsoControllerTest: 5 tests passed
✅ AuthorityControllerTest: 14 tests passed
✅ UserControllerTest: 21 tests passed
────────────────────────────────────────
✅ Total: 42 tests passed, 0 failures

트러블슈팅

1. "value cannot be null" 오류

증상:

java.lang.IllegalArgumentException: value cannot be null

원인: OAuth2TokenContext에 authorizationServerContext가 누락되어 JWT의 iss (issuer) claim을 생성할 수 없음

해결:

OAuth2TokenContext context = DefaultOAuth2TokenContext.builder()
        .registeredClient(registeredClient)
        .principal(authentication)
        .tokenType(OAuth2TokenType.ACCESS_TOKEN)
        .authorizedScopes(Collections.emptySet())
        .authorizationServerContext(createAuthorizationServerContext())  // 추가!
        .build();

2. OAuth2TokenGenerator Bean을 찾을 수 없음

증상:

No qualifying bean of type 'OAuth2TokenGenerator' available

원인: Spring이 자동 생성한 토큰 생성기는 내부용이며 Application Context에 Bean으로 등록되지 않음

해결: JwtConfig에서 직접 Bean 생성 (위의 "OAuth2TokenGenerator 커스터마이징" 섹션 참조)

3. WebMvcTest에서 SecurityConfig 로드 실패

증상:

Error creating bean with name 'SecurityConfig': 
Unsatisfied dependency expressed through constructor parameter 0: 
No qualifying bean of type 'CustomUserInfoMapper' available

원인: WebMvcTest는 제한된 컨텍스트만 로드하므로 OAuth2 관련 Bean들이 없음

해결:

  1. TestSecurityConfig 생성 (OAuth2 설정 제외)
  2. 테스트에서 @Import(TestSecurityConfig.class) 사용

핵심 요약

📌 Spring OAuth2 토큰 생성 메커니즘

  1. DelegatingOAuth2TokenGenerator가 여러 생성기를 순회
  2. JwtGenerator: Access Token → JWT 형식
  3. OAuth2RefreshTokenGenerator: Refresh Token → Opaque 형식
  4. 내부 생성기는 주입 불가 → 필요시 직접 Bean 생성

📌 커스터마이징 포인트

@Bean
public OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(...) {
    JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);
    jwtGenerator.setJwtCustomizer(jwtTokenCustomizer);  // 커스텀 Claim 추가
    
    return new DelegatingOAuth2TokenGenerator(
        jwtGenerator,
        new OAuth2AccessTokenGenerator(),
        new OAuth2RefreshTokenGenerator()
    );
}

📌 SSO 통합 체크리스트

  • OAuth2TokenGenerator Bean 등록
  • AuthorizationServerSettings Bean 등록
  • SsoTokenService 구현
  • OAuth2Authorization DB 저장 확인
  • AuthorizationServerContext 설정 (issuer)
  • 단위 테스트 작성
  • 통합 테스트 작성 (TestSecurityConfig 사용)

참고 자료

 

Building a Second Brain for the LLM Era - Series Index

llm-knowledge-base: A Second Brain system connecting Confluence with Obsidian and enabling AI collaboration via Multi-Agent


📚 Series Overview

In the LLM era, a Single Source of Truth has become even more important. This series shares the experience of building a system to effectively provide team Wiki (Confluence) context to AI while working across multiple projects, and deploying it to a 15-person team over 3 months.

Key Concepts:

  • Single Source of Truth
  • Second Brain
  • Confluence to Obsidian
  • Multi-Agent Orchestration (MoE)
  • Knowledge Management

🗂️ Series Structure

Part 1: Why I Built This

Subtitle: Problem Discovery and Solution Motivation

Main Content

  • Single Source of Truth in the LLM era
  • Context switching cost in multi-project work
  • Disconnect between team Wiki and local knowledge base
  • Limitations of single AI and need for MoE

Core Problems

  1. Wiki search time: 50 minutes wasted per day
  2. Context switching: Repeatedly finding related documents per project
  3. AI selection: Codex vs Claude, constant decision-making

Target Audience

  • Developers working on multiple projects
  • Developers frequently referencing team Wiki
  • Developers interested in LLM utilization

Part 2: Single Source of Truth Architecture

Subtitle: Confluence → Obsidian Pipeline

Main Content

  • Overall system architecture
  • Utilizing Confluence REST API
  • HTML → Markdown conversion strategy
  • Link conversion / image handling
  • Incremental sync design

Tech Stack

  • Python (requests, BeautifulSoup, html2text)
  • Confluence REST API
  • Obsidian (Markdown)
  • Bash Script

Target Audience

  • Developers interested in system architecture
  • Developers looking for Confluence API examples
  • Teams needing document conversion pipelines

Part 3: Confluence Sync Implementation Details

Subtitle: Python Code Line-by-Line Analysis

Main Content

  • Confluence API client implementation
  • Page tree crawling (DFS)
  • HTML preprocessing (Confluence macro handling)
  • Markdown conversion engine
  • Attachment downloader (images, Draw.io, Gliffy)
  • Frontmatter generation
  • Troubleshooting & optimization

Real Experience

  • Rate limit handling
  • Korean character corruption fix
  • Large page memory optimization
  • Special character filename handling

Target Audience

  • Developers curious about actual implementation code
  • Developers working on web crawling/conversion in Python
  • Developers interested in open source contribution

Part 4: Multi-Agent Orchestration Strategy

Subtitle: Mixture of Experts (MoE) Pattern

Main Content

  • MoE (Mixture of Experts) concept
  • Codex vs Claude comparative analysis
  • Keyword-based task classification
  • Agent interface design
  • Feedback loop implementation
  • Automatic Wiki Context injection

Performance Metrics

  • Simple CRUD: 67% time reduction
  • Complex security logic: 25% time reduction
  • Code review rounds: 60% reduction

Target Audience

  • Developers interested in AI Agent collaboration
  • Developers looking for advanced Claude/GPT API usage
  • Teams needing Multi-Agent system design experience

Part 5: Production Guide & Team Adoption

Subtitle: From 30-Minute Setup to 3-Month ROI

Main Content

  • 30-minute Quick Start guide
  • Daily workflow
  • Team adoption strategy (Pilot → Small Group → Full Team)
  • 15-person team case study
  • 3-month ROI analysis
  • Failure cases & solutions
  • FAQ & advanced tips

Quantitative Impact

  • Time saved: 2 hours per day per developer
  • Onboarding reduction: 50% for new hires
  • ROI: Break-even in 3.6 days
  • 3-month cumulative: 1,560 hours saved

Target Audience

  • Developers curious about actual usage
  • Leaders considering team adoption
  • Decision-makers needing ROI data

📊 Series Statistics

Overall Volume

  • Total words: ~25,000 words
  • Total code examples: 50+
  • Diagrams: 10+
  • Real experience: 3 months, 15-person team

Technical Depth

Part Technical Difficulty Target Audience

Part 1 ⭐️ All developers
Part 2 ⭐️⭐️⭐️ Architects
Part 3 ⭐️⭐️⭐️⭐️ Python developers
Part 4 ⭐️⭐️⭐️⭐️ AI/ML engineers
Part 5 ⭐️⭐️ Team leaders

🎯 Recommended Reading Order

Pattern 1: Quick Understanding (30 minutes)

Part 1 (10 min) → Part 5 Quick Start (20 min)

→ Understand concept + start using immediately

Pattern 2: Full Understanding (2 hours)

Part 1 → Part 2 → Part 4 → Part 5 (Skip Part 3)

→ Strategy and usage focused, excluding implementation details

Pattern 3: Complete Mastery (4 hours)

Part 1 → Part 2 → Part 3 → Part 4 → Part 5

→ Fully understand including code implementation

Pattern 4: Team Adoption Review (1 hour)

Part 1 (relate to problem) → Part 5 (check ROI) → Part 2 (verify tech stack)

→ For decision-makers


🔗 Related Links

Project

Documentation

  • SETUP-GUIDE.md: Detailed installation guide
  • MULTI-AGENT.md: Multi-Agent setup guide
  • CUSTOMIZATION.md: Customization guide
  • EXAMPLE-SETUP.md: Real team cases

💡 Key Insights

From Part 1

"In the LLM era, 'ability to provide appropriate context to AI' is more competitive than 'fast coding'."

From Part 2

"The nested structure preserving Confluence hierarchy matched developers' mental model and was effective."

From Part 3

"Rate limits, Korean character corruption, special character filenames... 90% of problems are unexpected in production."

From Part 4

"Codex for speed, Claude for quality. Collaborating them allowed us to take only each's strengths."

From Part 5

"Not forcing but recommending. When 3 people use it, it naturally spreads to 8 within 2 weeks."


🎬 Starting the Series

This series contains real experiences including failures and trial-and-error.

  • ❌ Not "perfect design" → ✅ "design that works in production"
  • ❌ Not "theoretical optimization" → ✅ "tangible improvements"
  • ❌ Not "cool demo" → ✅ "results from 15 people using for 3 months"

Goals:

  1. Provide inspiration to developers facing similar problems
  2. Share reusable open source project
  3. Discuss knowledge management methods in LLM era

📬 Feedback & Questions


Written: May 2025 (after 3 months of operation)
Author: @ggthename
Project Version: v1.0


🙏 Acknowledgments

  • Andrej Karpathy: Inspiration for LLM Knowledge Base architecture
  • Obsidian Community: Markdown knowledge management best practices
  • Data Services Team: Pilot participation and valuable feedback
  • Open Source Community: Bug reports and improvement suggestions

Thank you for reading!
Now start with Part 1: Why I Built This.


Last Updated: 2025-05-19

LLM 시대의 Second Brain 구축기 (1/5): 왜 만들게 되었나

TL;DR

LLM 시대에 Single Source of Truth의 중요성이 더욱 커졌습니다. 여러 프로젝트를 넘나들며 작업할 때 필요한 맥락(Context)을 AI에게 효과적으로 전달하기 위해, 팀 Wiki(Confluence)와 로컬 저장소(Obsidian)를 연결하고, 여러 AI Agent를 협업시키는 시스템을 만들었습니다.


🤔 문제의 시작: "이게 어디 있었더라?"

어느 날, 5개의 프로젝트를 동시에 작업하던 중 문제가 생겼습니다.

프로젝트 A (Flink 스트림 처리)
  ↓ 참조
프로젝트 B (Spring Boot API)
  ↓ 사용
프로젝트 C (Airflow 파이프라인)

Airflow DAG를 작성하는데, 참조해야 할 SQL 쿼리가 프로젝트 B의 문서에 있었고, 그 쿼리의 배경은 지난주 팀 미팅에서 결정되었습니다.

문제는:

  1. Confluence를 10분간 뒤져야 했고
  2. 찾은 문서가 최신인지 확신할 수 없었으며
  3. AI(Claude, GPT)에게 맥락을 설명하는데 또 10분이 걸렸습니다

하루에 이런 일이 5번 반복되면? 1시간이 증발합니다.


💡 깨달음: LLM 시대의 Single Source of Truth

LLM 이전 vs 이후

LLM 이전 (2022년):

  • 개발자가 문서를 읽고 → 코드를 작성
  • 문서는 사람을 위한 것
  • 검색은 "내가 기억하는 키워드"에 의존

LLM 이후 (2024년~):

  • AI가 문서를 읽고 → 코드를 생성
  • 문서는 사람 + AI를 위한 것
  • 검색은 "AI가 이해할 수 있는 형식"이 중요

Single Source of Truth의 새로운 의미

과거에는 "여러 곳에 중복 저장하지 마라"는 의미였다면,
이제는 "AI가 접근 가능한 단일 지식 저장소"를 의미합니다.

팀 위키 (Confluence)
  ↓ 자동 동기화
로컬 지식 베이스 (Obsidian)
  ↓ AI 맥락 주입
Claude / GPT / Codex
  ↓ 맥락 기반 코드 생성

🎯 해결하고 싶었던 3가지 문제

1️⃣ 멀티 프로젝트의 맥락 전환 비용

현실:

# 아침
~/project-A (Flink)    # 스트림 처리
  ↓
# 점심
~/project-B (Spring)   # REST API
  ↓
# 오후
~/project-C (Airflow)  # 데이터 파이프라인

각 프로젝트는 다른 문서를 참조합니다:

  • 프로젝트 A → Confluence 'ProjectA' Space (153개 문서)
  • 프로젝트 B → Confluence 'ProjectB' Space (65개 문서)
  • 프로젝트 C → 여러 Space 참조

문제: 프로젝트를 바꿀 때마다 "이 프로젝트의 아키텍처가 뭐였지?"를 다시 찾아야 합니다.

해결 방향: AI가 자동으로 관련 문서를 찾아서 맥락을 주입해주면 어떨까?


2️⃣ 팀 위키와 로컬 지식 베이스의 단절

팀의 지식은 Confluence에:

  • 아키텍처 문서
  • 주간 미팅 결과
  • 설계 결정 (RFC)
  • 운영 가이드

개인의 지식은 로컬에 (Obsidian, Notion, 메모):

  • 코드 스니펫
  • 디버깅 노트
  • 학습 자료

문제: 두 세계가 연결되지 않습니다.

  • Confluence는 브라우저에서만 접근 가능
  • AI에게 팀 지식을 전달하려면 복붙 반복

해결 방향: Confluence를 자동으로 Markdown으로 변환해서 Obsidian에 동기화하면 어떨까?


3️⃣ 단일 AI의 한계

Claude를 쓰다가 이런 생각이 들었습니다:

간단한 CRUD → Codex가 더 빠름
복잡한 보안 → Claude가 더 신중함

한 가지 AI만 쓰는 것은 비효율적입니다.

  • Codex: 빠른 구현, 패턴 기반 코드 생성
  • Claude: 아키텍처 검토, 엣지 케이스 고려

문제: 두 AI를 수동으로 번갈아 쓰면 시간이 더 걸립니다.

해결 방향: 여러 AI가 서로 피드백을 주고받으며 협업하면 어떨까?
Mixture of Experts (MoE) 패턴


🛠️ 해결책: LLM Knowledge Base 시스템

3가지 문제를 해결하기 위해 만든 것:

1. Confluence → Obsidian 자동 동기화

./tools/confluence/sync-space.sh MY_SPACE
  • Confluence의 모든 페이지를 Markdown으로 변환
  • 이미지, 다이어그램 자동 다운로드
  • Confluence 링크 → [[Obsidian 링크]] 변환
  • 증분 동기화 지원 (변경된 페이지만)

2. Wiki Context 자동 주입

# wiki_context.py
def search_wiki_context(task: str):
    # 1. task에서 키워드 추출
    # 2. Obsidian vault 검색
    # 3. 관련 문서 찾기
    # 4. AI 프롬프트에 맥락 주입

3. Multi-Agent Orchestration (MoE)

# .moe-config
simple_code:
  primary: codex       # 빠른 실행
  validator: claude
  
complex_code:
  primary: claude      # 견고한 설계
  validator: codex

📊 실제 효과 (3개월 사용 후)

시간 절약

활동 이전 이후 절약

Wiki 검색 10분 0초 (자동) 50분/일
맥락 전환 5분 유지됨 15분/일
코드 리뷰 2-3 라운드 1 라운드 40분/주

총 절약: 개발자 1명당 하루 2시간

품질 향상

  • ✅ 팀 아키텍처 준수율
  • ✅ 미팅 결정사항 반영률
  • ✅ 크로스 프로젝트 맥락 유지
  • ✅ 멀티 에이전트 리뷰로 엣지 케이스 발견

🎬 다음 편 예고

2편: 아키텍처 설계 - Single Source of Truth를 만드는 법

  • Confluence → Markdown 변환 파이프라인
  • Obsidian 연동 전략
  • 링크 변환 / 이미지 처리 / 증분 동기화

📌 프로젝트 정보


시리즈 목차

  1. 왜 만들게 되었나 (현재 글)
  2. 아키텍처 설계 - Single Source of Truth 구축
  3. Confluence to Obsidian 동기화 구현
  4. Multi-Agent Orchestration (MoE) 전략
  5. 실전 활용 가이드 & 팀 도입 경험

다음 편에서는 Confluence의 복잡한 HTML을 어떻게 Obsidian Markdown으로 변환했는지, 링크 관계를 어떻게 유지했는지 자세히 다룹니다.

LLM 시대의 Second Brain 구축기 - 시리즈 목차

llm-knowledge-base: Confluence를 Obsidian과 연동하고, Multi-Agent로 AI 협업하는 Second Brain 시스템


📚 시리즈 개요

LLM 시대에 Single Source of Truth가 더욱 중요해졌습니다. 여러 프로젝트를 넘나들며 작업할 때, 팀 Wiki(Confluence)의 맥락을 AI에게 효과적으로 전달하기 위한 시스템을 만들고, 활용한 경험을 공유합니다.

핵심 키워드:

  • Single Source of Truth
  • Second Brain
  • Confluence to Obsidian
  • Multi-Agent Orchestration (MoE)
  • Knowledge Management

🗂️ 시리즈 구성

1편: 왜 만들게 되었나

부제: 문제의 발견과 해결 동기

주요 내용

  • LLM 시대의 Single Source of Truth
  • 멀티 프로젝트 작업의 맥락 전환 비용
  • 팀 Wiki와 로컬 지식 베이스의 단절
  • 단일 AI의 한계와 MoE 필요성

핵심 문제

  1. Wiki 검색 시간: 하루 50분 소모
  2. 맥락 전환: 프로젝트마다 관련 문서 찾기 반복
  3. AI 선택: Codex vs Claude, 매번 고민

타겟 독자

  • 여러 프로젝트를 작업하는 개발자
  • 팀 Wiki를 자주 참조하는 개발자
  • LLM 활용에 관심 있는 개발자

2편: Single Source of Truth 아키텍처 설계

부제: Confluence → Obsidian 파이프라인

주요 내용

  • 전체 시스템 아키텍처
  • Confluence REST API 활용
  • HTML → Markdown 변환 전략
  • 링크 변환 / 이미지 처리
  • 증분 동기화 설계

기술 스택

  • Python (requests, BeautifulSoup, html2text)
  • Confluence REST API
  • Obsidian (Markdown)
  • Bash Script

타겟 독자

  • 시스템 아키텍처에 관심 있는 개발자
  • Confluence API 활용 예제 찾는 개발자
  • 문서 변환 파이프라인 구축 필요한 팀

3편: Confluence 동기화 구현 상세

부제: Python 코드 라인 바이 라인 분석

주요 내용

  • Confluence API 클라이언트 구현
  • 페이지 트리 크롤링 (DFS)
  • HTML 전처리 (Confluence 매크로 처리)
  • Markdown 변환 엔진
  • 첨부파일 다운로더 (이미지, Draw.io, Gliffy)
  • Frontmatter 생성
  • 트러블슈팅 & 최적화

실전 경험

  • Rate Limit 처리
  • 한글 깨짐 해결
  • 대용량 페이지 메모리 최적화
  • 특수문자 파일명 처리

타겟 독자

  • 실제 구현 코드가 궁금한 개발자
  • Python으로 웹 크롤링/변환 프로젝트 진행 중인 개발자
  • 오픈소스 기여에 관심 있는 개발자

4편: Multi-Agent Orchestration 전략

부제: Mixture of Experts (MoE) 패턴

주요 내용

  • MoE (Mixture of Experts) 개념
  • Codex vs Claude 비교 분석
  • 키워드 기반 Task 분류
  • Agent 인터페이스 설계
  • 피드백 루프 구현
  • Wiki Context 자동 주입

타겟 독자

  • AI Agent 협업에 관심 있는 개발자
  • Claude/GPT API 고급 활용 찾는 개발자
  • Multi-Agent 시스템 설계 경험 필요한 팀

5편: 실전 활용 가이드 & 팀 도입 경험

부제: 30분 셋업부터 3개월 ROI까지

주요 내용

  • 30분 Quick Start 가이드
  • 일일 워크플로우
  • 팀 도입 전략 (Pilot → Small Group → Full Team)
  • 팀 도입 사례
  • 3개월 ROI 분석
  • 실패 사례 & 해결책
  • FAQ & 고급 활용 팁

정량적 효과

  • 시간 절약: 개발자당 하루 2시간
  • 온보딩 단축: 신규 입사자 30% 단축
  • ROI: 3일 만에 Break-even

타겟 독자

  • 실제 사용 방법이 궁금한 개발자
  • 팀 리더,의사결정자

 


🔗 관련 링크

프로젝트

문서

  • SETUP-GUIDE.md: 상세 설치 가이드
  • MULTI-AGENT.md: Multi-Agent 설정 가이드
  • CUSTOMIZATION.md: 커스터마이징 가이드
  • EXAMPLE-SETUP.md: 실제 팀 사례

💡 핵심 인사이트

1편에서

"LLM 시대에는 '빠른 코딩'보다 'AI에게 적절한 맥락을 제공하는 능력'이 경쟁력이다."

2편에서

"Confluence의 계층 구조를 유지하는 Nested 구조가 개발자의 멘탈 모델과 일치하여 효과적이었다."

3편에서

"Rate Limit, 한글 깨짐, 특수문자 파일명... 실전에서는 예상치 못한 문제가 90%다."

4편에서

"Codex는 속도, Claude는 품질. 둘을 협업시키니 각자의 장점만 취할 수 있었다."

5편에서

"강제가 아닌 권장. 3명이 쓰면 2주 내 자연스럽게 8명으로 확산되었다."


🎬 시리즈를 시작하며

이 시리즈는 실패와 시행착오를 포함한 진짜 경험을 담았습니다.

  • ❌ "완벽한 설계"가 아닌 → ✅ "실전에서 작동하는 설계"
  • ❌ "이론적 최적화"가 아닌 → ✅ "체감 가능한 개선"
  • ❌ "멋진 데모"가 아닌 → ✅ "3개월간 사용한 결과"

목표:

  1. 같은 문제를 겪는 개발자에게 영감 제공
  2. 재사용 가능한 오픈소스 프로젝트 공유
  3. LLM 시대의 지식 관리 방법 논의

참고

  • Andrej Karpathy: LLM Knowledge Base 아키텍처 영감
  • Obsidian Community: Markdown 지식 관리 베스트 프랙티스

읽어주셔서 감사합니다!
이제 1편: 왜 만들게 되었나부터 시작해보세요.


 

  최근 실시간 이벤트 처리 파이프라인에서 멀티채널 선택 기능을 구현하면서 Strategy 패턴과 Template Method 패턴을 조합해서 사용하게 되었는데요, 두 패턴을 함께 사용하용한 구조를 공유하고자 합니다.

 

<현황>

  현재 운영 중인 시스템은 1 Scenario = 1 Channel = 1 Campaign 구조입니다.

  시나리오 A → A 채널로만 발송

  시나리오 B → B 채널로만 발송

  시나리오 C → C 채널로만 발송

  하나의 시나리오에서 여러 채널을 사용하려면 외부 캠페인 시스템에서 각 채널마다 별도 Campaign을 만들어야 하고, 사용자별로 최적 채널을 선택하는 로직도 없었습니다.

 

  문제는 비즈니스 요구사항이 점점 고도화되고 있다는 점입니다.

  - Phase 1: 단순 룰 기반 (시간/요일 제약 + 우선순위)

  - Phase 2: Score 기반 선택 (Redis/DynamoDB에서 사용자별 선호도 조회)

  - Phase 3: ML 모델 추론 (ONNX/SageMaker)

  - Phase 4: 온라인 러닝 (실시간 피드백 학습)

 

  각 Phase마다 선택 알고리즘이 완전히 다른데, 어떻게 확장 가능하게 설계할 것인가?

  ---

  <해결해야 하는 점>

  1. 선택 전략의 다양화

  - v1.0: Priority 기반 (낮은 숫자 = 높은 우선순위)

  - v2.0: Score 기반 (사용자별 시간대×채널 선호도)

  - v3.0: ML 모델 기반 (Feature Engineering + Inference)

  - v4.0: Online Learning (Contextual Bandit)

  Strategy 패턴으로 구현체를 쉽게 교체할 수 있어야 함

 

  2. 공통 로직의 중복

  모든 버전에서 시간/요일 제약 필터링은 동일하게 적용되어야 합니다.

  - A : 평일 9-18시만 활성

  - B : 매일 9-18시만 활성

  - C : 24시간 활성

 

  이 필터링 로직을 각 구현체에서 복사/붙여넣기 하면:

  - 코드 중복 (DRY 위반)

  - 실수로 필터링을 건너뛸 위험

  - 필터링 로직 변경 시 모든 구현체 수정 필요

  Template Method 패턴으로 공통 흐름을 강제해야 함

 

  3. Flink 분산 환경

  Apache Flink는 JobManager → TaskManager로 ProcessFunction을 직렬화해서 전송합니다.

  - RuntimeContext는 직렬화 불가능 (NotSerializableException)

  - 각 TaskManager에서 GlobalJobParameters를 다시 로드해야 함

 

  → transient 키워드로 직렬화 문제 해결 필요

 

<개요>

  Strategy Pattern + Template Method Pattern 조합

  ChannelSelector (인터페이스) ← Strategy Pattern

       

  AbstractChannelSelector (추상 클래스) ← Template Method Pattern

        ├─ selectChannel() [final] - 전체 흐름 강제

          ├─ Step 1: 시간/요일 필터링 (공통)

          └─ Step 2: selectFromActiveChannels() [abstract]

        ├─ transient RuntimeContext

        └─ transient Map<String, ChannelRule>

       

  구현체들:

    ├─ RuleBasedChannelSelector (v1.0)

    ├─ ScoreBasedChannelSelector (v2.0)

    ├─ MLModelChannelSelector (v3.0)

    └─ OnlineLearningChannelSelector (v4.0)

 

  핵심 아이디어:

  1. Strategy 패턴: 선택 알고리즘을 인터페이스로 추상화 → 구현체 교체 가능

  2. Template Method 패턴: 공통 흐름(시간/요일 필터링)을 final 메서드로 강제 → 중복 제거

  3. transient 키워드: RuntimeContext 등 직렬화 불가능 필드 제외 → Flink 안전성

 

< Before: 중복 로직 포함>

@Slf4j

  public class RuleBasedChannelSelector implements ChannelSelector {

      protected RuntimeContext runtimeContext;  // ❌ 직렬화 문제
      protected Map<String, ChannelRule> channelRules;

      @Override
      public ChannelSelectionResult selectChannel(
              AbstractEvent event,
              List<ChannelCampaignInfo> channels,
              ChannelSelectorContext context) {

          // Step 1: 시간/요일 필터링 (v1.0 전용 구현)
          List<ChannelCampaignInfo> activeChannels = channels.stream()
              .filter(channel -> {
                  ChannelRule rule = channelRules.get(channel.getChannelType());
                  if (rule == null) return true;

                  // 시간 체크
                  if (rule.allowedHours != null && !rule.allowedHours.isEmpty()) {
                      if (!rule.allowedHours.contains(context.getHourOfDay())) {
                          return false;
                      }
                  }
		           // 요일 체크
                  if (rule.allowedDays != null && !rule.allowedDays.isEmpty()) {
                      if (!rule.allowedDays.contains(context.getDayOfWeek())) {
                          return false;
                      }
                  }
                  return true;
              })
              .collect(Collectors.toList());

          if (activeChannels.isEmpty()) {
              return ChannelSelectionResult.builder()
                  .selectionReason("NO_ACTIVE_CHANNELS")
                  .build();
          }

          // Step 2: Priority 기반 선택 (v1.0 특화)
          ChannelCampaignInfo selected = activeChannels.stream()
              .min(Comparator.comparingInt(ChannelCampaignInfo::getPriority))
              .orElse(null);

          return ChannelSelectionResult.builder()
              .selectedChannel(selected)
              .selectionReason("RULE_BASED_PRIORITY")
              .confidence(1.0)
              .build();
      }
  }

   문제점:

  - (시간/요일 필터링 + Priority 선택 로직 혼재)

  - v2.0/v3.0 추가 시 필터링 로직을 복사/붙여넣기 해야 함

  - 실수로 필터링을 건너뛸 위험

  - RuntimeContext 직렬화 문제

  

  <After: Template Method + Strategy 조합>

  1. 인터페이스 (Strategy Pattern)

public interface ChannelSelector extends Serializable {
      void initialize(RuntimeContext runtimeContext) throws Exception;

      ChannelSelectionResult selectChannel(
          AbstractEvent event,
          List<ChannelCampaignInfo> channels,
          ChannelSelectorContext context
      );

      String getVersion();  // v1.0, v2.0, v3.0, ...
      void cleanup() throws Exception;
  }

    2. 추상 클래스 (Template Method Pattern)

  public abstract class AbstractChannelSelector implements ChannelSelector {

      // transient: 직렬화에서 제외 (각 TaskManager에서 initialize()로 재설정)
      protected transient RuntimeContext runtimeContext;
      protected transient Map<String, ChannelRule> channelRules;

      @Override
      public void initialize(RuntimeContext runtimeContext) throws Exception {
          this.runtimeContext = runtimeContext;
          // GlobalJobParameters에서 채널 룰 로드
          this.channelRules = loadChannelRulesFromProperties(
              runtimeContext.getGlobalJobParameters()
          );
      }
      /**
       * Template Method: 전체 흐름 강제 (final)
       */
      @Override
      public final ChannelSelectionResult selectChannel(
              AbstractEvent event,
              List<ChannelCampaignInfo> channels,
              ChannelSelectorContext context) {

          // Step 1: 공통 - 시간/요일 필터링
          List<ChannelCampaignInfo> activeChannels =
              filterActiveChannels(channels, context);

          if (activeChannels.isEmpty()) {
              return ChannelSelectionResult.builder()
                  .selectionReason("NO_ACTIVE_CHANNELS")
                  .build();
          }

          // Step 2: 하위 클래스 특화 로직 실행
          return selectFromActiveChannels(activeChannels, event, context);
      }
      
      /**
       * 공통 필터링 로직 (모든 버전에서 동일)
       */
      protected List<ChannelCampaignInfo> filterActiveChannels(
              List<ChannelCampaignInfo> channels,
              ChannelSelectorContext context) {

          return channels.stream()
              .filter(channel -> isChannelActiveByTimeAndDay(channel, context))
              .collect(Collectors.toList());
      }

      /**
       * 하위 클래스가 구현: 활성 채널 중에서 최종 선택
       */
      protected abstract ChannelSelectionResult selectFromActiveChannels(

          List<ChannelCampaignInfo> activeChannels,
          AbstractEvent event,
          ChannelSelectorContext context

      );

  }

  핵심:

  - selectChannel() 메서드를 final로 선언 → 하위 클래스가 override 불가

  - 전체 흐름을 강제: Step 1 (필터링) → Step 2 (선택)

  - selectFromActiveChannels()abstract → 하위 클래스가 구현

 

  3. v1.0 구현체 (Rule-Based)

  public class RuleBasedChannelSelector extends AbstractChannelSelector {

      @Override
      public String getVersion() {
          return "v1.0";
      }

      /**
       * v1.0 특화: Priority 기반 선택
       * (시간/요일 필터링은 Template Method가 자동 처리!)
       */
      @Override
      protected ChannelSelectionResult selectFromActiveChannels(
              List<ChannelCampaignInfo> activeChannels,
              AbstractEvent event,
              ChannelSelectorContext context) {

          // Priority 순으로 정렬 (낮은 숫자 = 높은 우선순위)
          ChannelCampaignInfo selected = activeChannels.stream()
              .min(Comparator.comparingInt(ChannelCampaignInfo::getPriority))
              .orElse(null);

          return ChannelSelectionResult.builder()
              .selectedChannel(selected)
              .selectionReason("RULE_BASED_PRIORITY")
              .confidence(1.0)
              .metadata(Map.of(
                  "priority", selected.getPriority(),
                  "activeChannelCount", activeChannels.size()
              ))
              .build();
      }
  }

    4. v2.0 구현체 (Score-Based) - 예시

  @Slf4j

  public class ScoreBasedChannelSelector extends AbstractChannelSelector {
      private transient FeatureStoreClient featureStoreClient;

      @Override
      public void initialize(RuntimeContext runtimeContext) throws Exception {
          super.initialize(runtimeContext);

          // Feature Store 클라이언트 초기화 (Redis/DynamoDB)
          String featureStoreUrl = runtimeContext.getGlobalJobParameters()
              .get("FEATURE_STORE_URL", "redis://localhost:6379");
          this.featureStoreClient = new RedisFeatureStoreClient(featureStoreUrl);
      }

      @Override
      public String getVersion() {
          return "v2.0";
      }

      /**
       * v2.0 특화: Score 기반 선택
       * (시간/요일 필터링은 Template Method가 자동 처리!)
       */
      @Override
      protected ChannelSelectionResult selectFromActiveChannels(

              List<ChannelCampaignInfo> activeChannels,
              AbstractCatchEvent event,
              ChannelSelectorContext context) {

          // Feature Store에서 사용자별 채널 Score 조회
          String timeSlot = context.getTimeSlot();  // "morning", "afternoon", "evening"
          Map<String, Double> scores = featureStoreClient.getChannelScores(
              event.getSvcMgmtNum(),
              timeSlot
          );

          // Score 기반 선택
          ChannelCampaignInfo bestChannel = activeChannels.stream()
              .max(Comparator.comparingDouble(ch ->
                  scores.getOrDefault(ch.getChannelType(), 0.5)))
              .orElse(null);

          double score = scores.get(bestChannel.getChannelType());
          return ChannelSelectionResult.builder()
              .selectedChannel(bestChannel)
              .selectionReason("SCORE_BASED_SELECTION")
              .confidence(score)
              .metadata(Map.of("timeSlot", timeSlot, "score", score))
              .build();
      }
  }

  v2.0 추가 시 장점:

  - 시간/요일 필터링 로직을 다시 작성할 필요 없음! (Template Method가 자동 처리)

  - selectFromActiveChannels() 구현만 집중하면 됨

  - 필터링을 실수로 건너뛸 수 없음 (final 메서드로 강제)

 

  <효과>

  1. 코드 간소화 (65% 감소)

  - Before: 212줄 (중복 로직 포함)

  - After: 73줄 (특화 로직만)

  - 139줄 제거, 65% 코드 감소

 

  2. 확장 용이성

  v2.0/v3.0 추가 시:  // ✅ 이것만 구현하면 됨!

  protected abstract ChannelSelectionResult selectFromActiveChannels(

      List<ChannelCampaignInfo> activeChannels,

      AbstractEvent event,

      ChannelSelectorContext context

  );

  - 시간/요일 필터링은 AbstractChannelSelector가 자동 처리

  - 각 버전의 특화 로직만 구현 , 간결하게 구현 가능

 

  3. 안전성 보장

  // final로 선언 → override 불가

  public final ChannelSelectionResult selectChannel(...) {

      // Step 1: 필터링 (건너뛸 수 없음)

      // Step 2: 선택 (하위 클래스 구현)

  }

  - 모든 버전에서 일관된 흐름 보장

  - 필터링을 실수로 건너뛰는 버그 방지

 

  4. Flink 분산환경 안전성

  protected transient RuntimeContext runtimeContext;

  protected transient Map<String, ChannelRule> channelRules;

 

  동작 방식:

  JobManager (배포 시)

    → ProcessFunction 직렬화 (transient 필드 = null)

    → TaskManager로 전송

 

  TaskManager (각 병렬 Task)

    open() 호출

    → channelSelector.initialize(getRuntimeContext())

    → GlobalJobParameters에서 channelRules 재로드

    모든 Task가 일관된 채널 선택

 

 

  <마무리>

  실무에서 디자인패턴을 적용할 때 "패턴을 위한 패턴"이 되지 않도록 주의해야 합니다.

 

  이번 경우는:

  1. Strategy 패턴: 선택 알고리즘이 v1.0 → v2.0 → v3.0으로 진화 예정 → 구현체 교체 필요

  2. Template Method 패턴: 모든 버전에서 시간/요일 필터링은 동일 → 공통 흐름 강제 필요

  3. transient 키워드: Flink 분산환경에서 직렬화 문제 → 안전성 확보 필요

 

  이렇게 실제 요구사항에서 자연스럽게 패턴이 도출되었습니다.

 

  결과적으로:

  - 코드가 65% 줄어들고

  - 새 버전 추가가 쉬워지고

  - 버그 발생 가능성이 낮아지고

  - Flink 분산환경에서 안전하게 동작

 

  "좋은 설계는 변경을 쉽게 만든다" 다시 한번 실감하게 되었습니다.

 

  

  <참고>

  - GoF Design Patterns - Strategy, Template Method

  - Effective Java - Item 20: Prefer interfaces to abstract classes

  - Apache Flink - State & Fault Tolerance

4. Factory 패턴 단순화

  4.1 불필요한 Config 클래스 제거

 public class FatigueRepositoryFactory {

      public static class FatigueRepositoryConfig {

          private String redisUri;
          private String dynamoDbTableName;
          private ObjectMapper objectMapper;

          public FatigueRepositoryConfig() {
              this.objectMapper = new ObjectMapper();  // 자동 생성
          }

          public FatigueRepositoryConfig objectMapper(ObjectMapper mapper) {
              this.objectMapper = mapper;  // 그런데 또 주입?
              return this;
          }

          // ... 80줄의 builder 코드

      }

      public static AsyncFatigueRepository create(RepositoryType type, Config config) {
          // 간접 호출
      }
  }

  // 사용
  ObjectMapper objectMapper = new ObjectMapper();
  Config config = new Config()
      .objectMapper(objectMapper)  // 이미 생성자에서 만들었는데?
      .redisUri(uri);

  repository = Factory.create(REDIS, config);

   문제점:

  - ObjectMapper가 이중으로 생성됨

  - Config가 불필요한 복잡성 추가

  - 설정과 생성이 분리되어 혼란스러움

 

  After: 단순한 Factory

public class FatigueRepositoryFactory {

      public static AsyncFatigueRepository createRedis(String redisUri) {
          RedisClient redisClient = RedisClient.create(redisUri);
          return new RedisAsyncFatigueRepository(redisClient);
      }

      public static AsyncFatigueRepository createDynamoDB(
              String tableName, String region, String endpointOverride) {
          DynamoDbAsyncClientBuilder builder = DynamoDbAsyncClient.builder()
                  .region(Region.of(region));

          if (endpointOverride != null) {
              builder.endpointOverride(URI.create(endpointOverride));
          }
          DynamoDbAsyncClient client = builder.build();
          return new DynamoDBAsyncFatigueRepository(client, tableName);
      }
  }
  // 사용
  repository = Factory.createRedis(redisUri);  // 간단!

  개선점:

  - ObjectMapper가 Repository 내부에서 관리됨

  - 직관적이고 명확한 API

 

  4.2 ProcessFunction에서의 사용

public class ExternalFatigueControlProcessFunction extends AbstractFatigueProcessFunction {
      transient AsyncFatigueRepository fatigueRepository;

      @Override
      public void open(OpenContext ctx) throws Exception {

          super.open(ctx);
          String repositoryType = getRuntimeContext()
                  .getGlobalJobParameters()
                  .getOrDefault("REPOSITORY_TYPE", "REDIS");

          if (repositoryType.equals("REDIS")) {
              String redisUri = getRuntimeContext()
                      .getGlobalJobParameters()
                      .getOrDefault("REDIS_URL", "redis://localhost:6379");
              this.fatigueRepository = FatigueRepositoryFactory.createRedis(redisUri);

          } else if (repositoryType.equals("DYNAMODB")) {
              String tableName = getRuntimeContext()
                      .getGlobalJobParameters()
                      .getOrDefault("DYNAMODB_TABLE_NAME", "fatigue-table");

              String region = getRuntimeContext()
                      .getGlobalJobParameters()
                      .getOrDefault("DYNAMODB_REGION", "ap-northeast-2");

              String endpoint = getRuntimeContext()
                      .getGlobalJobParameters()
                      .getOrDefault("DYNAMODB_ENDPOINT", null);

              this.fatigueRepository = FatigueRepositoryFactory.createDynamoDB(
                      tableName, region, endpoint);

          }
          log.info("Repository initialized: type={}", repositoryType);
      }

      @Override
      public void close() throws Exception {
          try {
              if (fatigueRepository != null) {
                  fatigueRepository.close();
              }
          } finally {
              super.close();
          }
      }
  }

  5. 실전 코드와 설정

  5.1 환경 변수 설정

  Redis 사용 시:

  REPOSITORY_TYPE=REDIS

  REDIS_URL=redis://your-redis-host:6379

 

  DynamoDB 사용 시:

  REPOSITORY_TYPE=DYNAMODB

  DYNAMODB_TABLE_NAME=fatigue-table

  DYNAMODB_REGION=ap-northeast-2

  DYNAMODB_ENDPOINT=  # 실제 AWS는 비워둠, 로컬 테스트는 http://localhost:8000

 

  5.2 DynamoDB 테이블 스키마

  테이블: 

  - PK (String): baseKey (Partition Key)

  - SK (String): suffix (Sort Key)

    - "f:g" (global)

    - "f:s:{scenarioCode}" (scenario)

    - "f:c:{channelType}" (channel)

  - data (String): FatigueValue JSON

  - ttl (Number): TTL epoch seconds (90일)

 

  5.3 리소스 close 

  ExternalFatigueControlProcessFunction.close()

    → fatigueRepository.close()

      ├─ RedisAsyncFatigueRepository.close()

        ├─ connection.close()

        └─ redisClient.shutdown()

      └─ DynamoDBAsyncFatigueRepository.close()

          └─ dynamoClient.close()

 

  중요 포인트:

  - 각 Repository가 자신의 리소스 정리 책임

  - ProcessFunction의 close()에서 repository.close() 호출

  - try-finally 패턴으로 안전한 정리 보장

 

  결론

  핵심 설계 원칙

  1. Flink의 분산 특성 이해

    - 각 Task는 독립적인 인스턴스

    - transient로 직렬화 제외

    - open()에서 초기화

  2. 적절한 공유 전략

    - ObjectMapper는 static final로 공유

    - 같은 TaskManager의 Task들이 공유하여 메모리 효율

    - mutable 객체는 camelCase + static 블록

  3. 단순한 설계

    - 불필요한 Config 클래스 제거

    - Repository가 자신의 설정 관리

    - Factory는 최소한의 파라미터만

  4. 확장 가능한 구조

    - 인터페이스 기반 설계

    - 새 Repository 추가 시 기존 코드 수정 불필요

    - 환경 변수로 런타임 선택

 

  성능 개선 효과

  | 항목              | Before          | After          |

  |-------------------|-----------------|----------------|

  | ObjectMapper 생성 | Parallelism × N | TaskManager 수 |

  | 코드 라인 수      | ~250줄          | ~170줄         |

  | API 복잡도        | 4-5개 파라미터  | 2-3개 파라미터 |

  | 메모리 사용       | 높음            | 낮음           |

 

  마치며

  - 프레임워크 특성 이해의 중요성: Flink의 동작 방식을 모르면 비효율적인 코드 작성

  - 단순함의 가치: Config 클래스 같은 과도한 추상화 제거

  - 네이밍의 중요성: 올바른 네이밍이 의도를 명확히 전달

  - 프로젝트 일관성: 기존 코드 패턴 준수가 유지보수성 향상

 

  Flink 환경에서 외부 저장소를 연동할 때 이 글이 도움이 되길 바랍니다!

  ---

  참고 자료:

  - Apache Flink Documentation - ProcessFunction

  - Jackson Documentation 

  - Effective Java 3rd Edition 

  - Spring Framework Source Code 

+ Recent posts