<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ABL.log</title>
        <link>https://velog.io/</link>
        <description>💻</description>
        <lastBuildDate>Wed, 03 Jul 2024 09:13:22 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ABL.log</title>
            <url>https://velog.velcdn.com/images/refoli_20/profile/a1cb42c7-f602-429e-afbb-4b899528cd8a/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ABL.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/refoli_20" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Spring Boot 다국어 지원, DB 번역값 처리하기 - AOP 방식으로 response 번역하기]]></title>
            <link>https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90-DB-%EB%B2%88%EC%97%AD%EA%B0%92-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-2</link>
            <guid>https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90-DB-%EB%B2%88%EC%97%AD%EA%B0%92-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-2</guid>
            <pubDate>Wed, 03 Jul 2024 09:13:22 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90-%EB%B2%88%EC%97%AD%EA%B0%92-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0">Spring Boot 다국어 지원, DB 번역값 처리하기 - 1</a></p>
<p>이전에 Entity단에서 직접 번역값과 매핑하는 것에 대해,
엔티티마다 Translatable 인터페이스를 구현하고, 번역 필드를 관리하는 코드가 반복되고, 필드가 추가될 때마다 TranslatableField를 함께 추가해주어야 한다는 부분에 대해 번거로움을 느꼈습니다.</p>
<p>따라서 AOP를 사용하여 DTO Response값을 받아와 번역이 요구되는 필드일 경우 번역하는 과정으로 바꾸어보았습니다.</p>
<p>먼저, AOP를 사용하기 때문에 이전처럼 Entity에 Transable을 implements할 이유가 없었습니다. 따라서, 커스텀 어노테이션을 만들어 번역이 필요한 필드에 직접 붙여주는 방식으로 구현했습니다.</p>
<hr>

<h3 id="spring-boot-구현-과정">Spring Boot 구현 과정</h3>
<p><strong>Trans Custom Annotation 선언</strong></p>
<pre><code class="language-java">@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trans {
}

@Data
public class ArtistSearchDto {
    private Long contentId;
    @Trans
    private String artistName;
}
</code></pre>
<p>위와 같이 ArtistSearchDto 내 번역이 필요한 artistName에 <strong>@Trans</strong> 어노테이션을 붙여주었습니다.</p>
<p><strong>Translation Aspect</strong></p>
<pre><code class="language-java">@Aspect
@Component
@RequiredArgsConstructor
public class TranslationAspect {

    private final TranslationService translationService;

    @Around(&quot;@annotation(org.springframework.web.bind.annotation.GetMapping)&quot;)
    public Object translateFields(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = joinPoint.proceed();

        if (result instanceof ResponseEntity) {
            ResponseEntity&lt;?&gt; responseEntity = (ResponseEntity&lt;?&gt;) result;
            Object body = responseEntity.getBody();
            if (body instanceof Page) {
                Page&lt;?&gt; page = (Page&lt;?&gt;) body;
                page.getContent().forEach(this::translateObject);
            } else {
                translateObject(body);
            }
            return ResponseEntity.status(responseEntity.getStatusCode()).headers(responseEntity.getHeaders()).body(body);
        } else {
            translateObject(result);
        }

        return result;
    }

    private void translateObject(Object obj) {
        if (obj == null) return;

        Field[] fields = obj.getClass().getDeclaredFields();
        for (Field field : fields) {
            if (field.isAnnotationPresent(Trans.class)) {
                field.setAccessible(true);
                try {
                    String originalValue = (String) field.get(obj);
                    String translatedValue = translationService.translate(originalValue, Language.ofLocale());
                    field.set(obj, translatedValue);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
</code></pre>
<ul>
<li>AOP를 사용하여, @GetMapping 어노테이션이 붙은 모든 메서드에서 실행</li>
<li>Response값의 instance 타입에 따라서, translateObject를 확인 후 번역 (@Trans가 붙은 필드 확인 (Trans.class)</li>
</ul>
<hr>

<h3 id="기존-방식과의-비교">기존 방식과의 비교</h3>
<h4 id="기존-방식-translatable-인터페이스를-사용한-번역">기존 방식 (Translatable 인터페이스를 사용한 번역)</h4>
<p><strong>장점</strong></p>
<ol>
<li><strong>명시적 호출:</strong> 번역 과정이 명시적으로 서비스 계층에서 호출되므로, 코드의 흐름이 명확합니다.</li>
<li><strong>구체적인 제어:</strong> 특정 상황에서 번역을 제외하거나 커스터마이징할 수 있는 유연성을 제공합니다.</li>
</ol>
<p><strong>단점</strong></p>
<ol>
<li><strong>반복적 코드:</strong> 엔티티마다 Translatable 인터페이스를 구현하고, 번역 필드를 관리하는 코드가 반복됩니다.</li>
<li><strong>유지보수 어려움:</strong> 번역 필드가 추가될 때마다 관련 인터페이스와 메서드를 업데이트해야 합니다.</li>
<li><strong>서비스 의존성:</strong> 번역이 필요한 곳마다 TranslationService를 호출해야 하므로 서비스 계층의 의존성이 증가합니다.</li>
</ol>
<hr>

<h4 id="aop-방식">AOP 방식</h4>
<p><strong>장점</strong></p>
<ol>
<li><strong>중앙 집중식 처리:</strong> 번역 로직이 하나의 Aspect로 중앙 집중화되어 유지보수가 용이합니다.</li>
<li><strong>코드 간소화:</strong> Response DTO에서 번역 필드에 직접 어노테이션을 붙여주기만 하면 되므로, 번역 관련 코드가 간소화됩니다.</li>
<li><strong>자동화:</strong> @GetMapping 어노테이션이 붙은 모든 메서드에서 자동으로 번역이 수행되므로, 개발자가 일일이 번역을 호출할 필요가 없습니다.</li>
</ol>
<p><strong>단점</strong></p>
<ol>
<li><strong>추적 어려움:</strong> AOP로 인해 번역이 자동으로 이루어지므로, 번역 로직이 어디서 호출되는지 추적하기 어렵습니다.</li>
<li><strong>성능 문제:</strong> 모든 @GetMapping 메서드에서 번역을 수행하므로, 불필요한 번역이 발생할 수 있으며, 성능에 영향을 미칠 수 있습니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/072354c6-f720-4b92-9869-5b8ab0f0c3f4/image.png" alt=""></p>
<blockquote>
<p>AOP를 사용한 방식은 코드의 간결성과 유지보수 측면에서 큰 장점을 제공합니다. 하지만 @GetMapping 메서드로부터 받은 응답값에 직접 번역을 수행하므로 성능 문제가 발생할 수 있는 단점이 있습니다.
만약 번역이 대부분의 엔드포인트에서 필요하고, 번역 필드가 자주 변경된다면 AOP 방식이 더 적합할 수 있습니다. 반면, 특정 엔드포인트에서만 번역이 필요하거나, 성능이 중요한 경우에는 기존 방식이 더 나을 수 있습니다.
제가 구현중인 서비스의 경우 전자이기 때문에, AOP 방식을 적용하고자 합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 다국어 지원, DB 번역값 처리하기 - Entity 바로 번역하기]]></title>
            <link>https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90-%EB%B2%88%EC%97%AD%EA%B0%92-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90-%EB%B2%88%EC%97%AD%EA%B0%92-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 03 Jul 2024 05:20:48 GMT</pubDate>
            <description><![CDATA[<p>현재 진행 중인 프로젝트는 외국인의 인바운드 관광 활성화를 돕기 위한 어플리케이션으로, 다국어 지원이 필요합니다. 예를들어, 여러 장소 리스트의 장소명과 설명(Description)의 번역값을 관리해야 합니다.</p>
<hr>

<h3 id="전통적인-다국어-처리-방법">전통적인 다국어 처리 방법</h3>
<p><strong>MessageSource 및 properties 파일로 관리:</strong></p>
<ul>
<li>일반적으로 각 언어별 메시지를 messages_en.properties, messages_ko.properties와 같이 프로퍼티 파일에 작성합니다.<pre><code>// 예시
messages_ko.properties에는 greeting.message=안녕하세요.
messages_en.properties에는 greeting.message=Hello.</code></pre></li>
</ul>
<p><strong>이 방법은 정적 문자열 값을 처리하는 데 적합하지만, 동적으로 관리되는 데이터에는 적합하지 않습니다.</strong></p>
<p>더하여, 프론트에서 데이터를 받아올 때마다 번역 api를 사용해서 처리를 하기에는 번역 결과의 정확도가 좋지 않을 수 있으며, 그만큼 성능이 떨어질 수 있기에 선택하지 않았습니다.</p>
<p>특히 서비스 특성 상 사용자들이 조회하는 기본적인 데이터들은 모두 DB에서 관리되기에 백엔드에서 관리되어야 합니다. </p>
<hr>

<p>다국어 지원을 위한 데이터베이스 설계 전략은 여러가지가 있는데, 처음에는 단순하게 title_kr, title_en과 같이 <strong>각 엔티티에 언어별로 필드를 추가했습니다.</strong>
하지만 지원하는 언어의 종류가 변경될 때마다 새로운 필드를 하나씩 수정해야 하기에, 적절하지 않다고 생각했습니다.
*<em>따라서 유연한 처리와 확장성을 위해 DB에 key-value 형식으로 Translation 테이블을 만들어 번역값을 처리하기로 결정했습니다. *</em></p>
<p><strong>Translation 테이블</strong>
<img src="https://velog.velcdn.com/images/refoli_20/post/92321e11-3b0e-4c16-90e1-81d6918d3df1/image.png" alt=""></p>
<hr>

<h3 id="spring-boot-설정">Spring Boot 설정</h3>
<p><strong>LocaleResolver 설정</strong></p>
<ul>
<li>프론트엔드에서 Accept-Language 헤더를 받아와서 해당 언어에 대응하는 응답값을 동적으로 내려줍니다.</li>
</ul>
<pre><code class="language-java">    @Bean
    public LocaleResolver localeResolver() {
        AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
        localeResolver.setDefaultLocale(Locale.ENGLISH);
        return localeResolver;
    }</code></pre>
<p><strong>Translation Entity</strong></p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/10e41d04-2282-48cd-8dee-e690d45bb4e6/image.png" alt=""></p>
<ul>
<li>복합키: key와 language를 조합하여 사용합니다.</li>
<li>value: 번역된 값을 담습니다.</li>
<li>여기서 key는 {{엔티티명_필드명_entityId}} 형태로 선언합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/441c2486-41c1-4613-8336-c20c4189c47e/image.png" alt=""></p>
<p><strong>TranslationId</strong></p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/1c7fbb5a-f368-480e-8263-72dea3564092/image.png" alt=""></p>
<p><strong>TranslationResolver</strong></p>
<p>다음으로 TranslationResolver class를 추가하여, 번역값을 가져올 메소드를 선언합니다.
<img src="https://velog.velcdn.com/images/refoli_20/post/041f9e90-2474-41dc-b67a-cccc6b2f14c8/image.png" alt=""></p>
<p><strong>TranslationService</strong></p>
<p>아래와 같이 TranslationService를 만들어, Translatable한 엔티티를 쉽게 번역할 수 있습니다.
<img src="https://velog.velcdn.com/images/refoli_20/post/5a011fc7-092e-4f00-9143-0cc7bc8c476d/image.png" alt=""></p>
<p><strong>Translatable 인터페이스 및 구현</strong>
번역값을 하나의 테이블이 아닌 여러 테이블에서의 처리가 필요하다면, Translatable interface를 선언하여 implements하여 사용하는 것이 일관성을 위해 좋습니다.</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/ffa768c3-582d-4dbd-963d-b2c0024285b8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/8569d795-552c-40f3-af6e-ebea39c34e81/image.png" alt=""></p>
<p><strong>상속 적용 예시</strong>
<img src="https://velog.velcdn.com/images/refoli_20/post/f6974cf3-11c6-4dfc-980e-6c148fe6243a/image.png" alt=""></p>
<hr>

<pre><code class="language-java">content = translationService.getTranslatedEntity(content);</code></pre>
<p>이후 위와 같이 사용해주면 번역이 필요한 필드들에 쉽게 적용할 수 있습니다.</p>
<hr>

<p><strong>결과</strong>
<img src="https://velog.velcdn.com/images/refoli_20/post/f8a1d123-9660-4e87-a8b6-294dee5b1178/image.png" alt="">
<img src="https://velog.velcdn.com/images/refoli_20/post/5ac32e0e-8426-4254-9a47-cc2136f30811/image.png" alt=""></p>
<blockquote>
<p>번역이 필요한 엔티티마다 직접 getTranslatedEntity를 사용해주어야 하는 부분이 번거롭다고 느껴졌다. 이후 Spring AOP와 custom annotation을 이용하여 Entity의 특정 필드에 대한 번역하는 방법을 시도했으나, return되는 Object가 Translatable한 엔티티가 아닌 DTO였기 때문에 기존 코드로는 적용이 불가능했다. 따라서 아예 DTO에 translatable한 필드를 따로 어노테이션으로 명시하여, AOP를 사용해서 구현하는 방법을 다음 포스트에 적어볼 예정이다. </p>
</blockquote>
<hr>
Reference

<p><a href="https://mindybughunter.com/%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90%EC%9D%84-%EC%9C%84%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%84%A4%EA%B3%84%EC%99%80-spring-boot-%EA%B5%AC%ED%98%84-%EC%A0%84/">다국어 지원을 위한 데이터베이스 설계와 Spring Boot 구현 전략</a>
<a href="https://velog.io/@mini-boo/DB%EB%A5%BC-%ED%86%B5%ED%95%9C-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90-%EA%B8%B0%EB%8A%A5">DB를 통한 다국어 지원 기능 구현</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring에서 AOP(Aspect-Oriented Programming) 적용하기]]></title>
            <link>https://velog.io/@refoli_20/Spring%EC%97%90%EC%84%9C-AOPAspect-Oriented-Programming-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@refoli_20/Spring%EC%97%90%EC%84%9C-AOPAspect-Oriented-Programming-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 20 Jun 2024 07:30:53 GMT</pubDate>
            <description><![CDATA[<h2 id="aopaspect-oriented-programming란">AOP(Aspect-Oriented Programming)란?</h2>
<p><strong>횡단 관심사(Cross-Cutting Concerns)를 모듈화하기 위한 기술 
**
여기서 **횡단 관심사</strong>란, 애플리케이션의 핵심 비즈니스 로직과는 별개로 <strong>여러 모듈에 공통적으로 적용되는 기능</strong>을 의미합니다!</p>
<p>스프링에서 AOP는 <strong>프로그램의 여러 곳에서 반복적으로 사용되는 공통 기능</strong>(예: 로그 기록, 보안, 트랜잭션 관리)을 분리해서 코드를 더 깔끔하고 관리하기 쉽게 만드는 방법입니다. 
예를 들어, 모든 메소드 호출 전에 로그를 남기고 싶다면, 일일이 모든 메소드에 로그 코드를 넣는 대신 AOP를 사용해 한 번만 정의해두면 됩니다!</p>
<hr>
간단하게 AOP의 사용방식을 설명하자면,

<p><strong>1. Aspect 클래스 만들기</strong>
<strong>2. 포인트컷 설정</strong></p>
<p>이렇게 정의할 수 있습니다.</p>
<p>Aspect Class는 공통으로 적용하고 싶은 기능을 정의하는 클래스 입니다. 예를 들어, <em>&quot;메소드가 실행되기 전에 로그를 남기자&quot;</em> 같은 기능을 이 클래스에 명시합니다.</p>
<p>다음으로 포인트컷 설정을 설정해야합니다. 이는 공통 기능을 &quot;언제&quot; 적용할지 정하도록 합니다. 예를 들어, <em>&quot;특정 패키지 안의 모든 메소드에 적용하자&quot;</em> 처럼 동작할 수 있습니다.</p>
<hr>

<h4 id="주요-개념">주요 개념</h4>
<p><strong>Aspect:</strong> 횡단 관심사를 모듈화한 것. 여러 개의 애드바이스(Advice)와 포인트컷(Pointcut)으로 구성됨</p>
<p><strong>Advice:</strong> 실제로 처리해야 할 로직을 정의한 코드</p>
<p><strong>Join Point:</strong> 프로그램 실행 중에 Advice가 적용될 수 있는 특정 시점 
(example. 메서드 호출이나 예외 발생 시점 등)</p>
<ul>
<li>Before Advice: 타겟 메서드가 호출되기 전에 실행</li>
<li>After Returning Advice: 타겟 메서드가 정상적으로 완료된 후에 실행</li>
<li>After Throwing Advice: 타겟 메서드가 예외를 던진 후에 실행</li>
<li>After (Finally) Advice: 타겟 메서드의 성공 여부와 상관없이 실행</li>
<li>Around Advice: 타겟 메서드 호출 전후에 실행</li>
</ul>
<p><strong>Pointcut:</strong> Advice가 적용될 Join Point를 지정하는 표현식</p>
<p><strong>Target Object:</strong> 애드바이스를 받는 객체입니다.</p>
<p><strong>Introduction:</strong> 특정 타입의 메서드 구현을 추가하여 기존 클래스를 확장</p>
<p><strong>AOP Proxy:</strong> AOP 기능을 구현하기 위해 생성된 프록시 객체</p>
<hr>

<h3 id="spring에-적용하기">Spring에 적용하기</h3>
<p><strong>AspectJ 라이브러리 추가</strong>
build.gradle</p>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-aop&#39;</code></pre><ol>
<li><strong>@Aspect</strong></li>
</ol>
<ul>
<li>해당 클래스가 Aspect임을 나타냄.</li>
<li>해당 클래스가 횡단 관심사를 정의하는 클래스임을 명시!</li>
</ul>
<pre><code class="language-java">@Aspect
@Component
public class LoggingAspect {
    // 애드바이스 정의
}</code></pre>
<ol start="2">
<li><p><strong>@Before - 타겟 메서드가 호출되기 전에 실행, 반환값 참조 불가</strong></p>
<pre><code class="language-java">@Aspect
public class LoggingAspect {

 @Before(&quot;execution(* com.example.service.*.*(..))&quot;)
 public void logBefore() {
     System.out.println(&quot;Method execution started&quot;);
 }
}</code></pre>
</li>
<li><p><strong>@AfterReturning - 타겟 메서드가 정상적으로 완료된 후에 실행, 반환값 참조 가능</strong></p>
</li>
</ol>
<pre><code class="language-java">@Aspect
public class LoggingAspect {

    @AfterReturning(pointcut = &quot;execution(* com.example.service.*.*(..))&quot;, returning = &quot;result&quot;)
    public void logAfterReturning(Object result) {
        System.out.println(&quot;Method returned value is : &quot; + result);
    }
}</code></pre>
<ol start="4">
<li><strong>@AfterReturning - 타겟 메서드가 예외를 던진 후에 실행, 예외 객체 참조 가능</strong></li>
</ol>
<pre><code class="language-java">@Aspect
public class LoggingAspect {

    @AfterThrowing(pointcut = &quot;execution(* com.example.service.*.*(..))&quot;, throwing = &quot;error&quot;)
    public void logAfterThrowing(Throwable error) {
        System.out.println(&quot;Exception : &quot; + error);
    }
}</code></pre>
<ol start="5">
<li><strong>@After - 타겟 메서드가 성공적으로 완료되었든, 예외를 던졌든 상관없이 실행</strong></li>
</ol>
<pre><code class="language-java">@Aspect
public class LoggingAspect {

    @After(&quot;execution(* com.example.service.*.*(..))&quot;)
    public void logAfter() {
        System.out.println(&quot;Method execution finished&quot;);
    }
}</code></pre>
<ol start="6">
<li><strong>@Around - 타겟 메서드 호출 전후에 실행, ProceedingJoinPoint 객체를 사용하여 타겟 메서드를 호출</strong></li>
</ol>
<ul>
<li><strong>joinPoint.proceed()</strong>를 이용하여 직접 메소드 실행을 제어할 수 있음<pre><code class="language-java"></code></pre>
</li>
</ul>
<p>@Aspect
public class LoggingAspect {</p>
<pre><code>@Around(&quot;execution(* com.example.service.*.*(..))&quot;)
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println(&quot;Method execution started&quot;);
    Object result = joinPoint.proceed(); // 타겟 메서드 호출
    System.out.println(&quot;Method execution finished&quot;);
    return result;
}</code></pre><p>}</p>
<pre><code>
7. **@Pointcut - 포인트컷 표현식을 정의하는 데 사용**

```java
@Aspect
public class LoggingAspect {

    @Pointcut(&quot;execution(* com.example.service.*.*(..))&quot;)
    public void serviceLayer() {
        // 포인트컷 정의
    }

    @Before(&quot;serviceLayer()&quot;)
    public void logBefore() {
        System.out.println(&quot;Method execution started&quot;);
    }
}</code></pre><hr>

<h4 id="적용해보기">적용해보기!</h4>
<p><strong>AOP를 사용하여 로깅을 구현하는데, 이 때 메소드 실행 시간을 측정하여 같이 기록하기</strong></p>
<pre><code class="language-java">package com.example.demo.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);

    @Around(&quot;execution(* com.example.demo.service.*.*(..))&quot;)
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // 메소드 시작 시간 기록
        long startTime = System.currentTimeMillis();

        // 메소드 이름과 인수 로깅
        logger.info(&quot;Entering method: {} with arguments: {}&quot;, joinPoint.getSignature(), joinPoint.getArgs());

        Object result;
        try {
            // 타겟 메소드 실행
            result = joinPoint.proceed();
        } catch (Throwable throwable) {
            // 예외가 발생한 경우 로깅
            logger.error(&quot;Exception in method: {} with cause: {}&quot;, joinPoint.getSignature(), throwable.getCause() != null ? throwable.getCause() : &quot;NULL&quot;);
            throw throwable;
        }

        // 메소드 종료 시간 기록 및 소요 시간 계산
        long endTime = System.currentTimeMillis();
        long duration = endTime - startTime;

        // 메소드 종료와 결과 로깅
        logger.info(&quot;Exiting method: {} with result: {}. Time taken: {} ms&quot;, joinPoint.getSignature(), result, duration);

        // 타겟 메소드의 결과 반환
        return result;
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis Sorted Set으로 실시간 급상승 검색어 랭킹 구현하기]]></title>
            <link>https://velog.io/@refoli_20/Redis-Sorted-Set%EC%9C%BC%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EA%B8%89%EC%83%81%EC%8A%B9-%EA%B2%80%EC%83%89%EC%96%B4-%EB%9E%AD%ED%82%B9-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@refoli_20/Redis-Sorted-Set%EC%9C%BC%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EA%B8%89%EC%83%81%EC%8A%B9-%EA%B2%80%EC%83%89%EC%96%B4-%EB%9E%AD%ED%82%B9-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 02 Apr 2024 15:47:51 GMT</pubDate>
            <description><![CDATA[<p>보통 Redis를 사용하여 인기 검색어 랭킹을 구현할 때, Sorted Set을 사용하곤 한다.
Redis Sorted Set은 key 하나에 여러개의 score와 member로 구성되는데, 이때 member값은 중복되지 않으며 score를 기준으로 정렬된다.</p>
<p>보통 member에 키워드를 지정해서 검색할 때마다 score을 1씩 증가시키는 로직을 작성한 뒤, score이 가장 높은 순으로 n개의 member를 가져오는 방식으로 사용한다. </p>
<p>하지만 내가 구현한 기능의 경우 요구사항은 실시간성을 반영하기 위해서 검색일자 기준 3일까지만 랭킹에 반영되도록 해야 했다. 검색 데이터별 TTL을 통해 관리하기에는, Sorted Set을 사용한 로직이 모든 검색 데이터를 저장하는 것이 아닌, 키워드별 score (count) 수를 1씩 증가하는 것이기 때문에 적용할 수 없었다.</p>
<p>따라서 &quot;ranking_240401&quot;과 같이, key값 자체를 랭킹이 보여질 날짜별로 여러개 생성한 뒤, key 자체의 TTL을 통해 정리하는 방식을 사용했다.</p>
<hr/>

<p><strong>Sorted Set 기본 구조</strong></p>
<p>key : [member, score]</p>
<p>**    적용 → <code>&quot;ranking:{ranking_date}&quot; : [{keyword} : {keyword_score}]</code>
**    </p>
<ul>
<li><p>각 키워드의 검색 빈도를 저장하기 위해 Redis의 Sorted Set을 사용 -&gt; 이 구조에서 각 키워드(member)는 고유하며, 각각의 검색 횟수(score)에 따라 자동으로 정렬</p>
</li>
<li><p>검색일을 기준으로 일자별로 별도의 key를 생성 (ex&gt; &quot;ranking:240401&quot;) -&gt; 특정 일자별로 검색 빈도를 관리 가능</p>
</li>
<li><p>각 key는 생성된 날짜로부터 3일 동안만 유효함(TTL 설정) -&gt; 랭킹 실시간성을 유지하면서도, 오래된 데이터 제거</p>
</li>
<li><p>사용자가 특정 키워드를 검색할 때, 그날을 포함해 이후 2일간 해당하는 key들에서 해당 키워드의 score를 1 증가, 미래의 날짜에도 미리 검색 빈도를 반영</p>
</li>
<li><p>랭킹을 조회할 때는 해당 일자의 key를 기준으로, 가장 높은 score를 가진 상위 n개의 키워드를 가져옴, 이를 통해 최근 3일간 가장 인기 있는 검색어를 파악할 수 있음</p>
</li>
</ul>
<p><strong>Example</strong></p>
<ol>
<li>240401에 &quot;뽀삐&quot; 라는 키워드 검색</li>
<li>key값이 &quot;ranking:240401&quot;, &quot;ranking:240402&quot;, &quot;ranking:240403&quot; 인 Set에서 member이 &quot;뽀삐&quot;인 score를 1씩 increase </li>
<li>새로 생성되는 key는 TTL 3일 (ranking:240403은 240401에 생성되어, 240403에 조회되며, 240404에 삭제된다)</li>
<li>240401, 240402, 240403 3일간 240401에 뽀삐 검색 1회가 누적된 랭킹을 보여줄 수 있음</li>
</ol>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/f46b2b7e-c0ff-4814-881c-e9b61db4033b/image.png" alt=""></p>
<hr>

<p><strong>코드 구현</strong></p>
<pre><code class="language-java">
    // 검색어 랭킹 반영
    public void increaseKeywordFrequency(KeywordRequest keywordRequest) {

        if (keywordRequest.getKeyword().trim().isEmpty()) {
            throw new CustomException(BLANK_REQUEST_VALUE, &quot;키워드 검색은 공백일 수 없습니다.&quot;);
        }

        LocalDate today = LocalDate.now();

        for (int i = 0; i &lt; 3; i++) {
            String key = &quot;ranking:&quot; + today.plusDays(i).format(DateTimeFormatter.ISO_DATE);

            redisTemplate.opsForZSet().incrementScore(key, keywordRequest.getKeyword(), 1);
            redisTemplate.expire(key, 3, TimeUnit.DAYS);

        }

    }

    // 키워드 랭킹 가져오기 (score 5 미만은 제외)
    public List&lt;SearchRankResponse&gt; getKeywordRank() {
        LocalDate today = LocalDate.now();
        String key = &quot;ranking:&quot; + today.format(DateTimeFormatter.ISO_DATE);
        Set&lt;ZSetOperations.TypedTuple&lt;String&gt;&gt; typedTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 4);

        List&lt;SearchRankResponse&gt; keywordRankResponses = extractResponses(typedTuples);
        double threshold = 5.0;
        removeBelowThreshold(keywordRankResponses, threshold);

        return keywordRankResponses;
    }

    private List&lt;SearchRankResponse&gt; extractResponses(Set&lt;ZSetOperations.TypedTuple&lt;String&gt;&gt; typedTuples) {
        return Optional.ofNullable(typedTuples)
                .map(tuples -&gt; tuples.stream()
                        .map(SearchRankResponse::of)
                        .collect(Collectors.toList()))
                .orElse(Collections.emptyList());
    }

    public static void removeBelowThreshold(List&lt;SearchRankResponse&gt; responses, double threshold) {
        responses.removeIf(response -&gt; response.getScore() &lt; threshold);
    }</code></pre>
<p>- </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@OneToMany Delete Not Working]]></title>
            <link>https://velog.io/@refoli_20/OneToMany-Delete-Not-Working</link>
            <guid>https://velog.io/@refoli_20/OneToMany-Delete-Not-Working</guid>
            <pubDate>Thu, 04 Jan 2024 02:55:07 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-java">@Table(name = &quot;post&quot;)
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;post_id&quot;)
    private Long id;

    @OneToMany(mappedBy = &quot;postId&quot;, cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    private Set&lt;File&gt; files;
}
</code></pre>
<p>위와 같이 @OneToMany 관계로 설정된 files</p>
<h4 id="문제-코드">문제 코드</h4>
<pre><code class="language-java">        Post post = postRepository.findById(postRequest.getPostId()).get(); // 1

        fileRepository.deleteAllByIdInBatch(postRequest.getDeletedFileId()); // 2


        // Post 엔티티를 업데이트하고 반환
        Post newPost = postRepository.save(post); // 3
</code></pre>
<p>기존 코드를 단순화해보았다</p>
<p>Delete가 제대로 작동하지 않는 이유는 너무나 당연하고 간단했다
(1) post 객체를 미리 가져온 후 (2) File 엔티티를 삭제하게 되면 분명 정상적으로 삭제되는 것을 확인할 수 있지만, post에 관계로 설정되어있던 files이 (3) post가 저장되는 시점에서 다시 새로운 객체로 저장되게 된다. (fileId = 1 -&gt; 2로 변경)
따라서 post.setFiles(null) 과 같이 설정해서 관계를 잠시 끊어주거나, Post 객체를 받아오는 시점을 File이 삭제된 이후로 잡아야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PostgreSQL 한글 정렬 문제 : COLLATE "C" 옵션]]></title>
            <link>https://velog.io/@refoli_20/PostgreSQL-%ED%95%9C%EA%B8%80-%EC%A0%95%EB%A0%AC-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@refoli_20/PostgreSQL-%ED%95%9C%EA%B8%80-%EC%A0%95%EB%A0%AC-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Fri, 15 Dec 2023 06:45:59 GMT</pubDate>
            <description><![CDATA[<p>데이터베이스 정렬문을 실행할 때 정렬이 되는 듯 하면서 안되는 현상이 나타난다면, collation 옵션을 확인하자</p>
<p>*<em>ORDER BY 열 COLLATE &quot;C&quot; 를 사용할 수 있다.
*</em></p>
<pre><code class="language-sql">SELECT *
FROM 테이블명
ORDER BY
  열1 COLLATE &quot;C&quot;,
  열2 COLLATE &quot;C&quot;,
  열3 COLLATE &quot;C&quot;,
  열4 COLLATE &quot;C&quot;;</code></pre>
<p>여기서 &quot;C&quot;는 C 언어의 바이트 순서를 사용하며, 이는 주어진 문자열을 바이트 단위로 비교하여 정렬한다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 다중 Database 구성 (JPA, PostgreSQL, MySQL)]]></title>
            <link>https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EC%A4%91-Database-%EA%B5%AC%EC%84%B1-JPA-PostgreSQL-MySQL</link>
            <guid>https://velog.io/@refoli_20/Spring-Boot-%EB%8B%A4%EC%A4%91-Database-%EA%B5%AC%EC%84%B1-JPA-PostgreSQL-MySQL</guid>
            <pubDate>Fri, 15 Dec 2023 02:21:57 GMT</pubDate>
            <description><![CDATA[<p>스프링부트 내에서 Database를 구성할 때 보통 application.yml에서 다음과 같이 작성한다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/dbname
    platform: postgres
    username: username
    password: password</code></pre>
<p>대부분의 작은 프로젝트에서는 이렇게 단일 DB로 구성되지만, 여러 개의 DB로 나누어 관리하거나 외부 DB를 참조해야 할 경우 다중 데이터베이스 구성이 필요하다.</p>
<h3 id="다중-database-구성">다중 Database 구성</h3>
<p>나는 PostgreSQL 로컬 DB 하나와, 외부에서 참조할 PostgreSQL, MySQL DB 하나씩 연동이 필요한 상황이었다. 
먼저 각각의 구성 정보를 db1, db2, db3과 같이 나누어 담아준다. </p>
<p><strong>application.yml</strong></p>
<pre><code class="language-yaml">  # 다중 Database 구성
  datasource:
    db1:
      jdbcUrl: jdbc:postgresql://localhost:5432/dbname
      username: username
      password: password
      driverClassName: org.postgresql.Driver
    db2:
      jdbcUrl: jdbc:postgresql://localhost:5432/dbname
      username: username
      password: password
      driverClassName: org.postgresql.Driver
    db3:
      dialect: org.hibernate.dialect.MySQLDialect
      jdbcUrl: jdbc:mysql://localhost:5432/dbname
      username: username
      password: password
      driverClassName: com.mysql.jdbc.Driver
</code></pre>
<p>단일 DB 구성에서는 yaml파일을 작성하면 프로젝트 내에서 자동으로 연동되지만, 다중 DB는 이 yaml파일 내의 정보를 이용해서 별도로 Config파일을 구성해주어야 한다.</p>
<p><strong>Db1Config</strong></p>
<pre><code class="language-java">@Configuration
@EnableJpaRepositories(
        basePackages = &quot;com.example.test.domain&quot;,
        entityManagerFactoryRef = &quot;db1EntityManagerFactory&quot;,
        transactionManagerRef = &quot;db1TransactionManager&quot;
)
public class Db1Config {
    @Primary
    @Bean(name = &quot;db1DataSource&quot;)
    @ConfigurationProperties(prefix = &quot;spring.datasource.db1&quot;)
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = &quot;db1EntityManagerFactory&quot;)
    public LocalContainerEntityManagerFactoryBean
    entityManagerFactory(EntityManagerFactoryBuilder builder,
                         @Qualifier(&quot;db1DataSource&quot;) DataSource dataSource) {
        return builder
                .dataSource(dataSource)
                .packages(&quot;com.example.test.domain&quot;)
                .persistenceUnit(&quot;db1&quot;)
                .build();
    }

    @Primary
    @Bean(name = &quot;db1TransactionManager&quot;)
    public PlatformTransactionManager transactionManager(
            @Qualifier(&quot;db1EntityManagerFactory&quot;) EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }
}</code></pre>
<p>@EnableJpaRepositories 내 basePackages에 이 Config가 적용될 패키지 레벨을 설정한다.
그 후 각각 EntityManagerFactory와 TransactionManager 설정을 Bean으로 주입해준 뒤, @EnableJpaRepositories 어노테이션 내에 매핑해준다.</p>
<p>@Primaery 여러 Datasource 중 우선으로 사용할 Config에 붙여준다. 
@Qualifier 여러 Datasource가 Bean으로 주입될 때 충돌되지 않도록 별칭으로 지정해준다.</p>
<p>그렇기 때문에 특정 데이터베이스의 config 파일에 @Primaery 어노테이션을 붙여서 Bean을 생성해주고, 그 외에 데이터베이스 설정 파일에는 @Qualifier 어노테이션 같은 걸로 지칭을 해줘야 한다.</p>
<p>Db2Config, Db3Config도 마찬가지로 구성하면 된다. 단, @Primary 어노테이션은 반드시 주 DB 구성에만 포함시켜야 한다. 그렇지 않을 경우 충돌이 발생해서 아래와 같은 오류가 발생한다.</p>
<blockquote>
<p>Parameter 0 of method entityManagerFactory in com.example.test.global.config.Db1Config required a bean of type &#39;org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder&#39; that could not be found.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Javadoc 에러 cannot read Input length = 1]]></title>
            <link>https://velog.io/@refoli_20/Javadoc-%EC%97%90%EB%9F%AC-cannot-read-Input-length-1</link>
            <guid>https://velog.io/@refoli_20/Javadoc-%EC%97%90%EB%9F%AC-cannot-read-Input-length-1</guid>
            <pubDate>Fri, 08 Dec 2023 08:28:18 GMT</pubDate>
            <description><![CDATA[<p><img src="blob:https://velog.io/4b48c5c4-914c-4676-8996-1836c88c781c" alt="업로드중.."></p>
<pre><code>javadoc: error - cannot read Input length = 1</code></pre><p>IntelliJ의 Generate Javadoc 기능을 통해 문서화를 진행하던 도중 이런 에러가 발생했다.
정말 단순한 문제였다. 파일 경로 중 인코딩이 안되는 문자열이 포함되어 있을 경우 발생한다.
영어, 숫자와 허용되는 &#39;_&#39;등의 특수문자를 제외하고 한글 포함 타 문자들은 모두 변환이 안되는 것 같았다.
해결방법 또한 단순하다. 폴더 명을 모두 영어로 바꾸어주면 해결된다.
나는 사용자 폴더명이 한글로 되어있어 변경하는 데 꽤나 힘들었지만, 그 외엔 간단하게 바꿀 수 있을 것이다.</p>
<p>혹시라도 경로 중 이상한 문자열이 존재하지 않는데 해당 오류가 발생했을 경우, javadoc 실행 시 나타나는 오류문에서 <strong><em>@C:\Users\refoli\AppData\Local\Temp\javadoc_args</em></strong> 와 같은 <strong>javadoc_args의 경로</strong>를 눌러 포함된 파일들을 확인하면 쉽게 찾을 수 있다.</p>
<p>사용자 폴더명 변경 시
<a href="https://extrememanual.net/41523">https://extrememanual.net/41523</a>
이 사이트를 참고하면 비교적 간단하게 바꿀 수 있다.</p>
<hr>

<p><a href="https://stackoverflow.com/questions/53248589/intellij-javadoc-error-cannot-read-input-length-1">https://stackoverflow.com/questions/53248589/intellij-javadoc-error-cannot-read-input-length-1</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Quill Editor 내 이미지 삽입/ 붙여넣기 (base64) 핸들링]]></title>
            <link>https://velog.io/@refoli_20/Quill-Editor-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@refoli_20/Quill-Editor-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 08 Dec 2023 08:11:02 GMT</pubDate>
            <description><![CDATA[<p>웹페이지 내에 에디터를 쉽게 임베딩 할 수 있는 Quill.js는 정말 간편하게 사용할 수 있는 자바스크립트용 툴이다.</p>
<p><a href="https://quilljs.com/docs/quickstart/">https://quilljs.com/docs/quickstart/</a>
공식문서에도 굉장히 깔끔하게 정리가 되어있지만, 사진 업로드 등의 후처리가 필요한 부분이 있어서 간단하게 정리하고자 한다.</p>
<p>어떤 에디터인지 간단하게 체험해보고 싶다면, 공식 사이트에서 playground를 통해 확인할 수 있다.
<img src="https://velog.velcdn.com/images/refoli_20/post/b68a17c0-fb5b-4bee-b50f-98d8554b872f/image.png" alt="">
<a href="https://quilljs.com/playground/">https://quilljs.com/playground/</a></p>
<h2 id="기본-구성">기본 구성</h2>
<pre><code class="language-html">&lt;!-- Include stylesheet --&gt;
&lt;link href=&quot;https://cdn.quilljs.com/1.3.6/quill.snow.css&quot; rel=&quot;stylesheet&quot;&gt;

&lt;!-- Include the Quill library --&gt;
&lt;script src=&quot;https://cdn.quilljs.com/1.3.6/quill.js&quot;&gt;&lt;/script&gt;
</code></pre>
<p>cdn을 통해 css 및 js파일을 import하고, Quill 객체를 생성한다. 이 과정만 거치면 기본적인 에디터가 나타나게 된다.</p>
<p>옵션은 공식문서에서 필요한 부분만 가져오면 된다.</p>
<p><strong>html</strong></p>
<pre><code>      &lt;div id=&quot;editor&quot; style=&quot;height: 400px&quot;&gt;&lt;/div&gt;
</code></pre><p><strong>javascript</strong></p>
<pre><code class="language-javascript">    var toolbarOptions = [
        [&#39;bold&#39;, &#39;italic&#39;, &#39;underline&#39;, &#39;strike&#39;],
        [{ &#39;header&#39;: 1 }, { &#39;header&#39;: 2 }],
        [{ &#39;list&#39;: &#39;ordered&#39;}, { &#39;list&#39;: &#39;bullet&#39; }],

        [{ &#39;color&#39;: [] }, { &#39;background&#39;: [] }],
        [&#39;image&#39;, &#39;link&#39;],

    ];

    function quilljsediterInit(){
        var option = {
            modules: {
                toolbar: toolbarOptions
            },
            theme: &#39;snow&#39;
        };

        quill = new Quill(&#39;#editor&#39;, option);
    }</code></pre>
<hr>

<h2 id="추가-설정">추가 설정</h2>
<ol>
<li>Form을 통해 바로 파라미터로 값을 넘기고 싶어서, 에디터의 내용을 받아올 별도의 textarea (id = taskDetails)를 만들어 주고 text에 변동이 있을 시 바로 반영되도록 코드를 추가했다.</li>
</ol>
<pre><code class="language-html">&lt;label for=&quot;taskDetails&quot;&gt;내용&lt;/label&gt;
&lt;textarea name = &quot;taskDetails&quot; id=&quot;taskDetails&quot; rows=&quot;16&quot; class=&quot;hidden&quot;&gt;&lt;/textarea&gt;
</code></pre>
<pre><code class="language-javascript">        quill.on(&#39;text-change&#39;, function() {
            document.getElementById(&quot;taskDetails&quot;).value = quill.root.innerHTML;
        });</code></pre>
<ol start="2">
<li>이미지를 삽입할 때, 현재 서버에서 이미지를 별도로 저장한 후 해당 경로를 반환하게 구성했다.<pre><code class="language-javascript">     quill.getModule(&#39;toolbar&#39;).addHandler(&#39;image&#39;, function () {
         selectLocalImage();
     });</code></pre>
quilljsediterInit 함수 내부에 이미지에 대한 핸들러를 추가해준다.</li>
</ol>
<pre><code class="language-javascript">function selectLocalImage() {
        const fileInput = document.createElement(&#39;input&#39;);
        fileInput.setAttribute(&#39;type&#39;, &#39;file&#39;);
        fileInput.accept = &quot;image/*&quot;;

        fileInput.click();

        fileInput.addEventListener(&quot;change&quot;, function () {  // change 이벤트로 input 값이 바뀌면 실행

            if ($(this).val() !== &quot;&quot;) { // 파일이 있을때만.

                var ext = $(this).val().split(&quot;.&quot;).pop().toLowerCase();

                if ($.inArray(ext, [&quot;gif&quot;, &quot;jpg&quot;, &quot;jpeg&quot;, &quot;png&quot;, &quot;bmp&quot;]) == -1) {

                    alert(&quot;jpg, jpeg, png, bmp, gif 파일만 업로드 가능합니다.&quot;);
                    return;
                }


                var fileSize = this.files[0].size;

                var maxSize = 20 * 1024 * 1024;

                if (fileSize &gt; maxSize) {

                    alert(&quot;업로드 가능한 최대 이미지 용량은 20MB입니다.&quot;);

                    return;

                }

                const formData = new FormData();
                const file = fileInput.files[0];
                formData.append(&#39;uploadFile&#39;, file);

                $.ajax({
                    type: &#39;post&#39;,
                    enctype: &#39;multipart/form-data&#39;,
                    url: &#39;/file/upload&#39;,
                    data: formData,
                    processData: false,
                    contentType: false,
                    dataType: &#39;text&#39;,
                    success: function (data) {
                        const range = quill.getSelection();
                        quill.insertEmbed(range.index, &#39;image&#39;, &quot;/file/display?fileName=&quot; + data);

                    },
                    error: function (err) {
                        console.log(&#39;ERROR!! ::&#39;);
                        console.log(err);
                    }
                });

            }

        });
    }</code></pre>
<p>이미지 업로드 시 확장자와 이미지 사이즈를 제한하고, FormData형식으로 ajax를 통해 서버에 upload를 요청한다. 이 때 enctype을 <strong>&#39;multipart/form-data&#39;</strong>로 명시하지 않으면 오류가 발생한다. 
upload 성공 시 선택한 index에 해당하는 이미지 파일을 보여줄 수 있도록 한다.</p>
<p>*<em>따라서 구현해야 하는 api는 두 개가 있다.
*</em></p>
<ol>
<li>이미지를 업로드하는 api</li>
<li>이미지를 보여주는 api</li>
</ol>
<br>
<hr>

<h3 id="api-구성">API 구성</h3>
<pre><code class="language-java">@RestController
@Slf4j
@RequestMapping(&quot;/file&quot;)
public class FileRestController {

    /**
     * 에디터 내 사진 파일 업로드
     * @param uploadFile
     * @return savePath - 저장경로
     */
    @RequestMapping(value = &quot;/upload&quot;, method = RequestMethod.POST)
    public String uploadTestPOST(MultipartFile[] uploadFile) {

        String savePath;

        // OS 따라 구분자 분리
        String os = System.getProperty(&quot;os.name&quot;).toLowerCase();
        if (os.contains(&quot;win&quot;)){
            savePath = System.getProperty(&quot;user.dir&quot;) + &quot;\\files\\image&quot;;
        }
        else{
            savePath = System.getProperty(&quot;user.dir&quot;) + &quot;/files/image&quot;;
        }

        java.io.File uploadPath = new java.io.File(savePath);

        // 파일 저장 경로가 없으면 신규 생성
        if (!uploadPath.exists()) {
            uploadPath.mkdirs();
        }

        for (MultipartFile multipartFile : uploadFile) {

            String uploadFileName = multipartFile.getOriginalFilename();

            String uuid = UUID.randomUUID().toString();

            // 파일명 저장
            uploadFileName = uuid + &quot;_&quot; + uploadFileName;

            java.io.File saveFile = new java.io.File(uploadPath, uploadFileName);

            try {
                multipartFile.transferTo(saveFile);
                return uploadFileName;
            } catch (Exception e) {
                throw new CustomException(ErrorCode.BAD_REQUEST);
            }
        }
        return savePath;
    }

    /**
     * 에디터 내 사진 파일 첨부
     * @param fileName
     * @return
     */
    @ResponseBody
    @GetMapping(value = &quot;/display&quot;)
    public ResponseEntity&lt;byte[]&gt; showImageGET(
            @RequestParam(&quot;fileName&quot;) String fileName
    ) {

        String savePath;

        // OS 따라 구분자 분리
        String os = System.getProperty(&quot;os.name&quot;).toLowerCase();
        if (os.contains(&quot;win&quot;)){
            savePath = System.getProperty(&quot;user.dir&quot;) + &quot;\\files\\image\\&quot;;
        }
        else{
            savePath = System.getProperty(&quot;user.dir&quot;) + &quot;/files/image/&quot;;
        }

        // 설정한 경로로 파일 다운로드
        java.io.File file = new java.io.File(savePath + fileName);

        ResponseEntity&lt;byte[]&gt; result = null;

        try {

            HttpHeaders header = new HttpHeaders();
            header.add(&quot;Content-type&quot;, Files.probeContentType(file.toPath()));

            result = new ResponseEntity&lt;&gt;(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);

        } catch (NoSuchFileException e){
            log.error(&quot;No Such FileException {}&quot;, e.getFile());
        } catch (IOException e) {
            log.error(e.getMessage());
        }

        return result;
    }</code></pre>
<p>여기까지 한다면 에디터 내에서의 기본적인 이미지 삽입은 구현이 되었다.
하지만 문제가 하나 더 있었는데, 바로 이미지/스크린샷을 에디터 내에 복사, 붙여넣기 하는 경우였다.</p>
<p>기본적으로 Quill 에디터에서 base64 형식으로 이미지를 넣게 되는데, 이 경우 <strong>data:image/png;base64 라는 형태의 매우 긴 문자열</strong>이 들어가게 된다. 
<img src="https://velog.velcdn.com/images/refoli_20/post/99ffd260-89d9-46b7-9f3c-c2482621202f/image.png" alt="">
_이런 문자열이 끝도 없이 보이는 공포스러운 상황이 발생한다...
_
당장 오류가 발생하는 부분은 아니지만, 이렇게 들어간 img 태그는 매우 긴, 심하면 몇 천자 정도의 길이를 가진 문자열이기 때문에 http 요청 시 부하가 심하다. 
tomcat 등 서버의 max-http-form-post-size 를 낮게 잡아놨다면 요청 사이즈를 넘었다고 오류가 발생하는 경우를 볼 수 있을 것이다.</p>
<p><strong>따라서 나는 form을 제출 시에 base64 형식의 img 태그를 모두 로컬의 이미지 경로로 바꾸어 주었다.</strong></p>
<br>
<hr>

<h3 id="base64-이미지-변환">Base64 이미지 변환</h3>
<p>제출 시 validate하는 코드 내에 이 부분을 추가하였다.</p>
<pre><code class="language-java">            const imgTags = document.querySelectorAll(&#39;img&#39;);

            const ajaxRequests = [];

            imgTags.forEach(function(img) {
                var currentSrc = img.getAttribute(&#39;src&#39;);

                // 이미지가 base64로 인코딩되어 있는지 확인
                if (currentSrc.startsWith(&#39;data:image&#39;)) {

                    // base64 데이터 추출
                    const splitDataURI = currentSrc.split(&#39;,&#39;)

                    if (splitDataURI[0].indexOf(&#39;base64&#39;) &gt;= 0){
                        const ajaxRequest = $.ajax({
                            type: &#39;post&#39;,
                            enctype: &#39;multipart/form-data&#39;,
                            url: &#39;/file/uploadBase64&#39;,
                            data: splitDataURI[1],
                            processData: false,
                            contentType: false,
                            dataType: &#39;text&#39;,
                            async: false,
                            success: function (data) {
                                img.setAttribute(&#39;src&#39;, &quot;/file/display?fileName=&quot; + data);
                            },
                            error: function (err) {
                                console.log(&#39;ERROR!! ::&#39;);
                                console.log(err);
                            }
                        });

                        ajaxRequests.push(ajaxRequest);

                    }
                }
            });

            if (ajaxRequests.length===0){
                return true;
            }
            // 모든 AJAX 요청이 완료된 후에 폼 제출
            $.when.apply($, ajaxRequests).done(function () {
                return true;
            }).fail(function () {
                return false;
            });</code></pre>
<p>base64 형식의 이미지 태그만 찾은 뒤, 위에 일반적인 이미지 삽입과 비슷한 코드이다. 
다만 base64 이미지를 변환 후 로컬에 저장하기 위한 별도의 api를 작성해주어야 한다.
이전에 작성한 <strong>FileRestController</strong> 클래스에 아래 메소드를 추가했다.</p>
<pre><code class="language-java">@RequestMapping(value = &quot;/uploadBase64&quot;, method = RequestMethod.POST)
    public String handleBase64Upload(@RequestBody String base64Image) {
        try {
            int maxLength = 20;

            String filename = truncateAndAppendTimestamp(base64Image, maxLength) + &quot;.png&quot;;
            String savePath;
            String filePath;

            String os = System.getProperty(&quot;os.name&quot;).toLowerCase();
            if (os.contains(&quot;win&quot;)){
                savePath = System.getProperty(&quot;user.dir&quot;) + &quot;\\files\\image&quot;;
                filePath = savePath + &quot;\\&quot; + filename;
            }
            else{
                savePath = System.getProperty(&quot;user.dir&quot;) + &quot;/files/image&quot;;
                filePath = savePath + &quot;/&quot; + filename;
            }

            if (!new java.io.File(savePath).exists()) {
                try{
                    new java.io.File(savePath).mkdir();
                }
                catch(Exception e){
                    e.getStackTrace();
                }
            }

            java.io.File file = new File(filePath);

            // BASE64를 일반 파일로 변환하고 저장합니다.
            java.util.Base64.Decoder decoder = Base64.getMimeDecoder();
            byte[] decodedBytes = decoder.decode(base64Image.getBytes());
            FileOutputStream fileOutputStream = new FileOutputStream(file);
            fileOutputStream.write(decodedBytes);
            fileOutputStream.close();

            return filename;
        } catch (IOException e) {
            log.error(e.getMessage());

            return &quot;File upload failed.&quot;;
        }
    }

    public static String truncateAndAppendTimestamp(String base64Image, int maxLength) {
        // 제거할 특수문자 정규식
        String specialCharactersRegex = &quot;[^a-zA-Z0-9]&quot;;

        String truncatedBase64Image = base64Image.length() &gt; maxLength
                ? base64Image.substring(base64Image.length() - maxLength)
                : base64Image;

        // 특수문자를 제거하고 timestamp 생성
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern(&quot;yyyyMMddHHmmssSSS&quot;));
        timestamp = timestamp.replaceAll(specialCharactersRegex, &quot;&quot;);

        // 특수문자를 제거한 timestamp를 포함하여 결과 문자열 생성
        return new StringJoiner(&quot;_&quot;)
                .add(truncatedBase64Image.replaceAll(specialCharactersRegex, &quot;&quot;))
                .add(timestamp)
                .toString();
    }</code></pre>
<p>BASE64를 일반 파일로 변환하고 저장한다. 이때 저장할 파일명은 생성날짜와 함께 기존의 base64 문자열의 뒷 20자리를 사용했는데, 이 때 특수문자가 있으면 오류가 발생하므로 제외해주었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[OAuth2 authorization_request_not_found 오류]]></title>
            <link>https://velog.io/@refoli_20/OAuth2-authorizationrequestnotfound-%EC%98%A4%EB%A5%98</link>
            <guid>https://velog.io/@refoli_20/OAuth2-authorizationrequestnotfound-%EC%98%A4%EB%A5%98</guid>
            <pubDate>Sun, 19 Nov 2023 02:13:33 GMT</pubDate>
            <description><![CDATA[<p>Local과 Http환경에선 잘 돌아가던 Kakao OAuth2가
로드밸런서를 통해 HTTPS로 빌드했더니 <strong>authorization_request_not_found</strong> 오류가 나타났다.</p>
<p>로드 밸런서 뒤에 여러 인스턴스를 배포할 때 세션이 공유되지 않기 때문에 오류가 발생한다고 나와있다. 로드밸런서를 사용한 https 빌드에서만 이러는 현상을 보아하니 세션과 관련된 오류인 것 같았다.</p>
<hr>


<h3 id="현재-login-flow-정리-spring-security-oauth2">현재 Login Flow 정리 (Spring Security, OAuth2)</h3>
<ol>
<li><p><strong>base-uri : 클라이언트에서 요청 시 카카오 로그인 화면으로 연결되는 주소</strong>
https://{{api-server}}/oauth2/authorization/kakao</p>
<ul>
<li>지정된 scope와 auth code, state, redirect-url을 파라미터로 가진 로그인 화면 리다이렉트</li>
</ul>
</li>
<li><p><strong>redirect-url : 카카오 로그인 후 최종 인증까지 마쳤을 때 우리 서버로 돌아와야 하는 주소</strong>
https://{{api-server}}/login/oauth2/code/kakao</p>
<ul>
<li>Oauth2LoginAuthenticationFilter → 카카오에서의 콜백 결과가 성공이고 사용자 인증코드 (authorization code)도 포함하고 있다면 Spring Security는 access_token 에 대한 authroization code를 교환하고, customOAuth2UserService 를 호출한다 (Security Config에 정의함)</li>
</ul>
</li>
<li><p> a. <strong>success-redirect-url : 카카오 서버로부터 받아온 인증 정보로 JWT 토큰을 발급한 뒤, 발급에 성공했으면 OAuth2AuthenticationSuccessHandler 로 이동</strong>
 https://{{api-server}}/oauth2/redirect?accessToken={{accessToken}}&amp;refreshToken={{refreshToken}}
 b. <strong>fail-redirect-url : 발급에 실패했으면 &#39;error&#39; query param에 에러 내용을인코딩해 담아서 OAuth2AuthenticationFailureHandler로 이동</strong>
 https://{{api-server}}/oauth2/redirect?error={{encodedErrorMessage}}</p>
</li>
</ol>
<hr>

<h3 id="문제상황">문제상황</h3>
<ul>
<li>base-uri (1)와 redirect-url (2) 로 연결되는 서버의 세션이 동일해야 인증 정보를 가져와 JWT 토큰을 발급할 수 있으나, 스케일 아웃의 로드밸런서로 분산된 세션으로 인해 리다이렉트 후 인증 정보를 찾을 수 없었다. base-uri (1)를 통해 연결된 로그인에서 사용자의 인증정보가 저장되지만, 인증이 완료된 다음 리다이렉트 되는 redirect-url (2)가 로드밸런서를 통해 다른 서버로 전송되며 기존에 저장했던 세션 인증 정보를 찾을 수 없다.</li>
</ul>
<br>
<hr>

<h3 id="변경사항">변경사항</h3>
<p>*<em>- 로드밸런서 &gt; 대상그룹 &gt; 속성 편집 &gt; 고정 켜기
*</em>- 해당 설정을 줌으로써, 모든 요청에 대해 로드밸런서 생성 쿠키에 매칭되는 세션으로 보내줄 수 있음</p>
<ul>
<li>Sticky Session은 <strong>세션 불일치 문제</strong>를 해결할 수 있으나, <strong>특정 세션의 요청을 최초 처리한 서버로만 전송함</strong>으로 인해 서버 과부하가 발생할 수 있으며, 특정 서버 장애 발생 시 연결되어 있는 세션이 모두 소실될 수 있음</li>
<li>대안으로 Session Clustering을 고려할 수 있음</li>
</ul>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/cc9f4fd3-a4e2-4a92-81f5-fa6535095505/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Apache Superset Embedding - URL Parameter 설정]]></title>
            <link>https://velog.io/@refoli_20/Apache-Superset-Embedding-supersetconfig.py-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@refoli_20/Apache-Superset-Embedding-supersetconfig.py-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Sun, 20 Aug 2023 13:57:44 GMT</pubDate>
            <description><![CDATA[<p>Env : Superset 2.0.1</p>
<p>현재 데이터 시각화를 위해 슈퍼셋을 docker에 빌드해 둔 상태다.
단순히 DB에 있는 데이터들을 시각화하지 않고, 내 어플리케이션에 임베딩한 뒤에 조건문에 사용될 값들을 url parameter로 넘겨주어 결과를 웹에서 iframe 내에 띄우려고 한다.</p>
<p>우선 대쉬보드를 공유할 때 url parameter를 받아와서 조건문으로 주기 위해, 설정을 바꾸어주어야 했다.
이는 Jinja template를 기본적으로 사용하고 있기 때문에, 아래와 같은 코드를 설정파일에 추가해주어야 한다.</p>
<p>공식문서를 읽어보면 아래와 같다.</p>
<p><a href="https://superset.apache.org/docs/installation/sql-templating/">https://superset.apache.org/docs/installation/sql-templating/</a>
<img src="https://velog.velcdn.com/images/refoli_20/post/8e1369a3-83a1-4043-9ed9-b343f1cd965e/image.png" alt=""></p>
<ol>
<li><p>기존의 config.py가 import할 새로운 설정파일로 superset_config.py를 생성한다.</p>
</li>
<li><p>ENABLE_TEMPLATE_PROCESSING을 활성화한다.</p>
<pre><code>&quot;ENABLE_TEMPLATE_PROCESSING&quot;: True</code></pre></li>
<li><p>python 또는 Security_config에 대한 path를 설정해주기만 하면 된다.
<code>export SUPERSET_CONFIG_PATH=/superset_config.py</code></p>
<p>반영이 잘 되지 않았을 경우 아래와 같은 커맨드와 docker container를 재실행하는 등의 방식을 사용해본다.</p>
<p><code>superset db upgrade</code></p>
<p><code>superset init</code></p>
</li>
</ol>
<p>이렇게 환경변수도 넣고 재실행도 마쳤으나 여전히 Jinja template을 사용하지 못하는 경우가 있다. 그런 경우 superset_config.py를 직접 생성하기보다 config.py를 수정하는 방식도 가능하다.</p>
<p>제대로 반영되었는지 확인하는 방법은 아래와 같다.</p>
<ul>
<li>select &#39;{{ current_user_id() }}&#39; 이 user_id를 성공적으로 반환한다.</li>
<li>SQL lab를 이용할 때 Parameters라는 버튼이 보인다.
<img src="https://velog.velcdn.com/images/refoli_20/post/617aeb08-f1f2-4902-a88e-88a45bbfd0d7/image.png" alt="">
이런식으로 뜬다면 실패한 것이다. {{ current_user_id()}} 가 그대로 출력되는 것이 아닌, id 값이 나와야 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] nginx + tomcat 연동 (서버 중단 문제)]]></title>
            <link>https://velog.io/@refoli_20/Linux-nginx-tomcat-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@refoli_20/Linux-nginx-tomcat-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Fri, 02 Jun 2023 08:13:46 GMT</pubDate>
            <description><![CDATA[<p>EC2 인스턴스 내에서 Nginx와 Tomcat를 연동해서, Nginx는 웹 서버로서 정적인 콘텐츠를 처리하고, Tomcat은 Java 기반의 동적인 웹 애플리케이션을 처리하도록 합니다.</p>
<p>jdk / openjdk version &quot;17.0.7&quot; 2023-04-18 LTS
Nginx / 1.24.0
Apache Tomcat / 10.1.9</p>
<p>Linux/UNIX 환경의 EC2 인스턴스에서 테스트했습니다.</p>
<p><a href="https://sangchul.kr/515">https://sangchul.kr/515</a></p>
<p>해당 블로그에 나와있는 커맨드 대부분을 사용하였지만, Tomcat 버전은 10.1.9버전을 사용하기 위해 tomcat을 다운받는 과정에서</p>
<pre><code class="language-shell">wget -q https://downloads.apache.org/tomcat/tomcat-10/v10.1.9/bin/apache-tomcat-10.1.9.tar.gz
tar xfz apache-tomcat-10.1.9.tar.gz -C /apps/tomcat --strip-components=1</code></pre>
<p>이렇게 수정해서 사용했습니다.</p>
<p>또한 /etc/systemd/system/tomcat.service 파일을 작성할 때, CATALINA_OPTS 환경변수를 수정해주었습니다. 
CATALINA_OPTS는 톰캣 서버 실행 시 JVM(Java Virtual Machine)에게 전달되는 옵션을 설정하는 변수로, </p>
<pre><code class="language-shell">Environment=&quot;CATALINA_OPTS=-Xms256M -Xmx512M -server -XX:+UseParallelGC&quot;</code></pre>
<p>이렇게 바꾸어 작성했습니다. 이는 힙 영역의 크기를 조정하는 부분인데, 너무 크게 가져가게 된다면 서버에 부하가 가기 때문에 본인 인스턴스 환경을 충분히 고려한 후에 설정해주셔야 합니다.
저는 t2.micro를 사용하여 CPU 및 메모리 측면에서 상대적으로 제한이 있었기 때문에 (1 GiB 메모리..) 이렇게 변경하여 사용했습니다.</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/d563b812-1e1f-4405-a6d3-e1c0e3aaed19/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ 로그 모니터링 환경 구축 : Elastic stack with Docker ]]></title>
            <link>https://velog.io/@refoli_20/Elastic-stack-with-Docker-Filebeats-1</link>
            <guid>https://velog.io/@refoli_20/Elastic-stack-with-Docker-Filebeats-1</guid>
            <pubDate>Sun, 28 May 2023 17:16:24 GMT</pubDate>
            <description><![CDATA[<p>현재 진행중인 프로젝트의 로그를 어떻게 모니터링할지 고민하다가, 이전부터 사용하고 싶었던 ELK 스택을 적용해보기로 했다.</p>
<p>ELK를 하나씩 세팅하기에는 상당히 오랜 시간이 걸릴 듯 하여, Docker &amp; Docker Compose를 사용해서 쉽게 구성할 수 있는 컨테이너가 제공되어있기에 클론받아왔다.</p>
<p>docker-elk 컨테이너를 제공하는 레포는 다양하지만, 그 중 가장 유명한 아래 레포지토리를 사용했다.
<a href="https://github.com/deviantony/docker-elk.git">https://github.com/deviantony/docker-elk.git</a></p>
<p>버저닝으로 인해 버전마다 설정 파일이 꽤나 다르며, 버전으로 인한 에러 상황도 잦았기에 나는 현재 버전에서 다운그레이드 하여 7. * 버전 사용했다. </p>
<pre><code class="language-shell">$ git clone -b release-7.x https://github.com/deviantony/docker-elk.git</code></pre>
<p>셋팅 방법은 비교적 간단하다. 적용할 비밀번호 정도를 설정해주면 된다.</p>
<pre><code class="language-shell">cd docker-elk</code></pre>
<p>이렇게 도커 폴더에 들어가게 되면 다음과 같은 폴더 및 파일들이 존재한다.</p>
<p>LICENSE  README.md  docker-compose.yml  elasticsearch  extensions  kibana  logstash  setup
<br/>
<br/>
파일을 하나씩 설정해보자.
<br/></p>
<p><strong>1. docker-compose.yml</strong></p>
<pre><code class="language-shell">~/docker-elk$ vi docker-compose.yml</code></pre>
<pre><code class="language-yml">setup:
    environment:
      ELASTIC_PASSWORD:</code></pre>
<p>이부분을 원하는 비밀번호로 바꾸어주면 된다.</p>
<br/>

<p><strong>2. elasticsearch.yml</strong></p>
<pre><code class="language-shell">~/docker-elk$ vi elasticsearch/config/elasticsearch.yml</code></pre>
<pre><code class="language-yml">xpack.security.authc.api_key.enabled: true
xpack.security.enabled: true</code></pre>
<p>xpack.security.enabled는 기본적으로 false로 되어있으며, true로 지정해줘야 xpack에서 제공하는 기본적인 보안 기능을 적용할 수 있다. 적용하지 않는다면 Elasticsearch에 연결하는 모든 클라이언트가 사용자 인증을 거치지 않고도 클러스터에 접근할 수 있게 되며 심각한 보안 위협을 초래할 수 있다.</p>
<br/>

<p><strong>3. kibana.yml</strong></p>
<pre><code class="language-shell">~/docker-elk$ vi kibana/config/kibana.yml</code></pre>
<pre><code class="language-yml">elasticsearch.username: elastic
elasticsearch.password: password</code></pre>
<p>kibana.yml에서도 마찬가지로 elasticsearch와 연동하기 위한 아이디 및 비밀번호를 설정해주면 된다. username의 경우 default는 elastic으로 되어있다.
<br/></p>
<p><strong>4. logstash.yml</strong></p>
<pre><code class="language-shell">~/docker-elk$ vi logstash/config/logstash.yml</code></pre>
<pre><code class="language-yml">xpack.monitoring.enabled: true
xpack.monitoring.elasticsearch.username: elastic
xpack.monitoring.elasticsearch.password: password</code></pre>
<p> Logstash에서 x-pack 모니터링 기능을 활성화 할 수 있다. 
<br/></p>
<p><strong>5. logstash.conf</strong></p>
<pre><code class="language-shell">~/docker-elk$ vi logstash/pipeline/logstash.conf</code></pre>
<p>logstash를 통해 로그가 들어오고, 필터링을 거쳐 elasticsearch로 보내지는 파이프라인의 구성파일이다.</p>
<p>filter {} 부분에는 로그를 특별히 파싱하거나 필터링 하는 패턴을 입력하면 된다. 보통 message라는 필드 안에 찍혔던 로그가 그대로 들어가기 때문에, json이나 특정 형태로 필터링하고 싶을 때 이 기능을 사용한다.
특정 패턴을 가진 로그를 파싱하기 위해서 grok을 자주 사용하고는 한다. elasticsearch에서는 패턴을 통한 output을 미리보기 할 수 있도록 하는 기능을 제공한다.
<img src="https://velog.velcdn.com/images/refoli_20/post/bb339461-d538-45ba-8c09-6de756c6e02e/image.png" alt=""></p>
<p>output에는 설정한 elastic의 아이디 및 비밀번호를 입력해주면 된다.</p>
<pre><code class="language-conf">output {
        elasticsearch {
                hosts =&gt; &quot;elasticsearch:9200&quot;
                user =&gt; &quot;elastic&quot;
                password =&gt; &quot;yourpassword&quot;
        }
}</code></pre>
<br/>
이상으로 간단한 설정이 마무리되었다. 이후 Filebeats를 사용해서 logfile의 변화를 감지하여, 변경사항을 logstash로 보내면 로그 모니터링 환경이 완성된다. ]]></description>
        </item>
        <item>
            <title><![CDATA[Rest(ful) API란? ]]></title>
            <link>https://velog.io/@refoli_20/Restful-API%EB%9E%80</link>
            <guid>https://velog.io/@refoli_20/Restful-API%EB%9E%80</guid>
            <pubDate>Wed, 24 May 2023 06:55:27 GMT</pubDate>
            <description><![CDATA[<p><strong>Roy가 발표한 Rest API</strong>는 요즘 시대에서는 잘 지켜지고 있진 않은 것 같다.
Rest 아키텍처를 진짜로 준수한 API는 <strong>Restful API</strong>라고 통칭하는 것 같은데, 잘 모르는 상태에서 두 개념을 모두 접한 사람들은 상당히 헷갈릴 수도 있다고 생각이 든다. 
여기서는 Rest 아키텍처를 엄격하게 지킨 형식을 설명하겠다.
<br/></p>
<p><em>시작하기에 앞서 Rest API는 반드시 지켜야 할 규격이 아니고, 상황에 따라 유연하게 사용하는 것을 추천하는 권고사항에 불가할 뿐이란 것을 말하고 싶다.</em>
<br/></p>
<p><strong>Rest(ful) API에서 지켜야 할 표준 룰은 다음과 같다.</strong></p>
<ul>
<li>*<em>client-server : *</em>클라이언트와 서버는 반드시 분리되어야 함</li>
<li>*<em>stateless : *</em>상태를 저장하지 않음</li>
<li>*<em>cacheable : *</em>캐싱 기능을 적용할 수 있어야 함</li>
<li><strong>uniform interface :</strong> 플랫폼이나 언어에 따른 제약이 없음</li>
<li><strong>layered system :</strong> 서버는 보안/로드 밸런싱/암호화 등 다중 계층으로 구성함</li>
<li><strong>code-on-demand (Optional) :</strong> client에 보내는 데이터를 바로 실행 가능한 코드 작성</li>
</ul>
<p>이 중 client-server, stateless, cacheable, layered system는 비교적 잘 지켜지는 편이지만, <strong>uniform interface</strong>와 <strong>code-on-demand</strong>는 보편적으로 지켜지지 않는 편에 속한다. 이 두가지만 자세히 살펴보자.</p>
<br/>


<p><strong>uniform interface</strong>
Resource에 대한 요청을 통일되고, 한정적으로 수행하는 아키텍처 스타일을 의미한다. 이것은 요청을 하는 Client가 플랫폼(Android, Ios, Jsp 등) 에 무관하며, 특정 언어나 기술에 종속받지 않는 특징을 의미한다.uniform interface를 위한 조건은 다음과 같다.</p>
<ul>
<li>identification of resources : Resource가 uri로 식별되어야 함</li>
<li>self-descriptive message : 스스로 잘 표현해야함</li>
<li>manipulation of resources through representation : representation 전송을 통해서 리소스를 조작해야함</li>
<li>Hypermedia As The Engine Of Application State(HATEOAS) : 애플리케이션 상태는 하이퍼링크를 통해 전이되어야함
실제로는 HATEOAS를 엄격하게 지키지 않고, Post요청 후 상태를 확인하는 용도의 Get 요청 uri 정도를 리턴해준다고 한다. 요청 관련 모든 uri를 넣어서 주게 된다면 유지 보수성도 현저히 떨어지며, API 명세가 체계적으로 이뤄진다면 굳이 추가하지 않아도 된다.</li>
</ul>
<p><strong>code-on-demand</strong>
클라이언트에서 실행 가능한 code를 제공하며, 이는 주로 Javascript에서 가능하다. 최근에는 현직에서 잘 사용하지 않는 규칙이라고 한다.</p>
<br/>
<br/>

<h4 id="출처">출처</h4>
<p><a href="https://www.youtube.com/watch?v=RP_f5dMoHFc&amp;ab_channel=naverd2">https://www.youtube.com/watch?v=RP_f5dMoHFc&amp;ab_channel=naverd2</a>
<a href="https://dingue.tistory.com/11">https://dingue.tistory.com/11</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security - BCryptPasswordEncoder 길이 제한]]></title>
            <link>https://velog.io/@refoli_20/Spring-Security-BCryptPasswordEncoder-%EA%B8%B8%EC%9D%B4-%EC%A0%9C%ED%95%9C</link>
            <guid>https://velog.io/@refoli_20/Spring-Security-BCryptPasswordEncoder-%EA%B8%B8%EC%9D%B4-%EC%A0%9C%ED%95%9C</guid>
            <pubDate>Wed, 10 May 2023 08:43:16 GMT</pubDate>
            <description><![CDATA[<p>스프링 시큐리티에서는 다양한 패스워드 암호화 방식을 사용할 수 있는데,
그 중 가장 대중적으로 사용되는 BCryptPasswordEncoder 라이브러리를 적용하려고 했다.
하지만 테스트를 하던 중, 패스워드 암호화 과정에서 문제가 있는 것을 발견했다.</p>
<p>우리 서비스에서 비밀번호는 회원의 고유키를 통해 자동으로 구성되는데,그 고유키는 80자 이상의 긴 값을 저장한다. 
암호화 방식을 적용하는 과정이 잘못된 부분이 있었던 것은 아니다.</p>
<p><a href="https://security.stackexchange.com/questions/39849/does-bcrypt-have-a-maximum-password-length">https://security.stackexchange.com/questions/39849/does-bcrypt-have-a-maximum-password-length</a>
여기서 같은 사례를 확인할 수 있었다.</p>
<p><strong><em>the key argument is a secret encryption key, which can be a user-chosen password of up to 56 bytes (including a terminating zero byte when the key is an ASCII string).</em></strong></p>
<p>BCryptPasswordEncoder는 최대 56 bytes까지만 암호화를 시킬 수 있다고 나와있다. 따라서 비밀번호는 55자까지로 제한할 수 있다고 나와있지만, 최대 암호화 가능 길이가 얼마인지에 대한 의견은 아직도 갈리는 것 같다. </p>
<p>길이가 긴 패스워드를 암호화하는 케이스의 경우 다른 라이브러리를 사용하는 걸 추천한다. 나는 Argon2PasswordEncoder로 대체했다. </p>
<p>BCryptPasswordEncoder를 좀 더 자세히 찾아보고 적용했으면 이런 결함이 생기지는 않았을 것 같은데, 다음 구현에서는 더 주의해야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security - 사용자 인증 중 적절하지 않은 BadCredentialsException 오류]]></title>
            <link>https://velog.io/@refoli_20/Spring-Security-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9D%B8%EC%A6%9D-%EC%A4%91-%EC%A0%81%EC%A0%88%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%80-BadCredentialsException-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@refoli_20/Spring-Security-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9D%B8%EC%A6%9D-%EC%A4%91-%EC%A0%81%EC%A0%88%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%80-BadCredentialsException-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Sun, 30 Apr 2023 00:47:16 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-java">  public TokenResponse login(String phone, String password) throws CustomException {
    try {
      UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(phone, password);
      Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
      SecurityContextHolder.getContext().setAuthentication(authentication);
      return tokenProvider.createFrom(authentication);
    } catch (UsernameNotFoundException e) {
      throw new CustomException(MEMBER_NOT_FOUND, &quot;User not found with phone number: &quot; + phone);
    } catch (BadCredentialsException e) {
    throw new CustomException(INVALID_AUTHORITY, &quot;Invalid password&quot;);
  }
  }</code></pre>
<p>위와 같이 사용자 인증 시 계정이 존재하지 않으면 UsernameNotFoundException, 단순 비밀번호 오류면 BadCredentialsException e가 뜨게 해놨다.</p>
<p>하지만, 테스트코드를 돌려본 결과 아이디가 없어도, 비밀번호가 틀려도 항상 BadCredentialsException만 떴다.</p>
<p>문제 해결을 위해 사용자의 인증 정보를 확인하는 loadUserByUsername 메소드를 살펴보니,해당 휴대폰 번호로 검색했을 때 결과가 없으면 UsernameNotFoundException을 던지는 부분이 잘 구현되어있다.</p>
<pre><code class="language-java">  @Override
  public UserDetails loadUserByUsername(String phone_number) throws UsernameNotFoundException {
    // 휴대폰번호(phone_number)을 받아 해당 사용자 정보를 데이터베이스에서 찾아 인증 정보를 제공하는 역할

    User user = userRepository.findOneWithAuthoritiesByPhone(phone_number)
        .orElseThrow(
            () -&gt; new UsernameNotFoundException(String.format(&quot;&#39;%s&#39; not found&quot;, phone_number)));</code></pre>
<p>문제는 UserDetailsService를 implements한 클래스가 아니라, 스프링 시큐리티의 기본 동작 구조 자체 있었다.</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/e114b3c2-1011-414c-b5f4-07160b655c4e/image.png" alt=""></p>
<p>AuthenticationManager는 가장 먼저 인증 처리가 가능한 AuthenticationProvider를 찾은 후 해당 인증을 처리할 수 있는 객체인 AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드를 실행시킨다. 여기서 retrieveUser 메서드가 실행되는데, UsernameNotFoundException이 뜨게 되면 BadCredentialsException을 던졌던 것이다.</p>
<br/>

<p>서비스단이나 UserDetailsService를 implements했을 때의 문제가 아니라, 구조상 당연한 결과였던 것이다. (이럴 거면 왜 구분해서 설명해놨는지 모르겠지만..)
찾아보니 보안을 위해서 아이디가 틀린 건지 비밀번호가 틀린 건지 알려주지 않기 위해, 두 상황에서 모두 BadCredentialsException을 발생시킨다고 한다. 두 에러 상황을 구분하기 위해서는 Custom class를 만들거나 config 파일을 수정해야 한다. </p>
<br/>
<br/>


<p><strong>출처</strong></p>
<p><a href="https://wildeveloperetrain.tistory.com/56">https://wildeveloperetrain.tistory.com/56</a>
<a href="https://theheydaze.tistory.com/307">https://theheydaze.tistory.com/307</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security - permitAll() Filter 호출 에러]]></title>
            <link>https://velog.io/@refoli_20/Spring-Security-permitAll-Filter-%ED%98%B8%EC%B6%9C</link>
            <guid>https://velog.io/@refoli_20/Spring-Security-permitAll-Filter-%ED%98%B8%EC%B6%9C</guid>
            <pubDate>Tue, 25 Apr 2023 05:27:56 GMT</pubDate>
            <description><![CDATA[<p>Spring Security의 Configure method를 아래와 같이 작성했으나, 
.antMatchers(&quot;/api/login&quot;, &quot;/api/signup&quot;, &quot;/resources/**&quot;).permitAll() 설정을 했음에도 불구하고 로그인을 시도할때마다 계속 JwtFilter를 타고 있었다.</p>
<pre><code class="language-java">  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
        .csrf().disable()
        .exceptionHandling()
        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
        .accessDeniedHandler(jwtAccessDeniedHandler) 
        .and()
        .authorizeRequests() // 인증이 필요한 URL 패턴을 설정
        .antMatchers(&quot;/api/login&quot;, &quot;/api/signup&quot;, &quot;/resources/**&quot;).permitAll() // 모두 접근 가능
        .anyRequest().authenticated() // 나머지 URL은 모두 인증이 필요
        .and()
        .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
         //  JWT를 사용하여 인증하는 필터 추가
  }</code></pre>
<p>Security 설정에서 특정 url이 필터를 타지 않게 하려면 이 방법을 사용한다고 했지만, 이 코드는 특정 url이 필터를 타지 않도록 하는 게 아니었다. 검색해보니 이 부분을 헷갈리시는 분이 많은 것 같당. 저 포함 ㅎㅎ</p>
<p><a href="https://stackoverflow.com/questions/46068433/spring-security-with-filters-permitall-not-working">https://stackoverflow.com/questions/46068433/spring-security-with-filters-permitall-not-working</a></p>
<p>이 글에서 확인할 수 있는 것처럼, permitAll()은 해당 요청에 대한 접근을 모든 사용자에게 허용하는 역할을 하지만, 요청 처리 과정에서 적용되는 모든 필터들을 무시하지는 않는다.
<strong>그저 해당 요청에 대한 인증 정보가 없더라도 (모든 필터를 처리한 후에도 SecurityContext에 인증 정보가 없더라도), 접근이 허용된다는 것을 의미한다.</strong>
따라서 filter 구성 중 특정 조건에서 Exception을 던지는 부분이 있다면, permitALl()을 한 것과는 상관 없이 에러가 잡히게 된다.</p>
<p>나와 같은 경우에는 </p>
<pre><code class="language-java">String jwt = resolveToken(request);</code></pre>
<pre><code class="language-java">  private String resolveToken(HttpServletRequest request) {
    String token = request.getHeader(AUTHORIZATION);
    if (StringUtils.hasText(token) &amp;&amp; token.startsWith(BEARER_PREFIX)) {
      return token.substring(7);
    }
    throw new RuntimeException();
  }
</code></pre>
<p>이 부분에서 RuntimeException()을 던지기 때문에 여기서 걸릴 수 밖에 없던 것이다.
보통 JWT과 같은 인증 filter단에서는 직접 에러를 던지지않으며, 401 또는 403과 같은 인증 및 권한 관련 에러는 </p>
<pre><code>            .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 인증 실패시 호출될 Entry Point 설정
            .accessDeniedHandler(jwtAccessDeniedHandler) // 접근 권한이 없는 경우 호출될 Access Denied Handler 설정</code></pre><p>이렇게 직접 에러 처리를 추가해줌으로써 가능해진다.</p>
<br/>



<p>필터를 아예 거치지 않고 호출하지 않기 위해서는,</p>
<pre><code class="language-java">@Override
public void configure(WebSecurity web) throws Exception {
    web
        .ignoring()
        .antMatchers(&quot;/api/login&quot;, &quot;/api/signup&quot;, &quot;/resources/**&quot;);
}</code></pre>
<p>이 방식이 더 적합하다.</p>
<p>하지만 스프링에서는 Warn을 통해 아래와 같이 permitAll()을 사용할 것을 권장한다.</p>
<p>_You are asking Spring Security to ignore Ant [pattern=&#39;/api/signup&#39;]. This is not recommended -- please use permitAll via HttpSecurity#authorizeHttpRequests instead. _</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Notification ?!!]]></title>
            <link>https://velog.io/@refoli_20/Notification</link>
            <guid>https://velog.io/@refoli_20/Notification</guid>
            <pubDate>Thu, 17 Nov 2022 13:53:19 GMT</pubDate>
            <description><![CDATA[<h3 id="concepts-of-notification">Concepts of Notification</h3>
<ul>
<li>상태바에 앱의 정보를 출력하는 것을 의미함 (알림)</li>
</ul>
<p>배터리 상태가 있는 부분을 상태바라고 하며, 이는 일종의 시스템 창이다. 시스템 코어 정보를 뿌리기 위한 창</p>
<ul>
<li>액티비티가 제어 불가능, 액티비티의 영역이 아님</li>
<li>직접 제어는 불가능하나, 시스템에 의뢰를 해서 시스템에서 관리하고 있는 상태바에 어떤 notification에 어떤 정보를 띄워줘 하고 의뢰하는 프로그램!</li>
</ul>
<p>-액티비티에서 자체 notification을 띄우는게 가능하긴 하나, 자체 액티비티를 통해 유저에게 알림이 충분히 가능하기 때문에 잘 사용하진 않는다. 대신 broadcastreceiver과 servicer가 notification을 많이 구현하곤 한다.</p>
<p>알림은 NotificationManager의 notify() 함수로 발생하며, 
Notification 객체는 NotificationCompat.Builder에 의해 생성된다
NotificationCompat.Builder가 필요한데 Builder를 만드는 방법이 API Level 26 버전부터 변경되었다.
 -&gt; NotificationChannel로 NotificationCompat.Builder을 생성함</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/7e9f08be-0a1f-4bf8-8d29-6bd7985d5615/image.png" alt=""></p>
<ol>
<li>Notification을 띄우기 위해서는 NotificationManager의 notify라는 함수를 호출하면 상태바에 notification이 뜸</li>
<li>그 notification의 내용을 담은 게 바로 Notification 객체라고 보면 되는데, 이는 Notification 빌더에 의해 만들어짐. 근데 이 빌더를 만들 때 Channel 개념을 도입해서 만들어야 한다!</li>
</ol>
<h3 id="notificationmanager">NotificationManager</h3>
<p>채널 개념 - 어플리케이션에서 띄우는 notification을 구분해서, 각각 받을건지 안받을건지 설정할 수 있도록 함</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/bfdb8cbf-5d0d-47af-809c-fb9b662bf465/image.png" alt=""></p>
<p>-IMPORTANCE_HIGH - 헤드업이란 상태바의 아이콘말고 둥둥둥둥 떠있는 듯하게..? 잉?</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/7543b326-b6c3-4cbd-a994-3db89a8fbb4e/image.png" alt=""></p>
<p>small icon -&gt; 확장 시 확장 컨텐츠, small icon, title, when, text 정보가 모두 보임</p>
<p><img src="https://velog.velcdn.com/images/refoli_20/post/90533c66-8e75-4740-aa19-84c7bd894140/image.png" alt=""></p>
<h3 id="notification-구성">Notification 구성</h3>
<p>코어 정보 이외의 다양한 정보 사용
user가 notification을 터치했을 때 이벤트 처리 (앱의 컴포넌트 실행)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Service ?! ]]></title>
            <link>https://velog.io/@refoli_20/Service</link>
            <guid>https://velog.io/@refoli_20/Service</guid>
            <pubDate>Wed, 16 Nov 2022 23:17:07 GMT</pubDate>
            <description><![CDATA[<h3 id="service란">Service란</h3>
<p>백그라운드에서 오랜 시간 수행되는 업무를 담당하기 위한 컴포넌트!
화면 출력 능력은 없음! (only background) , 다만 화면 반응성은 아예 없거나 드물게 발생하는 업무여야함 (activity는 반응성 있음!) // 화면에 다른 앱이 사용된다고 해도 계속 처리되어야 함</p>
<p>Manifest에 service 태그로 등록, name은 생략 불가능</p>
<p>startService(intent)에 의해서 실행
외부 앱의 서비스라면 setPackage()함수를 이용해 실행하고자 하는 앱의 패키지 명을 명시</p>
<p>4개 컴포넌트 중 유일하게 구동 뿐만 아니라 종료를 위한 인텐트 함수도 존재함!!
stopService(intent) // 유저에 의해서 종료할 수 없어성</p>
<h4 id="lifecycle">Lifecycle</h4>
<p>service 실행하는 방법 두가지 있음!</p>
<ol>
<li><p>startService() ( onCreate()는 최초 한번만 호출, 인텐트가 다시 발생되면 onStartCommand()함수만 반복 호출이 되는 것 )</p>
</li>
<li><p>bindService() ( 서비스 객체 생성, onCreate() 최초 한번만 호출 -&gt; onBind() 반복 호출 -&gt; onUnbind() 호출로 종료 )</p>
</li>
</ol>
<p><em>공식문서 나와있음, 참고해서 비교해 볼 것!</em></p>
<p>-service는 싱글톤으로 동작함 (하나의 클래스가 단 하나의 개채로만 동작하는 구조)</p>
<p>싱글톤에 대해 이해해보자.. 액티비티랑 비교해보깅</p>
<p>어떤 activity 객체가 생성되어있는데, 똑같은 activity를 생성하기 위한 인텐트가 한번 더 발생되면 또 하나의 객체가 생성 -&gt; 다수개 생성 가능 (activity가 화면을 목적으로 하기 때문이지롱) -&gt; 싱글톤 아님!!</p>
<p>service 싱글톤 -&gt; 인텐트가 발생된다고 객체를 다시 생성하진 않음 (화면 출력이 되지 않기에 내부적인 알고리즘으로 해결이 가능하기 때문!)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[BroadcastReceiver ?!]]></title>
            <link>https://velog.io/@refoli_20/BroadcastReceiver</link>
            <guid>https://velog.io/@refoli_20/BroadcastReceiver</guid>
            <pubDate>Wed, 16 Nov 2022 22:54:57 GMT</pubDate>
            <description><![CDATA[<h3 id="broadcastreceiver">BroadcastReceiver</h3>
<p>4개 컴포넌트 중 api적으로 가장 간단한 컴포넌트!
이벤트 모델로 실행되는 컴포넌트 (유저 이벤트는 아니고, 시스템의 특정 상황을 의미함)
개발자 코드에 의해 라이프사이클을 직접 관리할 수는 없고, 시스템이 관리함</p>
<p>broadcast receiver를 상속받아서 class 정의, 
시스템에서 객체를 생성하고 onReceiver이 자동 호출</p>
<p>컴포넌트기에 메니페스트에 등록해야함! (reveiver 태그로 등록, name 속성 생략 불가 -&gt; 시스템 인지 완료!)
_- 코드에서 동적으로 등록하는 것도 가능, registerReceiver - 언제 실행되어야 하는지를 intentFilter로 정의하여 선언 _
-&gt; 브로드캐스트 리시버가 항상 필요한 건 아니고, 필요한 시점에서만 실행되려면 동적 등록! (정적 등록은 인텐트 발생 시 항상 실행되기에)</p>
<p>unregisterReceiver() 를 통해 등록해제 (코드에서 등록했으면 코드에서 등록해제~!)</p>
<p>val intent = Intent(this, MyReveiver::class.jave) 
sendBroadcast(intent) 
-&gt; 브로드캐스트를 발생시키는 인텐트를 시스템에 발생시킴!</p>
<p>인텐트가 발생했을 때 인텐트에 의해 실행될 컴포넌트가 없는 경우,
액티비티는 에러를 발생시키지만
브로드캐스트는 에러를 발생시키지 않는다!</p>
<p>인텐트가 발생했을 때 인텐트에 의해 실행될 컴포넌트가 많은 경우,
액티비티는 선택할 수 있게 하지만
브로드캐스트는 모두가 한번에 실행이 된다! 오~~</p>
<h4 id="부팅-완료-시점에-특정-업무를-진행하는-코드">부팅 완료 시점에 특정 업무를 진행하는 코드</h4>
<p>user-permission에 REVEIVE_BOOT_COMPLETED를 받고, receiver를 manifest에서 선언할 때 intent-filter에 BOOT_COMPLETED를 함께 넣어주면 됨! (암시적 인텐트로)</p>
<h4 id="스크린-온오프-시-브로드캐스트-리시버-실행">스크린 온/오프 시 브로드캐스트 리시버 실행</h4>
<p>화면이 꺼졌을 때 특정 액티비티가 계속 움직일 필요가 없는 경우
리시버는 무조건 동적 등록해야만 실행!시스템 인텐트 정보에 맞춰서 등록</p>
<p>+배터리 관련 액션 문자열
BATTERY_LOW 배터리 낮은 상태
BATTERY_OKAY 배터리 정상 상태
BATTERY_CHANGED 충전 상태 변경
ACTION_POWER_CONNECTED 충전중
ACTION_POWER_DISCONNECTED 충전 끊김
등등..</p>
]]></description>
        </item>
    </channel>
</rss>