<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>뱀귤 블로그</title>
    <link>https://bcp0109.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 30 May 2026 20:26:57 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>뱀귤</managingEditor>
    <item>
      <title>Kotlin 에서 JPA 사용하기 3 (@Id 선언)</title>
      <link>https://bcp0109.tistory.com/389</link>
      <description>&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;Kotlin 에서 엔티티를 정의할 때 &lt;code&gt;@Id&lt;/code&gt; 값을 정의하는 방법에는 여러가지가 있습니다.&lt;/p&gt;
&lt;p&gt;Java 에서는 기본적으로 모든 변수가 nullable 하기 때문에 딱히 의견이 갈릴 일이 없었는데요.&lt;/p&gt;
&lt;p&gt;Kotlin 에서 많이 사용하는 대표적인 &lt;code&gt;@Id&lt;/code&gt; 정의 스타일과 장단점을 알아보겠습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;1. val + nullable 정의&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;@MappedSuperclass
abstract class BaseEntity1 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;아마 Kotlin + JPA 를 사용하면서 가장 많이 보이는 스타일일 것 같습니다.&lt;/p&gt;
&lt;p&gt;특징&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;엔티티의 특성상 DB 에 저장되기 전까지는 &lt;code&gt;null&lt;/code&gt; 값이므로 nullable 타입을 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 스타일의 단점이라고 하면 &lt;code&gt;id&lt;/code&gt; 가 nullable 이기 때문에 도메인 로직에서 사용할 때는 &lt;code&gt;id!!&lt;/code&gt; 나 &lt;code&gt;requireNotNull(id)&lt;/code&gt; 등을 사용해야 할 수 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;2. id 기본값을 0L 으로 지정&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;@MappedSuperclass
abstract class BaseEntity2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 스타일 역시 Kotlin + JPA 에서 많이 보이는 스타일입니다.&lt;/p&gt;
&lt;p&gt;특징&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Notnull 타입으로 정의했기 때문에 매번 &lt;code&gt;!!&lt;/code&gt; 을 붙이지 않아도 됨 (NPE 방지)&lt;/li&gt;
&lt;li&gt;DDD 관점에서는 도메인 객체가 &amp;quot;불완전한 상태(null ID)&amp;quot;를 가지는 것이 자연스럽지 않기 때문에 이를 해결&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;가장 큰 특징으로는 &lt;code&gt;nullable&lt;/code&gt; 을 지양하는 Kotlin 의 철학을 지킬 수 있다는 점입니다.&lt;/p&gt;
&lt;p&gt;엔티티의 id 는 DB 에 저장되기 전까지 &lt;code&gt;null&lt;/code&gt; 인데 어떻게 &lt;code&gt;0L&lt;/code&gt; 을 사용할 수 있는걸까요? &lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;2.1. Repository#save&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;@Override
@Transactional
public &amp;lt;S extends T&amp;gt; S save(S entity) {

    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JPA 의 Repository &lt;code&gt;save&lt;/code&gt; 는 &lt;code&gt;isNew&lt;/code&gt; 라는 함수를 사용해서 엔티티가 새로 생성된건지 기존 데이터인지 검사합니다.&lt;/p&gt;
&lt;p&gt;새로 생성된 데이터라면 &lt;code&gt;persist&lt;/code&gt; 를 진행해서 데이터를 저장, id 에 값을 주입하고 기존 데이터라면 &lt;code&gt;merge&lt;/code&gt; 를 사용해서 업데이트 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;2.2. isNew&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;@Override
public boolean isNew(T entity) {

    ID id = getId(entity);
    Class&amp;lt;ID&amp;gt; idType = getIdType();

    if (!idType.isPrimitive()) {
        return id == null;
    }

    if (id instanceof Number n) {
        return n.longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format(&amp;quot;Unsupported primitive id type %s&amp;quot;, idType));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring Data JPA 내부의 Repository 구현을 보면 &lt;code&gt;isNew&lt;/code&gt; 함수에서 &lt;code&gt;id == null&lt;/code&gt; 뿐만 아니라 &lt;code&gt;id == 0L&lt;/code&gt; 또한 &amp;quot;새로운 객체&amp;quot; 라고 판단해줍니다.&lt;/p&gt;
&lt;p&gt;그래서 &lt;code&gt;null&lt;/code&gt; 대신 &lt;code&gt;0L&lt;/code&gt; 을 입력해도 JPA 가 정상적으로 동작하는 겁니다.&lt;/p&gt;
&lt;p&gt;하지만 JPA 의 구현체로 Hibernate 가 아닌 다른 걸 사용한다면 0L 을 저장되지 않은 구현체로 보장하지 않기 때문에 문제가 발생할 수 있습니다.&lt;/p&gt;
&lt;p&gt;그리고 &lt;code&gt;id&lt;/code&gt; 를 0L 로 정의한다는 것 자체가 어색하거나 불편하게 느껴질 수도 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;3. var + nullable 정의&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;@MappedSuperclass
abstract class BaseEntity3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java 와 가장 유사하게 동작하는 스타일입니다.&lt;/p&gt;
&lt;p&gt;Jetbrain 이나 Spring 에서 제공하는 예제 코드에서 등장하는데 오픈소스 예제에서는 테스트 시 직접 &lt;code&gt;id&lt;/code&gt; 값을 설정하거나 유연하게 다루기 위해 때문에 사용하는 것 같습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;4. 개인적인 사용 방식&lt;/h1&gt;
&lt;p&gt;저는 위의 스타일들 중에서 마음에 안드는 부분이 하나씩 있었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;저장되지 않은 &lt;code&gt;id&lt;/code&gt; 에 &lt;code&gt;0L&lt;/code&gt; 을 넣는 것&lt;/li&gt;
&lt;li&gt;nullable 하게 정의하면 호출할 때 &lt;code&gt;id!!&lt;/code&gt; 를 사용해야 하는 것&lt;/li&gt;
&lt;li&gt;그렇다고 별도의 &lt;code&gt;id()&lt;/code&gt; 메서드를 정의하는 것도 마음에 안듬&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 Backing Field 를 사용했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;@MappedSuperclass
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected val _id: Long? = null

    val id: Long = _id ?: throw IllegalStateException(&amp;quot;엔티티의 ID 값이 존재하지 않습니다.&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;id&lt;/code&gt; 정의하는데 이렇게까지 해야하나? 하는 생각이 들수도 있지만 &lt;code&gt;BaseEntity&lt;/code&gt; 에만 정의해두면 다시 건들 일이 없긴 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@Id&lt;/code&gt; 가 사용되는 &lt;code&gt;_id&lt;/code&gt; 변수는 nullable 이라 저장되지 않았을 때 &lt;code&gt;null&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;protected&lt;/code&gt; 로 정의하여 상속 후 프록시객체로 필드 주입 가능&lt;/li&gt;
&lt;li&gt;외부에서 &lt;code&gt;id&lt;/code&gt; 호출시에도 &lt;code&gt;!!&lt;/code&gt; 를 붙이거나 함수처럼 &lt;code&gt;id()&lt;/code&gt; 형태로 사용하지 않아도 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;그래서 뭘 써야하나?&lt;/p&gt;
&lt;p&gt;정답은 없습니다.&lt;/p&gt;
&lt;p&gt;팀 컨벤션이나 개인적인 선호도에 따라 1번을 쓰기도 하고 2번을 쓰기도 합니다.&lt;/p&gt;
&lt;p&gt;현재 대중화된 Spring Boot + JPA Hibernate 기준으로는 어떤 스타일을 해도 정상 동작합니다.&lt;/p&gt;
&lt;p&gt;가장 많이 쓰는 방식은 1번 (&lt;code&gt;val id: Long? = null&lt;/code&gt;) 으로 알고 있습니다.&lt;/p&gt;</description>
      <category>Language/Kotlin</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/389</guid>
      <comments>https://bcp0109.tistory.com/389#entry389comment</comments>
      <pubDate>Sun, 15 Jun 2025 22:19:52 +0900</pubDate>
    </item>
    <item>
      <title>Kotlin 에서 JPA 사용하기 2 (사용하면 안되는 기능들)</title>
      <link>https://bcp0109.tistory.com/388</link>
      <description>&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;Kotlin 에서 제공하는 유용한 기능 중에 JPA 에서는 권장되지 않거나 사용하면 안되는 기능들이 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;1. data class&lt;/h1&gt;
&lt;p&gt;Kotlin 에서 제공하는 &lt;code&gt;data class&lt;/code&gt;는 JPA의 &lt;code&gt;@Entity&lt;/code&gt;에서는 사용하지 않는 것이 좋습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;data class&lt;/code&gt; 는 불변성과 값 기반 비교를 지향하지만, JPA의 엔티티는 보통 가변 상태를 가지며 식별자(ID) 기반으로 관리되기 때문에 둘의 지향점이 다릅니다.&lt;/p&gt;
&lt;p&gt;또한, &lt;code&gt;data class&lt;/code&gt; 가 자동 생성하는 &lt;code&gt;equals&lt;/code&gt;, &lt;code&gt;hashCode&lt;/code&gt; 구현은 모든 필드를 기반으로 비교하기 때문에 &lt;code&gt;id&lt;/code&gt; 가 아직 할당되지 않은 상태에서는 동일한 엔티티임에도 불구하고 서로 다른 객체로 인식될 수 있습니다.&lt;/p&gt;
&lt;p&gt;그래서 Kotlin 에서 엔티티 객체를 정의할 때는 기본 class 를 사용해야 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;2. init&lt;/h1&gt;
&lt;p&gt;Kotlin 에서는 &lt;code&gt;init&lt;/code&gt; 블럭을 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;init&lt;/code&gt; 블럭은 &amp;quot;객체 생성 직후&amp;quot; 실행되지만, JPA 는 리플렉션을 통해 엔티티를 &amp;quot;빈 객체로 생성 -&amp;gt; 필드 값을 주입&amp;quot; 하는 순서로 동작합니다.&lt;/p&gt;
&lt;p&gt;따라서 &lt;code&gt;init&lt;/code&gt; 블럭에서 필드 값을 참조하면 아직 값이 설정되지 않아 &lt;code&gt;NullPointerException&lt;/code&gt; 또는 예상치 못한 로직 오류가 발생할 수 있습니다.&lt;/p&gt;
&lt;p&gt;엔티티 객체 생성 이후에 뭔가를 처리하고 싶다면 외부에서 처리하거나, 내부에서 처리해야 한다면 &lt;code&gt;@PostLoad&lt;/code&gt; 등을 활용해야 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;Kotlin 은 여러 편의 기능을 제공하지만, JPA 와 함께 사용할 때는 특정 Kotlin 기능이 JPA 의 동작 방식과 충돌할 수 있습니다.&lt;/p&gt;
&lt;p&gt;그 중 대표적인 예로 &lt;code&gt;data class&lt;/code&gt;와 &lt;code&gt;init&lt;/code&gt; 블럭 사용을 지양해야 하는 이유를 알아보았습니다.&lt;/p&gt;</description>
      <category>Language/Kotlin</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/388</guid>
      <comments>https://bcp0109.tistory.com/388#entry388comment</comments>
      <pubDate>Sun, 15 Jun 2025 08:51:25 +0900</pubDate>
    </item>
    <item>
      <title>Kotlin 에서 JPA 사용하기 1 (all-open, no-arg 플러그인)</title>
      <link>https://bcp0109.tistory.com/387</link>
      <description>&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;Kotlin 으로 Spring Boot 를 만들다보면 JPA 를 함께 사용하는 일이 많습니다.&lt;/p&gt;
&lt;p&gt;JPA 는 대표적인 Spring Boot 의 ORM 이지만 Kotlin 과 함께 사용하려면 몇가지 불편한 점이 존재합니다.&lt;/p&gt;
&lt;p&gt;이런 불편한 점들을 해결하기 위해 Kotlin 측에서는 JPA 에서 사용하기 적합한 몇가지 플러그인을 제공합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;1. Kotlin JPA plugin&lt;/h1&gt;
&lt;p&gt;Kotlin 에서 제공하는 &lt;code&gt;plugin.jpa&lt;/code&gt; 에는 다음 두 가지 플러그인이 포함되어 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;plugin.allopen&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;plugin.noarg&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h2&gt;1.1. all-open plugin&lt;/h2&gt;
&lt;p&gt;Kotlin 에서는 기본적으로 모든 클래스와 메서드가 &lt;code&gt;final&lt;/code&gt; 입니다.&lt;/p&gt;
&lt;p&gt;이 말은 클래스나 메서드를 상속/오버라이드 할 수 없다는 뜻입니다.&lt;/p&gt;
&lt;p&gt;Java 에서는 기본적으로 &lt;code&gt;open&lt;/code&gt; 이라 상속에 열려있는 반면에 Kotlin 은 의도된 상속 구조를 사용하기 위해 기본적으로 &lt;code&gt;final&lt;/code&gt; 이고 상속 가능한 클래스만 명시적으로 &lt;code&gt;open&lt;/code&gt; 을 선언합니다.&lt;/p&gt;
&lt;p&gt;JPA 에서는 지연 로딩 (lazy loading), 더티 체킹 (dirty checking) 등에 사용되는 프록시 객체를 만들기 위해서는 클래스가 상속 가능해야 합니다.&lt;/p&gt;
&lt;p&gt;그래서 Kotlin 에서는 JPA 클래스를 &lt;code&gt;open&lt;/code&gt; 으로 변경시켜주는 &lt;a href=&quot;https://kotlinlang.org/docs/all-open-plugin.html&quot;&gt;&amp;quot;all-open&amp;quot; 플러그인&lt;/a&gt;을 제공합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;1.2. no-arg plugin&lt;/h2&gt;
&lt;p&gt;JPA 에서는 엔티티 객체를 리플렉션 (Reflection) 으로 생성햐는데 구현상 반드시 파라미터 없는 기본 생성자가 필요합니다.  &lt;/p&gt;
&lt;p&gt;Kotlin 에서는 정의된 생성자를 통해 모든 필드를 초기화하도록 요구하므로, 별도로 기본생성자를 만들어주지 않으면 JPA 에서 인스턴스를 생성할 수 없습니다.&lt;/p&gt;
&lt;p&gt;사실 기본생성자가 없는 문제는 Kotlin 만의 문제는 아니고 Java 에서도 &lt;code&gt;@Entity&lt;/code&gt; 객체는 항상 기본생성자를 직접 만들어주거나 Lombok 의 &lt;code&gt;@NoArgConstructor&lt;/code&gt; 를 붙여줘야 합니다.&lt;/p&gt;
&lt;p&gt;Kotlin 에서는 JPA 클래스에 기본생성자를 붙여주는 &lt;a href=&quot;https://kotlinlang.org/docs/no-arg-plugin.html&quot;&gt;&amp;quot;no-arg&amp;quot; 플러그인&lt;/a&gt;을 제공합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;2. 적용 방법&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-kt&quot;&gt;plugins {
    val kotlinVersion = &amp;quot;2.1.20&amp;quot;
    kotlin(&amp;quot;plugin.jpa&amp;quot;) version kotlinVersion
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Kotlin &lt;code&gt;2.1.20&lt;/code&gt; 버전 사용 기준으로 위와 같이 추가해주시면 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;allopen&lt;/code&gt;, &lt;code&gt;noarg&lt;/code&gt; 플러그인을 별도 사용시 원래는 &lt;code&gt;allOpen&lt;/code&gt;, &lt;code&gt;noArg&lt;/code&gt; 블럭을 추가하여 적용 대상을 지정해야 했는데 &lt;code&gt;plugin.jpa&lt;/code&gt; 에는 해당 설정 또한 내장되어 있기 때문에 따로 추가할 필요가 없습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;Spring Initializr 에서는  Kotlin + JPA 선택 시 &lt;code&gt;plugin.jpa&lt;/code&gt; 를 자동으로 추가해줍니다.&lt;/p&gt;
&lt;p&gt;그래서 Spring Initializr 나 Gradle 설정을 통해 무심코 적용되는 경우가 많지만 실제로 Kotlin 에서 JPA 를 안정적으로 사용하기 위해 중요한 역할을 하는 플러그인들을 정리해봤습니다.&lt;/p&gt;</description>
      <category>Language/Kotlin</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/387</guid>
      <comments>https://bcp0109.tistory.com/387#entry387comment</comments>
      <pubDate>Sun, 15 Jun 2025 08:15:23 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 에서 Redis Cache 사용하기</title>
      <link>https://bcp0109.tistory.com/386</link>
      <description>&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;베이스 코드로 &lt;a href=&quot;https://bcp0109.tistory.com/385&quot;&gt;Spring Boot Cache 적용&lt;/a&gt;에 있던 코드들을 재활용할 예정이라 앞의 글을 먼저 읽어보는걸 추천합니다.&lt;/p&gt;
&lt;p&gt;단순하게 Redis Cache 설정만 알고 싶다면 상관 없습니다.&lt;/p&gt;
&lt;p&gt;코드를 직접 실행해보려면 &lt;a href=&quot;https://bcp0109.tistory.com/327&quot;&gt;로컬에 Redis 를 설치&lt;/a&gt;해야 합니다.&lt;/p&gt;
&lt;p&gt;만약 별도의 Redis 서버를 운영 중이라면 해당 서버를 사용해도 됩니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;1. Dependency 추가&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;implementation &amp;#39;org.springframework.boot:spring-boot-starter-data-redis&amp;#39;
implementation &amp;#39;org.springframework.boot:spring-boot-starter-cache&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;spring-boot-starter-cache&lt;/code&gt; 에 이어 &lt;code&gt;spring-boot-starter-data-redis&lt;/code&gt; 설정을 추가합니다.&lt;/p&gt;
&lt;p&gt;Spring Data Redis 설정을 추가하면 자동으로 기본 캐시가 &lt;code&gt;ConcurrentMapCache&lt;/code&gt; 에서 &lt;code&gt;RedisCache&lt;/code&gt; 로 설정됩니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;2. Redis Configuration&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Configuration
public class RedisConfig {

    @Value(&amp;quot;${spring.data.redis.host}&amp;quot;)
    private String host;

    @Value(&amp;quot;${spring.data.redis.port}&amp;quot;)
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(
                new RedisStandaloneConfiguration(host, port)
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 연결을 위한 기본 설정을 추가합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;3. Redis Cache Configuration&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@EnableCaching
@Configuration
public class CacheConfig {

    /**
     * Spring Boot 가 기본적으로 RedisCacheManager 를 자동 설정해줘서 RedisCacheConfiguration 없어도 사용 가능
     * Bean 을 새로 선언하면 직접 설정한 RedisCacheConfiguration 이 적용됨
     */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(60))
                .disableCachingNullValues()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                );
    }

    /**
     * 여러 Redis Cache 에 관한 설정을 하고 싶다면 RedisCacheManagerBuilderCustomizer 를 사용할 수 있음
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return (builder) -&amp;gt; builder
                .withCacheConfiguration(&amp;quot;cache1&amp;quot;,
                        RedisCacheConfiguration.defaultCacheConfig()
                                .computePrefixWith(cacheName -&amp;gt; &amp;quot;prefix::&amp;quot; + cacheName + &amp;quot;::&amp;quot;)
                                .entryTtl(Duration.ofSeconds(120))
                                .disableCachingNullValues()
                                .serializeKeysWith(
                                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                                )
                                .serializeValuesWith(
                                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                                ))
                .withCacheConfiguration(&amp;quot;cache2&amp;quot;,
                        RedisCacheConfiguration.defaultCacheConfig()
                                .entryTtl(Duration.ofHours(2))
                                .disableCachingNullValues());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis Cache 설정을 추가합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CacheManager&lt;/code&gt; 를 사용했던 &lt;code&gt;ConcurrentMapCache&lt;/code&gt; 와는 다르게 Redis 는 간단하게 Redis Cache 설정을 적용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;우선 Spring Data Redis 를 사용한다면 Spring Boot 가 &lt;code&gt;RedisCacheManager&lt;/code&gt; 를 자동으로 설정해줍니다.&lt;/p&gt;
&lt;p&gt;하지만 Redis 는 직렬화/역직렬화 때문에 별도의 캐시 설정이 필요하고 이 때 사용하는게 &lt;code&gt;RedisCacheConfiguration&lt;/code&gt; 입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RedisCacheConfiguration&lt;/code&gt; 설정은 Redis 기본 설정을 오버라이드 한다고 생각하면 됩니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;computePrefixWith&lt;/code&gt;: Cache Key prefix 설정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;entryTtl&lt;/code&gt;: 캐시 만료 시간&lt;/li&gt;
&lt;li&gt;&lt;code&gt;disableCachingNullValues&lt;/code&gt;: 캐싱할 때 null 값을 허용하지 않음 (&lt;code&gt;#result == null&lt;/code&gt; 과 함께 사용해야 함)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;serializeKeysWith&lt;/code&gt;: Key 를 직렬화할 때 사용하는 규칙. 보통은 String 형태로 저장&lt;/li&gt;
&lt;li&gt;&lt;code&gt;serializeValuesWith&lt;/code&gt;: Value 를 직렬화할 때 사용하는 규칙. Jackson2 를 많이 사용함&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;p&gt;만약 캐시이름 별로 여러 세팅을 하고 싶다면 &lt;code&gt;RedisCacheManagerBuilderCustomizer&lt;/code&gt; 를 선언해서 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;위 코드에서는 &lt;code&gt;cache1&lt;/code&gt;, &lt;code&gt;cache2&lt;/code&gt; 두 가지 캐시를 설정했으며 만약 다른 이름의 캐시를 사용하려 한다면 기본 설정인 &lt;code&gt;RedisCacheConfiguration&lt;/code&gt; 를 따라갑니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GenericJackson2JsonRedisSerializer&lt;/code&gt; 를 사용할 때 주의할 점은 여러개의 데이터를 한번에 저장할 때 &lt;code&gt;List&lt;/code&gt; 를 사용하지 말고 별도의 일급 컬렉션을 선언해서 사용해야 합니다.&lt;/p&gt;
&lt;p&gt;자세한 이슈는 &lt;a href=&quot;https://bcp0109.tistory.com/384&quot;&gt;Spring Boot 에서 Redis Cache 사용 시 List 역직렬화 에러 (GenericJackson2JsonRedisSerializer)&lt;/a&gt; 글에 정리해두었습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;4. Redis 데이터 확인&lt;/h1&gt;
&lt;p&gt;API 호출 후 Redis CLI 에서 직접 저장된 데이터를 확인해봅니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;4.1. cache1 데이터 확인&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_04_15_23_37_06.png?raw=true&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cache1&lt;/code&gt; 은 설정한 대로 prefix 가 붙은 key 값이 사용됩니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;4.2. members 데이터 확인&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_04_15_23_37_59.png?raw=true&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;별도의 이름을 설정하지 않은 &lt;code&gt;members&lt;/code&gt; 캐시는 그냥 일반 키값으로 저장됩니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;Redis Cache 는 일반적으로 가장 많이 사용되는 글로벌 캐시입니다.&lt;/p&gt;
&lt;p&gt;직접 &lt;code&gt;RedisTemplate&lt;/code&gt; 을 호출해서 구현할 수도 있지만 Spring Boot 에서 제공하는 설정을 알아두면 나중에 유용하게 사용할 일이 있을 겁니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/ParkJiwoon/spring-boot-redis-cache-sample/&quot;&gt;Github Sample 코드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.baeldung.com/spring-boot-redis-cache&quot;&gt;Baeldung - Spring Boot Cache with Redis&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Framework/Spring</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/386</guid>
      <comments>https://bcp0109.tistory.com/386#entry386comment</comments>
      <pubDate>Sat, 15 Apr 2023 23:47:08 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 에서 Cache 사용하기</title>
      <link>https://bcp0109.tistory.com/385</link>
      <description>&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;Spring Boot 에서 Cache 를 적용하는 방법에 대해 알아봅니다.&lt;/p&gt;
&lt;p&gt;원래 Cache 를 사용할 때 Redis 같은 별도의 글로벌 저장소를 활용하는게 일반적이지만 이번에는 간단하게 기본 캐시인 &lt;code&gt;ConcurrentMapCache&lt;/code&gt; 를 사용합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;1. Dependency 추가&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;implementation &amp;#39;org.springframework.boot:spring-boot-starter-cache&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사실 &lt;code&gt;spring-boot-starter-cache&lt;/code&gt; 를 추가하지 않아도 캐시 기능을 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spring-boot-starter-web&lt;/code&gt; 같은 스타터 모듈에 자동으로 포함되어 있는 &lt;code&gt;spring-context&lt;/code&gt; 라는 모듈 덕분인데요.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/docs/3.0.5/reference/html/io.html#io&quot;&gt;Spring Boot 3.0.5 가이드&lt;/a&gt;를 보면 &lt;code&gt;spring-boot-starter-cache&lt;/code&gt; 모듈을 추가해야 &lt;code&gt;spring-context-support&lt;/code&gt; 모듈을 가져와서 캐시 관련된 여러 기능을 제공하기 때문에 캐시 관련 의존성을 추가해준다고 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;2. Configuration&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@EnableCaching
@Configuration
public class CachingConfig {

    @Bean
    public CacheManager cacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        cacheManager.setAllowNullValues(false);
        cacheManager.setCacheNames(List.of(&amp;quot;members&amp;quot;));
        return cacheManager;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다음은 Cache 관련 설정을 추가합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@EnableCaching&lt;/code&gt; 은 &lt;code&gt;SpringBootApplication&lt;/code&gt; 에 추가해도 되지만 어차피 캐시 설정을 위해 Config 클래스를 추가할 거라면 여기에 추가해도 됩니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;2.1. Cache Customizer&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
public class SimpleCacheCustomizer implements CacheManagerCustomizer&amp;lt;ConcurrentMapCacheManager&amp;gt; {

    @Override
    public void customize(ConcurrentMapCacheManager cacheManager) {
        cacheManager.setAllowNullValues(false);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cache Customizer 를 따로 추가하는 방법도 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;3. Cache 어노테이션&lt;/h1&gt;
&lt;p&gt;설정을 마쳤으니 Cache 관련 어노테이션을 사용하면 손쉽게 캐시 기능을 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;캐시 어노테이션들은 기본적으로 AOP 로 동작하기 때문에 내부 호출 같은 이슈를 주의해야 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@Cacheable&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;데이터를 캐시에 저장&lt;/li&gt;
&lt;li&gt;메서드를 호출할 때 캐시의 이름 (&lt;code&gt;value&lt;/code&gt;) 과 키 (&lt;code&gt;key&lt;/code&gt;) 를 확인하여 이미 저장된 데이터가 있으면 해당 데이터를 리턴&lt;/li&gt;
&lt;li&gt;만약 데이터가 없다면 메서드를 수행 후 결과값을 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@CachePut&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@Cacheable&lt;/code&gt; 과 비슷하게 데이터를 캐시에 저장&lt;/li&gt;
&lt;li&gt;차이점은 &lt;code&gt;@Cacheable&lt;/code&gt; 은 캐시에 데이터가 이미 존재하면 메서드를 수행하지 않지만 &lt;code&gt;@CachePut&lt;/code&gt; 은 항상 메서드를 수행&lt;/li&gt;
&lt;li&gt;그래서 주로 캐시 데이터를 갱신할 때 많이 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@CacheEvict&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;캐시에 있는 데이터를 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@CacheConfig&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;메서드가 아닌 클래스에 붙여서 공통된 캐시 기능을 모을 수 있음&lt;/li&gt;
&lt;li&gt;예를 들면 &lt;code&gt;cacheNames&lt;/code&gt;, &lt;code&gt;cacheManager&lt;/code&gt; 등등&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Caching&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;Cacheable, CachePut, CacheEvict 를 여러 개 사용할 때 묶어주는 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;p&gt;일반적으로 &lt;code&gt;@Cacheable&lt;/code&gt; 을 사용해서 캐싱하고 데이터를 갱신할 때 &lt;code&gt;@CachePut&lt;/code&gt;, &lt;code&gt;@CacheEvict&lt;/code&gt; 중 하나를 선택해서 갱신합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@CachePut&lt;/code&gt; 을 사용하면 &lt;code&gt;@Cacheable&lt;/code&gt; 데이터 조회 시 캐시에 새로운 데이터가 존재하기 때문에 DB 조회를 하지 않아도 된다는 장점이 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;4. Domain 정의&lt;/h1&gt;
&lt;p&gt;이제 캐시를 적용하기 전에 간단하게 필요한 클래스들을 정의해봅니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;4.1. Member&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@Setter
@ToString
@NoArgsConstructor
public class Member {

    private Long id;
    private String name;
    private Integer age;

    public Member(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;캐싱 대상인 &lt;code&gt;Member&lt;/code&gt; 클래스입니다.&lt;/p&gt;
&lt;p&gt;Lombok 을 사용했고 복잡한 데이터 없이 간단하게 만들었습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;4.2. Members (List&lt;Member&gt;)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@ToString
@NoArgsConstructor
public class Members {
    private List&amp;lt;Member&amp;gt; members = new ArrayList&amp;lt;&amp;gt;();

    public Members(List&amp;lt;Member&amp;gt; members) {
        this.members = members;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마찬가지로 캐시 대상인 &lt;code&gt;Members&lt;/code&gt; 입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;List&amp;lt;Member&amp;gt;&lt;/code&gt; 를 담은 일급 컬렉션이고 특별한 건 없습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;5. MemberRepository&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Slf4j
@Repository
@CacheConfig(cacheNames = &amp;quot;members&amp;quot;)
public class MemberRepository {

    private final Map&amp;lt;Long, Member&amp;gt; store = new LinkedHashMap&amp;lt;&amp;gt;();

    @Cacheable(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
    public Members findAll() {
        List&amp;lt;Member&amp;gt; members = store.values().stream().toList();
        log.info(&amp;quot;Repository findAll {}&amp;quot;, members);
        return new Members(members);
    }

    @Cacheable(key = &amp;quot;#memberId&amp;quot;, unless = &amp;quot;#result == null&amp;quot;)
    public Member findById(Long memberId) {
        Member member = store.get(memberId);
        log.info(&amp;quot;Repository find {}&amp;quot;, member);
        return member;
    }

    @CachePut(key = &amp;quot;#member.id&amp;quot;)
    @CacheEvict(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
    public Member save(Member member) {
        Long newId = calculateId();
        member.setId(newId);

        log.info(&amp;quot;Repository save {}&amp;quot;, member);

        store.put(member.getId(), member);
        return member;
    }

    private Long calculateId() {
        if (store.isEmpty()) {
            return 1L;
        }

        int lastIndex = store.size() - 1;
        return (Long) store.keySet().toArray()[lastIndex] + 1;
    }

    @CachePut(key = &amp;quot;#member.id&amp;quot;)
    @CacheEvict(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
    public Member update(Member member) {
        log.info(&amp;quot;Repository update {}&amp;quot;, member);
        store.put(member.getId(), member);
        return member;
    }

    @Caching(evict = {
            @CacheEvict(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;),
            @CacheEvict(key = &amp;quot;#member.id&amp;quot;)
    })
    public void delete(Member member) {
        log.info(&amp;quot;Repository delete {}&amp;quot;, member);
        store.remove(member.getId());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;캐시 어노테이션을 적용한 &lt;code&gt;MemberRepository&lt;/code&gt; 코드입니다.&lt;/p&gt;
&lt;p&gt;데이터는 실제 DB 대신 간단하게 &lt;code&gt;LinkedHashMap&lt;/code&gt; 을 사용했습니다.&lt;/p&gt;
&lt;p&gt;우선 전체 코드를 보고 하나씩 살펴봅니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;5.1. @CacheConfig&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;@CacheConfig&lt;/code&gt; 를 클래스에 붙여서 &lt;code&gt;members&lt;/code&gt; 라는 공통 캐시 이름을 설정합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;5.2. 복수 조회 (findAll)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Cacheable(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
public Members findAll() {
    List&amp;lt;Member&amp;gt; members = store.values().stream().toList();
    log.info(&amp;quot;Repository findAll {}&amp;quot;, members);
    return new Members(members);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;전체 데이터를 조회하는 메서드입니다.&lt;/p&gt;
&lt;p&gt;key 를 &lt;code&gt;all&lt;/code&gt; 로 설정했기 때문에 &lt;code&gt;members::all&lt;/code&gt; 이라는 key 값에 &lt;code&gt;Members&lt;/code&gt; 데이터가 저장됩니다.&lt;/p&gt;
&lt;p&gt;이후에 한번 더 조회를 하면 &lt;code&gt;members::all&lt;/code&gt; 을 확인하고 데이터가 있다면 그 값을 그대로 리턴합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;5.3. 단건 조회 (findById)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Cacheable(key = &amp;quot;#memberId&amp;quot;, unless = &amp;quot;#result == null&amp;quot;)
public Member findById(Long memberId) {
    Member member = store.get(memberId);
    log.info(&amp;quot;Repository find {}&amp;quot;, member);
    return member;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Member&lt;/code&gt; 데이터를 저장하는 단건 조회 메서드입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;memberId&lt;/code&gt; 를 키값으로 설정하며 &lt;code&gt;unless = &amp;quot;#result == null&amp;quot;&lt;/code&gt; 조건을 추가하여 DB 에 없는 데이터인 경우 캐싱하지 않도록 했습니다.&lt;/p&gt;
&lt;p&gt;만약 이 조건을 추가하지 않으면 null 값도 캐싱 대상이 됩니다.&lt;/p&gt;
&lt;p&gt;우리는 캐시 설정에서 &lt;code&gt;cacheManager.setAllowNullValues(false);&lt;/code&gt; 를 추가했기 때문에 null 값을 캐싱하려고 하면 에러가 발생하니 꼭 위 조건을 함께 추가해줘야 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;5.4. 생성 및 변경 (save &amp;amp; update)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@CachePut(key = &amp;quot;#member.id&amp;quot;)
@CacheEvict(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
public Member save(Member member) {
    Long newId = calculateId();
    member.setId(newId);

    log.info(&amp;quot;Repository save {}&amp;quot;, member);

    store.put(member.getId(), member);
    return member;
}


@CachePut(key = &amp;quot;#member.id&amp;quot;)
@CacheEvict(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
public Member update(Member member) {
    log.info(&amp;quot;Repository update {}&amp;quot;, member);
    store.put(member.getId(), member);
    return member;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;새로운 데이터를 저장합니다.&lt;/p&gt;
&lt;p&gt;여기에는 두가지 어노테이션이 붙어 있는데 &lt;code&gt;@CachePut&lt;/code&gt; 은 새로운 데이터를 저장하면 해당 데이터를 바로 캐싱하기 위해 추가했습니다.&lt;/p&gt;
&lt;p&gt;여기서 캐싱하지 않아도 조회할 때 캐싱되기 때문에 반드시 필요한 설정은 아닙니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@CacheEvict&lt;/code&gt; 는 전체 조회 데이터를 삭제합니다.&lt;/p&gt;
&lt;p&gt;2개의 데이터가 캐싱되어 있고 새로운 데이터가 추가되었는데 캐시를 갱신하거나 비워주지 않으면 만료될 때까지 이전 데이터를 보고 있기 때문에 한번 삭제해줘야 합니다.&lt;/p&gt;
&lt;p&gt;단건 조회라면 &lt;code&gt;@CachePut&lt;/code&gt; 을 사용해서 갱신할 수 있지만 복수 조회라면 갱신하는 일이 더 귀찮기 때문에 그냥 캐시를 비워주고 &lt;code&gt;findAll&lt;/code&gt; 을 호출할 때 새로 캐싱합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@CachePut&lt;/code&gt; 을 사용할 때 한가지 주의할 점이라면 반환값을 캐싱하기 때문에 &lt;code&gt;void update&lt;/code&gt; 처럼 리턴값을 제대로 지정하지 않는 경우 제대로 동작하지 않을 수 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;5.5. 삭제 (delete)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Caching(evict = {
        @CacheEvict(key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;),
        @CacheEvict(key = &amp;quot;#member.id&amp;quot;)
})
public void delete(Member member) {
    log.info(&amp;quot;Repository delete {}&amp;quot;, member);
    store.remove(member.getId());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Member 데이터를 삭제합니다.&lt;/p&gt;
&lt;p&gt;삭제는 &lt;code&gt;Member&lt;/code&gt; 데이터와 &lt;code&gt;Members&lt;/code&gt; 데이터의 캐싱을 모두 없애줘야 하기 때문에 &lt;code&gt;@CacheEvict&lt;/code&gt; 을 두개 사용했습니다.&lt;/p&gt;
&lt;p&gt;메서드에는 중복된 어노테이션을 두개 붙일 수 없기 때문에 &lt;code&gt;@Caching&lt;/code&gt; 을 사용해서 묶어주면 동일한 어노테이션 두개를 전부 적용할 수 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;6. MemberController&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Slf4j
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping(&amp;quot;/members&amp;quot;)
    public Members findAll() {
        Members members = memberRepository.findAll();
        log.info(&amp;quot;Controller findAll {}&amp;quot;, members);
        return members;
    }

    @GetMapping(&amp;quot;/members/{memberId}&amp;quot;)
    public Member findById(@PathVariable Long memberId) {
        Member member = memberRepository.findById(memberId);
        log.info(&amp;quot;Controller find {}&amp;quot;, member);
        return member;
    }

    @PostMapping(&amp;quot;/members&amp;quot;)
    public Member save(@RequestBody MemberDto memberDto) {
        Member member = new Member(memberDto.getName(), memberDto.getAge());
        Member savedMember = memberRepository.save(member);
        log.info(&amp;quot;Controller save {}&amp;quot;, savedMember);
        return savedMember;
    }

    @PutMapping(&amp;quot;/members/{memberId}&amp;quot;)
    public Member update(@PathVariable Long memberId, @RequestBody MemberDto memberDto) {
        Member member = new Member(memberDto.getName(), memberDto.getAge());
        member.setId(memberId);
        return memberRepository.update(member);
    }

    @DeleteMapping(&amp;quot;/members/{memberId}&amp;quot;)
    public void delete(@PathVariable Long memberId) {
        Member member = memberRepository.findById(memberId);
        log.info(&amp;quot;Controller delete {}&amp;quot;, member);
        memberRepository.delete(member);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;간단한 CRUD REST API 를 만들었습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;7. API Test&lt;/h1&gt;
&lt;p&gt;로컬에서 실제 테스트를 진행해봅니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.1. 빈 List 조회&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_04_09_00_56_38.png?raw=true&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;우선 아무것도 추가하지 않은 상태로 데이터를 조회해봅니다.&lt;/p&gt;
&lt;p&gt;처음에는 Repository 로그까지 남지만 똑같은 요청을 반복하면 캐싱된 데이터를 가져오므로 Controller 까지만 로그가 남습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.2. 새로운 데이터 추가&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_04_09_00_58_45.png?raw=true&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;새로운 데이터를 추가합니다.&lt;/p&gt;
&lt;p&gt;데이터 추가나 변경은 &lt;code&gt;@CachePut&lt;/code&gt; 을 사용하기 때문에 매번 Repository 로그를 남깁니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.3. Members 새로운 데이터 조회&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_04_09_00_59_27.png?raw=true&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;다시 &lt;code&gt;findAll&lt;/code&gt; 을 호출합니다.&lt;/p&gt;
&lt;p&gt;새로운 데이터를 추가할 때마다 &lt;code&gt;members::all&lt;/code&gt; 은 evict 되어 다시 Repository 조회까지 수행합니다.&lt;/p&gt;
&lt;p&gt;하지만 한번 조회한 이후에는 여전히 Controller 로그까지만 남깁니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.4. Member 단건 조회&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_04_09_01_00_45.png?raw=true&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;단건 조회를 해도 &lt;code&gt;@CachePut&lt;/code&gt; 으로 &lt;code&gt;members::2&lt;/code&gt; 가 이미 캐싱되어 있기 때문에 Repository 로그는 남지 않습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;Cache 는 서버 개발을 하는데 굉장히 중요한 기능입니다.&lt;/p&gt;
&lt;p&gt;대부분의 성능 개선을 캐시 추가로 할 수 있으며 설정도 다양하고 &lt;a href=&quot;https://bcp0109.tistory.com/364&quot;&gt;캐시 전략&lt;/a&gt;도 다양합니다.&lt;/p&gt;
&lt;p&gt;Spring Boot 에서는 Cache 를 사용하기 쉽게 AOP 로 제공하고 있으니 사용법을 알아두면 필요할 때 유용하게 사용할 수 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/docs/3.0.5/reference/html/io.html#io.caching&quot;&gt;Spring Boot Docs - Caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.baeldung.com/spring-cache-tutorial&quot;&gt;Baeldung - A Guide To Caching in Spring&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Framework/Spring</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/385</guid>
      <comments>https://bcp0109.tistory.com/385#entry385comment</comments>
      <pubDate>Sun, 9 Apr 2023 01:14:55 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 에서 Redis Cache 사용 시 List 역직렬화 에러 (GenericJackson2JsonRedisSerializer)</title>
      <link>https://bcp0109.tistory.com/384</link>
      <description>&lt;h1&gt;상황&lt;/h1&gt;
&lt;p&gt;Redis Cache 를 사용해서 &lt;code&gt;List&amp;lt;?&amp;gt;&lt;/code&gt; 를 저장하려고 했습니다.&lt;/p&gt;
&lt;p&gt;직렬화해서 데이터 저장까지는 잘 되었는데 다시 역직렬화 하려고 하니 에러가 발생하며 실패했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Cacheable(cacheNames = &amp;quot;members&amp;quot;, key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
public List&amp;lt;Member&amp;gt; findAll() {
    List&amp;lt;Member&amp;gt; members = store.values().stream().toList();
    return members;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;캐싱한 데이터는 위와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;List&amp;lt;Member&amp;gt;&lt;/code&gt; 를 응답으로 내려주고 &lt;code&gt;members&lt;/code&gt; 라는 캐시의 &lt;code&gt;all&lt;/code&gt; 이라는 키값으로 저장됩니다.&lt;/p&gt;
&lt;p&gt;Redis 설정으로 Value 는 &lt;code&gt;GenericJackson2JsonRedisSerializer&lt;/code&gt; 를 사용하여 직렬화했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ParkJiwoon/PrivateStudy/master/trouble-shooting/images/screen_2023_04_08_05_41_42.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Redis 를 확인해보면 제대로 저장된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;에러 로그&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;com.fasterxml.jackson.databind.exc.MismatchedInputException: Unexpected token (START_OBJECT), expected VALUE_STRING: need JSON String, Number of Boolean that contains type id (for subtype of java.lang.Object)
 at [Source: (byte[])&amp;quot;[{&amp;quot;@class&amp;quot;:&amp;quot;com.example.springbootcache.model.Member&amp;quot;,&amp;quot;id&amp;quot;:1,&amp;quot;name&amp;quot;:&amp;quot;ChulSoo&amp;quot;,&amp;quot;age&amp;quot;:50}]&amp;quot;; line: 1, column: 2]&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h1&gt;원인&lt;/h1&gt;
&lt;p&gt;List&amp;lt;?&amp;gt; 를 그대로 저장해서 그렇습니다.&lt;/p&gt;
&lt;p&gt;사실 정확한 원인은 저도 모릅니다.&lt;/p&gt;
&lt;p&gt;그래도 확신은 없지만 나름대로 추측을 해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GenericJackson2JsonRedisSerializer&lt;/code&gt; 는 직렬화 할 때 &lt;code&gt;@class&lt;/code&gt; 라는 Key 값에 클래스의 패키지 정보까지 전부 저장됩니다.&lt;/p&gt;
&lt;p&gt;그런데 List 를 통째로 저장하면 위 사진과 같이 &lt;code&gt;{ &amp;quot;@class&amp;quot;: &amp;quot;...&amp;quot; }&lt;/code&gt; 이 아니라 &lt;code&gt;[{ &amp;quot;@class&amp;quot;: &amp;quot;...&amp;quot;}]&lt;/code&gt; 로 저장되어 찾지 못해서 발생하는 이슈 같습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;해결&lt;/h1&gt;
&lt;p&gt;List 를 감싸는 Wrapper 클래스를 만들어 주면 해결됩니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;Members 클래스 정의&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@NoArgsConstructor
public class Members {
    private List&amp;lt;Member&amp;gt; members = new ArrayList&amp;lt;&amp;gt;();

    public Members(List&amp;lt;Member&amp;gt; members) {
        this.members = members;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h2&gt;캐싱 대상 변경&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Cacheable(cacheNames = &amp;quot;members&amp;quot;, key = &amp;quot;&amp;#39;all&amp;#39;&amp;quot;)
public Members findAll() {
    List&amp;lt;Member&amp;gt; members = store.values().stream().toList();
    return new Members(members);
}&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h2&gt;Redis 저장 확인&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ParkJiwoon/PrivateStudy/master/trouble-shooting/images/screen_2023_04_08_05_41_57.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;</description>
      <category>공부/Trouble Shooting</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/384</guid>
      <comments>https://bcp0109.tistory.com/384#entry384comment</comments>
      <pubDate>Sat, 8 Apr 2023 05:38:10 +0900</pubDate>
    </item>
    <item>
      <title>EC2 Ubuntu 에 Docker 설치</title>
      <link>https://bcp0109.tistory.com/383</link>
      <description>&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;Docker 공식 홈페이지에 있는 &lt;a href=&quot;https://docs.docker.com/engine/install/ubuntu/&quot;&gt;Ubuntu 설치&lt;/a&gt;를 보고 따라하면 됩니다&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;1. Docker 설치&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;$ sudo apt-get update
$ sudo apt-get install \
    ca-certificates \
    curl \
    gnupg&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h2&gt;2. Docker 공식 GPG 키 추가&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;$ sudo mkdir -m 0755 -p /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h2&gt;3. Docker Repository 설치&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;$ echo \
  &amp;quot;deb [arch=&amp;quot;$(dpkg --print-architecture)&amp;quot; signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  &amp;quot;$(. /etc/os-release &amp;amp;&amp;amp; echo &amp;quot;$VERSION_CODENAME&amp;quot;)&amp;quot; stable&amp;quot; | \
  sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h2&gt;4. Docker 설치&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;$ sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;

&lt;h2&gt;5. Docker 실행 테스트&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;$ sudo docker run hello-world

# 실행된 도커 컨테이너 확인
$ sudo docker ps

# 이미지 확인
$ sudo docker images&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;테스트 용으로 &lt;code&gt;hello-world&lt;/code&gt; 라는 이미지를 실행합니다.&lt;/p&gt;
&lt;p&gt;docker image 를 따로 받지 않아도 없으면 자동으로 pull 을 먼저 땡깁니다.&lt;/p&gt;</description>
      <category>공부/Linux</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/383</guid>
      <comments>https://bcp0109.tistory.com/383#entry383comment</comments>
      <pubDate>Sat, 25 Mar 2023 23:58:40 +0900</pubDate>
    </item>
    <item>
      <title>[LeetCode Medium] Single Element in a Sorted Array (Java)</title>
      <link>https://bcp0109.tistory.com/382</link>
      <description>&lt;h1&gt;Problem&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://leetcode.com/problems/single-element-in-a-sorted-array&quot;&gt;문제 링크&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;p&gt;숫자만 존재하는 오름차순 배열이 주어집니다.&lt;/p&gt;
&lt;p&gt;모든 값들은 2개씩 존재하고 단 하나의 값만 1개 존재합니다.&lt;/p&gt;
&lt;p&gt;1개만 존재하는 값을 찾는 문제입니다.&lt;/p&gt;
&lt;p&gt;&lt;br&gt;&lt;br&gt;&lt;/p&gt;
&lt;h1&gt;Solution&lt;/h1&gt;
&lt;p&gt;그냥 Map 을 사용해도 되는 문제지만 추가 조건으로 &lt;code&gt;O(log n) time and O(1) space&lt;/code&gt; 의 복잡도를 요구합니다.&lt;/p&gt;
&lt;p&gt;이 조건을 만족하기 위해선 이분탐색이 필요합니다.&lt;/p&gt;
&lt;p&gt;하지만 이 문제는 일반적인 이분탐색과 다르게 찾아야 하는 값이 따로 주어지지 않습니다.&lt;/p&gt;
&lt;p&gt;그럼 범위를 절반으로 나누었을 때 왼쪽과 오른쪽 중 찾으려는 값이 있는 곳을 알 수 있을까요?&lt;/p&gt;
&lt;p&gt;힌트는 &amp;quot;모든 숫자는 반드시 두개씩 존재한다&amp;quot; 입니다.&lt;/p&gt;
&lt;p&gt;반드시 두개씩 연달아 존재하기 때문에 인덱스 위치를 파악해서 숫자를 비교하면 단 하나만 있는 값의 위치를 알 수 있습니다.&lt;/p&gt;
&lt;p&gt;대신 현재 index 가 홀수인지 짝수인지에 따라 비교해야 하는 대상이 바뀌기 때문에 그 부분만 체크해주면 됩니다.&lt;/p&gt;
&lt;img width=&quot;962&quot; alt=&quot;image&quot; src=&quot;https://user-images.githubusercontent.com/28972341/225664925-fb1de28d-37a5-4bd6-9859-59bf93b38833.png&quot;&gt;

&lt;p&gt;&lt;br&gt;&lt;br&gt;&lt;/p&gt;
&lt;h1&gt;Java Code&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class Solution {
    public int singleNonDuplicate(int[] nums) {
        int left = 0;
        int right = nums.length - 1;
        int mid = 0;

        while (left &amp;lt; right) {
            mid = (left + right) / 2;

            if (mid % 2 == 0 &amp;amp;&amp;amp; nums[mid] == nums[mid + 1]) {
                // mid 위치가 짝수면 오른쪽 값이랑 비교하고 같으면 single 은 오른쪽에 있음
                left = mid + 2;
            } else if (mid % 2 == 1 &amp;amp;&amp;amp; nums[mid] == nums[mid - 1]) {
                // mid 위치가 홀수면 왼쪽 값이랑 확인하고 같으면 single 은 오른쪽에 있음
                left = mid + 1;
            } else {
                // 위에 전부 해당 안되면 single 은 왼쪽에 있음
                right = mid;
            }

        }

        return nums[left];
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>알고리즘 문제/LeetCode</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/382</guid>
      <comments>https://bcp0109.tistory.com/382#entry382comment</comments>
      <pubDate>Fri, 17 Mar 2023 00:27:36 +0900</pubDate>
    </item>
    <item>
      <title>[LeetCode Medium] Count Number of Teams (Java)</title>
      <link>https://bcp0109.tistory.com/381</link>
      <description>&lt;h1&gt;Problem&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://leetcode.com/problems/count-number-of-teams&quot;&gt;문제 링크&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;p&gt;n 개의 rating 순서가 주어질 때, 만들어질 수 있는 팀의 갯수를 구하는 문제입니다.&lt;/p&gt;
&lt;p&gt;팀을 만드려면 3 개의 rating 이 오름차순 또는 내림차순으로 존재해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;br&gt;&lt;br&gt;&lt;/p&gt;
&lt;h1&gt;Solution&lt;/h1&gt;
&lt;p&gt;팀의 인원은 반드시 3 명이라는 점에 주목할 수 있습니다.&lt;/p&gt;
&lt;p&gt;3 명이라면 어떤 값을 가운데 기준으로 잡았을 때, 왼쪽에 있는 값은 더 작고 오른족에 있는 값은 더 커야 오름차순이 됩니다.&lt;/p&gt;
&lt;p&gt;또는 반대가 되면 내림차순이 됩니다.&lt;/p&gt;
&lt;p&gt;아래와 같은 순서로 코드를 작성하면 해결할 수 있습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;ratings&lt;/code&gt; 를 전체 순회하면서 가운데 기준값을 잡는다.&lt;/li&gt;
&lt;li&gt;기준값 기준으로 왼쪽에서 더 작은 값 갯수, 오른쪽에서 더 큰 값 갯수를 구해 곱한다 (오름차순인 팀 경우의 수)&lt;/li&gt;
&lt;li&gt;기준값 기준으로 왼쪽에서 더 큰 값 갯수, 오른쪽에서 더 작은 값 갯수를 구해 곱한다 (내림차순인 팀 경우의 수)&lt;/li&gt;
&lt;li&gt;두 값을 더하면 만들어질 수 있는 모든 팀의 수가 나온다.&lt;/li&gt;
&lt;/ol&gt;
&lt;br&gt;

&lt;img width=&quot;854&quot; alt=&quot;image&quot; src=&quot;https://user-images.githubusercontent.com/28972341/225161444-a48432ce-028b-4679-9c8a-25d1d0f76dff.png&quot;&gt;

&lt;p&gt;예를 들어 그림으로 표현하면 위와 같습니다.&lt;/p&gt;
&lt;p&gt;6 을 기준으로 오름차순은 168, 368, 568 이 존재하고 내림차순은 962, 964, 762, 764 이 존재합니다.&lt;/p&gt;
&lt;p&gt;오름차순으로 봤을 때 왼쪽에는 1, 3, 5 세개가 있고 오른쪽에는 8 하나가 존재하기 때문에 오름차순 팀이 만들어질 수 있는 경우의 수는 1 * 3 입니다.&lt;/p&gt;
&lt;p&gt;내림차순을 보면 왼쪽에 9, 7 이 존재하고 오른쪽에 4, 2 가 존재하기 때문에 내림차순 팀이 만들어질 수 있는 경우의 수는 2 * 2 입니다.&lt;/p&gt;
&lt;p&gt;그러므로 가운데 숫자가 6 일때 만들 수 있는 팀은 7 개가 됩니다.&lt;/p&gt;
&lt;p&gt;이런식으로 앞에서부터 모든 숫자에 하나씩 가운데 숫자를 대입하면서 끝까지 돌면 만들 수 있는 모든 팀의 갯수를 구할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;br&gt;&lt;br&gt;&lt;/p&gt;
&lt;h1&gt;Java Code&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class Solution {
    public int numTeams(int[] rating) {
        int teamCount = 0;

        for (int mid = 0; mid &amp;lt; rating.length; mid++) {
            int leftSmaller = 0;
            int leftLager = 0;
            int rightSmaller = 0;
            int rightLager = 0;

            for (int left = 0; left &amp;lt; mid; left++) {
                if (rating[left] &amp;lt; rating[mid]) {
                    leftSmaller++;
                } else {
                    leftLager++;
                }
            }

            for (int right = mid + 1; right &amp;lt; rating.length; right++) {
                if (rating[right] &amp;lt; rating[mid]) {
                    rightSmaller++;
                } else {
                    rightLager++;
                }
            }

            teamCount += (leftSmaller * rightLager) + (leftLager * rightSmaller);
        }

        return teamCount;
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>알고리즘 문제/LeetCode</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/381</guid>
      <comments>https://bcp0109.tistory.com/381#entry381comment</comments>
      <pubDate>Wed, 15 Mar 2023 08:20:48 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 에서 Kakao, Naver 로그인하기 2편 (OAuth 2.0) - 코드 구현</title>
      <link>https://bcp0109.tistory.com/380</link>
      <description>&lt;h1&gt;1. Overview&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://bcp0109.tistory.com/379&quot;&gt;1편에서는 OAuth 2.0 에 대한 간단한 개념을 알아보고 네이버, 카카오 앱 등록까지 완료&lt;/a&gt;했습니다.&lt;/p&gt;
&lt;p&gt;2편에서는 직접 코드를 구현하면서 최종적으로 API 만들어봅니다.&lt;/p&gt;
&lt;p&gt;샘플 코드를 볼 때 다음 내용들을 참고해주세요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spring Security 를 사용하지 않음&lt;/li&gt;
&lt;li&gt;ID, PW 인증하는 기본 로그인 코드는 작성하지 않음&lt;/li&gt;
&lt;li&gt;회원을 의미하는 &lt;code&gt;Member&lt;/code&gt; 테이블에 저장되는 데이터는 각자 설계하기 나름이고 여기서는 기본적인 데이터만 사용&lt;/li&gt;
&lt;li&gt;OAuth 2.0 은 Client (웹, 앱) 개발자와의 협업이 필수지만 여기서는 Backend 코드만 작성&lt;ul&gt;
&lt;li&gt;클라이언트 없이 테스트 하는 방법 소개&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h1&gt;2. Server 의 역할&lt;/h1&gt;
&lt;p&gt;서버에서 구현해야 하는 로직은 크게 세가지 입니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;카카오/네이버와 같은 OAuth 플랫폼에 인증 후 프로필 정보 가져오기&lt;/li&gt;
&lt;li&gt;email 정보로 사용자 확인 (없으면 새로 가입처리)&lt;/li&gt;
&lt;li&gt;Access Token 생성 후 내려주기&lt;/li&gt;
&lt;/ol&gt;
&lt;br&gt;

&lt;h1&gt;3. 개발 환경&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;plugins {
    id &amp;#39;java&amp;#39;
    id &amp;#39;org.springframework.boot&amp;#39; version &amp;#39;3.0.3&amp;#39;
    id &amp;#39;io.spring.dependency-management&amp;#39; version &amp;#39;1.1.0&amp;#39;
}

group = &amp;#39;com.example&amp;#39;
version = &amp;#39;0.0.1-SNAPSHOT&amp;#39;
sourceCompatibility = &amp;#39;17&amp;#39;

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation &amp;#39;org.springframework.boot:spring-boot-starter-web&amp;#39;
    implementation &amp;#39;org.springframework.boot:spring-boot-starter-data-jpa&amp;#39;
    runtimeOnly &amp;#39;com.h2database:h2&amp;#39;
    compileOnly &amp;#39;org.projectlombok:lombok&amp;#39;
    annotationProcessor &amp;#39;org.projectlombok:lombok&amp;#39;
    testImplementation &amp;#39;org.springframework.boot:spring-boot-starter-test&amp;#39;

    implementation &amp;#39;io.jsonwebtoken:jjwt-api:0.11.5&amp;#39;
    implementation &amp;#39;io.jsonwebtoken:jjwt-impl:0.11.5&amp;#39;
    implementation &amp;#39;io.jsonwebtoken:jjwt-jackson:0.11.5&amp;#39;
}

tasks.named(&amp;#39;test&amp;#39;) {
    useJUnitPlatform()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;프로젝트 환경은 다음과 같습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spring Boot 3.0.3&lt;/li&gt;
&lt;li&gt;Java 17&lt;/li&gt;
&lt;li&gt;Spring Web&lt;/li&gt;
&lt;li&gt;Spring Data JPA&lt;/li&gt;
&lt;li&gt;H2 Database&lt;/li&gt;
&lt;li&gt;Lombok&lt;/li&gt;
&lt;li&gt;JWT 관련 라이브러리&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h1&gt;4. 사전 세팅&lt;/h1&gt;
&lt;h2&gt;4.1. application.yml&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;jwt:
  secret-key: Z29nby10bS1zZXJ2ZXItZGxyamVvYW9yb3JodG9kZ290c3Atam9vbmdhbmduaW0teWVvbHNpbWhpaGFsZ2VveW8K

oauth:
  kakao:
    client-id: 160cd4f66fc928d2b279d78999d6d018
    url:
      auth: https://kauth.kakao.com
      api: https://kapi.kakao.com
  naver:
    secret: W_2DmcLfYU
    client-id: Y2i4SlApP7A1KZsUoott
    url:
      auth: https://nid.naver.com
      api: https://openapi.naver.com&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 토큰 생성을 위한 Secret Key 와 OAuth 요청을 위한 여러가지 정보를 넣어둡니다.&lt;/p&gt;
&lt;p&gt;Secret 값 같은 경우는 외부에 노출되지 않게 Vault 같은 보안 저장소에 넣을 수도 있습니다.&lt;/p&gt;
&lt;p&gt;각자 본인이 등록한 Client ID 를 사용해야 합니다. (저는 게시글 작성 후 앱 삭제 예정)&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;4.2. Configuration&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Configuration
public class ClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RestTemplate 을 사용하기 위해 Spring Bean 컴포넌트로 등록하는 설정을 추가합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;5. Member 도메인 정의&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public enum OAuthProvider {
    KAKAO, NAVER
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;회원의 로그인 타입을 저장하는 Enum 클래스입니다.&lt;/p&gt;
&lt;p&gt;&amp;quot;OAuth 인증 제공자&amp;quot; 라는 의미에서 &lt;code&gt;OAuthProvider&lt;/code&gt; 라는 네이밍을 사용했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@Entity
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String nickname;

    private OAuthProvider oAuthProvider;

    @Builder
    public Member(String email, String nickname, OAuthProvider oAuthProvider) {
        this.email = email;
        this.nickname = nickname;
        this.oAuthProvider = oAuthProvider;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;회원 정보를 담는 &lt;code&gt;Member&lt;/code&gt; 엔티티입니다.&lt;/p&gt;
&lt;p&gt;Email, Nickname 과 같은 프로필 정보나 인증 타입을 갖고 있습니다.&lt;/p&gt;
&lt;p&gt;프로젝트 성격에 따라 회원 (&lt;code&gt;Member&lt;/code&gt;) 도메인과 인증 (&lt;code&gt;Authentication&lt;/code&gt;) 도메인을 분리하는 경우도 있으니 이 부분은 설계하기에 따라 바뀔 수 있습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;6. 외부 API 요청&lt;/h1&gt;
&lt;p&gt;외부 API 요청을 위한 Client 클래스를 만들어봅니다.&lt;/p&gt;
&lt;p&gt;API 요청을 위해 &lt;code&gt;RestTemplate&lt;/code&gt; 을 사용했지만, 선호도에 따라 다른 걸 사용해도 됩니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;OAuthApiClient&lt;/code&gt;: 카카오나 네이버 API 요청 후 응답값을 리턴해주는 (인터페이스)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OAuthLoginParams&lt;/code&gt;: 카카오, 네이버 요청에 필요한 데이터를 갖고 있는 파라미터 (인터페이스)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;KakaoTokens&lt;/code&gt;, &lt;code&gt;NaverTokens&lt;/code&gt;: 인증 API 응답&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OAuthInfoResponse&lt;/code&gt;: 회원 정보 API 응답 (인터페이스)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RequestOAuthInfoService&lt;/code&gt;: 외부 API 요청의 중복되는 로직을 공통화한 클래스&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;인터페이스를 많이 사용했는데 다음과 같은 장점이 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;카카오, 네이버 외에 새로운 OAuth 로그인 수단이 추가되어도 쉽게 추가할 수 있음&lt;/li&gt;
&lt;li&gt;&amp;quot;외부 Access Token 요청 -&amp;gt; 프로필 정보 요청 -&amp;gt; 이메일, 닉네임 가져오기&amp;quot; 라는 공통된 로직을 하나로 묶을 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h2&gt;6.1. OAuthLoginParams&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface OAuthLoginParams {
    OAuthProvider oAuthProvider();
    MultiValueMap&amp;lt;String, String&amp;gt; makeBody();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OAuth 요청을 위한 파라미터 값들을 갖고 있는 인터페이스입니다.&lt;/p&gt;
&lt;p&gt;이 인터페이스의 구현체는 Controller 의 &lt;code&gt;@RequestBody&lt;/code&gt; 로도 사용하기 때문에 &lt;code&gt;getXXX&lt;/code&gt; 라는 네이밍을 사용하지 않아야 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@NoArgsConstructor
public class KakaoLoginParams implements OAuthLoginParams {
    private String authorizationCode;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public MultiValueMap&amp;lt;String, String&amp;gt; makeBody() {
        MultiValueMap&amp;lt;String, String&amp;gt; body = new LinkedMultiValueMap&amp;lt;&amp;gt;();
        body.add(&amp;quot;code&amp;quot;, authorizationCode);
        return body;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;카카오 API 요청에 필요한 &lt;code&gt;authorizationCode&lt;/code&gt; 를 갖고 있는 클래스입니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@NoArgsConstructor
public class NaverLoginParams implements OAuthLoginParams {
    private String authorizationCode;
    private String state;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.NAVER;
    }

    @Override
    public MultiValueMap&amp;lt;String, String&amp;gt; makeBody() {
        MultiValueMap&amp;lt;String, String&amp;gt; body = new LinkedMultiValueMap&amp;lt;&amp;gt;();
        body.add(&amp;quot;code&amp;quot;, authorizationCode);
        body.add(&amp;quot;state&amp;quot;, state);
        return body;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;네이버는 &lt;code&gt;authorizationCode&lt;/code&gt; 와 &lt;code&gt;state&lt;/code&gt; 값을 필요로 합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;6.2. KakaoTokens, NaverTokens&lt;/h2&gt;
&lt;p&gt;Authorization Code 를 기반으로 타플랫폼 Access Token 을 받아오기 위한 Response Model 입니다.&lt;/p&gt;
&lt;p&gt;여러 가지 값을 받아오지만 여기서 사용할 부분은 Access Token 뿐입니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@NoArgsConstructor
public class KakaoTokens {

    @JsonProperty(&amp;quot;access_token&amp;quot;)
    private String accessToken;

    @JsonProperty(&amp;quot;token_type&amp;quot;)
    private String tokenType;

    @JsonProperty(&amp;quot;refresh_token&amp;quot;)
    private String refreshToken;

    @JsonProperty(&amp;quot;expires_in&amp;quot;)
    private String expiresIn;

    @JsonProperty(&amp;quot;refresh_token_expires_in&amp;quot;)
    private String refreshTokenExpiresIn;

    @JsonProperty(&amp;quot;scope&amp;quot;)
    private String scope;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;https://kauth.kakao.com/oauth/token&lt;/code&gt; 요청 결과값입니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token-response&quot;&gt;Kakao Developers - 카카오 로그인 토큰 받기&lt;/a&gt; 의 응답값 부분을 참고했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@NoArgsConstructor
public class NaverTokens {

    @JsonProperty(&amp;quot;access_token&amp;quot;)
    private String accessToken;

    @JsonProperty(&amp;quot;refresh_token&amp;quot;)
    private String refreshToken;

    @JsonProperty(&amp;quot;token_type&amp;quot;)
    private String tokenType;

    @JsonProperty(&amp;quot;expires_in&amp;quot;)
    private String expiresIn;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;https://nid.naver.com/oauth2.0/token&lt;/code&gt; 요청 결과값입니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.naver.com/docs/login/api/api.md#4-2--%EC%A0%91%EA%B7%BC-%ED%86%A0%ED%81%B0-%EB%B0%9C%EA%B8%89-%EC%9A%94%EC%B2%AD&quot;&gt;Naver Developers - 로그인 API 명세의 접근 토큰 발급 요청&lt;/a&gt; 응답값을 참고했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;6.3. OAuthInfoResponse&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface OAuthInfoResponse {
    String getEmail();
    String getNickname();
    OAuthProvider getOAuthProvider();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Access Token 으로 요청한 외부 API 프로필 응답값을 우리 서비스의 Model 로 변환시키기 위한 인터페이스입니다.&lt;/p&gt;
&lt;p&gt;카카오나 네이버의 email, nickname 정보를 필요로 하기 때문에 Getter 메서드를 추가했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoInfoResponse implements OAuthInfoResponse {

    @JsonProperty(&amp;quot;kakao_account&amp;quot;)
    private KakaoAccount kakaoAccount;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoAccount {
        private KakaoProfile profile;
        private String email;
    }

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoProfile {
        private String nickname;
    }

    @Override
    public String getEmail() {
        return kakaoAccount.email;
    }

    @Override
    public String getNickname() {
        return kakaoAccount.profile.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.KAKAO;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;https://kapi.kakao.com/v2/user/me&lt;/code&gt; 요청 결과값입니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info&quot;&gt;Kakao Developers - 사용자 정보 가져오기&lt;/a&gt; 를 참고해서 만든 응답값입니다.&lt;/p&gt;
&lt;p&gt;원래 더 많은 응답값이 오지만 필요한 데이터만 추려내기 위해 &lt;code&gt;@JsonIgnoreProperties(ignoreUnknown = true)&lt;/code&gt; 를 사용했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class NaverInfoResponse implements OAuthInfoResponse {

    @JsonProperty(&amp;quot;response&amp;quot;)
    private Response response;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class Response {
        private String email;
        private String nickname;
    }

    @Override
    public String getEmail() {
        return response.email;
    }

    @Override
    public String getNickname() {
        return response.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.NAVER;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;https://openapi.naver.com/v1/nid/me&lt;/code&gt; 요청 결과값입니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.naver.com/docs/login/profile/profile.md&quot;&gt;Naver Devlopers - 네이버 회원 프로필 조회 API 명세&lt;/a&gt; 를 참고해서 만든 응답값입니다.&lt;/p&gt;
&lt;p&gt;마찬가지로 &lt;code&gt;@JsonIgnoreProperties(ignoreUnknown = true)&lt;/code&gt; 를 사용해서 필요 없는 값들은 제외하고 원하는 값만 받도록 했습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;6.4. OAuthApiClient&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface OAuthApiClient {
    OAuthProvider oAuthProvider();
    String requestAccessToken(OAuthLoginParams params);
    OAuthInfoResponse requestOauthInfo(String accessToken);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OAuth 요청 을 위한 Client 클래스입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;oAuthProvider()&lt;/code&gt;: Client 의 타입 반환&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestAccessToken&lt;/code&gt;: Authorization Code 를 기반으로 인증 API 를 요청해서 Access Token 을 획득&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestOauthInfo&lt;/code&gt;: Access Token 을 기반으로 Email, Nickname 이 포함된 프로필 정보를 획득&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class KakaoApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = &amp;quot;authorization_code&amp;quot;;

    @Value(&amp;quot;${oauth.kakao.url.auth}&amp;quot;)
    private String authUrl;

    @Value(&amp;quot;${oauth.kakao.url.api}&amp;quot;)
    private String apiUrl;

    @Value(&amp;quot;${oauth.kakao.client-id}&amp;quot;)
    private String clientId;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + &amp;quot;/oauth/token&amp;quot;;

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap&amp;lt;String, String&amp;gt; body = params.makeBody();
        body.add(&amp;quot;grant_type&amp;quot;, GRANT_TYPE);
        body.add(&amp;quot;client_id&amp;quot;, clientId);

        HttpEntity&amp;lt;?&amp;gt; request = new HttpEntity&amp;lt;&amp;gt;(body, httpHeaders);

        KakaoTokens response = restTemplate.postForObject(url, request, KakaoTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + &amp;quot;/v2/user/me&amp;quot;;

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set(&amp;quot;Authorization&amp;quot;, &amp;quot;Bearer &amp;quot; + accessToken);

        MultiValueMap&amp;lt;String, String&amp;gt; body = new LinkedMultiValueMap&amp;lt;&amp;gt;();
        body.add(&amp;quot;property_keys&amp;quot;, &amp;quot;[\&amp;quot;kakao_account.email\&amp;quot;, \&amp;quot;kakao_account.profile\&amp;quot;]&amp;quot;);

        HttpEntity&amp;lt;?&amp;gt; request = new HttpEntity&amp;lt;&amp;gt;(body, httpHeaders);

        return restTemplate.postForObject(url, request, KakaoInfoResponse.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Kakao Develpers 의 &lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token&quot;&gt;카카오 로그인 토큰 받기&lt;/a&gt; 와 &lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info&quot;&gt;사용자 정보 가져오기&lt;/a&gt; 를 참고했습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RestTemplate&lt;/code&gt; 을 활용해서 외부 요청 후 미리 정의해둔 &lt;code&gt;KakaoTokens&lt;/code&gt;, &lt;code&gt;KakaoInfoResponse&lt;/code&gt; 로 응답값을 받습니다.&lt;/p&gt;
&lt;br&gt;

&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class NaverApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = &amp;quot;authorization_code&amp;quot;;

    @Value(&amp;quot;${oauth.naver.url.auth}&amp;quot;)
    private String authUrl;

    @Value(&amp;quot;${oauth.naver.url.api}&amp;quot;)
    private String apiUrl;

    @Value(&amp;quot;${oauth.naver.client-id}&amp;quot;)
    private String clientId;

    @Value(&amp;quot;${oauth.naver.secret}&amp;quot;)
    private String clientSecret;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.NAVER;
    }

    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + &amp;quot;/oauth2.0/token&amp;quot;;

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap&amp;lt;String, String&amp;gt; body = params.makeBody();
        body.add(&amp;quot;grant_type&amp;quot;, GRANT_TYPE);
        body.add(&amp;quot;client_id&amp;quot;, clientId);
        body.add(&amp;quot;client_secret&amp;quot;, clientSecret);

        HttpEntity&amp;lt;?&amp;gt; request = new HttpEntity&amp;lt;&amp;gt;(body, httpHeaders);

        NaverTokens response = restTemplate.postForObject(url, request, NaverTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + &amp;quot;/v1/nid/me&amp;quot;;

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set(&amp;quot;Authorization&amp;quot;, &amp;quot;Bearer &amp;quot; + accessToken);

        MultiValueMap&amp;lt;String, String&amp;gt; body = new LinkedMultiValueMap&amp;lt;&amp;gt;();

        HttpEntity&amp;lt;?&amp;gt; request = new HttpEntity&amp;lt;&amp;gt;(body, httpHeaders);

        return restTemplate.postForObject(url, request, NaverInfoResponse.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Naver Developers 의 &lt;a href=&quot;https://developers.naver.com/docs/login/api/api.md&quot;&gt;로그인 API 명세&lt;/a&gt; 와 &lt;a href=&quot;https://developers.naver.com/docs/login/profile/profile.md&quot;&gt;회원 프로필 조회 API 명세&lt;/a&gt; 를 참고했습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RestTemplate&lt;/code&gt; 을 활용해서 외부 요청 후 미리 정의해둔 &lt;code&gt;NaverTokens&lt;/code&gt;, &lt;code&gt;NaverInfoResponse&lt;/code&gt; 로 응답값을 받습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;6.5. RequestOAuthInfoService&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
public class RequestOAuthInfoService {
    private final Map&amp;lt;OAuthProvider, OAuthApiClient&amp;gt; clients;

    public RequestOAuthInfoService(List&amp;lt;OAuthApiClient&amp;gt; clients) {
        this.clients = clients.stream().collect(
                Collectors.toUnmodifiableMap(OAuthApiClient::oAuthProvider, Function.identity())
        );
    }

    public OAuthInfoResponse request(OAuthLoginParams params) {
        OAuthApiClient client = clients.get(params.oAuthProvider());
        String accessToken = client.requestAccessToken(params);
        return client.requestOauthInfo(accessToken);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;지금까지 만든 &lt;code&gt;OAuthApiClient&lt;/code&gt; 를 사용하는 Service 클래스입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KakaoApiClient&lt;/code&gt;, &lt;code&gt;NaverApiClient&lt;/code&gt; 를 직접 주입받아서 사용하면 중복되는 코드가 많아지지만 &lt;code&gt;List&amp;lt;OAuthApiClient&amp;gt;&lt;/code&gt; 를 주입 받아서 Map 으로 만들어두면 간단하게 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;참고로 &lt;code&gt;List&amp;lt;인터페이스&amp;gt;&lt;/code&gt; 를 주입받으면 해당 인터페이스의 구현체들이 전부 List 에 담겨옵니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;7. JWT(Access Token) 생성&lt;/h1&gt;
&lt;p&gt;네이버, 카카오 인증이 완료되면 클라이언트에게 Access Token 을 내려주어야 합니다.&lt;/p&gt;
&lt;p&gt;여기서 Access Token 은 내 서비스의 인증 토큰이지, 네이버나 카카오의 토큰이 아닙니다.&lt;/p&gt;
&lt;p&gt;OAuth 플랫폼들의 Access Token 을 클라이언트에게 내려주면 플랫폼 별로 만료 기간 관리도 번거롭고 혹여나 탈취라도 당하면 안되기 때문에 반드시 직접 토큰을 만들어서 내려줍니다.&lt;/p&gt;
&lt;p&gt;JWT 관련 부분은 이 글의 핵심 주제는 아니기 때문에 자세한 설명은 생략합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.1. JwtTokenProvider&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
public class JwtTokenProvider {

    private final Key key;

    public JwtTokenProvider(@Value(&amp;quot;${jwt.secret-key}&amp;quot;) String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String generate(String subject, Date expiredAt) {
        return Jwts.builder()
                .setSubject(subject)
                .setExpiration(expiredAt)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    public String extractSubject(String accessToken) {
        Claims claims = parseClaims(accessToken);
        return claims.getSubject();
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 토큰을 만들어주는 유틸 클래스입니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.2. AuthTokens&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthTokens {
    private String accessToken;
    private String refreshToken;
    private String grantType;
    private Long expiresIn;

    public static AuthTokens of(String accessToken, String refreshToken, String grantType, Long expiresIn) {
        return new AuthTokens(accessToken, refreshToken, grantType, expiresIn);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사용자에게 내려주는 서비스의 인증 토큰 값입니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;7.3. AuthTokensGenerator&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class AuthTokensGenerator {
    private static final String BEARER_TYPE = &amp;quot;Bearer&amp;quot;;
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final JwtTokenProvider jwtTokenProvider;

    public AuthTokens generate(Long memberId) {
        long now = (new Date()).getTime();
        Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        String subject = memberId.toString();
        String accessToken = jwtTokenProvider.generate(subject, accessTokenExpiredAt);
        String refreshToken = jwtTokenProvider.generate(subject, refreshTokenExpiredAt);

        return AuthTokens.of(accessToken, refreshToken, BEARER_TYPE, ACCESS_TOKEN_EXPIRE_TIME / 1000L);
    }

    public Long extractMemberId(String accessToken) {
        return Long.valueOf(jwtTokenProvider.extractSubject(accessToken));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AuthTokens&lt;/code&gt; 을 발급해주는 클래스입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;generate&lt;/code&gt;: memberId (사용자 식별값) 을 받아 Access Token 을 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;extractMemberId&lt;/code&gt;: Access Token 에서 memberId (사용자 식별값) 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h1&gt;8. Controller, Service&lt;/h1&gt;
&lt;p&gt;이제 지금까지 만든 코드를 갖고 최종 비즈니스 로직을 만들어봅니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;8.1. OAuthLoginService&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Service
@RequiredArgsConstructor
public class OAuthLoginService {
    private final MemberRepository memberRepository;
    private final AuthTokensGenerator authTokensGenerator;
    private final RequestOAuthInfoService requestOAuthInfoService;

    public AuthTokens login(OAuthLoginParams params) {
        OAuthInfoResponse oAuthInfoResponse = requestOAuthInfoService.request(params);
        Long memberId = findOrCreateMember(oAuthInfoResponse);
        return authTokensGenerator.generate(memberId);
    }

    private Long findOrCreateMember(OAuthInfoResponse oAuthInfoResponse) {
        return memberRepository.findByEmail(oAuthInfoResponse.getEmail())
                .map(Member::getId)
                .orElseGet(() -&amp;gt; newMember(oAuthInfoResponse));
    }

    private Long newMember(OAuthInfoResponse oAuthInfoResponse) {
        Member member = Member.builder()
                .email(oAuthInfoResponse.getEmail())
                .nickname(oAuthInfoResponse.getNickname())
                .oAuthProvider(oAuthInfoResponse.getOAuthProvider())
                .build();

        return memberRepository.save(member).getId();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음 설명했던 로직을 그대로 작성했습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;카카오/네이버와 같은 OAuth 플랫폼에 인증 후 프로필 정보 가져오기&lt;/li&gt;
&lt;li&gt;email 정보로 사용자 확인 (없으면 새로 가입처리)&lt;/li&gt;
&lt;li&gt;Access Token 생성 후 내려주기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;취향에 따라 &lt;code&gt;findOrCreateMember&lt;/code&gt; 부분을 별도 &lt;code&gt;MemberService&lt;/code&gt; 로 분리해도 상관없습니다.&lt;/p&gt;
&lt;br&gt;

&lt;p&gt;코드를 보면 알 수 있듯이 네이버, 카카오에 특화된 로직이 아닌 공통된 로직이며 인터페이스만을 사용했습니다.&lt;/p&gt;
&lt;p&gt;대신 &lt;code&gt;login&lt;/code&gt; 메서드 호출 시 &lt;code&gt;KakaoLoginParams&lt;/code&gt;, &lt;code&gt;NaverLoginParams&lt;/code&gt; 둘 중에 뭐가 들어오냐에 따라 API 요청하는 곳이 달라집니다.&lt;/p&gt;
&lt;p&gt;만약 새로운 Google, Facebook 로그인이 추가된다고 하더라도 이 코드는 수정할 필요가 없기 때문에 안전하게 추가 가능합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;8.2. AuthController&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RestController
@RequiredArgsConstructor
@RequestMapping(&amp;quot;/api/auth&amp;quot;)
public class AuthController {
    private final OAuthLoginService oAuthLoginService;

    @PostMapping(&amp;quot;/kakao&amp;quot;)
    public ResponseEntity&amp;lt;AuthTokens&amp;gt; loginKakao(@RequestBody KakaoLoginParams params) {
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }

    @PostMapping(&amp;quot;/naver&amp;quot;)
    public ResponseEntity&amp;lt;AuthTokens&amp;gt; loginNaver(@RequestBody NaverLoginParams params) {
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사용자에게 요청을 받는 Controller 부분입니다.&lt;/p&gt;
&lt;p&gt;딱히 특별한 부분은 없고 파라미터로 구현체를 받아서 직접 &lt;code&gt;login&lt;/code&gt; 을 호출하는 차이밖에 없습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;9. Class Diagram&lt;/h1&gt;
&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_03_12_20_09_40.png?raw=true&quot;&gt;

&lt;p&gt;위에서 작성한 코드를 간단하게 표현하면 이렇게 나옵니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;10. API 요청 테스트&lt;/h1&gt;
&lt;p&gt;클라이언트 없이 서버에서만 테스트를 진행해봅시다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;10.1. OAuth 로그인&lt;/h2&gt;
&lt;p&gt;네이버, 카카오 로그인 페이지를 직접 만들어서 접속 후 로그인 합니다.&lt;/p&gt;
&lt;p&gt;1편에서도 한번 다뤘기 때문에 자세한 설명은 생략합니다.&lt;/p&gt;
&lt;p&gt;로그인 후에 Redirect URI 로 전달된 Authorization Code 를 확인합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;카카오: &lt;code&gt;https://kauth.kakao.com/oauth/authorize?client_id=160cd4f66fc928d2b279d78999d6d018&amp;amp;redirect_uri=http://localhost:8080/kakao/callback&amp;amp;response_type=code&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;네이버: &lt;code&gt;https://nid.naver.com/oauth2.0/authorize?response_type=code&amp;amp;client_id=Y2i4SlApP7A1KZsUoott&amp;amp;state=hLiDdL2uhPtsftcU&amp;amp;redirect_uri=http://localhost:8080/naver/callback&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br&gt;

&lt;h2&gt;10.2. API 호출하기&lt;/h2&gt;
&lt;p&gt;Postman, Curl, IntelliJ IDE 등 자신만의 API 호출 방법을 사용해서 직접 API 를 호출합니다.&lt;/p&gt;
&lt;p&gt;저는 Talend API Tester 라는 크롬 확장프로그램을 사용했습니다.&lt;/p&gt;
&lt;p&gt;응답값으로 Access Token 을 받아온다면 성공입니다.&lt;/p&gt;
&lt;br&gt;

&lt;h3&gt;10.2.1. Kakao 로그인 호출&lt;/h3&gt;
&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_03_12_20_01_09.png?raw=true&quot;&gt;

&lt;p&gt;카카오는 &lt;code&gt;authorizationCode&lt;/code&gt; 파라미터만 추가해서 호출합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h3&gt;10.2.2. Naver 로그인 호출&lt;/h3&gt;
&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_03_12_20_03_19.png?raw=true&quot;&gt;

&lt;p&gt;네이버는 &lt;code&gt;authorizationCode&lt;/code&gt; 뿐만 아니라 &lt;code&gt;state&lt;/code&gt; 값도 함께 받아서 전달합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;10.3. Member 정보 호출&lt;/h2&gt;
&lt;img src=&quot;https://github.com/ParkJiwoon/PrivateStudy/blob/master/spring/images/screen_2023_03_12_20_05_38.png?raw=true&quot;&gt;

&lt;p&gt;간단한 Member API 를 만들어서 원하는 데이터가 잘 들어갔는지 확인합니다.&lt;/p&gt;
&lt;br&gt;

&lt;h2&gt;10.4. Access Token 검증&lt;/h2&gt;
&lt;p&gt;Access Token 검증은 두 가지 방법이 있습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;API 만들어서 호출&lt;/li&gt;
&lt;li&gt;Test Code 에서 코드를 만들어 &lt;code&gt;AuthTokensGenerator.extractMemberId&lt;/code&gt; 메서드를 직접 실행&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;둘 중에 편한 방법으로 확인하시면 됩니다.&lt;/p&gt;
&lt;p&gt;위에서 획득한 Access Token 으로 정확한 memberId 를 얻을 수 있다면 성공입니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;지금까지 Spring Boot 에서 OAuth 2.0 을 활용한 인증 기능을 개발했습니다.&lt;/p&gt;
&lt;p&gt;OAuth 2.0 에 대해 잘 모를 때는 어렵고 막막하단 느낌이 들었는데 실제로 구현하고 나니 간단하다고 느꼈습니다.&lt;/p&gt;
&lt;br&gt;

&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/ParkJiwoon/practice-codes/tree/master/spring-boot-oauth2&quot;&gt;Sample 코드 전체 Github&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaologin/common&quot;&gt;Kaka Developers - 카카오 로그인&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.naver.com/docs/login/api/api.md&quot;&gt;Naver Developers - 네이버 로그인&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Framework/Spring</category>
      <author>뱀귤</author>
      <guid isPermaLink="true">https://bcp0109.tistory.com/380</guid>
      <comments>https://bcp0109.tistory.com/380#entry380comment</comments>
      <pubDate>Sun, 12 Mar 2023 23:20:46 +0900</pubDate>
    </item>
  </channel>
</rss>