<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>boat_417.log</title>
        <link>https://velog.io/</link>
        <description>일주일에 한 번</description>
        <lastBuildDate>Thu, 09 Nov 2023 04:56:33 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>boat_417.log</title>
            <url>https://velog.velcdn.com/images/boat_417/profile/b6b5adba-b3e7-4425-b24a-a4fc77767cc9/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. boat_417.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/boat_417" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Inheritance vs Composition]]></title>
            <link>https://velog.io/@boat_417/Inheritance-vs-Composition</link>
            <guid>https://velog.io/@boat_417/Inheritance-vs-Composition</guid>
            <pubDate>Thu, 09 Nov 2023 04:56:33 GMT</pubDate>
            <description><![CDATA[<h2 id="inheritance">Inheritance</h2>
<h3 id="inheritance-1">Inheritance?</h3>
<ul>
<li><p><strong>현실 상속?</strong>
  : 부모가 자식에게 물려주는 행위, 부모가 자식 선택</p>
</li>
<li><p><strong>자바 상속?</strong>
  : 부모 클래스가 자식 클래스에게 멤버를 물려주는 행위, 자식 클래스가 부모 클래스 선택</p>
</li>
</ul>
<blockquote>
<p><strong>private 멤버는 상속에서 제외 !!</strong>
부모 클래스와 자식 클래스가 다른 패키지에 존재하는 경우 &gt;&gt; <strong>default도 상속에서 제외 !!</strong></p>
</blockquote>
<h3 id="inheritance-장점">Inheritance 장점</h3>
<h3 id="inheritance-단점">Inheritance 단점</h3>
<ul>
<li>상속은 자식 클래스가 부모 클래스에 강하게 의존
  : 결합도 높아짐</li>
<li>캡슐화가 잘 지켜지지 않음
  : 부모 클래스의 코드를 수정했을 때, 그 멤버를 사용하는 자식 클래스의 코드도 변경해야 하는 경우 생길 수 있음 
  : 불필요한 메서드도 상속받는 경우 생길 수 있음
  ex) public class Stack<E> extends Vector<E><pre><code>    -&gt; add 메서드 : 순서 상관 없이 특정 인덱스에 원소 추가 가능
    -&gt; 사실 Vector의 add를 상속받은 것
    기대하지 않은 결과 생성</code></pre></li>
</ul>
<h2 id="composition">Composition</h2>
<p>사실 조합이라는 단어가 익숙치 않다 뿐, 사용하고 있었음</p>
<h3 id="composition-1">Composition?</h3>
<p>기존 클래스를 확장하는 대신에 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하도록 만드는 설계
그리고 인스턴스의 메서드를 호출</p>
<h3 id="composition-장점">Composition 장점</h3>
<ul>
<li><p>새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어남
<em>(기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않음)</em>
<em>(부분 객체의 내부 구현이 공개되지 않음)</em></p>
</li>
<li><p>메서드를 호출하는 방식으로 동작하기 때문에 캡슐화를 깨뜨리지 않음</p>
</li>
<li><p>상위 클래스에 의존하지 않기 때문에 변화에 유연함</p>
</li>
</ul>
<h3 id="composition-단점">Composition 단점</h3>
<h3 id="비교">비교</h3>
<p>ex) public class Stack<E> extends Vector<E>
          -&gt; public class CompositionStack<T> {
                  private Vector<T> vector = new Vector&lt;&gt;();
              }
  Stack에 필요했던 메서드만 Vector의 public 메서드를 호출해 새로 구현     </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[QueryDSL 쿼리 최적화]]></title>
            <link>https://velog.io/@boat_417/QueryDSL-%EC%BF%BC%EB%A6%AC-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@boat_417/QueryDSL-%EC%BF%BC%EB%A6%AC-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Tue, 26 Sep 2023 05:10:46 GMT</pubDate>
            <description><![CDATA[<p>기존 <strong>UserRepositoryQueryImpl.java</strong> 이용해 검색했을 때</p>
<p><img src="https://velog.velcdn.com/images/boat_417/post/1dcaf74a-d05a-464c-ba9c-e2eb5980dbc3/image.png" alt=""></p>
<pre><code>@Component
@RequiredArgsConstructor
public class UserRepositoryQueryImpl implements UserRepositoryQuery {

    private final JPAQueryFactory jpaQueryFactory;


    @Override
    public List&lt;User&gt; searchUserByKeyword(UserSearchCond cond) {
        return searchByKeyword(cond, user.username);
    }

    @Override
    public List&lt;User&gt; searchNickByKeyword(UserSearchCond cond) {
        return searchByKeyword(cond, user.nickname);
    }

    private List&lt;User&gt; searchByKeyword(UserSearchCond cond, StringPath field) {
        var query = jpaQueryFactory.selectFrom(user)
                .where(containsKeyword(field, cond.getKeyword()));
        query.setHint(AvailableHints.HINT_READ_ONLY, true);
        return query.fetch();
    }

    private BooleanExpression containsKeyword(StringPath field, String keyword) {
        return StringUtils.hasText(keyword) ? field.containsIgnoreCase(keyword) : null;
    }
}</code></pre><p>Jpa 쿼리 메서드를 이용해 검색했을 때
<img src="https://velog.velcdn.com/images/boat_417/post/69915a7f-7165-4784-b537-3451c3bed112/image.png" alt=""></p>
<p><strong>UserService.java</strong></p>
<pre><code>    @Transactional(readOnly = true)
    public SearchUserResponseDto searchUserByKeyword(UserSearchCond userSearchCond) {
            List&lt;User&gt; u1 = userRepository.findAllByUsernameContaining(userSearchCond.getKeyword());
            List&lt;User&gt; u2 = userRepository.findAllByNicknameContaining(userSearchCond.getKeyword());

            List&lt;SimpleUserInfoDto&gt; result = mergeUserResultLists(u1, u2)
                .stream()
                .map(SimpleUserInfoDto::new)
                .toList();

        return new SearchUserResponseDto(result);
    }</code></pre><h3 id="문제">문제</h3>
<p>기존 코드는 QueryDSL을 사용하지 않아도 된다.
(간단한 검색이고 동적인 쿼리를 만들 필요가 없기 때문)
오히려 Jpa 쿼리 메서드를 사용하는 쪽이 빠르다.</p>
<h3 id="시도">시도</h3>
<ol>
<li>QueryDSL의 쿼리를 하나로 합친다.</li>
</ol>
<p><strong>UserRepositoryQueryImpl.java</strong></p>
<pre><code>@Component
@RequiredArgsConstructor
public class UserRepositoryQueryImpl implements UserRepositoryQuery {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List&lt;User&gt; search(UserSearchCond cond) {
        var query = jpaQueryFactory.selectFrom(user)
                .where(
                        Objects.requireNonNull(containsKeyword(user.username, cond.getKeyword()))
                                .or(containsKeyword(user.nickname, cond.getKeyword()))
                );
        query.setHint(AvailableHints.HINT_READ_ONLY, true);
        return query.fetch();
    }

    private BooleanExpression containsKeyword(StringPath field, String keyword) {
        return StringUtils.hasText(keyword) ? field.containsIgnoreCase(keyword) : null;
    }
}</code></pre><p><img src="https://velog.velcdn.com/images/boat_417/post/17604571-c688-4625-9ebf-7c6c60f1f98f/image.png" alt=""></p>
<p>훨씬 빠르게 동작하는 것을 확인할 수 있다.</p>
<ol start="2">
<li>query select문에 필요한 정보만 가져오도록 수정한다.</li>
</ol>
<p><strong>UserProfile.java</strong>
필요한 정보만 담을 dto</p>
<pre><code>@Component
@NoArgsConstructor
@Getter
public class UserProfile {
    private Long userId;
    private String username;
    private String nickname;

    public UserProfile(Long id, String username, String nickname) {
        this.userId = id;
        this.username = username;
        this.nickname = nickname;
    }
}</code></pre><p><img src="https://velog.velcdn.com/images/boat_417/post/950caa7c-fbfe-4723-8162-b15d64bc1523/image.png" alt=""></p>
<p>더 빠르게 동작하는 것을 확인할 수 있다.
사용자를 검색하는 경우는 채팅방에 초대할 때 뿐이므로 다른 정보를 조회하지 않는 편이 낫다고 판단해 위와 같이 수정했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[채팅 내용 Redis -> MySQL]]></title>
            <link>https://velog.io/@boat_417/%EC%B1%84%ED%8C%85-%EB%82%B4%EC%9A%A9-Redis-MySQL</link>
            <guid>https://velog.io/@boat_417/%EC%B1%84%ED%8C%85-%EB%82%B4%EC%9A%A9-Redis-MySQL</guid>
            <pubDate>Fri, 22 Sep 2023 05:36:04 GMT</pubDate>
            <description><![CDATA[<h2 id="redis">Redis</h2>
<h3 id="장점">장점</h3>
<ul>
<li>메모리 기반의 저장소로 데이터에 접근하는 속도가 빠름</li>
<li>다양한 type의 아키텍처를 지원함<ul>
<li>String, Hash, List, Set, Sorted Set, Map 등의 구조 활용 가능</li>
</ul>
</li>
<li>데이터를 분할하여 여러 서버에 분산 저장, 처리할 수 있음</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li>데이터가 휘발성이라 서버 재시작이나 장애 상황 발생 시 데이터 보존 어려움</li>
<li>복잡한 쿼리 지원하지 않음</li>
<li>단일 스레드 환경에서 동작<ul>
<li>다중 처리나 동시성이 요구되는 작업에는 적합하지 않을 수 있음</li>
</ul>
</li>
<li>용량이 큰 데이터에 적합하지 않음</li>
<li>유지 보수가 어려움</li>
</ul>
<h3 id="결론">결론</h3>
<ul>
<li>Map 형식으로 채팅방과 채팅 내용을 저장하기 위해 채팅 관련 데이터는 Redis에 저장하려 했으나 휘발성 메모리이기 때문에 데이터 유실 가능성 있음</li>
<li>채팅 중에는 redis pub/sub 사용, 처음 데이터 불러올 때만 MySQL에서 가져오도록 변경</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis에 저장할 때 Serialize]]></title>
            <link>https://velog.io/@boat_417/Redis%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%A0-%EB%95%8C-Serialize</link>
            <guid>https://velog.io/@boat_417/Redis%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%A0-%EB%95%8C-Serialize</guid>
            <pubDate>Fri, 22 Sep 2023 05:33:10 GMT</pubDate>
            <description><![CDATA[<ul>
<li>에러 메세지</li>
</ul>
<p>🚨 DefaultSerializer requires a Serializable payload but received an object of type [<del>ChatRoom</del>]</p>
<h2 id="문제">문제</h2>
<ul>
<li>ChatRoom을 만들고 redis에 저장할 때 위와 같은 에러 발생</li>
</ul>
<h2 id="원인">원인</h2>
<ul>
<li>Redis 는 data를 hash 해서 저장하기 때문에 redis에 저장할 객체는 Serializable을 implements 해야함</li>
</ul>
<h2 id="해결">해결</h2>
<ul>
<li><p>ChatRoom 이 Serializable을 implements 하도록 함</p>
</li>
<li><p><strong>ChatRoom.java</strong></p>
<pre><code class="language-java">  @Getter
  public class ChatRoom implements Serializable {

      @Serial
      private static final long serialVersionUID = 6494678977089006639L;

      private String roomId;
      private String name;

      public static ChatRoom create(String name) {
          ChatRoom chatRoom = new ChatRoom();
          chatRoom.roomId = UUID.randomUUID().toString();
          chatRoom.name = name;
          return chatRoom;
      }
  }</code></pre>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[WebSocket 채팅 (4)]]></title>
            <link>https://velog.io/@boat_417/Redis-Jedis-vs-Lettuce</link>
            <guid>https://velog.io/@boat_417/Redis-Jedis-vs-Lettuce</guid>
            <pubDate>Fri, 22 Sep 2023 05:13:27 GMT</pubDate>
            <description><![CDATA[<h3 id="로컬-설정">로컬 설정</h3>
<h3 id="프로젝트-환경">프로젝트 환경</h3>
<ul>
<li><p>embeded redis</p>
<ul>
<li><p><strong>build.gradle</strong></p>
<pre><code class="language-java">  // redis
  implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;
  // embedded-redis
  compileOnly group: &#39;it.ozimov&#39;, name: &#39;embedded-redis&#39;, version: &#39;0.7.2&#39;</code></pre>
</li>
</ul>
</li>
<li><p>redis 설정</p>
<ul>
<li><p><strong>application.properties</strong></p>
<pre><code class="language-java">  spring.profiles.active=local
  spring.data.redis.port=6379
  spring.data.redis.host=localhost</code></pre>
</li>
</ul>
</li>
<li><p>다른 환경에서 테스트가 필요할 경우</p>
<ul>
<li><p>application-alpha.yml</p>
<ul>
<li><p>alpha 서버용 환경 설정 파일</p>
<pre><code class="language-java">  spring:
    profiles:
      active: alpha
    redis:
      host: redis가 설치된 서버 호스트
      port: redis가 설치된 서버 포트</code></pre>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="기능">기능</h3>
<h3 id="redis-embedded-redis">Redis (Embedded Redis)</h3>
<ul>
<li><p>Redis에는 공통 주제(Topic)에 대하여 구독자(Subscriber)에게 메세지를 발행(Publish) 하는 기능이 있음</p>
<ul>
<li>topic - 채팅방</li>
<li>pub/ sub - 메세지 보내기, 메세지 받기</li>
</ul>
</li>
<li><p><strong>EmbeddedRedisConfig.java</strong></p>
<ul>
<li><p>@PostConstruct, @PreDestroy - 채팅 서버가 실행될 때 Embedded Redis 서버도 동시에 실행될 수 있도록</p>
</li>
<li><p>@Profile(”local”) - local 환경에서만 실행되도록</p>
<pre><code class="language-java">@Profile(&quot;local&quot;)
@Configuration
public class EmbeddedRedisConfig {

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

  private RedisServer redisServer;

  @PostConstruct
  public void redisServer() {
      redisServer = new RedisServer(redisPort);
      redisServer.start();
  }

  @PreDestroy
  public void stopRedis() {
      if(redisServer != null) {
          redisServer.stop();
      }
  }
}</code></pre>
</li>
</ul>
</li>
<li><p><strong>RedisConfig.java</strong></p>
<ul>
<li><p>MessageListener 추가 - Redis의 pub/sub 이용</p>
</li>
<li><p>RedisTemplate 설정 - 어플리케이션에서 redis 사용</p>
<pre><code class="language-java">@Configuration
public class RedisConfig {

  /*
      redis pub/sub 메세지를 처리하는 listener 설정
   */
  @Bean
  public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
      RedisMessageListenerContainer container = new RedisMessageListenerContainer();
      container.setConnectionFactory(connectionFactory);
      return container;
  }

  /*
      어플리케이션에서 사용할 redisTemplate 설정
   */
  @Bean
  public RedisTemplate&lt;String, Object&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
      RedisTemplate&lt;String, Object&gt; redisTemplate = new RedisTemplate&lt;&gt;();
      redisTemplate.setConnectionFactory(connectionFactory);
      redisTemplate.setKeySerializer(new StringRedisSerializer());
      redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer&lt;&gt;(String.class));
      return redisTemplate;
  }
}</code></pre>
</li>
</ul>
</li>
<li><p><strong>RedisPublisher.java</strong></p>
<ul>
<li><p>채팅방에 입장 후 메세지 작성</p>
<p>  → 메세지를 Redis Topic에 발행</p>
</li>
</ul>
</li>
</ul>
<pre><code>```java
@RequiredArgsConstructor
@Service
public class RedisPublisher {

    private final RedisTemplate&lt;String, Object&gt; redisTemplate;

    /*
        채팅방에 입장 후 메세지 작성
        -&gt; 해당 메세지 Redis Topic에 발행
        -&gt; 대기하고 있던 redis 구독 서비스가 메세지 처리
     */
    public void publish(ChannelTopic topic, ChatMessage message) {
        redisTemplate.convertAndSend(topic.getTopic(), message);
    }
}
```</code></pre><ul>
<li><p><strong>RedisSubscriber.java</strong></p>
<ul>
<li><p>Redis에 메세지 발행이 될 때까지 대기</p>
</li>
<li><p>발행되면 해당 메세지 읽어 처리</p>
<ul>
<li>Redis 발행 메세지 → ChatMessage 변환 → messagingTemplate으로 채팅방의 모든 클라이언트에게 메세지 전달</li>
</ul>
<pre><code class="language-java">@Slf4j(topic = &quot;RedisSubscriber&quot;)
@RequiredArgsConstructor
@Service
public class RedisSubscriber implements MessageListener {

  private final ObjectMapper objectMapper;
  private final RedisTemplate redisTemplate;
  private final SimpMessageSendingOperations messagingTemplate;

  /*
      Redis에서 메세지가 발행(publish)
      -&gt; 대기하고있던 onMessage() 가 해당 메세지 처리
   */
  @Override
  public void onMessage(Message message, byte[] pattern) {
      try {
          // redis 에서 발행된 데이터 받아 deserialize
          String publishMessage = (String) redisTemplate.getStringSerializer().deserialize(message.getBody());
          // ChatMessage 객체로 매핑
          ChatMessage roomMessage = objectMapper.readValue(publishMessage, ChatMessage.class);
          // Websocket 구독자에게 ChatMessage 발송
          messagingTemplate.convertAndSend(&quot;/sub/chat/room/&quot; + roomMessage.getRoomId(), roomMessage);
      } catch (Exception e) {
          log.error(e.getMessage());
      }

  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="redis">Redis</h3>
<ul>
<li><p>Embedded Redis가 아닌 Redis 사용</p>
</li>
<li><p><strong>RedisConfig.java</strong></p>
<pre><code class="language-java">  @Configuration
  public class RedisConfig {

      @Value(&quot;${redis.host}&quot;)
      private String redisHost;

      @Value(&quot;${redis.port}&quot;)
      private int redisPort;

      // Redis 저장소와 연결
      @Bean
      public RedisConnectionFactory connectionFactory() {
          return new LettuceConnectionFactory(redisHost, redisPort);
      }

      /*
          redis pub/sub 메세지를 처리하는 listener 설정
       */
      @Bean
      public RedisMessageListenerContainer redisMessageListenerContainer() {
          RedisMessageListenerContainer container = new RedisMessageListenerContainer();
          container.setConnectionFactory(connectionFactory());
          return container;
      }

      /*
          어플리케이션에서 사용할 redisTemplate 설정
       */
      @Bean
      public RedisTemplate&lt;String, Object&gt; redisTemplate() {
          RedisTemplate&lt;String, Object&gt; redisTemplate = new RedisTemplate&lt;&gt;();
          redisTemplate.setConnectionFactory(connectionFactory());
          // RedisTemplate을 사용할 때 Spring-Redis 간 데이터 직렬화/역직렬화 시 사용하는 방식이 jdk 직렬화 방식
          // 동작에는 문제가 없지만 redis-cli를 통해 데이터를 확인할 때 알아볼 수 없는 형태로 출력되기 때문
          redisTemplate.setKeySerializer(new StringRedisSerializer());
          redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer&lt;&gt;(String.class));
          return redisTemplate;
      }
  }</code></pre>
</li>
</ul>
<h3 id="chat">Chat</h3>
<ul>
<li><p><strong>ChatController.java</strong></p>
<ul>
<li><p>enterChatRoom() - 클라이언트 입장 시 채팅방(topic)에서 대화 가능하도록 리스너 연동</p>
<ul>
<li>채팅방에 발행된 메세지 → 서로 다른 서버에 공유하기 위해 redis의 topic으로 발행</li>
</ul>
<pre><code class="language-java">@RequiredArgsConstructor
@Controller
@Slf4j(topic = &quot;ChatController&quot;)
public class ChatController {

  private final RedisPublisher redisPublisher;
  private final ChatRoomRepository chatRoomRepository;

  /*
      Websocket &quot;/pub/chat/message&quot;로 들어오는 메세지 처리
   */
  @MessageMapping(&quot;/chat/message&quot;)
  public void message(ChatMessage message) {
      // 입장 메세지일 경우
      if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
          chatRoomRepository.enterChatRoom(message.getRoomId());
          message.setMessage(message.getSender() + &quot;님이 입장하셨습니다.&quot;);
      }

      // Websocket 에 발행된 메세지 redis 로 발행 (publish)
      redisPublisher.publish(chatRoomRepository.getTopic(message.getRoomId()), message);
  }
}</code></pre>
</li>
</ul>
</li>
<li><p><strong>ChatRoomRepository.java</strong></p>
<ul>
<li><p>채팅방 정보가 초기화되지 않도록 생성시 Redis Hash에 저장</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Repository
public class ChatRoomRepository {

  // 채팅방 (topic)에 발행되는 메세지 처리할 listener
  private final RedisMessageListenerContainer redisMessageListener;
  // 구독 처리 서비스
  private final RedisSubscriber redisSubscriber;
  // Redis
  private static final String CHAT_ROOMS = &quot;CHAT_ROOM&quot;;
  private final RedisTemplate&lt;String, Object&gt; redisTemplate;
  private HashOperations&lt;String, String, ChatRoom&gt; opsHashChatRoom;
  // 채팅방의 대화 메세지를 발행하기 위한 redis topic 정보
  // 서버별로 채팅방에 매치되는 topic 정보를 Map에 넣어 roomId로 찾을 수 있도록
  private Map&lt;String, ChannelTopic&gt; topics;

  @PostConstruct
  private void init() {
      opsHashChatRoom = redisTemplate.opsForHash();
      topics = new HashMap&lt;&gt;();
  }

  public List&lt;ChatRoom&gt; findAllRoom() {
      return opsHashChatRoom.values(CHAT_ROOMS);
  }

  public ChatRoom findRoomById(String id) {
      return opsHashChatRoom.get(CHAT_ROOMS, id);
  }

  /*
      채팅방 생성 : 서버간 채팅방 공유를 위해 redis hash에 저장
   */
  public ChatRoom createChatRoom(String name) {
      ChatRoom chatRoom = ChatRoom.create(name);
      opsHashChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom);
      return chatRoom;
  }

  /*
      채팅방 입장 : redis 에 topic 을 만들고 pub/sub 통신을 하기 위해 listener 설정
   */
  public void enterChatRoom(String roomId) {
      ChannelTopic topic = topics.get(roomId);
      if(topic == null) {
          topic = new ChannelTopic(roomId);
          redisMessageListener.addMessageListener(redisSubscriber, topic);
          topics.put(roomId, topic);
      }
  }

  public ChannelTopic getTopic(String roomId) {
      return topics.get(roomId);
  }
}</code></pre>
</li>
</ul>
</li>
<li><p><strong>ChatRoom.java</strong></p>
<ul>
<li><p>Serializable</p>
<ul>
<li>Redis에 저장되는 객체들은 Serialize 가능해야함</li>
<li>serialVersionUID</li>
</ul>
<pre><code class="language-java">@Getter
public class ChatRoom implements Serializable {

  @Serial
  private static final long serialVersionUID = 6494678977089006639L;

  private String roomId;
  private String name;

  public static ChatRoom create(String name) {
      ChatRoom chatRoom = new ChatRoom();
      chatRoom.roomId = UUID.randomUUID().toString();
      chatRoom.name = name;
      return chatRoom;
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="테스트">테스트</h3>
<h2 id="다중-서버-채팅-테스트">다중 서버 채팅 테스트</h2>
<ul>
<li>두 개의 서버를 실행시켜 테스트 성공</li>
<li>cmd로 실행<ul>
<li>프로젝트 폴더로 이동</li>
<li>실행 파일 만들기<ul>
<li><code>**gradlew.bat build**</code></li>
</ul>
</li>
<li>실행 파일이 생성된 위치로 이동<ul>
<li><code>**cd build/libs**</code></li>
</ul>
</li>
<li>포트 번호 새로 설정 후 실행<ul>
<li><code>**java -jar -Dserver.port=8090 websocket-prac-0.0.1-SNAPSHOT.jar**</code></li>
</ul>
</li>
<li>실행 종료 후 빌드 된 파일 삭제<ul>
<li><code>**gradlew.bat clean**</code></li>
</ul>
</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/boat_417/post/304418ea-9565-45a8-a9ee-4639e3aebda5/image.png" alt=""></p>
<ul>
<li>포트 지정 실행<ul>
<li>java -jar<ul>
<li>java -jar -Dserver.port={포트번호} {파일명.jar}</li>
</ul>
</li>
<li>Gradle<ul>
<li>—server 앞에 공백 필수</li>
<li>./gradlew bootrun —args ‘ —server.port={포트번호}’</li>
</ul>
</li>
<li>Maven<ul>
<li>mvn spring-boot:run -Dspring-boot.run.jvmArgument=’-Dserver.port={포트번호}’</li>
</ul>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[sockjs, stomp front 404 error]]></title>
            <link>https://velog.io/@boat_417/sockjs-stomp-front-404-error</link>
            <guid>https://velog.io/@boat_417/sockjs-stomp-front-404-error</guid>
            <pubDate>Thu, 21 Sep 2023 14:14:39 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<ul>
<li>채팅방 만들기 까지 됐지만 채팅방 상세화면으로 넘어갈 때 아래와 같은 에러 발생
  <img src="https://velog.velcdn.com/images/boat_417/post/8c7fbb3c-529a-4c82-a591-4d3441b99a9f/image.png" alt="">
<img src="https://velog.velcdn.com/images/boat_417/post/ea216722-ca9d-418b-a00f-3dae268dbe14/image.png" alt=""></li>
</ul>
<h3 id="시도">시도</h3>
<ul>
<li><p><strong>시도 1</strong></p>
<ul>
<li><p>기존 <strong>roomdetail.html</strong></p>
<pre><code class="language-java">&lt;!-- JavaScript --&gt;
&lt;script src=&quot;/webjars/sockjs-client/1.1.2/sockjs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/webjars/stomp-websocket/2.3.3-1/stomp.min.js&quot;&gt;&lt;/script&gt;</code></pre>
</li>
<li><p>수정 1</p>
<ul>
<li>라이브러리 버전 삭제</li>
</ul>
<pre><code class="language-java">&lt;!-- JavaScript --&gt;
&lt;script src=&quot;/webjars/sockjs-client/sockjs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/webjars/stomp-websocket/stomp.min.js&quot;&gt;&lt;/script&gt;</code></pre>
</li>
</ul>
</li>
<li><p><strong>시도 2</strong></p>
<ul>
<li><p>기존 <strong>roomdetail.html</strong></p>
<pre><code class="language-java">&lt;!-- JavaScript --&gt;
&lt;script src=&quot;/webjars/sockjs-client/1.1.2/sockjs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/webjars/stomp-websocket/2.3.3-1/stomp.min.js&quot;&gt;&lt;/script&gt;</code></pre>
</li>
<li><p>수정 2</p>
<ul>
<li>라이브러리 버전 <strong>build.gradle</strong>과 맞추기</li>
</ul>
<pre><code class="language-java">&lt;!-- JavaScript --&gt;
&lt;script src=&quot;/webjars/sockjs-client/1.5.1/sockjs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/webjars/stomp-websocket/2.3.4/stomp.min.js&quot;&gt;&lt;/script&gt;</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="원인">원인</h3>
<ul>
<li>위와 같은 오류는 정적 파일을 불러와야 하는데 해당 파일을 찾을 수 없다는 오류</li>
<li>경로에는 문제가 없다고 생각했는데 script에 버전을 다르게 적어서 파일을 찾을 수 없던 것으로 예상</li>
<li><strong>build.gradle</strong>의 sockjs, stomp는 현재 참고하고 있는 프로젝트를 시도해보기 전에 추가했던 버전</li>
</ul>
<h3 id="해결">해결</h3>
<ul>
<li>채팅을 잘 불러옴</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[WebSocket 채팅 (3) - Stomp 적용]]></title>
            <link>https://velog.io/@boat_417/WebSocket-%EC%B1%84%ED%8C%85-3-Stomp-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@boat_417/WebSocket-%EC%B1%84%ED%8C%85-3-Stomp-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Thu, 21 Sep 2023 14:10:15 GMT</pubDate>
            <description><![CDATA[<h2 id="로컬-설정">로컬 설정</h2>
<h3 id="프로젝트-환경">프로젝트 환경</h3>
<ul>
<li><p>stomp 추가</p>
</li>
<li><p>sockjs : websocket을 지원하지 않는 낮은 버전의 브라우저에서도 websocket 사용할 수 있도록</p>
</li>
<li><p>webjar : 채팅 웹 화면 구현 관련 js 로드</p>
</li>
<li><p>freemarker, vue.js : 프론트 웹 개발</p>
<ul>
<li><p><strong>build.gradle</strong></p>
<pre><code class="language-java">      implementation &#39;org.springframework.boot:spring-boot-starter-freemarker&#39;
      implementation &#39;org.springframework.boot:spring-boot-devtools&#39;
      // 채팅 웹 화면 구현 js
      implementation &#39;org.webjars.bower:bootstrap:4.3.1&#39;
      implementation &#39;org.webjars.bower:vue:2.5.16&#39;
      implementation &#39;org.webjars.bower:axios:0.17.1&#39;
      // sockjs
      implementation &#39;org.webjars:sockjs-client:1.5.1&#39;
      // stomp
      implementation &#39;org.webjars:stomp-websocket:2.3.4&#39;
      // gson
      implementation &#39;com.google.code.gson:gson:2.9.0&#39;</code></pre>
</li>
</ul>
</li>
<li><p>static 파일 개발할 때 서버를 재시작 하지 않고 수정한 내용이 반영되도록</p>
<ul>
<li><p><strong>application.properties</strong></p>
<pre><code class="language-java">  spring.devtools.livereload.enabled=true
  spring.devtools.restart.enabled=false
  spring.freemarker.cache=false</code></pre>
</li>
</ul>
</li>
</ul>
<h2 id="기능">기능</h2>
<h3 id="stomp">Stomp</h3>
<ul>
<li><p><strong>WebSocketConfig.java</strong></p>
<ul>
<li><p>Stomp를 사용</p>
<ul>
<li><code>**@EnableWebSocketMessageBroker</code>** 선언</li>
<li><code>**WebSocketMessageBrokerConfigurer</code>** 상속</li>
</ul>
</li>
<li><p>pub/ sub 구현</p>
<ul>
<li>메세지 발행 요청 prefix → /pub</li>
<li>메세지 구독 요청 prefix → /sub</li>
</ul>
</li>
<li><p>Stomp websocket 의 endpoint → /ws-stomp</p>
<ul>
<li>접속 주소 : ws://localhost:8080/ws-stomp</li>
</ul>
<pre><code class="language-java">@Slf4j(topic = &quot;WebSocketChatHandler&quot;)
@Component
public class WebSocketChatHandler extends TextWebSocketHandler {

  @Override
  protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
      String payload = message.getPayload();
      log.info(&quot;payload {}&quot;, payload);
      TextMessage textMessage = new TextMessage(&quot;입장하셨습니다.&quot;);
      session.sendMessage(textMessage);
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="chat">Chat</h3>
<ul>
<li><p>간단하게 ChatRoom을 Map으로 관리하도록 구현</p>
<p>  → 서비스에서는 DB 혹은 다른 저장 매체에 저장하도록 구현 </p>
<ul>
<li><p><strong>ChatRoomRepository.java</strong></p>
<ul>
<li><p>ChatService → ChatRoomRepository</p>
<pre><code class="language-java">@Repository
public class ChatRoomRepository {

  private Map&lt;String, ChatRoom&gt; chatRoomMap;

  @PostConstruct
  private void init() {
      chatRoomMap = new LinkedHashMap&lt;&gt;();
  }

  public List&lt;ChatRoom&gt; findAllRoom() {
      // 채팅방 생성 순서 최신 순으로 반환
      List&lt;ChatRoom&gt; chatRooms = new ArrayList&lt;&gt;(chatRoomMap.values());
      Collections.reverse(chatRooms);
      return chatRooms;
  }

  public ChatRoom findRoomById(String id) {
      return chatRoomMap.get(id);
  }

  public ChatRoom creaetChatRoom(String name) {
      ChatRoom chatRoom = ChatRoom.create(name);
      chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
      return chatRoom;
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
</li>
<li><p><code>**@MessageMapping</code>** 을 통해 Websocket으로 들어오는 메세지 발행 처리</p>
<ul>
<li><p><strong>ChatController.java</strong></p>
<ul>
<li><p>WebSocketChatHandler → ChatController</p>
<pre><code class="language-java">/*
  publisher 구현
*/
@RequiredArgsConstructor
@RestController
@RequestMapping(&quot;/chat&quot;)
@Slf4j(topic = &quot;채팅방 생성/ 조회&quot;)
public class ChatController {

  private final SimpMessageSendingOperations messagingTemplate;

  /*
      @MessageMapping -&gt; websocket 으로 들어오는 메세지 발행 처리
   */
  @MessageMapping(&quot;/chat/message&quot;)
  public void message(ChatMessage message) {
      // 1. 클라이언트 - prefix 붙여 &quot;/pub/chat/message&quot;로 발행 요청

      // 입장 메세지일 경우
      if(ChatMessage.MessageType.ENTER.equals(message.getType())){
          message.setMessage(message.getSender() + &quot;님이 입장하셨습니다.&quot;);
      }

      // 2. &quot;/sub/chat/room/{roomId}&quot;로 메세지 발송
      // 클라이언트 : &quot;/sub/chat/room/{roomId}&quot; 를 구독하고 있다가 메세지가 전달되면 화면에 출력
      messagingTemplate.convertAndSend(&quot;/sub/chat/room/&quot; + message.getRoomId(), message);
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
</li>
<li><p>채팅방 관련 controller 생성</p>
<ul>
<li><p><strong>ChatRoomController.java</strong></p>
<pre><code class="language-java">  @RequiredArgsConstructor
  @Controller
  @RequestMapping(&quot;/chat&quot;)
  public class ChatRoomController {

      private final ChatRoomRepository chatRoomRepository;

      // 채팅 리스트 화면
      @GetMapping(&quot;/room&quot;)
      public String rooms(Model model) {
          return &quot;room&quot;;
      }

      // 모든 채팅방 목록 반환
      @GetMapping(&quot;/rooms&quot;)
      @ResponseBody
      public List&lt;ChatRoom&gt; room() {
          return chatRoomRepository.findAllRoom();
      }

      // 채팅방 생성
      @PostMapping(&quot;/room&quot;)
      @ResponseBody
      public ChatRoom createRoom(@RequestParam String name) {
          return chatRoomRepository.createChatRoom(name);
      }

      // 채팅방 입장 화면
      @GetMapping(&quot;/room/enter/{roomId}&quot;)
      public String roomDetail(Model model, @PathVariable String roomId) {
          model.addAttribute(&quot;roomId&quot;, roomId);
          return &quot;roomdetail&quot;;
      }

      // 특정 채팅방 조회
      @GetMapping(&quot;/room/{roomId}&quot;)
      @ResponseBody
      public ChatRoom roomInfo(@PathVariable String roomId) {
          return chatRoomRepository.findRoomById(roomId);
      }
  }</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="프론트">프론트</h3>
<ul>
<li><p>프론트 코드는 샘플 코드 그대로 가져옴</p>
</li>
<li><p><strong>room.html</strong></p>
<pre><code class="language-java">  &lt;!doctype html&gt;
  &lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
      &lt;title&gt;Websocket Chat&lt;/title&gt;
      &lt;meta charset=&quot;utf-8&quot;&gt;
      &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no&quot;&gt;
      &lt;!-- CSS --&gt;
      &lt;link rel=&quot;stylesheet&quot; href=&quot;/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css&quot;&gt;
      &lt;style&gt;
          [v-cloak] {
              display: none;
          }
      &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
  &lt;div class=&quot;container&quot; id=&quot;app&quot; v-cloak&gt;
      &lt;div class=&quot;row&quot;&gt;
          &lt;div class=&quot;col-md-12&quot;&gt;
              &lt;h3&gt;채팅방 리스트&lt;/h3&gt;
          &lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;input-group&quot;&gt;
          &lt;div class=&quot;input-group-prepend&quot;&gt;
              &lt;label class=&quot;input-group-text&quot;&gt;방제목&lt;/label&gt;
          &lt;/div&gt;
          &lt;input type=&quot;text&quot; class=&quot;form-control&quot; v-model=&quot;room_name&quot; v-on:keyup.enter=&quot;createRoom&quot;&gt;
          &lt;div class=&quot;input-group-append&quot;&gt;
              &lt;button class=&quot;btn btn-primary&quot; type=&quot;button&quot; @click=&quot;createRoom&quot;&gt;채팅방 개설&lt;/button&gt;
          &lt;/div&gt;
      &lt;/div&gt;
      &lt;ul class=&quot;list-group&quot;&gt;
          &lt;li class=&quot;list-group-item list-group-item-action&quot; v-for=&quot;item in chatrooms&quot; v-bind:key=&quot;item.roomId&quot; v-on:click=&quot;enterRoom(item.roomId)&quot;&gt;
              {{item.name}}
          &lt;/li&gt;
      &lt;/ul&gt;
  &lt;/div&gt;
  &lt;!-- JavaScript --&gt;
  &lt;script src=&quot;/webjars/vue/2.5.16/dist/vue.min.js&quot;&gt;&lt;/script&gt;
  &lt;script src=&quot;/webjars/axios/0.17.1/dist/axios.min.js&quot;&gt;&lt;/script&gt;
  &lt;script&gt;
      var vm = new Vue({
          el: &#39;#app&#39;,
          data: {
              room_name : &#39;&#39;,
              chatrooms: [
              ]
          },
          created() {
              this.findAllRoom();
          },
          methods: {
              findAllRoom: function() {
                  axios.get(&#39;/chat/rooms&#39;).then(response =&gt; { this.chatrooms = response.data; });
              },
              createRoom: function() {
                  if(&quot;&quot; === this.room_name) {
                      alert(&quot;방 제목을 입력해 주십시요.&quot;);
                      return;
                  } else {
                      var params = new URLSearchParams();
                      params.append(&quot;name&quot;,this.room_name);
                      axios.post(&#39;/chat/room&#39;, params)
                          .then(
                              response =&gt; {
                                  alert(response.data.name+&quot;방 개설에 성공하였습니다.&quot;)
                                  this.room_name = &#39;&#39;;
                                  this.findAllRoom();
                              }
                          )
                          .catch( response =&gt; { alert(&quot;채팅방 개설에 실패하였습니다.&quot;); } );
                  }
              },
              enterRoom: function(roomId) {
                  var sender = prompt(&#39;대화명을 입력해 주세요.&#39;);
                  if(sender != &quot;&quot;) {
                      localStorage.setItem(&#39;wschat.sender&#39;,sender);
                      localStorage.setItem(&#39;wschat.roomId&#39;,roomId);
                      location.href=&quot;/chat/room/enter/&quot;+roomId;
                  }
              }
          }
      });
  &lt;/script&gt;
  &lt;/body&gt;
  &lt;/html&gt;</code></pre>
</li>
<li><p><strong>roomdetail.html</strong></p>
<pre><code class="language-java">  &lt;!doctype html&gt;
  &lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
      &lt;title&gt;Websocket ChatRoom&lt;/title&gt;
      &lt;!-- Required meta tags --&gt;
      &lt;meta charset=&quot;utf-8&quot;&gt;
      &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no&quot;&gt;

      &lt;!-- Bootstrap CSS --&gt;
      &lt;link rel=&quot;stylesheet&quot; href=&quot;/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css&quot;&gt;

      &lt;!-- JavaScript --&gt;
      &lt;script src=&quot;/webjars/vue/2.5.16/dist/vue.min.js&quot;&gt;&lt;/script&gt;
      &lt;script src=&quot;/webjars/axios/0.17.1/dist/axios.min.js&quot;&gt;&lt;/script&gt;
      &lt;script src=&quot;/webjars/sockjs-client/1.5.1/sockjs.min.js&quot;&gt;&lt;/script&gt;
      &lt;script src=&quot;/webjars/stomp-websocket/2.3.4/stomp.min.js&quot;&gt;&lt;/script&gt;

      &lt;style&gt;
          [v-cloak] {
              display: none;
          }
      &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
  &lt;div class=&quot;container&quot; id=&quot;app&quot; v-cloak&gt;
      &lt;div&gt;
          &lt;h2&gt;{{room.name}}&lt;/h2&gt;
      &lt;/div&gt;
      &lt;div class=&quot;input-group&quot;&gt;
          &lt;div class=&quot;input-group-prepend&quot;&gt;
              &lt;label class=&quot;input-group-text&quot;&gt;내용&lt;/label&gt;
          &lt;/div&gt;
          &lt;input type=&quot;text&quot; class=&quot;form-control&quot; v-model=&quot;message&quot; v-on:keypress.enter=&quot;sendMessage&quot;&gt;
          &lt;div class=&quot;input-group-append&quot;&gt;
              &lt;button class=&quot;btn btn-primary&quot; type=&quot;button&quot; @click=&quot;sendMessage&quot;&gt;보내기&lt;/button&gt;
          &lt;/div&gt;
      &lt;/div&gt;
      &lt;ul class=&quot;list-group&quot;&gt;
          &lt;li class=&quot;list-group-item&quot; v-for=&quot;message in messages&quot;&gt;
              {{message.sender}} - {{message.message}}&lt;/a&gt;
          &lt;/li&gt;
      &lt;/ul&gt;
      &lt;div&gt;&lt;/div&gt;
  &lt;/div&gt;

  &lt;script&gt;
      //alert(document.title);
      // websocket &amp; stomp initialize
      var sock = new SockJS(&quot;/ws-stomp&quot;);
      var ws = Stomp.over(sock);
      var reconnect = 0;
      // vue.js
      var vm = new Vue({
          el: &#39;#app&#39;,
          data: {
              roomId: &#39;&#39;,
              room: {},
              sender: &#39;&#39;,
              message: &#39;&#39;,
              messages: []
          },
          created() {
              this.roomId = localStorage.getItem(&#39;wschat.roomId&#39;);
              this.sender = localStorage.getItem(&#39;wschat.sender&#39;);
              this.findRoom();
          },
          methods: {
              findRoom: function() {
                  axios.get(&#39;/chat/room/&#39;+this.roomId).then(response =&gt; { this.room = response.data; });
              },
              sendMessage: function() {
                  ws.send(&quot;/pub/chat/message&quot;, {}, JSON.stringify({type:&#39;TALK&#39;, roomId:this.roomId, sender:this.sender, message:this.message}));
                  this.message = &#39;&#39;;
              },
              recvMessage: function(recv) {
                  this.messages.unshift({&quot;type&quot;:recv.type,&quot;sender&quot;:recv.type==&#39;ENTER&#39;?&#39;[알림]&#39;:recv.sender,&quot;message&quot;:recv.message})
              }
          }
      });

      function connect() {
          // pub/sub event
          ws.connect({}, function(frame) {
              ws.subscribe(&quot;/sub/chat/room/&quot;+vm.$data.roomId, function(message) {
                  var recv = JSON.parse(message.body);
                  vm.recvMessage(recv);
              });
              ws.send(&quot;/pub/chat/message&quot;, {}, JSON.stringify({type:&#39;ENTER&#39;, roomId:vm.$data.roomId, sender:vm.$data.sender}));
          }, function(error) {
              if(reconnect++ &lt;= 5) {
                  setTimeout(function() {
                      console.log(&quot;connection reconnect&quot;);
                      sock = new SockJS(&quot;/ws-stomp&quot;);
                      ws = Stomp.over(sock);
                      connect();
                  },10*1000);
              }
          });
      }
      connect();
  &lt;/script&gt;
  &lt;/body&gt;
  &lt;/html&gt;</code></pre>
</li>
</ul>
<h2 id="테스트">테스트</h2>
<h3 id="뷰-페이지">뷰 페이지</h3>
<ul>
<li>두 개의 브라우저로 테스트 성공</li>
</ul>
<p><img src="https://velog.velcdn.com/images/boat_417/post/ec39bf77-0bfc-44c5-b880-51f7a882847c/image.png" alt=""></p>
<p>(참고 : <a href="https://www.daddyprogrammer.org/post/4691/spring-websocket-chatting-server-stomp-server/">https://www.daddyprogrammer.org/post/4691/spring-websocket-chatting-server-stomp-server/</a>)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WebSocket 채팅 (2)]]></title>
            <link>https://velog.io/@boat_417/WebSocket-%EC%B1%84%ED%8C%85-2</link>
            <guid>https://velog.io/@boat_417/WebSocket-%EC%B1%84%ED%8C%85-2</guid>
            <pubDate>Thu, 21 Sep 2023 14:07:16 GMT</pubDate>
            <description><![CDATA[<h2 id="기능">기능</h2>
<ul>
<li>클라이언트 서버에 접속 → 개별적으로 WebSocket session 가지게 됨</li>
<li>채팅방에 클라이언트의 session 정보 저장</li>
<li>서버에 전달된 메세지를 채팅방의 sessionList로 발송</li>
</ul>
<h3 id="chat">Chat</h3>
<ul>
<li><p><strong>ChatMessage.java</strong></p>
<ul>
<li><p>클라이언트 메세지 DTO</p>
<pre><code class="language-java">@Getter
@Setter
public class ChatMessage {

  // 메세지 타입 : 입장, 채팅
  public enum MessageType {
      ENTER, TALK
  }

  private MessageType type;
  private String roomId;
  private String sender;
  private String message;
}</code></pre>
</li>
</ul>
</li>
<li><p><strong>ChatRoom.java</strong></p>
<ul>
<li><p>채팅방</p>
<ul>
<li>채팅방 정보</li>
<li>채팅방 내 메세지 전송 메서드</li>
</ul>
<pre><code class="language-java">@Getter
public class ChatRoom {
  private String roomId;
  private String name;

  // 입장한 클라이언트들의 session 정보
  private Set&lt;WebSocketSession&gt; sessions = new HashSet&lt;&gt;();

  /* 생성자 */
  @Builder
  public ChatRoom(String roomId, String name) {
      this.roomId = roomId;
      this.name = name;
  }

  public void handleActions(WebSocketSession session, ChatMessage chatMessage, ChatService chatService) {
      // 채팅방에 입장했을 때
      if (chatMessage.getType().equals(ChatMessage.MessageType.ENTER)) {
          sessions.add(session);
          chatMessage.setMessage(chatMessage.getSender() + &quot;님이 입장했습니다.&quot;);
      }

      sendMessage(chatMessage, chatService);
  }

  public &lt;T&gt; void sendMessage(T message, ChatService chatService) {
      // 채팅방의 모든 클라이언트에게 메세지 전송
      sessions.parallelStream().forEach(session -&gt; chatService.sendMessage(session, message));
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="chatservice">ChatService</h3>
<ul>
<li><p><strong>ChatController.java</strong></p>
<ul>
<li><p>채팅방 생성 및 전체 채팅방 조회</p>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
@RequestMapping(&quot;/chat&quot;)
@Slf4j(topic = &quot;채팅방 생성/ 조회&quot;)
public class ChatController {

  private final ChatService chatService;

  @PostMapping
  public ChatRoom createRoom(@RequestParam String name) {
      return chatService.createRoom(name);
  }

  @GetMapping
  public List&lt;ChatRoom&gt; findAllRoom() {
      return chatService.findAllRoom();
  }

}</code></pre>
</li>
</ul>
</li>
<li><p><strong>ChatService.java</strong></p>
<ul>
<li><p>채팅 서비스</p>
<ul>
<li>컨트롤러에서 사용할 메서드</li>
<li>채팅 handler에서 사용할 메서드</li>
</ul>
<pre><code class="language-java">@Slf4j(topic = &quot;ChatService&quot;)
@Service
@RequiredArgsConstructor
public class ChatService {

  private final ObjectMapper objectMapper;

  // 생성된 모든 채팅방 정보 roomId - ChatRoom
  private Map&lt;String, ChatRoom&gt; chatRooms;

  @PostConstruct
  private void init() {
      chatRooms = new LinkedHashMap&lt;&gt;();
  }

  /**
   * 모든 채팅방 조회
   *
   * @return
   */
  public List&lt;ChatRoom&gt; findAllRoom() {
      return new ArrayList&lt;&gt;(chatRooms.values());
  }

  /**
   * 채팅방 조회
   *
   * @param roomId 조회할 채팅방 id
   * @return 채팅방
   */
  public ChatRoom findRoomById(String roomId) {
      return chatRooms.get(roomId);
  }

  /**
   * 채팅방 생성
   *
   * @param name 생성할 채팅방 이름
   * @return 채팅방
   */
  public ChatRoom createRoom(String name) {
      String randomId = UUID.randomUUID().toString();
      ChatRoom chatRoom = ChatRoom.builder()
              .roomId(randomId)
              .name(name)
              .build();

      chatRooms.put(randomId, chatRoom);
      return chatRoom;
  }

  /**
   * 메세지 발송
   *
   * @param session 목적지 session
   * @param message 발송할 message
   * @param &lt;T&gt;     type
   */
  public &lt;T&gt; void sendMessage(WebSocketSession session, T message) {
      try {
          session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
      } catch (IOException e) {
          log.error(e.getMessage(), e);
      }
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="handler">Handler</h3>
<ul>
<li><p><strong>WebSocketChatHandler.java</strong></p>
<ul>
<li><p>handler 수정</p>
<ul>
<li><p>기본 입장 메세지 삭제 후 ChatMessage, ChatRoom 사용하도록 수정</p>
<pre><code class="language-java">TextMessage textMessage = new TextMessage(&quot;채팅 입장&quot;);
session.sendMessage(textMessage);</code></pre>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code>```java
@Slf4j(topic = &quot;WebSocketChatHandler&quot;)
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper;
    private final ChatService chatService;

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info(&quot;payload : {}&quot;, payload);

        // 클라이언트로부터 채팅 메세지를 전달받아 ChatMessage 객체로 변환
        ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
        // 메세지 속의 roomId로 채팅방 조회
        ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
        // 해당 채팅방에 있는 클라이언트들에게 메세지 발송
        room.handleActions(session, chatMessage, chatService);
    }
}
```</code></pre><h2 id="테스트">테스트</h2>
<h3 id="채팅방">채팅방</h3>
<ul>
<li><p>채팅방 생성
<img src="https://velog.velcdn.com/images/boat_417/post/35011ffd-b19c-4cb4-9f3e-8450108effb1/image.png" alt=""></p>
</li>
<li><p>채팅방 조회
<img src="https://velog.velcdn.com/images/boat_417/post/b4889917-6377-41fe-b5b1-aa1755be03ec/image.png" alt=""></p>
</li>
</ul>
<h3 id="채팅">채팅</h3>
<ul>
<li>simple websocket client 두 개를 열어 채팅
<img src="https://velog.velcdn.com/images/boat_417/post/97be9447-3ef5-45c5-9846-5e4955b36b07/image.png" alt=""></li>
</ul>
<p>(참고 : <a href="https://www.daddyprogrammer.org/post/4077/spring-websocket-chatting/">https://www.daddyprogrammer.org/post/4077/spring-websocket-chatting/</a>)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WebSocket 채팅 (1)]]></title>
            <link>https://velog.io/@boat_417/WebSocket-%EC%B1%84%ED%8C%85-1</link>
            <guid>https://velog.io/@boat_417/WebSocket-%EC%B1%84%ED%8C%85-1</guid>
            <pubDate>Thu, 21 Sep 2023 14:00:15 GMT</pubDate>
            <description><![CDATA[<h2 id="로컬-설정">로컬 설정</h2>
<h3 id="프로젝트-환경">프로젝트 환경</h3>
<ul>
<li><p>websocket 의존성 추가</p>
<ul>
<li><p><strong>build.gradle</strong></p>
<pre><code class="language-java">  // websocket
  implementation &#39;org.springframework.boot:spring-boot-starter-websocket&#39;</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="websocket-handler">Websocket Handler</h3>
<ul>
<li><p>socket 통신은 서버와 클라이언트가 1:N</p>
<p>  → 서버에는 여러 클라이언트가 발송한 메세지를 받아 처리해줄 Handler 필요</p>
</li>
<li><p><strong>WebSocketChatHandler.java</strong></p>
<ul>
<li><p>클라이언트 메세지 송신</p>
<p>  → console.log 출력</p>
<p>  → 클라이언트로 메세지 전송</p>
</li>
</ul>
</li>
</ul>
<pre><code>```java
@Slf4j(topic = &quot;WebSocketChatHandler&quot;)
@Component
public class WebSocketChatHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info(&quot;채팅 log : payload {}&quot;, payload);
        TextMessage textMessage = new TextMessage(&quot;입장하셨습니다.&quot;);
        session.sendMessage(textMessage);
    }
}
```</code></pre><ul>
<li><p><strong>WebSocketConfig.java</strong></p>
<ul>
<li><p>WebSocket 활성화</p>
<ul>
<li><p>@EnableWebSocket 선언</p>
</li>
<li><p>endpoint 설정(”/ws/chat”)</p>
</li>
<li><p>CORS : setAllowedOrigins(”*”)</p>
<p>  → 도메인이 다른 서버에서도 접속 가능</p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code>```java
@Slf4j(topic = &quot;WebSocketChatHandler&quot;)
@Component
public class WebSocketChatHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info(&quot;payload {}&quot;, payload);
        TextMessage textMessage = new TextMessage(&quot;입장하셨습니다.&quot;);
        session.sendMessage(textMessage);
    }
}
```</code></pre><h2 id="테스트">테스트</h2>
<h3 id="simple-websocket-client">Simple Websocket Client</h3>
<ul>
<li>Websocket 테스트를 위한 클라이언트가 없을 때 사용할 수 있는 chrome 확장 프로그램</li>
</ul>
<p><a href="https://chrome.google.com/webstore/detail/simple-websocket-client/gobngblklhkgmjhbpbdlkglbhhlafjnh?hl=ko-">Simple WebSocket Client</a></p>
<p><img src="https://velog.velcdn.com/images/boat_417/post/88bc4af0-f351-4867-b80f-6f5f9d84cb76/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/boat_417/post/58408ea3-294d-4f5f-9828-94971e110d4e/image.png" alt=""></p>
<p>(참고 : <a href="https://www.daddyprogrammer.org/post/4077/spring-websocket-chatting/">https://www.daddyprogrammer.org/post/4077/spring-websocket-chatting/</a>)
(참고 : <a href="https://terianp.tistory.com/146">https://terianp.tistory.com/146</a>)
(참고 : <a href="https://gnaseel.tistory.com/11">https://gnaseel.tistory.com/11</a>)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[S3 이미지 업로드]]></title>
            <link>https://velog.io/@boat_417/230814-S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C</link>
            <guid>https://velog.io/@boat_417/230814-S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C</guid>
            <pubDate>Mon, 14 Aug 2023 06:17:27 GMT</pubDate>
            <description><![CDATA[<h2 id="준비">준비</h2>
<h3 id="1-aws-설정">1. AWS 설정</h3>
<p>S3 버킷 생성 &gt; IAM 사용자 생성</p>
<h3 id="2-로컬-환경-설정">2. 로컬 환경 설정</h3>
<p>Spring Boot 연결</p>
<p><strong>build.gradle</strong></p>
<pre><code>//  aws s3
implementation &#39;org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE&#39;</code></pre><p><strong>application.properties</strong>
 : 배포하지 않을 예정이기 때문에 application.properties에 한꺼번에 적어둠
 : IAM 사용자의 access key를 발급받아 붙이기</p>
<pre><code>#S3
cloud.aws.credentials.accessKey={IAM-access-key}
cloud.aws.credentials.secretKey={IAM-secret-key}
cloud.aws.s3.bucket={bucket-name}
cloud.aws.region.static={region}
cloud.aws.s3.bucket.url=https://s3.ap-northeast-2.amazonaws.com/{bucket-name}
cloud.aws.stack.auto=false</code></pre><p><strong>AmazonS3Config.java</strong></p>
<pre><code>@Configuration
public class AmazonS3Config {

    @Value(&quot;${cloud.aws.credentials.accessKey}&quot;)
    private String accessKey;

    @Value(&quot;${cloud.aws.credentials.secretKey}&quot;)
    private String secretKey;

    @Value(&quot;${cloud.aws.region.static}&quot;)
    private String region;

    // S3 버킷에 접근할 수 있는 client 객체
    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);

        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }

}</code></pre><h2 id="구현">구현</h2>
<h3 id="업로드-기능">업로드 기능</h3>
<p>AwsS3FileUpload.java</p>
<pre><code>@Slf4j(topic = &quot;AwsS3Service&quot;)
@RequiredArgsConstructor
@Component
public class AwsS3FileUploadImpl implements AwsS3FileUpload {

    @Value(&quot;${cloud.aws.s3.bucket}&quot;)
    private String bucket;

    private final AmazonS3Client amazonS3Client;

    @Override
    public String upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -&gt; new IllegalArgumentException(&quot;MultipartFile -&gt; File로 전환이 실패했습니다.&quot;));

        // 현재 dirName 은 profile 만 받을 예정
        String fileName = dirName + &quot;/&quot; + uploadFile.getName();
        String uploadImageUrl = putS3(uploadFile, fileName);
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

    @Override
    public String putS3(File uploadFile, String fileName) {
        // bucket 권한때 설정한 것 중 하나
        amazonS3Client.putObject(
                new PutObjectRequest(bucket, fileName, uploadFile)
                        .withCannedAcl(CannedAccessControlList.PublicRead)
        );
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    @Override
    public void removeNewFile(File localSavedFile) {
        if (localSavedFile.delete()) {
            log.info(&quot;로컬에 저장된 파일 삭제&quot;);
        } else {
            log.info(&quot;로컬에 저장된 파일 삭제 실패&quot;);
        }
    }

    @Override
    public Optional&lt;File&gt; convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        log.info(&quot;convertFile = &quot; + convertFile);

        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }
}</code></pre><p>UserService.java</p>
<pre><code>    @Override
    public void uploadProfileImage(MultipartFile multipartFile, User user) throws IOException {
        fileUpload.upload(multipartFile, &quot;profile&quot;);
    }</code></pre><p>UserController.java</p>
<pre><code>    @PostMapping(&quot;/profileImg&quot;)
    public ResponseEntity&lt;ApiResponseDto&gt; uploadProfileImage(
            @RequestPart (required = false) MultipartFile multipartFile,
            @AuthenticationPrincipal UserDetailsImpl userDetails
            ) throws IOException {
        userService.uploadProfileImage(multipartFile, userDetails.getUser());
        return ResponseEntity.status(201).body(new ApiResponseDto(&quot;프로필 사진이 업로드 되었습니다.&quot;, HttpStatus.CREATED.value()));
    }</code></pre><p>프로필 이미지를 변경할 때 이미지 파일과 요청한 사용자 정보만 필요하기 때문에 위와 같이 작성함</p>
<h3 id="테스트">테스트</h3>
<p><img src="https://velog.velcdn.com/images/boat_417/post/563b463b-96b5-4f5e-949a-79eac49cdcc8/image.png" alt=""></p>
<p>API 에서 content type을 Image/{확장자명} 으로 설정해야함
-&gt; 설정하지 않는다면 415 Unsupported MediaType ERROR 발생</p>
<p><img src="https://velog.velcdn.com/images/boat_417/post/96286446-0740-4860-929d-a21fbb88e351/image.png" alt="">
profile 폴더에 들어간 것을 확인할 수 있음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[230813 GET 요청 데이터 전달 방식]]></title>
            <link>https://velog.io/@boat_417/230813-GET-%EC%9A%94%EC%B2%AD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%84%EB%8B%AC-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@boat_417/230813-GET-%EC%9A%94%EC%B2%AD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%84%EB%8B%AC-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Sun, 13 Aug 2023 12:00:16 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<h4 id="기존-코드">기존 코드</h4>
<p><strong>UserController.java</strong></p>
<pre><code>    @GetMapping(&quot;/search&quot;)
    public ResponseEntity&lt;SearchUserResponseDto&gt; searchUserByUsername(
            @RequestParam(&quot;keyword&quot;) String keyword
    ) {
        log.info(&quot;사용자 찾기 컨트롤러&quot;);
        SearchUserResponseDto result = userService.searchUserByUsername(new UserSearchCond(keyword));
        return ResponseEntity.ok().body(result);
    }</code></pre><p><strong>index.js</strong></p>
<pre><code>function clickSearchUserIcon() {
    console.log(&#39;사용자 검색 클릭&#39;);
    let userSearchKeyword = $(&#39;#searchKeywordInput&#39;).val();
    console.log(userSearchKeyword);

    if (userSearchKeyword.trim() === &#39;&#39;) {
        alert(&#39;검색어를 입력하세요&#39;);
        return;
    }

    let data = {
        keyword: userSearchKeyword
    };

    $.ajax({
        type: &quot;GET&quot;,
        url: `/api/users/search`,
        contentType: &quot;application/json&quot;,
        data: JSON.stringify(data),
        headers: {
            &#39;Authorization&#39;: document.cookie
        },
        success: function (response) {
            console.log(&#39;검색 요청 성공&#39;);
            console.log(response);
        },
        error: function(response) {
            console.log(&#39;검색 요청 실패&#39;);
            console.log(response);
        }
    })
}
</code></pre><h4 id="에러-메세지">에러 메세지</h4>
<pre><code>invalid character found in the request target
the valid characters are defined in rfc 7230 and rfc 3986</code></pre><p>에러메세지 안에 /api/users/search? 라고 뜨며 body에 보낸 data가 암호화 되어 보내졌다는 것을 알 수 있음</p>
<h3 id="해결">해결</h3>
<p><strong>UserController.java</strong></p>
<pre><code>    @GetMapping(&quot;/search&quot;)
    public ResponseEntity&lt;SearchUserResponseDto&gt; searchUserByUsername(
            @RequestParam(&quot;keyword&quot;) String keyword
    ) {
        log.info(&quot;사용자 찾기 컨트롤러&quot;);
        SearchUserResponseDto result = userService.searchUserByUsername(new UserSearchCond(keyword));
        return ResponseEntity.ok().body(result);
    }</code></pre><p><strong>index.js</strong></p>
<pre><code>$.ajax({
        type: &quot;GET&quot;,
        url: `/api/users/search?keyword=${userSearchKeyword}`,
        headers: {
            &#39;Authorization&#39;: document.cookie
        },
        success: function (response) {
            console.log(&#39;검색 요청 성공&#39;);
            console.log(response);
        },
        error: function (response) {
            console.log(&#39;검색 요청 실패&#39;);
            console.log(response);
        }
    })</code></pre><h3 id="원인">원인</h3>
<p>GET 요청은 간단한 데이터를 url에 넣도록 설계된 방식으로 데이터를 보내는 양에 한계가 있음
url 의 길이가 정해져있기 때문에 많은 양의 정보를 전달할 수 없음
url 형식에 맞지 않는 값은 인코딩되어 전달되어야 함</p>
<p>헤더의 내용 중 body의 데이터 타입을 설명하는 content-type 헤더필드도 들어가지 않음</p>
<p>따라서 필요한 데이터가 있다면 QueryParam 을 통해 전달</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[230810 javascript this, modal, thymeleaf]]></title>
            <link>https://velog.io/@boat_417/230810-javascript-this-modal-thymeleaf</link>
            <guid>https://velog.io/@boat_417/230810-javascript-this-modal-thymeleaf</guid>
            <pubDate>Thu, 10 Aug 2023 16:31:03 GMT</pubDate>
            <description><![CDATA[<h3 id="html-선택한-요소-내용물">html 선택한 요소 내용물</h3>
<p>html 파일 내 script</p>
<pre><code>&lt;li class=&quot;list-group-item&quot; id=&quot;${boardId}&quot;&gt;${boardName}&lt;/li&gt;</code></pre><pre><code>$(document).on(&quot;click&quot;, &quot;.list-group-item&quot;, function () {
        var clickedBoardId = $(this).attr(&#39;id&#39;);
        var clickedBoardName = $(this).text();

        console.log(&#39;Clicked Board ID:&#39;, clickedBoardId);
        console.log(&#39;Clicked Board Name:&#39;, clickedBoardName);
})</code></pre><p>java와 유사하게 this를 사용하면 선택한 요소의 id와 속의 text를 가져올 수 있다.</p>
<h3 id="thymeleaf">thymeleaf</h3>
<p>view controller</p>
<pre><code>    @GetMapping(&quot;/mypage&quot;)
    public String getMyPage(Model model, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        LoginUserProfileDto profileDto = new LoginUserProfileDto(userDetails.getUser());
        model.addAttribute(&quot;profile&quot;, profileDto);
        return &quot;mypage&quot;;
    }</code></pre><p>mypage.html</p>
<pre><code>&lt;h2&gt;&lt;span id=&quot;nickname&quot; th:text=&quot;${profile.nickname}&quot;&gt;${profile.nickname}&lt;/span&gt;의 페이지&lt;/h2&gt;
    &lt;p&gt;&lt;span id=&quot;username&quot; th:text=&quot;${profile.username}&quot;&gt;${profile.username}&lt;/span&gt;&lt;/p&gt;</code></pre><p>전에는 text만 model로 전달했었는데
이렇게 dto를 만들어 통째로 보내는 것도 가능하다.</p>
<p>현재 로그인 된 유저의 정보가 마이페이지에 반영될 수 있도록 했다.</p>
<h3 id="modal">modal</h3>
<p>script</p>
<pre><code>const changeNicknameButton = document.getElementById(&#39;changeNickname&#39;);
    const closeNicknameModalButton = document.getElementById(&#39;closeNicknameModal&#39;);
    const registerNicknameModal = document.getElementById(&#39;registerNicknameModal&#39;);
    const nicknameModalOverlay = document.getElementById(&#39;nicknameModalOverlay&#39;);

    changeNicknameButton.addEventListener(&#39;click&#39;, function () {
        registerNicknameModal.style.display = &#39;block&#39;;
        nicknameModalOverlay.style.display = &#39;block&#39;;
    });
    closeNicknameModalButton.addEventListener(&#39;click&#39;, function () {
        registerNicknameModal.style.display = &#39;none&#39;;
        nicknameModalOverlay.style.display = &#39;none&#39;;
    });</code></pre><p>modal은 &#39;display=none&#39;과 &#39;display=block&#39;으로 사용할 수 있다.
(none으로 시작)</p>
<h3 id="2중-ajax">2중 ajax</h3>
<pre><code>$.ajax({
            url: &#39;http://localhost:8080/api/boards/&#39; + clickedBoardId + &#39;/columns&#39;,
            type: &#39;GET&#39;,
            contentType: &#39;application/json&#39;,
            headers: {
                &#39;Authorization&#39;: document.cookie // 클라이언트 쿠키의 값을 전달
            },
            success: function (response) {
                console.log(response);
                let temp_htmls = &#39;&#39;;
                $(&#39;#listContainer&#39;).empty();

                response.forEach((a) =&gt; {
                    let columnId = a[&#39;id&#39;];
                    let columnTitle = a[&#39;title&#39;];


                    let temp_html = `
                        &lt;div class=&quot;list&quot; id=&quot;${columnId}&quot; draggable=&quot;true&quot;&gt;
                            &lt;div class=&quot;list-header&quot;&gt;${columnTitle}
                                   &lt;button onclick=&quot;DeleteColumnBtn(${columnId})&quot; style=&quot;float: right; margin-right: 5px;&quot; class=&quot;bi bi-trash3 fs-20&quot;&gt;&lt;/button&gt;
                                   &lt;button onclick=&quot;modifyColumnBtn(${columnId})&quot; style=&quot;float: right; margin-right: 5px;&quot; class=&quot;bi bi-pencil fs-20&quot;&gt;&lt;/button&gt;
                            &lt;/div&gt;

                            &lt;div class=&quot;card&quot; draggable=&quot;true&quot;&gt;Card 1&lt;/div&gt;
                            &lt;div class=&quot;card&quot; draggable=&quot;true&quot;&gt;Card 2&lt;/div&gt;
                        &lt;/div&gt;
                    `;

                    temp_htmls += temp_html;
                });
                console.log(&#39;forEach문 끝&#39;);
                $(&#39;#listContainer&#39;).append(temp_htmls);
            },
            error: function () {
                console.log(&#39;AJAX 요청 실패&#39;);
            }
        });</code></pre><p>column 속 card들도 붙여오려면 2중으로 ajax 요청을 보내야 할 것 같다.
아직 해결하지 못함.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Data Redis 사용, Refresh token, Access token]]></title>
            <link>https://velog.io/@boat_417/230808-Redis-refresh-token-%EC%B6%94%EA%B0%80</link>
            <guid>https://velog.io/@boat_417/230808-Redis-refresh-token-%EC%B6%94%EA%B0%80</guid>
            <pubDate>Tue, 08 Aug 2023 12:46:40 GMT</pubDate>
            <description><![CDATA[<h2 id="redis">Redis</h2>
<h3 id="설치">설치</h3>
<p>설치 후 
 -&gt; 작업 관리자 &gt; 서비스 탭에서 확인 가능
 만약 실행되지 않았다면 
 -&gt; 설치경로 &gt; redis-server.exe 파일 수동 실행</p>
<p>redis-cli.exe 통해 명령어 사용
(참고 : <a href="https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EA%B0%9C%EB%85%90-%EC%86%8C%EA%B0%9C-%EC%82%AC%EC%9A%A9%EC%B2%98-%EC%BA%90%EC%8B%9C-%EC%84%B8%EC%85%98-%ED%95%9C%EB%88%88%EC%97%90-%EC%8F%99-%EC%A0%95%EB%A6%AC?category=918728">https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EA%B0%9C%EB%85%90-%EC%86%8C%EA%B0%9C-%EC%82%AC%EC%9A%A9%EC%B2%98-%EC%BA%90%EC%8B%9C-%EC%84%B8%EC%85%98-%ED%95%9C%EB%88%88%EC%97%90-%EC%8F%99-%EC%A0%95%EB%A6%AC?category=918728</a>)
(참고 : <a href="https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-Window10-%ED%99%98%EA%B2%BD%EC%97%90-Redis-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0">https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-Window10-%ED%99%98%EA%B2%BD%EC%97%90-Redis-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0</a>)
(설치 : <a href="https://github.com/microsoftarchive/redis/releases">https://github.com/microsoftarchive/redis/releases</a>)</p>
<h3 id="spring-로그인">Spring 로그인</h3>
<p>(참고 : <a href="https://wildeveloperetrain.tistory.com/57">https://wildeveloperetrain.tistory.com/57</a>)</p>
<h3 id="redis-추가">Redis 추가</h3>
<p>RedisConfig.java 생성</p>
<pre><code>@Configuration
public class RedisConfig {

    @Value(&quot;${redis.host}&quot;)
    private String redisHost;

    @Value(&quot;${redis.port}&quot;)
    private int redisPort;

    // Redis 저장소와 연결
    @Bean
    public RedisConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    /***
     * Redis 서버와 통신
     * StringRedisTemplate를 사용하여 Key, value를 모두 문자열로 저장
     */
    @Bean
    public StringRedisTemplate redisTemplate() {
        final StringRedisTemplate redisTemplate = new StringRedisTemplate();

        // RedisTemplate을 사용할 때 Spring-Redis 간 데이터 직렬화/역직렬화 시 사용하는 방식이 jdk 직렬화 방식
        // 동작에는 문제가 없지만 redis-cli를 통해 데이터를 확인할 때 알아볼 수 없는 형태로 출력되기 때문
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        redisTemplate.setConnectionFactory(connectionFactory());
        return redisTemplate;
    }
}</code></pre><p>(참고 : <a href="https://wildeveloperetrain.tistory.com/59">https://wildeveloperetrain.tistory.com/59</a>)</p>
<pre><code>@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String accessToken = jwtUtil.getJwtFromCookie(request);

        if (StringUtils.hasText(accessToken)) {
            accessToken = jwtUtil.substringToken(accessToken);
            log.info(&quot;액세스 토큰 값 : &quot; + accessToken);

            &lt;&lt;시도 2. 요청을 받을 때마다 새로운 토큰 발급&gt;&gt;

            if (!jwtUtil.validateToken(accessToken)) {
                log.info(&quot;액세스 토큰 유효하지 않음&quot;);

                &lt;&lt;시도 1. 만료됐다면 새로운 토큰 발급&gt;&gt;

                return;
            }

            log.info(&quot;body의 사용자 정보 꺼내기&quot;);
            Claims info = jwtUtil.getUserInfoFromToken(accessToken);

            try {
                // token 생성 시 subject에 username 넣어둠
                log.info(info.getSubject());
                setAuthentication(info.getSubject());
            } catch (Exception e) {
                return;
            }
        }

        filterChain.doFilter(request, response);
    }</code></pre><h3 id="문제">문제</h3>
<h3 id="시도">시도</h3>
<ol>
<li>토큰이 만료됐다면 새로운 access token 발급</li>
</ol>
<p>-&gt; 요청을 2번 보내야 원하는 작업 할 수 있음
(1번 : 새로운 토큰 발급/ 2번 : 새로운 토큰으로 작업)</p>
<ol start="2">
<li>요청이 들어올 때마다 새로운 access token 발급
(1.의 문제 해결)</li>
</ol>
<p><strong>JwtAuthorizationFilter.java</strong>의 doFilterInternal</p>
<pre><code> @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String accessToken = jwtUtil.getJwtFromCookie(request);

        if (StringUtils.hasText(accessToken)) {
            accessToken = jwtUtil.substringToken(accessToken);
            log.info(&quot;액세스 토큰 값 : &quot; + accessToken);

            String newAccessToken = jwtUtil.reissueAccessToken(accessToken);
            if (newAccessToken != null) {
                jwtUtil.addJwtToCookie(newAccessToken, response);
            }

            if (!jwtUtil.validateToken(accessToken)) {
                log.info(&quot;액세스 토큰 유효하지 않음&quot;);
                return;
            }

            log.info(&quot;body의 사용자 정보 꺼내기&quot;);
            Claims info = jwtUtil.getUserInfoFromToken(accessToken);

            try {
                // token 생성 시 subject에 username 넣어둠
                log.info(info.getSubject());
                setAuthentication(info.getSubject());
            } catch (Exception e) {
                log.info(&quot;오류 발생&quot;);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Redis Jedis vs Lettuce]]></title>
            <link>https://velog.io/@boat_417/230807-Bean-%EC%88%98%EB%8F%99-%EB%93%B1%EB%A1%9D</link>
            <guid>https://velog.io/@boat_417/230807-Bean-%EC%88%98%EB%8F%99-%EB%93%B1%EB%A1%9D</guid>
            <pubDate>Mon, 07 Aug 2023 12:15:21 GMT</pubDate>
            <description><![CDATA[<h2 id="jedis">Jedis</h2>
<ul>
<li>다른 Redis Java 클라이언트에 비해 가벼움</li>
<li>적은 기능을 제공하지만 많은 양의 메모리 처리 가능</li>
<li>동기 통신 지원</li>
<li>Jedis pool 이용하면 멀티 스레드 환경에서 동작 가능 (모든 풀이 사용 중일 경우 요청과 응답 사이 블로킹이 발생해 어느 정도의 유휴상태에 빠질 수 있음)
  -&gt; 동기적 연결만 지원한다면 트래픽이 몰렸을 때 병목현상 발생할 수 있음
(<a href="https://github.com/redis/jedis">https://github.com/redis/jedis</a>)
(<a href="https://github.com/spring-projects/spring-session/issues/789">https://github.com/spring-projects/spring-session/issues/789</a>)</li>
</ul>
<h2 id="lettuce">Lettuce</h2>
<ul>
<li>확장성 높음, Spring 2.0 부터 기본 Redis가 jedis -&gt; lettuce로 변경</li>
<li>non-blocking 기능</li>
<li>동기, 비동기, 리액티브 프로그래밍 모델 지원</li>
<li>cluster, sentinel, pipelining, codecs 지원</li>
<li>다중 연결 처리</li>
<li>Jedis에 비해 사용이 어려움
(<a href="https://lettuce.io/core/release/reference/">https://lettuce.io/core/release/reference/</a>)</li>
</ul>
<h3 id="기능">기능</h3>
<ul>
<li><p>Cluster
  Distributed Redis Setup 과 상호작용 할 수 있는 api set 제공
  클러스터 노드 자동으로 발견
  토폴로지 변경 처리
  적절한 노드로 명령 라우팅 가능</p>
</li>
<li><p>Sentinel
  Redis 설정에 문제가 발생했을 때 모니터링, 알람, 자동 페일 오버 제공
  분산 환경에서 Redis와 함께 동작
  Redis 전체적인 상태와 신뢰성 유지</p>
</li>
<li><p>Codecs
  레디스에 데이터가 어떤 형식으로 저장될지 결정하는 직렬화, 역직렬화 기법 선택 가능</p>
</li>
<li><p>Pipeline
  여러개의 요청을 pipeline에 담아 한 번에 요청
  (<a href="https://redis.io/docs/manual/pipelining/">https://redis.io/docs/manual/pipelining/</a>)</p>
</li>
<li><p>Auto-reconnect and timeout handling
  어플리케이션의 연속성 보장
  레디스가 한 작업을 오래 잡고 있는 것 방지</p>
</li>
</ul>
<h3 id="spring에서-사용">Spring에서 사용</h3>
<p>(<a href="https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/">https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/</a>)</p>
<h3 id="lettuce-선택-이유">lettuce 선택 이유</h3>
<ul>
<li>TPS/CPU/Connection 개수/응답속도 등 성능 jedis &lt; lettuce</li>
<li>확장성이 높음</li>
</ul>
<p>(<a href="https://jojoldu.tistory.com/418">https://jojoldu.tistory.com/418</a>)
(<a href="https://gist.github.com/warrenzhu25/1beb02a09b6afd41dff2c27c53918ce7#why-lettuce">https://gist.github.com/warrenzhu25/1beb02a09b6afd41dff2c27c53918ce7#why-lettuce</a>)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[QueryDSL]]></title>
            <link>https://velog.io/@boat_417/230803-Consider-defining-a-bean-of-type-com.querydsl.jpa.impl.JPAQueryFactory-in-your-configuration</link>
            <guid>https://velog.io/@boat_417/230803-Consider-defining-a-bean-of-type-com.querydsl.jpa.impl.JPAQueryFactory-in-your-configuration</guid>
            <pubDate>Thu, 03 Aug 2023 07:02:50 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[230802 테스트 코드 결과 status 415]]></title>
            <link>https://velog.io/@boat_417/230802-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EA%B2%B0%EA%B3%BC-status-415</link>
            <guid>https://velog.io/@boat_417/230802-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EA%B2%B0%EA%B3%BC-status-415</guid>
            <pubDate>Thu, 03 Aug 2023 06:54:04 GMT</pubDate>
            <description><![CDATA[<h2 id="controller-테스트-코드">Controller 테스트 코드</h2>
<p><img src="https://velog.velcdn.com/images/boat_417/post/c4e81120-7d74-400c-8549-cde3ab89a204/image.png" alt=""></p>
<p>메모를 생성하는 기능을 테스트하기 위한 코드
status code 201로 기대했지만 415가 반환</p>
<pre><code>@PostMapping(&quot;&quot;)
    public ResponseEntity&lt;MemoResponseDto&gt; createMemo(@RequestBody MemoRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        MemoResponseDto result = memoService.createMemo(requestDto, userDetails.getUser());
        return ResponseEntity.status(201).body(result);
    }</code></pre><h3 id="시도">시도</h3>
<pre><code>// when - then
        mvc.perform(post(&quot;/api/memos&quot;)
                        .content(memoInfo)
                        .contentType(MediaType.APPLICATION_JSON)
                        .principal(mockPrincipal))
                .andExpect(status().isCreated());    // 415 나옴</code></pre><pre><code>jakarta.servlet.ServletException: Request processing failed: org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class com.example.springmemoreview.memo.dto.MemoRequestDto]</code></pre><p>accept를 contentType으로 바꿔봤더니
이전과는 다른 type definition error가 발생</p>
<ul>
<li>contentType : 요청과 응답 모두 보낼 데이터의 형식을 알려주는 헤더</li>
<li>accept : 클라이언트에서 서버로 요청시 요청메세지에 담기는 헤더</li>
</ul>
<pre><code>@Test
    @DisplayName(&quot;컨트롤러 - 메모 불러오기&quot;)
    void getMemosTest() throws Exception {
        mvc.perform(get(&quot;/api/memos&quot;)
                        .accept(MediaType.APPLICATION_JSON)
                )
                .andExpect(status().isOk());
    }</code></pre><p>같은 컨트롤러 안에 반환타입이 같은 get 요청의 테스트는 통과하는 것으로 보아 post로 보낸 내용에 문제가 있는 듯 함</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[230727 JUnit Test 실행 안됨]]></title>
            <link>https://velog.io/@boat_417/230727-JUnit-Test-%EC%8B%A4%ED%96%89-%EC%95%88%EB%90%A8</link>
            <guid>https://velog.io/@boat_417/230727-JUnit-Test-%EC%8B%A4%ED%96%89-%EC%95%88%EB%90%A8</guid>
            <pubDate>Thu, 27 Jul 2023 10:07:50 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p>Junit Test 실행 불가능</p>
<pre><code>InitializationError
Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...)</code></pre><pre><code>debug org.springframework.boot.test.autoconfigure.jdbc.jdbctestcontextbootstrapper - neither @contextconfiguration nor @contexthierarchy found for test class [jdbctemplatetest]: using springbootcontextloader</code></pre><p><img src="https://velog.velcdn.com/images/boat_417/post/4c6c544b-022d-42be-9a34-15cb96138dda/image.png" alt=""></p>
<h3 id="원인">원인</h3>
<ol>
<li><p>Application 패키지 구조와 Test 패키지 구조가 다른 경우
(application context가 따라가지 못함)</p>
</li>
<li><p>@SpringBootApplication으로 선언된 Class명과 Test Class명이 다른 경우</p>
</li>
</ol>
<h3 id="해결">해결</h3>
<ol>
<li><p>패키지 구조를 맞추기 
<img src="https://velog.velcdn.com/images/boat_417/post/732d9611-c35e-4ac1-a3f3-ae6411bea16c/image.png" alt="">
application configuration 정보가 넘어옴</p>
</li>
<li><p>테스트 클래스에 아래의 어노테이션 추가</p>
<pre><code>@ContextConfiguration(classes = Application.class)</code></pre><p>추가 후</p>
<pre><code>@JdbcTest // Jdbc Slice Test
@ContextConfiguration(classes = Application.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 테스트용 DB 쓰지 않도록
@Rollback(value = false) // Transactional 에 있는 테스트 변경은 기본적으론 롤백 하도록 되어있다.
public class JDBCTemplateTest </code></pre></li>
</ol>
<p>나의 경우는 1번만으로 해결</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[230726 PasswordEncoder.matches()]]></title>
            <link>https://velog.io/@boat_417/230726-PasswordEncoder.matches</link>
            <guid>https://velog.io/@boat_417/230726-PasswordEncoder.matches</guid>
            <pubDate>Wed, 26 Jul 2023 11:48:45 GMT</pubDate>
            <description><![CDATA[<h2 id="passwordencodermatches">PasswordEncoder.matches()</h2>
<h3 id="문제">문제</h3>
<p><img src="https://velog.velcdn.com/images/boat_417/post/2b9e7ea4-27da-4405-b7a0-b49608a3c7f1/image.png" alt=""></p>
<pre><code>String password = passwordEncoder.encode(requestDto.getPassword());
String confirmPassword = passwordEncoder.encode(requestDto.getConfirmPassword());

if(!passwordEncoder.matches(password, confirmPassword)) {
      throw new IllegalArgumentException(&quot;비밀번호를 다시 확인해주세요.&quot;);
}</code></pre><h3 id="시도">시도</h3>
<ol>
<li>둘 다 암호화 되어있어서 받아온 password 값과 confirmPassword 값은 항상 다를 수 밖에 없다는 것을 깨달았다.<pre><code>String confirmPassword = requestDto.getConfirmPassword();</code></pre>confirmPassword는 저장할 데이터가 아니기 때문에 이 값을 암호화하지 않은 채로 가져왔다.</li>
</ol>
<p>하지만 여전히 matches 함수는 false값이 나왔다.</p>
<ol start="2">
<li>PasswordEncoder.matches()는 Spring Security Java에서 제공하는 메서드이다.
이 함수는 암호화 된 비밀번호와 평문의 비밀번호가 일치하는지 확인할 때 사용된다.</li>
</ol>
<pre><code>boolean matches(CharSequence rawPassword, String encodedPassword);</code></pre><ul>
<li>rawPassword : 평문</li>
<li>encodedPassword : 암호화된 비밀번호</li>
</ul>
<p>matches() 메서드의 첫 번째 인자는 평문이어야 한다.</p>
<h3 id="해결">해결</h3>
<pre><code>String password = passwordEncoder.encode(requestDto.getPassword());
String confirmPassword = requestDto.getConfirmPassword();

if(!passwordEncoder.matches(confirmPassword, password)) {
      throw new IllegalArgumentException(&quot;비밀번호를 다시 확인해주세요.&quot;);
}</code></pre><p>confirmPassword는 requestDto에서 평문 상태 그대로 가져오고 matches 메서드의 첫 번째 인자로 넣었다.</p>
<h3 id="알게된-점">알게된 점</h3>
<p>PasswordEncoder 인터페이스에는 </p>
<pre><code>String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }</code></pre><p>세 가지 메서드가 있다.</p>
<p>matches 메서드에는 평문 비밀번호, 암호화된 비밀번호 순서대로 들어가야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[230725 DTO, DAO, VO]]></title>
            <link>https://velog.io/@boat_417/230725</link>
            <guid>https://velog.io/@boat_417/230725</guid>
            <pubDate>Tue, 25 Jul 2023 12:04:08 GMT</pubDate>
            <description><![CDATA[<h2 id="dto-data-transfer-object">DTO (Data Transfer Object)</h2>
<p>: 계층간 데이터 교환을 위해 사용하는 객체
: 로직을 가지지 않음 (getter, setter, toString, equals 등 작성 가능)</p>
<h2 id="dao-data-access-object">DAO (Data Access Object)</h2>
<p>: 데이터베이스의 데이터에 접근하기 위한 객체
: 데이터베이스에 접근하기 위한 로직, 비즈니스 로직을 분리하기 위해 사용</p>
<p>만약 유저가 입력한 데이터를 db에 저장하려는 상황
-&gt; 유저가 입력한 데이터를 dto를 통해 받음
-&gt; 서버는 dto를 받아 dao를 이용해 데이터베이스에 저장</p>
<h2 id="vo-value-object">VO (Value Object)</h2>
<p>: read-only (setter x)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[230724 Thymeleaf]]></title>
            <link>https://velog.io/@boat_417/230724-Thymeleaf</link>
            <guid>https://velog.io/@boat_417/230724-Thymeleaf</guid>
            <pubDate>Mon, 24 Jul 2023 11:31:27 GMT</pubDate>
            <description><![CDATA[<h2 id="template-engine">Template Engine</h2>
<ul>
<li><p>서버에서 데이터를 보내 웹 서비스를 만드는 방법</p>
<ul>
<li>Single Page Application </li>
<li>Server Side Rendering</li>
</ul>
</li>
<li><p>HTML과 데이터를 결합해 만들 수 있도록 도와주는 도구</p>
</li>
</ul>
<h3 id="thymeleaf">Thymeleaf</h3>
<p>build.gradle에 dependency 추가</p>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-thymeleaf&#39;</code></pre><p>html 태그에 추가</p>
<pre><code>&lt;html lang=&quot;ko&quot; xmlns:th=&quot;http://www.thymeleaf.org&quot;</code></pre><ul>
<li><p>xmlns:th
: 타임리프의 th 속성을 사용하기 위해 선언된 네임스페이스
: 순수 HTML로만 이루어진 페이지의 경우 선언하지 않아도 됨</p>
</li>
<li><p>th:text
: JSP의 EL 표현식인 ${}처럼 ${} 표현식을 사용해서 컨트롤러에서 전달받은 데이터에 접근
: 일반 텍스트 형식으로 출력됨</p>
</li>
<li><p>th:fragment
: &lt; head&gt; 태그에 해당 속성을 사용해 fragment의 이름 지정
: fragment는 다른 HTML에서 include 또는 replace 속성을 사용해 적용할 수 있음</p>
</li>
<li><p>th:href
: &lt; a&gt; 태그의 href 속성과 동일
: 웹 어플리케이션을 구분하는 context path를 포함</p>
</li>
<li><p>th:action
: &lt; form&gt; 태그 사용시 해당 경로로 요청 보내기</p>
</li>
<li><p>th:object
: &lt; form&gt; 태그에서 submit을 할 때 데이터가 th:object에 설정해둔 객체로 받아짐
: 컨트롤러와 뷰 사이의 dto 클래스 객체</p>
<pre><code>&lt;form class=&quot;form-horizontal&quot; th:action=&quot;@{/board/register.do}&quot; th:object=&quot;${board}&quot; method=&quot;post&quot;&gt;</code></pre></li>
<li><p>th:field
: th:object 속성을 이용하면 th:field를 이용해 HTML 태그에 멤버 변수를 매핑할 수 있음
: th:field를 이용한 사용자 입력 필드(input, textarea 등)는 id, name, value 속성 값 자동 매핑됨
: ${} 표현식이 아닌 *{} 표현식 사용
: th:object와 th:field는 컨트롤러에서 특정 클래스의 객체를 전달받은 경우에만 사용 가능</p>
</li>
<li><p>th:checked
: 체크박스, 조건이 true면 체크</p>
</li>
<li><p>th:inline=&quot;javascript&quot;
: &lt; script&gt; 태그에 th:inline 속성을 javascript로 지정해야 javascript 사용 가능</p>
</li>
</ul>
<p>===log town 팀 프로젝트===
HomeController.java</p>
<pre><code> @GetMapping(&quot;/home/onepost/{postId}&quot;)
    public String getOnePost(@PathVariable Long postId, Model model) {
        model.addAttribute(&quot;postId&quot;, postId);

        return &quot;onepost&quot;;
    }</code></pre><p>onepost.html</p>
<pre><code>th:text=&quot;${postId}</code></pre><p>이번에는 postId만 넘겨줬지만 다음에는 더 효율적인 방법으로 구현할 수 있는지 알아봐야겠다.</p>
]]></description>
        </item>
    </channel>
</rss>