<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>pie.log</title>
        <link>https://velog.io/</link>
        <description>⋆｡★⋆⁺₊⋆☾ ⁺⋆｡⋆ ☁︎｡₊⋆</description>
        <lastBuildDate>Thu, 13 Feb 2025 08:13:31 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>pie.log</title>
            <url>https://velog.velcdn.com/images/pie_e/profile/2e89cc40-1460-4c80-a6d9-41d30f773bf2/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. pie.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/pie_e" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[SQS 적용해보기 & AWS SQS, DLQ, CloudWatch 한번에 파헤치기]]></title>
            <link>https://velog.io/@pie_e/SQS-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0-AWS-SQS-DLQ-CloudWatch-%ED%95%9C%EB%B2%88%EC%97%90-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</link>
            <guid>https://velog.io/@pie_e/SQS-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0-AWS-SQS-DLQ-CloudWatch-%ED%95%9C%EB%B2%88%EC%97%90-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</guid>
            <pubDate>Thu, 13 Feb 2025 08:13:31 GMT</pubDate>
            <description><![CDATA[<h1 id="sqs-simple-queue-service-">SQS (Simple Queue Service) ?</h1>
<p>SQS는 마이크로서비스, 분산 시스템 및 서버리스 애플리케이션을 위한 완전 관리형 메시지 대기열 입니다. 간단히 설명하자면 애플리케이션 간의 메세지를 주고받기 위한 아주 간단한 Queue(또는 메세지 브로커)라고 생각하시면 됩니다.</p>
<h2 id="aws-sqs의-주요-개념">AWS SQS의 주요 개념</h2>
<ol>
<li>Queue (대기열) : SQS는 메세지를 저장하는 데 사용되는 대기열 입니다. 대기열은 메세지 저장소이며, 메세지가 비동기적으로 보내지고 처리됩니다. 각 대기열은 고유한 이름을 가지며, 여러 프로듀서 및 컨슈머가 동시에 대기열에 접근할 수 있습니다.</li>
<li>Message (메세지) : 메세지는 프로듀서가 컨슈머에게 전송하는 단위입니다. 메세지는 최대 256KB까지의 페이로드를 가질 수 있습니다. </li>
<li>Producer (프로듀서) : 프로듀서는 메세지를 SQS 대기열에 보내는 애플리케이션 또는 서비스입니다. 프로듀서는 메세지를 대기열에 전송하고, 필요에 따라 메세지 속성과 디바운스(딜레이) 시간을 설정할 수 있습니다.</li>
<li>Consumer (컨슈머) : 컨슈머는 SQS 대기열에서 메세지를 받아 처리하는 애플리케이션 또는 서비스입니다. 컨슈머는 대기열에서 메세지를 읽고, 해당 메세지에 대한 작업을 수행한 후 메세지를 삭제합니다. 여러 컨슈머가 대기열에 동시에 작업을 처리할 수 있으며, SQS는 메세지의 가용성과 처리 성능을 보장합니다.</li>
</ol>
<h2 id="sqs의-처리-과정">SQS의 처리 과정</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/1faf9fb4-b353-47c7-9240-7e068dbccef1/image.png" alt=""></p>
<p>위 그림은 일반적인 Queue의 처리과정 입니다.</p>
<ul>
<li>Procucer는 메세지를 생성하여 Queue로 메세지를 전달합니다.</li>
<li>Queue는 메세지를 일정 기간 가지고 있게 됩니다.</li>
<li>Consumer는 주기적으로 Queue를 Polling하면서 신규 메세지가 있다면 가져가서 처리합니다.</li>
<li>처리가 끝나면 Queue로 Ack를 전송합니다. (메세지 아이디에 해당하는 Ack를 받으면 Queue에서 메세지를 제거합니다.)</li>
</ul>
<p>이렇게 프로듀서와 컨슈머를 Queue라는 미들웨어로 분리하면, 각 시스템에 영향을 받지 않고 원하는 작업을 수행할 수 있습니다.</p>
<h2 id="sqs-대기열">SQS 대기열</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/8489596e-1ede-47c8-8e2b-ea4fa765a46c/image.png" alt=""></p>
<h3 id="표준-대기열-standard-queue">표준 대기열 (Standard Queue)</h3>
<p><strong>장점</strong></p>
<ul>
<li>무제한에 가까운 메세지 전송을 지원(최대 처리량) 합니다.</li>
<li>최소 1회 전달을 보장합니다. 단, 중복 수신이 될 수 있습니다.</li>
<li>Best-Effort-Ordering : 최대한 순서를 보장하고자 노력합니다. (하지만 신뢰할 수 는 없습니다.)</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>메세지의 순서를 보장할 수 없습니다.</li>
<li>반드시 1번만 읽기 보장을 할 수 없습니다. (중복 읽기 가능성이 존재합니다.)</li>
</ul>
<h3 id="fifo-대기열-first-in-first-out-queue">FIFO 대기열 (First In First Out Queue)</h3>
<p><strong>장점</strong></p>
<ul>
<li>메세지의 순서를 보장합니다.</li>
<li>Exactly-Once Processing : 1번의 전송, 1번의 수신을 보장할 수 있습니다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>Consumer가 하나여야 합니다.</li>
<li>Limited Throughput : 순서를 위해 초당 300TPS 제한이 있습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/pie_e/post/749af9b6-91e9-489b-97fd-fe37ffbd520a/image.png" alt=""></p>
<h1 id="1-sqs-적용하기">1. SQS 적용하기</h1>
<h2 id="overview">Overview</h2>
<p>현재 본인은 e-commerce 도메인의 이벤트 드리븐 아키텍처 환경을 고려한 주문 &amp; 선물하기 프로젝트를 진행하던 중 다음과 같은 시나리오에서 양방향 통신에 문제가 발생했습니다.</p>
<ol>
<li>Gift 프로젝트에서 Order 프로젝트로 주문 생성 API 요청을 전달합니다.</li>
<li>Order 프로젝트는 해당 요청을 받아 주문건을 성공적으로 처리합니다.</li>
<li>Gift 프로젝트는 또 한번 Order 프로젝트로 해당 주문건에 대한 결제를 요청합니다.</li>
<li>Order 프로젝트는 결제를 완료한 후, Gift 프로젝트로 결제에 대한 응답을 보내줘야 합니다. 이 과정에서 양방향 통신 문제가 발생합니다.</li>
</ol>
<p>주문 서비스와 선물하기 서비스 간의 메세지 브로커를 두고 메세지 기반 비동기 통신을 하며 Gift → Order의 방향성을 제거하기 위해, SQS 도입을 결정했습니다. </p>
<ul>
<li>Order : SqsMessageSender</li>
<li>Gift :  SqsMessageListener</li>
</ul>
<h2 id="sqs-대기열-생성하기">SQS 대기열 생성하기</h2>
<p>해당 프로젝트에선 메세지의 순서를 지켜야 하기때문에 FIFO 유형을 선택했습니다. FIFO 유형의 SQS를 만들고 싶다면 이름 뒤에 꼭 .fifo를 붙여줘야 합니다</p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/dacc9854-e23b-493e-a479-c7fc31f24db7/image.png" alt=""></p>
<h2 id="메세지-config-설정">메세지 Config 설정</h2>
<p>기본값으로 설정했습니다. 해당 구성은 메세지의 크기, 보존 기간 등을 설정할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/28cd7549-a80c-4355-9871-e4240a1b4548/image.png" alt=""></p>
<ul>
<li>표시 제한 기간<ul>
<li>한 컨슈머가 대기열에서 수신한 메세지가 다른 메세지 컨슈머에게 보이지 않게 되는 시간</li>
<li>컨슈머가 메세지를 가져가고, 처리 및 삭제하지 못하면 다른 컨슈머에게 메세지가 보이게 됩니다. (즉, 1번만 보여야 하는 메세지라면 시간 내 삭제가 필요합니다.)</li>
<li>기본값은 30초 입니다. (컨슈머가 처리하는 시간에 따라 조정하시면 됩니다.)</li>
<li>한번 설정하면 큐 내부의 모든 메세지에 대해 적용됩니다.</li>
<li>최적의 성능을 위해 표시 제한 시간 초과는 AWS SDK 읽기 제한 시간보다 길게 설정해야 합니다.</li>
</ul>
</li>
<li>메세지 보존 기간<ul>
<li>Amazon SQS가 삭제되지 않은 메세지를 보관하는 시간입니다.</li>
<li>보존기간 범위 1분 → 14일까지 가능합니다.</li>
<li>대기열, 배달하지 못한 메세지 대기열 통합 시간입니다. (배달되지 못한 메세지 대기열의 보존 기간을 원래 대기열의 보존 기간보다 길게 잡아야 합니다.)</li>
</ul>
</li>
<li>전송 지연<ul>
<li>이 대기열에 추가된 각 메세지의 첫 번째 전송에 대한 지연 시간입니다.</li>
<li>지정된 시간동안 메세지는 컨슈머에게 소비되지 않고 지연된다.</li>
<li>0초~15분까지 설정 가능합니다.</li>
<li>Standard 는 한번 설정된 시간은 계속 흘러 지연되었다가 진행합니다.</li>
<li>FIFO 는 설정 변경하면 이후 메시지부터 적용됩니다.</li>
</ul>
</li>
<li>최대 메세지 크기<ul>
<li>이 대기열에 최대 메시지 크기입니다.</li>
<li>1바이트 ~ 256KB까지 가능합니다.</li>
<li>256KB보다 큰 메시지 전송시에는 Amazon SQS Extended Client Library 를 이용하여 전송 가능합니다.</li>
<li>Amazon S3에 메시지 로드에 대한 참조가 포함된 메시지 전송합니다.</li>
<li>최대 크기는 2GB까지 가능합니다.</li>
</ul>
</li>
<li>메세지 수신 대기 시간<ul>
<li>폴링이 메시지를 사용할 수 있을 때까지 기다리는 최대 시간입니다.</li>
<li>0초 ~ 20초까지 가능합니다.</li>
<li>긴 폴링은 빈 응답수 (ReceiveMessage 요청에 사용할 수 메시지가 없는경우)와 잘못된 빈 응답(메시지를 사용할 수 있지만 응답에 포함되지 않은경우)를 제거 하여 Amazon SQS 사용 비용 줄이기 가능합니다.</li>
<li>수신 요청이 최대 메시지 수를 수집하면 즉시 반환합니다.</li>
<li>0으로 설정하면 짧은 폴링이 됩니다.</li>
</ul>
</li>
<li>콘텐츠 기반 중복 제거<ul>
<li>Amazon SQS 는 메시지 본문에 기반하여 중복 제거 ID를 자동 생성 가능합니다.<h2 id="암호화">암호화</h2>
</li>
</ul>
</li>
</ul>
<p>SQS 메세지에 대한 암호화입니다. 비활성화 해주었습니다.</p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/3153f175-4e41-4c0f-b694-dbd59c479853/image.png" alt=""></p>
<h2 id="액세스-정책">액세스 정책</h2>
<p>액세스 정책도 기본값으로 진행했습니다. 액세스 정책은 이 대기열에 액세스 할 수 있는 계정 및 사용자와 허용되는 작업을 정의합니다. </p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/2882d416-b82d-4b69-8d40-388301bdbeb6/image.png" alt=""></p>
<h2 id="리드라이브와-배달-못한-편지-대기열">리드라이브와 배달 못한 편지 대기열</h2>
<p>비활성화로 설정했습니다. </p>
<ul>
<li>리드라이브 허용 정책 설정은 메시지 처리 실패 시 대응 방법을 정의하는 것이고, 그중 하나의 옵션이 배달 못한 편지 대기열 설정입니다. 이 설정을 통해 실패한 메시지를 어떻게 처리할지 결정할 수 있습니다.</li>
<li>배달 못한 편지 대기열 설정은 실패한 메시지를 위한 DLQ(Dead Letter Queue)를 지정하는 부분입니다. 메시지가 설정된 재시도 횟수를 초과하여 여전히 처리되지 못했을 때, 해당 메시지를 DLQ로 보내서 관리할 수 있게 해 줍니다. 이렇게 DLQ를 사용하면 실패한 메시지를 분리하여 문제 해결을 위한 분석이나 후속 조치를 취할 수 있습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/pie_e/post/2c2ef840-43cb-4794-8d6f-c35978cd7469/image.png" alt=""></p>
<h2 id="태그">태그</h2>
<p>작성하지 않고 하단의 대기열 생성 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/ece4739a-fadf-4b8f-9e4c-1e110f493449/image.png" alt=""></p>
<h3 id="iam-설정하기">IAM 설정하기</h3>
<p>오른쪽 상단의 사용자 생성 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/59baef21-c1e6-4b0f-bf24-3a6c6ea09bee/image.png" alt=""></p>
<p>사용자 이름을 설정해 준 뒤 오른쪽 하단의 다음 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/63b90780-c205-4924-a2d9-8b84704b91bf/image.png" alt=""></p>
<p>직접 정책 연결 → AmazonSQSFullAccess 권한을 체크한 뒤 오른쪽 하단의 다음 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/a69820ef-2945-4c01-91d8-64898d3fb889/image.png" alt=""></p>
<p>태그 추가는 입력없이 skip한 후 사용자 생성 버튼을 눌러줍니다.</p>
<h3 id="액세스-키-만들기">액세스 키 만들기</h3>
<p>새로 만든 사용자에 들어가 액세스 키1 아래에 있는 액세스 키 만들기를 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/8df61019-db90-48e7-a4ee-a0ab719bf3f8/image.png" alt=""></p>
<p>Command Line Interface(CLI)를 선택 후 다음 버튼 클릭합니다.
<img src="https://velog.velcdn.com/images/pie_e/post/cfb70a2f-5512-4e3b-8b3e-8d2a119fcae8/image.png" alt=""></p>
<p>액세스 키 만들기 버튼을 클릭합니다.
<img src="https://velog.velcdn.com/images/pie_e/post/29c7ab5f-9dde-4575-bb57-749f2ed4df8e/image.png" alt=""></p>
<p>csv 파일 다운로드 후 액세스 키 생성을 완료합니다.</p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/6158934c-e541-49a7-bbd2-c714feb1dd87/image.png" alt=""></p>
</div>
</details>

<h2 id="프로젝트-구성">프로젝트 구성</h2>
<pre><code>Java 11
SpringBoot 2.7.16</code></pre><h2 id="buildgradle">build.gradle</h2>
<pre><code>// aws sqs
implementation platform(&#39;software.amazon.awssdk:bom:2.15.0&#39;)
implementation &#39;org.springframework.cloud:spring-cloud-aws-messaging:2.2.1.RELEASE&#39;</code></pre><h2 id="applicationyml">application.yml</h2>
<pre><code class="language-yaml">example.order:
  base-url: http://localhost:8080/

cloud:
  aws:
    access-key: ${aws.access-key}
    secret-key: ${aws.secret-key}</code></pre>
<p>AWS IAM에서 만든 access-key와 secret-key를 .yml에 작성해줍니다. access-key와 secret-key는 노출되면 안되기 때문에 application-secret.yml 파일을 따로 작성하여 저장했습니다.</p>
<h2 id="awssqsconfig-작성">AwsSqsConfig 작성</h2>
<pre><code class="language-java">@Configuration
public class AwsSqsConfig {
    @Value(&quot;${cloud.aws.access-key}&quot;)
    private String awsAccessKey;

    @Value(&quot;${cloud.aws.secret-key}&quot;)
    private String awsSecretKey;

    @Bean
    public AmazonSQSAsync amazonSQSAsync() {
        AWSCredentialsProvider awsCredentialsProvider = new AWSStaticCredentialsProvider(
            new BasicAWSCredentials(awsAccessKey, awsSecretKey));

        return AmazonSQSAsyncClientBuilder
            .standard()
            .withRegion(Regions.AP_NORTHEAST_2)
            .withCredentials(awsCredentialsProvider)
            .build();
    }

    @Bean
    public QueueMessagingTemplate queueMessagingTemplate(AmazonSQSAsync amazonSQSAsync) {
        return new QueueMessagingTemplate(amazonAsync());
    }
}</code></pre>
<p>QueueMessageTemplate를 사용하여 SQS Queue에 메세지를 보낼 수 있습니다. </p>
<h2 id="awssqssender-작성">AwsSqsSender 작성</h2>
<pre><code class="language-java">public class AwsSqsSender {
    private final String SQS_QUEUE_NAME = &quot;SQS를 생성할 당시 작성한 이름.fifo&quot;;
    private final QueueMessagingTemplate queueMessagingTemplate;

    public void messageSender(Message message) {
        try {
                Map&lt;String, Object&gt; headers = new HashMap&lt;&gt;();
                headers.put(SqsMessageHeaders.SQS_GROUP_ID_HEADER, &quot;item-queues&quot;);
                headers.put(SqsMessageHeaders.SQS_DEDUPLICATION_ID_HEADER, UUID.randomUUID().toString());
                queueMessagingTemplate.convertAndSend(SQS_QUEUE_NAME, message, headers);
        } catch (Exception e) {
                throw new RuntimeException(e);
        }
    }
}</code></pre>
<p>FIFO Queue로 전송한다면 메세지 헤더에 FIFO Queue를 나타내는 값을 실어서 보낼 수 있습니다.  FIFO Queue는 SQS_GROUP_ID_HEADER로 메세지 그룹핑이 필요합니다. 또한 SQS_DEDUPLICATION_ID_HEADER를 지정하여 중복을 제거합니다. 중복을 구분할 내용은 메세지 중복 키를 받아서 convertAndSend() 메서드로 메세지를 전송합니다.</p>
<h2 id="awssqslistnerconfig-작성">AwsSqsListnerConfig 작성</h2>
<pre><code class="language-java">@Component
public class AwsSqsListnerConfig {

    @Bean
    public SimpleMessageListenerContainerFactory simpleMessageListenerContainerFactory(AmazonSQSAsync amazonSQSAsync) {
            SimpleMessageListenerContainerFactory factory = new SimpleMessageListenerContainerFactory();
            factory.setAmazonSqs(amazonSQSAsync);
            factory.setAutoStartup(true); // 초기화 후 컨테이너를 자동으로 시작할 지 여부를 결정합니다.
            return factory;
    }

    @Bean
    public SimpleMessageListenerContainer simpleMessageListenerContainer(
        SimpleMessageListenerContainerFactory simpleMessageListenerContainerFactory,
        QueueMessageHandler queueMessageHandler,
        ThreadPoolTaskExecutor messageThreadPoolTaskExecutor) {
        SimpleMessageListenerContainer container = simpleMessageListenerContainerFactory.createSimpleMessageListenerContainer();
        container.setMessageHandler(queueMessageHandler);
        container.setTaskExecutor(messageThreadPoolTaskExecutor);
        return container;
    }

    @Bean
    @ConditionalOnMissingBean(QueueMessageHandler.class)
    public QueueMessageHandlerFactory queueMessageHandlerFactory(AmazonSQSAsync amazonSQSAsync) {
        QueueMessageHandlerFactory factory = new QueueMessageHandlerFactory();
        factory.setAmazonSqs(amazonSQSAsync);

        MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter();
        messageConverter.setStrictContentTypeMatch(false);
        factory.setArgumentResolvers(Collections.singletonList(new PayloadMethodArgumentResolver(messageConverter)));
        return factory;
    }

    @Bean
    @ConditionalOnMissingBean(QueueMessageHandler.class)
    public QueueMessageHandler queueMessageHandler(QueueMessageHandlerFactory queueMessageHandlerFactory) {
        return queueMessageHandlerFactory.createQueueMessageHandler();
    }

    @Bean
    public ThreadPoolTaskExecutor messageThreadPoolTaskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setThreadNamePrefix(&quot;sqs-&quot;);
        taskExecutor.setCorePoolSize(8);
        taskExecutor.setMaxPoolSize(100);
        taskExecutor.afterPropertiesSet();
        return taskExecutor;
    }
}</code></pre>
<p>메세지를 수신을 위한 핸들러와 리스너에 대한 정보를 설정해줍니다.</p>
<h2 id="awssqsmessagelistner">AwsSqsMessageListner</h2>
<pre><code class="language-java">public class AwsSqsMessageListner {

    @SqsListner(value = &quot;SQS 이름.fifo&quot;, deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
    public void readMessage(String message) {
        log.info(&quot;message&quot; : {} &quot;, message);
    }
}</code></pre>
<ul>
<li>deletionPolicy : 메세지를 받은 이후의 삭제 정책 입니다.<ul>
<li>ALWAYS : 메서드로 message가 들어오면 무조건 삭제 요청을 보낸다.</li>
<li>NEVER : 절대 삭제 요청을 보내지 않는다.</li>
<li>NO_REDRIVE : redrive policy(DLQ)가 정의되지 않았으면 메세지를 삭제한다.</li>
<li>ON_SUCCESS : SqsListner 애노테이션이 붙은 메서드에서 에러가 나지 않으면 메세지를 삭제한다.<pre><code>                      (에러가 난 경우는 삭제하지 않음.)</code></pre></li>
</ul>
</li>
</ul>
<h2 id="하지만-listner가-sqs에서-메세지를-제대로-전달받지-못하면-어떡하지">하지만 Listner가 SQS에서 메세지를 제대로 전달받지 못하면 어떡하지?</h2>
<p>물론 그런 경우는 매우 희박하겠지만, 다양한 이유로 메세지를 못 받을 가능성 역시 배제할 수 없습니다. 이런 경우를 위해 AWS SQS는 DLQ라는 기능을 제공합니다. </p>
<h1 id="2-dlq-적용하기">2. DLQ 적용하기</h1>
<h2 id="dlq-dead-letter-queue란-">DLQ (Dead Letter Queue)란 ?</h2>
<p>DLQ(Dead Letter Queue)는 소프트웨어 시스템에서 오류로 인해 처리할 수 없는 메시지를 임시로 저장하는 특수한 유형의 메시지 대기열입니다.</p>
<h2 id="dlq-어떻게-활용할까요-">DLQ 어떻게 활용할까요 ?</h2>
<p>DLQ에 쌓인 메시지들을 보면 왜 이 메시지들이 컨슈머에 의해 처리되지 못했는지를 알 수 있습니다. 만약 컨슈머 애플리케이션의 버그를 찾아서 수정했다면 AWS 콘솔 화면에서 redrive를 수행함으로써 해당 메시지들을 다시 소스 큐에 집어넣을 수 있습니다.</p>
<p>DLQ에 메시지가 쌓인다면, 컨슈머 어플리케이션을 디버깅해서 왜 컨슘이 실패했는지를 분석하고 패치한 다음, DLQ redrive를 하면 다시 메시지들을 소스 큐로 편하게 클릭 한 방으로 전송할 수 있습니다.</p>
<blockquote>
<p>주의사항</p>
</blockquote>
<ul>
<li>메세지 최대 보관 기간은 원래 Queue의 보관 기간보다 길어야 합니다.</li>
<li>FIFO 큐의 DLQ 또한 FIFO 큐여야 하며, 마찬가지로 Standard 큐의 DLQ 역시 Standard여야 합니다.</li>
</ul>
<h2 id="dlq를-위한-대기열-생성">DLQ를 위한 대기열 생성</h2>
<p>AWS의 SQS로 들어가 대기열을 새로 생성해줍니다. SQS를 FIFO로 설정해두었으니 DLQ 또한 FIFO로 설정해줍니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/f5bcf769-4aa9-4877-92cb-2796b2d2a8b1/image.png" alt=""></p>
<p>여기서 메세지 보존기간은 꼭 SQS에서 설정한 시간보다 길게 잡아줘야 합니다. 본인은 SQS에서 4일 SQS-DLQ에서는 8일로 잡았습니다.
DLQ에 들어온 메세지는 DLQ에 들어온 시간부터 타임을 체크하는 것이 아닌 메세지가 생성된 기준이기 때문입니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/b9738219-fc32-4043-94e0-e80106e0aeca/image.png" alt=""></p>
<p>SQS와 같이 암호화는 비활성화 해줬습니다.
<img src="https://velog.velcdn.com/images/pie_e/post/a5b14c3d-e944-41e6-9b43-7bb672c9a5ab/image.png" alt=""></p>
<p>엑세스 정책은 기본값으로 설정합니다.
<img src="https://velog.velcdn.com/images/pie_e/post/4e40aed9-f538-4be7-9d3d-07cc51d4d320/image.png" alt=""></p>
<p>모두 기본값으로 설정 해준 뒤 오른쪽 하단<span style='background-color: #FF9848'><strong> 대기열 생성 </strong></span> 버튼을 눌러줍니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/fd0f9e48-6c15-42e2-b86b-18261422567d/image.png" alt=""></p>
<h2 id="sqs-설정">SQS 설정</h2>
<p>이제 처음에 만들었던 SQS로 돌아가서 상단의 편집 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/3b2a62db-7230-4f34-be54-9b5fb843ca00/image.png" alt=""></p>
<p>배달 못한 편지 대기열로 들어가 활성화 버튼을 눌러주고 방금 생성했던 dlq를 선택합니다. 최대 수신 수란 실패 시 재시도하는 수를 말합니다. 기존의 Queue에서 메시지 폴링 작업을 행할 시 해당 메시지의 수신 수가 증가하게 되는데 <strong>최대 수신 수</strong>가 넘어갈 시 기존 Queue에 저장되었던 메시지가 DLQ로 넘어가게 됩니다.</p>
<p>설정이 끝나면<span style='background-color: #FF9848'> <strong>저장 </strong></span> 버튼을 눌러주면 끝입니다.
<img src="https://velog.velcdn.com/images/pie_e/post/42fd6650-d0e5-42b4-b7fc-66644d39e502/image.png" alt=""></p>
<h1 id="3-cloudwatch를-이용하여-dlq에-메세지가-들어온다면-알람-보내기">3. CloudWatch를 이용하여 DLQ에 메세지가 들어온다면 알람 보내기</h1>
<p>만약 DLQ에 메세지가 들어온다면 처리되지 못한 메세지가 있다는 뜻이니, 메세지가 전달되지 못한 이유를 빨리 해결하기 위해 CloudWatch를 통해 알람 설정을 해보겠습니다.</p>
<h3 id="cloudwatch란-">CloudWatch란 ?</h3>
<p>Amazon CloudWatch는 <strong>AWS 리소스</strong>와 <strong>AWS에서 실시간으로 실행 중인 애플리케이션</strong>을 <strong>모니터링</strong> 하는 서비스 입니다. 지표를 감시해 알림을 보내거나 임계값을 위반한 경우 모니터링 중인 리소스를 자동으로 변경하는 <strong>경보</strong>를 생성할 수 있습니다. 예를 들어 경보는 인스턴스 중지, auto scaling 및 Amazon SNS 작업 시작, 종료 등으로 구성할 수 있습니다.
<img src="https://velog.velcdn.com/images/pie_e/post/f45f01f5-52e3-4bda-ac19-75e118a877b6/image.png" alt=""></p>
<h2 id="cloudwatch-적용하기">CloudWatch 적용하기</h2>
<p>AWS의 CloudWatch로 들어가 왼쪽 메뉴바에 있는 경보 → 모든 경보로 들어가 줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/c4985f3b-ba4f-45fd-862b-3d73a41cb931/image.png" alt=""></p>
<p>오른쪽 상단에 있는<span style='background-color: #FF9848'> <strong> 경보 생성 </strong></span>버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/0306a4ca-525e-4a06-a2f3-abbad994a8f0/image.png" alt=""></p>
<p>맨 처음 지표 및 조건 지정에서 지표를 선택 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/58c976b5-5fb6-41a8-b0fe-a4dc7e165b52/image.png" alt=""></p>
<p>SQS → 대기열 지표를 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/7612bbcc-51be-4d9c-b591-a71bc4364594/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/6e3fbe77-be63-40b5-af04-74784fb0b228/image.png" alt=""></p>
<p>만들어둔 DLQ가 여러개 뜨는데 지표 이름이 NumberOfMessagesReceived인 DLQ를 체크해준 뒤<span style='background-color: #FF9848'> <strong> 지표 선택 </strong></span>버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/e2960499-e566-4b39-91cc-17f475dfb1b7/image.png" alt=""></p>
<p>통계 → 합계로 선택해줍니다. 그래야 DLQ에 메세지가 한개라도 들어오면 경보를 받을 수 있습니다.
<img src="https://velog.velcdn.com/images/pie_e/post/97ae317b-7793-4dd0-99ab-d344df26a03a/image.png" alt=""></p>
<p>조건에서는 보다 크거나 같음 선택 → … 보다에서 1을 입력해줍니다. 이 또한 마찬지로 메세지가 1개라도 들어오면 경보 울립니다.<span style='background-color: #FF9848'> <strong> 다음 </strong></span>버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/bdfffce3-e9fb-4dbc-8d4e-0201b0426abc/image.png" alt=""></p>
<p>알림에서는 새 주제 생성을 체크해주고 해당 주제의 이름을 정해줍니다. 알림을 수신할 이메일 엔드포인트는 알림을 받을 이메일의 주소를 적어주면 됩니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/f0b81ae6-d155-49db-9579-7f8e46991df4/image.png" alt=""></p>
<p>이름 및 설명 추가 부분에서 경보 이름을 지정해 주고 해당 경보에 대한 설명을 작성해 준 뒤<span style='background-color: #FF9848'> <strong> 다음 </strong></span> 버튼을 누르고 그 다음<span style='background-color: #FF9848'> <strong>생성 </strong></span>버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/pie_e/post/64f5a605-57e2-40f7-9646-a7391c4188e8/image.png" alt=""></p>
<p>이후 DLQ에 메세지가 들어오게 되면 아래와 같은 알람이 들어옵니다.
<img src="https://velog.velcdn.com/images/pie_e/post/80b090a7-0cb1-4e90-8865-3311005f72d1/image.png" alt=""></p>
<h2 id="끝">끝!</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/6a30c3d6-32bc-4b58-a2a5-d3441f7c737b/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS S3의 presigned URL을 이용한 이미지 업로드 기능]]></title>
            <link>https://velog.io/@pie_e/AWS-S3%EC%9D%98-presigned-URL%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@pie_e/AWS-S3%EC%9D%98-presigned-URL%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Wed, 12 Feb 2025 07:34:33 GMT</pubDate>
            <description><![CDATA[<h2 id="presigned-url이란">Presigned URL이란?</h2>
<p>미리 서명된 URL을 사용하여 다른 사람이 Amazon S3 버킷에 객체를 업로드할 수 있는 기능입니다. 
즉, 서버를 통해 컨텐츠를 S3 버킷에 업로드하는게 아닌, AWS 보안 자격 증명이나 권한이 없어도 사용자가 직접 미리 서명된 URL을 이용하여 S3 버킷에 컨텐츠를 업로드할 수 있게 됩니다. </p>
<h3 id="왜-presigned-url일까">왜 presigned URL일까?</h3>
<p>이전에 사용하던 <code>MultipartFile</code>과의 차이를 얘기하자면
<img src="https://velog.velcdn.com/images/pie_e/post/bd35fde6-b0e5-42fd-a8ef-aa95568391d7/image.png" alt="">
<img src="https://velog.velcdn.com/images/pie_e/post/de659fee-02d1-4352-885e-51e50bd1f6b6/image.png" alt="">
<code>MultipartFile</code>은 client가 파일 업로드를 요청하면 서버에서 파일의 바이너리를 <code>MultipartFile</code> 객체로 변환 시킨 후 S3에 스트림 데이터로 파일을 업로드 시키고, 또 S3는 서버의 권한을 검증하는 등 <strong>서버의 부하가 매우 큰 작업</strong>입니다. </p>
<p>따라서 이미지 업로드가 백엔드 서버를 거치게 되면 최악의 상황엔 서버는 금방 죽을 수 도 있고, 아니면 다수의 사용자로부터 동시에 요청을 제한하거나 파일 업로드를 전담하는 서버를 별도로 분리하는 등 추가적인 작업을 야기합니다.</p>
<p>그렇다면 <code>Presigned URL</code>은 어떨까요?
<img src="https://velog.velcdn.com/images/pie_e/post/156d45fc-5135-42d7-b994-f5ed25135ebd/image.png" alt="">
<code>Presigned URL</code>은 업로드할 파일을 작은 part로 나누어 각 부분을 개별적으로 업로드 합니다. 
그리고 파일의 바이너리가 spring boot를 거치지않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도되는 큰 장점을 가지고 있습니다. 
백엔드 서버는 <code>Presigned URL</code> 생성으로 보안절차 작업만 해주면 됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/acae520b-ac74-4f92-8084-483026b685d4/image.png" alt="">
<strong>모든 part가 업로드 되었을 경우 AWS에서 하나의 객체로 조립하여 저장</strong>합니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/36ac8103-3f74-4511-a061-76331eadc245/image.png" width="80%" height="80%">
또한 몇개의 파트가 업로드 되었는지 확인하여 위와 같이 사용자에게 업로드 진행사항을 제공할 수 도 있습니다.</p>
<h3 id="multipart-upload-방식">Multipart Upload 방식</h3>
<p>Multipart Upload는 총 4단계 프로세스로 구성되어 있습니다.</p>
<h4 id="1-multipart-업로드-시작">1. Multipart 업로드 시작</h4>
<p>멀티파트 업로드 시작(<code>initiate-upload</code>)을 요청하면 서버는 멀티파트 업로드에 대한 고유 식별자인 <code>Upload ID</code>를 응답합니다. 
부분 업로드, 업로드 완료 또는 업로드 중단 요청시 항상 <code>Upload ID</code>를 포함해야 하기 때문에 클라이언트는 이 값을 잘 저장해야 합니다.</p>
<h4 id="2-presignedurl-발급">2. PresignedURL 발급</h4>
<p>업로드를 위한 AWS의 서명된 URL을 발급받는 요청입니다. 
멀티파트 시작 요청에서 받은 <code>UploadID</code> 그리고 <code>PartNumber</code> 값을 함께 요청해야 합니다. 
<code>PartNumber</code>는 1부터 10,000까지 파트 번호 지정이 필요합니다. 
AWS에서 파트 번호를 이용하여 업로드하는 객체의 각 부분과 그 위치를 고유하게 식별하기 때문입니다. 파트 번호는 연속적인 시퀀스로 선택할 필요는 없습니다. 
만약 이전에 업로드한 부분과 동일한 부분 번호로 새 부분을 업로드할 경우 이전에 업로드한 부분을 덮어쓰게 됩니다.</p>
<h4 id="3-presignedurl-part-업로드">3. PresignedURL part 업로드</h4>
<p>발급받은 <code>PresignedURL</code>에 <code>PUT</code> 메서드로 파트의 바이너리를 실어서 요청합니다. 이때 파트의 용량은 클라이언트에서 결정하여 업로드합니다. </p>
<p>분할된 파트는 5MB~5GB의 크기만 가능합니다. 다만 마지막 파트는 5MB 이하도 괜찮습니다. 
만약 업로드할 파일의 크기가 5MB 이하라면 파트 업로드는 한번 수행되어야 합니다. 
즉, 첫번째 파트가 마지막 파트이기도 하다면 위 규칙은 위반되지 않으며 AWS S3는 멀티파트 업로드로 수락하여 객체를 생성하게 됩니다. 파트는 최대 5GB까지 10,000개까지 업로드할 수 있으니 이론상으로 5TB 크기의 파일까지 업로드할 수 있습니다.</p>
<p>파트를 업로드 후 받는 응답 헤더에 <a href="https://ko.wikipedia.org/wiki/MD5">MD5</a> <a href="https://ko.wikipedia.org/wiki/%EC%B2%B4%ED%81%AC%EC%84%AC">Checksum</a>값인 <code>ETag(Entity Tag)</code>가 포함되어 있습니다. 클라이언트는 각 파트 업로드 시 <code>PartNumber</code>와 <code>ETag</code>값을 매칭하여 보관합니다. 이후 멀티파트 업로드 완료 요청에 이러한 값을 포함해야 하기 때문입니다.</p>
<h4 id="4-multipart-업로드-완료">4. Multipart 업로드 완료</h4>
<p>멀티파트 업로드 완료는 <code>UploadID</code>, 각 <code>PartNumber</code>와 매칭되는 <code>ETag</code>값이 배열로 포함되어야 합니다. 
업로드 완료가 수행되어야 AWS S3에서 <code>PartNumber</code>와 <code>ETag</code>를 기준으로 객체를 재조립합니다. 
객체가 매우 클 경우 이 프로세스는 몇 분 정도 걸릴 수 있습니다. 
만약 업로드 완료를 하지 않을 경우 S3 버킷에 파일이 생성되지 않습니다. </p>
<h4 id="예외-multipart-업로드-취소">예외. Multipart 업로드 취소</h4>
<p>하나 이상의 파트가 업로드된 상태에서 예기치 못한 이슈가 발생한다면 멀티파트 업로드를 완료하거나 취소해야 업로드된 파트의 스토리지 비용이 청구되지 않습니다. </p>
<p>따라서 클라이언트에서 예외 발생 시 멀티파트 업로드를 취소할 것을 권장 드립니다. 단, 한번 취소된 <code>UploadID</code>로 다시 파트를 업로드할 수 없습니다.</p>
<h2 id="presigned-url-적용기">Presigned URL 적용기</h2>
<h3 id="gradle-설정">gradle 설정</h3>
<p>아래와 같은 의존성을 추가 합니다.</p>
<pre><code class="language-java">implementation platform(&#39;software.amazon.awssdk:bom:2.17.53&#39;)
implementation &#39;software.amazon.awssdk:s3&#39;

// presigned-url 테스트를 위해 html 파일 추가할 예정
implementation &#39;org.springframework.boot:spring-boot-starter-thymeleaf&#39;</code></pre>
<h3 id="s3config-설정">S3Config 설정</h3>
<p>AWS 서버와 통신할 <code>S3Client</code>, 서명된 Url을 발급받기 위한 <code>S3Presigner</code>, 시크릿 키와 엑세스 키로 인증할 <code>AwsCredentials</code>를 스프링 빈으로 등록합니다. </p>
<pre><code class="language-java">@Configuration
public class S3Config {

    @Bean
    public S3Client s3Client(S3Properties s3Properties) {
        AwsBasicCredentials credentials = getCredentials(s3Properties);

        return S3Client.builder()
                        .region(Region.of(s3Properties.getRegion())
                        .credentialsProvider(StaticCredentialsProvider.create(credentials))
                        .build();

    @Bean
    public S3Presigner s3Presigner(S3Properties s3Properties) {
        AwsBasicCredentials credentials = getCredentials(s3Properties);

        return S3Presigner.builder()
                        .region(Region.of(s3Properties.getRegion()))
                        .credentialsProvider(StaticCredentialsProvider.create(credentials))
                        .build();
    }

    private AwsBasicCredentials getCredentials(S3Properties s3Properties) {
        return AwsBasicCredentials.create(
                                s3Properties.getCredentials().getAccessKey(),
                                s3Properties.getCredentials().getSecretKey());
    }</code></pre>
<h3 id="s3properties">S3Properties</h3>
<p><code>@ConfigurationProperties</code>애노테이션이 <code>application-secret.yml</code> 파일에 있는 aws 정보를 읽어 값을 바인딩 해줍니다.</p>
<pre><code class="language-java">@Getter
@ConfigurationProperties(&quot;aws&quot;)
public class S3Properties {

    private final Credentials credentials;
    private final S3 s3;
    private final String region;

    @ConstructorBinding
    public S3Properties(Credentials credentials, S3 s3, Map&lt;String, String&gt; region) {
        this.credentials = credentials;
        this.s3 = s3;
        this.region = region.get(&quot;static&quot;);
    }

    @Getter
    @RequiredArgsConstructor
    public static class Credentials {

        private final String accessKey;
        private final String secretKey;
    }

    @Getter
    @RequiredArgsConstructor
    public static class S3 {

        private final String bucket;
    }
}</code></pre>
<h3 id="s3multipartservice">S3MultipartService</h3>
<p>AWS 서버에 멀티파트 업로드 요청하는 곳 입니다. 주요 메서드는 3개 입니다.</p>
<ol>
<li>멀티파트 업로드 시작</li>
<li>부분 업로드를 위한 미리 서명된 URL 발급</li>
<li>멀티파트 업로드 완료</li>
<li>추가적으로 업로드 중지 요청 시 사용할 abortUpload 메서드가 있습니다. </li>
</ol>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class S3MultupartService {

    private static final String UPLOADED_IMAGES_DIR = &quot;public/&quot;;

    private final S3Client s3Client;
    private final S3Presigner s3Presigner;
    private final AmazonS3Client amazonS3Client;

    **public S3UploadResponse initiateUpload(S3UploadInitiateRequest request, 
                                                                                         String bucket)** {
        // 사용자가 보낸 파일 확장자와 현재 시간을 이용해 새로운 파일 이름을 만든다
        String originalFileName = request.getFileName();
        String fileType = 
            originalFileName.substring(originalFileName.lastIndexOf(&quot;.&quot;))
            .toLowerCase();
        String newFileName = System.currentTimeMillis() + fileType;
        Instant now = Instant.now();

        CreateMultipartUploadRequest createMultipartUploadRequest = 
            CreateMultipartUploadRequest.builder()
            .bucket(bucket) // 버킷 설정
            .key(UPLOADED_IMAGES_DIR + newFileName) // 업로드될 경로 설정
            .acl(ObjectCannedACL.PUBLIC_READ) // public_read로 acl 설정
            .expires(now.plusSeconds(60 * 20)) // 객체를 더 이상 캐시할 수 없는 날짜 및 시간
            .build();

        //Amazon S3는 멀티파트 업로드에 대한 고유 식별자인 업로드 ID가 포함된 응답을 반환합니다.
        CreateMultipartUploadResponse response = 
                s3Client.createMultipartUpload(createMultipartUploadRequest);

        return S3UploadResponse.toEntity(response.uploadId(), newFileName);

    **public S3PresignedUrlResponse getUploadSignedUrl(
                                    S3UploadSignedUrlRequest request, String targetBucket) {**

        // 업로드 할 파일의 chunk로 쪼개서 각각 요청을 보내는 단계
        UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
                    .bucket(targetBucket)
                    .key(UPLOADED_IMAGES_DIR + request.getFileName())
                    .uploadId(request.getUploadId())
                    .partNumber(request.getPartNumber())
                    .build();

        // 미리 서명된 URL 요청
        UploadPartPresignRequest uploadPartPresignRequest = UploadPartPresignRequest.builder()
                    .signatureDuration(Duration.ofMinutes(10))
                    .uploadPartRequest(uploadPartRequest)
                    .build();

        // 클라이언트에서 S3로 직접 업로드하기 위해 사용할 인증된 URL을 받는다.
        PresignedUploadPartRequest presignedUploadPartRequest
                = s3Presigner.presignUploadPart(uploadPartPresignRequest);

        return new S3PreSignedUrlResponse(
                                        presignedUploadPartRequest.url().toString());

    **public S3UploadResultResponse completeUpload(
                        S3UploadCompleteRequest request, String targetBucket)** {

        List&lt;CompletedPart&gt; completedParts = new ArrayList&lt;&gt;();

        // 한 영상에 대한 모든 부분들에 부분 번호와 Etag를 설정한다.
        for (S3UploadPartsDetail s3UploadPartsDetail : request.getParts()) {
                 CompletedPart completedPart = CompletedPart.builder()
                    .partNumber(s3UploadPartsDetail.getPartNumber())
                    .eTag(s3UploadPartsDetail.getAwsETag())
                    .build();
                completedParts.add(completedPart);
        }

        // 멀티파트 업로드 완료 요청을 AWS 서버에 보냄
        CompletedMultipartUpload completedMultipartUpload = 
                CompletedMultipartUpload.builder()
                        .parts(completedParts)
                        .build();

        String fileName = request.getFileName();

        // 업로드 요청을 완료하여 업로드된 모든 chunk들을 하나로 합치고,
        // (S3에 저장한 파일의) 복사된 객체를 사용할 수 있게합니다.
        CompleteMultipartUploadRequest completeMultipartUploadRequest =
                 CompleteMultipartUploadRequest.builder()
                        .bucket(targetBucket)
                        .key(UPLOADED_IMAGES_DIR + fileName)
                        .uploadId(request.getUploadId())
                        .multipartUpload(completedMultipartUpload)
                        .build();

        CompleteMultipartUploadResponse completeMultipartUploadResponse = 
                s3Client.completeMultipartUpload(completeMultipartUploadRequest);

        // s3에 업로드 된 파일 이름
        String objectKey = completeMultipartUploadResponse.key();
        // s3에 업로드된 url
        String url = amazonS3Client.getUrl(targetBucket, objectKey).toString();
        String bucket = completeMultipartUploadResponse.bucket();

        // 파일 size를 구함
        long fileSize = getFileSizeFromS3Url(bucket, objectKey);

        return S3UploadResultResponse.builder()
                            .name(fileName)
                            .url(url)
                            .size(fileSize)
                            .build();

    **public void abortUpload(S3UploadAbortRequest abortRequest,
                                                         String targetBucket) {**
        AbortMultipartUploadRequest abortMultipartUploadRequest = AbortMultipartUploadRequest.builder()
            .bucket(targetBucket)
            .key(UPLOADED_IMAGES_DIR + abortRequest.getFileName())
            .uploadId(abortRequest.getUploadId())
            .build();

        s3Client.abortMultipartUpload(abortMultipartUploadRequest);

    **private long getFileSizeFromS3Url(String bucketName, String fileName)** {
        GetObjectMetadataRequest metadataRequest =
                 new GetObjectMetadataRequest(bucketName, fileName);
        ObjectMetadata objectMetadata = 
                 amazonS3Client.getObjectMetadata(metadataRequest);
        return objectMetadata.getContentLength();
    }</code></pre>
<h3 id="s3multupartcontroller">S3MultupartController</h3>
<p>웹에서 멀티파트 업로드 요청을 위해 사용할 API입니다.</p>
<pre><code class="language-java">@RestController
@RequiredArgsContructor
public class S3MultipartController {

    private final S3Properties s3Properties;
    private final S3MultipartService s3MultipartService;

    /**
     * 멀티파트 업로드 시작
     * 업로드 아이디를 반환하는데, 업로드 아이디는 부분 업로드, 업로드 완료 및 중지할 때 사용된다.
     */
    @PostMapping(&quot;/initiate-upload&quot;)
    public S3UploadResponse initiateUpload(
                                            @RequestBody S3UploadInitiateRequest initiateRequest) {
    return s3MultipartService.initiateUpload(
                                                    initiateRequest, s3Properties.getS3().getBucket());

    /**
     * 부분 업로드를 위한 서명된 URL 발급 요청
     */
    @PostMapping(&quot;/upload-signed-url&quot;)
    public S3PreSignedUrlResponse getUploadSignedUrl(
                                @RequestBody S3UploadSignedUrlRequest signedUrlRequest) {
        return s3MultipartService.getUploadSignedUrl(
                                        signedUrlRequest, s3Properties.getS3().getBucket());

    /**
     * 멀티파트 업로드 완료 요청
     */
    @PostMapping(&quot;/complete-upload&quot;)
    public S3UploadResultResponse completeUpload(
                                            @RequestBody S3UploadCompleteRequest completeRequest) {
        return s3MultipartService.completeUpload(
                                        completeRequest, s3Properties.getS3().getBucket());
    }

    /**
     * 멀티파트 업로드 중지
     */
    @PostMapping(&quot;/abort-upload&quot;)
    public Void abortUpload(@RequestBody S3UploadAbortRequest abortRequest) {
        s3MultipartService.abortUpload(abortRequest, s3Properties.getS3().getBucket());
        return null;
    }
}</code></pre>
<h3 id="테스트-웹페이지">테스트 웹페이지</h3>
<p>웹 템플릿 뷰를 보여줄 컨트롤러와 뷰입니다.</p>
<pre><code class="language-java">@Controller
public class HomeController {

    @GetMapping(&quot;/home&quot;)
    public String multipartS3() {
            return &quot;multipart-upload-s3&quot;;</code></pre>
<h3 id="multipart-upload-s3html">multipart-upload-s3.html</h3>
<p>웹 브라우저에서 실행하면 아래와 같이 나오게 됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/43f18512-e7c1-4396-a420-ca65af7b09b9/image.png" alt=""></p>
<p>멀티파트 업로드하는 javascript 주요 로직은 다음과 같습니다.</p>
<ol>
<li>SpringBoot 서버로 멀티파트 업로드 시작 요청을 합니다.</li>
<li>청크 사이즈와 파일 크기를 통해 청크 개수를 설정합니다.</li>
<li>SpringBoot 서버로 멀티파트 <strong>업로드를 위한 미리 서명된 URL을 발급 받습니다.</strong></li>
<li>3번에서 받은 미리 서명된 URL과 PUT을 사용해 AWS 서버에 청크를 업로드합니다.</li>
<li>3,4번을 반복합니다.</li>
<li>모든 청크 업로드가 완료되면 SpringBoot 서버로 업로드 완료 요청을 보냅니다.</li>
</ol>
<h3 id="멀티파트-업로드-테스트">멀티파트 업로드 테스트</h3>
<p>파일 선택 후 send file 버튼을 누르면 아래와 같이 S3 버킷에 저장되는걸 확인하실 수 있습니다.
<img src="https://velog.velcdn.com/images/pie_e/post/09e9e748-1b2f-484b-936a-7604430d4aea/image.png" alt=""></p>
<h1 id="끝">끝!</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/f0fa284b-957f-4e57-8bb4-af1d4e389090/image.png" alt="">
아니라니까요.???..</p>
<blockquote>
<p>Ref.
<a href="https://techblog.woowahan.com/11392/">https://techblog.woowahan.com/11392/</a>
<a href="https://aws.amazon.com/ko/blogs/korea/aws-api-call-2-s3-pre-signed-url/">https://aws.amazon.com/ko/blogs/korea/aws-api-call-2-s3-pre-signed-url/</a>
<a href="https://develop-writing.tistory.com/129">https://develop-writing.tistory.com/129</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[2025년 다들 안녕하신가요?]]></title>
            <link>https://velog.io/@pie_e/2025%EB%85%84-%EB%8B%A4%EB%93%A4-%EC%95%88%EB%85%95%ED%95%98%EC%8B%A0%EA%B0%80%EC%9A%94</link>
            <guid>https://velog.io/@pie_e/2025%EB%85%84-%EB%8B%A4%EB%93%A4-%EC%95%88%EB%85%95%ED%95%98%EC%8B%A0%EA%B0%80%EC%9A%94</guid>
            <pubDate>Tue, 14 Jan 2025 04:39:43 GMT</pubDate>
            <description><![CDATA[<p>저는 아쉽게도 아직 안녕하지 못한 것 같습니다.</p>
<p>저에게 2024년은 안팎으로 많이 시끄러웠던 한 해였습니다.
여러 일들이 많이 있었지만, 그 중에서도 가장 크게 작용한 것은 취업이었습니다. 
취업을 준비하며 제 인생에서 가장 치열하게 살았던 것 같아요.</p>
<p>매 순간, 매 시간마다 열심히였다곤 할 수 없었습니다. 달리다 보면 앉아서 쉬어가고 싶기도 하고 오랫동안 서 있다 보면 눕고 싶어 지는 게 사람이니까요.</p>
<p>나만 빼고 들려오는 취업 소식에 진심으로 축하를 전하면서도, 비좁은 마음이 시끄러운 걸 들키고 싶지 않아 약속들은 언제가 될지 모르는 다음을 기약을 했습니다. </p>
<p>여러 번의 면접을 봤지만 항상 아쉬운 결과를 맞이해 부정적인 말들로 저를 괴롭히기도 했지만, 새롭게 가진 취미와 나를 위한 시간들로 조금씩 이겨내어 다행히 새로운 2025년을 맞이했습니다.</p>
<p>사실 저는 지금도 여전히 두려운 게 많아요. 더 이상 20대가 아닌 30대를 맞이한 것도, 30대를 맞이했지만 아직 취업을 하지 못한 것도, 시간이 빠르게 흐르는데 내 시간만 멈춰 있는 것 같은 느낌도, 다들 나아가고 있지만 나는 여전히 제자리인 것 같은 생각 등 말로 표현하자면 끝없이 많은 것들이 저를 작아지게 합니다.</p>
<p>그래도 저는 그저 서 있지만은 않으려 합니다.</p>
<p>1년 동안 도망치고 싶기도 했고, 눈물 바람으로 몇 날 며칠을 보내기도 했고, 한숨으로 땅을 꺼트리기도 하는 날도 많았지만 이런 날들을 지나 보내고 그 끝에 도달할 곳이 어딘지, 너무 궁금해서라도 열심히 살아보고 싶습니다.</p>
<p>물론 올해도 힘들고 지칠 때면 새로운 취미인 뜨개질을 하기도 하고, 예쁘게 꾸민 다이어리에 저를 힘들게 하는 생각과 말들을 털어놓으며 마음을 다 잡아보고, 제가 사랑하는 반려묘의 꼬순내🐾와 같은 것 들로 충전하며 이겨내보려 합니다. </p>
<p>이 글이 닿는 여러분께도, 2025년이 여러분의 노력과 인내가 결실을 맺는 한 해가 되기를 진심으로 바라고 있겠습니다. 
행복하세요 ! 저도 행복하겠습니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/f7089ee3-e946-4843-82d5-012dd93baaa7/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] JVM이란 ?]]></title>
            <link>https://velog.io/@pie_e/Java-JVM%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@pie_e/Java-JVM%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Tue, 19 Nov 2024 08:30:41 GMT</pubDate>
            <description><![CDATA[<h1 id="jvm-이란-무엇인가">JVM 이란 무엇인가?</h1>
<p><strong>J</strong>ava <strong>V</strong>irtual <strong>M</strong>achine의 줄임말, 직역하면 자바를 실행하기 위한 가상 기계(컴퓨터), 또는 자바 프로그램 환경을 만들어주는 소프트웨어라고 할 수 있다. </p>
<p>Java는 OS에 종속적이지 않다는 특징을 가지고 있는데, OS에 종속 받지 않고 실행되기 위해선 OS 위에서 Java를 실행시킬 무언가를 필요로 하는데 바로 그게 <strong><code>JVM</code></strong>이다. 
<img src="https://velog.velcdn.com/images/pie_e/post/d12534c2-1cdd-4c20-a1e3-08c6c3a0081d/image.png" alt=""></p>
<p>JVM에서 Java코드 즉, 원시코드(<code>.java</code>)를 실행시키기 위해 Java의 Compiler가 Java 코드를 JVM이 이해할 수 있는 바이트 코드(<code>.class</code>)파일로 변환시켜준다.</p>
<p>변환된 바이트 코드는 기계어가 아니기 때문에 OS에서 바로 실행 시킬 수 없는데, 이 때 JVM이 OS가 바이트 코드를 이해할 수 있도록 해석해준다. </p>
<p>따라서, 바이트 코드는 JVM 위에서 OS에 상관없이 실행될 수 있다. OS에 종속적이지 않고, Java 파일 하나만 만들면 어느 디바이스든 JVM 위에서 실행이 가능하다.</p>
<h2 id="바이트-코드란">바이트 코드란?</h2>
<p>Java 바이트 코드는 JVM이 이해할 수 있는 언어로 변환된 Java 소스코드를 의미한다. Java 컴파일러에 의해 변환된 코드의 명령어 크기가 1바이트라서 Java 바이트코드라고 불리고있다. 바이트코드는 JIT 컴파일러에 의해 바이너리 코드로 변환된다. </p>
<blockquote>
<p>📢 바이너리 코드?
바이너리 코드 또는 이진 코드라고 한다. 컴퓨터가 인식할 수 있는 0과 1로 구성된 이진 코드를 의미한다.</p>
</blockquote>
<p>즉, <strong>CPU</strong>가 이해하는 언어는 <strong>바이너리 코드</strong>, <strong>가상 머신(JVM)</strong>이 이해하는 코드는 <strong>바이트 코드</strong>이다.</p>
<h3 id="바이트-코드를-읽는-방식">바이트 코드를 읽는 방식</h3>
<p>JVM은 바이트 코드를 명령어 단위로 읽어서 해석하는데, <code>Interpreter방식</code>과 <code>JIT 컴파일 방식</code>, <strong>두 가지 방식을 혼합하여 사용</strong>한다.</p>
<ul>
<li><strong>Interpreter 방식</strong><ul>
<li>바이트 코드를 <strong>한 줄씩 해석, 실행하는 방식</strong>이다. 초기 방식으로 속도가 <strong>느리다는 단점</strong>이 있다.</li>
</ul>
</li>
<li><strong>JIT(Just In Time) 컴파일 방식</strong><ul>
<li>Interpreter의 느린 속도를 보완하기 위해 나온 컴파일 방식. 
바이트 코드를 JIT 컴파일러를 이용해 프로그램을 실제 실행하는 시점(바이트 코드를 실행하는 시점)에 각 OS에 맞는 Native Code로 변환하여 실행 속도를 개선했다. 하지만 바이트 코드를 Native Code로 변환하는데에도 비용이 소모되므로 <strong>JVM은 모든 코드를 JIT 컴파일러 방식으로 실행하지 않고, Interpreter 방식을 사용하다 일정 기준이 넘어가면 JIT 컴파일 방식으로 명령어를 실행</strong>한다.<h2 id="jitjust-in-time-컴파일러">JIT(Just In Time) 컴파일러?</h2>
<img src="https://velog.velcdn.com/images/pie_e/post/95bbd8ec-eb97-4690-95a7-7ba6e1728741/image.png" alt="">
기존의 자바는 Interpreter 방식으로 명령어를 하나씩 실행하게끔 이루어져 있어 실행 속도가 느렸지만, 하드웨어가 발전하면서 자바 컴파일러로 JIT 방식으로 개선되어 속도적인 측면에서 상당한 개선을 이루었다.</li>
</ul>
</li>
</ul>
<p>또한, <span style='background-color: #fff8d3'>JIT 컴파일러는 같은 코드를 매번 해석하지 않고, 실행할 때 컴파일 하면서 기계어를 캐싱</span>해버린다. <span style='background-color: #fff8d3'>이후에는 바뀐 부분만 컴파일하고, 나머지는 캐싱된 코드를 사용</span>한다.
이렇게 JIT 컴파일러는 운영체제에 맞게 바이트 실행 코드로 한 번에 변환하여 실행하기 때문에 이전의 Interpreter 방식보다 성능에 10~20배 정도 더 좋다.</p>
<h1 id="jvm의-구성요소">JVM의 구성요소</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/5ac5a85d-5bd5-4317-a623-2c7e8b0879fb/image.png" alt=""></p>
<h2 id="클래스-로더-class-loader">클래스 로더 (Class Loader)</h2>
<p>자바는 프로그램이 실행중인 <strong>런타임시 동적으로 클래스를 로드하고, jar 파일 내 저장된 클래스들을 JVM 위에 탑재</strong>한다. 즉, <strong>클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 역할을 하는게 클래스 로더</strong>이다.</p>
<p>클래스 로더는 <code>.class</code>파일을 묶어서 JVM이 운영체제로부터 할당받은 메모리 영역인 Runtime Data Area로 적재한다.</p>
<h2 id="실행-엔진-execution-engine">실행 엔진 (Execution Engine)</h2>
<p>클래스 로더에 의해 JVM으로 로드된 <code>.class</code>파일(바이트코드)들은 Runtime Data Area의 Method Area에 배치된다.</p>
<p>이후 JVM은 Method Area의 바이트 코드를 실행 엔진에 제공하여, 로드된 바이트 코드를 실행하는 런타임 모듈이 실행 엔진이다.</p>
<p><strong>실행 엔진은 바이트 코드를 명령어 단위로 읽어서 실행하고, 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경</strong>한다. </p>
<p>또한, 실행 엔진은 가비지 컬렉터(GC)를 포함하고 있다.</p>
<h2 id="런타임-데이터-영역-runtime-data-area">런타임 데이터 영역 (Runtime Data Area)</h2>
<p>런타임 데이터 영역은 JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다.
<img src="https://velog.velcdn.com/images/pie_e/post/5b150e85-85d8-482b-bf5c-420663d08c29/image.png" alt=""></p>
<ul>
<li><p><span style='background-color: #d6eefc'><strong>PC Register</strong></span></p>
<ul>
<li>각 Thread마다 별도로 존재하며, JVM 명령어의 현재 위치를 가르키는 Register.</li>
</ul>
</li>
<li><p><span style='background-color: #fcecd6'><strong>JVM Stack</strong></span></p>
<ul>
<li>각 Thread마다 별도로 존재하며, 메서드 호출 시마다 프레임이 생성된다. 한 프레임마다 지역변수, 파라미터, 리턴 값등이 저장된다.</li>
</ul>
</li>
<li><p><span style='background-color: #f4f2c9'><strong>Native Method Stack</strong></span></p>
<ul>
<li>자바 이외의 언어(C, C++ 등)로 작성된 네이티브 코드를 실행할 때 사용되는 메모리 영역이다. </li>
</ul>
</li>
<li><p><span style='background-color: #ddf5cd'><strong>Heap</strong></span></p>
<ul>
<li>인스턴스 또는 객체를 위한 공간으로 GC의 관리 대상이다. </li>
</ul>
</li>
<li><p><span style='background-color: #bfd0ea'><strong>Methodn Area</strong></span></p>
<ul>
<li>모든 스레드가 공유하는 영역으로, JVM이 로드한 각 클래스 또는 인터페이스에 대한 정보가 저장된다. 이 정보에는 변수, 메서드, static 변수, 인터페이스 등이 포함된다.</li>
<li><span style='background-color: #c7eaf5'><strong>Runtime Constant Pool</strong></span><ul>
<li>각 클래스와 인터페이스의 상수뿐만 아니라, 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이다. 
즉, 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조한다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<h1 id="jvm의-동작방식">JVM의 동작방식</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/8616458b-fa87-4edd-8a87-b92af7ba34e1/image.png" alt=""></p>
<ol>
<li>자바로 개발된 프로그램을 실행하면 JVM은 운영체제로부터 메모리를 할당한다.</li>
<li>자바 컴파일러(javac)가 자바 소스코드(<code>.java</code>)를 자바 바이트코드(<code>.class</code>)로 컴파일한다.</li>
<li>클래스 로더를 통해 JVM 런타임 데이터 영역으로 로딩한다.</li>
<li>런타임 데이터 영역에 로딩된 <code>.class</code>들은 실행 엔진을 통해 해석한다.</li>
<li>해석된 바이트 코드는 런타임 데이터 영역의 각 영역에 배치되어 수행하며 이 과정에서 실행 엔진에 의해 GC의 작동과 Thread 동기화가 이루어진다.</li>
</ol>
<h2 id="-추가로">+ 추가로</h2>
<h3 id="jre란">JRE란?</h3>
<p>JRE는 자바 실행 환경(Java Runtime Enviroment)의 약자로 자바로 만들어진 프로그램을 실행시키는데 필요한 라이브러리들과 각종 API, 그리고 JVM이 포함되어 있다. 
JRE는 자바로 개발(쓰기)은 안되고 수행(읽기)만 된다.</p>
<h3 id="jdk란">JDK란?</h3>
<p>JDK는 자바 개발 키트(Java Development Kit)의 약자로 이름 그대로 개발자들이 자바로 개발하는데 사용된다. 
JDK 안에는 개발 시 필요한 라이브러리들과 javac, javadoc 등의 개발 도구들이 포함되어 있고, 개발을 하려면 당연히 실행도 시켜줘야 하기 때문에 JRE도 함께 포함되어 있다.</p>
<blockquote>
<p>📢 정리
Java로 프로그램을 직접 개발하려면 JDK가 필요하고, Java로 만들어진 프로그램을 실행시키려면 JRE가 필요하다.</p>
</blockquote>
<h1 id="끝">끝</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/e9dbec3b-b6ef-4540-ba27-72d58ad8264f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis 야무지게 사용하기]]></title>
            <link>https://velog.io/@pie_e/Redis-%EC%95%BC%EB%AC%B4%EC%A7%80%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@pie_e/Redis-%EC%95%BC%EB%AC%B4%EC%A7%80%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 07 Nov 2024 11:10:48 GMT</pubDate>
            <description><![CDATA[<p><span style='color: #a09f9b'> 이 글은 NHN Cloud의 Redis 야무지게 사용하기 를 정리한 글 입니다. </span></p>
<h1 id="1-redis-캐시로-사용하기">1. Redis 캐시로 사용하기</h1>
<p>먼저 캐싱이란? 사용자의 입장에서 데이터의 원래 소스보다 더 빠르고 효율적으로 액세스 할 수 있는 임시 데이터 저장소이다.<br><img src="https://velog.velcdn.com/images/pie_e/post/36c6ddd4-a38d-4906-a9b5-6c0a57b5fa35/image.png" alt=""></p>
<h2 id="캐시의-활용">캐시의 활용</h2>
<p>동일한 데이터에 대해 반복적으로 액세스하는 상황에 캐시를 사용하는 것이 좋다. 즉, 데이터의 재사용 횟수가 1회 이상이여야 캐시가 의미있게 된다. 또한, 잘 변하지 않는 데이터일 수록 캐시를 사용하는게 더 효율적이다. </p>
<h2 id="redis-as-a-cache-most-popular-software-caching-solution">Redis as a cache (Most popular software caching solution)</h2>
<p>캐시로서의 redis는 가장 널리 사용되는 캐싱 솔루션이다. </p>
<ul>
<li>단순한 key-value 구조</li>
<li>In-memory 데이터 저장소(RAM)</li>
<li>빠른 성능<ul>
<li>평균 작업속도 &lt; 1ms</li>
<li>초당 수백만 건의 작업 가능</li>
</ul>
</li>
</ul>
<h2 id="캐싱-전략-caching-straregies">캐싱 전략 (Caching Straregies)</h2>
<p>redis를 캐시로 사용할 때 어떻게 배치하느냐에 따라 시스템의 성능에 큰 영향을 끼친다. 이를 캐싱 전략이라고도 부르며, 캐싱 전략은 데이터의 유형과 해당 데이터에 대한 액세스 패턴을 잘 고려해서 선택해야 한다.</p>
<p>먼저 애플리케이션에서 데이터의 읽는 작업이 많을 때 사용하는 전략을 살펴보자.</p>
<h3 id="읽기-전략--look-aside">읽기 전략 : Look-Aside</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/752da850-e987-4d74-ad9d-7757802b21ad/image.png" alt="">
이 구조는 redis를 캐시로 쓸 때 가장 일반적으로 사용되는 방법이다. 애플리케이션은 데이터를 찾을 때 캐시에 먼저 확인하고 캐시에 데이터가 존재하면 데이터를 가져오는 작업을 반복한다. 
<img src="https://velog.velcdn.com/images/pie_e/post/ddb9e9b9-6ebf-4979-b984-46cc3dfb1c7b/image.png" alt="">
만약, 캐시에 데이터가 존재하지 않아 <strong>cache miss가 일어나게 되면</strong> 애플리케이션은 <strong>DB에 접근해서 직접 데이터를 가지고 온 뒤, 캐시에 저장 및 반환</strong>하는 작업을 한다. </p>
<p>이 구조는 redis가 다운되더라도 바로 장애로 이어지지 않고, DB에서 데이터를 가지고 올 수 있다. </p>
<p>여기서 <strong>주의할 점</strong>은 하지만 캐시에 붙어있던 커넥션이 많았다면, 그 커넥션이 모두 DB로 붙기 때문에 DB에 갑자기 많은 부하가 몰릴 수 있다. 그래서 이런 경우 캐시를 새로 투입하거나, redis가 다운되어 DB에만 새로운 데이터를 저장했다면 처음에 cache miss가 많이 발생해서 성능에 저하를 가지고 올 수 있다. </p>
<p>이럴 때 <strong>미리 DB에서 캐시로 데이터를 밀어넣어주는 작업</strong>을 할 수 있는데, 이를 <strong><code>cache warming</code></strong>이라고 한다. 
<img src="https://velog.velcdn.com/images/pie_e/post/14ea7aa8-1468-41c9-a5c1-f4ba879d5878/image.png" alt=""></p>
<h3 id="쓰기-전략--write-around-write-through">쓰기 전략 : Write-Around, Write-Through</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/288af03b-1ec6-4ac1-a1f9-6ae6bb1e9204/image.png" alt="">
<strong><code>Write-Around</code> 전략</strong>은 <span style='background-color: #fff8d3'><strong>DB에만 데이터를 저장</strong></span>한다. 일단 모든 데이터는 DB에 저장되고 <span style='background-color: #fff8d3'>cache miss가 발생한 경우 캐시에 데이터를 적재</span>한다. 이 경우 <strong>캐시 내의 데이터와 DB 내의 데이터가 다를 수 있다는 단점</strong>이 있다.</p>
<p><strong><code>Write-Through</code> 전략</strong>은 <span style='background-color: #fff8d3'><strong>DB에 데이터를 저장할 때 캐시에도 함께 저장하는 방법</strong></span>이다. <span style='background-color: #fff8d3'>캐시는 항상 최신 정보를 가지고있다는 장점</span>이 있지만, 저장할 때 마다 <strong>두 단계의 step을 거쳐야 하기 때문에 상대적으로 느리다고 볼 수 있다</strong>. 또한 <strong>저장하는 데이터가 재사용되지 않을 수 도있는데 무조건 캐시에 넣어버리기 때문에 일종의 리소스 낭비</strong>라고도 볼 수 있다. </p>
<p>따라서 이렇게 데이터를 저장할 때는 몇 분, 혹은 몇 시간동안만 데이터를 보관하겠다는 의미인 <strong><code>expire time</code>을 설정해주는 것이 바람직</strong>하다.</p>
<h1 id="2-redis의-데이터-타입-활용하기">2. Redis의 데이터 타입 활용하기</h1>
<p>redis는 자체적으로 많은 자료구조를 제공하고 있다. 
<img src="https://velog.velcdn.com/images/pie_e/post/3f4dc7ab-ae63-4e2c-ac64-333e7704ac8b/image.png" alt="">
특정 상황에서 어떻게 자료구조를 효율적으로 사용할 수 있는지 알아보자.</p>
<h2 id="2-1-best-practice---counting">2-1. Best Practice - Counting</h2>
<h3 id="string">String</h3>
<p><code>String</code>의 Increment 함수를 사용하면 아주 간단하게 카운팅 할 수 있다. 
<img src="https://velog.velcdn.com/images/pie_e/post/690b860e-9c3b-413f-88b9-d6cce386c807/image.png" alt="">
위 예시를 살펴보자. score:a에 10이라는 값을 저장 한 후 <code>INCR</code> 함수를 사용하면 1이 증가한다. <code>INCRBY</code> 사용하면 직접 카운트할 개수를 지정해 그 값만큼 증가한다. </p>
<h3 id="bit">Bit</h3>
<p>Bit를 이용하면 저장 공간을 굉장히 절약할 수 있다. </p>
<p>예를 들어, 우리 서비스의 오늘 접속한 유저 수를 세고 싶을 때 날짜 키 하나를 만들어 놓고 유저ID에 해당하는 bit를 1로 올려주면 된다. 한 개의 비트가 한 명을 의미하므로 천만 명의 유저는 천만 개의 비트로 표현할 수 잇고, 천만 개의 비트는 곧 1.2MB밖에 차지하지 않는다.
<img src="https://velog.velcdn.com/images/pie_e/post/1f3bcae5-ccf4-4bdc-b355-73d53a58cdd3/image.png" alt=""></p>
<p>예제에서 처럼 SETBIT로 비트를 설정할 수 있고 BITCOUNT를 통해 1로 설정된 값을 모두 카운팅 할 수 있다.</p>
<p>하지만 이 방법을 이용하려면 <strong>모든 데이터를 정수로 표현할 수 있어야 한다</strong>. 
즉, 유저ID 같은 값이 0이상의 정수 값일 때만 카운팅이 가능하며, 그런 sequential한 값이 없을 때에는 이 방법을 사용할 수 없다. </p>
<h3 id="hyperloglogs">HyperLogLogs</h3>
<p>마지막으로 알아볼 카운팅 방법은 HyperLogLogs를 사용하는 것이다. HyperLogLogs는 모든 String 데이터 값을 유니크하게 구분할 수 있다. HyperLogLogs는 set과 비슷하지만 <strong>저장되는 데이터 개수와 저장되는 값이 몇백만, 몇천만 건 이든 상관없이 모든 값이 12KB로 고정되어 있기 때문에 대량의 데이터를 카운팅 할 때는 훨씬 더 유용하게 사용</strong>된다.</p>
<p>예를 들어, 우리 웹사이트에 방문한 IP가 유니크한게 몇 개가 되는지 혹은 하루종일 크롤링한 URL의 개수가 몇 개인지, 우리 검색 엔진에서 검색된 유니크한 단어가 몇 개가 되는지 등등 유니크한 값을 계산할 때 아주 적절하게 사용할 수 있다.
<img src="https://velog.velcdn.com/images/pie_e/post/a2e118d5-4915-4b48-812a-afa8f4783150/image.png" alt="">
PFADD 커맨드로 데이터를 저장하고 PFCOUNT 커맨드로 유니크하게 저장된 값을 조회할 수 있다. </p>
<p>만약 일별로 데이터를 저장했는데 일주일 치를 취합해서 보고싶다면 PFMERGE 커맨드로 키들을 머지해서 확인할 수 있다. </p>
<h2 id="2-2-best-practice---messaging">2-2. Best Practice - Messaging</h2>
<h3 id="lists">Lists</h3>
<p>Lists는 메세지 큐로 사용하는데 적합하다. 특히 자체적으로 blocking 기능을 제공하기 때문에 이를 적절히 사용하면 불필요한 polling 프로세스를 막을 수 있다. 
<img src="https://velog.velcdn.com/images/pie_e/post/a4921a5e-d0b3-4bde-9c2f-6ee97b1df0c1/image.png" alt="">
클라이언트 A가 BRPOP 커맨드를 통해 myqueue에서 데이터를 꺼내오려하는데 현재 리스트 안에는 데이터가 없어 대기를 하고 있는 상황이다. 
<img src="https://velog.velcdn.com/images/pie_e/post/ca07af7a-b829-407b-8156-099851d2e2cf/image.png" alt="">
이 때 클라이언트 B가 hi라는 값을 넣어주면 클라이언트 A에서 바로 이 값을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/pie_e/post/82881a10-2c3d-432a-b1b6-dde5fdbe29ab/image.png" alt="">
또한, LPUSHX나 RPUSHX같은 커맨드를 사용하면 키가 존재할 때만 Lists에 데이터를 추가하는데 이 기능도 잘 사용하면 굉장히 유용하게 사용할 수 있다.
키가 이미 존재한다면 예전에 사용했던 큐라는 것이고, 사용했던 큐에만 메세지를 넣어줄 수 있기 때문에 비효율적인 데이터의 이동을 막을 수 있다. </p>
<p>인스타그램, 페이스북, 트위터 등과 같은 SNS에는 각 유저별로 타임라인이 존재하고 그 타임라인에 내가 팔로우한 사람들의 데이터가 보여지게 된다. 트위터에서는 각 유저의 타임라인에 보일 트윗을 캐싱하기위해 redis의 Lists를 사용하는데 이 때, RPUSHX 커맨드를 사용하여 트위터를 자주 이용하던 유저의 타임라인에만 새로운 데이터를 미리 캐시해 놓을 수 있다. 자주 사용하지 않는 유저는 캐싱 키 자체가 존재하지 않기 때문에 자주 사용하지 않는 유저를 위해 데이터를 미리 쌓아놓는 비효율적인 작업을 방지할 수 있다. </p>
<h3 id="stream">Stream</h3>
<p>stream은 로그를 저장하는 가장 적절한 자료구조라고 볼 수 있다. 실제 서버에 로그가 쌓이는 것처럼 모든 데이터는 append-only 방식으로 저장되며 중간에 데이터가 변경되지 않는다.
<img src="https://velog.velcdn.com/images/pie_e/post/518adbac-9d44-4e56-aed8-786dcfacccd6/image.png" alt="">
예제에서는 XADD 커맨드를 이용해 mystream이라는 키에 데이터를 저장한다. 키 이름 옆에 <code>*</code>는 ID 값을 의미하며 ID 값을 직접 저장할 수도 있지만 일반적으로 <code>*</code>로 입력하면 redis가 알아서 저장하고 ID 값을 반환해준다.
반환되는 ID 값은 데이터가 저장된 시간을 의미한다. 이 값 뒤로는 hash처럼 key-value 쌍으로 데이터가 저장되는데, 예제에서는 sensor-id 값에 1234를 온도에는 19.8을 저장한 것을 의미한다. </p>
<h1 id="3-redis에서-데이터를-영구적으로-저장하려면">3. Redis에서 데이터를 영구적으로 저장하려면?</h1>
<p>redis는 In-memory 데이터 스토어이다. 모든 데이터가 메모리에 저장되어 있기 때문에 서버나 redis 인스턴스가 재시작되면 모든 데이터는 유실된다.</p>
<p>복제 구조를 사용하고 있더라도 데이터 유실에 있어 안전하다고 볼 수는 없다.
코드상의 버그가 있거나 혹은 휴먼 에러로 데이터를 날린 경우에는 바로 복제본에도 똑같이 적용되기 때문에 이런 경우에는 데이터를 복원할 수 없다. </p>
<p>따라서 redis를 캐시 이외의 용도로 사용한다면 적절한 데이터의 백업이 필요하다.</p>
<h2 id="redis-persistence-option">Redis Persistence Option</h2>
<p>redis에서는 데이터를 영구적으로 저장하는 두 가지 방법을 제공한다.</p>
<ul>
<li>AOF : 데이터를 변경하는 커맨드가 들어오면 커맨드를 그대로 모두 저장한다.</li>
<li>RDB : RDB는 스냅샷 방식으로 동작하기 때문에 저장 당시 메모리에 있는 데이터 그대로 파일로 저장한다.
<img src="https://velog.velcdn.com/images/pie_e/post/8bfc9224-ccc2-400f-a368-3c157d9cea81/image.png" alt=""></li>
</ul>
<p>예제를 살펴보면 AOF에는 key1에 a가 저장되었다가 apple로 변경되었고, key2가 들어왔다가 삭제된 기록 모두 남아있다. 하지만 RDB에는 key1이 apple인 값만 남아있다.</p>
<p>AOF 파일은 Append Only하게 동작하기 때문에 데이터가 추가되기만 해서 대부분 RDB 파일보다 커지게 되기 때문에, AOF 파일은 주기적으로 압축해서 재작성되는 과정을 거쳐야 한다.</p>
<h3 id="자동--수동-파일-저장-방법">자동 / 수동 파일 저장 방법</h3>
<p>RDB와 AOF 파일 모두 커맨드를 이용해 직접 파일을 생성할 수 있으며, 원하는 시점에 파일이 자동 생성되도록 설정할 수도 있다. </p>
<ul>
<li>AOF - Append Only File<ul>
<li>데이터 변경 커맨드까지 모두 저장한다.</li>
<li>자동 저장시 : redis.conf 파일에서 auto-aof-rewrite-percentage 옵션으로 크기 기준으로 저장 가능</li>
<li>수동 저장시: BGREWRITEAOF 커맨드를 이용해 CLI창에서 수동으로 AOF 파일 재작성이 가능하다</li>
</ul>
</li>
<li>RDB - snapshot<ul>
<li>저장 당시 메모리에 있던 데이터들만 그대로 저장</li>
<li>자동 저장시 : redis.conf 파일에서 SAVE 옵션으로 시간 기준으로 저장할 수 있다.</li>
<li>수동 저장시 : BGSAVE 커맨드를 이용해 cli 창에서 수동으로 RDB파일을 저장할 수 있다.</li>
</ul>
</li>
</ul>
<h3 id="rdb-vs-aof-선택-기준">RDB vs AOF 선택 기준</h3>
<ul>
<li>백업은 필요하지만 어느 정도의 데이터 손실이 발생해도 괜찮은 경우<ul>
<li>RDB 단독 사용</li>
<li>redis.conf 파일에서 SAVE 옵션을 적절히 사용 </li>
<li>예) SAVE 900 1 : 900초 동안 한 개 이상의 키가 변경되었을 때 RDB 파일을 재 작성하라는 의미.</li>
</ul>
</li>
<li>장애 상황 직전까지의 모든 데이터가 보장되어야 할 경우<ul>
<li>AOF 사용(appendonly yes)</li>
<li>APPENDFSYNC 옵션이 everysec인 경우 최대 1초 사이의 데이터 유실 가능 (기본 설정)</li>
</ul>
</li>
<li>제일 강력한 내구성이 필요한 경우<ul>
<li>RDB &amp; AOF 동시에 사용하라고 redis 공식 문서에 가이드 되어 있다. </li>
</ul>
</li>
</ul>
<h1 id="redis의-아키텍처-선택-노하우-replication-vs-sentinel-vs-cluster">Redis의 아키텍처 선택 노하우 (Replication vs Sentinel vs Cluster)</h1>
<p>redis의 아키텍처는 크게 3가지로 나눌 수 있다.
<img src="https://velog.velcdn.com/images/pie_e/post/22df4081-04a2-4ec5-b4e4-8261cf1ce40b/image.png" alt=""></p>
<h2 id="replication">Replication</h2>
<p>*<em>단순히 복제만 연결된 상태를 말한다. *</em></p>
<ul>
<li>replicaof 커맨드를 이용해 간단하게 복제 연결</li>
<li>비동기식 복제</li>
<li>HA 기능이 없으므로 장애 상황 시 수동 복구<ul>
<li>replicaof no one</li>
<li>애플리케이션에서 연결 정보 변경<h2 id="sentinel">Sentinel</h2>
</li>
</ul>
</li>
<li><em>자동 페일오버 가능한 HA 구성 (High Availability)*</em></li>
<li>sentinel 노드가 다른 노드 감시</li>
<li>마스터가 비정상 상태일 때 자동으로 페일 오버</li>
<li>연결 정보 변경 필요 없음</li>
<li>sentinel 노드는 항상 3대 이상의 홀수로 존재해야 함<ul>
<li>과반수 이상의 sentinel이 동의해야 페일오버 진행</li>
</ul>
</li>
</ul>
<h2 id="cluster">Cluster</h2>
<p><strong>스케일 아웃과 HA 구성 (High Availability)</strong></p>
<ul>
<li>키를 여러 노드에 자동으로 분할해서 저장 (샤딩)</li>
<li>모든 노드가 서로를 감시하며, 마스터가 비정상 상태일 때 자동 페일오버</li>
<li>최소 3대의 마스터 노드 필요</li>
</ul>
<h3 id="아키텍처-선택-기준">아키텍처 선택 기준</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/3fd274c0-9bf5-444c-8a65-5aeeb69a3890/image.png" alt=""></p>
<h1 id="5-redis-운영-tip과-장애-포인트">5. Redis 운영 Tip과 장애 포인트</h1>
<h2 id="사용하면-안되는-커맨드">사용하면 안되는 커맨드</h2>
<p>redis는 싱글 스레드로 동작하기 때문에 한 사용자가 오래 걸리는 커맨드를 실행한다면 나머지 모든 요청들은 수행하지 못하고 대기하게 된다. 이로 인한 장애도 빈번하게 발생한다.</p>
<ul>
<li><strong>keys * -&gt; scan으로 대체</strong><ul>
<li>keys는 모든 키를 보여주는 커맨드인데 주로 개발할 때 자주 사용하다 운영환경에서 실수로 손이 먼저 나간다. scan을 사용하면 재귀적으로 키들을 호출할 수 있다. </li>
</ul>
</li>
<li><strong>Hash나 Sorted Set 등 자료구조</strong><ul>
<li>Hash나 Sorted Set 같은 자료구조는 내부에 여러 개의 아이템을 저장할 수 있는데, 키 내부에 아이템이 많아질 수록 성능이 저하된다. 만약 좋은 성능을 원한다면 하나의 키에 최대 백만개 이상은 저장하지 않도록 키를 적절히 나누는 것이 좋다. </li>
</ul>
</li>
<li><strong>del -&gt; unlink</strong><ul>
<li>키에 많은 데이터가 들어있을 때 del로 지우면 key를 지우는동안은 아무런 동작을 할 수 없다. 이때, unlink를 사용하면 백그라운드로 지워준다. </li>
</ul>
</li>
</ul>
<h2 id="변경하면-장애를-막을-수-있는-기본-설정-값">변경하면 장애를 막을 수 있는 기본 설정 값</h2>
<ul>
<li><strong>STOP-WRITE-ON-BGSAVE-ERROR = <code>NO</code></strong> 
해당 옵션의 기본 설정값은 YES이다. 의미는 <span style='background-color: #fff8d3'>RDB 파일이 정상적으로 작동하지 않았을 때 redis로 들어오는 모든 모든 WRITE를 차단하는 기능</span>이다. 만약 redis 서버에 대한 모니터링을 적절히 하고 있다면 이 기능은 꺼두는게 불필요한 장애를 막을 수 있는 방법이다. </li>
<li><strong>MAXMEMORY-POLICY = <code>ALLKEYS-LRU</code></strong>
해당 기본 설정에 대한 설명을 하기 앞서 중요한 얘기를 하자면 <span style='background-color: #fff8d3'>redis를 캐시로 사용할 때는 키에 대한 expire time 설정을 꼭 해야한다. 메모리는 한정되어 있기 때문에 expire time을 설정하지 않는다면 데이터가 금세 MaxMemory까지 가득차버리게 된다</span>. 데이터가 가득 차게 되면 MAXMEMORY-POLICY 정책에 의해 데이터가 삭제된다.<ul>
<li><strong><code>NOEVICTION</code></strong> : 기본값. <strong>메모리가 가득 차면 더 이상 레디스는 새로운 키를 저장하지 않는다</strong>. 새로운 데이터를 입력하는게 불가능하기 때문에 이는 장애 상황으로 발생할 수 있다. </li>
<li><strong><code>VALATILE-LRU</code></strong> : <strong>가장 최근에 사용하지 않았던 키부터 삭제</strong>한다. 이 때 <strong>expire 설정이 있는 key값만 삭제</strong>한다. (만약 메모리에 expire 설정이 없는 key만 남아있다면 위와 같은 장애가 발생할 수 있다.)</li>
<li><strong><code>ALLKEYS-LRU</code></strong> : <strong>모든 키에 대해 LRU 방식으로 키를 삭제</strong>한다. 이 설정에서는 적어도 데이터가 가득 참으로 인한 장애가 발생할 가능성은 없다. </li>
</ul>
</li>
</ul>
<h2 id="cache-stampede">Cache Stampede</h2>
<p>대규모 트래픽 환경에서 TTL(저장된 데이터의 만료시간)값을 너무 작게 설정한 경우 cache stampede 현상이 발생할 가능성이 존재한다. 1장에서 봤던 look-aside 패턴에서는 redis의 데이터가 없다는 응답을 받은 서버가 직접 DB로 데이터를 요청한 뒤 다시 redis에 저장하는 과정을 거친다. 하지만 키가 만료되는 순간 많은 서버에서 이 키를 같이 보고 있었다면? 모든 애플리케이션 서버들이 <strong>DB에 가서 같은 데이터를 찾게되는 duplicate read가 발생</strong>한다. 또 <strong>읽어온 값을 redis에 각각 write하는 duplicate write도 발생</strong>하게 된다. <strong>이러한 상황을 Cache Stampede</strong>라고 한다. 이는 굉장히 비효율적인 상황이고, 이런 상황이 발생하면 처리량도 느려질 뿐 아니라 불필요한 작업들이 늘어나 장애로까지 이어질 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/8963c605-1928-4105-b631-fa9f0670ec8d/image.png" alt=""></p>
<h2 id="maxmemory-설정-시-주의할-점">MAXMEMORY 설정 시 주의할 점</h2>
<p>redis의 데이터를 파일로 저장할 때 포크를 통해 자식 프로세스를 향상한다. <strong>자식 프로세스로 백그라운드에서는 데이터를 파일로 저장</strong>하고 있지만, <strong>원래 프로세스는 계속해서 일반적인 요청을 받아 데이터를 처리</strong>한다. 이게 가능한 이유는 <span style='background-color: #fff8d3'>copy on write라는 기능으로 메모리를 복사해서 사용하기 때문</span>이다. 이로 인해 <span style='background-color: #fff8d3'>서버의 메모리 사용률은 2배로 증가하는 상황이 발생할 수 있다.</span> 만약에 데이터를 영구 저장하지 않는다고 해도 복제 기능을 사용하고 있다면 주의해야 한다. 복제 연결을 처음 시도하거나, 혹은 연결이 끊겨 재시도를 할 때에 새로 RDB 파일을 저장하는 과정을 거치기 때문이다. 따라서 이런 경우에는 <span style='background-color: #fff8d3'>MAXMEMORY 값은 실제 메모리의 절반 정도로 설정해 주는 것이 바람직하다.</span> 예상치 못한 상황에 메모리가 가득차서 장애가 발생할 가능성이 매우 높기 때문이다.
<img src="https://velog.velcdn.com/images/pie_e/post/949a534d-ff2f-44aa-8b3c-98ca142fd2ef/image.png" alt=""></p>
<h2 id="모니터링시-유의해야-할-점">모니터링시 유의해야 할 점</h2>
<p>redis는 메모리를 사용하는 저장소이기 때문에 메모리 관리가 운영에서 제일 중요하다. 모니터링할 때도 유의해야할 점이 있는데 모니터링할 때 <span style='background-color: #fff8d3'><strong>used_memory</strong>값이 아닌 <strong>user_memory_rss</strong>값을 보는게 더 중요</span>하다. <span style='background-color: #fff8d3'><strong>used_memory</strong>는 논리적으로 레디스에 얼만큼의 데이터가 저장되어 있는 지를 나타내고 <strong>used_memory_rss</strong>는 OS가 실제로 redis에 얼만큼의 메모리를 할당했는지를 보여준다</span>. 따라서 <strong>실제 저장되어 있는 값은 적은데 rss 값은 큰 상황이 발생할 수 있고, 이 차이가 클 때 fragmentation이 크다고 할 수 있다</strong>. 주로 삭제되는 키가 많을 때 fragmentation이 증가한다. 예를 들어, 특정 시점에 키가 피크를 치고 다시 삭제되는 경우 혹은 TTL로 인해 삭제가 과도하게 많을 경우에 발생한다. 
<img src="https://velog.velcdn.com/images/pie_e/post/5fa073fd-53db-472d-bcb3-02efef1285eb/image.png" alt="">
위 그래프는 피크를 친 시점에 그래프인데 초록색 used 그래프는 확 내려간 반면 노란색 rss 그래프는 아직 많이 차있는 것을 볼 수 있다. 
이때 <strong><code>activefrag</code></strong> 라는 기능을 잠시 켜두면 도움이 된다. 실제 공식 문서에서도 이 값을 <span style='background-color: #fff8d3'>항상 켜두는 것보다는 단편화가 많이 발생했을 때 켜두는 것을 권장</span>하고 있다. </p>
<h1 id="끝">끝</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/b5990ab6-a5e9-410f-8758-7ad7cf1825e3/image.png" alt=""></p>
<p>[ref] <a href="https://www.youtube.com/watch?v=92NizoBL4uA">https://www.youtube.com/watch?v=92NizoBL4uA</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SOLID? 객체지향?]]></title>
            <link>https://velog.io/@pie_e/SOLID-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5</link>
            <guid>https://velog.io/@pie_e/SOLID-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5</guid>
            <pubDate>Wed, 06 Nov 2024 05:56:17 GMT</pubDate>
            <description><![CDATA[<h1 id="solid-">SOLID ?</h1>
<p>SOLID란 객체 지향 프로그래밍을 하면서 지켜야하는 5가지 원칙으로 각각 SRP(단일 책임 원칙), OCP(개방 폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존 역전 법칙)의 앞글자를 따서 만들어졌다.
SOLID 원칙을 철저히 지키면 시간이 지나도 변경이 용이하고, 유지보수와 확장이 쉬운 소프트웨어를 개발하는데 도움이 되는 것으로 알려져있다.
<img src="https://velog.velcdn.com/images/pie_e/post/d0fb44b3-bad8-4973-bf4e-3554e9f9e452/image.png" alt=""></p>
<h2 id="-단일-책임-원칙--srp-single-responsibility-principle">[ 단일 책임 원칙 ] (SRP, Single Responsibility Principle)</h2>
<p>헷갈릴 수 있지만 <span style='background-color: #fff8d3'> 단일 책임 원칙</span>은 하나의 클래스(이하 객체)가 하나의 책임을 가져야 한다는 모호한 원칙으로 해석하면 안된다. 대신 객체가 변경되는 이유가 한가지여야 함으로 받아 들여야 한다. 여기서 변경의 이유가 한가지 책임이라는 것은 해당 모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안되고, <span style='background-color: #fff8d3'>오직 하나의 액터에 대해서만 책임을 져야 한다는 것을 의미</span>한다. </p>
<p>만약 어떤 객체가 여러 액터에 대해 책임을 가지고 있다면 여러 객체들로부터 변경에 대한 요구가 올 수 있으므로, 해당 객체를 수정해야 하는 이유 또한 여러개가 될 수 있다. 반면에 <strong>어떤 객체가 단 하나의 책임 만을 가지고 있다면, 특정 액터로 부터 변경을 특정할 수 있으므로 해당 객체를 변경해야 하는 이유와 시점이 명확</strong>해진다. </p>
<p>단일 책임의 원칙을 제대로 지키면 변경이 필요할 때 수정할 대상이 명확해진다. 그리고 이러한 단일 책임의 원칙의 장점은 시스템이 커질수록 극대화되는데, 시스템이 커지면서 서로 많은 의존성을 갖게되는 상황에서 변경 요청이 오면 딱 1가지만 수정하면 되기 때문이다.</p>
<p>단일 책임 원칙을 적용하여 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 추상화함으로써 애플리케이션의 변화에 손쉽게 대응할 수 있다.</p>
<h2 id="-개방-폐쇄-원칙--ocp-open-close-principle">[ 개방 폐쇄 원칙 ] (OCP, Open-Close Principle)</h2>
<p><span style='background-color: #fff8d3'> 개방 폐쇄 원칙</span>은 <span style='background-color: #fff8d3'> 확장에 대해 열려있고 수정에 대해서는 닫혀있어야 한다는 원칙</span>으로, 각각이 갖는 의미는 다음과 같다.</p>
<ul>
<li>확장에 대해 열려있다 : 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.</li>
<li>수정에 대해 닫혀있다 : 기존의 코드를 수정하지 않고 애플리케이션의 동작을 추가하거나 변경할 수 있다.</li>
</ul>
<p><span style='background-color: #fff8d3'> OCP가 본질적으로 얘기하는 것은 추상화</span>이다. <span style='background-color: #fff8d3'> 추상화를 통해 변하는 것들은 숨기고 변하지 않는 것들에 의존하게 하면 우리는 기존의 코드 및 클래스들을 수정하지 않은 채로 애플리케이션을 확장할 수 있다</span>.</p>
<p><strong>닫혀있는 클래스를 확장하는 방법</strong>은 크게 두 가지 방법이 존재한다. 첫 번째 방법은 <strong>상속을 활용</strong>하는 것이고 두 번째 방법은 <strong>포함 관계를 이용</strong>하는 것이다. 객체지향에서는 한 클래스를 언제든지 상속하여 필요한 상태와 기능을 추가할 수 있다. 하지만 <strong>상속은 is-a 관계가 성립해야 하며, 클래스 간 관계가 고정되기 때문에 유연하지 못할 수 있다</strong>. 또 클래스가 상속을 염두하고 설계되어 있지 않다면 해당 클래스를 상속하여 자식 클래스를 정의하기 어려울 수 있다. </p>
<p><strong>닫혀있는 클래스를 더 유연하게 확장하는 방법은 포함 관계를 이용</strong>하는 것이다. 클래스의 어떤 기능을 다른 클래스에 위임하고 그 <strong>클래스의 객체를 상위 타입에 의존하면 언제든지 유지하고 있는 객체를 바꾸어 기능을 바꿀 수 있다</strong>. 또 포함 관계를 이용하면 다양한 것들을 조합하여 새로운 특성의 클래스를 만들 수 있다.</p>
<p>결제 클래스가 있고, 그것을 상속한 PG 결제, 토스페이 클래스가 있을 때 결제 클래스를 상속한 네이버페이, 페이코 클래스를 만든다고 기존 정의한 PG 결제나 토스페이를 수정할 필요는 없다. 또 다형성을 활용하였다면 PG 결제 타입이나 토스페이를 이용하는 기존 코드도 수정할 필요가 없다. 이것이 개방 폐쇄 원칙을 의미하는 것이다. </p>
<p>그렇다고 하여 추후 확장될 가능성이 거의 없는 것들까지 미리 준비하는 것은 과도한 설계가 될 수 있다. 개방 폐쇄 원칙은 &quot;공짜&quot;가 아니다. 따라서 코드의 확장성과 가독성 사이에서 적절한 균형이 필요하다. 따라서 단기간 내에 진행할 수 있는 확장, 구현 비용이 많이 들지 않는 확장에 대해 확장 포인트를 미리 준비하되, 향후 지원 여부가 확실하지 않은 요구사항이나 확장이 오히려 개발에 부하를 주는 경우에는 해당 작업이 실제로 필요할 때 리팩터링 하는 것이 더 나을 수 있다.</p>
<h2 id="-리스코프-치환-원칙--lsp-liskov-substitution-principle">[ 리스코프 치환 원칙 ] (LSP, Liskov Substitution Principle)</h2>
<p><span style='background-color: #fff8d3'> 리스코프 치환 원칙</span>은 1988년 바바라 리스코프가 <span style='background-color: #fff8d3'>올바른 상속 관계의 특징을 정의하기 위해 발표</span>한 것으로, <span style='background-color: #fff8d3'>하위 타입은 상위 타입을 대체할 수 있어야 한다는 것</span>이다. </p>
<p>상속은 코드를 재사용할 수 있도록 해주고, 코드 중복을 없애준다. 상속의 장점은 이뿐만이 아니다. 상속은 상위 클래스 타입을 후손 클래스를 아우르는 공통 리모컨으로 사용할 수 있도록 해준다. 이를 통해 <span style='background-color: #fff8d3'>다형성을 활용할 수 있고, 범용 프로그래밍 또한 가능</span>하다. </p>
<p>이와 같은 장점들이 존재하지만 코드를 재사용할 수 있다고 무조건 상속하면 매우 어색한 코드를 만들 수 있다. 따라서 상속은 반드시 is-a 관계가 성립하는 경우에만 사용해야 한다. 예시로 Pet을 상속하여 사람을 정의하면 &quot;사람은 애완동물이다.&quot;라는 명제가 성립하지 않기 때문에 올바른 상속관계라 할 수 없다. 또한 is-a 관계가 성립하더라도 무조건 상속할 필요는 없다. 하여 <strong>이 상속이 적절한 상속인지 살펴볼 때 고려해야하는 원리가 바로 LSP</strong>이다. </p>
<p>즉, 해당 객체를 사용하는 클라이언트는 상위 타입이 하위 타입으로 변경 되어도 차이점을 인식하지 못한 채 상위타입의 퍼블릭 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다는 것이다. 즉, 이는 클라이언트와 객체 사이의 계약이 존재하고, 이를 준수해야 한다는 원칙이므로 계약에 따른 설계(design by contract)라고 표현할 수 도 있다. 하위 클래스는 상위 클래스의 동작 규칙(계약)을 따라야 하는 것이다. </p>
<p>다음은 리스코프 치환 원칙을 위반할 수 있는 상황들을 일부 정리한 것이다.</p>
<ul>
<li>하위 클래스가 상위 클래스에서 선언한 기능을 위반한 경우<ul>
<li>상위 클래스가 주문 정렬을 위한 sortOrderByAmount() 함수를 구현해 두었는데, 하위 클래스에서 생성 날짜에 따라 정렬되도록 변경한 경우</li>
</ul>
</li>
<li>하위 클래스가 입력, 출력 및 예외에 대한 상위 클래스의 계약을 위반한 경우<ul>
<li>상위 클래스에서 오류가 발생하면 null을, 값을 얻을 수 없으면 빈 컬렉션을 반환하게 해두었는데, 하위 클래스에서 오류가 발생하면 예외를 발생시키고, 값을 얻을 수 없으면 null을 반환하도록 변경 한 경우</li>
<li>상위 클래스에서는 입력 시 모든 정수를 허용하지만, 하위 클래스에서는 음수일 때 예외를 발생시키는 경우</li>
<li>상위 클래스에서 던지는 예외는 ArgumentException뿐이데, 하위 클래스에서는 다른 예외도 던지는 경우</li>
</ul>
</li>
<li>하위 클래스가 상위 클래스의 주석에 나열된 특별 지침을 위반하는 경우<ul>
<li>상위 클래스에 예금을 인출하는 withDraw() 메서드에 사용자의 출금 금액이 잔액을 초과해서는 안된다는 주석이 있을 때, 하위 클래스에서는 가능한 경우</li>
</ul>
</li>
</ul>
<p>리스코프 치환 원칙 위반 여부를 판단하기 위한 방법으로 상위 클래스의 단위 테스트를 하위 클래스까지 확인하는 방법도 있다. 테스트가 실패하면 상위 클래스의 계약을 완전히 준수하지 않고 하위 클래스가 리스코프 치환 원칙을 위반할 수 있음을 알 수 있다. </p>
<h2 id="-인터페이스-분리-원칙--isp-interface-segregation-principle">[ 인터페이스 분리 원칙 ] (ISP, Interface Segregation Principle)</h2>
<p><span style='background-color: #fff8d3'>인터페이스 분리 원칙</span>은 <span style='background-color: #fff8d3'>클래스 자신이 필요하지 않는 메서드를 구현하도록 강요되지 않아야 한다는 원리</span>이다. 이 원리에 충실하기 위해 클래스나 인터페이스가 제공하는 메서드의 수는 최소화 되어야 한다. </p>
<p>객체가 충분히 높은 응집도의 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해주어야 한다는 것이다. 즉, <strong>인터페이스 분리 원칙</strong>이란 <span style='background-color: #fff8d3'>클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것</span>이다. 인터페이스 분리 원칙을 준수함으로써 모든 클라이언트가 자신의 관심에 맞는 퍼블릭 인터페이스(외부에서 접근 가능한 메세지)만을 접근하여 <strong>불필요한 간섭을 최소화할 수 있으며, 기존 클라이언트에 영향을 주지 않은 채로 유연하게 객체의 기능을 확장하거나 수정할 수 있다</strong>.</p>
<p>인터페이스 분리 원칙을 지킨다는 것은 어떤 구현체에 부가 기능이 필요하다면 이 인터페이스를 구현하는 다른 인터페이스를 만들어서 해결할 수 있다. 예를 들어 파일 읽기/쓰기 기능을 갖춘 구현 클래스가 존재하는데 어떤 클라이언트는 읽기 작업만을 필요로 한다면 별도의 읽기 인터페이스를 만들어 제공해주는 것이다. </p>
<p>클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더욱 세밀하게 제어할 수 있다. 그리고 이렇게 인터페이스를 클라이언트의 기대에 따라 분리하여 변경에 의한 영향을 제어하는 것을 인터페이스 분리 원칙이라고 한다.</p>
<p>여기서 인터페이스는 꼭 하나의 인터페이스 파일에만 해당하지는 않는다. 인터페이스 분리 원칙에서 이야기하는 인터페이스는 넓게 보아 아래의 내용들까지 확장될 수도 있다.</p>
<ul>
<li>API나 기능의 집합</li>
<li>단일 API 또는 기능</li>
<li>객체지향 프로그래밍의 인터페이스</li>
</ul>
<h2 id="-의존-역전-법칙--dip-dependency-inversion-principle">[ 의존 역전 법칙 ] (DIP, Dependency Inversion Principle)</h2>
<p><span style='background-color: #fff8d3'>의존 역전 법칙</span>이란 <span style='background-color: #fff8d3'>고수준 모듈은 저수준 모듈의 구현에 의존해서는 안되며, 저수준 모듈이 고수준 모듈에 의존해야 한다는 것</span>이다. 객체 지향 프로그래밍에서는 객체들 사이에 메세지를 주고 받기 위해 의존성이 생기는데, 의존성 역전 원칙은 올바른 의존 관계를 위한 원칙에 해당된다. 여기서 각각 고수준 모듈과 저수준 모듈이란 다음을 의미한다.</p>
<ul>
<li>고수준 모듈 : 입력과 출력으로부터 먼(비즈니스와 관련된) 추상화된 모듈</li>
<li>저수준 모듈 : 입력과 출력으로부터 가까운(데이터베이스, 캐시 등과 관련된) 구현 모듈</li>
</ul>
<p>의존 역전 원칙이란 결국 <span style='background-color: #fff8d3'>비즈니스와 관련된 부분이 세부 사항에는 의존하지 않는 설계 원칙을 의미</span>한다. </p>
<p>의존 역전 원칙은 개방 폐쇄 원칙과도 밀접한 관련이 있으며, 의존 역전 법칙이 위배되면 개방 폐쇄 원칙 역시 위배될 가능성이 높다. 또한 의존 역전 원칙에서 주의해야 하는 것이 있는데, 의존 역전 원칙에서 의존성이 역전되는 시점이라는 것이다.</p>
<h2 id="결론">결론</h2>
<p>위 내용들을 읽으면서 느꼈지만, 5가지 객체 지향 설계 원칙인 SOLID가 얘기하는 핵심은 결국 추상화와 다형성이다. 구체 클래스에 의존하지 않고 추상 클래스(또는 인터페이스)에 의존함으로써 우리는 유연하고 확장 가능한 애플리케이션을 만들 수 있다는 것이다.</p>
<h1 id="끝">끝</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/84bae0a5-90c8-486a-831f-012352a043ad/image.png" alt="">
재미 업ㅅ어? 재미.있다고 생각하면 되.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 문제 해결하기]]></title>
            <link>https://velog.io/@pie_e/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@pie_e/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 05 Nov 2024 09:29:56 GMT</pubDate>
            <description><![CDATA[<h1 id="overview">Overview</h1>
<p>진행하고 있는 프로젝트 중 상품의 상세페이지 조회 시 조회수가 1 증가하는 로직이 있습니다. 매번 각각의 요청이 들어오게 되면 조회수가 정상적으로 증가하겠지만, 동시에 100개의 요청이 들어온다면 어떻게 될까요?</p>
<h2 id="1-1-테스트-코드">1-1. 테스트 코드</h2>
<pre><code class="language-java">@DisplayName(&quot;상품의 상세 페이지를 여러명이 동시에 조회한다&quot;)
@Test
void itemDetails_V2() throws InterruptedException {
  // given
  Member member = 
    supportRepository.save(new Member(&quot;jenny&quot;,&quot;password&quot;, &quot;profile&quot;));

  Principal principal = setPrincipal(member);
  Category category = setCategory();

  Item item1 = setItem(member, category, &quot;1번 상품&quot;, &quot;내용&quot;, ItemStatus.ON_SALE);

  int threadCount = 8;
  ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
  CountDownLatch countDownLatch = new CountDownLatch(100);

  // when
  for (int i = 0; i &lt; 100; i++) {
    executorService.execute(() -&gt; {
      try {
        itemService.itemDetails(principal, item1.getId());
      } finally {
          countDownLatch.countDown();
      }
    });
  }

  countDownLatch.await();

  // then
  Thread.sleep(1000 * 90);

  Item findItem = supportRepository.findById(item1.getId()), Item.class);
  assertThat(findItem.getViewCount()).isEqualTo(100);
}</code></pre>
<h2 id="결과--문제점-발생">결과 : 문제점 발생</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/2ec41aa5-5605-43db-b77d-a14bac195314/image.png" alt="">
기대했던 조회수가 <strong>100이 아닌 93</strong>으로 나왔습니다. 왜 이런 상황이 발생할까요?</p>
<h2 id="원인">원인</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/bd198b04-1094-446d-9823-08a1d840772a/image.png" alt="">
먼저 T1의 트랜잭션이 조회수가 13인 데이터를 가져와 증가시키고 커밋시키기 직전 T2 트랜잭션이 아직 업데이트 되기 전인 조회수를 읽어와 조회수를 증가시키며 조회수에 대한 누락이 발생하여 기대한 조회수 100이 아닌 93이 나오게 되었습니다. </p>
<p>이제 문제를 해결할 방법을 찾아보겠습니다.</p>
<h2 id="해결1-syncronized-사용하기">해결1. syncronized 사용하기</h2>
<p>첫 번째로 생각한 방법은 <code>syncronized</code> 키워드를 이용해 동시성을 제어 방법입니다.</p>
<blockquote>
<p>💡 syncronized
<code>syncronized</code>는 멀티 스레드 환경에서 여러 스레드가 하나의 공유 자원에 동시에 접근하지 못하도록 막아주는 키워드.</p>
</blockquote>
<h3 id="조회수-증가로직">조회수 증가로직</h3>
<p>조회수를 증가 시키는 로직에 <code>syncronized</code> 키워드를 사용하여 작성해보았습니다.</p>
<pre><code class="language-java">@Transactional
public syncronized ItemDetailResponse ItemDetails(Long itemId) {
  itemViewCountService.addViewCount(itemId);
  ...
}</code></pre>
<p>이후 동일한 테스트를 돌렸을 때 <span style='background-color: #fff8d3'> <strong>각각의 스레드가 이전 스레드의 작업이 완료될 때까지 대기하기 때문에 성공할 것이라 예상했지만? 실패했습니다.</strong></span> 이유는 왜일까요?</p>
<p>바로 스프링이 <code>@Transactional</code>을 처리하는 방식 때문 입니다. 
스프링은 <code>@Transactional</code> 어노테이션을 AOP 방식으로 처리함으로써 <code>@Transactional</code> 어노테이션이 선언되어있는 클래스는 <code>proxy 클래스</code>를 만들고 기존 클래스(<code>@Transactional</code> 어노테이션이 선언되어 있는 클래스)를 상속 받아 트랜잭션을 처리하게 됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/f42d0856-d9e2-4d81-b9dc-cfd73c591184/image.png" alt="">
그림으로 살펴보면 <code>t1</code> 스레드가 조회수를 증가시키는 로직을 완료한 후 트랜잭션을 종료시키려는 시점에 <code>t2</code> 스레드가 접근해버려 문제가 발생했던 것입니다.</p>
<p>이상한 일이죠? 분명 이런 문제를 해결하기 위해 여러 개의 스레드가 하나의 공유 자원에 동시에 접근하지 못하도록 <code>syncronized</code>를 붙여놨는데도 왜 <code>t2</code> 스레드는 접근이 가능했을까요?</p>
<p>바로 <span style='background-color: #fff8d3'> <strong><code>syncronized</code>가 실행되기 전에 트랜잭션이 먼저 실행되었기 때문</strong></span>입니다.
즉, <code>syncronized</code>는 트랜잭션보다 앞서 시작되어야 공유 자원에 동시에 접근하지 못하는 상황을 만들 수 있을 것 같습니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class SyncronizedService {
  private final ItemService itemService;

  public syncronized void increaseViewCount(Long itemId) {
    itemService.itemDetails(itemId);
  }
}</code></pre>
<pre><code class="language-java">@Transactional
public syncronized ItemDetailResponse itemDetails(Long itemId) {
  itemViewCountService.addViewCount(itemId);
  ...
}</code></pre>
<p>그림으로 본다면 아래와 같은 형태가 됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/0d13d3a7-41db-4a65-92a5-da3637374e34/image.png" alt=""></p>
<h3 id="여전한-문제점">여전한 문제점</h3>
<p>하지만 <code>syncronized</code> 키워드는 서버가 여러 대인 경우 문제가 생길 수 있습니다. 
<strong><code>syncronized</code>는 하나의 프로세서 안에서만 보장</strong>을 받을 수 있기 때문에 서버가 두 대 이상일 경우 데이터의 접근을 여러 곳에서 하게 되면 <strong>race condition 문제를 야기</strong>할 수 있습니다.</p>
<blockquote>
<p>💡 race condition
두 개 이상의 프로세스가 공유 자원에 동시에 접근할 때 실행 순서에 따라 결과 값이 달라질 수 있는 현상을 의미</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/pie_e/post/e879867a-972d-4731-ac7f-b3c55e78da1d/image.png" alt="">
그렇다면 이 상황을 해결할 수 있는 다른 방법은 뭐가 있을까요?</p>
<h2 id="해결-2-locking">해결 2. Locking</h2>
<p>두 번째로 생각한 방법은 서버가 여러 대일 경우를 고안하여, DB에서 락을 활용해 동시성 문제를 해결할 수 있을 것 같습니다. 
Locking 기법에는 여러가지가 존재하지만 그 중 다음 두가지를 살펴 보겠습니다.</p>
<ol>
<li>Pessimistic lock (비관적 락)</li>
<li>Optimistic lock (낙관적 락)</li>
</ol>
<h3 id="pessimistic-lock">Pessimistic lock</h3>
<p>비관적 락은 DB의 실제 데이터에 락을 걸어 데이터의 정합성을 맞추는 방법입니다. 
비관적 락은 트랜잭션이 시작할 때 X-lock 또는 S-lock을 걸게 됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/9b01ca97-9320-4558-b3c8-46a41b16e996/image.png" alt="">
코드를 통해 비관적 락을 적용하는 과정을 살펴보겠습니다.</p>
<pre><code class="language-java">public interface ItemRepository extends JpaRepository&lt;Item, Long&gt; {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query(&quot;SELECT item FROM Item item WHERE item.id =: itemId&quot;)
  Optional&lt;Item&gt; findByWithPessimisticLock(@Param(&quot;itemId&quot;) Long itemId);
}</code></pre>
<pre><code class="language-java">@Transactional
public syncronized ItemDetailResponse itemDetails(Long itemId) {
  Item item = itemRepository.findByIdWithPessimisticLock(itemId)
       .orElseThrow(() -&gt; new NotFoundException());
  itemViewCountService.addViewCount(itemId);
}</code></pre>
<p>JPA에서는 쉽게 락을 설정할 수 있는 <code>@Lock</code> 어노테이션을 제공해줍니다. 해당 어노테이션의 <code>PESSIMISTIC_WRITE</code>는 X-lock을 건다는 의미와 동일합니다. 
위 그림처럼 조회수 증가 로직에 접근하는 동안 다른 프로세스에서 접근하지 못하게 하기 위해 사용하였습니다.</p>
<h3 id="여전한-문제점-1">여전한 문제점?</h3>
<p>비관적 락을 사용하는 것은 문제를 해결하는 하나의 방법이지만, 항상 좋은 방법은 아닙니다. 
왜냐하면 하나의 트랜잭션이 작업을 완료할 때 까지 lock을 걸고 있기 때문에 다른 트랜잭션은 대기해야하며 그로 인한 처리 속도는 떨어지기 때문입니다.
또한, 단일 DB가 아닌 환경에서는 문제가 발생할 수 있습니다.</p>
<h2 id="optimistic-lock">Optimistic lock</h2>
<p>낙관적 락은 실제로 락을 이용하지는 않고 버전 정보를 통해 데이터의 정합성을 맞추는 방법입니다.
<img src="https://velog.velcdn.com/images/pie_e/post/a872e2f0-2569-47a9-8b5d-1cdf4e11f598/image.png" alt="">
위 그림처럼 <code>t1</code>이 먼저 DB의 데이터를 변경하고 버전 정보를 수정한 뒤, <code>t2</code>가 <code>version=1</code>을 가지고 데이터를 수정한 후 DB에 반영하려 할 때 내가 가지고 있는 버전 정보와 DB에 반영되어 있는 버전 정보가 맞지 않아 업데이트에 실패하게 됩니다. 
이처럼 내가 읽은 버전 정보에서 수정사항이 생겼을 경우 애플리케이션에서 다시 데이터를 읽은 후 업데이트를 시도해야 합니다.</p>
<p>그럼 바로 코드를 통해 살펴보겠습니다.
먼저 조회수 증가 요청이 오면 이벤트를 받는 리스너를 등록해줍니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class ItemEventListener {
  private final OptimisticLockFacade optimisticLockFacade;

  @TransactionalEventListener
  public void addViewCount(Long itemId) throws InterruptedException {
    optimisticLockFacade.addViewCount(itemId);
  }
}</code></pre>
<p>낙관적 락의 경우 버전 정보가 일치하지 않을 때 재시도해야 하기 때문에 다음과 같이 while문을 통해 데이터를 수정하는 로직을 작성해 보았습니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class OptimisticLockFacade {
  private final OptimisticLockViewCountService optimisticLockViewCountService;

  public void addViewCount(Long itemId) throws InterruptedException {
    while (true) {
      try {
         optimisticLockViewCountService.addViewCount(new ItemViewEvent(itemId));
         break;
       } catch (Exception e) {
           Thread.sleep(50);
       }
     }
   }
 }</code></pre>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class OptimisticLockViewCountService {
  private final ItemRepository itemRepository;
  private final ItemRepository itemRepository;

  @Transactional
  public void addViewCount(Long itemId) {
     Item item = itemRepository.findByIdWithPessmisticLock(itemId).orElseThrow();
     itemViewCountService.addViewCount(itemId);
        ...
  }
}</code></pre>
<p>이처럼 낙관적 락의 경우 비관적 락과 달리 데이터에 락을 걸지 않고 버전 정보를 이용하기 때문에 상황에 따라 비관적 락보다 성능이 더 좋을 수 있습니다.
하지만 위처럼 업데이트가 실패했을 경우 개발자가 직접 재시도 로직을 작성해줘야 한다는 단점이 존재합니다.</p>
<h2 id="결론">결론</h2>
<p>여기까지 동시성 문제를 해결하기 위한 방법으로 syncronized, 비관적 락, 낙관적 락에 대해 알아보았습니다. 
현재 진행하고 있는 프로젝트는 다중 서버, 단일 DB 환경을 고려하고 있기 때문에 synchronized보다는 락을 활용한 동시성 제어를 사용하게 되었습니다.
또한 조회수 증가의 경우 데이터의 변경이 빈번하게 일어나고 race condition이 종종 발생할 수 있다 판단해 현재 가장 간단하게 적용할 수 있는 비관적 락을 선택하게 됐습니다. 
추후 DB의 환경이 변경된다거나 다른 문제가 생긴다면 그때 다른 방법을 고려해 보겠습니다!</p>
<h1 id="끝">끝</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/796b0001-d37a-4f7a-94dc-e4804f81cb0a/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CORS PreflightRequest]]></title>
            <link>https://velog.io/@pie_e/CORS-PreflightRequest</link>
            <guid>https://velog.io/@pie_e/CORS-PreflightRequest</guid>
            <pubDate>Mon, 04 Nov 2024 08:25:45 GMT</pubDate>
            <description><![CDATA[<h1 id="📌-cors-cross-origin-resource-sharing">📌 CORS (Cross Origin Resource Sharing)</h1>
<p>CORS란 말 그대로  <span style='background-color: #fff8d3'> <strong> origin이 다른 경우에도 리소스를 공유할 수 있게 허용해주는 정책을 의미</strong></span>한다. 여기서 <strong>origin</strong>이란 <strong>프로토콜 + 호스트 + 포트를 합한 것</strong>으로 이 세가지가 동일하면 같은 origin이라고 판단한다. 
예를 들어, <code>http://123.456.789.123:3000</code>과 <code>http://123.456.789.123:8001</code>은 서로 다른 origin이다.</p>
<h2 id="cors의-필요성--프론트엔드와-백엔드의-분리">CORS의 필요성 : 프론트엔드와 백엔드의 분리</h2>
<p>과거에는 프론트엔드와 백엔드가 같은 서버에서 운영되었기에 동일한 origin간의 통신을 했는데, 프론트엔드와 백엔드가 각각 다른 서버에서 운영되면서 문제가 발생하기 시작했다.<br>별도로 존재하는 프론트엔드 서버에서 백엔드 서버로 요청(request)이 이뤄져야 하는 상황이 발생했고, 동시에 어떤 곳에서 백엔드 서버로 요청이 올지 알 수 없게 되었다.
따라서 백엔드 서버쪽에서 어떤 origin에서 요청을 했을 때 받아줄 것인지 허용 origin에 대해 정의해 줄 필요가 생겼다.</p>
<blockquote>
<p>백엔드 서버 :
&quot;나는 이 origin에서 이뤄지는 요청만 처리해 줄 거야. 다른 origin은 믿을 수 없어. 해커가 날 공격할 수 도 있잖아?&quot;</p>
</blockquote>
<p>이 때, 프론트엔드에서 백엔드쪽으로 요청을 보낸 이후에만 허용된 origin인지 아닌지를 알 수 있는 상황이어서 문제가 되었다. <strong>애초에 요청 자체를 못하게 막을 방법이 필요</strong>했고, 그래서 나온것이 <strong>preflight request</strong>이다.</p>
<h1 id="📌-preflightrequest">📌 PreflightRequest</h1>
<p>preflight request는 <span style='background-color: #fff8d3'> <strong>실제 요청전에 브라우저에서 보내는 작은 요청</strong></span>이다. 이 요청의 목적은 다음과 같다.</p>
<ol>
<li>현재 요청을 보내려는 프론트엔드의 origin이 백엔드에서 허용한 origin인지 확인</li>
<li>요청하고자 하는 HTTP 메서드가 백엔드에서 허용한 메서드인지 확인</li>
</ol>
<p>백엔드 서버가 해당 origin과 메서드를 허용하는 경우에만 실제 요청이 이루어지며, 그렇지 않다면 실제 요청을 보내기도 전에 보내지 못하게 차단하는 것이다. 
preflight request 덕분에, 허용되지 않은 origin에서의 요청은 미리 차단되므로 서버에 불필요한 접근이 줄어들고 보안성이 강화되었다.
아래 그림을 보면 preflight request가 무엇인지 이해하는데 도움이 된다. 
<img src="https://velog.velcdn.com/images/pie_e/post/0e646a11-1a1e-42da-937b-91e37e841c1c/image.png" alt="">
preflight request는 OPTIONS 메서드에 의해 만들어지기 때문에,  <span style='background-color: #fff8d3'> <strong> preflight request가 이뤄지려면 서버에서 OPTIONS 메서드를 허용 </strong></span>해주어야 한다. </p>
<h2 id="해결-방법">해결 방법</h2>
<p>aws에서 해당 인스턴스의 <strong>권한</strong>으로 들어가면 CORS가 나오게 된다.
<img src="https://velog.velcdn.com/images/pie_e/post/7de5e7e8-4387-4ca5-8b27-fd3f36981442/image.png" alt="">
편집을 누르고 아래와 같이 <code>Access-Control-Allow-Methods</code> 부분에 OPTIONS 메서드를 허용해주면 된다.</p>
<pre><code>[
    {
        &quot;AllowedHeaders&quot;: [
            &quot;*&quot;
        ],
        &quot;AllowedMethods&quot;: [
            &quot;GET&quot;,
            &quot;HEAD&quot;,
            &quot;OPTIONS&quot;   // &lt;- OPTIONS 추가.
        ],
        &quot;AllowedOrigins&quot;: [
            &quot;http://www.example.com&quot;
        ],
       ....
    }
]</code></pre><p>또한, 해당 프로젝트에선 JWT 토큰을 검증하기 위해 JwtFilter를 구현해두었으며 모든 요청은 JwtFilter를 타게 되어있다.</p>
<pre><code class="language-java">public class JwtFilter extends OncePerRequestFilter {
  private static final String BEARER = &quot;bearer&quot;;

  ....

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
       if (CorsUtils.isPreFlightRequest(request)) {
           filterChain.doFilter(request, response);
            return;
        }

        ....</code></pre>
<p>하여 <code>doFilterInternal</code>이라는 메서드에 if 조건문을 달아 해당 요청이 <strong>preFlightRequest</strong>라면 return을 하도록 구현하도록 했다.</p>
<h1 id="끝">끝</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/73ab7d79-6d9b-4a89-a7d8-6be5fb3b2651/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Pagination의 offset 방식과 no-offset 방식]]></title>
            <link>https://velog.io/@pie_e/Pagination%EC%9D%98-offset-%EB%B0%A9%EC%8B%9D%EA%B3%BC-no-offset-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@pie_e/Pagination%EC%9D%98-offset-%EB%B0%A9%EC%8B%9D%EA%B3%BC-no-offset-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Sun, 03 Nov 2024 09:18:39 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-pagination을-사용하는-이유">📌 Pagination을 사용하는 이유?</h2>
<p>100만개의 상품이 있는 애플리케이션에서 사용자가 상품의 목록을 요청한다고 가정해보자. 매번 100만개의 데이터를 전부 가져오게되면 매우 느려지며 사용자는 불편함을 느끼게 될 것이다. 하지만 데이터를 조금씩 (20~100개) 나눠서 가져오고 사용자가 원하는 경우 다음 데이터를 가져오게 되면 훨씬 빠르고 사용자도 애플리케이션에 대해 만족할 것이다. 이러한 이유로 Pagination을 사용한다.</p>
<h3 id="pagination-구현-방법">Pagination 구현 방법</h3>
<ul>
<li><p>offset 방식 : offset과 limit 예약어를 통해 select의 전체 결과 중 일부만 가져오는 방법이다.</p>
</li>
<li><p>no-offset(cursor) 방식 : cursor는 어떠한 row를 가르키는 포인터이고, 이 cursor가 가르키는 row부터 일정 개수만큼 데이터를 가져오는 방식이다.</p>
<h3 id="📌-offset">📌 offset</h3>
<p>offset이란 SQL에서 조회를 시작할 기준점을 의미하고 limit는 조회할 결과의 개수를 의미한다. </p>
<pre><code class="language-SQL">SELECT * FROM { 테이블 이름 } LIMIT 20 OFFSET 60;</code></pre>
<p>위와 같은 예시는 60번째 행부터 20개의 데이터를 읽겠다는 것을 의미한다. (이때 행은 0부터 시작) <strong>offset은 조회를 한 결과에서 limit로 지정한 개수만큼만 반환하고 나머지는 버리는 식으로 동작</strong>한다. 
위 쿼리로 예를 들자면 60번째 row부터 20개를 조회하기 위해서 80개의 데이터(row의 0부터 79번째 까지)를 모두 읽은 뒤, 앞의 필요없는 60개의 데이터는 버려야 한다. 
위 쿼리처럼 적은양의 데이터를 조회할 때는 성능적인 문제가 발생하지 않지만, 전체 데이터의 개수가 많아질수록 버려지는 데이터의 양이 많아져 문제가 된다. </p>
<h4 id="offset-방식의-장점">offset 방식의 장점</h4>
</li>
<li><p><strong>직관적인 코드</strong>
기본적인 <code>SELECT ... OFFSET X LIMIT Y</code> 구조로 간단히 구현할 수 있어 코드가 직관적이다. 다양한 검색 조건을 추가해도 구조 자체는 동일해, 쿼리 작성이 비교적 간편하다.</p>
</li>
<li><p><strong>유연한 페이지 접근</strong>
<span style='background-color: #fff8d3'> 검색 조건이 다양해질 경우 <strong> </strong></span>에도 <code>OFFSET</code>과 <code>LIMIT</code>만 수정하면 되므로, 복잡한 필터나 검색 조건이 적용되는 경우에도 적용이 쉽다. </p>
<h4 id="offset-방식의-단점">offset 방식의 단점</h4>
</li>
<li><p><strong>성능 저하</strong>
위에서 말했듯이 offset 방식은 여러 개의 데이터를 한꺼번에 가져와 필요한 만큼만 출력하고 나머지는 버려지게 된다. 예를 들어 1,000,000개의 데이터를 가지고있고, 100,000번째의 row에서 20개만 필요하다고 가정해보자. 내가 필요한 만큼은 20개인데 데이터는 100,020개를 가져오고 100,000개의 불필요한 데이터가 버려지게된다. 그 숫자는 커지면 커질수록 속도는 저하된다.</p>
</li>
<li><p><strong>비효율적인 인덱스 활용</strong>
offset 방식은 인덱스 활용도가 낮고, 큰 페이지 번호로 이동할수록 인덱스 스캔 비용이 증가해 쿼리 성능에 영향을 미친다.</p>
<ul>
<li>offset이 큰 값일 때는 인덱스를 사용하더라도 많은 데이터 스캔이 필요하므로 인덱스에 대한 효율성이 떨어진다.</li>
</ul>
</li>
<li><p><strong>데이터 일관성 문제</strong>
실시간으로 데이터가 변동되는 경우, offset 방식은 페이지 간 이동 시 일관성을 보장하기 어려울 수 있다. 특히 데이터가 추가, 삭제가 빈번한 상황에서는 중복 조회나 데이터 누락이 발생할 수 있다. </p>
<ul>
<li>예를 들어, 사용자가 1페이지의 1번부터 5번까지의 상품을 보고있는데 관리자가 4,5번 상품을 삭제했다고 가정하고, 사용자는 상품의 2페이지를 요청한다고 가정할 시 데이터베이스는
<code>나는 1번부터 5개를 줬는.. 어? 4번 5번이 없네? 그럼 7번까지 줬구나 !</code>
라고 생각하고 2페이지는 8번 상품부터 5개의 데이터를 보내줄것이다. 그 결과 <strong>6번상품과 7번상품의 데이터가 누락</strong>이 된다.<h3 id="📌-no-offsetcursor">📌 no-offset(cursor)</h3>
no-offset(=cursor) 방식은 이 전 페이지의 마지막 데이터의 id 값을 기억하고, 다음 페이지를 요청할 때 이 id 값 이후의 데이터를 가져오는 방식이다. 
이를 통해 매번 offset 값을 계산하지 않아도 되기 때문에 데이터베이스에서 더욱 효율적으로 데이터를 가져올 수 있다. 
이 방식을 사용할 경우 기준점 이전의 데이터도 모두 조회하던 offset 방식과는 달리 기준점(=cursor)부터 limit의 개수만 조회하기 때문에 데이터의 개수가 많아져도 성능 문제가 발생하지 않는다. 
보통 무한 스크롤이라 칭하기도 하고, sns에서 많이 사용되고 있다.<pre><code class="language-SQL">SELECT * FROM { 테이블 }
WHERE { 조건문 }
AND id &lt; lastId
ORDER BY id DESC 
LIMIT { 컨텐츠 개수 }</code></pre>
<h4 id="no-offsetcursor방식의-장점">no-offset(cursor)방식의 장점</h4>
</li>
</ul>
</li>
<li><p><strong>성능 최적화</strong>
특정 인덱스를 기준으로 데이터를 조회하여, offset 방식과 달리 필요하지 않은 데이터 스캔이 줄어들어 성능이 빠르게 동작한다. 특히 대용량 데이터에서도 높은 성능을 유지할 수 있다.</p>
</li>
<li><p><strong>데이터 일관성 유지</strong>
고유 ID나 타임스탬프를 기준으로 조회하면 데이터가 변경되더라도 이전에 조회한 결과와 일관성을 유지하기가 용이하다. 이를 통해 페이지 이동 중 데이터의 순서가 안정적으로 유지된다. </p>
</li>
</ul>
<h4 id="no-offsetcursor방식의-단점">no-offset(cursor)방식의 단점</h4>
<ul>
<li><strong>검색 조건 추가 시 한계</strong><ul>
<li>no-offset방식은 주로 단일 키(고유 ID, 타임스탬프)를 기준으로 하기 때문에 검색 조건이 많아질 경우 특정 인덱스를 기반으로 데이터를 잘라내기 어렵다. 예를 들어, 필터가 여러 개 적용되는 경우 인덱스 필드 기준으로 페이지를 정하기가 복잡해지며, 특정 인덱스가 모든 조건에 적합하지 않아 성능 저하가 발생할 수 있다.</li>
<li>복합 조건에 따라 유동적인 정렬 순서를 요구하는 경우, 고유 키로 페이지를 구분하는 방식이 불가능해질 수 있다. 이 때문에 다양한 필터링을 필요로 하는 경우에는 구조적 한계가 있다.</li>
</ul>
</li>
<li><strong>임의 페이지 이동의 어려움</strong>
특정 위치에서 이어서 조회하는 방식이기 때문에, 사용자 요청에 따라 임의의 페이지 번호로 바로 이동하기 어렵다. (ex. 1페이지에서 5페이지로 바로 이동). 원하는 위치로 이동하기 위해서는 차례로 페이지를 조회하는 방법 외에는 대안이 없다.</li>
<li><strong>구현 복잡성</strong>
pagination구현 시 각 페이지의 마지막 ID를 기준으로 다음 페이지를 조회해야 하기 때문에 코드가 복잡해진다. 다양한 검색 조건을 추가하는 경우 이러한 기준을 설정하는 로직이 복잡해지며, 유지보수에 어려움을 겪을 수 있다.</li>
</ul>
<h3 id="요약">요약</h3>
<p>복합적인 검색 조건이 필요하거나 다양한 필터링을 제공해야 하는 경우라면 offset 방식이 유연하게 대응할 수 있다. 반면, 특정 키나 필드를 중심으로 일관된 순서를 유지하면서 빠르게 pagination을 제공해야 한다면 no-offset 방식이 적합하다.</p>
<p>즉, 각각의 장 단점을 살펴보고 상황에 맞게 적용하는게 <code>best!</code></p>
<h1 id="끝">끝!</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/38ad3388-e75f-4167-bec8-e88846f0d902/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA Auditing 이해 및 적용]]></title>
            <link>https://velog.io/@pie_e/JPA-Auditing-%EC%9D%B4%ED%95%B4-%EB%B0%8F-%EC%A0%81%EC%9A%A9-2mnqn13b</link>
            <guid>https://velog.io/@pie_e/JPA-Auditing-%EC%9D%B4%ED%95%B4-%EB%B0%8F-%EC%A0%81%EC%9A%A9-2mnqn13b</guid>
            <pubDate>Wed, 03 Jul 2024 06:47:41 GMT</pubDate>
            <description><![CDATA[<h2 id="auditing">Auditing?</h2>
<p>Audit은 사전적 의미로 감시하다, 심사하다 등의 의미를 가지고 있습니다.
Spring Data JPA에서는 Auditing이라는 기능을 제공하는데, 이를 사용하여 엔티티가 생성되고 변경되는 그 시점을 감지하여 생성시각, 수정시각, 생성한 사람, 수정한 사람 등의 정보를 저장할 수 있습니다. </p>
<h3 id="auditing-적용해보기">Auditing 적용해보기</h3>
<h4 id="📌-configuration-클래스-생성">📌 Configuration 클래스 생성</h4>
<pre><code class="language-java">@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {
}</code></pre>
<h4 id="enablejpaauditing">@EnableJpaAuditing</h4>
<p><code>@EnableJpaAuditing</code> 애노테이션을 붙여서 Auditing 활성화를 해줍니다. </p>
<h4 id="📌-엔티티-코드-작성">📌 엔티티 코드 작성</h4>
<pre><code class="language-java">@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Example {

  @CreationTimestamp
  private ZonedDateTime createdAt;

  @CreationTimestamp
  private ZonedDateTime updatedAt;
}</code></pre>
<h4 id="mappedsuperclass">@MappedSuperclass</h4>
<p>JPA Entity 클래스들은 Example 클래스를 상속 받을 경우 createdAt과 updatedAt을 필드로 인식합니다. </p>
<details>
  <summary> <strong> 예시</strong> </summary>
<div>

<p><img src="https://velog.velcdn.com/images/pie_e/post/0ae9da83-a09b-465a-8f60-4468e0643b8e/image.png" alt="">
  참고로 굳이 시간이 아니어도, 위 사진과 같이 여러 클래스에 공통 속성이 존재한다면 별개의 엔티티 클래스<code>ex) BaseEntity</code>로 분리하여 상속받아 사용하면 많은 코드의 중복을 줄여줄 수 있어 하나의 좋은 방법이 될 수 있습니다. </p>
</div>
</details>


<h4 id="entitylisteners">@EntityListeners</h4>
<p>Auditing을 적용할 엔티티 클래스에 <code>@EntityListeners</code> 애노테이션을 적용해야 합니다. 해당 애노테이션은 엔티티의 변화를 감지하여 엔티티와 매핑된 테이블의 데이터를 조작합니다.</p>
<h4 id="creationtimestamp">@CreationTimestamp</h4>
<p><code>@CreationTimestamp</code> 애노테이션은 INSERT 쿼리가 발생할 때, 현재 시간을 값으로 채워 쿼리를 생성합니다. </p>
<h3 id="끝-">끝 !</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/bb05e4fb-5682-41ea-b434-9d2008f3660f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[상속과 포함, Is-a와 has-a 이해]]></title>
            <link>https://velog.io/@pie_e/%EC%83%81%EC%86%8D%EA%B3%BC-%ED%8F%AC%ED%95%A8-Is-a%EC%99%80-has-a-%EC%9D%B4%ED%95%B4</link>
            <guid>https://velog.io/@pie_e/%EC%83%81%EC%86%8D%EA%B3%BC-%ED%8F%AC%ED%95%A8-Is-a%EC%99%80-has-a-%EC%9D%B4%ED%95%B4</guid>
            <pubDate>Tue, 18 Jun 2024 07:57:38 GMT</pubDate>
            <description><![CDATA[<h2 id="상속">상속</h2>
<p>상속이란 <strong>기존의 클래스에 기능을 추가하거나 재정의하여 새로운 클래스를 정의하는 것을 의미</strong>합니다. 이러한 상속은 캡슐화, 추상화와 더불어 객체 지향 프로그래밍을 구성하는 중요한 특징 중 하나입니다. </p>
<p>상속을 이용하면 기존에 정의되어 있는 클래스의 모든 필드와 메서드를 물려받아, 새로운 클래스를 생성할 수 있습니다. 이때 기존에 정의되어 있던 클래스를 부모 클래스(Parent Class) 또는 상위 클래스(Super Class)라고도 합니다. 그리고 상속을 통해 새롭게 작성되는 클래스를 자식 클래스(Child Class) 또는 하위 클래스(Sub Class)라고도 합니다.</p>
<h3 id="java의-상속-방법">Java의 상속 방법</h3>
<h3 id="추상-클래스-abstract-class">추상 클래스 (abstract class)</h3>
<p>추상클래스는 하나 이상의 추상 메서드를 포함하는 클래스를 가리켜 추상 클래스라고 합니다. 추상 클래스는 객체 지향 프로그래밍에서 중요한 특징인 다형성을 가지는 메서드의 집합을 정의할 수 있도록 해줍니다. 즉, 반드시 사용되어야 하는 메서드를 추상 클래스에 추상 메서드로 선언해 놓으면, 이 클래스를 상속받는 모든 클래스에서 이 추상 메서드를 반드시 재정의 해야 합니다.</p>
<h3 id="추상-클래스-문법">추상 클래스 문법</h3>
<pre><code class="language-java">abstract class 클래스이름 {
    abstract 반환타입 메서드이름();
}</code></pre>
<h3 id="추상-클래스-활용-예시">추상 클래스 활용 예시</h3>
<pre><code class="language-java">abstract class Animal { 
    abstract void cry(); 
}

class Cat extends Animal { 
    void cry() { 
        System.out.println(&quot;냐옹냐옹!&quot;);
    }
}

class Dog extends Animal { 
    void cry() {
        System.out.println(&quot;멍멍!&quot;);
    }
}



public class Main {
    public static void main(String[] args) {

        // 추상 클래스는 인스턴스를 생성할 수 없음.
        // Animal a = new Animal(); 

        Cat c = new Cat();
        Dog d = new Dog();

        c.cry();
        d.cry();

    }
}</code></pre>
<blockquote>
<p>실행결과
냐옹냐옹!
멍멍!</p>
</blockquote>
<h3 id="인터페이스-interface">인터페이스 (interface)</h3>
<p>인터페이스는 일종의 추상 클래스로, 추상 메서드를 갖지만 추상 클래스보다 추상화 정도가 높아 추상 클래스와 달리 몸통을 갖춘 일반 메서드 또는 멤버변수를 구성원으로 가질 수 없습니다. 오직 추상 메서드와 상수만을 멤버로 가질 수 있으며, 그 외의 다른 어떠한 요소도 허용되지 않습니다.
 
<span style="background-color: #F9ECCB;"> <strong>추상 클래스를 부분적으로만 완성된 &quot;미완성 설계도&quot;</strong>라고 한다면, <strong>인터페이스는 구현된 것은 아무것도 없는 그냥 스케치만 되어 있는 &quot;기본 설계도&quot;</strong>라 할 수 있습니다.</span></p>
<h3 id="인터페이스-문법">인터페이스 문법</h3>
<pre><code class="language-java">class 클래스이름 implements 인터페이스이름 { ... }</code></pre>
<h3 id="인터페이스-활용-예시">인터페이스 활용 예시</h3>
<pre><code class="language-java">interface Animal { public abstract void cry(); }
interface Pet { public abstract void play(); }

class Cat implements Animal, Pet {

    public void cry() {
        System.out.println(&quot;Cat : 냐옹!&quot;);
    }

    public void play() {
        System.out.println(&quot;Cat : 쥐 잡기 놀이하자&quot;);
    }
}

class Dog implements Animal, Pet {
    public void cry() {
        System.out.println(&quot;Dog : 멍멍!&quot;);
    }

    public void play() {
        System.out.println(&quot;Dog : 산책갈까?&quot;);
    }
}

public class Test1 {
    public static void main(String[] args) {
        Cat c = new Cat();
        Dog d = new Dog();

        c.cry();
        c.play();
        d.cry();
        d.play();
    }
}</code></pre>
<h2 id="📌-상속과-포함-is-a와-has-a-">📌 상속과 포함, Is-A와 Has-A ?</h2>
<p>추상 클래스는 <code>Is-A (상속)</code> 관계, 인터페이스는 <code>Has-A (포함)</code> 관계라고 정의합니다.
상속과 포함은 비슷한데, 언제 상속관계를 맺어야 할지 언제 포함관계를 맺어야할지 결정하는데에 어려움을 겪게 된다면 다음과 같은 문장을 떠올려 보면 됩니다. </p>
<blockquote>
<p><code>상속 : &#39;A는 B이다&#39;에서 &#39;~이다&#39;와 같습니다. = (is-a)</code>
ex. 고양이는 동물이다. 
➡️ A는 B를 상속하고 있습니다.</p>
</blockquote>
<blockquote>
<p><code>Has-A : &#39;A는 B를 가지고 있다&#39;에서 &#39;~를 가지고있다&#39;와 같습니다. = (has-a)</code>
ex. 자동차는 바퀴를 가지고 있다. 
➡️ A는 B를 포함하고 있습니다.</p>
</blockquote>
<p>이렇게 문장을 만들어 비교하면 상속 관계와 포함 관계를 비교하기 쉬워집니다.</p>
<h2 id="끝">끝!</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/87a16bf3-ff75-4bf5-98db-f07a7c6e676f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 직렬화(Serialization)와 역직렬화(Deserialization)]]></title>
            <link>https://velog.io/@pie_e/Java-%EC%A7%81%EB%A0%AC%ED%99%94Serialization%EC%99%80-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94Deserialization</link>
            <guid>https://velog.io/@pie_e/Java-%EC%A7%81%EB%A0%AC%ED%99%94Serialization%EC%99%80-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94Deserialization</guid>
            <pubDate>Fri, 31 May 2024 06:33:31 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-직렬화serialization와-역직렬화deserialization">📌 직렬화(Serialization)와 역직렬화(Deserialization)</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/be503fe5-45a7-41fa-89b2-250d8f245f1a/image.png" alt=""></p>
<ul>
<li>직렬화 : 객체들의 데이터를 연속적인 데이터(스트림)로 변형하여 전송 가능한 형태를 만드는것.<ul>
<li>객체 데이터를 통신하기 쉬운 포맷(Byte, CSV, Json..) 형태로 만들어주는 작업을 직렬화라고 볼 수 있습니다. </li>
</ul>
</li>
<li>역직렬화 : 직렬화된 데이터를 다시 객체의 형태로 만드는 것<ul>
<li>역으로 포맷(Byte, CSV, Json..) 형태에서 객체로 변환하는 과정을 역직렬화라고 할 수 있습니다.</li>
</ul>
</li>
</ul>
<pre><code class="language-java">public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }
}</code></pre>
<p>위와 같은 클래스가 있다고 가정할 때, Json 데이터 형식을 예로 들면
<code>Person person = new Person(&quot;김철수&quot;);</code> 객체를 Json 형식인 <code>{&quot;name&quot; : &quot;김철수&quot;}</code>로 변경하는 것을 직렬화, <code>{&quot;name&quot; : &quot;김철수&quot;}</code> 데이터를 받아서 Person이라는 객체의 name필드에 <code>&quot;김철수&quot;</code>를 할당하고 객체를 생성하는 것을 역직렬화라고 할 수 있습니다.</p>
<h3 id="직렬화는-왜-필요할까">직렬화는 왜 필요할까?</h3>
<p>자바에는 Primitive Type이 byte, int, char등 총 8가지가 있습니다. 그리고 그 외 객체(Reference Type : String, Integer..등)들은 주소값을 갖는 참조형 타입입니다.
<img src="https://velog.velcdn.com/images/pie_e/post/9650e05b-a047-4e94-9db9-597fa4047b5b/image.png" alt="">
Primitive Type은 stack에서 값 그 자체를 가지고 있어 외부로 데이터를 전달할 때 값을 일정한 형식의 raw byte 형태로 변경하여 전달할 수 있습니다. </p>
<p>하지만 위 그림과 같이 Reference Type 객체의 경우 실제로 Heap 영역에 존재하고, 스택에서는 Heap 영역에 존재하고 있는 객체의 주소(메모리 주소)를 가지고 있습니다. </p>
<p>위 주소값을 그대로 다른 곳에 보낸다고 가정한다면 ❓
먼저 프로그램이 종료되거나 객체가 쓸모없다고 판단되면 Heap 영역에 있던 데이터는 제거되고, 따라서 본인 메모리에서도 데이터가 사라지게 됩니다. 
외부로 전송했다고 가정했을때도, 전송받은 기기의 메모리 주소에 내가 전송하려고 했던 데이터는 존재할 리가 없습니다.</p>
<p>따라서 이 주소값의 데이터(실체)를 Primitive한 값 형식 데이터로 변환하는 작업을 거친 후, 전달해야 합니다. 그렇게 해야 파일 저장이나 네트워크 전송시 Parsing할 수 있는 유의미한 데이터가 됩니다.</p>
<h3 id="🔑-자바에서-직렬화를-구현하는-방법">🔑 자바에서 직렬화를 구현하는 방법</h3>
<p>자바의 primitive type과 <code>java.io.Serializable</code> 인터페이스를 상속받은 객체는 직렬화 할 수 있는 기본 조건을 가지게 됩니다.</p>
<pre><code class="language-java">public class Person implements Serializable {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    // Getter 생략 . .

    @Override
    public String toString() {
        return String.format(&quot;Person&quot;, name);
    }
}</code></pre>
<p>직렬화 하는 방법은 <code>java.io.ObjectOutputStream</code> 객체를 이용합니다.</p>
<pre><code class="language-java">Person person = new Person(&quot;김철수&quot;);
byte serializedPerson[];
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
    try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
        oos.writeObject(person);
        // serializedPerson -&gt; 직렬화된 person 객체
        serializedPerson = baos.toByteArray();
    }
}
// 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
System.out.println(Base64.getEncoder().encodeToString(serializedPerson));</code></pre>
<p>위 예제는 객체를 직렬화하여 바이트 배열(byte[]) 형태로 변환해보았습니다.</p>
<h3 id="🔑-자바에서-역직렬화를-구현하는-방법">🔑 자바에서 역직렬화를 구현하는 방법</h3>
<p>역직렬화를 구현해보기 전에 역직렬화의 조건에 대해 먼저 알아보겠습니다.</p>
<ul>
<li>직렬화 대상이 된 객체의 클래스가 클래스 패스에 존재해야하며 <code>import</code> 가 되어있어야 합니다.<ul>
<li><strong>중요한 점은 직렬화와 역직렬화를 진행하는 시스템이 서로 다를 수 있다는 것을 반드시 고려해야 합니다.</strong> 
(같은 시스템 내부라도 소스 버전이 다를 수 있습니다.)</li>
</ul>
</li>
<li>직렬화 대상 객체는 동일한 <code>serialVersionUID</code>를 가지고 있어야 합니다.<pre><code class="language-java">private static final long serialVersionUID = 1L;</code></pre>
하지만 Person 객체를 직렬화할 땐 <code>serialVersionUID</code>를 설정하지 않았는데 ? <code>serialVersionUID</code>가 뭔데 ?</li>
</ul>
<p>이 얘기는 역직렬화를 구현한 뒤 다시 설명하겠습니다.</p>
<p>역직렬화 예제를 살펴보겠습니다.</p>
<pre><code class="language-java">// 직렬화 예제에서 생성된 base64 데이터
String base64Person = &quot;..생략&quot;;
byte serializedPerson = Base64.getDecoder().decode(base64Person);
try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedPerson)) {
    try(ObjectInputStream ois = new ObjectInputStream(bais)) {
        // 역직렬화된 Person 객체를 읽어옵니다.
        Object objectPerson = ois.readObject();
        Person person = (Person) objectPerson;
        System.out.println(person);
    }
}</code></pre>
<p><code>java.io.ObjectInputStream</code> 객체를 이용해 역직렬화를 해줄 수 있습니다.</p>
<h3 id="-serialversionuid">+ serialVersionUID?</h3>
<p><code>serialVersionUID</code>는 무엇인지 예제를 통해 바로 알아보겠습니다.</p>
<p>먼저 기존의 <code>Person</code> 객체를 직렬화 시켜보겠습니다.</p>
<pre><code class="language-java">Base64.getEncoder().encodeToString(serializedPerson);</code></pre>
<pre><code>rO0ABXNyABp3b293YWhhbi5ibG9nLLkAABSQADYWdlSQAEYWdlM2ltQGJhZW1pbi5jb210AAnquYDrsLDrr7w=</code></pre><p>이 문자열을 바로 역직렬화 시키면 <code>Person</code> 객체로 변환됩니다.
하지만 <code>Person</code> 클래스에 나이를 추가해야 하는걸 깜빡 잊어버리고 만들어둔 <code>Person</code> 객체에 얼른 나이 필드를 추가합니다.</p>
<pre><code class="language-java">public class Person implements Serializable {
    private String name;
    // 나이 추가
    private int age;

    // ... 생략 ...
}</code></pre>
<p>여기서 우리는 <code>age</code>는 <code>null</code>이 되어도기존에 있던 <code>name</code> 필드엔 데이터가 채워지길 원합니다. 
이제 직렬화 해둔 <code>Person</code> 데이터를 역직렬화 해보겠습니다.</p>
<pre><code class="language-java">java.io.InvalidClassException: ..local class incompatible: stream classdesc serialVersionUID = -12345678909876543321, local class serialVersionUID = 19283746564738291</code></pre>
<p>예외 메세지를 읽어보면 <code>serialVersionUID</code>의 정보가 일치하지 않기 때문에 <code>InvalidClassException</code> 에러가 발생한 것을 알 수 있습니다.
우리는 <code>Person</code> 객체에 <code>serialVersionUID</code>를 설정한 적이 없는데도 말이죠..!</p>
<p>그래서 자바 직렬화 스펙을 확인해보았습니다. <a href="https://docs.oracle.com/javase/6/docs/platform/serialization/spec/class.html#4100">링크</a></p>
<pre><code>It may be declared in the original class but is not required. 
The value is fixed for all compatible classes. 
If the SUID is not declared for a class, the value defaults to the hash for that class. </code></pre><ul>
<li>SUID(SerialVersionUID) 필수 값이 아니다.</li>
<li>호환 가능한 클래스는 SUID 값이 고정되어 있다.</li>
<li>SUID가 선언되어 있지 않으면 클래스의 기본 해시값을 사용한다.</li>
</ul>
<p><code>serialVersionUID</code> 를 직접 기술하지 않아도 내부 적으로 <code>serialVersionUID</code> 정보가 추가되며, 내부 값도 자바 직렬화 스펙 그대로 자동으로 생성된 클래스의 해시 값을 이라는 것을 확인할 수 있었습니다. (해시 값은 클래스 구조를 이용해서 생성한다고 합니다.)</p>
<p>그럼 어떤 형태가 좋을까요 ?</p>
<pre><code class="language-java">public class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private int age;

    // . . 생략 . . 
}</code></pre>
<p>&quot;조금이라도 역직렬화 대상 클래스 구조가 변경된다면 에러가 발생해야 한다.&quot; 정도의 민감한 시스템이 아니라면 클래스를 변경할 때 직접 <code>serialVersionUID</code> 값을 관리해주는 것이 클래스 변경 시 혼란을 줄일 수 있습니다.</p>
<p>그럼 <code>serialVersionUID</code>만 관리해주면 문제가 없을까요 ?
<code>serialVersionUID</code> 값이 동일할 때에도 문제가 생길 수 있는 부분을 살펴보겠습니다.</p>
<ol>
<li><p>변수명은 같은데 변수 타입이 바뀔 때</p>
<pre><code class="language-java">public class Person implements Serializable {
 private static final long serialVersionUID = 1L;

 private StringBuilder name;
 private int age;

 // . . 생략 . . 
}</code></pre>
<p>기존 직렬화된 <code>name</code> 데이터는 <code>String</code>이었지만 <code>StringBuilder</code> 타입으로 변경해봤습니다.</p>
<pre><code>java.lang.ClassCastException: cannot assign instance of java.lang.String to field Person.name of type java.lang.StringBuilder in instance of ~~.Person</code></pre><p>혹시 primitive type인 <code>int</code>를 <code>long</code>으로 바꾸는건 괜찮지 않을까요?</p>
<pre><code class="language-java">public class Person implements Serializable {
 private static final long serialVersionUID = 1L;

 private String name;
 private long age;

 // . . 생략 . . 
}</code></pre>
<pre><code>java.lang.ClassCastException: ~~.incompatible types for field age</code></pre><p>역시 <code>ClassCastException</code> 예외가 발생했습니다. <strong>자바 직렬화는 상당히 타입에 엄격하다는 것을 알 수 있습니다.</strong></p>
</li>
<li><p>직렬화 자바 데이터에 존재하는 멤버 변수가 없애거나 추가했을 때</p>
<pre><code class="language-java">public class Person implements Serializable {
 private static final long serialVersionUID = 1L;

 private String name;

 // . . 생략 . . 
}</code></pre>
<pre><code>Person</code></pre><p>에러는 발생하지 않았지만 값 자체만 없어졌습니다. 
그럼 필드를 추가해보겠습니다.</p>
<pre><code class="language-java">public class Person implements Serializable {
 private static final long serialVersionUID = 1L;

 private String name;
 private int age;
 // 추가된 필드
 private String email;

 // . . 생략 . . 
}</code></pre>
<pre><code>Person</code></pre><p>이번에도 원하는 형태로 값은 채워졌지만 에러는 발생하지 않았습니다.</p>
</li>
</ol>
<h3 id="정리">정리</h3>
<ul>
<li>특별한 문제가 없으면 자바 직렬화 버전 <code>serialVersionUID</code>의 값은 개발 시 직접 관리해야 합니다.</li>
<li><code>serialVersionUID</code>의 값이 동일하면 멤버 변수 및 메서드 추가는 크게 문제가 없습니다. 그리고 멤버 변수 및 이름 변경은 오류가 발생하지는 않지만 데이터는 누락됩니다.</li>
<li>역직렬화 대상의 클래스의 멤버 변수 타입 변경을 지양해야 합니다. 자바 역직렬화시에 타입에 엄격합니다.
직렬화된 데이터가 존재하는 상황에 나중에라도 타입 변경이 되면 발생할 예외의 경우의 수를 다 신경 써야합니다.</li>
</ul>
<h3 id="끝-🔓">끝 🔓</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/8e92decc-917a-433e-b538-7ede72f475e4/image.png" alt=""></p>
<p><strong>Ref.</strong>
<a href="https://techblog.woowahan.com/2550/">https://techblog.woowahan.com/2550/</a>
<a href="https://techblog.woowahan.com/2551/">https://techblog.woowahan.com/2551/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] MultipartFile 사용 시 Unsupported Media Type 에러]]></title>
            <link>https://velog.io/@pie_e/Spring-MultipartFile-%EC%82%AC%EC%9A%A9-%EC%8B%9C-Unsupported-Media-Type-%EC%97%90%EB%9F%AC</link>
            <guid>https://velog.io/@pie_e/Spring-MultipartFile-%EC%82%AC%EC%9A%A9-%EC%8B%9C-Unsupported-Media-Type-%EC%97%90%EB%9F%AC</guid>
            <pubDate>Tue, 31 Oct 2023 07:54:19 GMT</pubDate>
            <description><![CDATA[<h3 id="사건-개요-">사건 개요 :</h3>
<p>postman을 통해 새로 작성한 로직에 문제가 없나 살피던중 이런 에러를 마주하게 되었습니다 </p>
<h4 id="postman-error">postman error</h4>
<pre><code>&quot;status&quot; : 415,
&quot;error&quot; : &quot;Unsupported Media Type&quot;
&quot;path&quot; : &quot;~~&quot;</code></pre><h4 id="intellij-error">intelliJ error</h4>
<pre><code>Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: 
Content type &#39;multipart/form-data;boundary=--------------------------
267881025418540389377987;charset=UTF-8&#39; not supported]</code></pre><h3 id="문제가-생긴-controller-로직">문제가 생긴 controller 로직</h3>
<pre><code class="language-java">    @PatchMapping(&quot;/{itemId}&quot;)
    public ResponseEntity&lt;Void&gt; modifyItem(@PathVariable Long itemId,
        @AuthPrincipal Principal principal,
        @RequestBody ItemModifyRequest modifyRequest,
        @RequestPart(value = &quot;images&quot;, required = false) List&lt;MultipartFile&gt; itemImages,
        @RequestPart(value = &quot;thumbnailImage&quot;, required = false) MultipartFile thumbnail) {
        itemService.modifyItem(itemId, principal, modifyRequest, itemImages, thumbnail);
        return ResponseEntity.ok().build();
    }</code></pre>
<h3 id="postman-요청-부분">postman 요청 부분</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/eb1a7be4-ab6a-4252-b586-6fc301fe7ab5/image.png" alt="">
딱히 문제가 되는 부분은 없어보이는데 왜그럴까 하던 중 눈에 들어오는 문장이 있었습니다.</p>
<h4 id="requestbody"><code>@RequestBody</code>,,!</h4>
<p>@RequestBody는 json으로 들어오는 바디 데이터를 파싱해주는거라 이상하지 않을 수 도 있겠지만 <strong>Http Header</strong>에 명시해준 데이터 타입은 <strong><code>multipart-form data</code></strong>입니다.</p>
<blockquote>
<p>💡 multipart-form data란?
간단히 설명하자면 서버에 이미지를 전송할 때 쓰는 content-type입니다.</p>
</blockquote>
<p>서버에서 <code>multipart-form data</code> Content-type을 받을 때는 <code>@RequestBody</code>가 아닌 <strong><code>@RequestPart</code></strong> 애노테이션을 사용해 주어야 합니다. </p>
<h3 id="해결">해결</h3>
<pre><code class="language-java">    @PatchMapping(&quot;/{itemId}&quot;)
    public ResponseEntity&lt;Void&gt; modifyItem(@PathVariable Long itemId,
        @AuthPrincipal Principal principal,
    -&gt;    @RequestPart(&quot;item&quot;) ItemModifyRequest modifyRequest,
        @RequestPart(value = &quot;images&quot;, required = false) List&lt;MultipartFile&gt; itemImages,
        @RequestPart(value = &quot;thumbnailImage&quot;, required = false) MultipartFile thumbnail) {
        itemService.modifyItem(itemId, principal, modifyRequest, itemImages, thumbnail);
        return ResponseEntity.ok().build();
    }</code></pre>
<p><code>@RequestBody</code>로 받았던 ItemModifyRequest를 <code>@RequestPart</code> 애노테이션으로 변경해주고 postman을 따라 ItemModifyRequest에 바인딩 될 데이터의 이름을 괄호안에 지정해주었습니다. (위 사진의 두번째 체크박스 부분 입니다.)
<img src="https://velog.velcdn.com/images/pie_e/post/c43d130c-aaed-47ec-8707-f83cc7fa8813/image.png" alt="">
실행 결과 <code>200 OK</code>를 반환 받아 해결했습니다.</p>
<h1 id="끝-">끝 !</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/b0960c51-96be-4981-8337-43f80f20a044/image.png" alt=""></p>
<p>별거 아닌 포스팅 이지만 누군가에게는 도움이 되길 바라며,, 코린이들 화이팅 ✨👩🏻‍💻</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] AWS S3에 이미지 업로드하기]]></title>
            <link>https://velog.io/@pie_e/Spring-AWS-S3%EC%97%90-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@pie_e/Spring-AWS-S3%EC%97%90-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 19 Oct 2023 14:10:30 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅은 <span style="background-color: #F9ECCB;">AWS S3에 이미지 업로드 기능</span> 에 대해 구현을 진행하며 어려웠던 부분이나, 클래스의 역할등을 다뤄보려 합니다. </p>
<blockquote>
<p>📣 이 글을 시작하기전에 미리 말씀드리자면 저는 Controller 부분은 작성하지 않고 오직 업로드를 구현하기 위해 필요한 로직만 작성되었음을 알립니다 ❗️</p>
</blockquote>
<h3 id="프로젝트-구성">프로젝트 구성</h3>
<pre><code>Java 11
SpringBoot 2.7.16</code></pre><h3 id="buildgradle에-의존성-추가하기">build.gradle에 의존성 추가하기</h3>
<p>필요한 의존성을 추가합니다.</p>
<pre><code>    // S3
    implementation &#39;com.amazonaws:aws-java-sdk-s3:1.12.518&#39;
    testImplementation &#39;com.amazonaws:aws-java-sdk-s3:1.12.518&#39;</code></pre><h3 id="applicationyml-작성">application.yml 작성</h3>
<pre><code>cloud:
  aws:
    credentials:
      access-key: ${aws.credentials.access-key}
      secret-key: ${aws.credentials.secret-key}
    s3:
      bucket: ${aws.s3.bucket}
    region:
      static: ${aws.region.static}
  stack:
    auto: false</code></pre><p><code>access-key</code>나 <code>secret-key</code>는 노출되면 안되기 때문에 본인은 <code>application-secret.yml</code> 파일을 따로 작성하여 저장했습니다.
따로 secret파일을 두고 사용하실게 아니라면 <code>.gitignore</code> 처리 후 public한 곳으로 업로드 하지 않길❗️ (혹시 해킹당하면 과금이 .. 무섭습니다 😱) </p>
<h3 id="s3properties-작성">S3Properties 작성</h3>
<pre><code class="language-java">@Getter
@ConfigurationProperties(&quot;aws&quot;)
public class S3Properties {

    private final Credentials credentials;
    private final S3 s3;
    private final String region;

    @ConstructorBinding
    public S3Properties(Credentials credentials, S3 s3, Map&lt;String, String&gt; region) {
        this.credentials = credentials;
        this.s3 = s3;
        this.region = region.get(&quot;static&quot;);
    }

    @Getter
    @RequiredArgsConstructor
    public static class Credentials {

        private final String accessKey;
        private final String secretKey;
    }

    @Getter
    @RequiredArgsConstructor
    public static class S3 {
        private final String bucket;
    }
}</code></pre>
<p><code>S3Properties</code>는 .yml파일에 설정해놓은 환경변수를 읽어와 각 필드에 값을 바인딩 해줍니다. </p>
<pre><code class="language-java">@Configuration
public class S3Config {
    @Value(&quot;${cloud.aws.credentials.accessKey}&quot;)
    private String accessKey;

    @Value(&quot;${cloud.aws.credentials.secretKey}&quot;)
    private String secretKey;

    @Value(&quot;${cloud.aws.region.static}&quot;)
    private String region;</code></pre>
<p>다른 블로그들을 찾아보면 따로 properties를 생성하지 않고 위와 같이 <code>S3Config</code> 필드위에 <code>@Value</code>애노테이션을 붙여서 사용하시던데 저는 config의 역할과 properties의 역할이 다르다고 생각해 따로 분리하여 사용하였습니다. (물론 지극히 개인적인 생각이기 때문에 위와 같이 사용하셔도 됩니다❗️)</p>
<h3 id="s3config-작성">S3Config 작성</h3>
<pre><code class="language-java">@Configuration
@EnableConfigurationProperties(S3Properties.class)
public class S3Config {

    @Bean
    public AmazonS3Client amazonS3Client(S3Properties s3Properties) {
        BasicAWSCredentials credentials = new BasicAWSCredentials(s3Properties.getCredentials().getAccessKey(),
            s3Properties.getCredentials().getSecretKey());

        return (AmazonS3Client)AmazonS3ClientBuilder.standard()
            .withCredentials(new AWSStaticCredentialsProvider(credentials))
            .withRegion(s3Properties.getRegion())
            .build();
    }
}</code></pre>
<p>S3Config는 S3에 이미지를 올리기 위해 <code>AmazonS3Client</code>를 bean으로 등록합니다.</p>
<blockquote>
<p><code>@EnableConfigurationProperties</code>를 사용하여 <code>S3Properties</code>를 spring bean처럼 사용할 수 있습니다.
참조 <a href="https://www.baeldung.com/spring-enable-config-properties">https://www.baeldung.com/spring-enable-config-properties</a></p>
</blockquote>
<h3 id="imageservice-작성">ImageService 작성</h3>
<pre><code class="language-java">@Service
@Transactional
@RequiredArgsConstructor
public class ImageService {

    private final ImageUploader imageUploader;

    public String uploadImageToS3(MultipartFile multipartFile) {
        ImageFile file = ImageFile.from(multipartFile);
        return imageUploader.uploadImageToS3(file);
    }

    public List&lt;String&gt; uploadImagesToS3(List&lt;MultipartFile&gt; multipartFiles) {
        List&lt;ImageFile&gt; imageFiles = ImageFile.from(multipartFiles);
        return imageUploader.uploadImagesToS3(imageFiles);
    }
}</code></pre>
<p><code>ImageService</code>는 MultipartFile에서 이미지 업로드할 때 필요한 정보만을 가져와 ImageFile객체로 만들어줍니다. 또한 <code>ImageService</code>는 이미지를 업로드 시키는 역할이 아니라고 판단해 이미지를 업로드 시키는 역할은 <code>ImageUploader</code> 클래스를 따로 작성했습니다.</p>
<h3 id="imagefile-작성">ImageFile 작성</h3>
<pre><code class="language-java">@Getter
@RequiredArgsConstructor
public class ImageFile {

    private final String fileName;
    private final String contentType;
    private final Long fileSize;
    private final InputStream imageInputStream;

    private ImageFile(MultipartFile multipartFile) {
        this.fileName = getFileName(multipartFile);
        this.contentType = getImageContentType(multipartFile);
        this.imageInputStream = getImageInputStream(multipartFile);
        this.fileSize = multipartFile.getSize();
    }

    public static ImageFile from(MultipartFile multipartFile) {
        return new ImageFile(multipartFile);
    }

    public static List&lt;ImageFile&gt; from(List&lt;MultipartFile&gt; multipartFiles) {
        List&lt;ImageFile&gt; imageFiles = new ArrayList&lt;&gt;();
        for (MultipartFile multipartFile : multipartFiles) {
            imageFiles.add(new ImageFile(multipartFile));
        }
        return imageFiles;
    }

    public InputStream getImageInputStream(MultipartFile multipartFile) {
        try {
            return multipartFile.getInputStream();
        } catch (IOException e) {
            throw new InternalServerException(ErrorCode.FILE_IO_EXCEPTION);
        }
    }

    private String getImageContentType(MultipartFile multipartFile) {
        return ImageContentType.findEnum(StringUtils.getFilenameExtension(multipartFile.getOriginalFilename()));
    }

    private String getFileName(MultipartFile multipartFile) {
        String ext = extractExt(multipartFile.getOriginalFilename());
        String uuid = UUID.randomUUID().toString();
        return uuid + &quot;.&quot; + ext;
    }

    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(&quot;.&quot;);
        return originalFilename.substring(pos + 1);
    }

    @Getter
    @RequiredArgsConstructor
    enum ImageContentType {

        JPEG(&quot;jpeg&quot;),
        JPG(&quot;jpg&quot;),
        PNG(&quot;png&quot;),
        SVG(&quot;svg&quot;);

        private final String contentType;

        public static String findEnum(String contentType) {

            for (ImageContentType imageContentType : ImageContentType.values()) {
                if (imageContentType.getContentType().equals(contentType.toLowerCase())) {
                    return imageContentType.getContentType();
                }
            }
            throw new BadRequestException(ErrorCode.INVALID_FILE_EXTENSION);
        }
    }
}</code></pre>
<p>S3에 이미지를 업로드하기 위해 위와 같이 4가지가 필요합니다.</p>
<ul>
<li>파일이름</li>
<li>파일의 컨텐트타입</li>
<li>파일 사이즈</li>
<li>파일의 inputstream</li>
</ul>
<blockquote>
<p>InputStream이란?
바이트 기반 입력 스트림의 최상위 추상클래스입니다. (모든 바이트 기반 입력 스트림은 이 클래스를 상속받습니다.)
파일 데이터를 읽거나 네트워크 소켓을 통해 데이터를 읽거나 키보드에서 입력한 데이터를 읽을 때 사용합니다. </p>
</blockquote>
<p>위에서부터 차례대로 설명해보겠습니다. </p>
<ul>
<li>2개의 <code>static from</code> 메서드들은 MultipartFile을 ImageFile로 변경시켜주는 역할을 합니다. (단일 이미지일때와 이미지 여러장을 받았을 때 차이 입니다.)</li>
<li><code>getInputStream</code> : multipartFile일의 inputStream을 가져옵니다. 이때 IOException이 발생할 수 있어 <code>try, catch</code>를 사용합니다.</li>
<li><code>getImageContentType</code> : 이너 클래스에 있는 <code>findEnum</code> 메서드를 통해 multipartFile에서 일치하는 contentType을 가져옵니다.<ul>
<li>StringUtils의 <code>getFilenameExtension</code> 메서드는 파일의 확장자를 반환합니다 <code>ex. image.png -&gt; png를 문자열로 반환합니다.</code></li>
<li>일치하지 않는 타입이 들어오면 BadRequestException을 던집니다.</li>
</ul>
</li>
<li><code>getImageFileName</code> : 파일 이름을 가져오기 위한 메서드입니다.<ul>
<li><code>extractExt</code> : 실제 파일 이름만 추출하기 위해 사용된 메서드입니다</li>
<li><code>UUID.randomUUID</code> : 파일의 이름이 중복되지 않기 위해 사용하였습니다.</li>
</ul>
</li>
</ul>
<h3 id="imageuploader-작성">ImageUploader 작성</h3>
<pre><code class="language-java">@Component
public class ImageUploader {

    private static final String UPLOADED_IMAGES_DIR = &quot;public/&quot;;

    private final AmazonS3Client amazonS3Client;
    private final String bucket;

    public ImageUploader(AmazonS3Client amazonS3Client, S3Properties s3Properties) {
        this.amazonS3Client = amazonS3Client;
        this.bucket = s3Properties.getS3().getBucket();
    }

    public String uploadImageToS3(ImageFile imageFile) {
        final String fileName = putImage(imageFile);
        return getObjectUrl(fileName);
    }

    public List&lt;String&gt; uploadImagesToS3(List&lt;ImageFile&gt; imageFile) {
        List&lt;String&gt; urls = new ArrayList&lt;&gt;();
        for (ImageFile file : imageFile) {
            final String fileName = putImage(file);
            urls.add(getObjectUrl(fileName));
        }
        return urls;
    }

    private String putImage(ImageFile imageFile) {
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentType(imageFile.getContentType());

        final String fileName = UPLOADED_IMAGES_DIR + imageFile.getFileName();
        amazonS3Client.putObject(bucket, fileName, imageFile.getImageInputStream(), metadata);
        return fileName;
    }

    private String getObjectUrl(final String fileName) {
        return URLDecoder.decode(amazonS3Client.getUrl(bucket, fileName).toString(), StandardCharsets.UTF_8);
    }
}</code></pre>
<p>자 이제 마지막입니다. 실제 S3에 이미지를 업로드하기 위한 클래스입니다. 위에서부터 차례대로 설명하겠습니다.</p>
<ul>
<li><p><code>UPLOADED_IMAGES_DIR</code> : S3 bucket에 이미지를 보낼 경로입니다.
<img src="https://velog.velcdn.com/images/pie_e/post/25a93714-f015-4593-9e40-983aa949de4d/image.png" alt=""></p>
</li>
<li><p><code>AmazonS3Client</code> : 이미지를 S3에 저장하기 위해 사용되는 객체입니다. S3Config에서 Bean으로 등록해놓았습니다.</p>
</li>
<li><p><code>bucket</code> : 버켓의 이름을 가지고 있습니다.</p>
</li>
<li><p><code>uploadImageToS3</code>, <code>uploadImagesToS3</code> : <code>putImage</code> 메서드를 사용해 S3에 업로드합니다.</p>
</li>
<li><p><code>putImage</code> : 실제 파일을 S3에 업로드 해주고 S3 URL 주소를 반환 받습니다.</p>
</li>
<li><p><code>getObjectUrl</code> : 반환받은 S3 URL주소를 decode해서 사람이 읽을 수 있는 이름으로 변환해줍니다.</p>
<h3 id="test-작성">Test 작성</h3>
<pre><code class="language-java">@Transactional
@SpringBootTest
class ImageServiceTest {

  @InjectMocks
  private ImageService imageService;
  @Mock
  private ImageUploader imageUploader;

  @DisplayName(&quot;이미지 파일이 주어지면 업로드에 성공한다.&quot;)
  @Test
  void imageUpload() throws IOException {
      // given
      // (1)
      MockMultipartFile mockMultipartFile = new MockMultipartFile(
          &quot;test-image&quot;, &quot;test.png&quot;,
          MediaType.IMAGE_PNG_VALUE, &quot;imageBytes&quot;.getBytes(StandardCharsets.UTF_8));

      // (2)
      given(imageUploader.uploadImageToS3(any(ImageFile.class))).willReturn(&quot;url&quot;);

      // when &amp; then
      // (3)
      assertThatCode(() -&gt; imageService.uploadImageToS3(mockMultipartFile))
          .doesNotThrowAnyException();
  }
}</code></pre>
</li>
</ul>
<ol>
<li>MockMultipartFile 객체를 생성합니다.</li>
<li>imageUploader.uploadImageToS3 메서드를 실행시켰을 때 매개변수로 아무 ImageFile클래스만 넣으면 url을 리턴받길 기대한다 라는 의미입니다.</li>
<li>imageService.uploadImageToS3 메서드를 실행시켰을 때 매개변수로 mockMultipartFile을 넣으면 어떤 에러도 발생하지 않는다는 의미입니다.</li>
</ol>
<h1 id="끝-">끝 !</h1>
<p>긴 글 읽어주셔서 감사합니다 🦋 🩵
틀린 부분이 있다면 가감없이 알려주시면 감사하겠습니다 ! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT는 무엇이고 어떻게 사용할까?]]></title>
            <link>https://velog.io/@pie_e/JWT%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@pie_e/JWT%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 12 Oct 2023 10:22:34 GMT</pubDate>
            <description><![CDATA[<p>이번 프로젝트를 진행하면서 jwt를 이용한 로그인 기능을 구현해보면서 생겼던 의문점이나, 어려웠던 점에 대해 설명을 해보려고 합니다. 
jwt를 설명하기 앞서 필요한 지식들을 먼저 배워보고 시작하겠습니다!</p>
<h2 id="🖥️-http-특징">🖥️ HTTP 특징</h2>
<ul>
<li>무상태 (Stateless)</li>
<li>요청 - 응답 모델 (Request - Response)</li>
<li>비연결성 (Connectionless)</li>
</ul>
<p>HTTP는 인터넷 상에서 데이터를 주고 받기 위한 서버/ 클라이언트 모델을 따르는 프로토콜입니다.
HTTP는 비연결성 및 무상태성이라는 특징을 가지고 있어, 클라이언트가 서버에게 요청(request)을 보내면, 서버는 응답(response)을 보냄으로써 데이터를 교환합니다.
HTTP는 요청을 처리 한 후 연결을 끊어버리기 때문에, 클라이언트의 상태 정보 및 현재 통신 상태가 남아있지 않습니다.</p>
<p>이 비연결성의 장점은 서버의 자원 낭비를 줄일 수 있다는 것입니다.
만약 다수의 클라이언트와 연결을 유지한다면 자원 낭비가 심해질 것입니다.</p>
<p>하지만 비연결성은 클라이언트를 식별할 수 없다는 단점이 존재해, 로그인을 하더라도 다음 요청에서 해당 클라이언트를 기억하지 못합니다. 
이러한 단점때문에 사용자는 무한 로그인을 하거나, 심지어 브라우저 새로고침을 누를 때마다 로그인을 해야할 수 도 있는데, </p>
<p>이와 같은 HTTP 프로토콜의 특성이자 약점을 보완하기 위해서 <strong><code>Cookie</code></strong>와 <strong><code>Session</code></strong>이라는 기술을 활용합니다.</p>
<h2 id="🍪-cookie">🍪 Cookie</h2>
<p>쿠키(Cookie)란 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각입니다.</p>
<p>클라이언트는 서버에 데이터(ex. user = 김철수)를 담아 로그인 요청을 보내게 되면, 서버는 쿠키를 생성해 HTTP <code>Set-Cookie</code> 헤더에 입력한 데이터(ex.Set-Cookie : user = 김철수)를 쿠키에 포함시켜 전달해 줍니다. 그러면 클라이언트는 서버에서 받은 쿠키를 저장하고 이후 해당 클라이언트가 요청을 보낼 때마다 저장된 쿠키를 전달하여, 서버는 쿠키에 담긴 정보를 바탕으로 해당 요청의 클라이언트가 누군지 식별하게 됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/1446367c-2476-45c3-a96b-755b8d2ee833/image.png" alt=""></p>
<h3 id="cookie의-단점">Cookie의 단점</h3>
<ul>
<li>보안에 취약합니다.<ul>
<li>요청 시 쿠키의 값을 그대로 보내어, 유출 및 조작당할 위험이 존재합니다.</li>
</ul>
</li>
<li><strong>용량 제한</strong>이 있어, 많은 정보를 담을 수 없습니다.</li>
<li>웹 브라우저마다 쿠키에 대한 지원 형태가 다르기에, <strong>브라우저 간 공유가 불가능</strong>합니다.</li>
<li>쿠키의 사이즈가 커질수록 네트워크에 부하가 심해집니다.<h2 id="🗃️-session">🗃️ Session</h2>
세션은 쿠키를 기반하고 있지만, <strong>사용자 정보 파일을 브라우저에 저장하는 쿠키</strong>와 달리 <strong>세션은 서버측에서 관리</strong>합니다.</li>
</ul>
<p>클라이언트는 로그인 요청에 대한 응답을 작성해 서버로 보내면, 서버는 인증 정보를 저장하고, 클라이언트 식별자인 <code>SESSION-ID</code>를 <code>Set-Cookie</code>헤더에 담아 보냅니다. 이후 클라이언트는 요청을 보낼 때마다 <code>SESSION-ID</code>의 유효성을 판별해 클라이언트를 식별합니다.</p>
<p><img src="https://velog.velcdn.com/images/pie_e/post/4fcf9b17-1ac0-4715-811d-8211d84c0d5f/image.png" alt=""></p>
<h3 id="세션-기반-인증의-장점">세션 기반 인증의 장점</h3>
<ul>
<li>쿠키를 탈취당해도 사용자 정보가 아닌 무의미한 정보가 들어가 있어서 쿠키보다 안전합니다.</li>
<li>각 사용자마다 고유한 SESSIONID가 발급되기 때문에, 요청이 들어올 때마다 회원 정보를 바로 확인할 수 있습니다.<h3 id="세션-기반-인증의-단점">세션 기반 인증의 단점</h3>
</li>
<li>해커가 세션 ID를 중간에 탈취하여 클라이언트인 척 위장할 수 있습니다.</li>
<li>서버의 세션 저장소를 사용하기 때문에 요청이 많아지면 서버에 부하가 생깁니다.</li>
<li>서버 증설 시 각기 다른 세션 저장소를 사용하기 때문에 세션 정보가 일치하지 않을 수 도 있습니다.<ul>
<li>이 경우 공통의 세션 DB를 만들어 관리하는 방법이 있습니다.<h2 id="📇-토큰-token">📇 토큰 (Token)</h2>
<h3 id="토큰-기반-인증-jwt">토큰 기반 인증 (JWT)</h3>
<code>JWT(JSON Web Token)</code>는 인증에 필요한 정보들을 암호화시킨 토큰입니다.
JWT는 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별합니다.
<img src="https://velog.velcdn.com/images/pie_e/post/534659d5-295d-4fba-a09b-254d6e70d7d2/image.png" alt="">
JWT는 위와 같이 세가지의 문자열 조합을 가지고 있으며, 구성은 아래와 같습니다.</li>
</ul>
</li>
<li>Header</li>
<li>Payload</li>
<li>Signature<h3 id="header">Header</h3>
Header는 토큰 타입과 토큰 생성에 어떤 알고리즘이 사용되었는지 알려줍니다.<pre><code>{
  &quot;alg&quot;: &quot;HS256&quot;,
  &quot;typ&quot;: &quot;JWT&quot;
}</code></pre><code>alg</code>는 정보를 암호화할 <code>해싱 알고리즘</code>을, <code>typ</code>는 <code>토큰의 타입</code>을 나타냅니다.
위를 봤을때 <code>HS256</code> 알고리즘을 사용했고, <code>JWT</code>타입 인 것을 알 수 있습니다.<h3 id="payload">Payload</h3>
Payload는 실제로 토큰에 담을 정보를 지니고 있으며, <code>Key-Value 형식</code>으로 이루어진 <code>한 쌍의 정보를 Claim</code>이라고 합니다.
주로 클라이언트 고유 ID, 유효 기간 등이 포함됩니다.<pre><code>{
  &quot;sub&quot;: &quot;1234567890&quot;,
  &quot;name&quot;: &quot;John Doe&quot;,
  &quot;iat&quot;: 1516230922
}</code></pre><h3 id="signature는">Signature는</h3>
Signature는 인코딩된 Header와 Payload를 더한 뒤, 비밀키로 해싱하여 생성합니다.
Header 및 Payload는 단순 인코딩된 값이기 때문에 해커가 복호화하고 조작할 수 있지만, Signature는 서버 측에서 관리하는 비밀키가 유출되지 않는 이상 복호화할 수 없습니다.
따라서 Signature는 토큰의 위변조 여부를 확인하는 데 사용됩니다.<pre><code>HMACSHA256(
  base64UrlEncode(header) + &quot;.&quot; +
  base64UrlEncode(payload),
  secret_key
)</code></pre><h3 id="jwt의-장점">JWT의 장점</h3>
</li>
<li>확장성이 우수합니다.</li>
<li>인증 정보에 대한 별도의 저장소가 필요 없습니다. (I/O 처리 필요 없음)</li>
<li>Header와 Payload를 가지고 Signature를 생성하므로 데이터 위변조를 막을 수 있습니다.</li>
<li>JWT는 토큰에 대한 기본 정보와 전달할 정보 및 토큰이 검증됐음을 증명하는 서명 등 필요한 모든 정보를 자체적으로 지니고 있습니다.</li>
<li>클라이언트의 인증 정보를 저장하는 세션과 다르게, 서버는 무상태(Stateless)가 됩니다.</li>
<li>토큰 기반으로 다른 로그인 시스템에 접근 및 권한 공유가 가능합니다. (토큰 서버 활용)</li>
<li>OAuth의 경우 Facebook, Google 등 소셜 계정을 이용해 다른 웹서비스에서도 로그인 할 수 있습니다.</li>
</ul>
<h3 id="jwt의-단점">JWT의 단점</h3>
<ul>
<li>한 번 발급한 토큰에 대한 제어권이 없습니다.<ul>
<li>JWT는 발급된 후에는 취소할 수 없으며 유효 기간이 지나기 전까지 계속 유효합니다. 따라서 토큰을 강제로 만료시키거나 취소해야 할 때, 서버 측에서 추가적인 관리 및 로직이 필요합니다.</li>
</ul>
</li>
<li>페이로드 크기 제한<ul>
<li>JWT는 인코딩된 문자열이기 때문에 많은 정보를 담는 경우 페이로드의 크기가 커지며 이는 네트워크의 부하를 증가시킬 수 있습니다.</li>
</ul>
</li>
<li>데이터 변경 감지<ul>
<li>토큰에는 발급된 후에 데이터가 변경되었는지를 확인하는 기능이 없습니다. 따라서 토큰을 갱신하지 않고는 변경된 데이터를 알 수 없습니다.</li>
</ul>
</li>
</ul>
<h2 id="jwt를-적용해보자">JWT를 적용해보자</h2>
<blockquote>
<p>프로젝트의 구성
Java11
SpringBoot 2.7.16</p>
</blockquote>
<h3 id="의존성-추가">의존성 추가</h3>
<p>필요한 의존성을 추가합니다.</p>
<pre><code>// jwt
implementation &#39;io.jsonwebtoken:jjwt-api:0.11.5&#39;
runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.11.5&#39;
runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.11.5&#39;</code></pre><h3 id="jwt-프로퍼티-설정">JWT 프로퍼티 설정</h3>
<pre><code class="language-java">@Getter
@ConfigurationProperties(&quot;jwt&quot;)
public class JwtProperties {

    private final String secretKey;
    private final long accessTokenExpirationTime;
    private final long refreshTokenExpirationTime;

    @ConstructorBinding
    public JwtProperties(String secretKey, long accessTokenExpirationTime, long refreshTokenExpirationTime) {
        this.secretKey = secretKey;
        this.accessTokenExpirationTime = accessTokenExpirationTime;
        this.refreshTokenExpirationTime = refreshTokenExpirationTime;
    }
}</code></pre>
<p>jwt의 비밀키는 노출되면 안되는 정보이기때문에 <code>application-secret.yml</code> 파일에 따로 저장해둡니다.
저장된 데이터를 사용할 때 <code>@ConfigurationProperties</code> 어노테이션을 이용해 <code>.yml</code>에 있는 비밀키와 토큰만료시간, 리프레쉬토큰의 만료시간 데이터를 가져와 바인딩해줍니다.</p>
<p>이후 JwtProperties 클래스를 읽어 Bean으로 등록합니다.</p>
<pre><code class="language-java">@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {

}</code></pre>
<p> <span style="color:#999998;"> <code>@EnableConfigurationProperties</code>는 @ConfigurationProperties 클래스를 Bean으로 등록하여 쓸 때(주입받을 때) 사용합니다.</span></p>
<blockquote>
<p>⚡️ 비밀키는 어떻게 만드나요 ?
현재 저는 mac의 M1을 사용하고 있으며, 터미널에 
<code>echo -n any string | shasum -a 256 | awk &#39;{ print $1 }&#39;</code>
라고 치면 비밀키를 만들 수 있습니다.</p>
</blockquote>
<h3 id="jwt-생성">JWT 생성</h3>
<pre><code class="language-java">@Component
public class JwtProvider {

    private final SecretKey secertKey;
    private final long accessTokenExpirationTime;

    public JwtProvider(JwtProperties jwtProperties) {
        this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
        this.accessTokenExpirationTime = jwtProperties.getAccessTokenExpirationTime();
    }

    public String createAccessToken(Long memberId) {
        Date now = new Date();
        Date accessTokenExpiration = new Date(now.getTime() + accessTokenExpirationTime);

        return Jwts.builder()
            .signWith(secretKey, SignatureAlgorithm.HS256)
            .setIssuedAt(now)
            .setExpiration(accessTokenExpiration)
            .addClaims(Map.of(&quot;memberId&quot;, memberId))
            .compact();
    }
}</code></pre>
<p><code>createToken(String memberId)</code> 메서드가 토큰을 발급하는 로직입니다. 
로그인에 성공한 사용자를 대상으로 토큰을 발급해주기 때문에 사용자 정보가 담긴 <code>memberId(PK)</code>를 payload로 설정해 토큰을 생성합니다.</p>
<ul>
<li>.signWith(secretKey, SignatureAlgorithm.HS256)<ul>
<li>먼저 JWT를 HS256알고리즘을 통해 secretKey로 서명합니다.</li>
</ul>
</li>
<li>setIssuedAt(now)<ul>
<li>JWT가 언제 발급되었는지 설정합니다.</li>
</ul>
</li>
<li>setExpiration(new Date(now.getTime() + accessTokenExpirationTime))<ul>
<li>JWT의 만료시간을 설정합니다.</li>
</ul>
</li>
<li>addClaims(Map.of(&quot;memberId&quot;, memberId))<ul>
<li>JWT의 payload의 클레임을 설정합니다.</li>
</ul>
</li>
</ul>
<blockquote>
<p>⚡️ 주의 ?
setClaims() 사용 시 위에서 설정한 issuedAt과 Expiration의 정보가  삭제되고 memberId라는 payload만 claims에 담기게 됩니다. 때문에 addClaims를 사용하였습니다.</p>
</blockquote>
<p>✔️ 로그인 성공시 토큰 생성 예시</p>
<pre><code>{
    &quot;tokenType&quot;: &quot;Bearer&quot;,
    &quot;accessToken&quot;: &quot;eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIyIn0.bvKP6d_hTx2stQj0k4ROa7LjDaD-ddncZjZ1jmd1VfY&quot;
}</code></pre><h3 id="토큰-검증">토큰 검증</h3>
<p>서버(스프링) 앞에는 여러 필터들이 존재하는데 서버로 진입하기 전 인증되지 않은 사용자 등을 검증을 위한 로직입니다. (혹 인증되지 않은 사용자라면 <code>너 우리가 발급한 토큰 가지고있어? 없으면 서버 내부로 진입하지 못해</code> 하고 필터링을 해줍니다. 쉽게 말해 입구컷?하는 느낌입니다.)</p>
<pre><code class="language-java">public class JwtFilter extends OncePerRequestFilter {

    private static final String BEARER = &quot;bearer&quot;;

    private final AntPathMatcher pathMatcher = new AntPathMatcher();
    private final List&lt;String&gt; excludeUrlPatterns = List.of(&quot;/api/auth/**&quot;);

    private final JwtProvider jwtProvider;

    public JwtFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return excludeUrlPatterns.stream()
            .anyMatch(pattern -&gt; pathMatcher.match(pattern, request.getRequestURI()));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        if (CorsUtils.isPreFlightRequest(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = extractJwt(request)
            .orElseThrow(() -&gt; new UnAuthorizedException(ErrorCode.INVALID_TOKEN));
        jwtProvider.validateToken(token);

        filterChain.doFilter(request, response);
    }

    private Optional&lt;String&gt; extractJwt(HttpServletRequest request) {
        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (!StringUtils.hasText(header) || !header.toLowerCase().startsWith(BEARER)) {
            return Optional.empty();
        }
        return Optional.of(header.split(&quot; &quot;)[1]);
    }
}</code></pre>
<ul>
<li><p><code>OncePerRequestFilter</code>를 상속받는 JwtFilter를 생성합니다.</p>
<ul>
<li>인증/인가에 대해서는 한 번의 검증만 필요하기 때문에 OncePerRequestFilter를 상속받았습니다.</li>
</ul>
</li>
<li><p>shouldNotFilter메서드를 오버라이딩해서 회원가입/로그인에 대해서는 인증/인가 로직을 수행하지 않게합니다.</p>
</li>
<li><p>doFilterInternal메서드를 오버라이딩해서 JWT를 검증합니다.</p>
</li>
<li><p>extractJwt메서드를 통해 Authorization 헤더로 넘어온 토큰을 추출합니다.
이후 토큰이 유효한지 검증합니다.</p>
</li>
<li><p><strong>PreFlightRequest</strong>는 포스팅 하단에 설명되어 있습니다.</p>
<h3 id="필터를-빈으로-등록하기">필터를 빈으로 등록하기</h3>
<p>필터의 사용을 위해선 bean으로 등록을 해줘야 합니다. 그래야 인증되지 않은 사용자 등을 검증 할 수 있습니다.</p>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class FilterConfig {

  private final JwtProvider jwtProvider;
  private final AuthenticationContext authenticationContext;

  @Bean
  public FilterRegistrationBean&lt;JwtFilter&gt; jwtFilter() {
      FilterRegistrationBean&lt;JwtFilter&gt; jwtFilter = new FilterRegistrationBean&lt;&gt;();
      jwtFilter.setFilter(new JwtFilter(jwtProvider, authenticationContext));
      jwtFilter.addUrlPatterns(&quot;/api/*&quot;);
      jwtFilter.setOrder(2);
      return jwtFilter;
  }

  @Bean
  public FilterRegistrationBean&lt;AuthExceptionHandlerFilter&gt; authExceptionHandlerFilter() {
      FilterRegistrationBean&lt;AuthExceptionHandlerFilter&gt; authExceptionHandlerFilter = new FilterRegistrationBean&lt;&gt;();
      authExceptionHandlerFilter.setFilter(new AuthExceptionHandlerFilter());
      authExceptionHandlerFilter.addUrlPatterns(&quot;/api/*&quot;);
      authExceptionHandlerFilter.setOrder(1);
      return authExceptionHandlerFilter;
  }
}</code></pre>
</li>
<li><p><code>FilterRegistrationBean</code> 객체를 생성해 Filter의 정보(본인이 만든 필터)를 입력합니다.</p>
<ul>
<li>이때 주의할 점은 Filter는 반드시 구현이 되어야 합니다.</li>
<li><code>.setFilter()</code> : 새로운 인스턴스로 만들어둔 filter를 적용합니다.</li>
<li><code>.addUrlPatterns()</code> : 어떤 api를 탈 때 적용 시킬지 지정합니다. 필자의 경우 모든 api가 <code>/api</code>로 시작하기 때문에 위와 같이 작성하였습니다.</li>
<li><code>.setOrder()</code> : 필터의 순서를 지정합니다.</li>
</ul>
</li>
</ul>
<blockquote>
<p>💡 왜 필터의 순서를 지정하나요 ?
필터의 진행 순서는 1-&gt;2-&gt;3 순서로 진행이 되지만 2번 필터에서 예외 발생 시 3번으로 넘어가지 않고 다시 1번 filter로 예외가 전파됩니다. 그래서 예외를 처리해주는 <code>authExceptionHandlerFilter()</code> 메서드를 1번에 위치시키고 2번에는 작성해둔 filter를 위치 시킵니다.</p>
</blockquote>
<h3 id="필터의-예외처리">필터의 예외처리</h3>
<pre><code class="language-java">@RequiredArgsConstructor
public class AuthExceptionHandlerFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (UnAuthorizedException e) {
            setErrorResponse(response);
        }
    }

    private void setErrorResponse(HttpServletResponse response) {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
    }
}</code></pre>
<ul>
<li>필터의 생성과 마찬가지로 <code>doFilterInternal</code> 오버라이딩 합니다.<ul>
<li>try catch 이용해서 에러를 잡아서 처리해줍니다.</li>
</ul>
</li>
</ul>
<h3 id="preflightrequest란">PreFlightRequest란?</h3>
<p><span style="background-color: #F9ECCB;"> preflight request는 실제 요청 전에 브라우저에서 보내는 작은 요청이다.</span> 지금 요청을 보내는 프론트 엔드가 백엔드 서버에서 허용한 *origin이 맞는지, 그리고 해당 엔드포인트에서 어떤 HTTP 메서드들을 허용하는지 등을 확인한다.
만약 허용되는 origin 요청이고 메서드도 허용되는 것이라면 실제 요청을 할 수 있게 해준다. 그렇지 않다면, 실제 요청을 보내기도 전에 보내지 못하게 막는것이다. 
<span style="color:#999998;"> origin은 프로토콜 + 호스트 + 포트를 합한 것.
</span> 아래 그림을 보면 preflight request가 무엇인지 이해하는데 도움이 된다.
<img src="https://velog.velcdn.com/images/pie_e/post/ca5c436e-cd8b-4382-8e9a-373d7b28ccf2/image.png" alt=""></p>
<h2 id="끝">끝!</h2>
<p><img src="https://velog.velcdn.com/images/pie_e/post/e9c8997b-7716-4175-9ab6-41ec55c264b0/image.png" alt=""></p>
<h4 id="ref">Ref.</h4>
<p><a href="https://velog.io/@bruni_23yong/JWT-%EC%A0%81%EC%9A%A9%EA%B8%B0">https://velog.io/@bruni_23yong/JWT-%EC%A0%81%EC%9A%A9%EA%B8%B0</a>
<a href="https://bskyvision.com/entry/CORS%EC%99%80-%EA%B4%80%EB%A0%A8-%EC%9E%88%EB%8A%94-preflight-request%EB%9E%80">https://bskyvision.com/entry/CORS%EC%99%80-%EA%B4%80%EB%A0%A8-%EC%9E%88%EB%8A%94-preflight-request%EB%9E%80</a>
<a href="https://velog.io/@whitebear/%EC%BF%A0%ED%82%A4-%EC%84%B8%EC%85%98-%ED%86%A0%ED%81%B0JWT-%ED%99%95%EC%8B%A4%ED%9E%88-%EC%95%8C%EA%B3%A0-%EA%B0%80%EA%B8%B0">https://velog.io/@whitebear/%EC%BF%A0%ED%82%A4-%EC%84%B8%EC%85%98-%ED%86%A0%ED%81%B0JWT-%ED%99%95%EC%8B%A4%ED%9E%88-%EC%95%8C%EA%B3%A0-%EA%B0%80%EA%B8%B0</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[intelliJ 유령 테스트코드]]></title>
            <link>https://velog.io/@pie_e/intelli-J-%EC%9C%A0%EB%A0%B9-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@pie_e/intelli-J-%EC%9C%A0%EB%A0%B9-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Thu, 21 Sep 2023 16:39:28 GMT</pubDate>
            <description><![CDATA[<h2 id="사건-개요--유령테스트-">사건 개요 : 유령테스트 ?</h2>
<p>테스트 코드 실행 중 분명히 <strong>삭제한 테스트가 실행</strong>되고 failed를 내면서 저의 테스트코드를 방해했습니다. 당황스러웠지만 일단 저는 이 현상을 유령 테스트라 부르기로했습니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/5145efc6-48fb-4bf0-801c-e546733079d4/image.png" alt="">
분명 <code>item</code> 패키지에는 <code>ItemControllerTest</code>와 <code>ItemServiceTest</code> 밖에 없는데 <code>ItemQueryServiceTest</code>가 같이 실행됩니다.
<img src="https://velog.velcdn.com/images/pie_e/post/d9c37e4f-920d-4abd-8f37-5b7e6aeca804/image.png" alt="">
기존에 <code>ItemQueryServiceTest</code>가 있었지만 삭제하고 <code>ItemServiceTest</code>로 통합을 시킨 뒤 전체 테스트를 돌리니 없는 파일이 자꾸 실행이 되면서 실패를 했습니다. 순간 제 머리는 물음표로 뒤덮였어요.</p>
<h2 id="--------이게-왜----------">? ? ? ? ? ? ? ?? 이게 왜.. ? ? ? ? ? ? ? ? ?? ?</h2>
<p>그래도 당황하지 않고 찬찬히 에러코드를 읽어봤습니다.<img src ="https://velog.velcdn.com/images/pie_e/post/225db821-b125-42e6-bf97-81e43f809b10/image.png">
음 <code>NoSuchMethodError</code>.. 당연하겠죠? 제가 테스트 코드를 삭제했으니까요 !</p>
<h2 id="1차-시도--gradle-clean-">1차 시도 [ gradle clean ]</h2>
<p>대게 오류가 생긴다면 저는 항상 <code>gradle clean</code>을 가장 먼저 합니다. 왜냐면 보통 해결이 되거든요.. 그래서 해결이 됐으면 제가 이 글을 쓰는 일은 없었을 겁니다.</p>
<h4 id="결과는-당연히-실패">결과는 당연히 실패</h4>
<h2 id="2차-시도--인텔리제이-재실행-">2차 시도 [ 인텔리제이 재실행 ]</h2>
<p>보통 이것도 많이 사용합니다. 왜냐면 끄고 다시 키면 안되던것도 되더라구요 
그치만? 이것도 성공했으면 글을 쓰지 않았습니다..</p>
<h4 id="결과는-당연히-실패-1">결과는 당연히 실패</h4>
<h2 id="3차-시도--invalidate-caches-">3차 시도 [ Invalidate Caches ]</h2>
<p>두번의 실패를 겪고 이번엔 되겠지 하며 꺼내들은 세번째 카드.. 인텔리제이 상단에 File 탭을 누르면 <code>Invalidate Caches...</code>가 있습니다.
<img src= "https://velog.velcdn.com/images/pie_e/post/e53ecb76-be90-483d-a549-b626d0e1259b/image.png" width="70%">
<code>Invalidate Caches...</code>로 들어가서 <code>invalidate and Restart</code>를 누르고 다시 <code>gradle clean</code>을 해줍니다. 체크박스를 아무것도 선택 안해도 보통 해결이 됩니다 !
저는 모든 체크박스를 누르든 안누르든 뭘 눌러도 안되더라구요 네..</p>
<h4 id="결과는-또-또-실패">결과는 또 또 실패</h4>
<h2 id="4차-시도--재부팅-">4차 시도 [ 재부팅 ]</h2>
<p>할 만큼 했다 남은건 재부팅뿐.. 이라고 믿고 컴퓨터 자체를 재부팅을 했습니다.</p>
<h4 id="결과는-또-또-또-실패입니다">결과는 또 또 또 실패입니다</h4>
<p>여기서 저는 제 과오를 되돌려봤습니다. 내가 뭘 잘못했지.. 
<img src= "https://velog.velcdn.com/images/pie_e/post/bab90c8f-94c9-4ba4-ba9a-90e2294dcac4/image.png" width="60%">
아무리 생각해봐도 제 잘못은 없습니다. 아무튼 아님.
어쨋든 다시 생각을 해보니 삭제한 테스트 클래스가 돌아간다는건 어딘가에 기생해있을 확률 58000%입니다. shift를 두번 클릭하면 검색창이 나옵니다.
<img src="https://velog.velcdn.com/images/pie_e/post/19a4cceb-76de-44af-84e9-43e90342afec/image.png" alt="">
안나옵니다. 
제 머리로는 문제 해결이 되지 않는다는걸 깨닫고 주변 동료들에게 상황을 설명했습니다.</p>
<h2 id="5차-시도--친구찬스-">5차 시도 [ 친구찬스 ]</h2>
<p>🍞 : 아이템 패키지 전체를 테스트 돌리면 문제가 생겨요 help..
🥟 : 패키지 명을 바꿔보세요 
🍞 : 님 천재신가 ?
생각해보니 <code>ItemQueryServiceTest</code>는 패키지명이 <code>item</code>일 때 생성되었으니 패키지명을 바꾸면 성공할 거 같았습니다. </p>
<h4 id="결과는-성공--">결과는 성공 .. ?</h4>
<p><img src="https://velog.velcdn.com/images/pie_e/post/23271ae6-4262-4307-acf4-cd8429ab3d61/image.png" alt=""></p>
<p>근데 이 찜찜한 마음을 감출 수 가 없었습니다. 아 이게 아닌데.. 
저는 현재 부트캠프에 참여중이고 부트캠프에는 마스터가 있습니다. 그럼 6차 시도는?</p>
<h2 id="해결방법은-여기-">해결방법은 여기 !</h2>
<h2 id="6차-시도--마스터-찬스-">6차 시도 [ 마스터 찬스 ]</h2>
<p>친구 찬스와 마찬가지로 갓스터에게 찾아가봅니다.
🍞 : 갓스터.. 1~5차 까지 시도를 해봤지만 마음에 들지 않습니다..
🤵🏻 : ㅇㅋ 구글 켜서 <code>intellij run removed test</code> 검색가보자
... (대충 클래스 파일이 있으니 실행이 된다는 얘기) ...
🤵🏻 : ㅇㅋ <code>finder</code>에서 프로젝트 열어보셈.
🍞 : ... ( 프로젝트 열어서 build 폴더 확인. 없었음  ) ...
🤵🏻 : <code>out</code> 폴더 ㄱㄱ 
<img src="https://velog.velcdn.com/images/pie_e/post/0e6d8a77-dfb6-40fc-b42a-c09400af48d1/image.png" alt=""></p>
<h3 id="✨-드디어-찾았다-----✨">✨ 드디어 찾았다 . . . ! ✨</h3>
<p>(참고로 저는 <code>out</code> 폴더 자체를 삭제했습니다.)
🍞 : 흥허ㅓ어엏윻유ㅠㅠ헝헝ㅎ유ㅠㅠ( 대충 오늘 이것때문에 고생했다는 얘기..)
🤵🏻 : 아이구 고생했어요 조금 더 일찍 찾아오지~.. 그래도 이렇게 하나 배웠다고 생각하면 되죠 ( 세상 따듯한 갓갓스터 그저 빛,,🌟)</p>
<h2 id="결론">결론</h2>
<p>왜 박연진이 푼돈으로 누군가의 하늘이 됐는지도 이해 됐습니다. 갓갓스터는 오늘부터 제 하늘임.
어쨋든 이렇게 저는 갓갓스터 님의 도움을 끝으로 해결했습니다. 
<img src="https://velog.velcdn.com/images/pie_e/post/d01fe46a-e484-4a0f-8c54-0643a6f931c8/image.png" alt="">
원래는 ignore 처리 되었어야 하는게 안됐던거 같습니다. 왜인지는 모름 . . </p>
<h1 id="어쨋든-해결-완">어쨋든 해결 완..</h1>
<p><img src="https://velog.velcdn.com/images/pie_e/post/e05921b4-891a-40e4-86a9-012203833d4d/image.png" alt="">
써보니 별거 없지만. . 누군가에게 도움이 되길 바라며 !</p>
<h6 id="feat-남세-짱-🔥">feat. 남세 짱 🔥</h6>
]]></description>
        </item>
        <item>
            <title><![CDATA[죽여도 죽지 않는 좀비 프로세스 죽이기]]></title>
            <link>https://velog.io/@pie_e/%EC%A3%BD%EC%97%AC%EB%8F%84-%EC%A3%BD%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%A2%80%EB%B9%84-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%A3%BD%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@pie_e/%EC%A3%BD%EC%97%AC%EB%8F%84-%EC%A3%BD%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%A2%80%EB%B9%84-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%A3%BD%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Tue, 25 Jul 2023 15:35:46 GMT</pubDate>
            <description><![CDATA[<p>죽여도 죽여도 죽지 않는 프로세스를 죽이는 법을 공유하기 위해 이 글을 씁니다.. 
어캐 알았냐고요? 저도 알기 싫었음</p>
<h2 id="일반적으로-프로세스-죽이는-방법">일반적으로 프로세스 죽이는 방법</h2>
<ol>
<li>터미널에 들어가 원하는 port를 찾는다.<pre><code>lsof -i :포트번호</code></pre></li>
<li>해당 port의 PID를 확인 후 프로세스를 죽이면 된다.<pre><code>kill -9 PID번호</code></pre></li>
</ol>
<h3 id="--이렇게-간단한데-왜-내-프로세스는-안죽지-">? ? 이렇게 간단한데 왜 내 프로세스는 안죽지 ?</h3>
<p>세상에서 이렇게 kill을 많이 한 적은 오늘이 처음인데.. 
누굴 이렇게 죽이고싶단 마음이 많이 든 것도 오늘이 처음이다. 진짜임.
어떻게 해야할 지 당황하며 다른 방법을 찾아보던 도중 부모 프로세스를 죽이면 된다고 했다 !
마음을 다스리고 천천히 다시 시도해보자.</p>
<h2 id="부모-프로세스를-죽여보자">부모 프로세스를 죽여보자</h2>
<ol>
<li>죽이고 싶은 port의 PID를 알기 위해 port를 찾는다.<pre><code>MacBook-Air ~ % lsof -i :3306
</code></pre></li>
</ol>
<p>COMMAND  PID       USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
mysqld   2148      user   20u  IPv4 0x8f092f19738178b1   0t0    TCP  localhost:mysql (LISTEN)</p>
<pre><code>2. PID를 찾았으니 이제 부모port를 찾아보자</code></pre><p>MacBook-Air ~ %  ps -ef | grep 2286 | grep -v grep
501 2148 2214      0   10:41PM ??     <del>생략</del>
501 98049 54312    0   9:55PM ??      <del>생략</del></p>
<pre><code>- 2148은 좀비 port, 2214는 좀비의 부모 port 이제 얘를 죽이면 되겠지?
3. 끝 ?</code></pre><p>MacBook-Air ~ % kill -9 2214
MacBook-Air ~ % lsof -i :3306
COMMAND  PID    USER   FD   TYPE             DEVICE  SIZE/OFF  NODE  NAME
mysqld  2286    user   20u  IPv4 0x8f092f19738178b1    0t0     TCP   localhost:mysql (LISTEN)</p>
<pre><code>### ✨ ✨ 🌸 ✨ 쨘 ✨ 나 아직 안죽었찌롱 ✨ 💖  ✨ ✨ ✨

![](https://velog.velcdn.com/images/pie_e/post/14f83e6b-ee9e-4ef6-bd18-303413b73ca1/image.png)

아 쫌 ㅠ 제발 왜그러시는데요 ㅠ
하지만 인생은 칠전팔기. . 굴하지 않고 오늘 어떻게든 죽이기로 마음 먹고 다시 시도를 해보겠습니다.</code></pre><p>MacBook-Air ~ % lsof -i :3306
COMMAND  PID       USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
mysqld   2286      user   20u  IPv4 0x8f092f19738178b1   0t0    TCP  localhost:mysql (LISTEN)</p>
<pre><code>PID를 먼저 찾았다.</code></pre><p>ps -ef | grep 2286 | grep -v grep
501  2286  2184   0 10:41PM ??         0:00.77 /opt/homebrew/opt/mysql/bin/mysqld --basedir=/opt/homebrew/opt/mysql --datadir=/opt/homebrew/var/mysql --plugin .. 생략
501 84156 41254   0  9:55PM ??         0:00.09 /Applications/zoom.us.app/Contents/Frameworks/.. 생략</p>
<pre><code>다시 보니 homebrew 어쩌고 저쩌고.. 이전에 homebrew를 설치했다가 삭제한 적이 있는데 루트 경로에 mysql을 설치하고 실행시켰었나보다.. 🤦🏻‍♀️ 
### 엥 그런데 더 자세히 보니 mysql?d?
mysql 프로세스가 자꾸 재실행을 시켜 죽여도 죽지않는 좀비 프로세스가 탄생한 줄 알았는데 자세히 보니 **mysqld?**??? mysqld는 뭐지 ?
&gt; 💡 **mysqld**란 ? 간단히 설명해 **mysql+D(Daemon의 약자)**이다.
**mysqld**는 **백그라운드에서 돌아가고 있는 프로세스**, MYSQL 서버이고
mysql은 우분투의 터미널처럼 sql문을 실행시켜주는 command-line client이다.


mysqld도 뭔지 알아냈으니 이제 경로를 따라가 백그라운드에서 돌고있는 애를 찾아 삭제하고 죽이면 될거같다 ! ! ! !</code></pre><p>MacBook-Air ~ % cd /opt/homebrew/opt
MacBook-Air opt % ls</p>
<p><del>~ 생략 ~</del>
git        libevent    libnettle    libtool        mysql        protobuf</p>
<pre><code>#### mysql. . . 너 이시끼. . .  드디어 찾았다. . . 지금 죽이러 갑니다 . . .</code></pre><p>MacBook-Air opt % rm -rf mysql
MacBook-Air opt % ls</p>
<p><del>생략</del>
git        libevent    libnettle    libtool        <a href="mailto:mysql@8.0">mysql@8.0</a>        protobuf</p>
<pre><code>? 이번엔 mysql@8.0이 되었네 ? 끝까지 가보자;</code></pre><p>MacBook-Air opt % rm -rf <a href="mailto:mysql@8.0">mysql@8.0</a>
MacBook-Air opt % ls</p>
<p><del>생략</del>
git        libevent    libnettle    libtool        protobuf</p>
<pre><code>mysql 폴더는 없앴다 이제 다시 port를 찾아서 **죽여버리자.**</code></pre><p>MacBook-Air opt % lsof -i :3306<br>COMMAND  PID       USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
mysqld  2286 user   20u  IPv4 0x8f092f19738178b1      0t0  TCP localhost:mysql (LISTEN)
MacBook-Air opt % kill -9 2286
MacBook-Air opt % lsof -i :3306</p>
<p><del>~</del></p>
<h3 id="쨘--✨-clear-✨">쨘 !!!!! ✨ clear~ ✨</h3>
<p><img src="https://velog.velcdn.com/images/pie_e/post/f6b604c8-141c-4d00-a955-eec01a030e3f/image.png" alt=""></p>
<h4 id="여러분도-이기세요-전-이겨냈거든요-화이팅-🍑✨🎶">여러분도 이기세요 전 이겨냈거든요 화이팅 🍑✨🎶</h4>
<p>*<em>Ref. *</em><a href="https://zetawiki.com/wiki/%EC%95%88_%EC%A3%BD%EB%8A%94_%EC%A2%80%EB%B9%84_%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4_%EC%A3%BD%EC%9D%B4%EA%B8%B0">https://zetawiki.com/wiki/%EC%95%88_%EC%A3%BD%EB%8A%94_%EC%A2%80%EB%B9%84_%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4_%EC%A3%BD%EC%9D%B4%EA%B8%B0</a></p>
]]></description>
        </item>
    </channel>
</rss>