<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>JS1024</title>
    <link>https://zks145.tistory.com/</link>
    <description>혼자 공부하다 까먹을까봐 적어두는 곳</description>
    <language>ko</language>
    <pubDate>Wed, 1 Jul 2026 05:11:38 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>js1024</managingEditor>
    <image>
      <title>JS1024</title>
      <url>https://tistory1.daumcdn.net/tistory/5261834/attach/b5fe2cd560b743398724d86f15d15da3</url>
      <link>https://zks145.tistory.com</link>
    </image>
    <item>
      <title>Kafka MSA 통신 및 데이터 트랜잭션 보완</title>
      <link>https://zks145.tistory.com/156</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;RabbitMQ가 아닌 Kafka를 선택한 이유&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;지난 MSA 모듈 간 통신 과정에서 X-Tenant-ID 헤더를 통해 Gateway부터 내부 마이크로서비스까지 요청의 Context를 전파하고 동적 스키마 라우팅을 구현하는 방법에 대해 작성했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;하지만 MSA 환경에서 데이터 무결성을 보장하는 것은 다른 주제 입니다. 주문 서비스의 DB와 정산 서비스가 DB가 물리적으로 분리되어 있기 때문에 기존 트랜잭션을 이용해 ACID 보장을 할 수 없습니다. (원자성, 일관성, 고립성, 영속성)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;동일한 message broker를 선정하는 과정에서 고려한 부분은 데이터의 처리 방식과 주요 강점 입니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 102px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 34px;&quot;&gt;
&lt;td style=&quot;width: 15.8914%; height: 34px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;특징&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.0543%; height: 34px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;RabbitMQ (Message Broker)&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 47.0542%; height: 34px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;Apache Kafka (Event Streaming Platform)&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 15.8914%; height: 17px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;설계&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.0543%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Smart Broker, Dumb Consumer&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;(브로커가 라우팅 등 복잡한 일을 처리)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 47.0542%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Dumb Broker, Smart Consumer&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;(컨슈머가 데이터를 읽어가고 처리 위치를 관리)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 15.8914%; height: 17px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;데이터 처리&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.0543%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;메시지 소비 후&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;삭제됨&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;&amp;nbsp;(휘발성)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 47.0542%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;로그 파일에&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;저장됨&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;&amp;nbsp;(영속성, 설정 기간 동안 보관)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 15.8914%; height: 17px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;통신 방식&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.0543%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;Push 방식 (브로커가 컨슈머에게 밀어넣음)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 47.0542%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;Pull 방식 (컨슈머가 브로커에서 가져옴)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 15.8914%; height: 17px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;주요 강점&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.0543%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;복잡한 라우팅, 우선순위 큐, 신뢰성 있는 전달&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 47.0542%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;압도적인 처리량&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;이벤트 재생&lt;/span&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RabbitMQ의 경우 메시지가 소요되는 순간 메시지가 사라지지만 Kafka의 경우 별도 로그처리가 가능해 희박하지만 통신 중 데이터 유실 및 데이터 처리 과정 중 통신 오류로 인한 재처리가 용의하다는 점에 선택의 영향을 주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 하지만 이와 반대로 재처리가 가능하기에 개발자 실수로 다른 데이터 셋 혹은 양식을 따를 경우 무한 루프에 빠질 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RabbitMq의 경우 Header 통한 X-Tenant-ID 처리가 불가능하기 때문에 기존 처리 방식과 다른 Dto 내에 TenantId 값을 포함해 처리하는 방식을 고정할 수 밖에 없다. 반면 Kafka의 경우 별도 Header 값을 포함 시켜 이벤트 생성이 가능해 일관성 있는 처리를 할 수 있다는 점을 고려하였습니다. (메타 데이터(TenantId, Jwt)와 비지니스 데이터 분리)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764603160805&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void createOrder(OrderDto orderDto) {
    String tenantId = TenantContext.getTenantId();

    Message&amp;lt;OrderDto&amp;gt; message = MessageBuilder
            .withPayload(orderDto)
            .setHeader(KafkaHeaders.TOPIC, &quot;order-created&quot;)
            .setHeader(&quot;X-Tenant-ID&quot;, tenantId)
            .build();

    kafkaTemplate.send(message);
}

@KafkaListener(topics = &quot;order-created&quot;, groupId = &quot;stock-service&quot;)
public void handleOrderCreated(
        @Payload OrderDto orderDto,
        @Header(&quot;X-Tenant-ID&quot;) String tenantId
) {
    try {
        TenantContext.setTenantId(tenantId);

        stockService.decreaseStock(orderDto.getProductId(), orderDto.getQty());

    } catch (Exception e) {
    	throw new IllegalStateException(&quot;Kafka consume error&quot;, e);
    } finally {
        // 5. Context 정리
        TenantContext.clear();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/156</guid>
      <comments>https://zks145.tistory.com/156#entry156comment</comments>
      <pubDate>Tue, 2 Dec 2025 00:37:53 +0900</pubDate>
    </item>
    <item>
      <title>MSA 환경에서 멀티테넌시 스키마 별 Transaction 관리하기</title>
      <link>https://zks145.tistory.com/155</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;MSA 멀티테넌시 환경에서의 스키마 별 트랜잭션 관리와 안정성 확보 전략&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이크로서비스 아키텍처를 구현하며 멀티 테넌시를 유실하지 않고 요청의 시작과 끝을 향해가는 과정에 사용자와 데이터를 유실하지 않고 트랜잭션을 유지하는 것을 말합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구성된 서버의 경우 Spring Cloud로 이뤄진 각각의 모듈 서버들 간 통신 과정에서 어떻게 데이터 정합성과 사용자 정보 유실 없이 안정성을 확보했는지에 대해 정리할 예정입니다.&lt;br /&gt;+ 서버의 데이터 베이스는 각각의 서버별 Tenant 기반 Schema 구분을 통해 사용자별 데이터를 저장하고 있습니다&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;테넌시 식별하는 방식과 GateWay와 X-Tenant-ID 전략 (실패)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티테넌시 전략의 핵심은 어떻게 사용자 요청을 식별하는 것입니다. 첫 방식은 서브 도메인을 이용해 추출하는 방식을 채택했습니다. 클라이언트가 허용된 서브도메인이 포함된 도메인 경로로 접속하면 Spring Cloud GateWay는 필터를 통해 SubDomain 값을 추출하고 이를 X-Tenant-ID라는 커스텀 헤더에 담아 다른 마이크서비스로 전달하는 방식을 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764580160911&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Gateway Filter
String host = exchange.getRequest().getHeaders().getFirst(&quot;Host&quot;);
String tenantId = extractSubdomain(host); // lim.kkhan.co.kr -&amp;gt; lim

request.mutate().header(&quot;X-Tenant-ID&quot;, tenantId).build();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 추후 클라이언트와 벡엔드 서버간 분리된 환경(서브도메인 분리)에서는 브라우저가 API를 서버 요청을 보낼 때 Host 값이 클라이언트가 아닌 서버 기반 도메인으로 설정되기 때문에 결국 해당 방식을 옳바른 서브 도메인 데이터를 추출할 수 없어 포기하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 방식&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;X-Tenant-ID라는 커스텀 헤더와 Jwt 토큰 내에 저장되어 있는 Tenantm, ip, Device 정보를 헤더 ID값과 비교해 검증하는 방식으로 변경하였습니다. 만약 해당 검증 수행 없이 커스텀 헤더의 Tenant 값을 믿고 검증 완료하는 경우 로그인 완료 후 다른 서브 도메인을 알아내어 클라이언트로 접근하는 순간 일부 데이터를 접속해 확인할 수 있는 문제가 발생할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764580470410&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (config.isVerifyTenant()) {
    try {
        String tokenTenantId = jwtUtil.getTenantId(token);
        String requestTenantId = exchange.getAttribute(TenantHeaderFilter.TENANT_ATTRIBUTE_KEY);

        if (requestTenantId == null) {
        requestTenantId = exchange.getRequest().getHeaders().getFirst(&quot;X-Tenant-ID&quot;);
        }

        log.debug(&quot;[AuthFilter] Tenant Check. Token: {}, Req: {}&quot;, tokenTenantId, requestTenantId);
        if (tokenTenantId != null &amp;amp;&amp;amp; !tokenTenantId.equals(requestTenantId)) {
            log.warn(&quot;[AuthFilter] Fail: Tenant Mismatch! ReqID: {}, TokenT: {}, ReqT: {}&quot;, reqId, tokenTenantId, requestTenantId);
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        	return exchange.getResponse().setComplete();
    	}
    } catch (Exception e) {
        log.error(&quot;[AuthFilter] Error during Tenant Check. ReqID: {}&quot;, reqId, e);
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }
}

..... Device, IP 검증 코드 추가되어 있음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;동적 스키마 라우팅과 트랜잭션 관리&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 식별된 Tenant-ID 값은 요청이 끝날 때까지 DB 커넥션을 결정하는 중요 키로 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 구현 방식으로 TenantID를 이용해 ThreadLocal에 Context를 격리 시켜 Hibernate의 SchemaMultiTenantConnectionProvider라는 커스텀 컴포넌트를 통해 데이터베이스의 스키마에 할당하는 동적으로 방식으로 데이터 쿼리 실행을 하게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764583772558&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@ManagedResource(objectName=&quot;com.msa:type=MultiTenant,bean=SchemaProvider&quot;)
public class SchemaMultiTenantConnectionProvider implements MultiTenantConnectionProvider {

 .... 

    @Override
    public Connection getConnection(Object tenantIdentifier) throws SQLException {
        Connection connection = getAnyConnection();
        String tenant = tenantIdentifier.toString().toLowerCase();

        if (!isValidTenantIdentifier(tenant)) {
            throw new SQLException(&quot;Invalid tenant identifier: &quot; + tenant);
        }

        if (!initializedTenants.contains(tenant)) {
            try (Statement stmt = connection.createStatement()) {
                stmt.execute(&quot;CREATE SCHEMA IF NOT EXISTS &quot; + tenant);
            }
            runMigration(tenant);
            initializedTenants.add(tenant);
        }

        try (Statement stmt = connection.createStatement()) {
            stmt.execute(&quot;SET search_path TO &quot; + tenant);
        }

        return connection;
    }

    @Override
    public void releaseConnection(Object tenantIdentifier, Connection connection) throws SQLException {
        try (Statement statement = connection.createStatement()) {
            // 기본 스키마로 복귀
            statement.execute(&quot;SET search_path TO &quot; + DEFAULT_SCHEMA);
        }
        connection.close();
    }

    private void runMigration(String tenant) {
        Flyway.configure()
                .dataSource(defaultDataSource)
                .schemas(tenant)
                .initSql(String.format(&quot;SET search_path TO '%s'&quot;, tenant))
                .locations(&quot;classpath:db/migration&quot;)
                .baselineOnMigrate(true)
                .load()
                .migrate();
    }
 ....
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 내부에서는 이러한 방식으로 트랜잭션의 스키마 호출을 실시합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;동기 통신의 안정성 FeignClient&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSA를 구성하면 필연적으로 서버간 내부 통신이 필요하게 됩니다. 이러한 서비스 간 통신 시에도 Tenant Context는 유지되어야 되는데 이를 위에 구성 방식과 동일한 X-Tenant-ID 커스텀 헤더를 통해 넣어 서비스 간 호출 시에도 일관된 Context를 유지할 수 있게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;비동기 데이터 정합성 Kafka 헤더의 활용&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 처리 부분은 메시지를 큐에 넣고나면 Consumer르 통해 스레드에서 실행되는 ThreadLocal 값이 사라지게 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Producer가 이벤트를 발행할 때 별도의 Tenant 값을 포함하는 방식으로 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Tenant&lt;span&gt; 값을 넣어 추출해 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;ThreadLocal&lt;span&gt; 저장해 처리하는 방식으로 데이터의 정합성을 유지하였습니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/155</guid>
      <comments>https://zks145.tistory.com/155#entry155comment</comments>
      <pubDate>Mon, 1 Dec 2025 19:15:01 +0900</pubDate>
    </item>
    <item>
      <title>Jpa Update 안됨, Dirty Check 오류, 다중 DataSource</title>
      <link>https://zks145.tistory.com/154</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 같은 트랜잭션 내에서 Dirty Check 오류가 발생해 엔티티 값이 갱신 되지 않는 문제에 봉착했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Update가 실행되지 않는 원인은 크게 3가지다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;값이 변경되지 않음&lt;/li&gt;
&lt;li&gt;엔티티 설정이&amp;nbsp; updateable = false&lt;/li&gt;
&lt;li&gt;트랜잭션 경계 오류&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;난 이중 크게 보면 3번의 원인에 해당된다 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 프로젝트 환경은 dataSource 설정을 통해 두개의 db를 연결해 사용하고 있고 우선 순위가 현재 엔티티를 관리하는 dataSource에 있는 것이 아니라 다른 dataSource 환경에 우선 순위가 주어지고 있어 Dirty Check가 다른 트랜잭션 매니저에게 먹혀 update가 누락된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인 순서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;376&quot; data-start=&quot;318&quot;&gt;@Primary가 meta TM에 걸려 도메인 JPA 로직도 meta TM로 처리됨.&lt;/li&gt;
&lt;li data-end=&quot;456&quot; data-start=&quot;377&quot;&gt;그래서 트랜잭션 커밋 시 JPA EntityManager와 동기화가 안 됨 &amp;rarr; &amp;ldquo;더티체킹 &amp;rarr; 플러시 &amp;rarr; UPDATE&amp;rdquo;가 생략.&lt;/li&gt;
&lt;li data-end=&quot;559&quot; data-start=&quot;457&quot;&gt;save()를 호출하면 JPA가 persist/merge 경로로 즉시 DB I/O를 수행(상황에 따라 flush/오토커밋 유발) &amp;rarr; 이때는 UPDATE가 보임.&lt;/li&gt;
&lt;li data-end=&quot;634&quot; data-start=&quot;560&quot;&gt;즉 우선순위 때문이 아니라 &amp;ldquo;커밋 시점 플러시가 안 도는 환경&amp;rdquo;이라 save를 호출했을 때만 DB에 반영된 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754982443340&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void updateOrderStore(String accessToken, Long orderId, StoreDto.Request storeDto) {
    String tenantId = jwtUtil.getTenantId(accessToken);
    Orders order = ordersRepository.findById(orderId)
            .orElseThrow(() -&amp;gt; new IllegalArgumentException(NOT_FOUND));

    StoreDto.Response storeInfo = storeClient.getStoreInfo(tenantId, storeDto.getStoreId());

    order.updateStore(storeInfo);
}

// 특별할 거 없는 order 내부 colum data Update&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실질적인 원인은 dataSource 우선순위 이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;기존 코드&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754982944068&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Profile(&quot;dev&quot;)
@Configuration
public class DataSourceConfig {

    @Bean
    @Qualifier(&quot;defaultDataSource&quot;)
    public DataSource defaultDataSource(DataSourceProperties properties) {
        HikariDataSource ds = properties
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();

        ds.setConnectionInitSql(&quot;SET SCHEMA PUBLIC&quot;);
        return ds;
    }

    @Bean
    public DataSource dataSource(
            @Qualifier(&quot;defaultDataSource&quot;) DataSource defaultDs) {
        return new DynamicDataSourceRouter(defaultDs);
    }
    
      
    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

@Profile(&quot;dev&quot;)
@Configuration
public class MetaDBConfig {

	@Primary
    @Bean(name = &quot;meatDataSource&quot;)
    @ConfigurationProperties(prefix = &quot;custom.datasource-meta&quot;)
    public DataSource metaDBSource() {
        return DataSourceBuilder.create().build();
    }

	@Primary
    @Bean(name = &quot;metaTxManager&quot;)
    public PlatformTransactionManager metaTransactionManager() {
        return new DataSourceTransactionManager(metaDBSource());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;수정 코드 - 우선 순위 변경&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754982974290&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Profile(&quot;dev&quot;)
@Configuration
public class DataSourceConfig {

    @Bean
    @Qualifier(&quot;defaultDataSource&quot;)
    public DataSource defaultDataSource(DataSourceProperties properties) {
        HikariDataSource ds = properties
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();

        ds.setConnectionInitSql(&quot;SET SCHEMA PUBLIC&quot;);
        return ds;
    }
    
	@Primary
    @Bean
    public DataSource dataSource(
            @Qualifier(&quot;defaultDataSource&quot;) DataSource defaultDs) {
        return new DynamicDataSourceRouter(defaultDs);
    }
    
    @Primary
    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

@Profile(&quot;dev&quot;)
@Configuration
public class MetaDBConfig {

    @Bean(name = &quot;meatDataSource&quot;)
    @ConfigurationProperties(prefix = &quot;custom.datasource-meta&quot;)
    public DataSource metaDBSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = &quot;metaTxManager&quot;)
    public PlatformTransactionManager metaTransactionManager() {
        return new DataSourceTransactionManager(metaDBSource());
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>개발 고민</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/154</guid>
      <comments>https://zks145.tistory.com/154#entry154comment</comments>
      <pubDate>Tue, 12 Aug 2025 16:17:42 +0900</pubDate>
    </item>
    <item>
      <title>Spring Cloud Api GateWay Global Filter(전역 필터)를 삭제한 이유</title>
      <link>https://zks145.tistory.com/153</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Spring Cloud를 이용한 프로젝트를 진행하며 Global Filter를 제외한 이유 4가지&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;전역 필터는 작은 변경에도 전체 트래픽에 영향을 줘 혼란을 준다.&lt;/li&gt;
&lt;li&gt;예외 경로가 생길 수 록 if/else와 같이 분기 코드가 추가되어야 한다.&lt;/li&gt;
&lt;li&gt;내부/외부 통신간의 보안 권한 분류가 깨진다.&lt;/li&gt;
&lt;li&gt;모든 요청에 대한 같은 작업을 반복하는 문제&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #333333; letter-spacing: 0px;&quot;&gt;1) 전역 예외 리스트&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 모듈 서버가 추가될 때 api 엔드포인트 별 예외 사항이 추가될 때 마다 라우트 별 필터에 해당 엔드 포인트 예외 사항을 일일히 추가해야 되었고 이 때문에 누락된 혹은 생각지 못한 오류가 계속 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입/로그인/토큰 재발급 같은 공개 엔드포인트가 늘수록 전역 필터엔 path.startsWith(...) 예외가 누적되고 경로 하나만 틀려도 인증 401 문제가 발생하였습니다. 라우트별 필터로 빼면 공개 라우트는 require: false로 &lt;b&gt;선언형&lt;/b&gt;으로 변경하면 예외 처리할 라우트와 아닌 라우트 구분이 쉽고 예외 사항에 대한 대응도 용의하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 레거시 코드&lt;/p&gt;
&lt;pre id=&quot;code_1754832753951&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class AuthorizationHeaderFilter implements GlobalFilter, Ordered {

    @Override
    public Mono&amp;lt;Void&amp;gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String path = exchange.getRequest().getURI().getPath();

        if (path.startsWith(&quot;/eureka&quot;) || path.startsWith(&quot;/actuator/**&quot;) || path.startsWith(&quot;/auth/reissue&quot;) || path.startsWith(&quot;/auth/login&quot;) || path.startsWith(&quot;/users/login&quot;) || path.startsWith(&quot;/users/signup&quot;)) {
            return chain.filter(exchange);
        }
        ....
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;- 개선된 코드&lt;/p&gt;
&lt;pre id=&quot;code_1754832830284&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- id: users-public
  predicates: [ Path=/users/login, Path=/users/signup ]
  filters:
    - StripPrefix=1
    - name: AuthorizationHeaderFilter
      args: { require: false }
      
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory&amp;lt;AuthorizationHeaderFilter.Config&amp;gt; {
    private final JwtUtil jwtUtil;

    public AuthorizationHeaderFilter(JwtUtil jwtUtil) {
        super(Config.class);
        this.jwtUtil = jwtUtil;
    }

    @Getter @Setter
    public static class Config {
        private boolean require = true;              // false면 인증 스킵 (공개 라우트)
        private boolean verifyDevice = true;         // UA 바인딩 검증
        private boolean verifyForwardedFor = true;   // X-Forwarded-For 바인딩 검증
        private String userIdHeader = &quot;X-User-ID&quot;;
        private int order = -1;
    }

    @Override
    public GatewayFilter apply(Config config) {
    	...
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;내부/외부 트래픽에 다른 보안 정책 적용&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에서는 보안 강화를 위해 jwt 검사 시 초기 device 정보와 ip 정보를 기록해 동일한 사용자인지 비교하는 기능도 있는데 내부 &amp;lt;-&amp;gt; 내부 서버간의 통신 과정에서는 해당 보안 정책을 제외하여 내부 서비스 토큰만을 이용해 보안 검사를 통과할 수 있게 설정해 기존 Header에 대한 문제를 해결 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전역 필터를 사용할 때는 보안 정책 상 어쩔 수 없이 내부망 통신 내에서도 Header 값을 가져와 추가해주고 api 요청을 하는 방식을 진행 했지만 프로젝트 정책 상 내부망 통신끼리는 추가 검사 없이 진행하는 것이 DRY 원칙을 지킨다 생각하였습니다.&lt;br /&gt;추가적으로 내부망 통신은 보안정책이 약화 되므로 철저히 내부망끼리 통신이 가능하게 만들어야 됩니다. 때문에 배포되는 도커 서버 내부 ip를 Config Server 설정을 통해 할당 받아 통신하는 방식으로 변경하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754833503886&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- id: users-public
  filters:
    - name: AuthorizationHeaderFilter
      args: { require: false }

- id: users-internal
  filters:
    - RemoveRequestHeader=Cookie
    - name: AuthorizationHeaderFilter
      args:
        require: true
        verifyDevice: false
        verifyForwardedFor: false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>개발 고민</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/153</guid>
      <comments>https://zks145.tistory.com/153#entry153comment</comments>
      <pubDate>Sun, 10 Aug 2025 22:47:32 +0900</pubDate>
    </item>
    <item>
      <title>멀티테넌시 스키마 분리 전략</title>
      <link>https://zks145.tistory.com/152</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;MSA 구조 설계 중 사용자 DB 관리에 사용된 방식으로 &lt;span style=&quot;text-align: start;&quot; data-huuid=&quot;10959682194881437803&quot;&gt;하나의 소프트웨어 인스턴스가 여러 사용자 그룹 또는 테넌트를 지원하도록 설계된 아키텍처입니다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot; data-huuid=&quot;10959682194881439938&quot;&gt;쉽게 말해 여러 사용자가 동일한 소프트웨어와 인프라를 공유하면서도 서로의 데이터나 설정을 간섭받지 않고 독립적으로 서비스를 이용할 수 있는 구조입니다. &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot; data-huuid=&quot;10959682194881439938&quot;&gt;다수의 이용자들을 하나의 애플리케이션 서비스를 제공하고 사용자별 데이터를 노출되지 않도록 완전 분리함.&amp;nbsp;&lt;br /&gt;&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot; data-huuid=&quot;10959682194881439938&quot;&gt;아키텍처 전체 흐름&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트 요청 수신&lt;/li&gt;
&lt;li&gt;필터/인터셉터에서 헤더에서 테넌트 식별자 추출(GateWay 서버에서 request에 추가되어 전달됨)&lt;/li&gt;
&lt;li&gt;Spring JPA 호출 시
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;507&quot; data-start=&quot;436&quot;&gt;DynamicDataSourceRouter가 TenantContext로부터 lookup key(테넌트 ID) 결정&lt;/li&gt;
&lt;li data-end=&quot;567&quot; data-start=&quot;511&quot;&gt;기존에 생성된 DataSource 반환 또는 신규 생성(createAndMigrate)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;567&quot; data-start=&quot;511&quot;&gt;Hibernate
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;744&quot; data-start=&quot;590&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;647&quot; data-start=&quot;590&quot;&gt;CurrentTenantIdentifierResolverImpl로부터 현재 테넌트 ID 획득&lt;/li&gt;
&lt;li data-end=&quot;727&quot; data-start=&quot;651&quot;&gt;SchemaMultiTenantConnectionProvider로부터 해당 스키마로 SET SCHEMA 처리된 커넥션 제공&lt;/li&gt;
&lt;li data-end=&quot;744&quot; data-start=&quot;731&quot;&gt;실제 SQL 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;응답 완료&amp;nbsp;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TenantContext.clear&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TenantContext&lt;/li&gt;
&lt;li&gt;CurrentTenantIdentifierResolverImpl&lt;/li&gt;
&lt;li&gt;SchemaMultiTenantConnectionProvider&lt;/li&gt;
&lt;li&gt;DynamicDataSourceRouter&lt;/li&gt;
&lt;li&gt;HibernateConfig&lt;/li&gt;
&lt;li&gt;DataSourceConfig&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyway 마이그레이션&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2448&quot; data-start=&quot;2395&quot;&gt;Flyway로 classpath:db/migration에 정의된 DDL 스크립트를&lt;/li&gt;
&lt;li data-end=&quot;2481&quot; data-start=&quot;2449&quot;&gt;방금 생성한 tenantId 스키마에 자동 실행&lt;/li&gt;
&lt;li data-end=&quot;2511&quot; data-start=&quot;2482&quot;&gt;테이블, 인덱스 등 초기 구조를 갖추도록 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 hibernate ddl을 이용하면 서버가 실행될 때만 DB table을 실행하기에 별도의 flyway에서 db 마이그래이션을 진행해 tenant-id가 주입되었을 때 동적 생성이 가능하도록 하였다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;TenantContext &amp;amp; TenantInterceptor&lt;/blockquote&gt;
&lt;pre id=&quot;code_1751037004493&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class TenantContext {

    private static final ThreadLocal&amp;lt;String&amp;gt; CURRENT = new ThreadLocal&amp;lt;&amp;gt;();

    public static void setTenant(String tenant) {
        CURRENT.set(tenant);
    }

    public static String getTenant() {
        return CURRENT.get();
    }

    public static void clear() {
        CURRENT.remove();
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1751037155683&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String tenantId = request.getHeader(&quot;X-Tenant-ID&quot;);
        if (!StringUtils.hasText(tenantId)) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), &quot;Missing X-Tenant-ID&quot;);
            return false;
        }
        TenantContext.setTenant(tenantId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req,
                                HttpServletResponse res,
                                Object handler,
                                Exception ex) {
        TenantContext.clear();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Http 응답에서 Tenant에 해당하는 헤더를 추출 후 TenantContext.setTenant를 통해 ThreadLocal에 저장&lt;/li&gt;
&lt;li&gt;작업이 끝나면 TenantContext.clear() 작업 종료&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;CurrentTenantIdentifierResolverImpl&lt;/blockquote&gt;
&lt;pre id=&quot;code_1751037696644&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {

    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.getTenant();
        return (tenant != null &amp;amp;&amp;amp; !tenant.isEmpty()) ? tenant : &quot;public&quot;;
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate의 다중 테넌시 단계에서 현재 테넌트 식별자를 호출&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;TenantContext에서 ID 획득&lt;/li&gt;
&lt;li&gt;값이 없다면 기본값 PUBLIC 사용&lt;/li&gt;
&lt;li&gt;식별자 변경을 허용 validateExistingCurrentSessions = true
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동적 테넌시를 통해 요청별 스키마가 변경 가능해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;SchemaMultiTenantConnectionProvider&lt;/blockquote&gt;
&lt;pre id=&quot;code_1751038128114&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@ManagedResource(objectName=&quot;com.msa:type=MultiTenant,bean=SchemaProvider&quot;)
public class SchemaMultiTenantConnectionProvider implements MultiTenantConnectionProvider {

    private final DataSource defaultDataSource;
    private final Set&amp;lt;String&amp;gt; initializedTenants = ConcurrentHashMap.newKeySet();
    private static final String DEFAULT_SCHEMA = &quot;PUBLIC&quot;;
    
    public SchemaMultiTenantConnectionProvider(@Qualifier(&quot;defaultDataSource&quot;) DataSource defaultDataSource) {
        this.defaultDataSource = defaultDataSource;
    }

    @Override
    public Connection getAnyConnection() throws SQLException {
        return defaultDataSource.getConnection();
    }

    @Override
    public void releaseAnyConnection(Connection connection) throws SQLException {
        connection.close();
    }

    @Override
    public Connection getConnection(Object tenantIdentifier) throws SQLException {
        Connection connection = getAnyConnection();
        String tenant = tenantIdentifier.toString().toUpperCase();

        if (DEFAULT_SCHEMA.equals(tenant)) {
            try (Statement stmt = connection.createStatement()) {
                stmt.execute(&quot;SET SCHEMA &quot; + DEFAULT_SCHEMA);
                log.info(&quot;Switched to default schema: {}&quot;, DEFAULT_SCHEMA);
            }
            return connection;
        }

        log.info(&quot;tenantIdentifier {}&quot;, tenantIdentifier.toString().toUpperCase());
        try (Statement statement = connection.createStatement()) {
            // 동적 테넌트 스키마 생성 (없다면 생성)
            statement.execute(&quot;CREATE SCHEMA IF NOT EXISTS &quot; + tenant);
        }

        if (initializedTenants.add(tenant)) {
            Flyway.configure()
                    .dataSource(defaultDataSource)
                    .schemas(tenant)
                    .locations(&quot;classpath:db/migration&quot;)
                    .baselineOnMigrate(true)
                    .load()
                    .migrate();
            log.info(&quot;Flyway migration applied for tenant schema: {}&quot;, tenant);
        }

        // 스키마 전환
        try (Statement stmt = connection.createStatement()) {
            stmt.execute(&quot;SET SCHEMA &quot; + tenant);
            log.info(&quot;Switched to tenant schema: {}&quot;, tenant);
        }

        return connection;
    }
}

//releaseConnection, supportsAggressiveRelease, isUnwrappableAs, unwrap 생략&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate가 사용하는 커넥션을 테넌트별 스키마로 전환&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기본 DS에서 커넥션을 획득&amp;nbsp;&lt;/li&gt;
&lt;li&gt;SET SCHEMA를 이용해 스키마 실행&lt;/li&gt;
&lt;li&gt;쿼리 실행 후 다시 기본 스키마로 이동&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;DynamicDataSourceRouter&lt;/blockquote&gt;
&lt;pre id=&quot;code_1751039362697&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class DynamicDataSourceRouter extends AbstractRoutingDataSource {

    private final DataSource defaultDs;
    private final Map&amp;lt;String, DataSource&amp;gt; tenantDsMap = new ConcurrentHashMap();
    
    public DynamicDataSourceRouter(@Qualifier(&quot;defaultDataSource&quot;) DataSource defaultDs) {
        this.defaultDs = defaultDs;
        super.setDefaultTargetDataSource(defaultDs);
        super.setTargetDataSources(new HashMap&amp;lt;&amp;gt;());
        super.afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getTenant();
    }
    
    @Override
    protected DataSource determineTargetDataSource() {
        String tenantId = (String) determineCurrentLookupKey();

        if (tenantId == null || tenantId.isEmpty()) {
            return defaultDs;
        }

        return tenantDsMap.computeIfAbsent(tenantId, this::createAndRegisterTenantDs);
    }

    private synchronized DataSource createAndRegisterTenantDs(String tenantId) {
        // 스키마 생성
        try (Connection conn = defaultDs.getConnection();
             Statement stmt = conn.createStatement()) {
            stmt.execute(&quot;CREATE SCHEMA IF NOT EXISTS &quot; + tenantId);
        } catch (SQLException e) {
            throw new IllegalStateException(&quot;Failed to create schema: &quot; + tenantId, e);
        }

        // 새로운 DataSource 설정
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(&quot;jdbc:h2:tcp://localhost/~/userdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE&quot;);
        ds.setUsername(&quot;sa&quot;);
        ds.setPassword(&quot;&quot;);
        ds.setConnectionInitSql(&quot;SET SCHEMA &quot; + tenantId);

        Flyway.configure()
                .dataSource(ds)
                .schemas(tenantId)
                .locations(&quot;classpath:db/migration&quot;)
                .baselineOnMigrate(true)
                .load()
                .migrate();

        // 라우팅 맵 업데이트
        Map&amp;lt;Object, Object&amp;gt; targetDataSources = new HashMap&amp;lt;&amp;gt;(this.tenantDsMap);
        targetDataSources.put(tenantId, ds);
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();

        return ds;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테넌트 ID를 조회해 해당 ID에 스키마 접속 후 DataSource가 생성되었는지 확인하고 없다면 createAndRegisterTenantDs를 이용해 새로 생성해 등록 후 반환&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티테넌시 구조로 사용하는 경우에는 setJdbcUrl의 연결 db 부분을 yml 파일이나 별도로 저장해 가변적용을 해주면 멀티 모듈로도 사용가능하다.&lt;/p&gt;</description>
      <category>Spring</category>
      <category>Flyway</category>
      <category>JPA</category>
      <category>멀티테넌시</category>
      <category>스키마</category>
      <category>테넌시</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/152</guid>
      <comments>https://zks145.tistory.com/152#entry152comment</comments>
      <pubDate>Sat, 28 Jun 2025 00:53:30 +0900</pubDate>
    </item>
    <item>
      <title>SSR 기반 Spring Server 모니터링 생성기 (Prometheus, Grafana, Spring Security)</title>
      <link>https://zks145.tistory.com/151</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모니터링 서버 자체를 구축하는 방법은 크게 어렵지 않았지만 그 과정에서 서버에 적용된 Security에 대해 다시 공부해보는 계기가 되었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 두가지 방법으로 모니터링 페이지를 구축하였습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Spring Security의 별도 SecurityFilterChain 생성 + 모니터링 전용 Id와 role을 생성&lt;/li&gt;
&lt;li&gt;Spring Security의 별도 SecurityFilterChain 생성 + 도커 내부망 통신&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Spring Security의 별도&amp;nbsp;SecurityFilterChain 생성 + 모니터링 전용 Id와 role을 생성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;779&quot; data-start=&quot;356&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;557&quot; data-start=&quot;356&quot;&gt;&lt;b&gt;필터 체인의 적용 순서&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;557&quot; data-start=&quot;381&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;466&quot; data-start=&quot;381&quot;&gt;기존 SecurityFilterChain이 먼저 선언되어 있으면, 기존 로그인 방식(LoginForm)을 사용하지 않는 문제가 발생합니다.&lt;/li&gt;
&lt;li data-end=&quot;557&quot; data-start=&quot;470&quot;&gt;이를 해결하기 위해 &lt;b&gt;별도의 SecurityFilterChain을 생성&lt;/b&gt;하고,@Order를 이용해 &lt;b&gt;우선 적용&lt;/b&gt;되도록 설정하였습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;779&quot; data-start=&quot;559&quot;&gt;&lt;b&gt;로그인 처리 방식&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;779&quot; data-start=&quot;581&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;625&quot; data-start=&quot;581&quot;&gt;SSR 기반 프로젝트이므로 &lt;b&gt;Base Path 방식&lt;/b&gt;을 사용했습니다.&lt;/li&gt;
&lt;li data-end=&quot;711&quot; data-start=&quot;629&quot;&gt;사용자가 로그인할 때, &lt;b&gt;해당 Role에 따라 모니터링 URI로 리디렉션&lt;/b&gt;하는 별도의 LoginHandler를 구현하였습니다.&lt;/li&gt;
&lt;li data-end=&quot;779&quot; data-start=&quot;715&quot;&gt;Prometheus에서는 &lt;b&gt;모니터링 전용 아이디와 비밀번호를 사용&lt;/b&gt;하여 데이터를 가져가도록 설정했습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;첫번째 방식의 문제점&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 방식의 문제점은 지속적인 로그인 시도와 외부망 오픈으로 인한 취약점&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;모니터링의 특성상 실시간에 가까운 데이터들이 필요한데 Prometheus에서는 이러한 데이터 정보를 얻기 위해 지속적인 로그인 시도하게 됩니다. 이러한 경우 필연적으로 서버 리소스를 지속적으로 사용하게 됩니다. 시스템 상에서도 불필요한 로그들이 지속적으로 발생하기에 모니터링 데이터 좋지 않은 영향을 끼치게 됩니다. 물론 해결방법으로 별도의 로그인 핸들링 코드를 작성하는 방법과 불필요한 로그 데이터를 모니터링 환경에서 제외하는 등의 방법이 존재하긴 합니다.&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;제가 생각하는 외부망 오픈되어 있는 환경은 불필요한 보안 취약점을 남긴다 생각해 두번째 방법을 선택하게 되었습니다. 외부망이 오픈되어 있는 경우 uri를 알게 되고 지속적인 로그인 시도를 통해 비밀번호와 아이디를 해킹할 가능성을 남긴다 생각하여 해당 방법을 포기하게 되었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743078607481&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {

    private final RedisTemplate&amp;lt;String, Object&amp;gt; redisTemplate;
    private final CustomLoginSuccessHandler customLoginSuccessHandler;
    private final CustomLogoutSuccessHandler customLogoutSuccessHandler;
    private final CustomLoginFailHandler customLoginFailHandler;
    private final CustomMemberDetailService customMemberDetailService;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    @Order(1)
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests((auth) -&amp;gt; auth
                        .requestMatchers(&quot;/&quot;, &quot;/login&quot;, &quot;/signup&quot;, &quot;/css/**&quot;, &quot;/js/**&quot;).permitAll()
                        .requestMatchers(&quot;/automessage/admin&quot;).hasRole(&quot;ADMIN&quot;)
                        .requestMatchers(&quot;/automessage/**&quot;).hasAnyRole(&quot;USER&quot;, &quot;ADMIN&quot;)
                        .anyRequest().authenticated());

        http.exceptionHandling((except) -&amp;gt; except
                .accessDeniedPage(&quot;/login&quot;));

        http.exceptionHandling((e) -&amp;gt; e
                .accessDeniedHandler(customAccessDeniedHandler));

        http.formLogin((auth) -&amp;gt; auth
                .loginPage(&quot;/login&quot;)
                .usernameParameter(&quot;memberId&quot;)
                .passwordParameter(&quot;memberPassword&quot;)
                .loginProcessingUrl(&quot;/login&quot;)
                .successHandler(customLoginSuccessHandler)
                .failureHandler(customLoginFailHandler)
                .permitAll());

        http.logout(logout -&amp;gt; logout
                .logoutUrl(&quot;/logout&quot;)
                .logoutSuccessHandler(customLogoutSuccessHandler)
                .invalidateHttpSession(true)
                .deleteCookies(&quot;JSESSIONID&quot;)
                .permitAll());

        http.rememberMe((remember) -&amp;gt; remember
                .rememberMeParameter(&quot;remember&quot;)
                .tokenValiditySeconds(3 * 24 * 60 * 60) // 3일 동안 유효한 쿠키
                .tokenRepository(persistentTokenRepository())
                .userDetailsService(customMemberDetailService));

        return http.build();
    }

    @Bean
    @Order(0) // Actuator 관련 보안 필터 체인을 먼저 실행
    public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
        log.info(&quot;actuatorSecurity&quot;);

        http.securityMatcher(&quot;/metrics/**&quot;)
            .httpBasic(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -&amp;gt; {})  // 기본 CORS 설정 적용
            .sessionManagement(session -&amp;gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -&amp;gt; auth
                    .requestMatchers(&quot;/metrics/**&quot;).hasRole(&quot;MONITOR&quot;)
                    .anyRequest().denyAll()
            );

        return http.build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743079507645&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# my global config
global:
  scrape_interval: 15s 
  evaluation_interval: 15s

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:

rule_files:
scrape_configs:
  - job_name: 'prometheus'
    metrics_path: '/metrics/prometheus'
    static_configs:
      - targets: [&quot;host ip:port&quot;]
  	basic_auth:
      username: 'id'
      password: 'pw'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Spring Security의 별도&amp;nbsp;SecurityFilterChain 생성 + 도커 내부망 통신&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Spring Server의 경우 Docker 환경에 배포되어 있고 Prometheus와 Grafana도 Docker 환경에 배포할 예정이기에 docker network를 이용해 내부망 통신을 이용해 외부 환경에서는 접속이 불가능한 환경으로 변경하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 3개의 도커 컨테이너를 배포하게 되는데 이를 하나의 네트워크로 묶어준다 Network Name: Bridge (사용자 별로 상이)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하단 이미지는 실제 사용하고 있는 Prometheus 컨테이너 설정 정보이다. 배포 후 알았는데 Prometheus의 경우 환경 변수로 한국 시간을 별도로 변경할 수 없다 항상 UK 시간이 고정이고 Grafana 환경 설정을 통해 KR 시간으로 변경할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Prometheus Config&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-03-27 오후 10.36.21.png&quot; data-origin-width=&quot;726&quot; data-origin-height=&quot;1148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PUYcr/btsM0HdIv81/Z5AlThk2aiQFSWKmFqA7Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PUYcr/btsM0HdIv81/Z5AlThk2aiQFSWKmFqA7Ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PUYcr/btsM0HdIv81/Z5AlThk2aiQFSWKmFqA7Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPUYcr%2FbtsM0HdIv81%2FZ5AlThk2aiQFSWKmFqA7Ok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;257&quot; height=&quot;406&quot; data-filename=&quot;스크린샷 2025-03-27 오후 10.36.21.png&quot; data-origin-width=&quot;726&quot; data-origin-height=&quot;1148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Prometheus.yml&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1743082732520&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# my global config
global:
  scrape_interval: 15s 
  evaluation_interval: 15s

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:

rule_files:
scrape_configs:
  - job_name: 'prometheus'
    metrics_path: '/metrics/prometheus'
    static_configs:
      - targets: [&quot;Spring 컨테이너 이름:8002&quot;]

  - job_name: 'prometheus-health'
    metrics_path: '/metrics/health'
    static_configs:
      - targets: [&quot;Spring 컨테이너 이름:8002&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prometheus의 경우 내부망 통신만 사용하기에 기존 포트포워딩으로 열어줬던 포트(9090)를 해지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana의 경우 외부망과 연결되어야 하기 때문에 별도의 포트포워딩이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;SpringSecurity FilterChain 변경 사항&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;securityMatcher -&amp;gt; 해당 클라이언트 uri 요청만 필터링한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;requestMatchers -&amp;gt; &lt;span style=&quot;background-color: #ffffff; color: #24292f; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;클라이언트의 모든 요청 경로에 대해 보안 작동을 진행하고 그 중에 해당 uri 경로로 오는 것에 대해 추가 보안 검사를 실행한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InternaNetworkFilter()를 통해 ip 추출 후 내부망 여부 판단&lt;/p&gt;
&lt;pre id=&quot;code_1743083061213&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
@Order(0) // 내부망 모니터링
public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {

    http.securityMatcher(&quot;/metrics/**&quot;);

    http.
            authorizeHttpRequests((auth) -&amp;gt; auth
                    .requestMatchers(&quot;/metrics/**&quot;).permitAll()
                    .anyRequest().denyAll());

    http.
            addFilterBefore(new InternalNetworkFilter(), BasicAuthenticationFilter.class);

    http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -&amp;gt; {})
            .sessionManagement(session -&amp;gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    return http.build();
}

@Slf4j
public class InternalNetworkFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String remoteAddr = request.getRemoteAddr();
        String requestURI = request.getRequestURI();

        log.info(&quot;접속 ip {}&quot;, remoteAddr);

        // 로그인 URL 제외
        if (requestURI.contains(&quot;/login&quot;)) {
            log.info(&quot;로그인 URL 제외&quot;);
            filterChain.doFilter(request, response);
            return;
        }

        if (!isInternalNetwork(remoteAddr)) {
            log.info(&quot;외부 ip 주소 입니다&quot;);
            response.sendRedirect(&quot;/login&quot;);
            return;
        }

        filterChain.doFilter(request, response);
    }

    private boolean isInternalNetwork(String ip) {
        return ip.startsWith(&quot;허용할 내부 ip&quot;);
    }

}

application.yml

server:
	forward-headers-strategy: native #추가 작성
    ....&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에 설정들을 Spring Server, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Prometheus, Grafana 3개의 컨테이너를 하나의 네트워크로 묶어줬다면 동일한 네트워크가 아닌 이상 직접적인 uri 입력으로는 Spring Server의 Prometheus uri에 접근할 수 없게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-03-27 오후 10.51.01.png&quot; data-origin-width=&quot;2836&quot; data-origin-height=&quot;1106&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjXS2Z/btsM0s8OXtr/ruaFkc96pTK1stpNXX8kPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjXS2Z/btsM0s8OXtr/ruaFkc96pTK1stpNXX8kPk/img.png&quot; data-alt=&quot;활성화 된 대시보드 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjXS2Z/btsM0s8OXtr/ruaFkc96pTK1stpNXX8kPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjXS2Z%2FbtsM0s8OXtr%2FruaFkc96pTK1stpNXX8kPk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2836&quot; height=&quot;1106&quot; data-filename=&quot;스크린샷 2025-03-27 오후 10.51.01.png&quot; data-origin-width=&quot;2836&quot; data-origin-height=&quot;1106&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;활성화 된 대시보드 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;참고 자료&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1743080881443&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Docker network로 모니터링 시스템 내부망 사용&quot; data-og-description=&quot;안녕하세요, 셀럽잇의 로이스입니다.이전 글들을 통해 모니터링을 적절히 구성하였는데요.이번엔 보안적인 부분에 대해 신경쓰며 개선해보도록 하겠습니다.메트릭은 어플리케이션에 대한 다&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@roycewon/Docker-network%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%82%B4%EB%B6%80%EB%A7%9D-%EC%82%AC%EC%9A%A9&quot; data-og-url=&quot;https://velog.io/@roycewon/Docker-network로-모니터링-시스템-내부망-사용&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/chuI6Y/hyYuj1WHvU/bJadeXywu7V4og2sv4KldK/img.png?width=1324&amp;amp;height=1218&amp;amp;face=413_647_466_704,https://scrap.kakaocdn.net/dn/dzy38W/hyYupumgS2/LPobm5rQ4rshlXcTvxLur0/img.png?width=1324&amp;amp;height=1218&amp;amp;face=413_647_466_704,https://scrap.kakaocdn.net/dn/w9GNk/hyYvmcQXNx/xdoPZhmL0jkbEded4nMts0/img.png?width=2510&amp;amp;height=1830&amp;amp;face=0_0_2510_1830&quot;&gt;&lt;a href=&quot;https://velog.io/@roycewon/Docker-network%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%82%B4%EB%B6%80%EB%A7%9D-%EC%82%AC%EC%9A%A9&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@roycewon/Docker-network%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%82%B4%EB%B6%80%EB%A7%9D-%EC%82%AC%EC%9A%A9&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/chuI6Y/hyYuj1WHvU/bJadeXywu7V4og2sv4KldK/img.png?width=1324&amp;amp;height=1218&amp;amp;face=413_647_466_704,https://scrap.kakaocdn.net/dn/dzy38W/hyYupumgS2/LPobm5rQ4rshlXcTvxLur0/img.png?width=1324&amp;amp;height=1218&amp;amp;face=413_647_466_704,https://scrap.kakaocdn.net/dn/w9GNk/hyYvmcQXNx/xdoPZhmL0jkbEded4nMts0/img.png?width=2510&amp;amp;height=1830&amp;amp;face=0_0_2510_1830');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Docker network로 모니터링 시스템 내부망 사용&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;안녕하세요, 셀럽잇의 로이스입니다.이전 글들을 통해 모니터링을 적절히 구성하였는데요.이번엔 보안적인 부분에 대해 신경쓰며 개선해보도록 하겠습니다.메트릭은 어플리케이션에 대한 다&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/151</guid>
      <comments>https://zks145.tistory.com/151#entry151comment</comments>
      <pubDate>Thu, 27 Mar 2025 22:50:34 +0900</pubDate>
    </item>
    <item>
      <title>Jpa Save vs SaveAll 성능 차이 체감해보기</title>
      <link>https://zks145.tistory.com/150</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하면서 JPA의 save와 saveAll 메서드를 사용해 데이터를 저장하게 됩니다. 일반적으로 100개 미만의 데이터를 저장할 때는 두 메서드 간의 성능 차이를 크게 느끼지 못할 수 있습니다. 그러나 대량의 데이터를 저장할 때는 두 메서드의 &lt;b&gt;성능 차이가 매우 크다&lt;/b&gt;는 점을 확인할 수 있었습니다. 이번 글에서는 이 차이에 대해 살펴보고 각각의 사용 시 주의해야 할 점을 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Save vs SaveAll 차이점&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;save와 saveAll의 가장 큰 차이는 &lt;b&gt;트랜잭션의 생성 방식&lt;/b&gt;과 &lt;b&gt;처리 방식&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;동작 방식&lt;/b&gt;: 데이터를 하나씩 저장하며, 호출될 때마다 별도의 트랜잭션을 생성하여 처리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;특징&lt;/b&gt;: N개의 데이터를 저장할 경우 N번의 트랜잭션이 생성됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-11 오후 4.43.15.png&quot; data-origin-width=&quot;1076&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjqF1Y/btsLJgw5gz5/q19Hid5QOzmnL6yWt8dKE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjqF1Y/btsLJgw5gz5/q19Hid5QOzmnL6yWt8dKE1/img.png&quot; data-alt=&quot;save&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjqF1Y/btsLJgw5gz5/q19Hid5QOzmnL6yWt8dKE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjqF1Y%2FbtsLJgw5gz5%2Fq19Hid5QOzmnL6yWt8dKE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;380&quot; height=&quot;618&quot; data-filename=&quot;스크린샷 2025-01-11 오후 4.43.15.png&quot; data-origin-width=&quot;1076&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;save&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;동작 방식&lt;/b&gt;: 한 번의 트랜잭션 안에서 여러 개의 데이터를 반복 처리하며 저장합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;특징&lt;/b&gt;: N개의 데이터를 저장해도 트랜잭션은 한 번만 생성됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-11 오후 4.43.27.png&quot; data-origin-width=&quot;1070&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/U7wLQ/btsLJuWdbJT/OA4d0XU0qDpXpktmPktLVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/U7wLQ/btsLJuWdbJT/OA4d0XU0qDpXpktmPktLVK/img.png&quot; data-alt=&quot;saveAll&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/U7wLQ/btsLJuWdbJT/OA4d0XU0qDpXpktmPktLVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FU7wLQ%2FbtsLJuWdbJT%2FOA4d0XU0qDpXpktmPktLVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;382&quot; height=&quot;652&quot; data-filename=&quot;스크린샷 2025-01-11 오후 4.43.27.png&quot; data-origin-width=&quot;1070&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;saveAll&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;성능 비교&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;save: 트랜잭션 생성 비용이 데이터의 개수만큼 기하급수적으로 증가합니다. 많은 데이터를 처리할수록 저장 시간이 급격히 늘어납니다.&lt;/li&gt;
&lt;li&gt;saveAll: 하나의 트랜잭션에서 데이터를 일괄 저장하므로 상대적으로 더 빠르고 효율적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;13000개의 데이터를 저장하는 상황에서 save를 사용하면 각 데이터별로 트랜잭션이 생성되므로 엄청난 시간과 비용이 소모되었습니다. 반면 saveAll은 트랜잭션을 한 번만 생성하여 데이터를 저장하므로 &lt;b&gt;시간과 비용 면에서 훨씬 효율적&lt;/b&gt;임을 확인했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-11 오후 5.02.48.png&quot; data-origin-width=&quot;1368&quot; data-origin-height=&quot;98&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mRPf7/btsLLCd3bQC/HakYljHMDkRKOwN04WrI7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mRPf7/btsLLCd3bQC/HakYljHMDkRKOwN04WrI7K/img.png&quot; data-alt=&quot;save 사용 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mRPf7/btsLLCd3bQC/HakYljHMDkRKOwN04WrI7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmRPf7%2FbtsLLCd3bQC%2FHakYljHMDkRKOwN04WrI7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;444&quot; height=&quot;98&quot; data-filename=&quot;스크린샷 2025-01-11 오후 5.02.48.png&quot; data-origin-width=&quot;1368&quot; data-origin-height=&quot;98&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;save 사용 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;save를 사용해 13000개의 데이터를 로컬 db에 저장할 때는 16분 30초가 걸리는 상황이 발생했습니다. 만약 클라우드 db 공간에 저장하는 경우에는 더 큰 시간과 비용이 발생할 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-11 오후 5.05.05.png&quot; data-origin-width=&quot;1744&quot; data-origin-height=&quot;70&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Pr209/btsLKI0dvne/bNOlvnYpvFE6IL6VnOIiw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Pr209/btsLKI0dvne/bNOlvnYpvFE6IL6VnOIiw0/img.png&quot; data-alt=&quot;saveAll&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Pr209/btsLKI0dvne/bNOlvnYpvFE6IL6VnOIiw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPr209%2FbtsLKI0dvne%2FbNOlvnYpvFE6IL6VnOIiw0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1744&quot; height=&quot;70&quot; data-filename=&quot;스크린샷 2025-01-11 오후 5.05.05.png&quot; data-origin-width=&quot;1744&quot; data-origin-height=&quot;70&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;saveAll&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;saveAll을 이용할 경우 38초만에 처리가 가능한 것을 확인할 수 있다. 이를 통해 트랜잭션의 생성과 삭제를 하는 과정에서 꽤 많은 시간이 소요되는 것을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;사용 유의점&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 정형화된 데이터&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;saveAll 추천&lt;/b&gt;: 데이터 형식이 정형화되어 있고 저장 과정에서 문제가 발생하지 않을 것으로 예상되는 경우 saveAll을 사용하는 것이 적합합니다.&lt;/li&gt;
&lt;li&gt;이유: 하나의 트랜잭션에서 처리하므로 빠르고 효율적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 비정형 데이터&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;save 추천&lt;/b&gt;: 대용량 데이터가 비정형적이거나 데이터 유효성 검증이 확실하지 않은 경우 save를 사용하는 것이 더 안전할 수 있습니다.&lt;/li&gt;
&lt;li&gt;이유: 트랜잭션 특성상 saveAll로 데이터를 처리하다가 하나의 데이터에서 오류가 발생하면 전체 트랜잭션이 롤백됩니다. 결과적으로 모든 데이터 저장이 실패하게 됩니다.&lt;/li&gt;
&lt;li&gt;해결 방안: 데이터를 저장하기 전에 철저히 검증하거나 개별 저장(save) 방식을 고려해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;결론&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;소규모 데이터 처리&lt;/b&gt;: save와 saveAll의 성능 차이가 크지 않으므로 상황에 맞게 선택할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대용량 데이터 처리&lt;/b&gt;: 가능한 경우 saveAll을 사용하여 성능을 최적화합니다. 단 데이터 유효성 검증과 예외 상황에 주의해야 합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/150</guid>
      <comments>https://zks145.tistory.com/150#entry150comment</comments>
      <pubDate>Sat, 11 Jan 2025 17:08:25 +0900</pubDate>
    </item>
    <item>
      <title>Redis Cluster 모드 생성 및 배포 (feat: redis Redisson, Synology NAS, Spring boot Cache 연동)</title>
      <link>https://zks145.tistory.com/149</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;목차&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis Cluster 란?&lt;/li&gt;
&lt;li&gt;Redis Cluster 로컬환경 배포&lt;/li&gt;
&lt;li&gt;Redis Cluster 클라우드 환경 배포
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;redis.conf 파일 수정&lt;/li&gt;
&lt;li&gt;redis 연결 테스트&lt;/li&gt;
&lt;li&gt;spring boot 캐싱 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Redis Cluster 란?&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러개의 master를 두고 데이터를 분산 저장하며 수평적인 확장을 가능하게 만들어 준다. 즉 서버의 필요 상태에 따라 저장 공간을 늘리거나 줄이는 것이 용의하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각의 마스터들에는 하나 이상의 slave가 매핑될 수 있으며 해당 slave는 master 상태가 비정상적인 경우 master로 승급되며 이전 데이터 상태를 유지하며 다시 redis 서버가 중지 없이 가동 될 수 있게 만들어 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-06 오전 11.14.12.png&quot; data-origin-width=&quot;972&quot; data-origin-height=&quot;1140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xCEbP/btsLFECMmYF/z3qIqK5DyKy0jvXLpQoGHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xCEbP/btsLFECMmYF/z3qIqK5DyKy0jvXLpQoGHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xCEbP/btsLFECMmYF/z3qIqK5DyKy0jvXLpQoGHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxCEbP%2FbtsLFECMmYF%2Fz3qIqK5DyKy0jvXLpQoGHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;398&quot; height=&quot;467&quot; data-filename=&quot;스크린샷 2025-01-06 오전 11.14.12.png&quot; data-origin-width=&quot;972&quot; data-origin-height=&quot;1140&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Redis Cluster 로컬환경 배포 3M - 3S&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 로컬 환경에는 redis cluster를 배포하기 위해 6개의 포트가 필요하다. 3개의 마스터와 3개의 슬레이브 구조로 만들 예정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;docker-compose.yml 파일&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736130663089&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;version: '3'
services:
  redis-master-1:
    container_name: redis-master-1
    image: redis:7.0.0
    command: redis-server /etc/redis.conf
    volumes:
      - ./config/redis-master-1.conf:/etc/redis.conf
    restart: always
    depends_on:
      - redis-master-2
      - redis-master-3
      - redis-slave-1
      - redis-slave-2
      - redis-slave-3
    ports:
      - &quot;9001:9001&quot;
      - &quot;19001:19001&quot;

  redis-master-2:
    container_name: redis-master-2
    image: redis:7.0.0
    command: redis-server /etc/redis.conf
    volumes:
      - ./config/redis-master-2.conf:/etc/redis.conf
    restart: always
    ports:
      - &quot;9002:9002&quot;
      - &quot;19002:19002&quot;

  redis-master-3:
    container_name: redis-master-3
    image: redis:7.0.0
    command: redis-server /etc/redis.conf
    volumes:
      - ./config/redis-master-3.conf:/etc/redis.conf
    restart: always
    ports:
      - &quot;9003:9003&quot;
      - &quot;19003:19003&quot;

  redis-slave-1:
    container_name: redis-slave-1
    image: redis:7.0.0
    command: redis-server /etc/redis.conf
    volumes:
      - ./config/redis-slave-1.conf:/etc/redis.conf
    restart: always
    ports:
      - &quot;9004:9004&quot;
      - &quot;19004:19004&quot;

  redis-slave-2:
    container_name: redis-slave-2
    image: redis:7.0.0
    command: redis-server /etc/redis.conf
    volumes:
      - ./config/redis-slave-2.conf:/etc/redis.conf
    restart: always
    ports:
      - &quot;9005:9005&quot;
      - &quot;19005:19005&quot;

  redis-slave-3:
    container_name: redis-slave-3
    image: redis:7.0.0
    command: redis-server /etc/redis.conf
    volumes:
      - ./config/redis-slave-3.conf:/etc/redis.conf
    restart: always
    ports:
      - &quot;9006:9006&quot;
      - &quot;19006:19006&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;redis-[이름]-[번호].conf&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736130793189&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;port 9001
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 3000
appendonly yes&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더에 설정을 마치고 실행을 하게 되면 도커에 해당 설정을 마친 컨테이너들이 실행된다. 하단 redis-cluster 명령어 모음을 참고해 master와 slave들을 연결을 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Redis Cluster 클라우드 환경 배포&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬환경에 배포하는 경우 비교적 특별한 설정 없이 연결이 가능하다. 하지만 클라우드 환경에 배포하는 경우에는 도커 환경과 클라우드 연결 상태에 따라 설정이 바뀌는 경우가 있는데 현재 연결 방식은 클라우드 외부 ip를 이용해 redis-cluster를 구축할 예정이다. 외부 ip로 연결하는 방식이 마음에 들지 않을 경우&amp;nbsp; 해당 테스트를 마친 후에는 .conf 파일별로 고정 로컬 ip(도커 컨테이너에 할당되는 ip를 말함)를 작성해주고 고정 할당된 로컬 ip를 매핑에 컨테이너 브릿지를 구축해 연결하는 방식으로도 cluster 구축이 가능할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트를 동일하게 사용한다는 가정하에 변경해야되는 것은 conf 에 작성된 값을 조금 수정해주면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1736131302954&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;port 9001
bind 0.0.0.0
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000

cluster-announce-ip 외부 ip 주소
cluster-announce-port 9001       

protected-mode no
appendonly yes&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cluster-announce-ip에는 클라우드 환경에서 사용하는 외부 ip를 작성해주면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 구현과 별개로 로컬에서는 비교적 자동으로 master와 slave를 연결해주는 작업이 잘 수행되었지만 클라우드 환경에서는 자동 구축 명령어가 잘 이행되지 않아 수동으로 master노드와 slave 연결을 수행해 cluster 연결을 하였습니다. 해당 명령어는 하단 명령어 목록과 깃허브에 참고 되어 있는 자료를 참고해 주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Spring boot에 redis-cluster 연결해 캐시로 사용하기&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;properties 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml&lt;/p&gt;
&lt;pre id=&quot;code_1736131832928&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:  
  data:
    redis:
      cluster:
        nodes:
          - ${SERVER_URL}:${REDIS_MASTER_PORT_1},
          - ${SERVER_URL}:${REDIS_MASTER_PORT_2},
          - ${SERVER_URL}:${REDIS_MASTER_PORT_3},
        max-redirects: 3
  cache:
    type: redis
    redis:
      enable-statistics: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application-api-key.properties&lt;/p&gt;
&lt;pre id=&quot;code_1736131906773&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;REDIS_MASTER_PORT_1 = 포트번호
REDIS_MASTER_PORT_2 = 포트번호
REDIS_MASTER_PORT_3 = 포트번호
SERVER_URL = 서버 ip&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RedisClusterProperties&lt;/p&gt;
&lt;pre id=&quot;code_1736131975885&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Setter @Getter
@Configuration
@ConfigurationProperties(prefix = &quot;spring.data.redis.cluster&quot;)
public class RedisClusterProperties {

    private int maxRedirects;
    private List&amp;lt;String&amp;gt; nodes;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Application.java 설정&lt;/p&gt;
&lt;pre id=&quot;code_1736131960458&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootApplication
@EnableCaching
@EnableConfigurationProperties(RedisClusterProperties.class)
public class ManagementApplication {

	public static void main(String[] args) {
		SpringApplication.run(ManagementApplication.class, args);
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RedissonConfig&lt;/p&gt;
&lt;pre id=&quot;code_1736132060114&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@RequiredArgsConstructor
public class RedissonConfig {

    private final RedisClusterProperties redisClusterProperties;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // Redis 클러스터 설정
        String[] nodeAddresses = redisClusterProperties.getNodes().stream()
                .map(node -&amp;gt; &quot;redis://&quot; + node.replace(&quot;,&quot;, &quot;&quot;))
                .toArray(String[]::new);

        config.useClusterServers()
                .addNodeAddress(nodeAddresses)
                .setScanInterval(2000)  // 클러스터 스캔 주기 설정 (밀리초)
                .setConnectTimeout(5000)  // 연결 시간 제한 (밀리초)
                .setIdleConnectionTimeout(10000);  // 유휴 연결 시간 제한 (밀리초)

        return Redisson.create(config);
    }

    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(3L)); // 캐시 수명 설정

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(cf)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }

    @Bean
    public RedisTemplate&amp;lt;String, String&amp;gt; redisTemplate(RedissonClient redissonClient) {
        RedisTemplate&amp;lt;String, String&amp;gt; template = new RedisTemplate&amp;lt;&amp;gt;();
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setConnectionFactory(new RedissonConnectionFactory(redissonClient));
        return template;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐싱 사용 방법 - 프로젝트 내에서 사용한 조회 쿼리&lt;/p&gt;
&lt;pre id=&quot;code_1736132175071&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Cacheable(value = &quot;productSearch&quot;, key = &quot;#condition.productName + ':' + #condition.factory + ':' + #condition.modelClassification + ':' + #pageable.pageNumber&quot;, cacheManager = &quot;redisCacheManager&quot;)
public PageCustom&amp;lt;ProductDto.productSearchResult&amp;gt; searchProducts(ProductDto.productCondition condition, Pageable pageable) {
    return productRepository.searchProduct(condition, pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하단에 사용한 조회 쿼리를 참고해 각각의 사용자가 필요한 형태로 커스텀 해서 사용하면 된다. 이 상태로 서버를 실행하게 된다면 빌드 과정에서 해당 값이 정상적으로 보인다면 spring-boot에 redis-cluster를 성공적으로 연결한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1736132452031&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Redis cluster nodes configuration got from 외부ip/175.117.4.180:8016:
97fbc82cc73588eefd0218c502e22514386fa43a 외부ip:8016@18016 myself,slave 0e3b51404ffc197bded7551a464183a21c859bbb 0 1736132327000 28 connected
0ee3caa9c1c9a467c81e706c67d7a58c86070014 외부ip:8015@18015 master - 0 1736132327561 27 connected 5461-10922
d72fdb35670d917f588a88630e4aa9073f44fac1 외부ip:8013@18013 master - 0 1736132328565 14 connected 10923-16383
0e3b51404ffc197bded7551a464183a21c859bbb 외부ip:8011@18011 master - 0 1736132327561 28 connected 0-5460
2b4faa2ba3ea662afe5cd6d0231c5048f55fc4a8 외부ip:8014@18014 slave d72fdb35670d917f588a88630e4aa9073f44fac1 0 1736132328000 14 connected
53294702d92e0e78c9bab296c5466779f9322ef0 외부ip:8012@18012 slave 0ee3caa9c1c9a467c81e706c67d7a58c86070014 0 1736132328063 27 connected&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인정보가 담겨있어 모자이크 처리했지만 위에 조회 쿼리를 이용해 사용자가 요청한 경우 cluster에 정상적으로 데이터가 저장된 것을 확인할 수 있다. 해당 정보는 redis의 TTL 설정에 따라 설정된 시간이 지나면 자동으로 삭제된다 (&lt;span style=&quot;color: #777777; text-align: center;&quot;&gt;Redis Desktop Manager App)&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2025-01-06 오후 12.02.58.png&quot; data-origin-width=&quot;674&quot; data-origin-height=&quot;656&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTyZtT/btsLDGB56Md/X9KcLLx17acnFl70rEDHH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTyZtT/btsLDGB56Md/X9KcLLx17acnFl70rEDHH1/img.png&quot; data-alt=&quot;Redis Desktop Manager App&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTyZtT/btsLDGB56Md/X9KcLLx17acnFl70rEDHH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTyZtT%2FbtsLDGB56Md%2FX9KcLLx17acnFl70rEDHH1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;315&quot; height=&quot;656&quot; data-filename=&quot;edited_스크린샷 2025-01-06 오후 12.02.58.png&quot; data-origin-width=&quot;674&quot; data-origin-height=&quot;656&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Desktop Manager App&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐싱 전 조회 속도 548ms -&amp;gt; 로컬 DB 환경&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2025-01-06 오후 12.05.55.png&quot; data-origin-width=&quot;1742&quot; data-origin-height=&quot;606&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nJCGw/btsLEp0T3I8/8PecS4RsEoCfbok98pvOWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nJCGw/btsLEp0T3I8/8PecS4RsEoCfbok98pvOWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nJCGw/btsLEp0T3I8/8PecS4RsEoCfbok98pvOWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnJCGw%2FbtsLEp0T3I8%2F8PecS4RsEoCfbok98pvOWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1742&quot; height=&quot;606&quot; data-filename=&quot;edited_스크린샷 2025-01-06 오후 12.05.55.png&quot; data-origin-width=&quot;1742&quot; data-origin-height=&quot;606&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐싱 후 조회 속도 77ms -&amp;gt; 클라이언트 redis 환경&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2025-01-06 오후 12.06.09.png&quot; data-origin-width=&quot;1756&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkNArG/btsLEXQnUCe/XRbtEWBSWUfVAaAsiAvIU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkNArG/btsLEXQnUCe/XRbtEWBSWUfVAaAsiAvIU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkNArG/btsLEXQnUCe/XRbtEWBSWUfVAaAsiAvIU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkNArG%2FbtsLEXQnUCe%2FXRbtEWBSWUfVAaAsiAvIU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1756&quot; height=&quot;604&quot; data-filename=&quot;edited_스크린샷 2025-01-06 오후 12.06.09.png&quot; data-origin-width=&quot;1756&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 DB를 이용한 환경에서 나온 속도와 비교했을 때도 꽤 많은 차이를 보여준다. 만일 사용자가 자주 조회하게 되는 대표 페이징 값들을 기본적으로 redis에 저장해둔다면 기존 DB에 조회해 얻어내는 결과보다 사용자에게 더 빠른 결과를 제공해 줄 수 있을 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 토이 프로젝트 수준의 경우 cluster모드는 과하다 생각이 들고 redis의 standalone 모드로 진행해도 무방할 것입니다. 부족하다면 master/slave 모드까지 확장한다면 대부분의 프로젝트에서는 커버가 가능할 것이라 생각이 듭니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;-- 주의 --&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;제일 중요한 포트포워딩으로 포트 개방을 해줘야 통신이 됩니다. 예시로 9001~9006 포트를 레디스 포트로 사용한다면 꼭 19001 ~ 19006 포트 또한 포트포워딩으로 개방해줘야 NAT 통신 과정에 오류가 발생하지 않습니다. 만약 9001~9006 포트만 개방한 상태로 cluster 구성을 진행한 경우 redis끼리 통신이 불가능하기 때문에 cluster가 옳바르게 구성되지 않습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;redis-cluster 설정을 하며 사용한 명령어 모음&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736130967080&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;### 클러스터 마스터 수동 생성
##### --cluster-replicas 0 -&amp;gt; 수동으로 slave 추가 (1의 경우 슬레이브를 뒤에 추가하면 마스터 노드에 자동으로 슬레이브 추가)
```
docker exec -it redis-master-1 redis-cli --cluster create redis-master-1 [ip 주소]:[포트번호] redis-master-2 [ip 주소]:[포트번호] redis-master-3 [ip 주소]:[포트번호] --cluster-replicas 0
```
### 클러스터 마스터에 슬레이브 추가 
- 로컬 환경인 경우 127.0.0.1 or localhost or host.docker.internal 삽입
- 외부 클라우드에 배포 (AWS, 개인 서버등등) 해당 서버의 외부 ip 주소를 삽입
```
docker exec -it redis-cli --cluster add-node [ip 주소]:[슬레이브 포트번호] [ip 주소]:[마스터 포트번호] --cluster-slave
```

### 클러스터 상태 확인
```
docker exec -it redis-master-1 redis-cli -p [포트번호] cluster info
```

```
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
```

### 클러스터 노드 상태 확인
```
docker exec -it redis-master-1 redis-cli -p [포트번호] cluster nodes
```

### 외부 접근 테스트
```
redis-cli -c -h [외부 ip 주소] -p [포트번호]
```

### 클러스터 생성 중 오류 발생 시 노드들을 초기화 한 상태로 처음부터 다시 시작
#### 마스터 노드 초기화
```
docker exec -it redis-master-1 redis-cli -p [포트번호] cluster reset hard
```
### 슬레이브 노드 초기화
```
docker exec -it redis-slave-1 redis-cli -p [포트번호] cluster reset hard
```

클러스터 마스터 수동 생성 -&amp;gt; 클러스터 마스터에 슬레이브 추가 -&amp;gt; 클러스터 상태 확인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736128345801&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - lsg1024/redis&quot; data-og-description=&quot;Contribute to lsg1024/redis development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/lsg1024/redis&quot; data-og-url=&quot;https://github.com/lsg1024/redis&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Yly04/hyXWs5mbQI/3hQJNhKbs36U2H0dsUJizK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/njWac/hyXWtb6Zf7/MFXMkOMERFXuKMD639z63k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/lsg1024/redis&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/lsg1024/redis&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Yly04/hyXWs5mbQI/3hQJNhKbs36U2H0dsUJizK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/njWac/hyXWtb6Zf7/MFXMkOMERFXuKMD639z63k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - lsg1024/redis&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to lsg1024/redis development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Redis</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/149</guid>
      <comments>https://zks145.tistory.com/149#entry149comment</comments>
      <pubDate>Mon, 6 Jan 2025 12:32:50 +0900</pubDate>
    </item>
    <item>
      <title>Redis 운영 방식 3가지 Standalone, Sentinel, Cluster의 장단점</title>
      <link>https://zks145.tistory.com/147</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 운영 방식에는 크게 Standalone, Sentinel, Cluster 3가지가 존재한다. 대부분의 토이프로젝트에서는 간단한 Standalone 방식을 이용해 배포해 캐싱 작업을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Redis Standalone 모드&amp;nbsp;&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단일 인스턴스로 단일 서버에서 Redis를 실행하며, 별도의 백업 노드 없이 동작한다.&lt;/li&gt;
&lt;li&gt;별도의 설정과 구조 변경 없이 바로 사용이 가능해 쉽게 사용가능하다.&lt;/li&gt;
&lt;li&gt;단일 인스턴스 서버이므로 장애가 발생시 데이터 손실 가능성이 생기며 별도의 복구 메커니즘이 존재하지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;817&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zl8uv/btsK1P0YnSF/FVJWTVVPlOlmXXi2DinEUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zl8uv/btsK1P0YnSF/FVJWTVVPlOlmXXi2DinEUk/img.png&quot; data-alt=&quot;Standalone 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zl8uv/btsK1P0YnSF/FVJWTVVPlOlmXXi2DinEUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzl8uv%2FbtsK1P0YnSF%2FFVJWTVVPlOlmXXi2DinEUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;153&quot; height=&quot;817&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;817&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Standalone 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Redis Sentinel 모드&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis의 노드간의 마스터 - 슬레이브 구조를 가진다. 이를 통해 장애가 발생하면 슬레이브 중 하나를 새로운 마스터로 승격시켜 서비스를 유지 시킨다.&lt;/li&gt;
&lt;li&gt;복잡한 클러스터 설정 없이도 기본적인 고가용성을 확보할 수 있다.&lt;/li&gt;
&lt;li&gt;데이터 샤딩을 지원하지 않기 때문에 데이터 저장 용량이나 트래픽 처리량 확장에 한계가 존재&lt;/li&gt;
&lt;li&gt;Standalone에 비하면 복잡한 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1029&quot; data-origin-height=&quot;772&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xVUYV/btsK3f4UlaI/kRaLwZBVTOQAqKp5iHUYkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xVUYV/btsK3f4UlaI/kRaLwZBVTOQAqKp5iHUYkK/img.png&quot; data-alt=&quot;Master Slave 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xVUYV/btsK3f4UlaI/kRaLwZBVTOQAqKp5iHUYkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxVUYV%2FbtsK3f4UlaI%2FkRaLwZBVTOQAqKp5iHUYkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;391&quot; height=&quot;293&quot; data-origin-width=&quot;1029&quot; data-origin-height=&quot;772&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Master Slave 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Redis Cluster 모드&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터 샤딩을 통해 데이터를 여러 마스터 노드에 나누어 저장해 수평적 확장을 지원한다.&lt;/li&gt;
&lt;li&gt;다중 마스터 구조로 클러스터 내의 각 마스터 노드는 데이터를 저장하고 이를 복제하는 슬레이브 노드를 가질 수 있다.&lt;/li&gt;
&lt;li&gt;자동 장애 복구가 존재하며 마스터 노드가 다운되면 클러스터가 슬레이브 노드 중 하나를 마스터로 승격 시켜 자동으로 복구한다.&lt;/li&gt;
&lt;li&gt;해시 슬록을 이용해 각 마스터 노드에 균등하게 분배하기 위해 16384개의 해시 슬롯을 사용한다.&lt;/li&gt;
&lt;li&gt;Standalone과 Sentinel에 비하면 설정과 관리가 복잡하며 노드간 통신과 데이터 재분배를 고려해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1057&quot; data-origin-height=&quot;949&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBdcIk/btsK2lLDd6w/YZVsWbLXiSJrh9Qx6OZOkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBdcIk/btsK2lLDd6w/YZVsWbLXiSJrh9Qx6OZOkK/img.png&quot; data-alt=&quot;Cluster 모드 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBdcIk/btsK2lLDd6w/YZVsWbLXiSJrh9Qx6OZOkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBdcIk%2FbtsK2lLDd6w%2FYZVsWbLXiSJrh9Qx6OZOkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;437&quot; height=&quot;392&quot; data-origin-width=&quot;1057&quot; data-origin-height=&quot;949&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Cluster 모드 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;결론&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 137px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt; 특징 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt; Standalone&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt; Sentinel&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt; Cluster &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;고가용성&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;마스터-슬레이브, 자동 복구&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;다중 마스터, 자동 복구&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;확장성&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;제한적&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;제한적&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;수평적 확장 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;데이터 샤딩&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;지원하지 않음&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;지원하지 않음&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;자동 장애 복구&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;지원하지 않음&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;지원&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;구조 복잡도&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;간단&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;중간&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;복잡&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;사용 사례&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;개발 환경, 소규모&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;고가용성이 필요한 중소규모&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;대규모 트래픽 및 고가용성 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Standalone&lt;/b&gt; 모드는 단순한 캐시나 소규모 프로젝트, 테스트 환경에서 적합하며, 복잡성이 낮고 유지보수가 간편합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Sentinel&lt;/b&gt; 모드는 &lt;b&gt;고가용성&lt;/b&gt;이 요구되지만 확장성 요구가 적은 경우에 적합합니다. 마스터-슬레이브 구조로 기본적인 고가용성을 제공하며, 자동 장애 복구가 가능하여 서비스 중단을 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Cluster&lt;/b&gt; 모드는 &lt;b&gt;대규모 트래픽 처리와 데이터의 수평적 확장&lt;/b&gt;이 필요한 경우에 사용됩니다. 여러 마스터 노드와 슬레이브 노드를 통한 고가용성과 자동 장애 복구 기능을 제공하며, 확장성이 뛰어나 분산 환경에서의 서비스에 적합합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Redis</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/147</guid>
      <comments>https://zks145.tistory.com/147#entry147comment</comments>
      <pubDate>Fri, 29 Nov 2024 23:46:03 +0900</pubDate>
    </item>
    <item>
      <title>PostMan을 이용한 Auth2.0 KAKAO Login Test</title>
      <link>https://zks145.tistory.com/146</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;사전 준비 사항&lt;/p&gt;
&lt;figure id=&quot;og_1732710356291&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Kakao Developers&quot; data-og-description=&quot;카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.&quot; data-og-host=&quot;developers.kakao.com&quot; data-og-source-url=&quot;https://developers.kakao.com/&quot; data-og-url=&quot;https://developers.kakao.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/AgQx3/hyXGFWRZNp/GYVkkTmX2H3B4TZ1Y3noT1/img.png?width=800&amp;amp;height=400&amp;amp;face=0_0_800_400&quot;&gt;&lt;a href=&quot;https://developers.kakao.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developers.kakao.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/AgQx3/hyXGFWRZNp/GYVkkTmX2H3B4TZ1Y3noT1/img.png?width=800&amp;amp;height=400&amp;amp;face=0_0_800_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Kakao Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developers.kakao.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Client id: 앱 설정 -&amp;gt; 앱 키 -&amp;gt; REST API 키&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;Client Secret: 제품 설정 -&amp;gt; 보안 -&amp;gt; 키 발급&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;요청 링크 : &lt;/span&gt;&lt;span style=&quot;color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt;&lt;a href=&quot;https://kapi.kakao.com/v2/user/me&quot;&gt;https://kapi.kakao.com/v2/user/me&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-27 오후 9.26.29.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;92&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcsY8s/btsKYACnxVG/MqBYZlqoof0vgt0ATNX8Jk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcsY8s/btsKYACnxVG/MqBYZlqoof0vgt0ATNX8Jk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcsY8s/btsKYACnxVG/MqBYZlqoof0vgt0ATNX8Jk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcsY8s%2FbtsKYACnxVG%2FMqBYZlqoof0vgt0ATNX8Jk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;92&quot; data-filename=&quot;스크린샷 2024-11-27 오후 9.26.29.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;92&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Authorization 설정&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Auth Type : OAuth 2.0&lt;/li&gt;
&lt;li&gt;Add authorization data to : Request Headers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-27 오후 9.27.12.png&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;478&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NDbXc/btsKY0HFB6q/WsG5US44aHJKMIJULY3tj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NDbXc/btsKY0HFB6q/WsG5US44aHJKMIJULY3tj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NDbXc/btsKY0HFB6q/WsG5US44aHJKMIJULY3tj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNDbXc%2FbtsKY0HFB6q%2FWsG5US44aHJKMIJULY3tj0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;470&quot; height=&quot;478&quot; data-filename=&quot;스크린샷 2024-11-27 오후 9.27.12.png&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;478&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Confugure New Token&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Token Name : 저장할 토큰 이름 ( 아무거나 입력 )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grant type : Authorization Code&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CallbackUrl : &lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt;&lt;a href=&quot;https://www.getpostman.com/oauth2/callback&quot;&gt;https://www.getpostman.com/oauth2/callback&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Auth URL : &lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt;&lt;a href=&quot;https://kauth.kakao.com/oauth/authorize&quot;&gt;https://kauth.kakao.com/oauth/authorize&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Access Token URL : &lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt;&lt;a href=&quot;https://kauth.kakao.com/oauth/token&quot;&gt;https://kauth.kakao.com/oauth/token&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client ID : REST API KEY&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client Secret : 제품 설정 -&amp;gt; 보안 -&amp;gt; 발급 받은 키&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client Authentication : Send client credentials in body&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2024-11-27 오후 9.28.51.png&quot; data-origin-width=&quot;1242&quot; data-origin-height=&quot;1092&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yIlBf/btsKYXqzN4L/oEzJGDl3ruwLN4jldlN2J1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yIlBf/btsKYXqzN4L/oEzJGDl3ruwLN4jldlN2J1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yIlBf/btsKYXqzN4L/oEzJGDl3ruwLN4jldlN2J1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyIlBf%2FbtsKYXqzN4L%2FoEzJGDl3ruwLN4jldlN2J1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;471&quot; height=&quot;414&quot; data-filename=&quot;edited_스크린샷 2024-11-27 오후 9.28.51.png&quot; data-origin-width=&quot;1242&quot; data-origin-height=&quot;1092&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>개발 고민</category>
      <author>js1024</author>
      <guid isPermaLink="true">https://zks145.tistory.com/146</guid>
      <comments>https://zks145.tistory.com/146#entry146comment</comments>
      <pubDate>Wed, 27 Nov 2024 21:34:43 +0900</pubDate>
    </item>
  </channel>
</rss>