<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>여러가지 이야기</title>
    <link>https://9oongoguma.tistory.com/</link>
    <description>안녕하시렵니까</description>
    <language>ko</language>
    <pubDate>Thu, 21 May 2026 05:57:56 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>jimddong</managingEditor>
    <image>
      <title>여러가지 이야기</title>
      <url>https://tistory1.daumcdn.net/tistory/6841105/attach/a67580e253364f05806140631246c4f9</url>
      <link>https://9oongoguma.tistory.com</link>
    </image>
    <item>
      <title>[Spring] Lock 동시성 해결 중 트랜잭션의 관리, Transaction 원리</title>
      <link>https://9oongoguma.tistory.com/20</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://9oongoguma.tistory.com/19&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.01.23 - [study/Spring] - 객체 중복 생성 방지를 위한 Redis 분산 Lock 도입&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770737485258&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;객체 중복 생성 방지를 위한 Redis 분산 Lock 도입&quot; data-og-description=&quot;최근 진행 중인 프로젝트에서, AI를 활용하여 사용자가 원하는 주제의 요약글과 퀴즈를 생성하는 기능을 개발하는 역할을 맡았다.사용자는 원하는 목표 기간 동안 최대 하루 한 번씩 생성된 요&quot; data-og-host=&quot;9oongoguma.tistory.com&quot; data-og-source-url=&quot;https://9oongoguma.tistory.com/19&quot; data-og-url=&quot;https://9oongoguma.tistory.com/19&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bODYoZ/dJMb895ZusL/IXzdZUA8atv6y7c1XeG2Y1/img.png?width=800&amp;amp;height=865&amp;amp;face=0_0_800_865,https://scrap.kakaocdn.net/dn/dze3MC/dJMb84XUEP4/jknY5V2PzxH0UXiNCPMpcK/img.png?width=800&amp;amp;height=865&amp;amp;face=0_0_800_865,https://scrap.kakaocdn.net/dn/fejMn/dJMb9aKA0gt/HBrpkazcUvZWcFW22RkmJk/img.png?width=1392&amp;amp;height=1506&amp;amp;face=0_0_1392_1506&quot;&gt;&lt;a href=&quot;https://9oongoguma.tistory.com/19&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://9oongoguma.tistory.com/19&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bODYoZ/dJMb895ZusL/IXzdZUA8atv6y7c1XeG2Y1/img.png?width=800&amp;amp;height=865&amp;amp;face=0_0_800_865,https://scrap.kakaocdn.net/dn/dze3MC/dJMb84XUEP4/jknY5V2PzxH0UXiNCPMpcK/img.png?width=800&amp;amp;height=865&amp;amp;face=0_0_800_865,https://scrap.kakaocdn.net/dn/fejMn/dJMb9aKA0gt/HBrpkazcUvZWcFW22RkmJk/img.png?width=1392&amp;amp;height=1506&amp;amp;face=0_0_1392_1506');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;객체 중복 생성 방지를 위한 Redis 분산 Lock 도입&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;최근 진행 중인 프로젝트에서, AI를 활용하여 사용자가 원하는 주제의 요약글과 퀴즈를 생성하는 기능을 개발하는 역할을 맡았다.사용자는 원하는 목표 기간 동안 최대 하루 한 번씩 생성된 요&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;9oongoguma.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 발생&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 하루에 최대 하나만 생성되어야 할 요약글 객체가, 해당 생성 API가 여러 번 호출되며 중복되는 문제가 있었다. 이에 따라&amp;nbsp;&lt;b&gt;Lock 로직&lt;/b&gt;을 도입하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1770742455633&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// DistributedLockFacade.java
public &amp;lt;T&amp;gt; Optional&amp;lt;T&amp;gt; tryExecuteWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, Supplier&amp;lt;T&amp;gt; action) {
    RLock lock = redissonClient.getLock(lockKey);
    try {
        // 락 획득 시도
        if (!lock.tryLock(waitTime, leaseTime, timeUnit)) {
            log.warn(&quot;Failed to acquire lock: {}&quot;, lockKey);
            return Optional.empty();
        }
        try {
            return Optional.ofNullable(action.get());
        } finally {
            // 락 해제
            if (lock.isLocked() &amp;amp;&amp;amp; lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    } catch (InterruptedException e) {
        // 예외 처리
        Thread.currentThread().interrupt();
        throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
    }
}&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;요약글뿐만 아니라 퀴즈 세트도 사용자에게 하루에 최대 하나 생성되어야하므로, 퀴즈에도 Redis 분산 Lock을 도입하려 했다. 하지만 코드를 수정하여 실제 운영 서버에 반영하고, 서버 로그를 확인해보니 다음과 같은 에러가 발생했다.&lt;/p&gt;
&lt;pre id=&quot;code_1770737649600&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;2026-01-19T19:57:04.478+09:00  WARN 1 --- [Project1] [nio-8080-exec-9] i.s.t.g.component.DistributedLockFacade  : Failed to acquire lock: lock:category_document:generation:1526:2026-01-19
2026-01-19T19:57:04.482+09:00 ERROR 1 --- [Project1] [nio-8080-exec-9] i.s.t.g.e.GlobalExceptionHandler         : BaseException: 
.
.
.
im.project1.global.exception.BaseException: 서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
	at im.project1.global.component.DistributedLockFacade.executeWithLock(DistributedLockFacade.java:38) ~[!/:0.0.1-SNAPSHOT]
	at im.project1.domains.categoryDocument.application.usecase.CategoryDocumentUseCase.createNewDailyDocument(CategoryDocumentUseCase.java:153) ~[!/:0.0.1-SNAPSHOT]
.
.
.
2026-01-19T19:58:42.361+09:00 ERROR 1 --- [Project1] [nio-8080-exec-6] i.s.t.g.e.GlobalExceptionHandler         : Exception: 

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:804) ~[spring-tx-6.2.14.jar!/:6.2.14]
	at&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;하나만 생겨야할 요약글이 중복 저장되고, Lock이 제대로 지켜지지 않는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 문제가 발생했을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 현재 프로젝트에서는 GitHub Flow에 따라 개발 브랜치의 작업을 운영 브랜치에 머지하면, 배포 서버에 반영되어 즉각적으로 수정 사항을 볼 수 있다. 즉 QA용 서버가 존재하지 않는데, 이미 해당 프로젝트에서 앱을 배포하여 운영 중이었기에 무턱대고 코드를 수정하여 바로 바로 운영 서버에 반영하기에는 앱 이용자에게도 에러가 즉각적으로 감지될 수 있는 위험이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 수정하여도 로컬에서 API를 동시에 여러 번 호출하여 생기는 동시성 문제를 Postman이나 Swagger로 확인하기에는 무리가 있기에, 따라서 로컬에서도 동시성 문제를 테스트해볼 수 있는 스크립트를 작성하여 테스팅을 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 스크립트는&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;요약글 요청 후 퀴즈를 요청하는 내용이며, &lt;/span&gt;동시에 여러 번 요청이 들어 왔을 때 Lock이 잘 처리되는지 보기 위해서 요약글&amp;rarr;퀴즈 flow를 여러 번(CONCURRENT_REQUESTS만큼) 반복하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스크립트: concurrency_test.sh&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1770743627582&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash

# 명령행 인자에 JWT access token, categoryId 포함
JWT_TOKEN=$1
CATEGORY_ID=$2

CONCURRENT_REQUESTS=5 # Lock이 걸리는 API를 거의 동시에 똑같이 5번 호출 

if [ -z &quot;$JWT_TOKEN&quot; ] || [ -z &quot;$CATEGORY_ID&quot; ]; then
  echo &quot;Usage: $0 &amp;lt;JWT_TOKEN&amp;gt; &amp;lt;CATEGORY_ID&amp;gt;&quot;
  exit 1
fi

URL_DOC=&quot;http://localhost:8080/api/v1/categories/$CATEGORY_ID/documents/daily&quot; # 요약글 생성(이미 존재 시 조회) 요청
URL_QUIZ_BASE=&quot;http://localhost:8080/api/v1/user-quizzes&quot;

echo &quot;  Starting $CONCURRENT_REQUESTS concurrent user flows (Doc -&amp;gt; Quizzes)...&quot;
echo &quot;Target: Category ID $CATEGORY_ID&quot;

run_flow() {
  local id=$1
  
  # 1. Request: GET api/v1/categories/{categoryId}/documents/daily
  RESPONSE=$(curl -s -w &quot;\n%{http_code}&quot; -X GET &quot;$URL_DOC&quot; \
    -H &quot;Authorization: Bearer $JWT_TOKEN&quot; \
    -H &quot;Content-Type: application/json&quot;)

  HTTP_CODE=$(echo &quot;$RESPONSE&quot; | tail -n1)
  BODY=$(echo &quot;$RESPONSE&quot; | sed '$d')

  echo &quot;User $id Step 1 (Doc): HTTP $HTTP_CODE&quot;

  if [ &quot;$HTTP_CODE&quot; -eq 200 ]; then
      # Document ID로 JSON parsing
      # Structure: { &quot;data&quot;: { &quot;documentId&quot;: 123, ... } }
      DOC_ID=$(echo &quot;$BODY&quot; | python3 -c &quot;import sys, json; print(json.load(sys.stdin)['data']['documentId'])&quot; 2&amp;gt;/dev/null)
      
      if [ -n &quot;$DOC_ID&quot; ]; then
          # 2. Request: 퀴즈 요청
          QUIZ_URL=&quot;$URL_QUIZ_BASE?documentId=$DOC_ID&amp;amp;documentType=CATEGORY&quot;
          QUIZ_RESPONSE=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; -X GET &quot;$QUIZ_URL&quot; \
            -H &quot;Authorization: Bearer $JWT_TOKEN&quot; \
            -H &quot;Content-Type: application/json&quot;)
          echo &quot;User $id Step 2 (Quiz): HTTP $QUIZ_RESPONSE (DocId: $DOC_ID)&quot;
      else
          echo &quot;User $id Step 2 (Quiz): SKIPPED (No Doc ID)&quot;
      fi
  else
      echo &quot;User $id Step 2 (Quiz): SKIPPED (Doc Failed)&quot;
  fi
}

# Export (함수 및 변수)
export -f run_flow
export JWT_TOKEN CATEGORY_ID URL_DOC URL_QUIZ_BASE

# Run requests
for i in $(seq 1 $CONCURRENT_REQUESTS); do
  run_flow $i &amp;amp;
done

# 작업 끝
wait
echo &quot;✅ All flows completed.&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 1) tryLock(waitTime, leaseTime, timeUnit)의 waitTime 늘리기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 지식(요약글+퀴즈) 생성 과정에서 lock의 점유가 풀리지 않았을 경우를 고려해보았다. 참고로 지식은, 요약글이 생겨야 해당 내용을 바탕으로 퀴즈 세트 내용이 구성되어 생성되는 로직이다. 즉 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;1️⃣ 요약글(CategoryDocument 또는 DocumentSummary) 생성 위해 Lock 점유 &amp;rarr; 생성 완료 시 Unlock&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;2️⃣ 퀴즈 생성 위해 Lock 점유 &amp;rarr; 생성 완료 시 Unlock&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 나는 '1️⃣ &amp;rarr; 2️⃣ 사이 간극이 너무 짧아서, 아직 앞선 점유 1️⃣이 Unlock 하지도 않았는데 2️⃣가 Lock을 시도해서 그런 것 아닐까?'하는 의문을 가졌다. 이에 따라 tryLock 메서드 내 파라미터에서의 waitTime, 즉&amp;nbsp;&lt;b&gt;Wait Time을 증가&lt;/b&gt;(기존 3초 &amp;rarr; 변경 뒤 30초) 시켰다. 즉, 요약글과 퀴즈 생성 로직의 락 대기 시간을 넉넉하게 늘려, 앞선 요청이 끝날 때까지 기다렸다가 결과를 받아갈 수 있게 한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 여전히 에러가 발생했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;로컬에서 실행한 스크립트 에러 결과&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1770738390578&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  Starting 10 concurrent user flows (Doc -&amp;gt; Quizzes)...
Target: Category ID 104
User 8 Step 1 (Doc): HTTP 200
User 8 Step 2 (Quiz): SKIPPED (No Doc ID)
User 3 Step 1 (Doc): HTTP 500
User 3 Step 2 (Quiz): SKIPPED (Doc Failed)
User 1 Step 1 (Doc): HTTP 500
User 1 Step 2 (Quiz): SKIPPED (Doc Failed)
User 5 Step 1 (Doc): HTTP 500
User 5 Step 2 (Quiz): SKIPPED (Doc Failed)
User 7 Step 1 (Doc): HTTP 500
User 7 Step 2 (Quiz): SKIPPED (Doc Failed)
User 9 Step 1 (Doc): HTTP 500
User 9 Step 2 (Quiz): SKIPPED (Doc Failed)
User 6 Step 1 (Doc): HTTP 500
User 6 Step 2 (Quiz): SKIPPED (Doc Failed)
User 2 Step 1 (Doc): HTTP 500
User 2 Step 2 (Quiz): SKIPPED (Doc Failed)
User 10 Step 1 (Doc): HTTP 500
User 10 Step 2 (Quiz): SKIPPED (Doc Failed)
User 4 Step 1 (Doc): HTTP 200
User 4 Step 2 (Quiz): SKIPPED (No Doc ID)
✅ All flows completed.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;참고로 요약글 GET API는 그 날 요약글이 생성되지 않았다면 생성 후 반환, 이미 있다면 바로 반환한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;위 결과에서 HTTP 200으로 처리된 쪽을 보면 GET 요청은 성공적이었지만, 처음에 요약글 1회 생성에 성공한 뒤 후반에 조회로 된 게 아니라 요약글이 2번 생성된 것이었다. (DB에서 확인 시 중복 생성된 걸 확인함)&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 2) 트랜잭션 시점 문제를 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 Lock이 분명 있는데도, 대기 시간을 LLM 소요 시간보다 길게 잡았는데도, 요약글이 중복 생성 되는 이유는 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에 보여줬던 운영 서버 에러 로그에서 '&lt;b&gt;Transaction&lt;/b&gt;'이라는 단어가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 DB 트랜잭션 개념은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;&lt;b&gt;DB Transaction 트랜잭션&lt;br /&gt;&lt;/b&gt;&lt;/b&gt;데이터베이스 트랜잭션(Database Transaction)은 데이터베이스의 상태를 변화시키기 위해 수행하는 하나의 논리적인 작업 단위&lt;b&gt;&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/b&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1038&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/osnmz/dJMcaaRLK09/c1GspcKDQqlIPkpyvnxiyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/osnmz/dJMcaaRLK09/c1GspcKDQqlIPkpyvnxiyK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/osnmz/dJMcaaRLK09/c1GspcKDQqlIPkpyvnxiyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fosnmz%2FdJMcaaRLK09%2Fc1GspcKDQqlIPkpyvnxiyK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2048&quot; height=&quot;1038&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1038&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1️⃣&amp;nbsp;Active (활성)&lt;/b&gt;: 트랜잭션이 시작되어 읽기(Read)나 쓰기(Write) 연산을 수행 중인 초기 상태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2️⃣&amp;nbsp;Partially Committed (부분 완료)&lt;/b&gt;: 마지막 연산까지 실행을 마쳤지만, 아직 데이터베이스에 최종적으로 반영(Commit)되기 직전의 상태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3️⃣&amp;nbsp;Committed (완료)&lt;/b&gt;: 트랜잭션이 성공적으로 완료되어 변경 내용이 데이터베이스에 영구적으로 반영된 상태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4️⃣, 5️⃣&amp;nbsp;Failed (실패)&lt;/b&gt;: 연산 수행 중 오류가 발생하거나 명시적으로 중단(Abort)되어 더 이상 정상적인 수행이 불가능한 상태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6️⃣&amp;nbsp;Terminated (종료)&lt;/b&gt;: 트랜잭션이 커밋되거나 롤백(Rollback)된 후 완전히 종료된 상태&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 요약글 생성 메서드가 시작되고 연산을 수행 중인데, 아직 생성된 요약글을 저장하려 했지만 DB에 반영되지 않은 상태에서 다시 한번 요약글 생성 API를 호출하면 어떻게 될까? 프록시 입장에서는 확인되는 오늘의 요약글이 없을 테니, 문제가 없을 거라 판단하고 한 번 더 요약글 생성을 하게 될 것이다. 즉 &lt;b&gt;트랜잭션 커밋 시점 차이&lt;/b&gt;로 인해 문제가 생길 수 있는 것이다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;1. &lt;b&gt;서버(Thread A)&lt;/b&gt;: 요약글 생성 API&amp;nbsp;호출 &amp;amp; &lt;b&gt;락 점유  &lt;/b&gt; (아직&lt;b&gt; Transaction commit이 되지 않아 DB에 완전히 저장 x&lt;/b&gt;)&lt;br /&gt;2.&lt;b&gt; 프론트엔드&lt;/b&gt;: 버튼을 모르고 연타하는 등, 요약글 생성 API 중복 호출됨&lt;br /&gt;3. &lt;b&gt;서버(Thread A)&lt;/b&gt;: (조금 뒤) &lt;b&gt;락 해제  &lt;/b&gt;&lt;br /&gt;4. &lt;b&gt;서버(Thread B)&lt;/b&gt;: 요약글 생성 API 호출 &amp;amp;&amp;nbsp;락 점유  , DB 조회 했지만 아직 Thread A에서의 Transaction이 끝나지 않아 저장된 요약글이 없기에 또 요약글을 중복 생성&lt;br /&gt;5. &lt;b&gt;서버(Thread A)&lt;/b&gt;: &lt;b&gt;뒤늦게&amp;nbsp;트랜잭션 커밋 완료&lt;/b&gt;. 이제 DB에 요약글이 진짜 save&lt;br /&gt;6. &lt;b&gt;서버(Thread B)&lt;/b&gt;: (조금 뒤) 락 해제  , 이후 트랜잭션 커밋 완료, 또 다시 요약글이 save&lt;br /&gt;.&lt;br /&gt;.&lt;br /&gt;.&lt;br /&gt;=&amp;gt; 결과적으로 요약글이 중복 되어 여러 개가 생김&amp;nbsp;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;또한 이는 요약글 생성 이후의 퀴즈 생성에서도 발생할 수 있는 문제이다. &lt;br /&gt;만약 요약글 데이터 저장 호출은 했는데 아직 DB에 반영(Commit)이 안 된 순간에 프론트엔드가 퀴즈 생성 API를 요청한다면 다음과 같을 것이다.&lt;br /&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;서버(Thread A)&lt;/b&gt;: 요약글 만들고 퀴즈 생성함. (아직 트랜잭션 안 끝남, DB에 완전히 저장 x)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프론트엔드&lt;/b&gt;: 요약글 생겼다고 판단 후 GET /quizzes&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버(Thread B)&lt;/b&gt;: DB 조회 했지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;아직 커밋 안 돼서 퀴즈가 없음&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;-&amp;gt;&amp;nbsp;&lt;b&gt;빈 화면 반환&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(Empty List)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버(Thread A)&lt;/b&gt;: (조금 뒤) 트랜잭션 커밋 완료. 이제 DB에 퀴즈가 진짜로 보임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유저&lt;/b&gt;: (앱 나갔다 들어옴) 다시 퀴즈 요청 -&amp;gt;&amp;nbsp;&lt;b&gt;서버가 이제는 퀴즈 반환 가능&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제를 &lt;b&gt;트랜잭션 커밋 시점 차이(Race Condition / Visibility Issue)라고 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하려면 &lt;b data-index-in-node=&quot;12&quot; data-path-to-node=&quot;10&quot;&gt;&quot;트랜잭션이 완전히 커밋된 후에 락이 해제되도록&quot;&lt;/b&gt; 코드를 수정해야한다. 트랜잭션 관련 코드를 수정하기 앞서, 스프링에서의 트랜잭션 전략에 대해서 잠시 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  Spring에서 트랜잭션 활용하기&lt;br /&gt;&lt;/b&gt;&lt;br /&gt;&lt;b&gt;✔️ @Transactional 어노테이션&lt;/b&gt;: 메서드나 클래스에 붙여, 메서드 시작 시 트랜잭션을 열고 메서드가 성공적으로 종료될 시 commit, 런타임 예외 발생 시 rollback을 자동 수행한다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;✔️ 동작 원리 (AOP Proxy)&lt;/b&gt;&lt;br /&gt;Spring에서 @Transactional이 동작하는 방식은 내부적으로 AOP(Aspect Oriented Programming)를 활용한다.&lt;br /&gt;1. Spring이 해당 객체를 상속받거나 인터페이스를 구현한 프록시(Proxy, 가짜 스프링 Bean) 객체를 생성&lt;br /&gt;2. 사용자가 메서드를 호출하면 프록시가 먼저 호출을 가로챔.&lt;br /&gt;3. 프록시 내부에서 TransactionManager를 통해 트랜잭션을 시작&lt;br /&gt;4. 실제 비즈니스 로직을 수행한 후, 결과에 따라 Commit 또는 Rollback이 호출&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;355&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dQ1Lpf/dJMcaioINVD/UJunwsolyqvZUUd2e2zr6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dQ1Lpf/dJMcaioINVD/UJunwsolyqvZUUd2e2zr6k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dQ1Lpf/dJMcaioINVD/UJunwsolyqvZUUd2e2zr6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdQ1Lpf%2FdJMcaioINVD%2FUJunwsolyqvZUUd2e2zr6k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;512&quot; height=&quot;355&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;355&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;이에 따라 기존 요약글 관련 UseCase 메서드의 @Transactional에서 Propagation.NOT_SUPPORTED 조건을 넣어 트랜잭션의 범위를 수정하기로 결정했다. 즉, 큰 트랜잭션의 범위를 무시하는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1770742515749&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// CategoryDocumentUseCase.java
// 1. 외부 트랜잭션 없이 진입 (락보다 넓은 범위의 트랜잭션 방지)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public CategoryDocumentResponse generateDocument(Long categoryId, Long userId) {
    // ... 검증 로직 ...
    // 2. 락 획득 시도 및 로직 수행
    CategoryDocument targetDocument = distributedLockFacade.tryExecuteWithLock(
        lockKey, 
        30, 
        60, 
        TimeUnit.SECONDS, 
        () -&amp;gt; {
            // 3. 락 내부에서 이중 체크 (Double Check)
            if (categoryDocumentService.existsByGoalIdAndDate(goal.getId(), LocalDate.now())) {
                return categoryDocumentService.getDocumentsByGoalId(...);
            }
            // 4. 실제 생성 및 저장 (여기서 내부적으로 새로운 트랜잭션이 시작되고 커밋됨)
            return createDocumentInternal(goal);
        }
    ).orElseGet(() -&amp;gt; {
        // 락 획득 실패 시 재조회 등 처리
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 정리해보자면 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;b&gt;기존 트랜잭션 &lt;br /&gt;@Transactional&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;b&gt;✅&amp;nbsp;트랜잭션 &lt;br /&gt;@Transactional(propagation = Propagation.NOT_SUPPORTED))&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span&gt;: 메서드가 시작될 때 트랜잭션을 열고 메서드가 완전히 끝나서 리턴될 때 닫는(커밋) 구조&lt;b&gt;&lt;br /&gt;&lt;br /&gt;기존 로직&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;UseCase&lt;/b&gt;&amp;nbsp;메서드 시작 (트랜잭션 시작  )&lt;/li&gt;
&lt;li&gt;락 점유  &lt;/li&gt;
&lt;li&gt;요약글/퀴즈 생성 &amp;amp; DB&amp;nbsp;&lt;b&gt;save&lt;/b&gt;&amp;nbsp;호출 -&amp;gt; 하지만 아직 커밋 x, 메모리에만 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;락 해제  &lt;/b&gt;&amp;nbsp;(메서드 로직 상&amp;nbsp;finally 블록에서 해제됨)&lt;/li&gt;
&lt;li&gt;메서드 종료 및 프록시가&amp;nbsp;&lt;b&gt;트랜잭션 커밋 (DB 반영)  &lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span&gt;: 큰 트랜잭션 없이 작은 트랜잭션을 관리하는 구조&lt;b&gt;&lt;br /&gt;&lt;br /&gt;변경된 흐름&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;UseCase&lt;/b&gt;&amp;nbsp;메서드 시작 (트랜잭션 없음)&lt;/li&gt;
&lt;li&gt;락 점유  &lt;/li&gt;
&lt;li&gt;요약글/퀴즈 생성&lt;/li&gt;
&lt;li&gt;DB save resultRepository.save(...)&amp;nbsp;&lt;b&gt;호출&lt;/b&gt;&amp;nbsp; -&amp;gt;&amp;nbsp;&lt;b&gt;이 순간 트랜잭션이 열리고, 바로 저장하고, 바로 커밋됨  &lt;/b&gt;&amp;nbsp;(작은 트랜잭션 종료)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 저장 메서드 등은 자체 트랜잭션을 가짐&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;락 해제  &lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;변경 전(왼쪽의&amp;nbsp;기존 트랜잭션)&lt;/b&gt;: 전체 로직이 하나의 트랜잭션으로 묶여 락이 먼저 풀림, DB 반영은 나중에 됨.&lt;br /&gt;&lt;b data-path-to-node=&quot;13,1,0&quot; data-index-in-node=&quot;0&quot;&gt;➡️ 변경 후(오른쪽의 트랜잭션 전략)&lt;/b&gt;: 락 점유 중 내부에서 개별적인 작은 트랜잭션(Repository의 save 등)이 실행되고 즉시 커밋됨. 락이 풀리는 시점에는 이미 DB에 데이터가 저장된 상태이도록 함.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오른쪽 방법처럼 주요 메서드에서 트랜잭션 옵션을 바꾸어, 락 점유 중에 생성된 요약글을 바로 DB에 저장하여 락이 풀리자마자 다른 요청들이 요약글이 생성된 사실을 인지하게끔 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;스크립트 실행 결과(성공)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1770738856501&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  Starting 5 concurrent user flows (Doc -&amp;gt; Quizzes)...
Target: Category ID 107
User 3 Step 1 (Doc): HTTP 200
User 2 Step 1 (Doc): HTTP 200
User 5 Step 1 (Doc): HTTP 200
User 4 Step 1 (Doc): HTTP 200
User 1 Step 1 (Doc): HTTP 200
User 3 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 2 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 5 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 4 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 1 Step 2 (Quiz): HTTP 200 (DocId: 79)
✅ All flows completed.&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;다만, 트랜잭션 없이 접근하는 부분에서 엮여있는 기존의 지연 로딩 접근 메서드에서는 LazyInitializationException이 발생하기도 했다.&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 부분(findWithCurrentGoalById, findWithGoalById 등...)에는 해결책으로 &lt;b&gt;Fetch Join&lt;/b&gt;을 사용하는 전용 조회 메서드를 추가하여, 필요한 데이터를 한 번에 안전하게 가져오도록 수정하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1770742562326&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// UserRepository.java
@Query(&quot;select u from UserEntity u left join fetch u.currentGoal g left join fetch g.category where u.id = :id&quot;)
Optional&amp;lt;UserEntity&amp;gt; findWithCurrentGoalById(Long id);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;이번 트러블 슈팅을 통해 단순히 Redis Lock을 건다고 해서 모든 동시성 문제가 해결되는 것은 아닌 걸 깨달았다. 처음에는 Lock 관점에서만 생각했는데, 에러 로그의 Transaction이라는 키워드를 통해 DB 저장 시점 즉 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;DB 커밋 시점과 락 해제 시점의 불일치에서&lt;/span&gt;&amp;nbsp;문제가 있었다는 걸 파악할 수 있었다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;즉, &lt;b&gt;Lock&lt;/b&gt;&lt;/span&gt;&lt;b&gt;의 범위와 트랜잭션의 범위(생명주기) 관계&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;를 정확히 이해하고 설계해야 데이터 정합성을 보장할 수 있다는 게 핵심이었다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;또한 Spring에서 다루는 DB의 트랜잭션 범위나, @Transactional에 걸 수 있는 조건은 readOnly 외에는 잘 몰랐었는데 프록시와 엮어 AOP 기반으로 어떻게 관리되는지까지 확장해서 공부할 수 있었다!&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;@Transactional&lt;span style=&quot;text-align: start;&quot;&gt;과 AOP 기반의 락이 만났을 때,&amp;nbsp;&lt;/span&gt;&lt;b&gt;트랜잭션 커밋 시점&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;이 락 해제보다 늦어질 수 있다는 점은 잊지 않아야겠다고 다짐했다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;앱 서비스를 출시하였지만, 계속 고도화하면서 아마 광고를 본 사용자에게는 요약글과 퀴즈를 더 제공하는 기능 등이 생길 예정인데 해당 Lock을 어떻게 리팩토링할지 고민해보아야겠다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;niniz&quot; data-emoticon-name=&quot;030&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/030.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/030.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>study/Spring</category>
      <category>db</category>
      <category>Lock</category>
      <category>spring</category>
      <category>Transaction</category>
      <category>데이터베이스</category>
      <category>트랜잭션</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/20</guid>
      <comments>https://9oongoguma.tistory.com/20#entry20comment</comments>
      <pubDate>Wed, 11 Feb 2026 01:52:43 +0900</pubDate>
    </item>
    <item>
      <title>[Prometheus, Grafana] 모니터링 시스템 구축 방법과 대시보드 개선</title>
      <link>https://9oongoguma.tistory.com/21</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1872&quot; data-origin-height=&quot;1268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIV1I4/dJMcadU5OfT/wQgxU4FzS7Ndxvg20ebex1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIV1I4/dJMcadU5OfT/wQgxU4FzS7Ndxvg20ebex1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIV1I4/dJMcadU5OfT/wQgxU4FzS7Ndxvg20ebex1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIV1I4%2FdJMcadU5OfT%2FwQgxU4FzS7Ndxvg20ebex1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1872&quot; height=&quot;1268&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1872&quot; data-origin-height=&quot;1268&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;  모니터링 대시보드 개선 및 지표 추가 기록&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 안정성과 장애 대응력을 높이기 위해 주요 지표들을 추가하였습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 실시간 API별 트래픽 분석표 패널 생성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;도입 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 &lt;b&gt;Mean(평균)&lt;/b&gt; 지표는 소수의 사용자가 겪는 극심한 지연(Long Tail)을 반영하지 못하는 평균의 함정 발생 가능&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex. 대다수 사용자가 0.1초의 응답을 받아도, 특정 요청이 10초가 걸릴 경우 평균값은 실제 사용자 경험보다 훨씬 낮게 측정되어 장애 감지가 늦어질 수 있음.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 처리 시간 지표 고도화
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;P95 (95th Percentile):&lt;/b&gt; 하위 5%를 제외한 대다수 사용자가 체감하는 최악의 속도를 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기대 효과:&lt;/b&gt; 일시적인 네트워크 지연이나 특정 API의 성능 저하를 통계적으로 유의미하게 파악할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 에러율(Error Rate) 및 상태 코드 가시화&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1432&quot; data-origin-height=&quot;1004&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbiZUg/dJMcafk7IQn/BZFjINAfJdhHtTBBmL1b80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbiZUg/dJMcafk7IQn/BZFjINAfJdhHtTBBmL1b80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbiZUg/dJMcafk7IQn/BZFjINAfJdhHtTBBmL1b80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbiZUg%2FdJMcafk7IQn%2FBZFjINAfJdhHtTBBmL1b80%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;351&quot; data-origin-width=&quot;1432&quot; data-origin-height=&quot;1004&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;도입 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 요청 수(RPM) 그래프는 요청의 성공 여부를 구분하기 어려움.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex. 서버에 치명적인 오류가 발생해 모든 요청이 500 에러를 반환하고 있음에도, 요청 수가 급증하면 이를 서비스 활성화로 오해할 수도 있음.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;HTTP Status Code 분류:&lt;/b&gt; 4xx(클라이언트 오류), 5xx(서버 오류)를 시각적으로 구분&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러 발생 알림 기준:&lt;/b&gt; 최근 1분간 에러 발생 빈도를 계산하는 PromQL 적용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기대 효과:&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 서비스 가용성(Availability)을 실시간으로 확인하고, 배포 직후나 트래픽 폭증 시 즉각적인 장애 인지가 가능함.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769587415750&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sum by (status) (increase(http_server_requests_seconds_count{status=~&quot;4..|5..&quot;}[1m])) &amp;gt; 0&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. DB 커넥션 풀(HikariCP) 상태 모니터링&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;도입 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DB 커넥션 풀 고갈:&lt;/b&gt; Spring Boot 기반 애플리케이션에서 성능 저하의 가장 흔한 원인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; 대시보드에서 하드웨어 자원(CPU/RAM)만 볼 경우, DB 연결을 기다리느라 응답이 멈춘 상황을 분석하기 어려울 수 있기에 이를 도입함.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;주요 모니터링 지표:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;hikaricp_connections_active: 현재 활성 상태인 커넥션&lt;/li&gt;
&lt;li&gt;hikaricp_connections_pending: 커넥션 할당을 기다리며 대기 중인 스레드 수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기대 효과:&lt;/b&gt; 응답 지연의 원인이 애플리케이션 로직인지, DB 연결 병목인지 명확히 구분하여 인프라 증설이나 쿼리 최적화 등의 의사결정 근거로 활용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추가 고려 사항&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대시보드 구축 후 모니터링 과정에서 UNKNOWN으로 기록된 401, 400 에러들이 다수 관측됨.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해당 요청들은 Spring Security의 Filter Chain 단계에서 즉시 차단된 악성/비인가 요청 &amp;rArr; 비정상적인 요청이 비즈니스 로직(Controller)까지 진입하여 자원을 소모하기 전에 앞단(Filter)에서 효율적으로 거부되고 있다는 뜻..&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;지표들 기반으로 코드 수정점 발견 시 코드 개선 필요&lt;br /&gt;&lt;br /&gt;이에 따른 코드 개선점에 대해 다음 글에서 설명하도록 하겠다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>study/Cloud</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/21</guid>
      <comments>https://9oongoguma.tistory.com/21#entry21comment</comments>
      <pubDate>Sat, 24 Jan 2026 01:30:55 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 객체 중복 생성 방지를 위한 Redis 분산 Lock 도입</title>
      <link>https://9oongoguma.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 진행 중인 프로젝트에서, AI를 활용하여 사용자가 원하는 주제의 요약글과 퀴즈를 생성하는 기능을 개발하는 역할을 맡았다.&lt;br /&gt;사용자는 원하는 목표 기간 동안 최대 하루 한 번씩 생성된 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;요약글과 퀴즈 세트를 확인 후 풀이할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하루 1회 요약글과 퀴즈 세트가 모두 OpenAI 모델을 활용해 생성되게끔 구현하였는데, 사용자가 그날 최초로 홈 화면에 접속 했을 때 해당 객체들이 순차적으로 생기게끔 구현하기로 했다.&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;하지만 실제 서버 로그와 DB를 확인했을 때는 그렇지 않았다. 그날 최초 홈화면 접속 시 하루 1회만 생성 되어야 할 요약글과 퀴즈 세트가 여러 개 생성되는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1530&quot; data-origin-height=&quot;1565&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMYt0a/dJMcahpF61K/HQy6OuezSpQZbL7jeR1ckk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMYt0a/dJMcahpF61K/HQy6OuezSpQZbL7jeR1ckk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMYt0a/dJMcahpF61K/HQy6OuezSpQZbL7jeR1ckk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMYt0a%2FdJMcahpF61K%2FHQy6OuezSpQZbL7jeR1ckk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;1565&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1530&quot; data-origin-height=&quot;1565&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 입장에서 요약글이나 퀴즈 세트를 조회하면 그날분의 것만 볼 수 있어서 문제가 되지 않았지만, DB 비용을 낭비한다는 점에서 문제가 있어 수정이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 요약글과 퀴즈 세트가 생기기까지 즉, LLM이 생성한 응답을 조회하기까지 OpenAI 특성 상 시간이 꽤 걸린다. 그동안 유저는 홈 화면에서 기다리기만 하지 않고 다른 탭을 눌러보는 등 홈 화면을 여러 번 들락거릴 수 있다. 아까 언급 했듯이 '홈 화면에 들어오면 요약글과 퀴즈 세트가 하루 최대 한 번 생김'에서 나는 &lt;i&gt;하루 한 번 생성되어야하는 요약글, 퀴즈 세트 로직에만 집중했었다.&lt;/i&gt; 실제로 그 날 한 번 이 객체들이 생성 되면, 그 날에 더이상 요약글, 퀴즈 세트를 생성할 수 없게 코드는 작성되어있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 놓친 것은 &lt;b&gt;LLM이 응답을 만들고 있을 때&lt;/b&gt;였다. 현재 프론트 쪽에서는 요약글과 퀴즈가 생기기 전에 홈화면에 들어 오면 요약글 생성 API와 퀴즈 생성 API를 호출한다. 객체가 생성되기 직전인, LLM이 응답(요약글과 퀴즈 세트)을 만들고 있을 동안에 사용자 홈화면 버튼을 여러 번 연타하거나 들락거리는 경우는 고려하지 못 했던 것이다..!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;1416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5pFbX/dJMcaaqxRFc/9I3KCdg3DX4fLxeGbtKSNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5pFbX/dJMcaaqxRFc/9I3KCdg3DX4fLxeGbtKSNK/img.png&quot; data-alt=&quot;문제 상황을 시퀀스 다이어그램으로 나타낸 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5pFbX/dJMcaaqxRFc/9I3KCdg3DX4fLxeGbtKSNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5pFbX%2FdJMcaaqxRFc%2F9I3KCdg3DX4fLxeGbtKSNK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;659&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;1416&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제 상황을 시퀀스 다이어그램으로 나타낸 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;niniz&quot; data-emoticon-name=&quot;039&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/039.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/039.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(역시 실제 화면 플로우와 그 속에서 일어날 수 있는 다양한 케이스를 고려해야하는 걸 여기서 뼈저리게 느꼈다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이처럼 요약글 및 퀴즈가 생기기 전까지 홈 화면에 여러번 접속해서 생성 요청이 여러 번 보내진다면, 그 횟수만큼 요약글 및 퀴즈가 중복되어 생성되며 안 그래도 LLM 응답이 느린데 응답이 더 느려지는 문제도 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이내 '그날 &lt;b&gt;최초로 홈 화면에 접속 했을 때만&lt;/b&gt; 해당 객체들이 생기게 하기'에 집중했다. 처음에는 2가지 방법을 함께 도입했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;객체 중복 생성 방지&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 1. DB Unique 제약 조건 -&amp;gt; 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요약글 생성은 단일 엔티티를 생성하는 것이라 이런 식으로 요약글 엔티티 코드 앞에 해당 날에는 딱 하나의 요약글이 생기게 제약을 두었다.&lt;/p&gt;
&lt;pre id=&quot;code_1769105458418&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Table(name = &quot;category_document&quot;, uniqueConstraints = {
        @UniqueConstraint(name = &quot;uk_category_document_goal_date&quot;, columnNames = { &quot;goal_id&quot;, &quot;created_date&quot; })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 배포 서버에 반영 후 DB를 확인해보니 여전히 요약글이 생기기 전까지 LLM이 작동하는 동안 홈 화면을 여러 번 들어올 때마다 그 횟수만큼 요약글이 여러 개 생기는 문제가 있었다. 하루 생성 중복이 막아지지 않은 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 다음과 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요약글 엔티티는 공통 필드를 사용하기 위해 BaseEntity를 상속하는데, 이 중 LocalDateTime createdDate은 초 단위까지 기록하게 되어있다. 이러한 경우에, 현재처럼 createdDate를 비교하여 엔티티가 독립적인지 판별하려면 당연히 초 단위까지 고려할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 LLM 응답이 오기 전까지 홈 화면을 여러 번 들어오거나 생성 요청이 연타되었다면 요청 시간대는 다음과 같을 수 있다.&lt;/p&gt;
&lt;div style=&quot;background-color: #0d1117; color: #f0f6fc; text-align: start;&quot;&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #151b23; color: #f0f6fc;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// 요청 예시
첫 번째 요청: 2026-01-19 16:49:02.471919
두 번째 요청: 2026-01-19 16:49:11.590923&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, DB Unique Constraint 설정에서 (goal_id, created_date)가 중복되지 않게 막았지만 초 단위(밀리초 차이로 다른 시간에 들어온 요청은 모두 다른 데이터로 인식)로 판단하여, 요약글 생성이 1일 1회로 지켜지지 않는 문제가 발생한 것이다. 따라서 요약글에도 다음 2번 방법을 적용하기로 결정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(해결 방법) 2. Redis 분산 Lock&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 퀴즈에 우선 이 방법을 적용했었다. 하루에 퀴즈 세트 하나를 생성하는 것이지, 퀴즈 엔티티를 딱 하나 만드는 것이 아니었기에 퀴즈에는 DB Unique 제약을 걸 수 없었기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis 분산 Lock 개념&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;tryLock(waitTime, leaseTime, timeUnit): 앞선 lock을 최대 waitTime만큼 기다리고, 최대 leaseTime만큼 점유(Lock 획득)할 수 있다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앞선 첫 요청이 Lock을 점유하고 있을 때(작업 중) &amp;rarr; 두 번째 요청은 첫 번째 요청이 끝날 때까지&amp;nbsp;&lt;b&gt;최대 waitTime까지&lt;/b&gt;&amp;nbsp;기다려준다.&lt;/li&gt;
&lt;li&gt;앞선 요청이 Lock을 점유하고 있지 않다면 두 번째 요청은 락을 즉시 획득한다. (대기 0초)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퀴즈에서의 Redis 분산 Lock의 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #151b23; color: #f0f6fc; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;&amp;lt;로직&amp;gt;
1. 퀴즈 생성 전에 lock:quiz:generation:{문서ID}:{유저ID} 라는 이름으로 lock 걸기
2. 1번째 요청: lock(최대 60초)-&amp;gt; 퀴즈 생성(LLM 호출) -&amp;gt; unlock(생성 시 바로 잠금 해제)
=&amp;gt; 예를 들어 사용자가 버튼을 10번 연타해도, 2~10번째 요청은 대기하다가 1번 요청 확인 후 퀴즈 생성 안 하고 리턴하게 됨.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1392&quot; data-origin-height=&quot;1506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B1ILE/dJMcagYzLMo/lWGqym9vkJDea8UpGRKTU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B1ILE/dJMcagYzLMo/lWGqym9vkJDea8UpGRKTU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B1ILE/dJMcagYzLMo/lWGqym9vkJDea8UpGRKTU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB1ILE%2FdJMcagYzLMo%2FlWGqym9vkJDea8UpGRKTU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1392&quot; height=&quot;1506&quot; data-origin-width=&quot;1392&quot; data-origin-height=&quot;1506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1769106685716&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Redisson Distributed Lock 도입
            String lockKey = &quot;lock:quiz:generation:&quot; + documentId + &quot;:&quot; + userId;
            RLock lock = redissonClient.getLock(lockKey);

            try {
                // 3초 대기, 60초 락 점유 (LLM 응답이 늦어질 수 있으므로 넉넉하게 잡음)
                if (lock.tryLock(3, 60, TimeUnit.SECONDS)) {
                    try {
                        // Double-Check: 락 획득 후 다시 한 번 개수 확인 (다른 스레드가 이미 생성했을 수도 있음)
                        priorityQuizzes = quizService.getUnsolvedQuizzesByAttributes(documentId, userId,
                                targetDifficulty, targetTopic, quizCount);

                        if (priorityQuizzes.size() &amp;lt; quizCount) {
                            int remainingCount = quizCount - priorityQuizzes.size();
                            quizUseCase.createQuizzesForDocument(documentId, userId, remainingCount);

                            // 재생성 후 최종 조회
                            priorityQuizzes = quizService.getUnsolvedQuizzesByAttributes(documentId, userId,
                                    targetDifficulty, targetTopic, quizCount);
                        }
                    } finally {
                        if (lock.isLocked() &amp;amp;&amp;amp; lock.isHeldByCurrentThread()) {
                            lock.unlock();
                        }
                    }
                } else {
                    // 락 획득 실패 시 (Timeout) -&amp;gt; 기존 조회된 것만 반환하거나 예외처리
                    // 여기서는 최대한 생성된 만큼만 반환
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
            }
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퀴즈에 Redis 분산 Lock을 도입했을 때는 퀴즈 세트가 딱 하나가 생성 되어 문제 없이 작동하는 것을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 요약글 생성에도 위 로직을 도입하기 위해 공통 메서드를 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769106844020&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; public &amp;lt;T&amp;gt; T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, Supplier&amp;lt;T&amp;gt; action) {
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (!lock.tryLock(waitTime, leaseTime, timeUnit)) {
                log.warn(&quot;Failed to acquire lock: {}&quot;, lockKey);
                throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
            }

            try {
                return action.get();
            } finally {
                if (lock.isLocked() &amp;amp;&amp;amp; lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error(&quot;Lock acquisition interrupted: {}&quot;, lockKey, e);
            throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
        }
    }&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;하지만 Lock 로직이 여러 개 생겼을 때는 또 다른 문제가 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요약글이 먼저 생성되고, 그 글의 내용을 기반으로 퀴즈를 생성해야하기에 Lock 로직을 두 개로 분리하였는데 요약글이 여러 개 생기는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 문제는 트랜잭션과 관련이 있었고, 바로 다음 글에서 자세히 해결 과정을 기술하겠다.&lt;/p&gt;</description>
      <category>study/Spring</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/19</guid>
      <comments>https://9oongoguma.tistory.com/19#entry19comment</comments>
      <pubDate>Fri, 23 Jan 2026 02:38:07 +0900</pubDate>
    </item>
    <item>
      <title>[BOJ] 1012번: 유기농 배추 (Java)</title>
      <link>https://9oongoguma.tistory.com/18</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.acmicpc.net/problem/1012&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;&lt;span&gt;  &lt;/span&gt;https://www.acmicpc.net/problem/1012&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c05AKu/dJMcaacwbjk/4OLfuE1r0ZC5qHWimwwV0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c05AKu/dJMcaacwbjk/4OLfuE1r0ZC5qHWimwwV0K/img.png&quot; data-alt=&quot;사진과 같은 배추밭은 배추 구역이 5개, 따라서 답도 5이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c05AKu/dJMcaacwbjk/4OLfuE1r0ZC5qHWimwwV0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc05AKu%2FdJMcaacwbjk%2F4OLfuE1r0ZC5qHWimwwV0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;203&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;사진과 같은 배추밭은 배추 구역이 5개, 따라서 답도 5이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 배추가 심어진 칸(1)이 상하좌우로 붙어있는 면적은 구역이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 1 - Comparator로 2차원 배열을 탐색하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 Comparator로 2차원 배열을 탐색하는데, 배추가 있는 구역의 바로 오른쪽/아래가 인접하면 같은 덩어리로 간주하게끔 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1762151046989&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int T = Integer.parseInt(br.readLine());

        StringTokenizer field = new StringTokenizer(br.readLine());
        int M = Integer.parseInt(field.nextToken());
        int N = Integer.parseInt(field.nextToken());
        int K = Integer.parseInt(field.nextToken());

        StringBuilder result = new StringBuilder();

        for (int i = 0; i &amp;lt; T; i++) {
            result.append(getCabbagePosition(K)).append(&quot;\n&quot;);
        }

        System.out.print(result);
    }

    static int getCabbagePosition(int K) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int[][] cabbages = new int[K][2];

        for (int i = 0; i &amp;lt; K; i++) {
            StringTokenizer xOrY = new StringTokenizer(br.readLine());
            int x = Integer.parseInt(xOrY.nextToken());
            int y = Integer.parseInt(xOrY.nextToken());
            int[] arr = {x,y};
            cabbages[i] = arr;
        }

        Arrays.sort(cabbages, (o1, o2) -&amp;gt; {
            if (o1[0] - o2[0] == 0)
                return o1[1] - o2[1];

            else return o1[0] - o2[0];
        });

        return countEarthWorm(cabbages, K);
    }

    static int countEarthWorm(int[][] arr, int K) {
        int count = 0;

        for (int i = 0; i &amp;lt; K; i++) {
            if ((arr[i][0] + 1 == arr[i+1][0] &amp;amp;&amp;amp; arr[i][1] == arr[i+1][1])
                || (arr[i][0] == arr[i+1][0] &amp;amp;&amp;amp; arr[i][1] + 1 == arr[i+1][1])) {
                continue;
            }

            else {
                count++;
                break;
            }
        }
        return count;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;배추가 있는 (x,y) 좌표를 정렬하였을 때 만약 바로 다음 좌표가 오른쪽/아래로 인접하면 같은 덩어리로 간주하려했는데, 그래프 연결은 바로 다음 원소만 인접한다고 생기는 것이 아닐뿐더러 count++의 기준도 이상했다. 따라서 이런 방식으로 구현하기에 복잡도가 올라가 다른 방식을 찾아보려 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 2 - 배열 내 요소를 오른쪽, 아래 방향으로 탐색하기&lt;/h3&gt;
&lt;pre id=&quot;code_1762151063422&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Main2 {
    static int M, N, K, count;
    static int[][] field;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int T = Integer.parseInt(br.readLine());
        int[][] cabbages;
        StringBuilder result = new StringBuilder();

        // 밭에 배추 심기
        for (int i = 0; i &amp;lt; T; i++) {
            // 밭 크기, 배추 개수
            StringTokenizer st = new StringTokenizer(br.readLine());
            M = Integer.parseInt(st.nextToken());
            N = Integer.parseInt(st.nextToken());
            K = Integer.parseInt(st.nextToken());

            field = new int[N][M];
            cabbages = new int[K][2];

            // 배추 심은 좌표값 = 1
            for (int j = 0; j &amp;lt; K; j++) {
                StringTokenizer st2 = new StringTokenizer(br.readLine());
                int x = Integer.parseInt(st2.nextToken());
                int y = Integer.parseInt(st2.nextToken());
                field[x][y] = 1;

                // 배추 위치 배열에 저장
                cabbages[j][0] = x;
                cabbages[j][1] = y;
            }

            count = 0;
            for (int j = 0; j &amp;lt; K; j++) {
                int x = cabbages[j][0];
                int y = cabbages[j][1];

                if(field[x][y] == 0)
                    continue;

                countEarthWorm(x, y);
            }
            result.append(count).append(&quot;\n&quot;);
        }

        System.out.println(result);
    }

    public static int countEarthWorm(int x, int y) {
        field[x][y] = 0; // 체크 완료했기에 중복 count 방지를 위해 0으로 변경

        if (x&amp;lt;M || y&amp;lt;N) {
            if (x==M-1 &amp;amp;&amp;amp; y==N-1){
                count++;
                return 0;
            }

            if (x==M-1) {
                if ((field[x][y+1] == 0)) {
                    countEarthWorm(x, y+1);
                }
            }

            if (y==N-1) {
                if ((field[x+1][y] == 0)) {
                    countEarthWorm(x+1, y);
                }
            }

            if ((field[x+1][y] == 0) &amp;amp;&amp;amp; (field[x][y+1] == 0)) {
                count++;
                return 0;
            }

            else {
                if (field[x+1][y] == 1) {
                    countEarthWorm(x+1, y);
                }

                if (field[x][y+1] == 1) {
                    countEarthWorm(x, y+1);
                }
            }
        }
        return 0;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 Comparator를 버리고, 그냥 좌표를 처음부터 끝까지 탐색하려했다. 하지만 문제는 이 코드에서도 나는 오른쪽, 아래쪽에 바로 인접한 정점만 확인하려 했던 것이다. 배추밭에서의 그래프는 무방향이므로 &lt;b&gt;상하좌우 4방향&lt;/b&gt;을 모두 검사해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 인덱스도 field[y][x]로 확인해야했는데 뒤집어서 설정했다는 것도 큰 오류였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(* &lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;배추밭의 가로길이 M(1 &amp;le; M &amp;le; 50)과 세로길이 N(1 &amp;le; N &amp;le; 50), &lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;배추의 위치 X(0 &amp;le; X &amp;le; M-1), Y(0 &amp;le; Y &amp;le; N-1)&lt;/span&gt;&lt;/span&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(⭐️최종) 시도 3 - BFS, DFS를 활용하여 탐색하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 시도 끝에 내가 고려해야할 점은 2가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;1. 현재 정점에서 인접한 &lt;b&gt;상하좌우 4방향&lt;/b&gt;을 모두 탐색하기&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;검사해야할 좌표: 현재 좌표의 상하좌우 1칸 근방에 있는 노드 4개 &lt;br /&gt;- 방향: (-1,0), (1,0), (0,-1), (0,1) &lt;br /&gt;&amp;rArr; &lt;b&gt;&lt;i&gt;int[] dx = {1,-1, 0, 0} : x좌표 direction &lt;/i&gt;&lt;/b&gt;&lt;br /&gt;&lt;b&gt;&lt;i&gt;&amp;amp; int[] dy = {0, 0, 1, -1} : y좌표 direction&lt;/i&gt;&lt;/b&gt; &lt;br /&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1762153053731&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 상하좌우 4가지 확인 (0, 1) (0,-1) (1,0) (-1,0)
            for (int dir = 0; dir &amp;lt; 4; dir++) {
                int nextJ = j + dy[dir];
                int nextI = i + dx[dir];
                .
                .
                .​&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;둘을 조합한 값을 현재 좌표에 더해 이동시키면, 현재 좌표의 상하좌우 1칸 근방에 있는 좌표를 모두 표현 가능하다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #333333; text-align: left;&quot;&gt;따라서 만약 좌표 값이 1이면(배추가 있으면) 그 노드의 상하좌우를 검사한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #333333; text-align: left;&quot;&gt; 이를 상하좌우 모든 좌표 값이 모두 0이 나올 때까지 반복하면 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #9feec3; color: #333333; text-align: left;&quot;&gt;2. 탐색 기준 정하기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #333333; text-align: left;&quot;&gt;이때까지는 배추가 있는 노드의 오른쪽이나 아래에도 배추가 있다면(값이 1이라면) 순차적으로 탐색을 진행했다. 하지만 이 방식은 배열 요소 값이 1임에도 미처 탐색하지 못하고 지나치게 될 수 있기에 적합하지 않았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #333333; text-align: left;&quot;&gt;따라서 적절한 우선 순위나 기준에 따라 2차원 배열을 탐색해야하기에, 그래프 탐색 방식인 &lt;b&gt;DFS 혹은 BFS&lt;/b&gt;를 활용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;DFS(깊이 우선 탐색)&lt;/b&gt; &lt;br /&gt;- 재귀 &lt;br /&gt;- field 2차원 배열 &lt;br /&gt;- boolean visited[] 혹은 field 배열에서 방문한 정점의 값은 0으로 대체 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;BFS(너비 우선 탐색)&lt;/b&gt; &lt;br /&gt;- 큐(FIFO): 큐에 해당 정점 넣고, 검사 시 제거&lt;br /&gt;- field 배열 &lt;br /&gt;- boolean visited[] 혹은 field 배열에서 방문한 정점의 값은 0으로 대체 &lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이 문제와 같이 2차원 배열을 탐색해야하는 유형의 문제는 대체로 BFS/DFS를 활용하여 푼다. 즉 큐에 검사한 요소들을 넣고 빼서 탐색하거나, 재귀(스택)으로 탐색을 반복하면 된다. 처음 해결했을 때는 이 배추밭은 탐색 순서가 정해져있지 않기에 BFS/DFS 즉 너비 우선/깊이 우선 탐색이 무슨 관련이 있는지, 단순히 '큐나 재귀로 푸는 문제' 같기만 했다. '너비나 깊이를 우선으로 탐색 순서를 정하는 게 배추밭에 배추가 있는(값이 1인) 모든 정점을 탐색해내야하는 것은 거리가 느껴져.'가 내 생각이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 의문은 그래프의 정의와 연결관계를 생각해보며 해결할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;⚠️ &lt;/span&gt;주의할 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 단순히 DFS/BFS 문제가 아니다. 2차원 배열 내 그래프를 어떻게 탐색할 것인지, 즉 큐나 재귀(스택)을 활용해 어떤 방식으로 탐색하느냐에 달려있다.&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;그래프는 정점(Vertex)와 간선(Edge)의 집합으로, 방향이 있든 없든 상관 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 문제의 배추밭은 루트도 방향도 없는 &lt;b&gt;무방향 비연결 그래프&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 이 문제에서는 마치 DFS/BFS의 본래 의미인 &amp;ldquo;탐색 순서&amp;rdquo;, 즉 &lt;i&gt;깊이나 너비를 우선으로 탐색한다는 의의는 퇴색&lt;/i&gt;되어 보이고 실제로도 상대적으로 중요하지 않다. 핵심은 배열 내 값이 1인 정점 기준으로, 상하좌우 인접한 1들이 있는지 덩어리로 묶어 탐색(connected component 탐색)한다. 서로 연결된 것만 다 훑는 것이 문제의 목표로, 어느 방향부터 우선적으로 탐색할 것인지 구체적인 순서는 중요하지 않은 특수 케이스다.&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;DFS/BFS는 개념, 탐색 전략이고 큐/재귀(스택)은 구현 도구이다. 여기서는 &lt;u&gt;정점간 연결 관계를 완전히 탐색하기 위한 도구로 DFS/BFS의 구현 도구인 큐/재귀(스택)을 이용&lt;/u&gt;할 수 있다. 즉, 단순 DFS/BFS 문제라기 보다는 그래프 연결 관계 탐색이라는 문제 목표를 위해 DFS/BFS를 활용하여 풀 수 있는 것 핵심이다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 내가 처음에 DFS, BFS를 납득하기 어려웠던 이유는 DFS, BFS를 구현할 때 이때까지는 번호가 매겨진 정점이 어떤 정점과 이어져있는지를 확인하여 각 정점별 배열에 넣었기 때문이다. 배열에 들어간 요소들이 너비 우선이나 깊이 우선이라는 기준에 따라 어떤 정점과 이어져있는지 하나씩 검사하는 과정이었다. 그래서 DFS, BFS를 탐색 순서를 찾기 위한 용도로만 이제껏 생각해왔다.&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;그래프 정의에 따르면, 이 배추밭은 루트도 방향도 없기에 '무방향 비연결 그래프'이다. 그래프 탐색 순서라는 개념이 들어간 DFS/BFS는 결국 &lt;b&gt;정점간 연결관계를 모두 완전히 탐색한다는 점&lt;/b&gt;에서 &lt;b&gt;탐색 전략&lt;/b&gt;이기도 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 모든 정점을 탐색하기 위해, 탐색 방식에 있어서 너비나 깊이를 우선으로 탐색할지만 결정한 것에 그치지 않는다. 즉, 이번 문제에서는 DFS/BFS를 구현하는 &lt;b&gt;큐/재귀(스택)으로 모든 연결된 정점을 탐색하겠다는 목표로 DFS/BFS 개념을 활용&lt;/b&gt;하면 되는 것이다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;BFS - 큐&lt;/h4&gt;
&lt;pre id=&quot;code_1762151115121&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayDeque;
import java.util.StringTokenizer;

public class Bfs {
    static int[][] field;
    static int M, N, K;
    static int[] dx = {1,-1, 0, 0};
    static int[] dy = {0, 0, 1, -1};

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int T = Integer.parseInt(br.readLine());

        StringBuilder result = new StringBuilder();

        for (int i = 0; i &amp;lt; T; i++) {
            StringTokenizer st = new StringTokenizer(br.readLine());
            M = Integer.parseInt(st.nextToken());
            N = Integer.parseInt(st.nextToken());
            K = Integer.parseInt(st.nextToken());

            field = new int[N][M];

            for (int j = 0; j &amp;lt; K; j++) {
                StringTokenizer st_xy = new StringTokenizer(br.readLine());
                int x = Integer.parseInt(st_xy.nextToken());
                int y = Integer.parseInt(st_xy.nextToken());
                field[y][x] = 1;
            }

            int count = 0;
            for (int y = 0; y &amp;lt; N; y++) {
                for (int x = 0; x &amp;lt; M; x++) {
                    if(field[y][x] == 1) {
                        count++; // 지렁이 필요한 구역 하나씩 count
                        bfs(y,x);
                    }
                }
            }
            result.append(count).append(&quot;\n&quot;);
        }

        System.out.print(result);
    }

    static void bfs(int y, int x) {
        ArrayDeque&amp;lt;int[]&amp;gt; queue = new ArrayDeque&amp;lt;&amp;gt;();
        field[y][x] = 0; // 방문
        queue.add(new int[]{y,x});

        while(!queue.isEmpty()) {
            int[] cur = queue.poll();
            int j = cur[0];
            int i = cur[1];

            // 상하좌우 4가지 확인 (0, 1) (0,-1) (1,0) (-1,0)
            for (int dir = 0; dir &amp;lt; 4; dir++) {
                int nextJ = j + dy[dir];
                int nextI = i + dx[dir];
                if (nextI&amp;gt;=0 &amp;amp;&amp;amp; nextI&amp;lt;M &amp;amp;&amp;amp; nextJ&amp;gt;=0 &amp;amp;&amp;amp; nextJ&amp;lt;N &amp;amp;&amp;amp; field[nextJ][nextI] == 1) {
                    field[nextJ][nextI] = 0; // 방문 확인
                    queue.add(new int[]{nextJ, nextI});
                }
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DFS - 재귀&lt;/h4&gt;
&lt;pre id=&quot;code_1762151126105&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Dfs {
    static int[][] field;
    static int M, N, K;
    static int[] dirX = {1,-1, 0, 0};
    static int[] dirY = {0, 0, 1, -1};

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int T = Integer.parseInt(br.readLine());

        StringBuilder result = new StringBuilder();

        for (int i = 0; i &amp;lt; T; i++) {
            StringTokenizer st = new StringTokenizer(br.readLine());
            M = Integer.parseInt(st.nextToken());
            N = Integer.parseInt(st.nextToken());
            K = Integer.parseInt(st.nextToken());

            field = new int[N][M];

            for (int j = 0; j &amp;lt; K; j++) {
                StringTokenizer st_xy = new StringTokenizer(br.readLine());
                int x = Integer.parseInt(st_xy.nextToken());
                int y = Integer.parseInt(st_xy.nextToken());
                field[y][x] = 1;
            }

            int count = 0;
            for (int y = 0; y &amp;lt; N; y++) {
                for (int x = 0; x &amp;lt; M; x++) {
                    if(field[y][x] == 1) {
                        count++; // 지렁이 필요한 구역 하나씩 count
                        dfs(y,x);
                    }
                }
            }
            result.append(count).append(&quot;\n&quot;);
        }

        System.out.print(result);
    }

    // 재귀
    static void dfs(int y, int x) {
        field[y][x] = 0; // 방문 확인

        for (int i = 0; i &amp;lt; 4; i++) {
            int nextY = y + dirY[i];
            int nextX = x + dirX[i];

            if (nextY&amp;gt;=0 &amp;amp;&amp;amp; nextY&amp;lt;N &amp;amp;&amp;amp; nextX&amp;gt;=0 &amp;amp;&amp;amp; nextX&amp;lt;M &amp;amp;&amp;amp; field[nextY][nextX] == 1) {
                dfs(nextY, nextX);
            }
        }
    }
}&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;배추밭 탐색 시 모든 정점을 확인하되, 값이 1이라면 탐색 시작 시 count++(구역의 개수)을 해준다. 중요한 포인트는, 이때 이 정점은 BFS(큐)나 DFS(재귀)로 상하좌우까지 확인되고 &lt;b&gt;이 정점의 값 1을 0으로 값을 바꿔주어&lt;/b&gt; 다음 배추밭 탐색에서 다시 확인(재방문)되어 count가 증가되지 않게 해주어야한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배운 점&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 문제에서&amp;nbsp;&lt;b&gt;시도 1, 2가 틀렸던 것은 이 문제의 본질이 그래프 문제임을 몰랐기 때문&lt;/b&gt;이라 생각한다. 만약 배추가 있단 좌표를 정렬 하고 그 다음 원소(오른쪽, 아래)만 비교하면&lt;span&gt;&amp;nbsp;무방향 그래프의&amp;nbsp;&lt;/span&gt;&lt;b&gt;연쇄/분기 연결&lt;/b&gt;을 놓칠 것이다. 또한 인덱스가 뒤바뀌거나(field[y][x]), 구역 개수를 세는 count++의 타이밍도 중요 했었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그래프라는 주제&lt;/b&gt;를 깨닫고 나서는 문제 풀이를 완전히 이해할 수 있었다. 결국 2차원 배열은 암묵적 그래프로, 앞으로도 &lt;u&gt;2차원 배열을 탐색할 일이 있으면 그래프 탐색 BFS/DFS 개념을 활용&lt;/u&gt;할 수 있다. 이 문제에서는 배추밭을 그래프로 이해하면 격자의 각 칸을 정점, 상하좌우 인접을 간선으로 생각해볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 짜면서 앞서 언급했듯이 그저 큐나 재귀로 푼 문제 같다고만 느꼈다 했는데, 명심할 점은 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;DFS/BFS는 &amp;ldquo;어떤 순서로 방문할지&amp;rdquo;를 정하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;전략&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;이고, 재귀(스택)/큐는 그 전략을 실현하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;구현 수단&lt;/b&gt;이다. 여기서는 무방향 그래프이기에 어떤 순서로 방문할지가 주 쟁점이 되기 보다는 모든 정점을 탐색하고자 하는 의도에서 BFS/DFS를 탐색에 활용한다.&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 data-ke-size=&quot;size16&quot;&gt;그리고 &lt;b&gt;상하좌우&lt;/b&gt; 모두를 검사하기 위해 x,y 방향을 지정해주는 &lt;b&gt;dirX={1,-1,0,0}, dirY={0,0,1,-1}&lt;/b&gt; 같은 배열을 좌표에 조합하여 더해 쓸 수 있다는 새로운 아이디어도 얻었다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>study/BOJ</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/18</guid>
      <comments>https://9oongoguma.tistory.com/18#entry18comment</comments>
      <pubDate>Mon, 3 Nov 2025 16:33:25 +0900</pubDate>
    </item>
    <item>
      <title>[Git] Git branch 전략(Git-Flow), feature와 develop 브랜치 사이 작업 시 pull 전략</title>
      <link>https://9oongoguma.tistory.com/17</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 팀 협업 시 Git을 쓰긴 했지만, 보통은 각자 작업한 내용을 feature 브랜치를 만들어 push하고 develop으로 merge하는 방식이었다. 그런데 이번에 새로운 프로젝트를 진행하게 되면서, Git branch 전략 중 Git-Flow를 접하게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 나는 그간 여러 브랜치를 파는 게 개발적으로 단순 작업 분리 및 통합의 기능을 한다고 생각해왔었는데, 그 이상의 것을 한다는 걸 알게 되었다!&lt;br /&gt;그 내용을 정리해보려 한다.&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;본론부터 말하자면 브랜치 전략은 팀 프로젝트 코드를 작업을 나누어 개발하는 것에서 더 나아가, 검증 및 배포 후 운영하는 데 있어서 필요하다. 왜냐면 Git-Flow에서는 단순 feature, develop이 끝이 아니라 release, main 브랜치까지 존재하기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;1420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLMKMf/btsNWqKvfTl/iN1GIVRcrjUjuvaZEC4vjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLMKMf/btsNWqKvfTl/iN1GIVRcrjUjuvaZEC4vjk/img.png&quot; data-alt=&quot;Git Flow 구조를 직접 그림으로 그려보았다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLMKMf/btsNWqKvfTl/iN1GIVRcrjUjuvaZEC4vjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLMKMf%2FbtsNWqKvfTl%2FiN1GIVRcrjUjuvaZEC4vjk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;794&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;1420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Git Flow 구조를 직접 그림으로 그려보았다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;feature 브랜치&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;feature 브랜치는 개인이 할당 받은 작업을 처리하는 &lt;b&gt;개인 개발용 브랜치&lt;/b&gt;라고 보면 된다. 그림에서 볼 수 있듯이 feature 브랜치는 develop에서 뻗어나와, 작업이 끝나면 다시 develop으로 합쳐진다. 즉, develop에서 시작하여 만든 브랜치에서 작업을 하고 develop으로 push한다는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1747208149336&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git checkout -b feature-project-init

// 해당 브랜치에서 코드 작업 후
git add .
git commit -m &quot;feat: Initial project setting&quot;
git push origin feature-project-init

// Develop 브랜치에서 merge
git pull origin feature-project-init&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;develop 브랜치 (Dev)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop 브랜치는 말 그대로 &lt;b&gt;개발 브랜치&lt;/b&gt;이다. 여러 feature 브랜치 내용들이 병합된 작업물이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;release 브랜치 (QA)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop 브랜치의 내용을 release 브랜치에서 검증한다. &lt;b&gt;검증 브랜치&lt;/b&gt;로, 최종 배포인 main 브랜치에 가기 전 통합 테스트를 진행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 만약 검증 단계인 release(QA) 브랜치에서 버그나 문제가 생겼다면 어떻게 해야할까? 이 때는 main(Prod)으로 바로 직행하지 않고 develop으로 돌아가야한다. &lt;b&gt;(롤백)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 &lt;b&gt;롤백&lt;/b&gt;에서는, &lt;u&gt;develop에서 수정된 내용을 다시 release에 병합 하는 검증 과정&lt;/u&gt;을 문제가 모두 해결될 때까지 반복한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 과정이 첫 번째였다면 알파 테스트, 두 번째였다면 베타 테스트 등으로 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;main 브랜치 (Prod)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이제 마지막 브랜치 main이다. &lt;span data-token-index=&quot;0&quot;&gt;실제 운영 환경&lt;/span&gt; 브랜치로&amp;nbsp;&lt;b&gt;&lt;span data-token-index=&quot;2&quot;&gt;최종 배포&lt;/span&gt;&lt;/b&gt;만 이뤄지는 곳이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;실제로 나의 경우, 터미널에서 bastion 서버를 거쳐 실제 배포 서버(ssh)로 들어가 develop (release는 생략) 브랜치의 내용을 main 브랜치로의 병합하는 과정을 경험할 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;main에 반영하는 방법은 크게 두 가지이다.&lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;1. 운영 서버에서 수동 배포하기&lt;br /&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1334&quot; data-origin-height=&quot;172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FtJhG/btsPeCDryP4/FtYfxS1JpjUAAEUZrxYknk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FtJhG/btsPeCDryP4/FtYfxS1JpjUAAEUZrxYknk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FtJhG/btsPeCDryP4/FtYfxS1JpjUAAEUZrxYknk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFtJhG%2FbtsPeCDryP4%2FFtYfxS1JpjUAAEUZrxYknk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1334&quot; height=&quot;172&quot; data-origin-width=&quot;1334&quot; data-origin-height=&quot;172&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/b&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;main 브랜치에서 git pull origin develop을 통해 병합(merge)하면 되는데, 코드 충돌이 발생하면 터미널에서 수동으로 해결해야한다는 번거로움이 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Git Action 활용하기&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;정확히는 프로젝트 GitHub에 들어가 develop에서 main으로 pull하게끔 PR(pull request)을 날려 수동 배포하는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이때 develop 기능을 main 반영에 반영할 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;Git Actions의&amp;nbsp;&lt;/span&gt;CI/CD 및 CodeDeploy가 쓰일 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;CI/CD 자동 배포로, Git Action이 CI/CD 파이프라인을 자동으로 실행시키기에 PR만 날려주면 직접 1번과 같은 명령어 입력 없이 배포(main 브랜치에 최종 반영)가 가능하다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1757&quot; data-origin-height=&quot;1167&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/elG1b1/btsPfXZ0AXf/Sr1X1kOsfQw8X0hqq60Oi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/elG1b1/btsPfXZ0AXf/Sr1X1kOsfQw8X0hqq60Oi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/elG1b1/btsPfXZ0AXf/Sr1X1kOsfQw8X0hqq60Oi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FelG1b1%2FbtsPfXZ0AXf%2FSr1X1kOsfQw8X0hqq60Oi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;399&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1757&quot; data-origin-height=&quot;1167&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Git Action을 활용한 CI/CD 방법은 추후 다시 정리해보겠다!&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;추가로 배포 서버 내에서 변경된 rds DB 스키마를 수정하는 등의 작업도 하기도 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;1420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLMKMf/btsNWqKvfTl/iN1GIVRcrjUjuvaZEC4vjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLMKMf/btsNWqKvfTl/iN1GIVRcrjUjuvaZEC4vjk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLMKMf/btsNWqKvfTl/iN1GIVRcrjUjuvaZEC4vjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLMKMf%2FbtsNWqKvfTl%2FiN1GIVRcrjUjuvaZEC4vjk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;794&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;1420&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;추가로, 이렇게 각 브랜치 업데이트별로 버전을 붙이는 작업을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Tagging&lt;/b&gt;이라 하는데, Tag는 각 개발 시점의 버전을 맞춰 기록하기 위해 쓰인다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;그런데 다시 위 사진을 보면 develop, release 브랜치와 main 브랜치의 버전이 조금씩 차이가 나는 걸 확인할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;만약 계속 반복적으로 이뤄지는 &lt;b&gt;펜딩&lt;/b&gt;(release &amp;harr; main, 검증 후 수정과 merge 반복)이 있었다면, release와 develop 사이에서는 계속해서 새로운 각 커밋의 release note 버전이 존재할 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main 브랜치에는 검증하여 최종적으로 통과된 버전만 올라가게 해야하므로, 버전이 사진과 같이 순차적이지 않을 수 있다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(ex. v1.0.0 &amp;rarr; v1.3.2)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;* 숫자 차이가 클수록 QA 후 배포하려는 과정에서 엄청난 핑퐁이 있었다고 생각하면 된다고 한다..&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;다시 정리해보자면 아래와 같다&lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;작업 순서 (feature &amp;rarr; dev &amp;rarr; release &amp;rarr; main)&lt;/b&gt; &amp;amp; Tagging&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;feature&lt;/b&gt;(개인 개발용, 사진 상 dev 브랜치 왼쪽 가생이들) 브랜치에서 작업 후 &lt;b&gt;dev 브랜치로 병합&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;여러 feature들이 모인 dev를 &lt;b&gt;release(검증용)으로 병합(merge)&lt;br /&gt;&lt;/b&gt;&lt;b&gt;- release note&lt;/b&gt; - 비개발자들이 볼 수 있는 documentation, 깃헙 형식이 아닌 &lt;b&gt;파일 형식&lt;br /&gt;&lt;/b&gt;(ex. release note (v1.0.0))&lt;/li&gt;
&lt;li&gt;검증 통과 시 최종적으로 &lt;b&gt;main에도 push&lt;br /&gt;&lt;/b&gt;(ex. main branch (v1.1.0))&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop, release, main 3개 브랜치가 &lt;b&gt;모두 동일한 버전, 같은 커밋( = 한 release note 기준으로 굴러가기)&lt;/b&gt;으로 관리하는 과정은 중요하기에&amp;nbsp;&lt;b&gt;relelase note&lt;/b&gt;도 필수다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;+ Git 유의사항 (pull &amp;amp; merge)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;추가로 내 작업이 있는 feature 브랜치에서 develop 브랜치로 push하기 전에 develop에서 다른 사람들의 작업이 추가되었다면, feature 브랜치에서 develop의 내용을 가져와 합친 후 (혹시 충돌 내용이 있다면 고쳐야함) develop으로 push할 수 있다. &lt;br /&gt;하지만 아래처럼 에러가 발생하기도 했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1752222071144&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; * branch            develop    -&amp;gt; FETCH_HEAD
힌트: You have divergent branches and need to specify how to reconcile them.
힌트: You can do so by running one of the following commands sometime before
힌트: your next pull:
힌트: 
힌트:   **git config pull.rebase false  # merge**
힌트:   git config pull.rebase true   # rebase
힌트:   git config pull.ff only       # fast-forward only
힌트: 
힌트: You can replace &quot;git config&quot; with &quot;git config --global&quot; to set a default
힌트: preference for all repositories. You can also pass --rebase, --no-rebase,
힌트: or --ff-only on the command line to override the configured default per
힌트: invocation.
fatal: Need to specify how to reconcile divergent branches.
```&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메시지는 git pull 전략을 설정하지 않아서 나오는 것인데, 위에서 나오는 pull 전략 3가지에 대해 정리해보겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Git pull 전략 3가지&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. ⭐️ (merge) git config pull.rebase false (= git merge develop)&lt;br /&gt;: 기본적인 merge&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;협업, 팀 프로젝트에서 많이 쓰이는 기본 전략&lt;/li&gt;
&lt;li&gt;아래와 같이 커밋 히스토리에도 Merge branch 'develop' into feature/... 같은 &lt;b&gt;merge commit&lt;/b&gt;이 남는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1752222425780&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;*   f7a3d45  &amp;larr; merge commit (feature와 develop 병합)
|\
| * d43c123  &amp;larr; feature 브랜치 작업 ⬆️
|/
* 53d8210  &amp;larr; develop 최신 ⬆️&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;li&gt;여러 명이 같은 브랜치 작업할 때 나는 충돌 관리(한 번에 처리)도 쉽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. (rebase) git config pull.rebase true (= git rebase develop)&lt;br /&gt;: 커밋 히스토리를 깔끔하게 정리&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;개인 작업용 브랜치 정리할 때 좋음,&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;팀에서는 신중하게 써야 함 (충돌 추적이 어려워질 수도 있어서) 난이도 ⬆️&lt;/li&gt;
&lt;li&gt;커밋 히스토리가 일직선으로 깔끔해진다.&lt;/li&gt;
&lt;li&gt;병합 커밋(merge commit)이 생기지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1752222529613&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;* d43c123  &amp;larr; feature 커밋 (rebase 후 새로 생성됨)
* 53d8210  &amp;larr; develop 최신 ⬆️&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;충돌이 났을 때도 해결해야 하지만(커밋마다 처리), Git이 &quot;이 커밋은 어디서 왔는지&quot; 파악하기 어려울 수 있음 &lt;br /&gt;(충돌 발생 시 rebase 명령어로 새 커밋을 만들어 해결)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1752222592694&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git rebase develop

# 충돌 발생
# 수정 후
git add .

# 다음 커밋으로 계속 진행
git rebase --continue

# 또 충돌? 똑같이 반복
git add .
git rebase --continue

# 만약 중단하고 싶으면
git rebase --abort&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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3.&amp;nbsp;&lt;/b&gt;&lt;b&gt;(fast-forward only) git config pull.ff only: 커밋 충돌이 없는 경우에만 진행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 말했듯이 feature 브랜치에서 업데이트 된 develop의 내용을 가져와 합친 후 충돌 내용이 있다면 고쳤어야했기에,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 내가 한 git pull은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;merge 후 conflict 해결&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;= &lt;span data-token-index=&quot;0&quot;&gt;git config pull.rebase false&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;= git fetch develop + git merge develop 이었다.&lt;br /&gt;&lt;br /&gt;결론적으로 작업 시 다음과 같은 전략을 쓰는 것이 좋다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1752222899854&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// feature에서 작업하고
git add .
git commit -m &quot;feat: XXX 기능 추가&quot;
git push origin feature/XXX

// 만약 feature 브랜치에서 develop merge
git checkout feature/XXX
git config pull.rebase false    # merge 전략으로 설정
git pull origin develop 
git push origin feature/XXX     # 충돌 해결 사항도 포함해서 push

// develop &amp;lt;- feature PR 보내기&lt;/code&gt;&lt;/pre&gt;</description>
      <category>study/GitHub</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/17</guid>
      <comments>https://9oongoguma.tistory.com/17#entry17comment</comments>
      <pubDate>Fri, 11 Jul 2025 17:37:06 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] 401 에러, PasswordEncoder를 써야하는 진짜 이유 / SecurityContext 내 UsernamePasswordAuthenticationToken의 원리</title>
      <link>https://9oongoguma.tistory.com/16</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security를 토대로 로그인을 구현하기 앞서, 우선 로그인용 필터를 사용한 로직을 구현하려 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityContext 내의 Authentication 객체를 만드는 과정을 구현한 필터를 만드는 것인데, 나는 form 로그인 대신 추후 json으로 로그인하여 JWT를 활용하는 것이 목표였기에 기본적인 로그인 로직만 UsernamePasswordAuthenticationFilter에서 가져와 변형하여 코드를 짰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;username과 password를 입력받을 LoginRequestDTO, Authentication 객체를 생성하는 LoginFilter, Authentication-UsernamePasswordAuthenticationToken 내의 Principal 객체에서 쓰일 유저의 정보(username, password)를 담을 UserDetails와 UserDetailsService의 구현체 등을 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;179&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XoNGF/btsMUzPOaLD/EPO8VD63kxthwUk9dFnUk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XoNGF/btsMUzPOaLD/EPO8VD63kxthwUk9dFnUk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XoNGF/btsMUzPOaLD/EPO8VD63kxthwUk9dFnUk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXoNGF%2FbtsMUzPOaLD%2FEPO8VD63kxthwUk9dFnUk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;486&quot; height=&quot;179&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;179&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 Authentication 인터페이스에 속하는 UsernamePasswordAuthenticationToken 객체를 만드는 과정을 구현했다.&amp;nbsp;&lt;br /&gt;위 사진처럼 Authentication 객체는 SpringContextHolder 안의 SpringContext에 보관된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;SpringContextHolder&lt;/b&gt;: 인증된 사용자들의 세부사항을 저장하는 곳, 현재 실행 중인 스레드(Thread)에서 SecurityContext를 관리하는 역할&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SpringContext&lt;/b&gt;: &lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: left;&quot;&gt;Spring Security에서 로그인 후 인증이 완료되면&lt;/span&gt; Authentication 객체가 저장 되는 보관소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Authentication&lt;/b&gt; (인터페이스) 구성 요소 3가지
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Principal: 유저 정보를 담은 객체 = UserDetails 인스턴스 (username, password)&lt;/li&gt;
&lt;li&gt;Credentials: 증명 (비밀번호, 토큰)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Principal이 올바른지 입증하는 객체, 보통 비밀번호를 저장한다.&lt;/li&gt;
&lt;li&gt;증명 후에는 유출 방지를 위해 비워짐&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Authorities: 유저의 권한(ROLE) 목록을 저장한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;authorities 요소들 = GrantedAuthority(혹은 구현체의) 객체들&lt;br /&gt;(Authentication.getAuthorities()로 구할 수 있음.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이후 코드를 실행시켜 Postman으로 로그인을 시도하니 처음에는 다음과 같은 에러가 떴다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-03-17 오전 12.37.19.png&quot; data-origin-width=&quot;1078&quot; data-origin-height=&quot;1038&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3ZPvV/btsMVDKrrsd/1kiw3Isq5ANBMyCBqZy6d1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3ZPvV/btsMVDKrrsd/1kiw3Isq5ANBMyCBqZy6d1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3ZPvV/btsMVDKrrsd/1kiw3Isq5ANBMyCBqZy6d1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3ZPvV%2FbtsMVDKrrsd%2F1kiw3Isq5ANBMyCBqZy6d1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;481&quot; data-filename=&quot;스크린샷 2025-03-17 오전 12.37.19.png&quot; data-origin-width=&quot;1078&quot; data-origin-height=&quot;1038&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ef6f53; color: #ffffff;&quot;&gt;401 Unauthorized 에러&lt;/span&gt;가 뜬 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;401 에러란 즉슨... 인증이 되지 않았다는 것인데, 코드를 디버깅해보니 Authentication 객체가 아예 생기지도 않았었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유가 뭘까?&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;009&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/009.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/009.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PasswordEncoder&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PasswordEncoder는 비밀번호를 암호화하는 기능을 하는 인터페이스로, 해시함수를 사용해 encode()를 할 수 있다. 또한 encoded 된 비밀번호와 요청으로부터 전달받은 비밀번호가 일치하는지 확인하는 matches()나 추가적인 암호화 메서드도 사용할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1742887854664&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이와 같은 내용으로 SecurityConfig 클래스 안에 빈 등록하여 PasswordEncoder로 쓸 수 있다. (&lt;span data-token-index=&quot;0&quot;&gt;bcrypt 사용)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 앞선 코드에서 나는 회원 가입 로직에서 다음과 같이 PasswordEncoder를 일절 사용하지 않았었다.&lt;/p&gt;
&lt;pre id=&quot;code_1742886612213&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    @Override
    public Member createMember(MemberRequestDTO.JoinDTO joinDTO) {
        Member member = MemberConverter.toMember(joinDTO);
        return memberRepository.save(member);
    }
}&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;일단 회원가입 된 유저가 로그인을 시도하면 username과 password를 받아 Authentication 객체를 생성시켜 로그인이 되는 과정을 만들어보고 싶은 마음이 앞섰다. 하지만 이내 이 과정에서 반드시 PasswordEncoder가 필요함을 깨달았다. &lt;span style=&quot;color: #0593d3;&quot;&gt;왜지? 단순히 보안을 위해서?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 핵심은 바로 내가 &lt;u&gt;Authentication 객체로 사용한 &lt;span style=&quot;color: #409d00;&quot;&gt;&lt;b&gt;UsernamePasswordAuthenticationToken을 발급&lt;/b&gt;&lt;/span&gt;하기 위한 과정&lt;/u&gt;에 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;LoginFilter 코드&lt;/h4&gt;
&lt;pre id=&quot;code_1742888134994&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class LoginRequestDTO {

    private String email;

    private String password;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1742888067078&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        LoginRequestDTO loginRequestDTO = readBody(request);
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(loginRequestDTO.getEmail(),
                loginRequestDTO.getPassword());

        return authenticationManager.authenticate(usernamePasswordAuthenticationToken);
    }

    public LoginRequestDTO readBody (HttpServletRequest request) {
        ObjectMapper om = new ObjectMapper();

        try {
            return om.readValue(request.getInputStream(), LoginRequestDTO.class);
        } catch (IOException e) {
            throw new AuthHandler(ErrorStatus._BAD_REQUEST);
        }
    }
}&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;내가 짠 코드는 위와 같았다. (username과 password를 json으로 받아 이를 토대로 인증 객체를 만드는 과정, JWT 구현 제외)&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;내가 로그인 필터를 구현할 때 참고한 메서드는 UsernamePasswrdAuthenticationFilter의 attemptAuthentication()이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LoginFilter를 보면 클라이언트 요청 request를 토대로 username(email), password를 필드로 하는 LoginRequestDTO 클래스의 객체로 변환했다. 이 내용을 토대로 &lt;b&gt;UsernamePasswordAuthentiationToken&lt;/b&gt;을 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;771&quot; data-origin-height=&quot;702&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/K04ek/btsMVbAWB43/3GKpYuMMu1f1VrmaiHdVdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/K04ek/btsMVbAWB43/3GKpYuMMu1f1VrmaiHdVdk/img.png&quot; data-alt=&quot;UsernamePasswordAuthenticationFilter의 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/K04ek/btsMVbAWB43/3GKpYuMMu1f1VrmaiHdVdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FK04ek%2FbtsMVbAWB43%2F3GKpYuMMu1f1VrmaiHdVdk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;455&quot; data-origin-width=&quot;771&quot; data-origin-height=&quot;702&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UsernamePasswordAuthenticationFilter의 로직&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 코드와 그림에서도 볼 수 있듯이, &lt;b&gt;AuthenticationManager&lt;/b&gt;가 관여한다. 이 안의 DaoAuthenticationProvider라는 인터페이스는 인증 객체인 UsernamePasswordAuthenticationToken을 처리하는데, 여기서 PasswordEncoder가 필요했던 이유를 파악할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세히 알아보자!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AuthenticationManager의 원리&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AuthenticationManager&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityContextHolder에 있는 Authentication이 나오게끔 혹은 만들어지게끔 &lt;u&gt;인증을 도와주는 인터페이스&lt;/u&gt;다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;❗️&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;반드시 SecurityConfig에 AuthenticationManager 등록해 주기!&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 그렇지 않으면 Spring Security가 SecurityContext 내에 있는 Authentication 객체를 인증 처리해 줄 AuthenticationManager가 선언되지도 않았기에, 작동할 수도 없고 Authentication(- 안의 Principal - 안의 UserDetails까지 모두)이 생기지 않는다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;* 만약 AuthenticationManager를 등록해주지 않으면, 직접 구현한 UserDetailsService(PrinicipalDetailsService)를 호출할 수 없기에 결국 인증 과정을 처리할 수도 없음 &amp;rarr; SecurityContext에 Authentication 객체가 만들어지지 않아 에러&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AuthenticationManager의 가장 흔한 &lt;span style=&quot;color: #337ea9;&quot; data-token-index=&quot;1&quot;&gt;구현체&lt;/span&gt;는 &lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;ProviderManager&lt;/b&gt;&lt;/span&gt;이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;354&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/waXKc/btsMWuTQRWw/5T9L9AQKUsmDkTXIuQ4R1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/waXKc/btsMWuTQRWw/5T9L9AQKUsmDkTXIuQ4R1k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/waXKc/btsMWuTQRWw/5T9L9AQKUsmDkTXIuQ4R1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwaXKc%2FbtsMWuTQRWw%2F5T9L9AQKUsmDkTXIuQ4R1k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;243&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;354&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ProviderManager는 여러 AuthenticationProviders를 List로 선언한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AuthenticationManager가 모든 AuthenticationProvider를 반복문으로 돌려 인증을 확인한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DaoAuthenticationProvider&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 AuthenticationProvider의 구현체 중 하나가 &lt;span style=&quot;color: #ef6f53;&quot;&gt;&lt;b&gt;DaoAuthenticationProvider&lt;/b&gt;&lt;/span&gt;다. &lt;span style=&quot;color: #409d00;&quot;&gt;&lt;u&gt;DaoAuthenticationProvider는&amp;nbsp; UsernamePasswordAuthenticationToken을 처리한다.&lt;/u&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1336&quot; data-origin-height=&quot;992&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZ6IQE/btsMVybvsCC/SZMVWvMFMRAQDDxRlGHLbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZ6IQE/btsMVybvsCC/SZMVWvMFMRAQDDxRlGHLbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZ6IQE/btsMVybvsCC/SZMVWvMFMRAQDDxRlGHLbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZ6IQE%2FbtsMVybvsCC%2FSZMVWvMFMRAQDDxRlGHLbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;371&quot; data-origin-width=&quot;1336&quot; data-origin-height=&quot;992&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사진에서 볼 수 있듯이 username과 password를 인증화하기 위해 DaoAuthenticationProvider는&amp;nbsp;&lt;b&gt;UserDetailsService&lt;/b&gt;와 &lt;b&gt;&lt;span style=&quot;color: #ef6f53;&quot;&gt;PasswordEncoder(필수!!)&lt;/span&gt;&lt;/b&gt;를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그런데 만약 회원 가입 코드에서 PasswordEncoder를 사용하지 않았다면, 회원 가입 시 입력된 비밀번호가 그대로 데이터 베이스에 저장될 것이다. &lt;/b&gt;DaoAuthenticationProvider는 PasswordEncoder.matches()를 사용해 비밀번호 검증을 수행하는데, 데이터베이스에 저장된 값이 암호화되지 않은 상태라면 비교에 실패하게 된다. 따라서 &lt;b&gt;비밀번호 검증이 실패하여 인증 객체(Authentication)가 생성되지 않고 로그인도 실패한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DaoAuthenticationProvider&lt;/b&gt; 입장에서는 인증 객체(토큰)를 발급하려는데, 요청으로 받은 password를 PasswordEncoder로 암호화하여 matches()로 비교해보려 해도, &lt;u&gt;애초에 회원 가입 코드에서&lt;/u&gt; (MemberServiceImpl의 createMember() 참고) 멤버의 비밀번호가 &lt;u&gt;PasswordEncoder.encode()를 통해 암호화가 되지도 않았으니&lt;/u&gt; 요청으로 입력 받은 비밀번호와 이가 일치하는 비밀번호인지 비교할 수가 없을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론은 &lt;span style=&quot;color: #409d00;&quot;&gt;&lt;b&gt;인증 객체&lt;/b&gt;&lt;/span&gt;(Authentication, UsernamePasswordAuthenticationToken)를 만들어 SecurityContext 내에 저장하고 싶다면 &lt;span style=&quot;color: #409d00;&quot;&gt;반드시 유저 생성 시 &lt;b&gt;PasswordEncoder로 비밀번호를 반드시 암호화시켜줘야 한다!&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;회원 가입 로직에 PasswordEncoder로 비밀번호 암호화 과정 추가 (MemberServiceImpl, MemberConverter)&lt;/h4&gt;
&lt;pre id=&quot;code_1742898565556&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Member createMember(MemberRequestDTO.JoinDTO joinDTO) {
        Member member = MemberConverter.toMember(joinDTO, passwordEncoder);
        return memberRepository.save(member);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1742898643797&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class MemberConverter {

    public static Member toMember (MemberRequestDTO.JoinDTO joinDTO, PasswordEncoder passwordEncoder) {
        return Member.builder()
                .email(joinDTO.getEmail())
                .password(passwordEncoder.encode(joinDTO.getPassword()))
                .signUpType(joinDTO.getSignUpType())
                .role(joinDTO.getRole())
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UserDetails, UserDetailsService&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 DaoAuthenticationProvider에 의해 사용되는 &lt;b&gt;UserDetailsServices&lt;/b&gt;가&amp;nbsp;&lt;b&gt;username과 password &lt;/b&gt;등을 가진다. UserDetailsService가 반환하는 것이 UserDetails이며, UserDetailsService와 UserDetails 인터페이스를 구현해 주는 PrincipalDetailsService와 PrincipalDetails를 다음과 같이 작성했다.&lt;/p&gt;
&lt;pre id=&quot;code_1742888172246&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final Member member;

    // 역할들
    @Override
    public Collection&amp;lt;? extends GrantedAuthority&amp;gt; getAuthorities() {
        List&amp;lt;String&amp;gt; roleList = new ArrayList&amp;lt;&amp;gt;();
        roleList.add(&quot;ROLE_&quot; + member.getRole().name());
        Collection&amp;lt;? extends GrantedAuthority&amp;gt; authorities = roleList.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        return authorities;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1742888198263&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email).orElseThrow(() -&amp;gt; {
            throw new MemberHandler(ErrorStatus._NOT_FOUND_MEMBER);
        });

        return new PrincipalDetails(member);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정리&lt;/h4&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다시금 정리해 보자면 UsernamePasswordAuthenticationToken이 SecurityContext 내에 보관되는 구체적인 과정은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;UsernamePasswordAuthenticaitonFilter 같은 필터&lt;span style=&quot;color: #9d9d9d;&quot;&gt;(나는 LoginFilter로 상속 받아 구현했다.)&lt;/span&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;AuthenticationManager&lt;/b&gt;에게 토큰을 건네준다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;AuthenticationManager의 구현체, ProviderManager, 이것에 의해 선언 된 AuthenticationProvider들의 구현체&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;DaoAuthenticationProvider&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;DaoAuthenticationProvider에서는 UserDetailsService에서 UserDetails(username, password)들을 파악&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;PasswordEncoder가 이 UserDetails의 비밀번호를 검증&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;( &amp;rArr; UserDetailsService로 사용자 조회, PasswordEncoder로 비밀번호 검증 - 기존의 비밀번호와 비교 시 PasswordEncoder.matches()로)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;최종적으로 토큰이 필터에 의해 SecurityContextHolder에 설정됨&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 짤 때 역시 중요한 건 작동 원리를 알아야 한다는 것을 재차 깨달았다. 그냥 '아 PasswordEncoder 없으면 인증 안 되는구나' 하고 넘어갈 수도 있었겠지만, 왜?라는 질문을 던지면서 Spring Security의 전반적인 구조와 필터의 원리까지 세세하게 살펴볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 도움 됐던 점은 &lt;b&gt;스프링 공식 문서&lt;/b&gt;를 살펴보며 개념을 정리해 가며 공부했다는 것!&lt;span style=&quot;color: #9d9d9d;&quot;&gt; 교환 학생 다녀오길 잘했다(?)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로도 의문이 생기면 끊임없이 질문하고 탐구하는 자세를 잃지 말자!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends2&quot; data-emoticon-name=&quot;051&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends2/large/051.png&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends2/large/051.png&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 자료&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;Spring Security - authentication, DaoAuthenticationProvider&quot; href=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html#servlet-authentication-daoauthenticationprovider&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html#servlet-authentication-daoauthenticationprovider&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>study/Spring</category>
      <category>Java</category>
      <category>로그인</category>
      <category>백엔드</category>
      <category>스프링</category>
      <category>자바</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/16</guid>
      <comments>https://9oongoguma.tistory.com/16#entry16comment</comments>
      <pubDate>Tue, 25 Mar 2025 19:46:37 +0900</pubDate>
    </item>
    <item>
      <title>[BOJ] 1966번 - 프린터 스택 (Java)</title>
      <link>https://9oongoguma.tistory.com/15</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.acmicpc.net/problem/1966&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt; https://www.acmicpc.net/problem/1966&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 사용자에게 문서들의 &lt;b&gt;중요도&lt;/b&gt;와 &lt;b&gt;출력 순서를 알고 싶은 특정 문서&lt;/b&gt;를 입력 받아, 그 문서가 출력물들 중에서 몇번째로 나올지 알려주는 코드를 짜야했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점은 문서열 큐에서 가장 앞에 있는 것을 빼낼 때, 가장 앞의 것의 중요도보다 더 큰 것이 열에 존재한다면 앞의 것을 제일 뒤로 add해야한다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;코드 1) 출력 순서를 알고 싶은 특정 문서의 위치를 기억하기 위해 큐에 0 삽입&lt;/h4&gt;
&lt;pre id=&quot;code_1740121428073&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.LinkedList;
import java.util.Queue;
import java.util.StringTokenizer;

public class Main {

    // 어떻게 원래 위치를 저장하는가? &amp;lt;- 문제

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int testCase = Integer.parseInt(br.readLine());
        StringBuilder result = new StringBuilder();

        for (int i = 0; i &amp;lt; testCase; i++) {
            StringTokenizer documents = new StringTokenizer(br.readLine());
            StringTokenizer importance = new StringTokenizer(br.readLine());

            int num = Integer.parseInt(documents.nextToken());
            int test = Integer.parseInt(documents.nextToken());

            Queue&amp;lt;Integer&amp;gt; queue = new LinkedList&amp;lt;&amp;gt;();

            // 큐에 중요도 넣기
            for (int j = 0; j &amp;lt; num; j++) {
                queue.add(Integer.parseInt(importance.nextToken()));

                // test 위치 확인용 0 삽입
                if (j == test)
                    queue.add(0);
            }

            // 출력 순서 확인
            result.append(checkPrintSeq(queue)).append(&quot;\n&quot;);
        }

        System.out.print(result);
    }

    static String checkPrintSeq (Queue&amp;lt;Integer&amp;gt; q) {
        int max = 1;
        int count = 0;
        int qSize = q.size();

        // 초기 max 중요도 확인
        for (int i = 0; i &amp;lt; qSize; i++) {
            if (q.peek() &amp;gt; max)
                max = q.peek();

            q.add(q.poll());
        }

        while (true) {
            if (q.peek() &amp;lt; max)
                q.add(q.poll());

            // 중요도 max인 요소 만났을 때 빼기
            else {
                q.poll();
                count++; // 빠진 순서

                // 바로 다음이 0이라면 종료
                if (q.peek() == 0)
                    break;

                // 요소가 빠졌다면 중요도 다시 확인
                max = 1;
                qSize = q.size();

                for (int j = 0; j &amp;lt; qSize; j++) {
                    int top = q.poll();

                    if (top &amp;gt; max)
                        max = top;

                    q.add(top);
                }
            }
        }

        return Integer.toString(count);
    }
}&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;일단 코드 짜기에 앞서 가장 고민 했던 것은 '어떻게 사용자가 알고자 하는 문서의 위치를 기억하느냐?'였다. 큐에서 poll, add가 반복되면서 특정 문서의 위치도 변화하기 때문이다.&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;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;중요도는 1 이상 9 이하의 정수이므로 이와 구별되는 0을 이용했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;하지만 이보다 더 간단하고 좋은 방법이 있었다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;코드 2) Priority Queue 활용하기&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1741329795180&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int testCase = Integer.parseInt(br.readLine());
        StringBuilder result = new StringBuilder();

        for (int i = 0; i &amp;lt; testCase; i++) {
            StringTokenizer documents = new StringTokenizer(br.readLine());
            int N = Integer.parseInt(documents.nextToken());
            int M = Integer.parseInt(documents.nextToken());

            StringTokenizer importance = new StringTokenizer(br.readLine());
            Queue&amp;lt;int[]&amp;gt; queue = new LinkedList&amp;lt;&amp;gt;(); // 1차원 배열에 (중요도, 위치) 이렇게 삽입
            // 우선 순위 큐 내림차순으로 (오름차순이 기본 설정)
            PriorityQueue&amp;lt;Integer&amp;gt; priorityQueue = new PriorityQueue&amp;lt;&amp;gt;(Collections.reverseOrder()); // 중요도 순으로 요소 꺼낼 때 활용

            for (int j = 0; j &amp;lt; N; j++) {
                int priority = Integer.parseInt(importance.nextToken());
                queue.add(new int[]{priority, j});
                priorityQueue.add(priority);
            }

            int count = 0;
            while (!queue.isEmpty()) {
                int[] current = queue.poll();
                if(current[0] == priorityQueue.peek()){
                    count++;
                    priorityQueue.poll();

                    if (current[1] == M) {
                        result.append(count).append(&quot;\n&quot;);
                        break;
                    }
                }

                else {
                    queue.add(current);
                }
            }
        }

        System.out.print(result);
    }
}&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;일반적으로 큐에서 요소를 꺼내거나 확인할 때 (poll, peek) 가장 앞의 요소가 반환된다. 하지만 Priority Queue, 우선 순위 큐는 큐의 요소를 꺼낼 때 가장 우선 순위가 높은(가장 작거나 큰, 기본은 오름차) 요소를 꺼낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 이 우선순위 큐를 어떻게 이용한다는 걸까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 기본 큐 요소를 1차원 배열로 하여, 입력 받은 문서의 중요도와 큐에서의 처음 위치를 함께 넣는다. 그리고 Priority Queue에는 중요도만 넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 문제 설명에서 큐에서 요소를 하나씩 출력하려 할 때 뒷 요소들 중 중요도가 더 큰 것이 있다면 그 요소(제일 앞 요소)를 가장 뒤로 보낸다고 했다. 이 말은 즉슨 결국에 문서를 중요도가 큰 순으로 출력하겠다는 것이므로, &lt;b&gt;큐에서 꺼낸 문서의 중요도와 PriorityQueue에서 peek를 한 값(중요도)이 같다면 출력&lt;/b&gt;하면 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 문서가 몇 번째로 출력되는지 계산하기 위해서는 count 값을 문서가 빠져나갈 때마다 증가시켜주고, 문서의 기존 순서 값이 원래 찾으려던 문서의 순서 값과 같다면 반복문을 멈춘다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Priority Queue&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삽입되는 순서와 상관 없이 큐 요소를 우선 순위가 높은 순으로 삭제시켜 주는 자료구조이다. &lt;br /&gt;Priority Queue는 기본적으로 삭제 시 오름차순으로 요소를 꺼낸다.&lt;br /&gt;&lt;br /&gt;만약, 내림차순으로 이용 시 Collections.reverseOrder()를 사용한다. (이는 Comparator의 일종)&lt;/p&gt;
&lt;pre id=&quot;code_1741334876363&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;PriorityQueue&amp;lt;Integer&amp;gt; priorityQueue = new PriorityQueue&amp;lt;&amp;gt;(Collections.reverseOrder());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항상 큐에서 특정 요소의 위치를 어떻게 기억하나 싶었는데, 오늘 문제와 같이 기존 순서를 따로 또 저장해주는 방법을 활용할 수 있겠다 생각했다. 또한 Priority Queue를 처음 접했는데, 우선순위에 따라 가장 먼저 요소를 꺼내야한다는 조건 등이 있을 때 사용하도록 잘 익혀놔야겠다.&lt;/p&gt;</description>
      <category>study/BOJ</category>
      <category>백준</category>
      <category>우선순위</category>
      <category>자바</category>
      <category>큐</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/15</guid>
      <comments>https://9oongoguma.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 7 Mar 2025 17:08:49 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 예상치 못한 JSON 필드 추가? - Lombok과 Jackson 직렬화 문제 해결</title>
      <link>https://9oongoguma.tistory.com/14</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;응답 통일을 위해 DTO, BaseCode, 성공 시 응답 처리 등을 코드로 작성했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;*   응답 통일을 해야하는 이유&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 생각: 데이터가 전달될 때 일정한 형식으로 오고 가야 CRUD 기능 내 메소드가 처리될 수 있고, 프론트단에서도 일정한 형식으로 (json 등) 제대로 정보를 받고 다시 보낼 데이터의 형식도 맞추는 등 쉽게 처리할 수 있다. 꼭 응답 통일 코드 작성을 잊지 말자!&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답의 형식을 담을 BaseResponse 코드를 대략 아래와 같이 짰었다.&lt;/p&gt;
&lt;pre id=&quot;code_1739675399443&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
@JsonPropertyOrder({&quot;isSuccess&quot;, &quot;code&quot;, &quot;message&quot;, &quot;result&quot;})
public class BaseResponse&amp;lt;T&amp;gt; {

    @JsonProperty(&quot;isSuccess&quot;)
    private final boolean isSuccess;
    private final String code;
    private final String message;
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T result;

    public static &amp;lt;T&amp;gt; BaseResponse&amp;lt;T&amp;gt; onSuccess(T result) {
        return new BaseResponse&amp;lt;&amp;gt;(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result);
    }
	.
    .
    .
}&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;보다시피 내가 json으로 변환 시키고 싶은 자바 필드는 isSuccess, code, message, result 이다.&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;근데 TestController를 실행 시켜 onSuccess의 결과를 localhost:8080에서 확인 했을 때 다음과 같은 결과가 나왔다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;{ &quot;isSuccess&quot;: true, &quot;code&quot;: &quot;COMMON200&quot;, &quot;message&quot;: &quot;성공입니다.&quot;, &quot;result&quot;: &quot;성공 응답 테스트 - 성공!&quot;,&amp;nbsp;&lt;br /&gt;&lt;b&gt;&quot;success&quot;: true&lt;/b&gt; }&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 추가시키지도 않은 success라는 값이 추가된 것!&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;이렇게 Java 객체를 JSON으로 변환할 때 예상과 달리 나올 때를 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;JSON 직렬화 문제&lt;/span&gt;라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고로 반대로 JSON에서 Java 객체로 변환시키는 것은 &lt;u&gt;역직렬화&lt;/u&gt;라고 한다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 문제가 발생했을까?&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Lombok의 &lt;b&gt;@Getter&lt;/b&gt;와 &lt;b&gt;Jackson&lt;/b&gt;의 원리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 해결하려면 내가 어노테이션으로 붙인 @Getter와 Jackson에 대해서 생각해봐야한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Lombok&lt;/b&gt;의 &lt;b&gt;@Getter&lt;/b&gt;는 자동으로 각 필드에 대한 &lt;b&gt;getter 메서드&lt;/b&gt;를 &lt;b&gt;자동 생성&lt;/b&gt;해준다. &lt;br /&gt;(ex. 내 코드에는 code, message라는 필드가 있기에 getCode, getMessage 등이 되겠다.)&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;그런데 @Getter가&lt;span style=&quot;color: #ef6f53;&quot;&gt; boolean 타입의 필드&lt;/span&gt;를 가지고 getter 메서드를 만들 때는 getXXX()가 아니라 &lt;span style=&quot;color: #ef6f53;&quot;&gt;isXXX()&lt;/span&gt;를 이름으로 한 메서드를 생성시킨다. 내 코드의 경우, isSuccess가 boolean 타입이었기에 다음과 같이 자동으로 getter가 만들어졌을 것이다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public boolean isSuccess() { return isSuccess; }
&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;이제 자바에서 json 변환할 때 getter 메서드를 탐색하여 json 필드를 결정해주는 도구,&amp;nbsp;&lt;b&gt;Jackson&lt;/b&gt;이 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson은 getter 메서드 이름을 json키로 사용하기에 &lt;b&gt;is 제외 success를 필드로 간주해 json으로 자동 변환한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;따라서 json 형식 응답을 확인 했을 때, 우리가 원하는 isSuccess뿐만 아니라 success도 추가로&amp;nbsp; 생기는&lt;/span&gt;&amp;nbsp;위와 같은 문제가 생긴 것이다.&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;i&gt;* 참고로 Jackson은 자바 객체 -&amp;gt; json 변환 시 이용되는 도구일뿐, 단순 데이터 타입 json과는 다르다!&lt;/i&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@JsonIgnore&lt;/b&gt;는&amp;nbsp; Jackson이 해당 필드를 자동 변환하지 않도록(직렬화에서 제외) 막을 수 있는 어노테이션이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lombok이 @Getter로 자동 생성 시켜주는 isSuccess() 대신, 직접 명시적으로 getter 메서드를 적어주고 그 위에 @JsonIgnore 어노테이션을 붙여주자.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@JsonIgnore
public boolean isSuccess() { return isSuccess; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public static &amp;lt;T&amp;gt; BaseResponse&amp;lt;T&amp;gt; onSuccess(T result) {
        return new BaseResponse&amp;lt;&amp;gt;(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result);
    }
    
// 필드 값을 초기화하는 것
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1906&quot; data-origin-height=&quot;898&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cffORb/btsMjKRsOO3/jIlUpmpJC6tsOqzkTkSVW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cffORb/btsMjKRsOO3/jIlUpmpJC6tsOqzkTkSVW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cffORb/btsMjKRsOO3/jIlUpmpJC6tsOqzkTkSVW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcffORb%2FbtsMjKRsOO3%2FjIlUpmpJC6tsOqzkTkSVW1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;283&quot; data-origin-width=&quot;1906&quot; data-origin-height=&quot;898&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 isSuccess 값은 우리가 직접 정의한 getter를 통해 가져오고, 나머지(code, message, result)는 Lombok이 자동 생성한 getter를 통해 JSON으로 변환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 '뭐지? 갑자기 난데 없는 success가 어디서 생긴거지? success라는 걸 내가 어딘가 넣었었나' 해서 당황스러웠다. 하지만 문제 해결을 위해 찾아보다 내가 무심코 쓰고 있던 어노테이션이 이런 원리를 하고, 거기서 json 변환에 Jackson이라는 도구가 이용된다는 사실을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시 각 도구들이 어떤 기능을 하는지 원리를 알아야함을 뼈저리게 깨달았다. 앞으로도 궁금하거나 잘 모르는 어노테이션, 도구가 등장하면 꼭 원리를 익히고 사용해야겠다고 다짐했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>study/Spring</category>
      <category>스프링</category>
      <category>자바</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/14</guid>
      <comments>https://9oongoguma.tistory.com/14#entry14comment</comments>
      <pubDate>Sun, 16 Feb 2025 12:39:48 +0900</pubDate>
    </item>
    <item>
      <title>[BOJ] 4949번: 균형잡힌 세상 (Java)</title>
      <link>https://9oongoguma.tistory.com/13</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.acmicpc.net/problem/4949&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;  https://www.acmicpc.net/problem/4949&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 소괄호와 대괄호가 짝이 맞게, 올바른 순서로 이루어져 있는지 확인하는 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 문제를 접했을 때, 주어진 문장을 한 글자씩 검사하여 괄호가 등장하면 큐나 덱에 push하고 그 후 괄호가 잘 구성되어 있는지 검사하려 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;시도 1) 큐에 괄호들 push 후 큐 검사&lt;/h4&gt;
&lt;pre id=&quot;code_1739419741974&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;

public class BOJ_4949_1 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

        String st = &quot;&quot;;
        ArrayList&amp;lt;String&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();

        // . 입력 전까지 문자열 입력
        do {
            st = br.readLine();
            Deque&amp;lt;Character&amp;gt; sentence = new ArrayDeque&amp;lt;&amp;gt;();

            if(st.equals(&quot;.&quot;))
                break;

            else {
                for (char c : st.toCharArray()) {
                    if (c == '(' || c == ')' || c == '[' || c == ']' || c == '.')
                        sentence.add(c);
                }
            }
            result.add(checkBalance(sentence));

        } while(true);

        while (!result.isEmpty())
            System.out.println(result.remove(0));
    }

    static String checkBalance(Deque&amp;lt;Character&amp;gt; dq) {
        char first, second = ' ';
        int count = 0;

        do {
            first = dq.poll();

            if(first!='.') second = dq.peek();

            if(((first=='(' &amp;amp;&amp;amp; second==')') || (first=='[' &amp;amp;&amp;amp; second==']'))){
                second = dq.poll();
            }

            else {
                dq.add(first);
            }

            if (dq.size() == 3)
                count++;

            if (count == 4) break;

            if (dq.size() == 1 &amp;amp;&amp;amp; dq.peek() == '.') break;

        } while(true);

        if (dq.size() == 1) return &quot;yes&quot;;

        else return &quot;no&quot;;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;s&gt;코드가 딱 봐도 길다...&lt;/s&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;처음에 내가 코드를 짠 기준은 다음과 같았다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;801&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nLxQL/btsMgr5lO89/jZduTIiNEc6p23X1BW17I1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nLxQL/btsMgr5lO89/jZduTIiNEc6p23X1BW17I1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nLxQL/btsMgr5lO89/jZduTIiNEc6p23X1BW17I1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnLxQL%2FbtsMgr5lO89%2FjZduTIiNEc6p23X1BW17I1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;801&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;801&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 문장에는 .도 함께 포함된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 첫 요소를 pop()하고 두 번째 것을 peek()하여 비교했을 때 ( 와&amp;nbsp; ) 혹은 [ 와 ] 로 짝이 맞는다면 두 번째 것도 역시 pop을 해준다. 하지만 pop한 첫 번째 요소가 다음 요소와 짝이 맞지 않는다면, 첫 번째 것을 제일 뒤로 add 해준다.&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;이러한 과정을 큐에 . 만 남고 괄호가 모두 pop 되어 없어질 때까지 반복한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백준 사이트에서 제공된 예제 입력을 콘솔창에 입력했을 때 예제 출력대로 결과가 잘 나와 사실 정답인 줄 알았다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1007&quot; data-origin-height=&quot;40&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blOfRW/btsMgIM6RzS/zjeb1A5y7P3yQ6GAdHcxnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blOfRW/btsMgIM6RzS/zjeb1A5y7P3yQ6GAdHcxnk/img.png&quot; data-alt=&quot;틀렸다!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blOfRW/btsMgIM6RzS/zjeb1A5y7P3yQ6GAdHcxnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblOfRW%2FbtsMgIM6RzS%2Fzjeb1A5y7P3yQ6GAdHcxnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1007&quot; height=&quot;40&quot; data-origin-width=&quot;1007&quot; data-origin-height=&quot;40&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;틀렸다!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜일까? 곰곰이 생각해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&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;size18&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&amp;lt;개선점&amp;gt;&lt;/span&gt; &lt;i&gt;괄호의 순서, 구성이 &lt;u&gt;잘못된 경우를 제대로 처리하지 않는다.&lt;/u&gt;&lt;/i&gt;&lt;i&gt;&lt;u&gt;&lt;/u&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불필요한 count 변수를 사용하고 있는 게 문제이다. 일단, 괄호를 pop하고 add하는 걸 계속 반복만 하고 더 이상 괄호가 빠지지 않는다면 불균형적인 괄호 문장으로 판단하여 반복문을 break하고 결과를 &quot;no&quot;로 저장되게끔 하였다. 이 일정 이상의 반복의 기준을 count로 설정했다. 예를 들어&amp;nbsp;&lt;span style=&quot;background-color: #c0d1e7;&quot;&gt; )(.&lt;/span&gt; 와 같은 문장이 있을 때, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;큐의 길이는 .을 포함해 3이고 이때 pop&amp;amp;add가 4번이나 반복 되어 count++이 총 4번 반복 되면, 이 이상 반복해도 큐에서 빠지는 괄호가 없기에 같은 과정이 반복되기만 하는 불균형 문장으로 판단할 수 있다. 얼핏 보면 괜찮다고 생각했는데, 이 당시에 저 예시에 홀린 것인지... '큐 길이가 3인 상태가 4번이나 반복되면 불균형 문장!' &amp;lt;- 이렇게 생각하며 논리의 오류를 남발했다... 허허&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 우리는 ( [ ) ] 와 같은 불균형적인 괄호 문장도 판단해내야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;지금 생각해보면&lt;span&gt; &lt;/span&gt;&lt;/span&gt;큐의 균형 여부는 당연히 큐의 길이&lt;span style=&quot;color: #9d9d9d;&quot;&gt;(처음에는 괄호는 짝이 맞아야하니까 짝수, 홀수 이런 것도 떠올려보기도 했지만...)&lt;/span&gt;와는 관련이 없고, 가장 중요하게도 &lt;b&gt;괄호 순서&lt;/b&gt;에 초점을 두어야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외가 있으면 안 되니까 다양한 입력 예시를 모두 생각하고 고려해보며 코드를 짜야함을 또 명심했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;시도 2) (최종 코드) 스택을 이용하기&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;괄호 순서&lt;/b&gt;에 유의하며 스택을 이용한 코드로 바꿨다.&lt;/p&gt;
&lt;pre id=&quot;code_1739420288988&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Stack;

public class BOJ_4949_2 {

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String st = &quot;&quot;;
        StringBuilder sb = new StringBuilder();

        while (true) {
            st = br.readLine();

            if (st.equals(&quot;.&quot;)) break;

            if (isBalanced(st))
                sb.append(&quot;yes\n&quot;);

            else sb.append(&quot;no\n&quot;);
        }
        System.out.println(sb);
    }

    public static boolean isBalanced(String st) {
        Stack&amp;lt;Character&amp;gt; stack = new Stack&amp;lt;&amp;gt;();

        for (char c : st.toCharArray()) {
            if (c == '(' || c == '[')
                stack.push(c);

            else if ((c == ')' || c == ']')) {
                if (stack.isEmpty())
                    return false;

                else {
                    char top = stack.pop();

                    if ((top == '(' &amp;amp;&amp;amp; c != ')') || (top == '[' &amp;amp;&amp;amp; c != ']'))
                        return false;
                }
            }
        }
        return stack.isEmpty();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각보다 더욱 간단한 로직으로 풀 수 있는 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 달라진 점이 있다면, 앞선 코드에서는 문장의 모든 괄호 (,[,),], .을 덱(deque)에 다 넣고 나서 균형한지 판단했지만, 이번에는 순차적으로 스택에 ( 과 [ 만 &lt;b&gt;넣으면서&lt;/b&gt; 동시에 균형한지 &lt;b&gt;판단&lt;/b&gt;(짝이 맞으면 pop)한다는 것이다.&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;그리고 )이나 ] 이 나오면 스택 안의 top 요소- ( 또는 [ - 를 꺼내 비교하고, 짝이 맞지 않는다면 false를 반환해 no가 출력되게 하는 것이다. 괄호 검사 반복문을 마쳤을 때는 stack.isEmpty()의 결과를 반환해 스택이 모두 비었다면 true, 즉 균형 있는 스택, 스택에 아직 요소가 있다면 false, 불균형 스택이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 봤을 때 pop, push가 있는 스택, 큐나 덱을 써야겠다고 생각은 했고, 모든 요소를 넣어야 판별이 될거라 처음에는 생각했다. 그래서 코드가 복잡해지고 count 변수도 생기고 했었다... 하지만 이 문제에서는 &lt;i&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;모든 괄호를 넣는 게 아니라&lt;/span&gt; 시작 괄호들만을 넣고&lt;/i&gt; 끝 괄호와 &lt;span style=&quot;color: #0593d3;&quot;&gt;비교해서 스택에서 요소들을 pop 한다&lt;/span&gt;는 발상이 나에게는 새로웠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 다양한 &lt;span style=&quot;color: #0593d3;&quot;&gt;예시, 예외 상황을 눈흐릿..하며 무시하지 말고 귀기울이자고&lt;/span&gt; 다짐했다. &quot;여기선 이 논리가 당연하겠지~&quot;라며 어물쩡 넘어 갔던 사소한 부분들로부터 예외 상황을 고려하지 못한 코드를 낳는다는 점을 명심하며...&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 부족한 점을 깨달을 수 있는 문제였다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;008&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/008.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/008.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>study/BOJ</category>
      <category>백준</category>
      <category>자바</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/13</guid>
      <comments>https://9oongoguma.tistory.com/13#entry13comment</comments>
      <pubDate>Thu, 13 Feb 2025 21:24:53 +0900</pubDate>
    </item>
    <item>
      <title>[Spring/클론 코딩] todo mate를 따라 만들어보자(1) - IA와 데이터베이스 ERD 설계</title>
      <link>https://9oongoguma.tistory.com/12</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;반년간 교환학생을 다녀온 뒤, 개발에 대한 감각이 많이 무뎌진 것 같다고 스스로 느끼곤 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 무얼해야할까 고민하다 혼자서 웹/앱 서비스를 구현해보는 건 어떨지 고민하게 되었다. 처음에는 아예 새로운 서비스를 구현해보고자 하는 마음에 우선 내 아이디어에서 시작해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 좋아하고, 만들고 싶었던 걸 떠올리다 나온 것은 '내가 좋아하는 다이어리 꾸미기(다꾸)를 웹이나 앱으로 만들어보자!'었다. 매일 쓴 일기에 그 주변을 꾸미고, 월간 캘린더, 거기다 내가 구현해보고팠던 채팅 기능까지(웹소켓을 공부해보고 싶었기 때문...) 넣으면 어떨지 생각해보았다. 그런데 자꾸만 어쩐지 겹쳐지는 투두 메이트의 잔상...&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;사실 난 투두 메이트를 굉장히 애용하는 사람이다. 그만큼 내가 투두를 이용하면서 느낀 아쉬운 점도 많았다. 내가 개발자라면.. 이 기능도 괜찮을 것 같은데? 하며 말이다 (ㅋㅋ) 기존의 투두 메이트는 이름 그대로 'mate'에 집중하여 사람들과 소통할 수 있는, 그리고 다른 친구들이 하루 하루 그리고 한 달 동안 성취해내는 체크 리스트를 보면서 나도 자극 받고 열의를 다질 수 있는 어플이라 느꼈다. 하지만 나는 다꾸러(아직 파릇파릇한 다꾸 어린이 수준이지만...)로서 다이어리 꾸미기나 기록에도 집중한 나만의 투두 메이트를 만들어 보고 싶었다 ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각했던 추가 기능은 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;일기나 그 날의 할 일에 자신이 원하는 위치를 넣게끔 하는 기능을 추가하면 어떨지(약속 등은 시간뿐만 아니라 장소도 기록해두면 기억하기 편하니까), 단체 채팅방도 추가하면 어떨지 등등... 또한&lt;span&gt; 가능하다면 다꾸할 수 있게끔 스티커들도 추가해본다든지..였다!&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;아이디어가 여기 저기 떠돌아다니지만 우선 투두 메이트를 클론 코딩하며 무뎌진 나의 개발 감각을 일깨워보려한다! 그리고 원했던 기능들도 추가할 것이다. 또한 사실 서버 구현말고도 리액트로 웹앱 등까지 욕심 내보고 싶다. 서버 개발자가 되기를 원하지만 프론트 쪽을 너무 몰라도 문제인 것 같다고 부쩍 느끼고 있기 때문... 서버에는 내가 서툰, 안 써본 기술들을 적용해보기도 하고 좀 더 코드를 고민하며 짜보려 한다. 여튼 그만큼 꽤 힘들 것 같기도 하지만, 힘든 만큼 성장할 수 있다 생각하고 최선을 다해 임해보려 한다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잔말 말고 시작!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;IA&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발하기에 앞서 내가 생각한 나만의 투두메이트 IA(Information Architecture)를 그려봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1809&quot; data-origin-height=&quot;1309&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHrhop/btsL1gCK5Yp/N8pFmKDWQ9knIC1XQs0uK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHrhop/btsL1gCK5Yp/N8pFmKDWQ9knIC1XQs0uK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHrhop/btsL1gCK5Yp/N8pFmKDWQ9knIC1XQs0uK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHrhop%2FbtsL1gCK5Yp%2FN8pFmKDWQ9knIC1XQs0uK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;362&quot; data-origin-width=&quot;1809&quot; data-origin-height=&quot;1309&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(검색 기능에 친구 그룹을 찾는다든지 하는 다양한 기능들도 있었지만, 나는 친구 아이디로 조회하는 기능만 우선 넣을 것이다.)&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&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;로그인/회원가입&lt;span style=&quot;color: #009a87;&quot;&gt; (jwt token)&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메일, 비번&lt;/li&gt;
&lt;li&gt;소셜 로그인 (구글, 카카오톡) &lt;span style=&quot;color: #009a87;&quot;&gt;(OAuth)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;피드
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;달력 &amp;rarr; 날짜별 할 일 목록
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&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;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;달력 &amp;rarr; 날짜별 일기
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일기에는 사진, 글, 장소&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&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;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;대화 &lt;span style=&quot;color: #009a87;&quot;&gt;(ws)&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;채팅방: 1:1, 단체
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사람, 채팅 내용, 시간&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&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;/li&gt;
&lt;li&gt;설정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;계정&lt;/li&gt;
&lt;li&gt;로그아웃&lt;/li&gt;
&lt;li&gt;회원탈퇴&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존에 없던 추가 기능
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단체 채팅방&lt;/li&gt;
&lt;li&gt;할 일과 일기에 장소 추가하기 &lt;span style=&quot;color: #006dd7;&quot;&gt;(프론트 지도 api)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&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;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 위 목록들은 내가 구현할 수 있는 기능들로 정리해보았다. 사실 기존 투두에서는 내가 적은 할 일 리스트나 일기를 나만 보게 하거나 특정 팔로워에게만 보이게 설정할 수 있다. 하지만 우선적으로는 모두에게 보이게끔 권한을 설정하려한다. (나에겐 일단 꽤나 어려울 것 같음...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터베이스 ERD&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1414&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/s5uHw/btsL0o2EW90/TQKDrfoxKfHvgI8SSjQkpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/s5uHw/btsL0o2EW90/TQKDrfoxKfHvgI8SSjQkpK/img.png&quot; data-alt=&quot;StarUML 사용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/s5uHw/btsL0o2EW90/TQKDrfoxKfHvgI8SSjQkpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fs5uHw%2FbtsL0o2EW90%2FTQKDrfoxKfHvgI8SSjQkpK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-origin-width=&quot;1414&quot; data-origin-height=&quot;1414&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;StarUML 사용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 나서는 IA와 투두 메이트 화면을 와이어프레임 삼아 고민해보며 데이터베이스 erd를 설계했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 어려웠던 부분은 '&lt;b&gt;&lt;i&gt;팔로잉과 팔로워&lt;/i&gt;&lt;/b&gt;를 어떻게 erd에서 나타내지?' 였다.&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 data-ke-size=&quot;size16&quot;&gt;생각한 방법은 크게 두 가지였다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; &amp;nbsp;팔로잉과 팔로워 관리하는 법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;아이디어 1) &lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;멤버 엔티티 내의 attribute으로 팔로우, 팔로잉 리스트 만들기&lt;/span&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;li&gt;다른 유저도 여러 명을 팔로우할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; 멤버 엔티티 내의 attribute으로 팔로우, 팔로잉 리스트 만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; 이렇게 되면 누군가 멤버를 팔로우 했을 때 그 멤버의 팔로우를 건드려야함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 업데이트 시 매번 리스트 전체를 수정해야하므로 효율적 x&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(데이터 조회, 관리도 어렵)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기각!&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;br /&gt;아이디어 2) &lt;b&gt;Follow 엔티티 만들기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한 유저는 여러 명을 팔로우할 수 있다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동시에, 한 유저는 여러 명에게 팔로잉 될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; 팔로워/팔로잉 관계를 표시하는 Follow 엔티티를 만들자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 한 팔로워/팔로잉 관계 당 한 Follow 객체가 생김.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex. 멤버 1이 멤버 2를 팔로우한다. &amp;rArr; follower_id는 멤버 1의 id, followed_id는 멤버 2의 id&lt;br /&gt;예를 들어, 멤버 10명이 한 명당 5명을 팔로우 했을 때에는 50개의 Follow 객체가 생기는 것이다. (여기서 서로 맞팔로우를 한다면 100개)&lt;/p&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;412&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0WFfZ/btsL03XCujt/hMmIeSpAE1UXKQBdjpJr10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0WFfZ/btsL03XCujt/hMmIeSpAE1UXKQBdjpJr10/img.png&quot; data-alt=&quot;멤버 및 팔로우 엔티티&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0WFfZ/btsL03XCujt/hMmIeSpAE1UXKQBdjpJr10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0WFfZ%2FbtsL03XCujt%2FhMmIeSpAE1UXKQBdjpJr10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;250&quot; height=&quot;442&quot; data-origin-width=&quot;412&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;멤버 및 팔로우 엔티티&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;i&gt;기타 또 고민한 문제들, 그리고 하나하나 생각해보며 어떤 방법이 나을지 정리했다.&lt;/i&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;  멤버 조회 시 팔로워 수와 팔로잉 수를 프로필에서 나타내기&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;투두에는 마이페이지에서 자신의 팔로워 수와 팔로잉 수를 확인할 수 있다. 이 수를 어떻게 조회하고 보여줘야할지 고민했다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;실시간으로 계산해서 보여주기 =&amp;gt; 데이터 많아지면 느려질 수도 있음.&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 멤버의 팔로워 수는 followed_id가 해당 멤버의 id인 Follow 객체의 수를 count 하면 됨.&lt;/li&gt;
&lt;li&gt;반대로 팔로잉 수는 follower_id가 해당 멤버의 id인 Follow 객체 수를 센다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;미리 저장 (Member 속성으로 follower_count, following_count 두기)&lt;br /&gt;- 관계 변경 시 마다 member 업데이트하기&lt;br /&gt;&lt;br /&gt;이 방법 중에서 고민해봤는데, 우선 데이터를 많이 넣지는 않을 것이니 1번으로 구현할 예정이다.&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  채팅방 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단체 채팅방까지 구현하고 싶어 이것 저것 생각해보았다.&lt;/p&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;한 멤버는 여러 채팅방에 들어갈 수 있다.&lt;/li&gt;
&lt;li&gt;또한 한 채팅방에는 여러 멤버가 참여할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rArr; 이는 다대다 관계이므로, 이를 일대다 테이블로 풀어주기 위해 &lt;b&gt;중간 테이블을 추가했다.&lt;br /&gt;&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;추가적으로 했던 고민...&lt;br /&gt;&lt;span style=&quot;color: #9d9d9d; caret-color: auto; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;  채팅 기능 구현에 웹소켓을 쓸 것인데 메시지 엔티티를 추가해야할까?&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; caret-color: auto; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;A. Yes!&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; caret-color: auto; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;- 웹소켓은 실시간 메시지 소통에 효과적, 하지만 메시지를 영구 저장하지는 않는다.&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; caret-color: auto; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;- 메시지를 저장해서 나중에 또 조회하려면 Message 테이블 필요함.&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;br /&gt;Message 엔티티&lt;/b&gt;&lt;/p&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;누가, 어느 채팅방에서 보냈는지 조회하는 방법
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;member_id와 chatroom_id 가져오기(FK)&lt;br /&gt;- 다대다 관계를 다시 정의(중복 정의)하는 것과 비슷&lt;br /&gt;- member_id와 chatroom_id 조합이 있는지 message 테이블에서 찾아봐아하는 추가 로직 필요 &lt;br /&gt;&amp;rArr; 매번 확인 시 비효율적, &lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: left;&quot;&gt;무결성 유지⬇️&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;member_chatroom_id를 가져오기(FK)&lt;br /&gt;&lt;/b&gt;- 간결, 무결성 유지 &amp;zwj;♀️&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;1182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6unqJ/btsL1UMtm2J/cpz8cPvok4PsCNpTCzWj11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6unqJ/btsL1UMtm2J/cpz8cPvok4PsCNpTCzWj11/img.png&quot; data-alt=&quot;채팅방 구현 관련 erd&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6unqJ/btsL1UMtm2J/cpz8cPvok4PsCNpTCzWj11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6unqJ%2FbtsL1UMtm2J%2Fcpz8cPvok4PsCNpTCzWj11%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;433&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;1182&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;채팅방 구현 관련 erd&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;+. 메시지를 보낸 날짜, 시각은 created_at 속성 활용할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 우리가 이용하고 있는 서비스에서 제공하는 기능들은 가만 보면 다 개발자들이 고심하고 고심해서 설정한 것이란 것..!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몸소 깨달을 수 있었다. 위 erd는 점차 코드를 짜다보면 달라지기도 할 것이다. 얼른 엔티티 코드를 작성해봐야겠다!&lt;/p&gt;</description>
      <category>study/Spring</category>
      <category>todomate</category>
      <category>백엔드</category>
      <category>스프링</category>
      <category>클론코딩</category>
      <category>투두메이트</category>
      <category>프로그래밍</category>
      <author>jimddong</author>
      <guid isPermaLink="true">https://9oongoguma.tistory.com/12</guid>
      <comments>https://9oongoguma.tistory.com/12#entry12comment</comments>
      <pubDate>Fri, 24 Jan 2025 23:12:54 +0900</pubDate>
    </item>
  </channel>
</rss>