<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>najang 님의 블로그</title>
    <link>https://najang.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 9 Jul 2026 16:13:34 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>najang</managingEditor>
    <image>
      <title>najang 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/8522112/attach/142d093b843f4879b039d59ad9651574</url>
      <link>https://najang.tistory.com</link>
    </image>
    <item>
      <title>6월 회고 &amp;mdash; 인증부터 옷장 도메인까지</title>
      <link>https://najang.tistory.com/entry/6%EC%9B%94-%ED%9A%8C%EA%B3%A0-%E2%80%94-%EC%9D%B8%EC%A6%9D%EB%B6%80%ED%84%B0-%EC%98%B7%EC%9E%A5-%EB%8F%84%EB%A9%94%EC%9D%B8%EA%B9%8C%EC%A7%80</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가기 전에 &amp;mdash; OMO를 왜 만들게 됐나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매일 아침 출근 준비할 때 날씨 앱부터 켰다. 정확히는 오늘 기온이 궁금해서가 아니라, 뭘 입어야 할지 정하기 위해서였다. 근데 회사에서 팀원들이랑 얘기해보니 다들 똑같았다. 아침마다 날씨 앱 켜서 숫자 보고, 그 숫자를 머릿속으로 &quot;그래서 오늘 뭐 입지&quot;로 한 번 더 변환하는 과정을 매일 반복하고 있었다.&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;그래서 처음 든 생각은 단순했다. 그냥 매일 아침 정해진 시간에 &quot;오늘 이렇게 입으세요&quot;라고 알림으로 알려주는 앱이 있으면 그 변환 과정을 안 거쳐도 되지 않을까 하는 거였다.&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;근데 조금 더 얘기하다 보니 다른 문제가 보였다. 같은 18도라도 누구는 가디건만 걸치고 나가고, 누구는 그 위에 자켓까지 입어야 한다. 추위나 더위를 타는 정도가 사람마다 다르기 때문이다. 단순히 &quot;오늘 기온은 18도니까 이 옷&quot;이라고 일괄로 추천하면 절반은 맞고 절반은 안 맞는 추천이 될 거였다.&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;그래서 거기에 &quot;개인화&quot;를 더하기로 했다. 오늘 추천한 옷차림이 그 사람한테 어땠는지(추웠다/적당했다/더웠다) 다음 날 한 번 물어보고, 그 피드백이 쌓일수록 그 사람의 체감 온도에 맞춰 추천이 점점 보정되도록 만드는 것이다. OMO는 이렇게 &quot;매일 아침 옷차림 고민을 줄여주는 알림 + 그 사람한테 맞춰지는 추천&quot;이라는 두 축으로 시작한 프로젝트다.&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;신규 유저는 당연히 피드백 이력이 없으니 추천이 동작할 기반이 없다. 그래서 온보딩에서 &quot;지금 가지고 있는 옷&quot;과 &quot;평소에 추위/더위를 얼마나 타는지&quot;를 미리 받아두기로 했는데, 이번 달 작업의 상당 부분(옷장 도메인)이 바로 이 시작점을 만드는 과정이었다.&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;이번 달엔 OMO 앱의 핵심 골격을 거의 다 만들었다. JWT 인증, 닉네임 온보딩, 그리고 옷장(wardrobe) 도메인까지. PR로 치면 두 개를 머지했고(#3, #4), 마이페이지 옷장 수정 API(OMO-63)는 지금 작업 중이다. 작업량 자체보다 중간에 만난 삽질들이 더 기억에 남아서, 그걸 위주로 정리해본다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 인증 인터셉터를 만들면서 &amp;mdash; Filter 말고 Interceptor를 쓴 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 인증을 붙일 때 가장 먼저 고민한 게 &quot;Filter로 할까, Interceptor로 할까&quot;였다. 둘 다 요청을 가로채서 검증할 수 있는데, 차이를 제대로 몰라서 처음엔 그냥 익숙한 Filter로 시작하려 했다.&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;code&gt;CoreException&lt;/code&gt; + &lt;code&gt;@ControllerAdvice&lt;/code&gt;로 처리하고 있다. 그런데 Filter는 &lt;code&gt;DispatcherServlet&lt;/code&gt; 앞단이라 &lt;code&gt;@ControllerAdvice&lt;/code&gt;가 못 잡는다. Filter에서 던진 예외는 결국 톰캣 기본 에러 페이지로 가버린다. 반면 Interceptor는 &lt;code&gt;DispatcherServlet&lt;/code&gt; 안에서 동작해서, &lt;code&gt;preHandle()&lt;/code&gt;에서 던진 &lt;code&gt;CoreException&lt;/code&gt;도 컨트롤러에서 던진 것과 똑같이 &lt;code&gt;ApiControllerAdvice&lt;/code&gt;가 받아서 우리 응답 포맷(&lt;code&gt;ApiResponse&lt;/code&gt;)으로 변환해준다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;String token = authHeader.substring(BEARER_PREFIX.length());
try {
    Long userId = jwtProvider.parseUserId(token);
    request.setAttribute(USER_ID_ATTRIBUTE, userId);
    return true;
} catch (ExpiredJwtException e) {
    throw new CoreException(ErrorType.TOKEN_EXPIRED, &quot;만료된 토큰입니다.&quot;);
} catch (JwtException e) {
    throw new CoreException(ErrorType.UNAUTHENTICATED, &quot;유효하지 않은 토큰입니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 또 하나 걸린 게 catch 순서였다. JJWT의 &lt;code&gt;ExpiredJwtException&lt;/code&gt;은 &lt;code&gt;JwtException&lt;/code&gt;을 상속한다. 처음에 순서를 반대로 써서, 만료된 토큰도 그냥 &lt;code&gt;UNAUTHENTICATED&lt;/code&gt;로 떨어지는 버그가 있었다. 만료(&lt;code&gt;TOKEN_EXPIRED&lt;/code&gt;)와 변조(&lt;code&gt;UNAUTHENTICATED&lt;/code&gt;)는 클라이언트 입장에서 완전히 다른 처리다 &amp;mdash; 만료면 리프레시 토큰으로 재시도하면 되지만, 변조면 그냥 로그인 화면으로 보내야 한다. 자식 예외를 먼저 잡아야 한다는 당연한 원칙인데, 실제로 안 걸려봤으면 계속 모르고 넘어갔을 것 같다.&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;인증된 userId를 컨트롤러에 넘기는 부분도 재밌었다. &lt;code&gt;request.getAttribute(&quot;userId&quot;)&lt;/code&gt;를 컨트롤러마다 직접 캐스팅해서 쓰면 코드가 지저분해질 게 뻔해서, &lt;code&gt;@AuthUser&lt;/code&gt; 커스텀 어노테이션 + &lt;code&gt;HandlerMethodArgumentResolver&lt;/code&gt;로 뺐다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public ApiResponse&amp;lt;?&amp;gt; getProfile(@AuthUser Long userId) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두니까 컨트롤러는 &quot;인증된 유저 아이디가 필요하다&quot;는 의도만 선언하면 되고, attribute 키 이름이나 캐스팅 같은 디테일은 리졸버 한 곳에만 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. ErrorType, &quot;같은 상태코드인데 왜 나눠?&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 타입을 설계하면서 계속 스스로 물어본 질문이 &quot;이거 굳이 나눠야 하나?&quot;였다. 답은 의외로 단순했다 &amp;mdash; &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;code&gt;TOKEN_EXPIRED&lt;/code&gt; vs &lt;code&gt;UNAUTHENTICATED&lt;/code&gt;: 둘 다 401이지만, 전자는 토큰 갱신을 시도해야 하고 후자는 바로 로그인 화면으로 보내야 한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BAD_REQUEST&lt;/code&gt; vs &lt;code&gt;INVALID_INPUT&lt;/code&gt;: 둘 다 400이지만, 전자는 &quot;개발자가 API 스펙을 잘못 맞춘 것&quot;에 가깝고 후자는 &quot;사용자가 입력을 잘못한 것&quot;이다.&lt;/li&gt;
&lt;li&gt;옷장 API에서는 &lt;code&gt;WARDROBE_NOT_FOUND&lt;/code&gt;(404)도 새로 추가했다. 옷장이 아예 없는 것(온보딩 미완료)과 옷장은 있는데 아이템을 0개 선택한 것은 의미가 다르다. 빈 배열로 두 경우를 퉁치면 클라이언트가 &quot;이 유저는 온보딩을 안 끝냈구나&quot;를 알 방법이 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 안 만든 에러 타입도 있다. 추천 결과가 없을 때 404를 내려주는 안을 처음엔 생각했는데, 곰곰이 보니 사용자가 앱을 처음 켰을 때 에러 화면을 보게 되는 최악의 시나리오였다. 그래서 &quot;배치가 실패했으면 그 자리에서 날씨 API를 불러서라도 추천을 만들어 보여준다&quot;는 fallback 전략으로 바꾸고 에러 타입 자체를 없앴다. 에러를 잘 설계하는 것만큼, 어떤 상황을 아예 에러로 만들지 않는 것도 중요하다는 걸 느꼈다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 옷장 도메인 &amp;mdash; &lt;code&gt;@ElementCollection&lt;/code&gt;에서 &lt;code&gt;@ManyToMany&lt;/code&gt;로 갈아엎은 이야기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 달에 가장 시간을 많이 쓴 부분이다. 처음에 옷장 데이터를 설계할 때는 &quot;유저가 어떤 카테고리(상의/아우터/바지...)의 옷을 가지고 있는지&quot;만 저장하면 될 줄 알고 &lt;code&gt;@ElementCollection&amp;lt;ClothingCategory&amp;gt;&lt;/code&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;근데 추천 로직을 같이 고민하다 보니 &quot;반팔 티셔츠는 있는데 긴팔은 없다&quot; 같은 &lt;b&gt;아이템 단위&lt;/b&gt; 정보가 필요하다는 게 명확해졌다. 카테고리 수준 정보로는 추천이 너무 뭉뚱그려진다. 그래서 &lt;code&gt;ClothingItem&lt;/code&gt;이라는 카탈로그 엔티티를 새로 만들고, &lt;code&gt;UserWardrobe&lt;/code&gt;와 &lt;code&gt;@ManyToMany&lt;/code&gt;로 연결하는 구조로 갈아엎었다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
    name = &quot;user_wardrobe_item&quot;,
    joinColumns = @JoinColumn(name = &quot;wardrobe_id&quot;),
    inverseJoinColumns = @JoinColumn(name = &quot;item_id&quot;)
)
private Set&amp;lt;ClothingItem&amp;gt; items = new HashSet&amp;lt;&amp;gt;();

public void updateItems(Set&amp;lt;ClothingItem&amp;gt; items) {
    this.items.clear();
    this.items.addAll(items);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 처음엔 &lt;code&gt;this.items = new HashSet&amp;lt;&amp;gt;(items)&lt;/code&gt;처럼 통째로 갈아끼우려고 했다. 컴파일도 되고 로직상 문제도 없어 보였는데, 실제로 돌려보니 DB에 반영이 안 됐다. 알고 보니 Hibernate가 추적하고 있던 영속 컬렉션 인스턴스를 새 &lt;code&gt;HashSet&lt;/code&gt;으로 바꿔치기하면 dirty checking 대상에서 빠진다고 한다. &lt;code&gt;clear()&lt;/code&gt;로 기존 걸 비우고 &lt;code&gt;addAll()&lt;/code&gt;로 새로 채워야 같은 인스턴스를 유지하면서 변경을 감지한다 &amp;mdash; JPA를 쓰면서 &quot;참조를 바꾸는 것&quot;과 &quot;내용을 바꾸는 것&quot;의 차이를 처음으로 체감한 순간이었다.&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;code&gt;system_key&lt;/code&gt;라는 개념도 새로 도입했다. Flutter 화면에서 쓰는 아이템 ID(&lt;code&gt;'short-tee'&lt;/code&gt;, &lt;code&gt;'padding'&lt;/code&gt;)와 서버 DB의 PK(숫자)를 분리한 것이다. PK는 배포 환경마다 달라질 수 있는 내부 값이라, API 계약에는 코드로 관리되는 &lt;code&gt;system_key&lt;/code&gt;만 노출한다. 대신 이 방식의 단점도 바로 보였다 &amp;mdash; Flutter 쪽 하드코딩 목록과 서버 시드 데이터가 코드 레벨에서만 동기화된다. 한쪽만 바뀌면 400 에러가 나는데, CI에서는 안 잡힌다.&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;이&amp;nbsp;빚은&amp;nbsp;결국&amp;nbsp;같은&amp;nbsp;달&amp;nbsp;안에&amp;nbsp;갚았다.&amp;nbsp;방법은&amp;nbsp;단순했다&amp;nbsp;&amp;mdash;&amp;nbsp;옷&amp;nbsp;목록을&amp;nbsp;Flutter가&amp;nbsp;들고&amp;nbsp;있지&amp;nbsp;말고,&amp;nbsp;서버가&amp;nbsp;`GET&amp;nbsp;/api/v1/clothing-items`로&amp;nbsp;내려주면&amp;nbsp;된다.&amp;nbsp;목록이&amp;nbsp;한&amp;nbsp;벌만&amp;nbsp;존재하면&amp;nbsp;어긋날&amp;nbsp;일도&amp;nbsp;없고,&amp;nbsp;DB에서&amp;nbsp;아이템을&amp;nbsp;지우면&amp;nbsp;화면에서도&amp;nbsp;바로&amp;nbsp;사라진다.&amp;nbsp;근데&amp;nbsp;막상&amp;nbsp;옮기려니&amp;nbsp;걸리는&amp;nbsp;게&amp;nbsp;하나&amp;nbsp;있었다.&amp;nbsp;화면에서&amp;nbsp;옷을&amp;nbsp;묶어&amp;nbsp;보여주는&amp;nbsp;그룹과&amp;nbsp;추천&amp;nbsp;로직이&amp;nbsp;쓰는&amp;nbsp;카테고리(`ClothingCategory`)가&amp;nbsp;1:1이&amp;nbsp;아니었다.&amp;nbsp;`PANTS`랑&amp;nbsp;`SKIRT`는&amp;nbsp;화면에선&amp;nbsp;그냥&amp;nbsp;&quot;하의&quot;&amp;nbsp;한&amp;nbsp;칸이고,&amp;nbsp;반대로&amp;nbsp;`ACCESSORY`는&amp;nbsp;&quot;모자&quot;랑&amp;nbsp;&quot;스카프/목도리&quot;&amp;nbsp;두&amp;nbsp;칸으로&amp;nbsp;갈라진다.&amp;nbsp;`category`만&amp;nbsp;내려주면&amp;nbsp;볼캡이랑&amp;nbsp;스카프를&amp;nbsp;다른&amp;nbsp;칸에&amp;nbsp;그릴&amp;nbsp;수가&amp;nbsp;없다&amp;nbsp;&amp;mdash;&amp;nbsp;둘&amp;nbsp;다&amp;nbsp;`ACCESSORY`니까.&amp;nbsp;그래서&amp;nbsp;화면&amp;nbsp;그룹을&amp;nbsp;`display_group`&amp;nbsp;컬럼으로&amp;nbsp;따로&amp;nbsp;뒀다.&lt;br /&gt;&lt;br /&gt;여기서&amp;nbsp;한&amp;nbsp;번&amp;nbsp;더&amp;nbsp;고민한&amp;nbsp;게&amp;nbsp;&quot;이&amp;nbsp;그룹&amp;nbsp;매핑을&amp;nbsp;어디에&amp;nbsp;둘까&quot;였다.&amp;nbsp;그냥&amp;nbsp;Flutter에&amp;nbsp;'systemKey&amp;nbsp;&amp;rarr;&amp;nbsp;그룹'&amp;nbsp;표를&amp;nbsp;상수로&amp;nbsp;남기는&amp;nbsp;것도&amp;nbsp;방법이었는데,&amp;nbsp;그러면&amp;nbsp;새&amp;nbsp;액세서리를&amp;nbsp;추가할&amp;nbsp;때마다&amp;nbsp;&quot;이건&amp;nbsp;모자야&amp;nbsp;스카프야&quot;를&amp;nbsp;또&amp;nbsp;Flutter가&amp;nbsp;알아야&amp;nbsp;해서&amp;nbsp;하드코딩&amp;nbsp;문제가&amp;nbsp;절반만&amp;nbsp;풀린다.&amp;nbsp;그래서&amp;nbsp;그룹&amp;nbsp;소속까지&amp;nbsp;DB가&amp;nbsp;쥐게&amp;nbsp;했다.&amp;nbsp;Flutter엔&amp;nbsp;그룹의&amp;nbsp;&quot;생긴&amp;nbsp;것&quot;(라벨&amp;middot;아이콘&amp;middot;색)만&amp;nbsp;남기고,&amp;nbsp;어떤&amp;nbsp;아이템이&amp;nbsp;어느&amp;nbsp;그룹인지는&amp;nbsp;DB가&amp;nbsp;정한다.&amp;nbsp;물론&amp;nbsp;공짜는&amp;nbsp;아니라서,&amp;nbsp;이제&amp;nbsp;목록이&amp;nbsp;네트워크에&amp;nbsp;의존하니&amp;nbsp;화면마다&amp;nbsp;로딩/에러&amp;nbsp;상태를&amp;nbsp;처리해야&amp;nbsp;하고&amp;nbsp;오프라인이면&amp;nbsp;&quot;다시&amp;nbsp;시도&quot;&amp;nbsp;버튼만&amp;nbsp;남는다.&amp;nbsp;그래도&amp;nbsp;&quot;코드&amp;nbsp;레벨&amp;nbsp;동기화&quot;가&amp;nbsp;한&amp;nbsp;달&amp;nbsp;만에&amp;nbsp;&quot;DB가&amp;nbsp;유일한&amp;nbsp;출처&quot;로&amp;nbsp;바뀐&amp;nbsp;건,&amp;nbsp;통합&amp;nbsp;테스트로&amp;nbsp;땜질하려던&amp;nbsp;처음&amp;nbsp;생각보다&amp;nbsp;훨씬&amp;nbsp;마음이&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;h2 data-ke-size=&quot;size26&quot;&gt;4. 테스트 격리 버그 &amp;mdash; &quot;Duplicate entry&quot; 에러를 만난 날&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옷장 기능 테스트를 추가하다가 갑자기 &lt;code&gt;Duplicate entry&lt;/code&gt; 에러를 만났다. 분명 각 테스트가 끝나면 &lt;code&gt;DatabaseCleanUp&lt;/code&gt;이 모든 테이블을 TRUNCATE하는데, 왜 이전 테스트의 데이터가 남아있는 걸까 한참 헤맸다.&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;code&gt;user_wardrobe_item&lt;/code&gt;이라는 조인 테이블이었다. &lt;code&gt;DatabaseCleanUp&lt;/code&gt;은 JPA 메타모델에서 &lt;code&gt;@Entity&lt;/code&gt;가 붙은 클래스들을 찾아서 그 테이블들을 비운다. 그런데 &lt;code&gt;@ManyToMany&lt;/code&gt; + &lt;code&gt;@JoinTable&lt;/code&gt;로만 존재하는 조인 테이블은 &lt;code&gt;@Entity&lt;/code&gt;가 아니기 때문에 메타모델에 안 잡힌다. 그래서 테스트 A가 만든 &lt;code&gt;(wardrobe_id=1, item_id=1)&lt;/code&gt; 행이 테스트 B까지 살아남아서 PK 충돌이 난 것이었다.&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;해결은 reflection으로 각 엔티티 필드의 &lt;code&gt;@JoinTable&lt;/code&gt; 어노테이션을 추가로 훑어서 테이블 이름을 수집하는 것. 한 줄짜리 원인을 찾는 데 꽤 오래 걸렸지만, 덕분에 앞으로 새로운 &lt;code&gt;@ManyToMany&lt;/code&gt;가 추가돼도 자동으로 정리 대상에 들어가게 됐다.&lt;/p&gt;
&lt;pre class=&quot;elm&quot;&gt;&lt;code&gt;for (Field field : type.getDeclaredFields()) {
    JoinTable joinTable = field.getAnnotation(JoinTable.class);
    if (joinTable != null &amp;amp;&amp;amp; !joinTable.name().isBlank()) {
        names.add(joinTable.name());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 같은 &quot;재호출 가능한 API&quot;인데 정책이 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옷장 설정(&lt;code&gt;setupWardrobe&lt;/code&gt;)과 체감 온도 초기화(&lt;code&gt;initTempProfile&lt;/code&gt;)는 둘 다 &quot;재호출 가능한 멱등 API&quot;라는 공통점이 있는데, 재호출했을 때 동작은 정반대다.&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;체감 온도: 다시 호출해도 이미 값이 있으면 무시한다. 온보딩에서 받은 초기값은 어디까지나 &quot;출발점&quot;이고, 그 이후로는 사용자의 착용 피드백으로만 점진적으로 보정되어야 한다. 사용자가 설문을 다시 본다고 그동안 쌓인 학습 데이터를 덮어써버리면 안 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;// 옷장: 있으면 교체
userWardrobeRepository.findByUser(user)
    .ifPresentOrElse(
        wardrobe -&amp;gt; wardrobe.updateItems(items),
        () -&amp;gt; userWardrobeRepository.save(UserWardrobe.create(user, items))
    );

// 체감 온도: 있으면 무시
userTempProfileRepository.findByUser(user)
    .ifPresentOrElse(
        profile -&amp;gt; {},
        () -&amp;gt; userTempProfileRepository.save(UserTempProfile.of(user, tempOffset, 0))
    );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드만 보면 &lt;code&gt;ifPresentOrElse&lt;/code&gt;의 두 분기를 어떻게 채우느냐 차이밖에 없다. 근데 이 한 줄 차이가 &quot;이 데이터는 사용자가 언제든 바꿀 수 있는 현재 상태인가, 아니면 시간에 따라 누적되는 학습 데이터인가&quot;라는 도메인 판단을 그대로 코드로 옮긴 거라는 걸 나중에 깨달았다. 비슷해 보이는 upsert 로직도 도메인 의미를 먼저 묻고 분기를 정해야 한다는 걸 배웠다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. PATCH를 만들었는데 새 로직이 하나도 없다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 작업 중인 마이페이지 옷장 수정 API(OMO-63) 얘기다. &quot;옷장 수정&quot;이라고 하면 뭔가 새 검증 로직이 필요할 것 같은데, 막상 들여다보니 온보딩 때 만든 &lt;code&gt;setupWardrobe()&lt;/code&gt;의 &quot;있으면 교체, 없으면 생성&quot; 로직과 요구사항이 100% 동일했다. 그래서 그냥 위임만 했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public void updateWardrobe(Long userId, List&amp;lt;String&amp;gt; itemKeys) {
    setupWardrobe(userId, itemKeys);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;이래도 되나, PATCH인데 너무 성의 없는 거 아닌가&quot; 싶었는데, 다시 생각해보면 &quot;옷장을 설정한다&quot;와 &quot;옷장을 수정한다&quot;가 도메인적으로 똑같은 동작(현재 보유 의류 전체 교체)이라면, 억지로 다른 로직을 만드는 게 오히려 두 엔드포인트가 미묘하게 어긋날 위험만 키운다. 다만 이 선택에는 빚이 하나 남는다 &amp;mdash; Flutter는 아직 이 PATCH를 안 쓰고 기존 POST를 그대로 재사용하고 있어서, 지금 PATCH 엔드포인트는 API 문서상으로만 존재하는 죽은 경로다. REST 시맨틱을 깔끔하게 정리할지, 그냥 이대로 둘지는 다음에 한 번 더 얘기해봐야 할 것 같다.&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;GET 응답에서 옷장이 없을 때 빈 배열이 아니라 404를 내려주기로 한 것도 같은 맥락이다. 옷장 row 자체가 없다는 건 &quot;온보딩을 아직 안 끝냈다&quot;는 뜻인데, 이걸 빈 배열로 내려주면 &quot;옷장은 있는데 아무것도 안 골랐다&quot;와 구분이 안 된다. Flutter 쪽에서는 이 404를 그냥 &quot;빈 선택 상태로 시작&quot;으로 처리하면 되니, 에러 종류를 세분화하기보다 케이스를 단순하게 합쳤다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&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;Spring Boot 4.x로 올라오면서 &lt;code&gt;@RestClientTest&lt;/code&gt;, &lt;code&gt;@MockBean&lt;/code&gt;이 사라진 영향을 꽤 많이 받았다. &lt;code&gt;RestClient.Builder&lt;/code&gt;를 생성자로 주입받게 클라이언트를 설계해야 &lt;code&gt;MockRestServiceServer&lt;/code&gt;로 테스트할 수 있다는 걸 이번에 처음 알았는데, &quot;테스트 가능한 구조&quot;가 결국 설계에도 영향을 준다는 걸 체감했다.&lt;/li&gt;
&lt;li&gt;같은 맥락에서 &lt;code&gt;RestClient.Builder&lt;/code&gt; 빈이 자동 등록되지 않아서 서버가 아예 안 뜨는 사고도 있었다. 로컬에서 빌드는 되는데 기동이 안 되는 류의 문제는 항상 설정 쪽을 의심하게 된다.&lt;/li&gt;
&lt;li&gt;다음 달엔 Flutter &amp;harr; 서버 카탈로그 동기화 문제, JWT 토큰 무효화(로그아웃 시 블랙리스트), Apple JWKS 캐싱처럼 &quot;지금은 괜찮지만 트래픽이 늘면 문제될&quot; 항목들을 백로그로 옮겨두고 우선순위를 다시 매겨야겠다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>OMO</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/22</guid>
      <comments>https://najang.tistory.com/entry/6%EC%9B%94-%ED%9A%8C%EA%B3%A0-%E2%80%94-%EC%9D%B8%EC%A6%9D%EB%B6%80%ED%84%B0-%EC%98%B7%EC%9E%A5-%EB%8F%84%EB%A9%94%EC%9D%B8%EA%B9%8C%EC%A7%80#entry22comment</comments>
      <pubDate>Wed, 10 Jun 2026 21:33:12 +0900</pubDate>
    </item>
    <item>
      <title>Docker Volume &amp;mdash; 개념 + MySQL로 검증하기</title>
      <link>https://najang.tistory.com/entry/Docker-Volume-%E2%80%94-%EA%B0%9C%EB%85%90-MySQL%EB%A1%9C-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
      <description>&lt;div&gt;
&lt;div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨테이너가 가진 근본적인 문제&lt;/h3&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 프로그램을 업데이트할 때 기존 컨테이너를 수정하지 않는다. &lt;b&gt;새로운 컨테이너를 만들어 통째로 교체&lt;/b&gt;하는 방식으로 동작한다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 여기서 발생한다. 컨테이너를 교체하면 기존 컨테이너 내부에 저장되어 있던 데이터도 함께 삭제된다. MySQL 컨테이너였다면 DB에 쌓아둔 데이터까지 전부 날아간다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 삭제되면 안 되는 상황이라면 &lt;b&gt;볼륨(Volume)&lt;/b&gt; 을 활용해야 한다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Docker Volume이란?&lt;/h3&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;볼륨(Volume)&lt;/b&gt; 은 컨테이너의 데이터를 영속적으로 보존하기 위한 방법이다. 컨테이너 자체의 저장 공간을 쓰는 대신, &lt;b&gt;호스트 컴퓨터의 저장 공간을 컨테이너와 공유&lt;/b&gt;하는 형태로 동작한다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너가 삭제되더라도 데이터는 호스트에 그대로 남기 때문에, 새 컨테이너를 띄울 때 같은 경로를 연결하면 데이터가 이어진다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1504&quot; data-origin-height=&quot;854&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uSQOl/dJMcabYvXCa/nY4H4DDPpHBQiQdYU1WfH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uSQOl/dJMcabYvXCa/nY4H4DDPpHBQiQdYU1WfH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uSQOl/dJMcabYvXCa/nY4H4DDPpHBQiQdYU1WfH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuSQOl%2FdJMcabYvXCa%2FnY4H4DDPpHBQiQdYU1WfH1%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;650&quot; height=&quot;369&quot; data-origin-width=&quot;1504&quot; data-origin-height=&quot;854&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;볼륨을 사용하는 명령어&lt;/h3&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# docker run -v [호스트 디렉토리 절대경로]:[컨테이너 디렉토리 절대경로] 이미지명[:태그명]
$ docker run -v /host/data:/container/data nginx&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 방식은 호스트 경로의 존재 여부에 따라 달라진다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&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; &amp;mdash; 호스트 디렉토리가 컨테이너 디렉토리를 덮어씌운다.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;575&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Up2RY/dJMcafGB60W/mBYREs9gekVHPhQATa00y0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Up2RY/dJMcafGB60W/mBYREs9gekVHPhQATa00y0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Up2RY/dJMcafGB60W/mBYREs9gekVHPhQATa00y0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUp2RY%2FdJMcafGB60W%2FmBYREs9gekVHPhQATa00y0%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;650&quot; height=&quot;365&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;575&quot;/&gt;&lt;/span&gt;&lt;/figure&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; &amp;mdash; 호스트에 디렉토리를 새로 만들고, 컨테이너 내부의 파일들을 호스트로 복사해온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;578&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caKo3j/dJMcahj9lfD/o3K5zjNZGvGFQAATxY82M0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caKo3j/dJMcahj9lfD/o3K5zjNZGvGFQAATxY82M0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caKo3j/dJMcahj9lfD/o3K5zjNZGvGFQAATxY82M0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaKo3j%2FdJMcahj9lfD%2Fo3K5zjNZGvGFQAATxY82M0%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;650&quot; height=&quot;367&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;578&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;[실습] MySQL 컨테이너 띄워보기&lt;/h3&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1단계 &amp;mdash; 볼륨 없이 띄우기 (데이터가 사라지는 문제 직접 확인)&lt;/h4&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 볼륨 없이 MySQL을 실행해서 컨테이너 교체 시 데이터가 어떻게 되는지 눈으로 확인해보자.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://hub.docker.com/_/mysql&quot;&gt;mysql - Official Image | Docker Hub&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker run -e MYSQL_ROOT_PASSWORD=password123 -p 3306:3306 -d mysql&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-e 옵션은 컨테이너의 환경 변수를 설정한다. MySQL 공식 이미지는 MYSQL_ROOT_PASSWORD 환경 변수가 반드시 설정되어 있어야 정상 실행된다. 아래 명령어로 컨테이너 내부에서 직접 확인할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker exec -it [MySQL 컨테이너 ID] bash
$ echo $MYSQL_ROOT_PASSWORD  # 특정 환경 변수 값 출력
$ export                      # 설정된 모든 환경 변수 출력&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker ps
$ docker logs [컨테이너 ID]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;b&gt;MySQL에 접속해서 데이터베이스 만들기&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker exec -it [MySQL 컨테이너 ID] bash
$ mysql -u root -p

mysql&amp;gt; show databases;
mysql&amp;gt; create database mydb;
mysql&amp;gt; show databases;     # mydb가 생성된 걸 확인&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;b&gt;컨테이너 삭제 후 재생성 &amp;mdash; 데이터 확인&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# 컨테이너 종료 및 삭제
$ docker stop [MySQL 컨테이너 ID]
$ docker rm [MySQL 컨테이너 ID]

# 컨테이너 재생성
$ docker run -e MYSQL_ROOT_PASSWORD=password123 -p 3306:3306 -d mysql
$ docker exec -it [MySQL 컨테이너 ID] bash
$ mysql -u root -p

mysql&amp;gt; show databases;  # mydb가 사라진 걸 확인할 수 있다&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까 만들었던 mydb가 사라진다. 볼륨 없이 운영하면 컨테이너를 교체할 때마다 데이터가 초기화된다는 걸 직접 확인한 것이다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2단계 &amp;mdash; 볼륨을 적용해서 데이터 유지하기&lt;/h4&gt;
&lt;/div&gt;
&lt;/div&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ cd /Users/jaeseong/Documents/Develop
$ mkdir docker-mysql  # MySQL 데이터를 저장할 폴더 생성&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ mysql_data 디렉토리는 미리 만들어두면 안 된다. 경로가 이미 존재하면 빈 호스트 디렉토리가 컨테이너의 /var/lib/mysql을 덮어씌워버려, MySQL이 정상적으로 초기화되지 않는다. 경로가 없어야 컨테이너 내부 파일이 호스트로 복사된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;crystal&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker run \
  -e MYSQL_ROOT_PASSWORD=password123 \
  -p 3306:3306 \
  -v /Users/jaeseong/Documents/Develop/docker-mysql/mysql_data:/var/lib/mysql \
  -d mysql&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows 환경이라면 경로 형식이 다를 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778682637916&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$ docker run -e MYSQL_ROOT_PASSWORD=password123 -p 3306:3306 -v C:/Users/jaeseong/js-mysql:/var/lib/mysql -d mysql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;pwd 명령어로 현재 절대 경로를 확인한 뒤 입력하자.&lt;/span&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MySQL에 접속해서 데이터베이스 만들기&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker exec -it [MySQL 컨테이너 ID] bash
$ mysql -u root -p

mysql&amp;gt; show databases;
mysql&amp;gt; create database mydb;
mysql&amp;gt; show databases;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;b&gt;컨테이너 삭제 후 재생성 &amp;mdash; 데이터 유지 확인&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;crystal&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# 컨테이너 종료 및 삭제
$ docker stop [MySQL 컨테이너 ID]
$ docker rm [MySQL 컨테이너 ID]

# 동일한 볼륨 경로로 재생성
$ docker run \
  -e MYSQL_ROOT_PASSWORD=password123 \
  -p 3306:3306 \
  -v /Users/jaeseong/Documents/Develop/docker-mysql/mysql_data:/var/lib/mysql \
  -d mysql

$ docker exec -it [MySQL 컨테이너 ID] bash
$ mysql -u root -p

mysql&amp;gt; show databases;  # mydb가 그대로 존재하는 걸 확인할 수 있다&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kNe3m/dJMcabYvXFg/OFJw6ThS7DkJ1caGnuM5lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kNe3m/dJMcabYvXFg/OFJw6ThS7DkJ1caGnuM5lk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kNe3m/dJMcabYvXFg/OFJw6ThS7DkJ1caGnuM5lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkNe3m%2FdJMcabYvXFg%2FOFJw6ThS7DkJ1caGnuM5lk%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;650&quot; height=&quot;353&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;주의 &amp;mdash; 볼륨에 비밀번호 정보가 저장된다&lt;/h4&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;볼륨이 연결된 상태에서 MYSQL_ROOT_PASSWORD를 바꿔 컨테이너를 새로 띄워도 비밀번호가 바뀌지 않는다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;crystal&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker stop [MySQL 컨테이너 ID]
$ docker rm [MySQL 컨테이너 ID]

# 비밀번호를 변경해서 재생성
$ docker run -e MYSQL_ROOT_PASSWORD=pwd1234 -p 3306:3306 \
  -v /Users/jaeseong/Documents/Develop/docker-mysql/mysql_data:/var/lib/mysql \
  -d mysql

$ docker exec -it [MySQL 컨테이너 ID] bash
$ mysql -u root -p  # 새 비밀번호로 접속이 안 된다&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;볼륨 경로에 이미 기존 비밀번호 정보가 저장되어 있기 때문이다. 비밀번호를 바꾸고 싶다면 볼륨 디렉토리를 삭제하거나 비운 뒤 새로 시작해야 한다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Docker</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/21</guid>
      <comments>https://najang.tistory.com/entry/Docker-Volume-%E2%80%94-%EA%B0%9C%EB%85%90-MySQL%EB%A1%9C-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0#entry21comment</comments>
      <pubDate>Wed, 13 May 2026 23:36:13 +0900</pubDate>
    </item>
    <item>
      <title>컨테이너 내부 접속 + Redis 실습</title>
      <link>https://najang.tistory.com/entry/%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EB%82%B4%EB%B6%80-%EC%A0%91%EC%86%8D-Redis-%EC%8B%A4%EC%8A%B5</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨테이너 내부에 접속하기 &amp;mdash; docker exec -it&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 컨테이너를 미니 컴퓨터라고 표현했다. 즉, 각 컨테이너는 자기만의 OS, 저장 공간, 프로그램 환경을 가지고 있다. 그렇다면 그 내부에 직접 들어가서 파일을 확인하거나 명령어를 실행할 수도 있어야 한다. 이때 사용하는 명령어가 exec -it다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker exec -it 컨테이너명[또는 컨테이너 ID] bash
$ docker run -d nginx
$ docker exec -it [컨테이너 ID] bash

$ ls              # 컨테이너 내부 파일 조회
$ cd /etc/nginx
$ cat nginx.conf&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컨테이너 내부에서 빠져나오려면 exit 또는 Ctrl + D를 입력하면 된다.&lt;/li&gt;
&lt;li&gt;bash는 쉘(Shell)의 일종으로, 명령어를 입력할 수 있는 인터페이스다.&lt;/li&gt;
&lt;li&gt;-it 옵션을 붙여야 명령어를 반복해서 입력할 수 있다. 이 옵션 없이 실행하면 명령어 한 번만 실행하고 바로 종료된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1494&quot; data-origin-height=&quot;842&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3gkWF/dJMcafGsN8N/8xaeQqBKlWOYi4j5ZsKDd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3gkWF/dJMcafGsN8N/8xaeQqBKlWOYi4j5ZsKDd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3gkWF/dJMcafGsN8N/8xaeQqBKlWOYi4j5ZsKDd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3gkWF%2FdJMcafGsN8N%2F8xaeQqBKlWOYi4j5ZsKDd0%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;650&quot; height=&quot;366&quot; data-origin-width=&quot;1494&quot; data-origin-height=&quot;842&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;[실습] Docker로 Redis 실행시켜보기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 배운 명령어들을 한데 모아 Redis를 직접 실행해보자.&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;1. Redis 컨테이너 실행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://hub.docker.com/_/redis&quot;&gt;redis - Official Image | Docker Hub&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker run -d -p 6379:6379 redis&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에 Redis 이미지가 없으면 Docker Hub에서 자동으로 내려받는다.&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;&lt;b&gt;2. 이미지 확인&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;mel&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker image ls  # ls : list의 약자&lt;/code&gt;&lt;/pre&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;b&gt;3. 컨테이너 실행 상태 확인&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker ps&lt;/code&gt;&lt;/pre&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;b&gt;4. 로그로 에러 여부 확인&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker logs [컨테이너 ID 또는 컨테이너명]&lt;/code&gt;&lt;/pre&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;b&gt;5. Redis 컨테이너 내부 접속&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker exec -it [컨테이너 ID 또는 컨테이너명] bash&lt;/code&gt;&lt;/pre&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;b&gt;6. Redis 직접 사용해보기&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ redis-cli

127.0.0.1:6379&amp;gt; set 1 jscode  # key: 1, value: jscode 저장
127.0.0.1:6379&amp;gt; get 1          # key: 1에 해당하는 value 조회&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsBdQy/dJMcajaYwy0/FFBVUozGaybxVqaPxOw221/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsBdQy/dJMcajaYwy0/FFBVUozGaybxVqaPxOw221/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsBdQy/dJMcajaYwy0/FFBVUozGaybxVqaPxOw221/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsBdQy%2FdJMcajaYwy0%2FFFBVUozGaybxVqaPxOw221%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;1024&quot; height=&quot;352&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;352&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;562&quot; data-origin-height=&quot;198&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HFxj7/dJMcadaJiZi/8XkPCsfvndczeJV8dX96mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HFxj7/dJMcadaJiZi/8XkPCsfvndczeJV8dX96mk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HFxj7/dJMcadaJiZi/8XkPCsfvndczeJV8dX96mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHFxj7%2FdJMcadaJiZi%2F8XkPCsfvndczeJV8dX96mk%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;300&quot; height=&quot;106&quot; data-origin-width=&quot;562&quot; data-origin-height=&quot;198&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;지금까지 배운 명령어를 한눈에 정리하면 이렇다.&lt;/p&gt;
&lt;div&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;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;명령어&lt;/td&gt;
&lt;td&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker create&lt;/td&gt;
&lt;td&gt;이미지로 컨테이너 생성 (실행 X)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker start&lt;/td&gt;
&lt;td&gt;정지된 컨테이너 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker run&lt;/td&gt;
&lt;td&gt;컨테이너 생성 + 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker ps&lt;/td&gt;
&lt;td&gt;실행 중인 컨테이너 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker ps -a&lt;/td&gt;
&lt;td&gt;전체 컨테이너 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker stop / kill&lt;/td&gt;
&lt;td&gt;컨테이너 중지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker rm&lt;/td&gt;
&lt;td&gt;컨테이너 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker logs&lt;/td&gt;
&lt;td&gt;컨테이너 로그 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker exec -it&lt;/td&gt;
&lt;td&gt;컨테이너 내부 접속&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;&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;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;본 블로그는 인프런의&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #4663eb; text-align: start;&quot; href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot;&gt;비전공자도 이해할 수 있는&amp;nbsp;&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;a style=&quot;color: #4663eb;&quot; href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot;&gt;Docker 입문/실전&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;강의를 참고하여 작성했습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Docker</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/20</guid>
      <comments>https://najang.tistory.com/entry/%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EB%82%B4%EB%B6%80-%EC%A0%91%EC%86%8D-Redis-%EC%8B%A4%EC%8A%B5#entry20comment</comments>
      <pubDate>Thu, 30 Apr 2026 22:01:28 +0900</pubDate>
    </item>
    <item>
      <title>컨테이너 조회 &amp;middot; 중지 &amp;middot; 삭제 &amp;middot; 로그 확인</title>
      <link>https://najang.tistory.com/entry/%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%A1%B0%ED%9A%8C-%C2%B7-%EC%A4%91%EC%A7%80-%C2%B7-%EC%82%AD%EC%A0%9C-%C2%B7-%EB%A1%9C%EA%B7%B8-%ED%99%95%EC%9D%B8</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨테이너 조회 &amp;mdash; docker ps&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행 중인 컨테이너만 조회&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker ps&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ps는 process status의 약자다.&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;&lt;b&gt;모든 컨테이너 조회 (실행 중 + 정지된 컨테이너)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker ps -a&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-a는 all의 약자다.&lt;/p&gt;
&lt;/blockquote&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;컨테이너 중지 &amp;mdash; docker stop / docker kill&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;sql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker stop 컨테이너명[또는 컨테이너 ID]
$ docker kill 컨테이너명[또는 컨테이너 ID]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;stop과 kill은 둘 다 컨테이너를 멈추지만 방식이 다르다. stop은 컴퓨터의 정상 종료 버튼처럼 진행 중인 작업을 마무리하고 graceful하게 종료하고, kill은 전원 버튼을 강제로 눌러 즉시 종료하는 것과 같다. 일반적으로는 stop을 사용한다.&lt;/p&gt;
&lt;/blockquote&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;컨테이너 삭제 &amp;mdash; docker rm&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정지된 특정 컨테이너 삭제&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker rm 컨테이너명[또는 컨테이너 ID]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 중인 컨테이너는 먼저 중지한 뒤에만 삭제할 수 있다.&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;&lt;b&gt;실행 중인 특정 컨테이너 강제 삭제&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker rm -f 컨테이너명[또는 컨테이너 ID]&lt;/code&gt;&lt;/pre&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;b&gt;정지된 모든 컨테이너 삭제&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker rm $(docker ps -qa)&lt;/code&gt;&lt;/pre&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;b&gt;실행 중인 모든 컨테이너 강제 삭제&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker rm -f $(docker ps -qa)&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;h3 data-ke-size=&quot;size23&quot;&gt;컨테이너 로그 조회 &amp;mdash; docker logs&lt;/h3&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;b&gt;특정 컨테이너의 전체 로그 조회&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker logs [컨테이너 ID 또는 컨테이너명]
$ docker run -d nginx
$ docker logs [컨테이너 ID]&lt;/code&gt;&lt;/pre&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;b&gt;최근 로그 N줄만 조회&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;inform7&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker logs --tail [줄 수] [컨테이너 ID 또는 컨테이너명]
$ docker logs --tail 10 [컨테이너 ID 또는 컨테이너명]&lt;/code&gt;&lt;/pre&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;b&gt;기존 로그 + 실시간 로그 함께 보기&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker logs -f [컨테이너 ID 또는 컨테이너명]
$ docker run -d -p 80:80 nginx
$ docker logs -f [컨테이너 ID]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-f는 follow의 약자다. 로그가 쌓이는 걸 실시간으로 스트리밍해서 보여준다.&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;&lt;b&gt;기존 로그는 제외하고 실시간 로그만 보기&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ada&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker logs --tail 0 -f [컨테이너 ID 또는 컨테이너명]&lt;/code&gt;&lt;/pre&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;&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;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;본 블로그는 인프런의&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #4663eb; text-align: start;&quot; href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot;&gt;비전공자도 이해할 수 있는&amp;nbsp;&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;a style=&quot;color: #4663eb;&quot; href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot;&gt;Docker 입문/실전&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;강의를 참고하여 작성했습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Docker</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/19</guid>
      <comments>https://najang.tistory.com/entry/%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%A1%B0%ED%9A%8C-%C2%B7-%EC%A4%91%EC%A7%80-%C2%B7-%EC%82%AD%EC%A0%9C-%C2%B7-%EB%A1%9C%EA%B7%B8-%ED%99%95%EC%9D%B8#entry19comment</comments>
      <pubDate>Thu, 30 Apr 2026 21:56:08 +0900</pubDate>
    </item>
    <item>
      <title>컨테이너 생성과 실행 &amp;mdash; create / start / run</title>
      <link>https://najang.tistory.com/entry/%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%83%9D%EC%84%B1%EA%B3%BC-%EC%8B%A4%ED%96%89-%E2%80%94-create-start-run</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨테이너 생성 &amp;mdash; docker create&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지를 바탕으로 컨테이너를 &lt;b&gt;생성만&lt;/b&gt; 한다. 실행까지 이어지지는 않는다. 실제로 생성만 하고 멈추는 경우는 거의 없어서, 이 명령어 단독으로 쓰이는 일은 드물다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# docker create 이미지명[:태그명]
$ docker create nginx

$ docker ps -a  # 모든 컨테이너 조회&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  로컬에 해당 이미지가 없으면 Docker Hub에서 자동으로 pull한 뒤 컨테이너를 생성한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/teUVl/dJMcadBMsbd/Ve04zP7X2NKsAwVOBilC2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/teUVl/dJMcadBMsbd/Ve04zP7X2NKsAwVOBilC2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/teUVl/dJMcadBMsbd/Ve04zP7X2NKsAwVOBilC2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FteUVl%2FdJMcadBMsbd%2FVe04zP7X2NKsAwVOBilC2k%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;860&quot; height=&quot;66&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;컨테이너 실행 &amp;mdash; docker start&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정지되어 있는 컨테이너를 &lt;b&gt;다시 실행&lt;/b&gt;할 때 사용한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker start 컨테이너명[또는 컨테이너 ID]
$ docker start 컨테이너명[또는 컨테이너 ID]

$ docker ps  # 실행 중인 컨테이너 조회&lt;/code&gt;&lt;/pre&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;실습 후 정리가 필요하다면 아래 순서로 컨테이너를 중단&amp;middot;삭제한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;sql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker ps                                          # 실행 중인 컨테이너 확인
$ docker stop {Container ID}                         # 컨테이너 중단
$ docker rm {Container ID}                           # 컨테이너 삭제
$ docker image rm nginx                              # 이미지 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1808&quot; data-origin-height=&quot;206&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HYo0W/dJMcad2SH3U/edBEvPAkwDwYvvSOKvDt11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HYo0W/dJMcad2SH3U/edBEvPAkwDwYvvSOKvDt11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HYo0W/dJMcad2SH3U/edBEvPAkwDwYvvSOKvDt11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHYo0W%2FdJMcad2SH3U%2FedBEvPAkwDwYvvSOKvDt11%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;860&quot; height=&quot;98&quot; data-origin-width=&quot;1808&quot; data-origin-height=&quot;206&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;컨테이너 생성 + 실행 &amp;mdash; docker run&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지를 바탕으로 컨테이너를 &lt;b&gt;생성하고 바로 실행&lt;/b&gt;까지 한다. 처음 컨테이너를 띄울 때 가장 자주 사용하는 명령어다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker run 이미지명[:태그명]
$ docker run nginx  # 포그라운드에서 실행 &amp;mdash; Ctrl + C로 종료&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  로컬에 이미지가 없으면 Docker Hub에서 자동으로 pull한 뒤 실행한다. 새롭게 갱신된 이미지를 명시적으로 받고 싶다면 docker pull 명령어를 따로 실행해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;백그라운드로 실행하기 &amp;mdash; -d&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포그라운드(Foreground) 란 실행한 프로그램의 출력이 화면에 그대로 나타나는 상태다. 화면을 점유하기 때문에 다른 명령어를 입력할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;백그라운드(Background)&lt;/b&gt; 란 프로그램이 내부적으로 실행되는 상태다. 화면에 출력이 나타나지 않아 다른 명령어를 자유롭게 입력할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker run -d 이미지명[:태그명]
$ docker run -d nginx&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;h4 data-ke-size=&quot;size20&quot;&gt;컨테이너에 이름 붙이기 &amp;mdash; --name&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 ID 대신 직접 지정한 이름으로 관리할 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker run -d --name [컨테이너 이름] 이미지명[:태그명]
$ docker run -d --name my-web-server nginx&lt;/code&gt;&lt;/pre&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;h4 data-ke-size=&quot;size20&quot;&gt;호스트 포트와 컨테이너 포트 연결하기 &amp;mdash; -p&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너는 독립된 네트워크를 가지기 때문에, 외부에서 접근하려면 호스트의 포트와 컨테이너의 포트를 &lt;b&gt;명시적으로 연결&lt;/b&gt;해줘야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# docker run -d -p [호스트 포트]:[컨테이너 포트] 이미지명[:태그명]
$ docker run -d -p 4000:80 nginx&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1634&quot; data-origin-height=&quot;876&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdHns3/dJMb99TK4zT/QMXaj9Q4gRjL04yG6ObK21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdHns3/dJMb99TK4zT/QMXaj9Q4gRjL04yG6ObK21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdHns3/dJMb99TK4zT/QMXaj9Q4gRjL04yG6ObK21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdHns3%2FdJMb99TK4zT%2FQMXaj9Q4gRjL04yG6ObK21%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;650&quot; height=&quot;348&quot; data-origin-width=&quot;1634&quot; data-origin-height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker run -p 4000:80 nginx는 호스트의 4000번 포트를 컨테이너의 80번 포트로 연결한다. 브라우저에서 http://localhost:4000으로 접속하면 컨테이너 내부의 Nginx 서버에 닿는다.&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;&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;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;본 블로그는 인프런의 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;비전공자도 이해할 수 있는&amp;nbsp;&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;a href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Docker 입문/실전&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;강의를 참고하여 작성했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>Docker</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/18</guid>
      <comments>https://najang.tistory.com/entry/%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%83%9D%EC%84%B1%EA%B3%BC-%EC%8B%A4%ED%96%89-%E2%80%94-create-start-run#entry18comment</comments>
      <pubDate>Thu, 30 Apr 2026 21:52:56 +0900</pubDate>
    </item>
    <item>
      <title>Docker 전체 흐름 느껴보기 (Nginx 설치 및 실행)</title>
      <link>https://najang.tistory.com/entry/Docker-%EC%A0%84%EC%B2%B4-%ED%9D%90%EB%A6%84-%EB%8A%90%EA%BB%B4%EB%B3%B4%EA%B8%B0-Nginx-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%8B%A4%ED%96%89</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Docker는 어떻게 조작할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 터미널에서 &lt;b&gt;명령어(CLI)&lt;/b&gt; 를 입력하는 방식으로 조작한다.&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;Nginx가 처음이라면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Nginx는 다양한 기능을 가진 서버 중 하나다. 대표적인 기능은 아래와 같다.&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; &amp;mdash; HTML 웹 페이지를 브라우저에 렌더링하는 역할을 한다.&lt;/li&gt;
&lt;li&gt;&lt;s&gt;로드 밸런싱&lt;/s&gt; (지금은 몰라도 괜찮다.)&lt;/li&gt;
&lt;li&gt;&lt;s&gt;리버스 프록시&lt;/s&gt; (지금은 몰라도 괜찮다.)&lt;/li&gt;
&lt;/ul&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;무작정 따라하며 Docker 사용해보기&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  아래 명령어가 지금 당장 이해되지 않아도 괜찮다. 다음 섹션에서 하나씩 차근차근 살펴볼 예정이다.&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;&lt;b&gt;1. Nginx 이미지 다운로드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Hub에 공식 Nginx 이미지가 등록되어 있다. 아래 명령어로 로컬에 내려받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://hub.docker.com/_/nginx&quot;&gt;nginx - Official Image | Docker Hub&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;$ docker pull nginx&lt;/code&gt;&lt;/pre&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;b&gt;2. 다운로드된 이미지 확인&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지가 정상적으로 내려받아졌는지 목록으로 확인한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;$ docker image ls&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ls는 list의 약자로, 목록을 출력하는 명령어다.&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;&lt;b&gt;3. 이미지를 컨테이너에 올려 Nginx 서버 실행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지를 실행해 컨테이너를 생성하고, Nginx 서버를 구동한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker run --name webserver -d -p 80:80 nginx&lt;/code&gt;&lt;/pre&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;b&gt;4. Nginx 서버가 잘 실행되는지 확인&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;463&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eF0qy7/dJMcagSSMn8/HNhxhE8ECL6xzYBvXw9ec1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eF0qy7/dJMcagSSMn8/HNhxhE8ECL6xzYBvXw9ec1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eF0qy7/dJMcagSSMn8/HNhxhE8ECL6xzYBvXw9ec1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeF0qy7%2FdJMcagSSMn8%2FHNhxhE8ECL6xzYBvXw9ec1%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;597&quot; height=&quot;270&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;463&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 http://localhost:80 에 접속했을 때 Nginx 기본 페이지가 보이면 정상이다.&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;5. 실행 중인 컨테이너 상태 확인&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 실행 중인 모든 컨테이너의 상태를 한눈에 확인한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker ps&lt;/code&gt;&lt;/pre&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;b&gt;6. 특정 컨테이너 정지&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 중인 컨테이너를 중단하려면 컨테이너 이름을 지정해 정지한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;$ docker stop webserver&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;h3 data-ke-size=&quot;size23&quot;&gt;그림으로 이해하기&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1494&quot; data-origin-height=&quot;842&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mNhld/dJMcaaE9rsO/THn0KbXxsk4B9VwPeLn5o1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mNhld/dJMcaaE9rsO/THn0KbXxsk4B9VwPeLn5o1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mNhld/dJMcaaE9rsO/THn0KbXxsk4B9VwPeLn5o1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmNhld%2FdJMcaaE9rsO%2FTHn0KbXxsk4B9VwPeLn5o1%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;647&quot; height=&quot;365&quot; data-origin-width=&quot;1494&quot; data-origin-height=&quot;842&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;&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;style6&quot; /&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;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot;&gt;비전공자도 이해할 수 있는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot;&gt;Docker 입문/실전&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;강의를 참고하여 작성했습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Docker</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/17</guid>
      <comments>https://najang.tistory.com/entry/Docker-%EC%A0%84%EC%B2%B4-%ED%9D%90%EB%A6%84-%EB%8A%90%EA%BB%B4%EB%B3%B4%EA%B8%B0-Nginx-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%8B%A4%ED%96%89#entry17comment</comments>
      <pubDate>Wed, 29 Apr 2026 22:02:26 +0900</pubDate>
    </item>
    <item>
      <title>Docker 입문: 컨테이너와 이미지</title>
      <link>https://najang.tistory.com/entry/Docker-%EC%9E%85%EB%AC%B8-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%99%80-%EC%9D%B4%EB%AF%B8%EC%A7%80</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;Docker란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 &lt;b&gt;컨테이너(Container)&lt;/b&gt; 기술을 기반으로, 각각의 프로그램을 분리된 환경에서 실행하고 관리할 수 있게 해주는 도구다.&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;컨테이너(Container)란?&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;696&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjpWzw/dJMcabRytpo/BTG48XUZk2xwBwzlsYZpk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjpWzw/dJMcabRytpo/BTG48XUZk2xwBwzlsYZpk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjpWzw/dJMcabRytpo/BTG48XUZk2xwBwzlsYZpk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjpWzw%2FdJMcabRytpo%2FBTG48XUZk2xwBwzlsYZpk1%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;450&quot; data-origin-width=&quot;696&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows를 사용해본 적 있다면, 하나의 컴퓨터를 여러 사용자 계정으로 나눠 쓸 수 있다는 걸 알 것이다. 각 계정은 서로 독립된 환경을 가지기 때문에, 필요한 프로그램을 계정마다 따로 설치해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너도 이와 비슷한 개념이다. 하나의 컴퓨터 안에서 여러 개의 &lt;b&gt;독립적인 미니 컴퓨터 환경&lt;/b&gt;을 만들고, 각 환경에 프로그램을 별도로 설치&amp;middot;실행할 수 있다. Docker에서는 이 미니 컴퓨터 환경 하나하나를 &lt;b&gt;컨테이너(Container)&lt;/b&gt; 라고 부른다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  컨테이너를 이해할 때는 '미니 컴퓨터'를 머릿속에 떠올리면 편하다.&lt;/p&gt;
&lt;/blockquote&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;1280&quot; data-origin-height=&quot;869&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cXxPq9/dJMcahxuH12/mVPnC2P6CqGeni38gzsM30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cXxPq9/dJMcahxuH12/mVPnC2P6CqGeni38gzsM30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cXxPq9/dJMcahxuH12/mVPnC2P6CqGeni38gzsM30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcXxPq9%2FdJMcahxuH12%2FmVPnC2P6CqGeni38gzsM30%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;407&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;869&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너를 담고 있는 실제 컴퓨터는 &lt;b&gt;호스트(Host) 컴퓨터&lt;/b&gt;라고 구분해서 부른다.&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;컨테이너의 독립성&lt;/h3&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; &amp;mdash; 각 컨테이너는 고유한 저장 공간을 갖는다. 기본적으로 컨테이너 A에서 컨테이너 B의 파일에 접근할 수 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네트워크 (IP, Port)&lt;/b&gt; &amp;mdash; 각 컨테이너는 고유한 네트워크 환경을 갖는다. 즉, 컨테이너마다 독립적인 IP 주소가 부여된다.&lt;/li&gt;
&lt;/ul&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;이미지(Image)란?&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1090&quot; data-origin-height=&quot;1082&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bc06y7/dJMb997hGva/3k6ADNCFnlkWzgfvePFFVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bc06y7/dJMb997hGva/3k6ADNCFnlkWzgfvePFFVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bc06y7/dJMb997hGva/3k6ADNCFnlkWzgfvePFFVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbc06y7%2FdJMb997hGva%2F3k6ADNCFnlkWzgfvePFFVK%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;533&quot; height=&quot;529&quot; data-origin-width=&quot;1090&quot; data-origin-height=&quot;1082&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;닌텐도 게임기를 생각해보자. 칩만 바꿔 꽂으면 전혀 다른 게임을 즐길 수 있다. Docker에서 이 &lt;b&gt;게임 칩과 같은 역할&lt;/b&gt;을 하는 것이 바로 &lt;b&gt;이미지(Image)&lt;/b&gt; 다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, Node.js 기반의 Express.js 서버를 이미지로 만들어두면, Docker로 실행하는 순간 해당 서버가 컨테이너 환경에서 바로 구동된다. 복잡한 설치 과정 없이 말이다. MySQL도 마찬가지다. MySQL 이미지를 실행하기만 하면, 별도의 설치 없이 데이터베이스를 즉시 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지는 &lt;b&gt;프로그램 실행에 필요한 모든 것&lt;/b&gt; &amp;mdash; 설치 과정, 환경 설정, 버전 정보 등 &amp;mdash; 을 패키징한 파일이다. 이미지가 있으면 어떤 환경에서든 동일하게 프로그램을 실행할 수 있다는 게 Docker의 핵심 강점이다.&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;&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;본 블로그는 인프런의 &lt;a href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;비전공자도 이해할 수 있는 &lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;a href=&quot;https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-docker-%EC%9E%85%EB%AC%B8-%EC%8B%A4%EC%A0%84?cid=334085&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Docker 입문/실전&lt;/a&gt; 강의를 참고하여 작성했습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Docker</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/16</guid>
      <comments>https://najang.tistory.com/entry/Docker-%EC%9E%85%EB%AC%B8-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%99%80-%EC%9D%B4%EB%AF%B8%EC%A7%80#entry16comment</comments>
      <pubDate>Wed, 29 Apr 2026 22:02:03 +0900</pubDate>
    </item>
    <item>
      <title>Redis와 Spring Batch로 만든 하이브리드 랭킹 아키텍처</title>
      <link>https://najang.tistory.com/entry/Redis%EC%99%80-Spring-Batch%EB%A1%9C-%EB%A7%8C%EB%93%A0-%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C-%EB%9E%AD%ED%82%B9-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size25&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;
&lt;script type=&quot;text/javascript&quot; src=&quot;https://tistory1.daumcdn.net/tistory/8522112/skin/images/jquery.toc.js&quot;&gt;&lt;/script&gt;
&lt;script type=&quot;text/javascript&quot;&gt;$(function() {    $(&quot;#toc&quot;).toc({        content: &quot;.tt_article_useless_p_margin.contents_style&quot;,        headings: &quot;h2,h3,h4&quot;,        toc_show: true,        blinkColor : &quot;#218B0E&quot;    });});&lt;/script&gt;
&lt;/span&gt;&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;span style=&quot;text-align: left;&quot;&gt;TL;DR&lt;/span&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;일별 랭킹은 Redis ZSET으로 실시간 처리하고, 주별/월별 랭킹은 Spring Batch로 집계해 Materialized View 테이블에 적재하는 하이브리드 구조를 선택했다. &lt;br /&gt;기간별 특성(실시간 vs 집계 정확성)이 달라 단일 저장소로는 두 요구사항을 동시에 만족할 수 없었기 때문이다.&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;&amp;nbsp;&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;1. 들어가며 &amp;mdash; &quot;기간별로 저장소가 달라야 하나?&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일별, 주별, 월별 랭킹을 구현해야 했습니다. 처음엔 하나의 방식으로 통일하고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 각 기간의 특성을 뜯어보니, 요구사항이 근본적으로 달랐습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기간&lt;/th&gt;
&lt;th&gt;핵심 요구사항&lt;/th&gt;
&lt;th&gt;데이터 갱신 빈도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;일별&lt;/td&gt;
&lt;td&gt;실시간성 (방금 전 좋아요가 반영돼야 한다)&lt;/td&gt;
&lt;td&gt;이벤트마다 즉시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주별&lt;/td&gt;
&lt;td&gt;집계 정확성 (7일치 누적 합산)&lt;/td&gt;
&lt;td&gt;주 1회 배치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;월별&lt;/td&gt;
&lt;td&gt;집계 정확성 (한 달치 누적 합산)&lt;/td&gt;
&lt;td&gt;월 1회 배치&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일별 랭킹은 매 이벤트(좋아요, 주문)마다 점수가 바뀌어야 합니다.&lt;/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;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;h2 data-ke-size=&quot;size26&quot;&gt;2. 설계 결정: 기간별로 다른 저장소&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;929&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yTeo4/dJMcaflZvc6/4scICIjxkkWs5Vnx8sX6kk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yTeo4/dJMcaflZvc6/4scICIjxkkWs5Vnx8sX6kk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yTeo4/dJMcaflZvc6/4scICIjxkkWs5Vnx8sX6kk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyTeo4%2FdJMcaflZvc6%2F4scICIjxkkWs5Vnx8sX6kk%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;611&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;929&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;&lt;b&gt;왜 하나로 통일하지 않았나?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis ZSET으로 전부 처리하면 주별/월별 TTL 관리가 복잡해지고, 기간별 데이터 분리가 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL 집계 쿼리로 전부 처리하면 조회마다 &lt;code&gt;GROUP BY&lt;/code&gt; + &lt;code&gt;SUM&lt;/code&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;&amp;nbsp;&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;3. Redis ZSET으로 실시간 랭킹 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일별 랭킹은 Redis의 Sorted Set(ZSET)을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ranking:all:yyyyMMdd&lt;/code&gt; 형태의 키에 &lt;code&gt;productId&lt;/code&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;동시성 문제: Lua 스크립트로 원자성 보장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요를 취소하면 점수가 감소합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 Unlike 이벤트가 Like보다 많이 들어오면 점수가 음수가 될 수 있다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZSET에 음수 스코어가 들어가면 정렬이 깨집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &lt;code&gt;ZINCRBY&lt;/code&gt; 후 결과를 확인하고 &lt;code&gt;ZADD 0&lt;/code&gt;을 호출하면 두 명령 사이에 다른 명령이 끼어들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 막기 위해 Lua 스크립트로 두 연산을 원자적으로 묶었습니다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;private static final RedisScript&amp;lt;Void&amp;gt; ZINCRBY_WITH_FLOOR = RedisScript.of(
    &quot;local score = redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2]) &quot; +
    &quot;if tonumber(score) &amp;lt; 0 then &quot; +
    &quot;  redis.call('ZADD', KEYS[1], 0, ARGV[2]) &quot; +
    &quot;end&quot;,
    Void.class
);&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는 단일 스레드로 명령을 처리하므로, Lua 스크립트 내 연산은 중간에 끼어드는 명령 없이 원자적으로 실행됩니다.&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;TTL 2일 &amp;mdash; 트레이드오프&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키 하나에 TTL 2일을 설정했습니다. &quot;오늘&quot;과 &quot;어제&quot; 데이터만 유지하겠다는 의도입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TTL이 짧으면 메모리는 아끼는데 조회 가능한 날짜 범위가 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일별 랭킹은 &quot;오늘&quot; 기준 조회가 대부분이지만, 어제 데이터까지는 캐싱 이점이 있겠다 싶어서 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;&amp;nbsp;&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;4. Spring Batch로 주간/월간 랭킹 집계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주별/월별 랭킹은 Spring Batch Job으로 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WeeklyRankingJob&lt;/code&gt;은 매주 월요일 새벽 2시, &lt;code&gt;MonthlyRankingJob&lt;/code&gt;은 매월 1일 새벽 2시에 &lt;code&gt;@Scheduled&lt;/code&gt;로 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치 작업은 두 Step으로 구성됩니다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;weeklyRankingJob
  ├── Step 1: weeklyTruncateStep  (DELETE FROM mv_product_rank_weekly)
  └── Step 2: weeklyAggregateStep (product_metrics 집계 &amp;rarr; INSERT)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Chunk 기반 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Step 2는 Chunk 기반으로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;JdbcCursorItemReader&lt;/code&gt;로 &lt;code&gt;product_metrics&lt;/code&gt;를 읽어 점수를 계산한 뒤, &lt;code&gt;JdbcBatchItemWriter&lt;/code&gt;로 결과를 삽입합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chunk 크기는 1000건입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Reader &amp;rarr; Processor &amp;rarr; Writer
(1000건 단위 반복)&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;Chunk 처리를 쓰는 이유는 메모리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 결과를 한 번에 메모리에 올리지 않고, 1000건씩 처리-커밋하므로 데이터 규모가 늘어나도 메모리 부담이 일정합니다.&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;DELETE + INSERT 전략 &amp;mdash; 재실행 안전성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집계 테이블 갱신에 UPSERT 대신 DELETE + INSERT를 선택했습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;전략&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UPSERT&lt;/td&gt;
&lt;td&gt;데이터 공백 없음&lt;/td&gt;
&lt;td&gt;중복 키 관리, 부분 갱신 복잡&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETE + INSERT&lt;/td&gt;
&lt;td&gt;구현 단순, 재실행 안전&lt;/td&gt;
&lt;td&gt;Step 1~2 사이 일시적 데이터 공백&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MV 테이블은 배치가 통째로 교체하는 읽기 전용 캐시에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;최신 집계 결과&quot;로 완전히 대체하면 충분하므로 기존 데이터를 보존할 이유가 없습니다.&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;단점인 &quot;일시적 데이터 공백&quot;은 두 Step을 별도 트랜잭션으로 분리해 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Step을 분리하면 Step 2(INSERT)가 실패해도 Step 1(DELETE)이 롤백되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 배치를 재실행할 때 DELETE를 다시 실행하지 않고 INSERT만 재시작하면 됩니다.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 점수 가중치 설계: 좋아요 0.2, 주문 0.7&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 점수는 좋아요와 주문 수를 가중합산합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;score = 좋아요수 &amp;times; 0.2 + 주문수 &amp;times; 0.7&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;버튼 하나로 남기는 좋아요와 실제 결제까지 이어진 주문은 &quot;인기&quot;를 측정하는 데 다른 무게를 가져야 합니다.&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;배치(주별/월별)와 스트리밍(일별 Redis)에서 주문 점수 계산이 다릅니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 배치: RankingScoreCalculator (선형)
score = likeCount * 0.2 + orderCount * 0.7

// 스트리밍: OrderEventConsumer (로그 스케일)
double rankingScore = ORDER_WEIGHT * Math.log1p(message.finalAmount());&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;code&gt;log1p(finalAmount)&lt;/code&gt;를 쓰는 이유는 &lt;b&gt;고액 주문의 영향력을 조절&lt;/b&gt;하기 위해서입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10만 원짜리 주문과 100만 원짜리 주문이 있을 때, 선형으로 계산하면 고가 상품이 일별 랭킹을 독점할 수 있습니다.&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;code&gt;product_metrics&lt;/code&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;의도한 차이입니다.&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;code&gt;LIKE_WEIGHT = 0.2&lt;/code&gt;, &lt;code&gt;ORDER_WEIGHT = 0.7&lt;/code&gt; 상수는 &lt;code&gt;RankingScoreCalculator&lt;/code&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;두 시스템이 같은 상수를 참조하므로, 코드에서 척도가 일치한다는 것을 확인할 수 있습니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Kafka 이벤트 동시 처리 &amp;mdash; 낙관적 락의 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka Consumer는 같은 토픽의 메시지를 병렬로 처리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 이벤트가 동시에 같은 &lt;code&gt;(productId, date)&lt;/code&gt; 행을 갱신하려 하면 Lost Update가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ProductMetrics&lt;/code&gt;에 JPA의 &lt;code&gt;@Version&lt;/code&gt;을 추가해 낙관적 락으로 이를 방지했습니다.&lt;/p&gt;
&lt;pre class=&quot;d&quot;&gt;&lt;code&gt;@Version
private Long version;&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;code&gt;ObjectOptimisticLockingFailureException&lt;/code&gt;이 발생하고, Consumer에서 이를 잡아 중복 처리로 간주하고 skip합니다.&lt;/p&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;} catch (ObjectOptimisticLockingFailureException e) {
    log.warn(&quot;LikeEvent 낙관적 락 충돌 &amp;mdash; 중복 처리 감지, skip: productId={}, eventId={}&quot;,
        message.productId(), message.eventId());
}&lt;/code&gt;&lt;/pre&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;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;)을 쓰면 충돌 시 대기가 발생하고 DB 커넥션을 오래 점유합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 이벤트 처리처럼 처리량이 중요한 환경에서는 병목이 될 수 있습니다.&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; 하에, 충돌 시 재시도 대신 skip합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 파티션을 &lt;code&gt;productId&lt;/code&gt; 기준으로 설정하면 같은 상품에 대한 이벤트가 같은 파티션으로 직렬화되어 처리되므로 동시 충돌 가능성이 낮아집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 파티셔닝이 &lt;code&gt;productId&lt;/code&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;&amp;nbsp;&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;8. 마무리 &amp;mdash; 결국은 &quot;왜&quot;의 연속&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹이라는 단일 도메인 안에 저장소가 세 개 공존합니다(MySQL product_metrics, Redis ZSET, MySQL MV 테이블).&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;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일별에 Redis를 쓴 건 &quot;Redis가 좋아서&quot;가 아니라 실시간 점수 갱신에 ZSET이 맞아서입니다.&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;두 시스템이 같은 가중치 상수를 쓰면서 계산 방식은 다릅니다(선형 vs 로그 스케일).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이가 의도적이라면 코드와 주석에 남겨야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서화하지 않으면 나중에 누군가 &quot;왜 다르지?&quot;라는 질문을 반드시 하게 됩니다.&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 data-ke-size=&quot;size16&quot;&gt;DELETE + INSERT 전략에서 Step 1과 Step 2 사이의 짧은 순간, 조회 API는 빈 테이블을 봅니다.&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;Shadow Table 전략&lt;/b&gt;: &lt;code&gt;mv_product_rank_weekly_shadow&lt;/code&gt;에 먼저 적재한 뒤 MySQL의 &lt;code&gt;RENAME TABLE&lt;/code&gt;로 원자적으로 교체&lt;/li&gt;
&lt;li&gt;&lt;b&gt;버전 컬럼 전략&lt;/b&gt;: &lt;code&gt;batch_version&lt;/code&gt; 컬럼을 두어 최신 버전의 데이터만 조회&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 하나를 만드는 데도 결정이 생각보다 많았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;일별은 Redis냐 DB냐?&quot;, &quot;Truncate냐 Upsert냐?&quot;, &quot;낙관적 락이냐 비관적 락이냐?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash; 이 글은 그 결정마다 Why를 남긴 기록입니다.&lt;/p&gt;</description>
      <category>Tech</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/15</guid>
      <comments>https://najang.tistory.com/entry/Redis%EC%99%80-Spring-Batch%EB%A1%9C-%EB%A7%8C%EB%93%A0-%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C-%EB%9E%AD%ED%82%B9-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98#entry15comment</comments>
      <pubDate>Thu, 16 Apr 2026 22:28:08 +0900</pubDate>
    </item>
    <item>
      <title>조회수 하나를 세는 데 이렇게 많은 고민이 필요하다니</title>
      <link>https://najang.tistory.com/entry/%EC%A1%B0%ED%9A%8C%EC%88%98-%ED%95%98%EB%82%98%EB%A5%BC-%EC%84%B8%EB%8A%94-%EB%8D%B0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%A7%8E%EC%9D%80-%EA%B3%A0%EB%AF%BC%EC%9D%B4-%ED%95%84%EC%9A%94%ED%95%98%EB%8B%A4%EB%8B%88</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size25&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&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;script type=&quot;text/javascript&quot; src=&quot;https://tistory1.daumcdn.net/tistory/8522112/skin/images/jquery.toc.js&quot;&gt;&lt;/script&gt;
&lt;script type=&quot;text/javascript&quot;&gt;$(function() {    $(&quot;#toc&quot;).toc({        content: &quot;.tt_article_useless_p_margin.contents_style&quot;,        headings: &quot;h2,h3,h4&quot;,        toc_show: true,        blinkColor : &quot;#218B0E&quot;    });});&lt;/script&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;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;상품 조회 시 랭킹 집계 실패가 조회 API 실패로 번지지 않도록 Redis에 직접 +1하는 대신, Kafka 이벤트로 분리했다.&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;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;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며 &amp;mdash; &quot;그냥 Redis에 +1 하면 안 되나?&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 상세를 조회할 때 그 상품의 랭킹 점수를 올리는 기능을 구현해야 했습니다.&lt;br /&gt;처음엔 이렇게 짜면 되겠다 싶었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public ProductInfo findById(Long productId, Long userId) {
    Product product = productService.findById(productId);
    redisTemplate.opsForZSet().incrementScore(&quot;ranking:all:&quot; + today, productId, 0.1); // 이 한 줄
    return ProductInfo.from(product);
}&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;&lt;b data-index-in-node=&quot;53&quot; data-path-to-node=&quot;9,0&quot;&gt;&quot;과연 랭킹 점수 1점을 올리는 일이, 상품 정보를 보여주는 핵심 비즈니스보다 중요할까?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Redis 네트워크에 장애가 발생한다면, 사용자에게는 상품 상세 정보 대신 500 에러 페이지가 노출될 겁니다.&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;2. 요구사항 파악 &amp;mdash; 이 기능이 풀려는 문제&lt;/h2&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;pre class=&quot;css&quot;&gt;&lt;code&gt;ranking:all:20260409  &amp;larr; 오늘 키
ranking:all:20260408  &amp;larr; 어제 키 (TTL 2일 후 자동 삭제)&lt;/code&gt;&lt;/pre&gt;
&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;점수 계산&lt;/b&gt;: 조회, 좋아요, 주문에 각기 다른 가중치를 곱해서 합산합니다.
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;조회   +0.1
좋아요 +0.2
주문   +0.7 &amp;times; log(금액 + 1)&lt;/code&gt;&lt;/pre&gt;
&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;순위 조회&lt;/b&gt;: 상품 상세 API가 해당 상품의 오늘 순위를 함께 반환합니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 첫 번째 고민 &amp;mdash; 어디서 점수를 올릴까&lt;/h2&gt;
&lt;pre class=&quot;inform7&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; GET /products/{id} &amp;rarr; [ProductFacade] &amp;rarr; Redis ZINCRBY &amp;rarr; 응답&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;b&gt;이 흐름이 하나의 동기 체인&lt;/b&gt;이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에 장애가 생기면?&lt;/p&gt;
&lt;pre class=&quot;inform7&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; GET /products/{id} &amp;rarr; [ProductFacade] &amp;rarr; Redis 연결 실패 &amp;rarr; 500 에러&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 하나 때문에 조회 API 전체가 죽어버립니다.&lt;br /&gt;&lt;code&gt;try-catch&lt;/code&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 해결 &amp;mdash; 이벤트로 분리하기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ChatGPT Image 2026년 4월 9일 오후 10_31_09.png&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bN3TDs/dJMcadatxyn/AkOK8pPtSR1F6n4hLgD9Zk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bN3TDs/dJMcadatxyn/AkOK8pPtSR1F6n4hLgD9Zk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bN3TDs/dJMcadatxyn/AkOK8pPtSR1F6n4hLgD9Zk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbN3TDs%2FdJMcadatxyn%2FAkOK8pPtSR1F6n4hLgD9Zk%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;1774&quot; height=&quot;887&quot; data-filename=&quot;ChatGPT Image 2026년 4월 9일 오후 10_31_09.png&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 조회 API는 이벤트만 던지고 끝내기로 했습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; GET /products/{id} &amp;rarr; 응답  &amp;larr; 여기서 끊김
                 &amp;darr; (비동기)
         ApplicationEvent 발행
                 &amp;darr;
         Kafka 토픽 &quot;user-action-events&quot;
                 &amp;darr; (별도 서비스)
         commerce-streamer Consumer
                 &amp;darr;
         Redis ZINCRBY&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;&lt;b&gt;1단계: 조회 시 ApplicationEvent 발행&lt;/b&gt; (&lt;code&gt;ProductFacade.java&lt;/code&gt;)&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (userId != null) {
    eventPublisher.publishEvent(new UserActionEvent(
        UserActionEvent.EventType.PRODUCT_VIEWED, userId, productId, null
    ));
}&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 얘기가 전혀 없습니다.&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;2단계: 트랜잭션 커밋 후 Kafka 발행&lt;/b&gt; (&lt;code&gt;UserActionEventKafkaPublisher.java&lt;/code&gt;)&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publish(UserActionEvent event) {
    kafkaTemplate.send(TOPIC, String.valueOf(event.targetId()), event);
}&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;AFTER_COMMIT&lt;/code&gt;&lt;/b&gt;: 트랜잭션이 롤백되면 이벤트를 발행하지 않습니다. 트랜잭션이 실패했는데 조회 이벤트가 랭킹에 잡히는 걸 막습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;@Async&lt;/code&gt;&lt;/b&gt;: Kafka 발행을 별도 스레드에서 처리해서 API 응답 속도에 영향을 주지 않습니다.&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;3단계: Consumer가 점수 반영&lt;/b&gt; (&lt;code&gt;UserActionEventConsumer.java&lt;/code&gt;)&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@KafkaListener(topics = TOPIC, containerFactory = KafkaConfig.BATCH_LISTENER)
public void handleUserActionEvents(
    List&amp;lt;ConsumerRecord&amp;lt;String, UserActionEventMessage&amp;gt;&amp;gt; messages,
    Acknowledgment acknowledgment
) {
    for (ConsumerRecord&amp;lt;String, UserActionEventMessage&amp;gt; record : messages) {
        if (!&quot;PRODUCT_VIEWED&quot;.equals(message.eventType())) continue;

        LocalDate eventDate = Instant.ofEpochMilli(record.timestamp())
            .atZone(ZoneId.of(&quot;Asia/Seoul&quot;))
            .toLocalDate();
        rankingRedisRepository.incrementScore(eventDate, message.targetId(), VIEW_WEIGHT);
    }
    acknowledgment.acknowledge();
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 두 번째 고민 &amp;mdash; Kafka 파티션 키를 왜 productId로 잡았을까&lt;/h2&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;kafkaTemplate.send(TOPIC, String.valueOf(event.targetId()), event);
//                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//                         key = productId&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;code&gt;ZINCRBY&lt;/code&gt;는 순서가 바뀌어도 결과는 같습니다. A 상품에 +0.1이 두 번 오는 것과 순서가 달라도 합은 똑같습니다.&lt;br /&gt;그러면 굳이 key를 productId로 고정할 필요가 없지 않을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 &lt;code&gt;user-action-events&lt;/code&gt; 토픽에는 &lt;code&gt;PRODUCT_VIEWED&lt;/code&gt; 말고도 &lt;code&gt;LIKED&lt;/code&gt;, &lt;code&gt;UNLIKED&lt;/code&gt;가 함께 흐릅니다.&lt;br /&gt;좋아요 후 취소(&lt;code&gt;LIKED&lt;/code&gt; &amp;rarr; &lt;code&gt;UNLIKED&lt;/code&gt;)는 순서가 중요합니다. 순서가 뒤집히면 취소가 먼저 처리되고 좋아요가 나중에 처리되어 결과가 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 상품의 이벤트가 항상 같은 파티션으로 가도록 key를 productId로 잡으면, 지금 당장은 필요 없어도 나중에 순서 의존적인 이벤트가 생길 때 구조를 바꾸지 않아도 됩니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 세 번째 고민 &amp;mdash; 중복 소비는 괜찮을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka Consumer가 재시작되면 이미 처리한 메시지를 다시 소비할 수 있습니다.&lt;br /&gt;그러면 같은 조회 이벤트가 두 번 랭킹에 반영됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 주석에 이런 내용이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;/**
 * - 멱등성: 조회 이벤트는 중복 처리 시 점수가 소폭 중복 적재될 수 있으나,
 * weight=0.1이므로 허용
 */&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;code&gt;VIEW_WEIGHT = 0.1&lt;/code&gt;이라서 조회 이벤트 하나가 중복 적재되어도 점수 오차가 0.1에 불과합니다.&lt;br /&gt;완벽한 멱등성을 위해 Redis에 Event ID를 체크하는 로직을 넣을 수도 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 0.1점의 오차를 잡기 위해 추가적인 네트워크 I/O와 저장 공간을 쓰는 것은 &lt;b data-index-in-node=&quot;107&quot; data-path-to-node=&quot;18,0&quot;&gt;배보다 배꼽이 더 큰 설계&lt;/b&gt;라고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;때로는 완벽한 기술보다 &lt;b data-index-in-node=&quot;145&quot; data-path-to-node=&quot;18,0&quot;&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;&amp;nbsp;&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;7. 결과적으로 얻은 것과 잃은 것&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;얻은 것 &amp;mdash; 장애 격리&lt;/h3&gt;
&lt;table style=&quot;height: 58px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;비교 항목&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;AS-IS (동기 직접 적재)&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;TO-BE (Kafka 비동기)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;가용성&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redis 장애 시 API 전체 장애 발생&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redis 장애 시에도 API 정상 응답&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;응답 속도&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redis 연산 시간만큼 지연 (Latency)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;이벤트 발행 후 즉시 응답 (Low Latency)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 정합성&lt;/td&gt;
&lt;td&gt;강한 일관성 (Immediate)&lt;/td&gt;
&lt;td&gt;최종 일관성 (Eventual Consistency)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시스템 복잡도&lt;/td&gt;
&lt;td&gt;매우 낮음&lt;/td&gt;
&lt;td&gt;보통 (Kafka 및 Consumer 관리 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;잃은 것 &amp;mdash; 즉시 반영과 단순함&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Eventual Consistency&lt;/b&gt;: 조회 후 랭킹에 즉시 반영되지 않습니다. Kafka를 통해 Consumer가 처리할 때까지 지연이 생깁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;디버깅 복잡도&lt;/b&gt;: 문제가 생겼을 때 API 코드만 보면 안 됩니다. Kafka 토픽, Consumer 로그, Redis 상태를 모두 확인해야 합니다. 호출 스택이 끊기기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인프라 의존성 증가&lt;/b&gt;: Kafka, Redis, commerce-streamer 서비스가 모두 떠 있어야 랭킹이 동작합니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 마치며 &amp;mdash; 단순해 보이는 기능 뒤의 의사결정들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ZINCRBY ranking:all:20260409 0.1 product:42&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Redis 명령 한 줄을 언제, 어디서, 어떻게 실행할지 결정하는 데 이만큼의 고민이 필요했습니다.&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;API 서버에서 직접 처리할까, 별도 Consumer로 분리할까?&lt;/li&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;단순해 보이는 ZINCRBY 한 줄 뒤에 숨겨진 수많은 'Why'를 해결하는 과정이 즐거웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현은 금방이었지만, 이 글을 쓰며 제 고민의 궤적을 정리하는 시간이 저를 더 단단한 개발자로 만들어준 것 같습니다.&lt;/p&gt;</description>
      <category>Tech</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/14</guid>
      <comments>https://najang.tistory.com/entry/%EC%A1%B0%ED%9A%8C%EC%88%98-%ED%95%98%EB%82%98%EB%A5%BC-%EC%84%B8%EB%8A%94-%EB%8D%B0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%A7%8E%EC%9D%80-%EA%B3%A0%EB%AF%BC%EC%9D%B4-%ED%95%84%EC%9A%94%ED%95%98%EB%8B%A4%EB%8B%88#entry14comment</comments>
      <pubDate>Thu, 9 Apr 2026 22:13:10 +0900</pubDate>
    </item>
    <item>
      <title>[자바의 신] 1.프로그래밍이란 무엇인가?</title>
      <link>https://najang.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%9D%98-%EC%8B%A0-1%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 개발을 하면서 Spring Boot를 사용하고 있지만, 막상 Java의 기초 개념을 제대로 설명하라고 하면 막막할 때가 있었다. 이번 포스팅에서는 자바의 신 1장 내용을 토대로 프로그래밍의 핵심 개념들을 정리해본다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size25&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;
&lt;script type=&quot;text/javascript&quot; src=&quot;https://tistory1.daumcdn.net/tistory/8522112/skin/images/jquery.toc.js&quot;&gt;&lt;/script&gt;
&lt;script type=&quot;text/javascript&quot;&gt;$(function() {    $(&quot;#toc&quot;).toc({        content: &quot;.tt_article_useless_p_margin.contents_style&quot;,        headings: &quot;h2,h3,h4&quot;,        toc_show: true,        blinkColor : &quot;#218B0E&quot;    });});&lt;/script&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 프로그래밍이란?&lt;/h2&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;실생활에 비유하면, 옆에 있는 사람에게 어떤 일을 시키려면 말을 하든, 메일을 쓰든, 메신저를 보내든 그 사람이 알아들을 수 있는 방식으로 전달해야 한다. 프로그래밍도 마찬가지다. 컴퓨터에게 &quot;무슨 일을 해야 하는지&quot;를 컴퓨터가 이해할 수 있는 언어로 전달하는 것이다.&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;pre class=&quot;css&quot;&gt;&lt;code&gt;[사용자 단말기]  &amp;rarr;요청&amp;rarr;  [서버]
  웹 페이지    &amp;larr;응답&amp;larr;  프로그램&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;b&gt;프론트엔드&lt;/b&gt;: HTML, JavaScript로 화면 구성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;백엔드&lt;/b&gt;: Java, C, Python, PHP, Ruby 등으로 데이터 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;안드로이드&lt;/b&gt;: Java 또는 Kotlin 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 자바 프로그램의 메소드 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디지털 도어락의 비밀번호 확인 기능을 Java 코드로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public boolean checkPassword(String password) {
    // 중간 내용
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메소드의 구성 요소를 정리하면 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;height: 96px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;구성 요소&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;용어&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;public&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;접근 제어자(Access Modifier)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;boolean&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;리턴 타입(Return Type)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;void&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;checkPassword&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;메소드 이름&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;checkPassword&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;String password&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;매개 변수(Parameter)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;code&gt;String password&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ 메소드 선언 시 순서를 반드시 지켜야 한다&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// ✅ 올바른 순서
public boolean checkPassword(String password) { }

// ❌ 리턴 타입이 뒤에 오면 컴파일 에러
public checkPassword(String password) boolean { }

// ❌ 매개 변수 앞에 public을 쓰면 에러
(String password) public boolean checkPassword { }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;void 리턴 타입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리턴 타입이 없는 경우 &lt;code&gt;void&lt;/code&gt;를 사용한다. &quot;이 메소드는 아무것도 돌려주지 않아요&quot;라는 의미다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public void setPassword(String password) {
    // 값은 받지만 아무것도 반환하지 않는다
}

public void resetPassword() {
    // 입력값도, 반환값도 없다
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 자바의 가장 작은 단위 - 클래스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메소드는 반드시 어떤 &lt;b&gt;클래스(Class)&lt;/b&gt; 소속이어야 한다. 클래스 없이 메소드만 단독으로 존재할 수 없다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class DoorLockManager {
    public boolean checkPassword(String password) {
        // 중간 내용
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;클래스 선언 규칙&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반드시 &lt;code&gt;class&lt;/code&gt; 키워드가 앞에 와야 한다&lt;/li&gt;
&lt;li&gt;접근 제어자는 일단 &lt;code&gt;public&lt;/code&gt;으로 지정&lt;/li&gt;
&lt;li&gt;선언 순서: &lt;code&gt;public class 클래스이름&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 클래스 안에는 0개 이상의 메소드가 존재할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class DoorLockManager {
    public boolean checkPassword(String password) {
        // 중간 생략
    }
    public void setPassword(String password) {
        // 중간 생략
    }
    public void resetPassword() {
        // 중간 생략
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 클래스의 상태(State)와 행동(Behavior)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java는 &lt;b&gt;객체지향 프로그래밍 언어(OOP)&lt;/b&gt;다. 클래스는 현실 세계의 사물을 프로그램으로 표현한 것으로, 다음 조건을 만족해야 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스는 상태(state)와 행동(behavior)이 있어야 한다.&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;행동(Behavior)&lt;/b&gt; &amp;rarr; 메소드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태(State)&lt;/b&gt; &amp;rarr; 변수(Variable)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class DoorLockManager {
    String currentPassword;  // 상태: 현재 비밀번호를 저장하는 변수

    public boolean checkPassword(String password) {
        // 중간 생략
    }
    // 이하 메소드 생략
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태(변수)는 &lt;b&gt;클래스 안, 메소드 밖&lt;/b&gt;에 정의한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 반드시 상태와 행동이 모두 있어야 하는 건 아니다. 변수나 메소드가 없다고 컴파일 에러가 발생하지는 않는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 프로그래밍의 가장 기본, = 연산자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수학에서 &lt;code&gt;=&lt;/code&gt;는 좌변과 우변이 같다는 의미지만, &lt;b&gt;프로그래밍에서 &lt;code&gt;=&lt;/code&gt;는 우변의 결과를 좌변의 변수에 대입하는 것이다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 수학: 1+2 = 3  (양쪽이 같다)
// Java: 좌측에 변수, 우측에 계산식
int a;
a = 1 + 2;  // a라는 변수에 3을 저장한다&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변수 선언 방법&lt;/h3&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;타입 변수명;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;int a;           // 정수형 변수 선언
String currentPassword;  // 문자열 변수 선언&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: int는 소수점이 없는 정수형 타입, String은 단어나 문장을 저장하는 문자열 타입이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사칙연산&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;연산 종류&lt;/th&gt;
&lt;th&gt;기호&lt;/th&gt;
&lt;th&gt;예 (num1=10, num2=5)&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;더하기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;num1 + num2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빼기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;num1 - num2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;곱하기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;num1 * num2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;나누기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;num1 / num2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-와 /는 키보드 왼쪽에, *는 Shift를 눌러 입력한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;return 키워드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메소드가 값을 돌려줄 때 &lt;code&gt;return&lt;/code&gt; 키워드를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;public class Calculator {
    public int add(int num1, int num2) {
        int sum;
        sum = num1 + num2;
        return sum;  // sum 값을 반환한다
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 한 줄을 의미하는 세미콜론(;)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java에서 &lt;b&gt;세미콜론(&lt;code&gt;;&lt;/code&gt;)은 한 줄의 끝을 의미한다.&lt;/b&gt; &quot;컴파일러는 세미콜론이 나올 때까지를 한 줄로 인식한다.&quot;&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;// 아래 두 코드는 동일하다
sum = num1 + num2;

sum =
num1 +
num2;&lt;/code&gt;&lt;/pre&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 인덴트(들여쓰기)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java는 인덴트(들여쓰기)를 별로 신경 쓰지 않는다. 다음 두 코드는 컴파일 결과가 완전히 동일하다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;// 인덴트 없는 버전 (컴파일은 되지만...)
public int add(int num1, int num2) {
int sum;
sum = num1 + num2;
return sum;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;// 인덴트 있는 버전 (협업을 위해 이렇게 써야 한다)
public int add(int num1, int num2) {
    int sum;
    sum = num1 + num2;
    return sum;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덴트 없이 코드를 작성하면 같이 일하는 동료에게 엄청난 민폐다. &lt;b&gt;중괄호(&lt;code&gt;{&lt;/code&gt;)가 시작되면 Tab 키를 한 번씩 눌러 들여쓰기&lt;/b&gt;하는 습관을 들이자.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 모든 프로그래밍 언어의 예약어(Reserved Word)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예약어(Reserved Word)&lt;/b&gt;란 이미 프로그래밍 언어에서 의미가 정해진, 사용자가 쓸 수 없는 단어다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;public class Calculator {
    public int add(int num1, int num2) {
        int sum;
        sum = num1 + num2;
        return sum;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서 예약어: &lt;code&gt;public&lt;/code&gt;, &lt;code&gt;class&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;return&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예약어는 마치 식당에서 예약된 자리처럼, 아무리 손님이 많아도 먼저 온 손님을 앉히지 않는다. Java에서도 마찬가지로 이 예약어들을 클래스 이름, 메소드 이름, 변수 이름으로 사용할 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 정리&lt;/h3&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  1장 전체 요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;개념&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;프로그래밍&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;컴퓨터에게 할 일을 가르치는 것&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;메소드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;접근제어자 리턴타입 메소드이름(매개변수) { }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;클래스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;메소드의 소속, &lt;code&gt;public class 이름 { }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;상태(State)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클래스 안, 메소드 밖에 선언된 변수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;행동(Behavior)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;메소드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;= 연산자&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;우변 계산 결과를 좌변 변수에 대입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;세미콜론(;)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;한 줄의 끝을 의미&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;인덴트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Java는 강제하지 않지만, 협업을 위해 반드시 지켜야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;예약어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;언어에서 이미 의미가 정해진 단어, 이름으로 사용 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 개발자로서 Spring Boot를 다루면서 이미 이 개념들을 실무에서 사용하고 있었지만, 다시 한번 정리해보니 왜 Java 코드가 이런 구조를 갖는지 더 명확하게 이해할 수 있었다. 기초를 탄탄히 하는 것이 결국 복잡한 시스템을 이해하는 토대가 된다는 걸 다시 한번 느꼈다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>JAVA</category>
      <author>najang</author>
      <guid isPermaLink="true">https://najang.tistory.com/13</guid>
      <comments>https://najang.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%9D%98-%EC%8B%A0-1%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80#entry13comment</comments>
      <pubDate>Wed, 8 Apr 2026 22:41:18 +0900</pubDate>
    </item>
  </channel>
</rss>