<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Ich bin rati</title>
    <link>https://rati.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 3 Nov 2026 22:51:30 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>rati</managingEditor>
    <image>
      <title>Ich bin rati</title>
      <url>https://tistory1.daumcdn.net/tistory/8602213/attach/1e747893d71042f2bd786be581c67431</url>
      <link>https://rati.tistory.com</link>
    </image>
    <item>
      <title>객체 지향 : 상속, 합성, 조합</title>
      <link>https://rati.tistory.com/3</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;객체 지향적인 코드를 구성하는 방법에는 &lt;code&gt;상속&lt;/code&gt; &lt;code&gt;합성&lt;/code&gt; &lt;code&gt;조합&lt;/code&gt; 이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우아한 테크코스에서 &lt;a href=&quot;https://github.com/woowacourse/java-janggi&quot;&gt;&lt;code&gt;장기&lt;/code&gt;&lt;/a&gt; 를 하며, 해당 개념에 대해 학습한 후 어떻게 사용하였는 지 정리하였습니다.&lt;/p&gt;
&lt;h1&gt;1. 상속&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;상속&lt;/code&gt; 은 객체의 책임, 역할은 같으나 구현 방식이 다를 때 사용되어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;장기&lt;/code&gt;에는 차, 마, 상, 사, 궁, 포, 졸(병) , 총 7가지 기물이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미션에서 제가 정의한 &lt;code&gt;기물(Piece)&lt;/code&gt; 의 역할은 보드 위의 특정 좌표까지 갈 수 있다면 경로를 제공하는 것이었습니다. 이동 가능성을 확인하고 경로를 제공하는 세부적인 구현이 7개 이상 나오겠다는 생각을 하여 &lt;code&gt;상속&lt;/code&gt; 을 사용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 &lt;code&gt;기물(Piece)&lt;/code&gt; 의 경우에는 각자 다른 팀과 타입을 이동 가능성을 확인하기 위해 가져야하기 때문에 추상 클래스로 구현하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;moveStrategy&lt;/code&gt; 에 대해서는 후술하겠습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public abstract class Piece {
    protected final MoveStrategy moveStrategy;
    private final PieceType pieceType;
    private final Team team;

    public Piece(MoveStrategy moveStrategy, PieceType pieceType, Team team) {
        this.moveStrategy = moveStrategy;
        this.pieceType = pieceType;
        this.team = team;
    }

    abstract public void check(BoardStatus boardStatus, Position start, Position destination);

    abstract public boolean isEmpty();

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 &lt;code&gt;포&lt;/code&gt; 를 제외한 모든 기물들의 이동 경로를 확인하는 과정에서 다른 기물을 뛰어 넘을 수 없다는 공통점을 공통화하기 위해서 &lt;code&gt;NonJumpable&lt;/code&gt; 이라는 추상 클래스를 공통으로 도입하였습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public abstract class NonJumpable extends Piece {
    public NonJumpable(MoveStrategy moveStrategy, PieceType pieceType, Team team) {
        super(moveStrategy, pieceType, team);
    }

    @Override
    public void check(BoardStatus boardStatus, Position start, Position destination) {
        List&amp;lt;Position&amp;gt; movablePath = moveStrategy.findMovablePath(start, destination);
        checkPathIsEmpty(boardStatus, movablePath);
    }

    @Override
    public boolean isEmpty() {
        return false;
    }

    private void checkPathIsEmpty(BoardStatus boardStatus, List&amp;lt;Position&amp;gt; movablePaths) {
        for (Position movablePath : movablePaths) {
            if (isOccupied(boardStatus, movablePath)) {
                throw new IllegalArgumentException(PieceErrorMessage.PATH_BLOCKED.getMessage());
            }
        }
    }

    private boolean isOccupied(BoardStatus boardStatus, Position movablePath) {
        return boardStatus.status().containsKey(movablePath);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;2. 조합과 합성&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;조합(Aggregation)&lt;/code&gt; 과 &lt;code&gt;합성(Composition)&lt;/code&gt; 의 경우 둘 다 &lt;code&gt;Has-a&lt;/code&gt; 관계이지만 둘 사이에도 차이점이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하자면 다음과 같습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;합성 (Composition)&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;조합 (Aggregation)&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;관계 강도&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;강한 결합&lt;/b&gt; (Strong Has-a)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약한 결합&lt;/b&gt; (Weak Has-a)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;생명주기&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;공유함.&lt;/b&gt; 전체가 소멸하면 부분도 소멸.&lt;/td&gt;
&lt;td&gt;&lt;b&gt;독립적임.&lt;/b&gt; 전체가 소멸해도 부분은 남음.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;소유권&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;독점적 소유.&lt;/td&gt;
&lt;td&gt;공유 가능 (여러 곳에서 참조 가능).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구현 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;내부에서 직접 생성 (&lt;code&gt;new&lt;/code&gt;).&lt;/td&gt;
&lt;td&gt;외부에서 주입받음 (DI).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 사람이 작성한 코드에서 조합과 합성을 구분하는 방법은 &lt;code&gt;DI&lt;/code&gt; 와 &lt;code&gt;new&lt;/code&gt; 키워드였지만, 직접 작성하는 경우에는 &lt;b&gt;생명 주기&lt;/b&gt; 를 고민하는 것이 가장 효과적이었습니다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;조합&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조합을 사용한 부분들 중 하나는 앞에서 후술하겠다고 한 &lt;code&gt;moveStrategy&lt;/code&gt; 였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Piece&lt;/code&gt; 를 설계하는 과정에서 몇 몇 제약 조건이 없이 보드 위에 특정 한 기물만 있다는 조건이면 움직임이 동일한 기물이 있음을 확인했습니다. 예를 들어 &lt;code&gt;포&lt;/code&gt; 와 &lt;code&gt;차&lt;/code&gt; 의 경우, 다른 기물과의 관계를 생각하지 않으면 상하좌우로 이동 칸 수의 제약 없이 얼마든지 이동할 수 있다는 &lt;b&gt;공통점&lt;/b&gt;이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 기물에서도 다른 기물이 없는 경우에는 움직임이 비슷한 경우가 존재하여 &lt;b&gt;다른 기물과의 관계를 고려하지 않는 경우 목적지까지 이동할 수 있는 이동 경로를 제공한다&lt;/b&gt; 는 책임을 가지는 &lt;code&gt;strategy&lt;/code&gt; 를 도입하였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;*PalaceMoveRule&lt;/code&gt; 도 동일한 경우라 추가적인 설명에서 제외.*&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공통 인터페이스 정의&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;package domain.piece.strategy;

import domain.position.Position;
import java.util.List;

public interface MoveStrategy {
    List&amp;lt;Position&amp;gt; findMovablePath(Position start, Position destination);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상하좌우 이동 거리 제약이 없는 &lt;code&gt;strategy&lt;/code&gt; 구현&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public class SlidingMoveStrategy implements MoveStrategy {
    private final PalaceMoveRule palaceMoveRule;

    public SlidingMoveStrategy(PalaceMoveRule palaceMoveRule) {
        this.palaceMoveRule = palaceMoveRule;
    }

    @Override
    public List&amp;lt;Position&amp;gt; findMovablePath(Position start, Position destination) {
        if (start.isSameRow(destination.getRow())) {
            return start.getSameRowPositionsToDestination(destination);
        }
        if (start.isSameColumn(destination.getColumn())) {
            return start.getSameColumnPositionsToDestination(destination);
        }
        if (palaceMoveRule.isPalacePath(start, destination)) {
            return List.of();
        }
        throw new IllegalArgumentException(MoveStrategyErrorMessage.NOT_EXIST_MOVABLE_PATH.getMessage());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;new Po(new SlidingMoveStrategy(palaceMoveRule);
new Cha(new SlidingMoveStrategy(palaceMoveRule);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;합성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;합성을 사용한 곳은 &lt;code&gt;BoardInitializer&lt;/code&gt; 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체는 보드가 관리하는 상태의 형태인 &lt;code&gt;Map&amp;lt;Position, Piece&amp;gt;&lt;/code&gt; 타입에 맞게 모든 기물을 넣어서 제공하는 역할을 가지고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체를 합성으로 사용한 이유는 우선 해당 객체는 &lt;code&gt;Board&lt;/code&gt; 가 없다면, 의미가 없는 쓰레기 코드가 되기 때문이었습니다. 조합을 사용하지 않은 이유는 해당 객체의 경우에는 &lt;code&gt;Board&lt;/code&gt; 의 초기값 생성이라는 &lt;code&gt;Board&lt;/code&gt; 와 강력하게 결합된 책임이어서 공통으로 사용하지 않기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계를 하고 나서 해당 글을 작성하면서 보니 만약 &lt;code&gt;조합&lt;/code&gt; 을 사용한다면 &lt;code&gt;getter&lt;/code&gt; 의 사용이 필수가 됩니다. 이 경우, &lt;code&gt;BoardInitializer&lt;/code&gt; 의 역할은 보드가 관리하는 상태의 타입에 따라 기물과 위치 초기값들을 구성한다가 되고, 자율성을 잃게 되어 변경에 취약할 것 같아, 객체 지향의 사용 이점을 잃게 되기도 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;BoardInitializer&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public class BoardInitializer {
    private final Map&amp;lt;PieceType, Piece&amp;gt; choPieces;
    private final Map&amp;lt;PieceType, Piece&amp;gt; hanPieces;

    public BoardInitializer() {
        choPieces = initPieceByTeam(Team.CHO);
        hanPieces = initPieceByTeam(Team.HAN);
    }

    private Map&amp;lt;PieceType, Piece&amp;gt; initPieceByTeam(Team team) {
        PalaceMoveRule palaceMoveRule = new PalaceMoveRule();

        Map&amp;lt;PieceType, Piece&amp;gt; pieces = new HashMap&amp;lt;&amp;gt;();

        pieces.put(PieceType.PO, new Po(new SlidingMoveStrategy(palaceMoveRule), team));
        pieces.put(PieceType.MA, new Ma(new MaMoveStrategy(), team));
        pieces.put(PieceType.SANG, new Sang(new SangMoveStrategy(), team));
        pieces.put(PieceType.SA, new Sa(new SingleStepMoveStrategy(palaceMoveRule), team));
        pieces.put(PieceType.JANG, new Jang(new SingleStepMoveStrategy(palaceMoveRule), team));
        pieces.put(PieceType.CHA, new Cha(new SlidingMoveStrategy(palaceMoveRule), team));
        pieces.put(PieceType.JOL, new Jol(new JolMoveStrategy(palaceMoveRule), team));
        pieces.put(PieceType.BYEONG, new Byeong(new ByeongMoveStrategy(palaceMoveRule), team));
        return pieces;
    }

    public Map&amp;lt;Position, Piece&amp;gt; setup(SettingType choSettingType, SettingType hanSettingType) {
        Map&amp;lt;Position, Piece&amp;gt; defaultSetting = initPiecesWithoutSangAndMa();
        initSangAndMa(choSettingType, hanSettingType, defaultSetting);

        return defaultSetting;
    }

    {아래 생략}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class Board {
    private final Map&amp;lt;Position, Piece&amp;gt; pieces;

    private Board(Map&amp;lt;Position, Piece&amp;gt; pieces) {
        this.pieces = pieces;
    }

    public static Board of(SettingType choSettingType, SettingType hanSettingType) {
        Map&amp;lt;Position, Piece&amp;gt; setup = new BoardInitializer().setup(choSettingType, hanSettingType);
        return new Board(setup);
    }

    {생략}

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;반박 너무 환영합니다.&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;반박해주시면 정말 감사하겠습니다.&lt;/p&gt;</description>
      <category>OOP</category>
      <category>객체지향</category>
      <category>상속</category>
      <category>장기</category>
      <category>조합</category>
      <category>합성</category>
      <author>rati</author>
      <guid isPermaLink="true">https://rati.tistory.com/3</guid>
      <comments>https://rati.tistory.com/3#entry3comment</comments>
      <pubDate>Fri, 3 Apr 2026 17:10:16 +0900</pubDate>
    </item>
    <item>
      <title>Avoid Unnecessary Implicit Joins when using QueryDSL</title>
      <link>https://rati.tistory.com/2</link>
      <description>&lt;h1&gt;Background&lt;/h1&gt;
&lt;p&gt;I got advice from &lt;code&gt;gemini3&lt;/code&gt; when I was developing a feature. That feature allows users to accept or reject friend requests.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Gemini3&lt;/code&gt; recommended using ID when querying friend candidates. But from the &lt;code&gt;adapter&lt;/code&gt; layer to the &lt;code&gt;application&lt;/code&gt; layer, the using user email is an easier method. ( I love &lt;code&gt;@AuthenticationPrincipal&lt;/code&gt; from Spring Framework !)&lt;/p&gt;
&lt;p&gt;So I decided to compare the performance between using email and using id when querying that.&lt;/p&gt;
&lt;h1&gt;Test&lt;/h1&gt;
&lt;h3&gt;Target Method&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Repository
class SpringQueryDSLUserRepository(private val queryFactory: JPAQueryFactory) {

    private val user = QUserJpaEntity.userJpaEntity

    // 1. Email-based (Nested subquery - relatively slow)
    fun searchByEmail(userEmail: String, keyword: String): List&amp;lt;UserJpaEntity&amp;gt; {
        return queryFactory.selectFrom(user)
            .where(
                isNicknameMatching(keyword).or(isEmailMatching(keyword)),
                isActive(),
                user.email.ne(userEmail),
                isNotAlreadyFriendEmail(userEmail),
                isNotUnderRequestEmail(userEmail),
                isNotRestrictedEmail(userEmail)
            ).fetch()
    }

    // 2. ID-based (Using constant - relatively fast)
    fun searchById(currentUserId: UUID, keyword: String): List&amp;lt;UserJpaEntity&amp;gt; {
        return queryFactory.selectFrom(user)
            .where(
                isNicknameMatching(keyword).or(isEmailMatching(keyword)),
                isActive(),
                user.id.ne(currentUserId),
                // Directly pass the already extracted ID (constant)
                isNotAlreadyFriend(currentUserId),
                isNotUnderRequest(currentUserId),
                isNotRestricted(currentUserId)
            ).fetch()
    }

    // Email-based (for subquery)
    private fun isNotAlreadyFriendEmail(userEmail: String): BooleanExpression {
        val relationships = QFriendRelationshipsJpaEntity.friendRelationshipsJpaEntity
        return user.email.notIn(
            JPAExpressions
                .select(relationships.friendUser.email)
                .from(relationships)
                .where(
                    relationships.ownerUser.email.eq(userEmail)
                )
        )
    }

    // ID-based (for constant)
    private fun isNotAlreadyFriend(currentUserId: UUID): BooleanExpression {
        val relationships = QFriendRelationshipsJpaEntity.friendRelationshipsJpaEntity
        return user.id.notIn(
            JPAExpressions.select(relationships.friendUser.id)
                .from(relationships)
                .where(relationships.ownerUser.id.eq(currentUserId))
        )
    }

    // Email-based (for subquery)
    private fun isNotUnderRequestEmail(userEmail: String): BooleanExpression {
        val requests = QFriendRequestJpaEntity.friendRequestJpaEntity
        return user.email.notIn(
            JPAExpressions.select(requests.receiver.email).from(requests)
                .where(requests.requester.email.eq(userEmail))
        ).and(
            user.email.notIn(
                JPAExpressions.select(requests.requester.email).from(requests)
                    .where(requests.receiver.email.eq(userEmail))
            )
        )
    }

    // ID-based (for constant)
    private fun isNotUnderRequest(currentUserId: UUID): BooleanExpression {
        val requests = QFriendRequestJpaEntity.friendRequestJpaEntity
        return user.id.notIn(
            JPAExpressions.select(requests.receiver.id).from(requests).where(requests.requester.id.eq(currentUserId))
        ).and(
            user.id.notIn(
                JPAExpressions.select(requests.requester.id).from(requests)
                    .where(requests.receiver.id.eq(currentUserId))
            )
        )
    }

    // Email-based (for subquery)
    private fun isNotRestrictedEmail(userEmail: String): BooleanExpression {
        val restrictions = QFriendRestrictionJpaEntity.friendRestrictionJpaEntity
        return user.email.notIn(
            JPAExpressions.select(restrictions.target.email).from(restrictions)
                .where(restrictions.actor.email.eq(userEmail))
        ).and(
            user.email.notIn(
                JPAExpressions.select(restrictions.actor.email).from(restrictions)
                    .where(restrictions.target.email.eq(userEmail))
            )
        )
    }

    // ID-based (for constant)
    private fun isNotRestricted(currentUserId: UUID): BooleanExpression {
        val restrictions = QFriendRestrictionJpaEntity.friendRestrictionJpaEntity
        return user.id.notIn(
            JPAExpressions.select(restrictions.target.id).from(restrictions)
                .where(restrictions.actor.id.eq(currentUserId))
        ).and(
            user.id.notIn(
                JPAExpressions.select(restrictions.actor.id).from(restrictions)
                    .where(restrictions.target.id.eq(currentUserId))
            )
        )
    }

    private fun isIdMatching(id: UUID): BooleanExpression = user.id.eq(id)

    private fun isEmailMatching(keyword: String): BooleanExpression = user.email.eq(keyword)

    private fun isNicknameMatching(keyword: String): BooleanExpression = user.nickname.eq(keyword)

    private fun isActive(): BooleanExpression = user.status.eq(UserStatus.ACTIVE)

}s.ACTIVE)

}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Test Logic&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@SpringBootTest
@Transactional
class PerformanceComparisonTest {

    @Autowired
    lateinit var userRepository: SpringDataUserRepository

    @Autowired
    lateinit var userRepositoryImpl: SpringQueryDSLUserRepository

    private lateinit var targetUserEmail: String
    private lateinit var targetUserId: UUID

    @BeforeEach
    fun setUp() {
        val me = userRepository.save(
            UserJpaEntity(
                &amp;quot;me@test.com&amp;quot;, &amp;quot;나&amp;quot;, UserRole.NORMAL,
                OAuth2Provider.KAKAO, UserStatus.ACTIVE
            )
        )
        targetUserEmail = me.email
        targetUserId = me.id!!

        (1..100_000).forEach { i -&amp;gt;
            userRepository.save(
                UserJpaEntity(
                    &amp;quot;user$i@test.com&amp;quot;,
                    &amp;quot;친구$i&amp;quot;,
                    UserRole.NORMAL,
                    OAuth2Provider.KAKAO,
                    UserStatus.ACTIVE
                )
            )
        }
    }

    @Test
    @DisplayName(&amp;quot;Email 기반 vs ID 기반 검색 성능 비교&amp;quot;)
    fun comparePerformance() {
        // given
        val keyword = &amp;quot;친구&amp;quot;
        val stopWatch = org.springframework.util.StopWatch()

        // when
        stopWatch.start(&amp;quot;Email Based (Subquery)&amp;quot;)
        val result1 = userRepositoryImpl.searchByEmail(targetUserEmail, keyword)
        stopWatch.stop()

        stopWatch.start(&amp;quot;ID Based (Constant)&amp;quot;)
        val currentUserId = userRepository.findByEmail(targetUserEmail)?.id!!
        val result2 = userRepositoryImpl.searchById(currentUserId, keyword)
        stopWatch.stop()

        println(stopWatch.prettyPrint())

        // then
        assertThat(result1.size).isEqualTo(result2.size)

        val emailBasedTime = stopWatch.taskInfo[0].timeMillis
        val idBasedTime = stopWatch.taskInfo[1].timeMillis

        println(&amp;quot;Email Based: ${emailBasedTime}ms&amp;quot;)
        println(&amp;quot;ID Based: ${idBasedTime}ms&amp;quot;)

        assertThat(idBasedTime).isLessThanOrEqualTo(emailBasedTime)
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Test Result&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;================================================================================
[Query 1] Email-Based Search (took 130ms)
================================================================================

2026-02-13T21:58:59.157+09:00  INFO 53924 --- [IMHERE-USER-SERVICE] [    Test worker] p6spy
#1770987539157 | took 130ms | statement | connection 4| url jdbc:h2:mem:1cea8f7b-11ce-42b1-ab96-e9c9bde72501

-- Parameterized Query:
select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where (uje1_0.nickname=? or uje1_0.email=?) 
  and uje1_0.status=? 
  and uje1_0.email&amp;lt;&amp;gt;? 
  and uje1_0.email not in (
    select fu1_0.email 
    from friend_relationships frje1_0 
    join users fu1_0 on fu1_0.id=frje1_0.friend_user_id 
    join users ou1_0 on ou1_0.id=frje1_0.owner_user_id 
    where ou1_0.email=?
  ) 
  and (uje1_0.email not in (
    select r1_0.email 
    from friend_request frje2_0 
    join users r1_0 on r1_0.id=frje2_0.receiver_id 
    join users r2_0 on r2_0.id=frje2_0.requester_id 
    where r2_0.email=?
  ) and uje1_0.email not in (
    select r3_0.email 
    from friend_request frje3_0 
    join users r3_0 on r3_0.id=frje3_0.requester_id 
    join users r4_0 on r4_0.id=frje3_0.receiver_id 
    where r4_0.email=?
  )) 
  and (uje1_0.email not in (
    select t1_0.email 
    from friend_restrictions frje4_0 
    join users t1_0 on t1_0.id=frje4_0.target_id 
    join users a1_0 on a1_0.id=frje4_0.actor_id 
    where a1_0.email=?
  ) and uje1_0.email not in (
    select a2_0.email 
    from friend_restrictions frje5_0 
    join users a2_0 on a2_0.id=frje5_0.actor_id 
    join users t2_0 on t2_0.id=frje5_0.target_id 
    where t2_0.email=?
  ))

-- Actual Values:
select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where (uje1_0.nickname=&amp;#39;친구&amp;#39; or uje1_0.email=&amp;#39;친구&amp;#39;) 
  and uje1_0.status=&amp;#39;ACTIVE&amp;#39; 
  and uje1_0.email&amp;lt;&amp;gt;&amp;#39;me@test.com&amp;#39; 
  and uje1_0.email not in (
    select fu1_0.email 
    from friend_relationships frje1_0 
    join users fu1_0 on fu1_0.id=frje1_0.friend_user_id 
    join users ou1_0 on ou1_0.id=frje1_0.owner_user_id 
    where ou1_0.email=&amp;#39;me@test.com&amp;#39;
  ) 
  and (uje1_0.email not in (
    select r1_0.email 
    from friend_request frje2_0 
    join users r1_0 on r1_0.id=frje2_0.receiver_id 
    join users r2_0 on r2_0.id=frje2_0.requester_id 
    where r2_0.email=&amp;#39;me@test.com&amp;#39;
  ) and uje1_0.email not in (
    select r3_0.email 
    from friend_request frje3_0 
    join users r3_0 on r3_0.id=frje3_0.requester_id 
    join users r4_0 on r4_0.id=frje3_0.receiver_id 
    where r4_0.email=&amp;#39;me@test.com&amp;#39;
  )) 
  and (uje1_0.email not in (
    select t1_0.email 
    from friend_restrictions frje4_0 
    join users t1_0 on t1_0.id=frje4_0.target_id 
    join users a1_0 on a1_0.id=frje4_0.actor_id 
    where a1_0.email=&amp;#39;me@test.com&amp;#39;
  ) and uje1_0.email not in (
    select a2_0.email 
    from friend_restrictions frje5_0 
    join users a2_0 on a2_0.id=frje5_0.actor_id 
    join users t2_0 on t2_0.id=frje5_0.target_id 
    where t2_0.email=&amp;#39;me@test.com&amp;#39;
  ))

================================================================================
[Query 2] User Lookup (took 0ms)
================================================================================

2026-02-13T21:58:59.280+09:00  INFO 53924 --- [IMHERE-USER-SERVICE] [    Test worker] p6spy
#1770987539280 | took 0ms | statement | connection 4| url jdbc:h2:mem:1cea8f7b-11ce-42b1-ab96-e9c9bde72501

-- Parameterized Query:
select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where uje1_0.email=?

-- Actual Values:
select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where uje1_0.email=&amp;#39;me@test.com&amp;#39;

================================================================================
[Query 3] ID-Based Search (took 38ms)
================================================================================

2026-02-13T21:58:59.390+09:00  INFO 53924 --- [IMHERE-USER-SERVICE] [    Test worker] p6spy
#1770987539390 | took 38ms | statement | connection 4| url jdbc:h2:mem:1cea8f7b-11ce-42b1-ab96-e9c9bde72501

-- Parameterized Query:
select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where (uje1_0.nickname=? or uje1_0.email=?) 
  and uje1_0.status=? 
  and uje1_0.id&amp;lt;&amp;gt;? 
  and uje1_0.id not in (
    select frje1_0.friend_user_id 
    from friend_relationships frje1_0 
    where frje1_0.owner_user_id=?
  ) 
  and (uje1_0.id not in (
    select frje2_0.receiver_id 
    from friend_request frje2_0 
    where frje2_0.requester_id=?
  ) and uje1_0.id not in (
    select frje3_0.requester_id 
    from friend_request frje3_0 
    where frje3_0.receiver_id=?
  )) 
  and (uje1_0.id not in (
    select frje4_0.target_id 
    from friend_restrictions frje4_0 
    where frje4_0.actor_id=?
  ) and uje1_0.id not in (
    select frje5_0.actor_id 
    from friend_restrictions frje5_0 
    where frje5_0.target_id=?
  ))

-- Actual Values:
select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where (uje1_0.nickname=&amp;#39;친구&amp;#39; or uje1_0.email=&amp;#39;친구&amp;#39;) 
  and uje1_0.status=&amp;#39;ACTIVE&amp;#39; 
  and uje1_0.id&amp;lt;&amp;gt;&amp;#39;abdfed91-cadb-480d-b2c1-7b592f57de1a&amp;#39; 
  and uje1_0.id not in (
    select frje1_0.friend_user_id 
    from friend_relationships frje1_0 
    where frje1_0.owner_user_id=&amp;#39;abdfed91-cadb-480d-b2c1-7b592f57de1a&amp;#39;
  ) 
  and (uje1_0.id not in (
    select frje2_0.receiver_id 
    from friend_request frje2_0 
    where frje2_0.requester_id=&amp;#39;abdfed91-cadb-480d-b2c1-7b592f57de1a&amp;#39;
  ) and uje1_0.id not in (
    select frje3_0.requester_id 
    from friend_request frje3_0 
    where frje3_0.receiver_id=&amp;#39;abdfed91-cadb-480d-b2c1-7b592f57de1a&amp;#39;
  )) 
  and (uje1_0.id not in (
    select frje4_0.target_id 
    from friend_restrictions frje4_0 
    where frje4_0.actor_id=&amp;#39;abdfed91-cadb-480d-b2c1-7b592f57de1a&amp;#39;
  ) and uje1_0.id not in (
    select frje5_0.actor_id 
    from friend_restrictions frje5_0 
    where frje5_0.target_id=&amp;#39;abdfed91-cadb-480d-b2c1-7b592f57de1a&amp;#39;
  ))

================================================================================
Performance Results
================================================================================

StopWatch &amp;#39;&amp;#39;: 9.4650058 seconds
----------------------------------------
Seconds       %       Task name
----------------------------------------
9.2751745     98%     Email Based (Subquery)
0.1898313     02%     ID Based (Constant)

Email Based: 9275ms
ID Based: 189ms&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Summary&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Email-based query&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;9,275ms (98% of total time)&lt;/li&gt;
&lt;li&gt;Uses subqueries with multiple JOIN operations on the &lt;code&gt;users&lt;/code&gt; table, resulting in nested loop joins&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ID-based query&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;189ms (2% of total time)&lt;/li&gt;
&lt;li&gt;Uses direct ID constants in subqueries, eliminating unnecessary table joins&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Root cause&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;The email-based approach requires email information to compare entities, which causes multiple joins to resolve email-to-ID mappings. Additionally, each subquery operates independently without sharing memory or intermediate results.&lt;/li&gt;
&lt;li&gt;But ID-based approach don’t have to multiple join to resolve email.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To understand this case, I have to be reminded about &lt;code&gt;database&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Database&lt;/h1&gt;
&lt;h2&gt;Required Knowledge 1: How Database Engines Find Targets&lt;/h2&gt;
&lt;h3&gt;Index Seek&lt;/h3&gt;
&lt;p&gt;Databases jump immediately using indexes. We call this &lt;code&gt;index seek&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Then, how?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;B-Tree Architecture&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;erDiagram
    INTERNAL_PAGE ||--o{ LEAF_PAGE_P00 : &amp;quot;Seek Path&amp;quot;
    INTERNAL_PAGE ||--o{ LEAF_PAGE_P01 : &amp;quot;Seek Path&amp;quot;
    INTERNAL_PAGE ||--o{ LEAF_PAGE_P02 : &amp;quot;Seek Path&amp;quot;
    INTERNAL_PAGE ||--o{ LEAF_PAGE_P10 : &amp;quot;Seek Path&amp;quot;
    INTERNAL_PAGE ||--o{ LEAF_PAGE_P11 : &amp;quot;Seek Path&amp;quot;

    %% Horizontal Scan Path (Linked List)
0    LEAF_PAGE_P00 ||--|| LEAF_PAGE_P1 : &amp;quot;Next&amp;quot;
    LEAF_PAGE_P01 ||--|| LEAF_PAGE_P02 : &amp;quot;Next&amp;quot;
    LEAF_PAGE_P02 ||--|| LEAF_PAGE_P10 : &amp;quot;Next&amp;quot;
    LEAF_PAGE_P10 ||--|| LEAF_PAGE_P11 : &amp;quot;Next&amp;quot;

    LEAF_PAGE_P00 {
        string keys &amp;quot;1 ~ 100&amp;quot;
        pointer_to_data row_1_to_100 &amp;quot;RID/PK&amp;quot;
    }
    LEAF_PAGE_P01 {
        string keys &amp;quot;101 ~ 200&amp;quot;
        pointer_to_data row_101_to_200 &amp;quot;RID/PK&amp;quot;
    }
    LEAF_PAGE_P02 {
        string keys &amp;quot;201 ~ 500&amp;quot;
        pointer_to_data row_201_to_500 &amp;quot;RID/PK&amp;quot;
    }
    LEAF_PAGE_P10 {
        string keys &amp;quot;501 ~ 700&amp;quot;
        pointer_to_data row_501_to_700 &amp;quot;RID/PK&amp;quot;
    }
    LEAF_PAGE_P11 {
        string keys &amp;quot;701 ~ 1000&amp;quot;
        pointer_to_data row_701_to_1000 &amp;quot;RID/PK&amp;quot;
    }

    %% Data Mapping
    LEAF_PAGE_P11 ||--o{ USERS_TABLE : &amp;quot;Fetch actual data&amp;quot;
    USERS_TABLE {
        UUID user_id PK
        string email
        string nickname
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key Characteristics of B-Tree Structure&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Root Node&lt;/strong&gt;: The topmost node that manages the entire key range&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Branch Nodes&lt;/strong&gt;: Intermediate nodes that further partition the key space into smaller ranges&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leaf Nodes&lt;/strong&gt;: Bottom-level nodes containing actual data pointers (RIDs or primary keys), linked sequentially for efficient range scans&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Balanced Tree Property&lt;/strong&gt;: All leaf nodes are at the same depth, guaranteeing predictable O(log n) search performance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Index Seek Example&lt;/strong&gt;: Query execution for &lt;code&gt;WHERE id = 150&lt;/code&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start at Root Node → Compare key: &amp;quot;150 falls within range 1~500&amp;quot;&lt;/li&gt;
&lt;li&gt;Traverse to Branch1 → Narrow down: &amp;quot;150 falls within range 101~200&amp;quot;&lt;/li&gt;
&lt;li&gt;Reach Leaf2 → Retrieve data pointer for key 150&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total of 3 node accesses&lt;/strong&gt; to locate the target record among tens of thousands of rows&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Index Scan&lt;/h3&gt;
&lt;p&gt;When the database &lt;strong&gt;cannot use Index Seek,&lt;/strong&gt; it falls back to &lt;strong&gt;Index Scan.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Index Scan Example&lt;/strong&gt;: Query execution for &lt;code&gt;WHERE status = &amp;#39;ACTIVE&amp;#39;&lt;/code&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start at the leftmost Leaf Node (beginning of the linked list)&lt;/li&gt;
&lt;li&gt;Read through each leaf node sequentially, checking every entry&amp;#39;s status value&lt;/li&gt;
&lt;li&gt;Continue traversing via &amp;quot;Next&amp;quot; pointers until reaching the end&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Result&lt;/strong&gt;: All leaf nodes are read, though still faster than a full table scan since index pages are smaller and sequential&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Key Differences: Seek vs Scan&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Aspect&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Index Seek&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Index Scan&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Access Pattern&lt;/td&gt;
&lt;td&gt;Direct jump via B-Tree navigation&lt;/td&gt;
&lt;td&gt;Sequential read through leaf nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages Read&lt;/td&gt;
&lt;td&gt;3-4 nodes (Root → Branch → Leaf)&lt;/td&gt;
&lt;td&gt;All leaf nodes in range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When Used&lt;/td&gt;
&lt;td&gt;Equality/range on indexed columns&lt;/td&gt;
&lt;td&gt;Non-selective filters, missing WHERE clause&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Required Knowledge 2: How Database Engines Deal with JOIN Commands&lt;/h2&gt;
&lt;p&gt;Database Engine (i.e., Optimizer) normally uses 3 algorithms when performing JOIN operations. These are &lt;code&gt;Nested Loop Join&lt;/code&gt;, &lt;code&gt;Hash Join&lt;/code&gt;, and &lt;code&gt;Sort Merge Join&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;1. Inner And Outer Tables&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Above all, it&amp;#39;s crucial to understand the concepts of &lt;strong&gt;inner and outer tables.&lt;/strong&gt; However, we don&amp;#39;t necessarily need to dictate their roles when writing SQL, as the &lt;strong&gt;Optimizer determines&lt;/strong&gt; the most efficient execution plan. &lt;/p&gt;
&lt;p&gt;To be efficient, before performing JOIN operations, the Optimizer takes some actions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;STEP 1: Selection of the Outer (Driving) Table&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Optimizer &lt;strong&gt;reduces the target dataset size&lt;/strong&gt; by applying &lt;strong&gt;filtering conditions&lt;/strong&gt; (via &lt;code&gt;WHERE&lt;/code&gt; clauses).&lt;/li&gt;
&lt;li&gt;The table with the &lt;strong&gt;smallest&lt;/strong&gt; result set after filtering ⇒ &lt;strong&gt;Outer (Driving) Table&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Purpose&lt;/strong&gt; : &lt;strong&gt;To minimize the total number of iterations&lt;/strong&gt; required for subsequent join operations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;STEP 2: Selection of the Inner (Driven) Table&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Optimizer &lt;strong&gt;actively prefers&lt;/strong&gt; designating a table as the &lt;strong&gt;Inner (Driven) Table&lt;/strong&gt; if it has a usable index on the join key.&lt;ul&gt;
&lt;li&gt;⇒ To be efficient via &lt;code&gt;index seek&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;STEP 3: Algorithm Selection&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The Optimizer normally considers 4 elements to select algorithm between &lt;code&gt;Nested Loop Join&lt;/code&gt; &lt;code&gt;Hash Join&lt;/code&gt; &lt;code&gt;Sort Merge Join&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cardinality &amp;amp; Selectivity&lt;/strong&gt;: Estimating the number of rows after filtering to decide the join order.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Index Presence&lt;/strong&gt;: Checking if an &lt;strong&gt;Index Seek&lt;/strong&gt; is possible for the driven table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;System Resources&lt;/strong&gt;: Evaluating the available memory for &lt;strong&gt;Hash Table&lt;/strong&gt; creation or &lt;strong&gt;Sorting&lt;/strong&gt; operations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost Aggregation&lt;/strong&gt;: Calculating the sum of &lt;strong&gt;Disk I/O&lt;/strong&gt; and &lt;strong&gt;CPU cycles&lt;/strong&gt; to find the path with the minimum &amp;quot;Total Cost.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;2. Nested Loop Join&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cardinality &amp;amp; Selectivity&lt;/strong&gt;: The outer table has a small result set after filtering (low cardinality), minimizing the number of iterations.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Index Presence&lt;/strong&gt;: The inner table has an index on the join key, enabling fast index seeks for each outer row.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;System Resources&lt;/strong&gt;: Limited memory available; no need to build large hash tables or sort datasets.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost Aggregation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{Total Cost} = \sum_{i=1}^{n_{\text{outer}}} \left( C_{\text{seek}} + C_{\text{cpu}} \right)&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{where } C_{\text{seek}} = \log_b(N_{\text{inner}}) \text{ (B-Tree depth)}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{Efficient when: } n_{\text{outer}} \times \log(N_{\text{inner}}) &amp;lt; \text{cost}&lt;em&gt;{\text{hash}} + \text{cost}&lt;/em&gt;{\text{sort}}&lt;br&gt;  $$&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Time Complexity&lt;/strong&gt;: O(n_outer × log(n_inner)) - Number of outer rows × B-Tree search cost&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It must use index seek&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;// Outer Table (Driving Table)
for each row_outer in outer_table:

  // Inner Table (Driven Table) - Index Seek 수행
  matching_rows = index_seek(inner_table, join_key = row_outer.key)

  for each row_inner in matching_rows:
      // 조인 조건이 만족하면 결과에 추가
      if join_condition(row_outer, row_inner):
          result.add(merge(row_outer, row_inner))&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;3. Hash Join&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cardinality &amp;amp; Selectivity&lt;/strong&gt;: Both tables are large, and neither can be significantly reduced through filtering.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Index Presence&lt;/strong&gt;: No suitable index exists on the join key, or using the index would be less efficient than sequential scans.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;System Resources&lt;/strong&gt;: Sufficient memory is available to build a hash table from the smaller table (or partition).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost Aggregation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{Total Cost} = C_{\text{build}} + C_{\text{probe}}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{where } C_{\text{build}} = n_{\text{smaller}} \text{ (sequential scan)}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{and } C_{\text{probe}} = n_{\text{larger}} \times O(1) \text{ (hash lookup)}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{Efficient when: } n_{\text{smaller}} + n_{\text{larger}} &amp;lt; \text{cost}&lt;em&gt;{\text{nested}} + \text{cost}&lt;/em&gt;{\text{sort}}&lt;br&gt;  $$&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Time Complexity&lt;/strong&gt;: O(n_smaller + n_larger) - Linear scan with hash lookups&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No index required; uses sequential I/O&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;// Phase 1: Build Hash Table from Smaller Table
hash_table = {}
for each row in smaller_table:
  key = row.join_key
  hash_table[key] = row  // O(1) insertion

// Phase 2: Probe Hash Table with Larger Table
for each row_outer in larger_table:
  key = row_outer.join_key

  // Hash lookup - O(1) average case
  if key in hash_table:
      row_inner = hash_table[key]
      if join_condition(row_outer, row_inner):
          result.add(merge(row_outer, row_inner))&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;4. Sort Merge Join&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cardinality &amp;amp; Selectivity&lt;/strong&gt;: Both tables are large, but hash join would require too much memory.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Index Presence&lt;/strong&gt;: Data is already sorted (e.g., via clustered index), or the join condition involves range comparisons rather than equality.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;System Resources&lt;/strong&gt;: Insufficient memory for hash tables, but disk space is available for sort operations.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost Aggregation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{Total Cost} = C_{\text{sort1}} + C_{\text{sort2}} + C_{\text{merge}}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{where } C_{\text{sort}} = n \log(n) \text{ (if not pre-sorted)}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{and } C_{\text{merge}} = n_{\text{table1}} + n_{\text{table2}} \text{ (linear scan)}&lt;br&gt;  $$&lt;/p&gt;
&lt;p&gt;  $$&lt;br&gt;  \text{Efficient when: } 2n\log(n) + (n_1 + n_2) &amp;lt; \text{cost}&lt;em&gt;{\text{nested}} + \text{cost}&lt;/em&gt;{\text{hash}}&lt;br&gt;  $$&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Time Complexity&lt;/strong&gt;: O(n log n + m log m) - Sorting both tables, then O(n + m) for merge&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pre-sorted data (via index) eliminates sorting cost&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;// Phase 1: Sort Both Tables (if not already sorted via index)
sorted_table1 = sort(table1, join_key)  // O(n log n)
sorted_table2 = sort(table2, join_key)  // O(m log m)

// Phase 2: Merge Sorted Tables
pointer1 = 0
pointer2 = 0

while pointer1 &amp;amp;lt; len(sorted_table1) and pointer2 &amp;amp;lt; len(sorted_table2):
  row1 = sorted_table1[pointer1]
  row2 = sorted_table2[pointer2]

  if row1.join_key == row2.join_key:
      // Match found
      if join_condition(row1, row2):
          result.add(merge(row1, row2))
      pointer1++
      pointer2++
  else if row1.join_key &amp;amp;lt; row2.join_key:
      pointer1++  // Advance smaller key
  else:
      pointer2++  // Advance smaller key&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Reason of Gemini3’s Advice&lt;/h1&gt;
&lt;h3&gt;Entity Relationship Diagram&lt;/h3&gt;
&lt;p&gt;It is my service’s ERD.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;erDiagram
    USERS {
        UUID user_id PK &amp;quot;사용자 id&amp;quot;
        VARCHAR(255) email &amp;quot;이메일&amp;quot;
        VARCHAR(255) nickname &amp;quot;닉네임&amp;quot;
        ENUM role &amp;quot;역할&amp;quot;
        ENUM provider &amp;quot;OAuth2 제공자&amp;quot;
        ENUM status &amp;quot;상태(PENDING, ACTIVE)&amp;quot;
        TIMESTAMP created_at &amp;quot;가입일&amp;quot;
        TIMESTAMP updated_at &amp;quot;마지막 업데이트&amp;quot;
    }

    FRIEND_REQUESTS {
        BIGINT id PK &amp;quot;친구 요청 id&amp;quot;
        UUID requester_id FK &amp;quot;신청자 UUID&amp;quot;
        UUID receiver_id FK &amp;quot;수신자 UUID&amp;quot;
        VARCHAR(255) message &amp;quot;요청 메시지&amp;quot;
        TIMESTAMP created_at &amp;quot;신청 일자&amp;quot;
    }

    FRIEND_RELATIONSHIPS {
        BIGINT id PK &amp;quot;확정 관계 id&amp;quot;
        UUID owner_user_id FK &amp;quot;기준 사용자 UUID&amp;quot;
        UUID friend_user_id FK &amp;quot;친구 UUID&amp;quot;
        VARCHAR(255) friend_alias &amp;quot;내가 설정한 친구 이름&amp;quot;
        TIMESTAMP created_at &amp;quot;친구 관계 수립 일시&amp;quot;
    }

    FRIEND_RESTRICTIONS {
        BIGINT id PK &amp;quot;거절/차단 id&amp;quot;
        UUID actor_id FK &amp;quot;차단/거절을 수행한 사용자&amp;quot;
        UUID target_id FK &amp;quot;차단/거절 대상 UUID&amp;quot;
        ENUM type &amp;quot;타입(BLOCK, REJECT)&amp;quot;
        TIMESTAMP created_at &amp;quot;차단/거절 시점&amp;quot;
    }

    USERS ||--o{ FRIEND_REQUESTS : &amp;quot;requests(sent)&amp;quot;
    USERS ||--o{ FRIEND_REQUESTS : &amp;quot;requests(received)&amp;quot;
    USERS ||--o{ FRIEND_RELATIONSHIPS : &amp;quot;friends(owner)&amp;quot;
    USERS ||--o{ FRIEND_RELATIONSHIPS : &amp;quot;friends(friend)&amp;quot;
    USERS ||--o{ FRIEND_RESTRICTIONS : &amp;quot;restrictions(actor)&amp;quot;
    USERS ||--o{ FRIEND_RESTRICTIONS : &amp;quot;restrictions(target)&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this structure, I needed to find users who could become friends based on the current user&amp;#39;s email and the target user&amp;#39;s email or nickname.&lt;/p&gt;
&lt;p&gt;The conditions for users who could become friends were set as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Exclude self&lt;/li&gt;
&lt;li&gt;No pending friend requests&lt;/li&gt;
&lt;li&gt;Not currently friends&lt;/li&gt;
&lt;li&gt;No blocks or rejections&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Email based Query’s Problem&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select uje1_0.id, uje1_0.created_at, uje1_0.email, uje1_0.nickname, 
       uje1_0.provider, uje1_0.role, uje1_0.status, uje1_0.updated_at 
from users uje1_0 
where (uje1_0.nickname=&amp;#39;친구&amp;#39; or uje1_0.email=&amp;#39;친구&amp;#39;) 
  and uje1_0.status=&amp;#39;ACTIVE&amp;#39; 
  and uje1_0.email&amp;lt;&amp;gt;&amp;#39;me@test.com&amp;#39; 
  and uje1_0.email not in (
    select fu1_0.email 
    from friend_relationships frje1_0 
    join users fu1_0 on fu1_0.id=frje1_0.friend_user_id 
    join users ou1_0 on ou1_0.id=frje1_0.owner_user_id 
    where ou1_0.email=&amp;#39;me@test.com&amp;#39;
  ) 
  and (uje1_0.email not in (
    select r1_0.email 
    from friend_request frje2_0 
    join users r1_0 on r1_0.id=frje2_0.receiver_id 
    join users r2_0 on r2_0.id=frje2_0.requester_id 
    where r2_0.email=&amp;#39;me@test.com&amp;#39;
  ) and uje1_0.email not in (
    select r3_0.email 
    from friend_request frje3_0 
    join users r3_0 on r3_0.id=frje3_0.requester_id 
    join users r4_0 on r4_0.id=frje3_0.receiver_id 
    where r4_0.email=&amp;#39;me@test.com&amp;#39;
  )) 
  and (uje1_0.email not in (
    select t1_0.email 
    from friend_restrictions frje4_0 
    join users t1_0 on t1_0.id=frje4_0.target_id 
    join users a1_0 on a1_0.id=frje4_0.actor_id 
    where a1_0.email=&amp;#39;me@test.com&amp;#39;
  ) and uje1_0.email not in (
    select a2_0.email 
    from friend_restrictions frje5_0 
    join users a2_0 on a2_0.id=frje5_0.actor_id 
    join users t2_0 on t2_0.id=frje5_0.target_id 
    where t2_0.email=&amp;#39;me@test.com&amp;#39;
  )
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;1. Cannot Use Index Seek&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It maybe use &lt;code&gt;index scan&lt;/code&gt; or &lt;code&gt;full table scan&lt;/code&gt;. Not &lt;code&gt;Index Seek&lt;/code&gt; . Because of &lt;code&gt;email&lt;/code&gt; is not a ID.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;2. Requires 6 Additional JOINs to Resolve Email Values&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The email-based query creates 6 times &lt;strong&gt;implicit join&lt;/strong&gt; pattern  to resolve email addresses in subqueries.&lt;/li&gt;
&lt;li&gt;HOWEVER, The ID-based approach eliminates these 6 JOINs entirely by using direct UUID comparisons like &lt;code&gt;WHERE owner_user_id = {constant UUID}&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;The Optimizer can pre-compute and cache subquery results(reduce from &lt;code&gt;WHERE&lt;/code&gt; clause, before &lt;code&gt;JOIN&lt;/code&gt; operation.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>ImHere 프로젝트</category>
      <category>Implicit Joins</category>
      <category>queryDSL</category>
      <author>rati</author>
      <guid isPermaLink="true">https://rati.tistory.com/2</guid>
      <comments>https://rati.tistory.com/2#entry2comment</comments>
      <pubDate>Thu, 19 Mar 2026 23:33:28 +0900</pubDate>
    </item>
    <item>
      <title>Jackson 3 변경으로 인한 Redis 역직렬화 장애 분석</title>
      <link>https://rati.tistory.com/1</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/kdongsu5509/imhere_server&quot;&gt;ImHere 프로젝트&lt;/a&gt;는 로그인 및 회원가입 과정에서 카카오의 OIDC(OpenID Connect)를 활용하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#oidc-find-public-key&quot;&gt;카카오 공식 문서&lt;/a&gt;에서는 OIDC 서명 검증에 필요한 공개 키를 매 요청마다 조회하는 대신, 일정 시간 캐싱하여 사용할 것을 권장하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 따라 본 프로젝트에서는 카카오 공개키를 캐싱하여 사용하고 있으며, 추가로 자체적으로 사용하는 refresh-token 역시 데이터베이스가 아닌 캐시 기반으로 관리하도록 설계하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로젝트는 기존 MVP 형태로 배포했던 서비스를 리팩토링하여 재운영할 계획이기 때문에, 확장성과 성능을 고려하여 캐시 저장소로 Redis를 채택하였습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 단순한 서비스 구현을 넘어 학습 목적도 함께 가지고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 따라 최신 기술 스택 경험을 위해 &lt;b&gt;Spring Boot 4로의 마이그레이션&lt;/b&gt;을 진행하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 4는 여러 주요 변경 사항을 포함하고 있으며, 그중 하나가 &lt;b&gt;Jackson 라이브러리의 2.x &amp;rarr; 3.x 업그레이드&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson 2.x는 과거 &lt;a href=&quot;https://cowtowncoder.medium.com/on-jackson-cves-dont-panic-here-is-what-you-need-to-know-54cd0d6e8062&quot;&gt;Remote Code Execution(RCE) 취약점 이슈&lt;/a&gt;가 있었으며, 이를 완화하기 위해 PolymorphicTypeValidator가 도입되었습니다. 또한 &lt;a href=&quot;https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/latest/com/fasterxml/jackson/databind/ObjectMapper.DefaultTyping.html&quot;&gt;ObjectMapper.DefaultTyping.EVERYTHING 옵션은 보안 문제로 인해 deprecated 처리&lt;/a&gt;되었고, 이후 &lt;a href=&quot;https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md&quot;&gt;Jackson 3에서 완전히 제거&lt;/a&gt;되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/FasterXML/jackson-databind/issues/4160&quot;&gt;DefaultTyping.EVERYTHING 삭제 결정 Github Issu&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;All&amp;nbsp;@Deprecated&amp;nbsp;(as of 2.20) methods, fields and classes are removed from 3.0
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Javadocs in Jackson&amp;nbsp;2.20&amp;nbsp;updated to indicate replacements where available (incomplete: PRs welcome for more!)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 공개키를 Redis에 캐싱하는 과정에서, 다음과 같은 구조를 사용하고 있습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Component
class KakaoOauthClient(
    restClientBuilder: RestClient.Builder
) : OauthClientPort {

    companion object {
        private val log = LoggerFactory.getLogger(KakaoOauthClient::class.java)
        const val KAKAO_BASE_URL = &quot;&amp;lt;https://kauth.kakao.com&amp;gt;&quot;
        const val KEY_REQ_URL = &quot;/.well-known/jwks.json&quot;
    }

    private val restClient: RestClient = restClientBuilder
        .baseUrl(KAKAO_BASE_URL)
        .build()

    @Cacheable(value = [&quot;kakaoOidcKeys&quot;], cacheManager = &quot;oidcCacheManager&quot;, key = &quot;'kakaoPublicKeySet'&quot;)
    override fun getPublicKeyFromProvider(): OIDCPublicKeyResponse? {
        return fetchPublicKey()
    }

    private fun fetchPublicKey(): OIDCPublicKeyResponse? {
        return restClient.get()
            .uri(KEY_REQ_URL)
            .retrieve()
            .body()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공개키를 받은 즉시 OIDCPublicKeyResponse 로 매핑하는데, OIDCPublicKeyResponse 는 Kotlin의 data class로 정의되어 있습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;data class OIDCPublicKeyResponse(
    val keys: List&amp;lt;OIDCPublicKey&amp;gt; = emptyList()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 캐시 조회 시점에서 발생했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
 
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #467] 
	at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer.deserialize(GenericJacksonJsonRedisSerializer.java:212) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer.deserialize(GenericJacksonJsonRedisSerializer.java:182) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.core.AbstractOperations.deserializeValue(AbstractOperations.java:427) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.core.AbstractOperations$ValueDeserializingRedisCallback.doInRedis(AbstractOperations.java:67) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:414) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:381) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.core.AbstractOperations.execute(AbstractOperations.java:166) ~[spring-data-redis-4.0.3.jar:4.0.3]
	at org.springframework.data.redis.core.DefaultValueOperations.get(DefaultValueOperations.java:51) ~[spring-data-redis-4.0.3.jar:4.0.3]

{생략}

Caused by: tools.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
 
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #467]
	at tools.jackson.databind.exc.InvalidTypeIdException.from(InvalidTypeIdException.java:41) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.DeserializationContext.missingTypeIdException(DeserializationContext.java:2105) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.DeserializationContext.handleMissingTypeId(DeserializationContext.java:1690) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.jsontype.impl.TypeDeserializerBase._handleMissingTypeId(TypeDeserializerBase.java:285) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer._deserializeTypedUsingDefaultImpl(AsPropertyTypeDeserializer.java:173) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromObject(AsPropertyTypeDeserializer.java:112) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromAny(AsPropertyTypeDeserializer.java:203) ~[jackson-databind-3.0.4.jar:3.0.4]
	at tools.jackson.databind.deser.jdk.UntypedObjectDeserializerNR.deserializeWithType(UntypedObjectDeserializerNR.java:98) ~[jackson-databind-3.0.4.jar:3.0.4]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson은 내부적으로 DefaultTypeResolverBuilder를 사용하여 직렬화 및 역직렬화 시 타입 정보를 포함할지 여부를 결정합니다. 이때 핵심적인 판단 기준이 되는 메서드가 useForType이며, 해당 로직에서는 특정 타입에 대해 타입 정보를 포함할지 여부를 판단합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 useForType 메서드의 주석을 살펴보면, String, Boolean, Integer와 같은 기본 타입뿐과 아니라 &lt;b&gt;구체적(concrete)이고 final인 클래스는 타입 정보를 포함하지 않는 대상이 됩니다&lt;/b&gt;.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Method called to check if the default type handler should be used for given type. Note: &quot;natural types&quot; (String, Boolean, Integer, Double) will never use typing; that is both due to them being concrete and final, and since actual serializers and deserializers will also ignore any attempts to enforce typing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 Redis에는 단순한 JSON 형태의 데이터만 저장되게 되었고, 이를 나중에 다시 역직렬화 하는 과정에서 매핑할 클래스 정보를 알 수 없게 되어서 문제가 발생하게 되었습니다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Component
class RedisCacheAdapter(
    private val redisTemplate: RedisTemplate&amp;lt;String, String&amp;gt;,
    private val mapper: JsonMapper
) : CachePort {

    override fun save(key: String, data: Any, duration: Duration) {
        val jsonString = mapper.writeValueAsString(data)
        redisTemplate.opsForValue().set(key, jsonString, duration)
    }

    override fun &amp;lt;T&amp;gt; find(key: String): T? {
        val jsonString = redisTemplate.opsForValue().get(key) ?: return null

        return try {
            mapper.readValue(jsonString)
        } catch (e: Exception) {
            null
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 &lt;b&gt;애플리케이션 레벨에서 역직렬화 시 클래스 타입을 명시하였습니다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis에는 객체를 &lt;b&gt;JSON 문자열(String)&lt;/b&gt; 형태로 저장&lt;/li&gt;
&lt;li&gt;&amp;rArr; 조회 시에는 &lt;b&gt;명시적으로 타입을 전달하여 역직렬화 수행&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식으로 변경함으로써 다음과 같은 효과를 얻을 수 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jackson 3의 정책 변화에 영향을 받지 않음&lt;/li&gt;
&lt;li&gt;타입 정보 누락으로 인한 역직렬화 문제 제거&lt;/li&gt;
&lt;li&gt;직렬화 구조가 단순해져 디버깅이 용이해짐&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring이 JSON 변환에 Jackson을 사용하는 것은 알고 있었지만 내부 구조에 대해서는 생각해보거나 공부한 적이 없어서 문제를 해결하는 데 오랜 시간이 걸렸습니다. 특히 Deprecated 예고가 된 기능을 사용했다는 점에서 Spring 내부에서 사용되는 라이브러리에 대한 이해 역시 필요함을 느끼게 되었습니다.&lt;/p&gt;</description>
      <category>ImHere 프로젝트</category>
      <category>InvalidTypeIdException</category>
      <category>Jackon3</category>
      <category>kotlin</category>
      <category>Redis</category>
      <category>SerializationException</category>
      <author>rati</author>
      <guid isPermaLink="true">https://rati.tistory.com/1</guid>
      <comments>https://rati.tistory.com/1#entry1comment</comments>
      <pubDate>Thu, 19 Mar 2026 23:03:13 +0900</pubDate>
    </item>
  </channel>
</rss>