타임리프와 웹소켓을 이용해서 채팅기능을 구현해야 했다.

그 과정에서 나온 각종 버그들과 해결 방법을 적어보려고 한다.

 

1. 웹소켓 설정 작성하기

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");  // 메모리 기반의 메시지 브로커를 사용
    }
}
  1. registry.addEndpoint("/ws"): 이 부분은 클라이언트가 서버에 웹소켓 연결을 시도할 수 있는 경로를 정의합니다. 여기서는 "/ws"라는 경로를 사용하고 있습니다. 즉, 클라이언트는 이 경로를 통해 웹소켓 연결을 시도할 수 있습니다.
    1. registry.setApplicationDestinationPrefixes("/app");: 이 코드는 클라이언트가 서버로 메시지를 보낼 때 사용할 수 있는 목적지(prefix)의 접두어를 설정합니다.
      여기서 설정된 "/app" 접두어는 클라이언트가 메시지를 보낼 때 해당 메시지가 어플리케이션 내부의 어떤 핸들러로 라우팅되어야 하는지를 결정하는 데 사용됩니다. 예를 들어, 클라이언트가 "/app/message"로 메시지를 보내면, 이 메시지는 "/message"에 해당하는 어플리케이션의 핸들러로 라우팅됩니다. 이를 통해 어플리케이션 내부에서 메시지를 처리하는 데 필요한 라우팅 로직을 구성할 수 있습니다.
    2. registry.enableSimpleBroker("/topic");: 이 코드는 간단한 메모리 기반의 메시지 브로커를 활성화합니다. "/topic"이라는 접두어는 이 메시지 브로커가 처리할 수 있는 목적지 메시지의 접두어로 설정됩니다. 클라이언트가 이 접두어("/topic")를 사용하여 메시지를 구독(subscribe)하면, 메시지 브로커는 해당 목적지로 메시지가 발행(publish)될 때마다 구독자에게 메시지를 전달합니다.

      예를 들어, 서버가 "/topic/news"에 메시지를 발행하면, "/topic/news"를 구독하고 있는 모든 클라이언트는 그 메시지를 받게 됩니다. 이 메모리 기반의 간단한 메시지 브로커는 복잡한 설정 없이도 메시지의 라우팅과 전달을 처리할 수 있게 해주지만, 대규모 분산 시스템에서는 더 고급 메시지 브로커가 필요할 수 있습니다.

 

2. 도메인설정하기

1. 멤버 엔티티

@Entity
@Table(name = "member")
@Data
public class Member {
    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    @Column(name = "name")
    private String name;

    @Column(name = "email")
    private String email;
}

 

2.대화 엔티티

@Entity
@Table(name = "conversation")
@Data
@RequiredArgsConstructor
public class Conversation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "conversation_id")
    private Long conversationId;

    @ManyToMany
    @JoinTable(
            name = "conversation_members",
            joinColumns = @JoinColumn(name = "conversation_id"),
            inverseJoinColumns = @JoinColumn(name = "member_id")
    )
    private Set<Member> members;

    // 기본 생성자, 게터, 세터 생략
}

 

3. 메시지 엔티티

@Entity
@Table(name = "message")
@Data
@RequiredArgsConstructor
public class Message {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "message_id")
    private Long messageId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "conversation_id", nullable = false)
    private Conversation conversation;

    @ManyToOne
    @JoinColumn(name = "sender_id", nullable = false)
    private Member sender;

    @Column(name = "content", nullable = false)
    private String content;

    @Column(name = "timestamp", nullable = false)
    private Long timestamp; // UNIX 타임스탬프 사용

    // 기본 생성자, 게터, 세터 생략
}

 

4.채팅메시지 엔티티

3. DTO 만들기

@Data
@NoArgsConstructor
public class MessageDTO {
    private String content;
    private Long conversationId;
    private Long senderId; // sender의 타입을 Long으로 가정합니다.

    // 기본 생성자, 게터, 세터 생략
}

 

타임리프단에서 서버로 메시지를 날려줄때 컨트롤러에서 인식을 못하는 오류가 있었다.


그래서 DTO를 사용해서 타임리프에서 보내는 메시지 객체를 저장하고

이 DTO를 이용해서 메시지를 만들어줬다.

DTO를 왜 사용하는지 알게 된 부분

 

4. 리포지토리 만들기

1. 대화 리포지토리

@Repository
public interface ConversationRepository extends JpaRepository<Conversation, Long> {
    List<Conversation> findByMembers_MemberId(Long memberId);
}

 

2.멤버 리포지토리

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}

3. 메시지 리포지토리

@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
    List<Message> findByConversation_ConversationId(Long conversationId);
}

 

5. 채팅 컨트롤러

@Controller
public class ChatController {

    @Autowired
    private MessagingService messagingService;

    private final SimpMessagingTemplate messagingTemplate;

    @Autowired
    public ChatController(MessagingService messagingService, SimpMessagingTemplate messagingTemplate) {
        this.messagingService = messagingService;
        this.messagingTemplate = messagingTemplate;
    }

    // memberId를 통해 특정 사용자의 모든 대화 목록을 보여주는 페이지를 반환합니다.
    @GetMapping("/chat/{memberId}")
    public String getAllChatsForUser(@PathVariable Long memberId, Model model) {
        model.addAttribute("conversations", messagingService.getAllConversationsByMemberId(memberId));
        model.addAttribute("memberId", memberId);
        return "chat";
    }

    // 선택된 대화에 대한 대화창을 보여주는 페이지를 반환합니다.
    @GetMapping("/chat/{memberId}/{conversationId}")
    public String getConversation(@PathVariable Long memberId, @PathVariable Long conversationId, Model model) {
        Conversation selectedConversation = messagingService.getConversation(conversationId);
        if (selectedConversation != null && selectedConversation.getMembers().stream().anyMatch(member -> member.getMemberId().equals(memberId))) {
            model.addAttribute("selectedConversation", selectedConversation);
            model.addAttribute("messages", messagingService.getMessagesByConversation(conversationId));
            model.addAttribute("memberId", memberId);
            return "conversation";
        }
        return "redirect:/chat/" + memberId;
    }

    // 클라이언트로부터 메시지를 받아 다른 클라이언트에게 전송합니다.
    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/messages")
    @Transactional
    public Message sendMessage(MessageDTO messageDTO) {
        Conversation conversation = messagingService.getConversation(messageDTO.getConversationId());
        Member sender = messagingService.getMember(messageDTO.getSenderId()); // getMember 메서드는 회원의 ID를 기반으로 회원 정보를 조회하는 메서드입니다.

        if (conversation == null || sender == null) {
            throw new IllegalStateException("No conversation or sender found with the given ID.");
        }

        Message message = new Message();
        message.setContent(messageDTO.getContent());
        message.setConversation(conversation);
        message.setSender(sender);
        message.setTimestamp(System.currentTimeMillis());
        // timestamp 설정 등 필요한 추가 설정

        // 메시지 저장
        Message savedMessage = messagingService.sendMessage(conversation.getConversationId(), message);

        String destination = "/topic/messages/" + message.getConversation().getConversationId();
        // 구독자에게 메시지 전송
        messagingTemplate.convertAndSend(destination, message);

        return savedMessage;
    }

//    // 웹소켓 구독자에게 메시지를 보냅니다. (예시: 새 메시지 알림)
//    public void sendMessagesToSubscribers(Message message) {
//        Message savedMessage = messagingService.sendMessage(message.getConversation().getConversationId(), message);
//        messagingTemplate.convertAndSend("/topic/messages", savedMessage);
//    }
}

 

로그인 기능을 구현하지 않아서 멤버, 채팅방을 path를 통해 구분했다.

 

나머지 기능들은 JPA+타임리프의 기능과 똑같은데, 이제 아래에 웹소켓을 사용하는 기능을 추가 설명해보려고 한다.

 

메시지가 오면 sendMessage 메소드가 호출이 되고,

원래는 메시지 DTO가 아니라 그냥 MESSAGE 객체를 받았었는데

메시지 객체를 타임리프에서 보내 줄 때 문제가 발생해서 CONTENT빼고 나머지 내용이 빠져있는 문제점이 있었다.

그래서 DTO를 써서 받아줬다.

 

그리고 메시지 객체를 생성해서 보내는데,

여기서 sendmessage 메소드는 사실 그냥 메시지를 데이터베이스에 저장하는 메소드이다.

우리가 생각하는 메시지를 보내는 기능은 

 

messagingTemplate.convertAndSend(destination, message);

이 코드를 통해 이뤄지는데

desination을 채팅방 경로로 수정하지않아서 데이터베이스에 저장만 되고 구독이 되지않는 문제점이 있었다.

 

String destination = "/topic/messages/" + message.getConversation().getConversationId();

 그래서 이런식으로 데스티네이션을 설정해줘서 채팅방 멤버들이 채팅방 내용만 구독할 수 있게 만들어줬다.

 

그리고 데이터 영속성 문제때문인지, 자꾸 LazyInitializationException이 발생했는데

그냥 sendMessage에 @Transactional을 붙여주니까 해결이 됐다.

 

6. 메세지 서비스

@Service
@Transactional
public class MessagingService {

    @Autowired
    private ConversationRepository conversationRepository;

    @Autowired
    private MessageRepository messageRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Transactional
    public Conversation createConversation(Conversation conversation) {
        return conversationRepository.save(conversation);
    }

    @Transactional
    public Message sendMessage(Long conversationId, Message message) {
        Conversation conversation = conversationRepository.findById(conversationId)
                .orElseThrow(() -> new EntityNotFoundException("Conversation not found with id: " + conversationId));

        // Associate the existing conversation with the message.
        message.setConversation(conversation);

        // Save the message with the associated conversation.
        return messageRepository.save(message);
    }

    public List<Message> getMessagesByConversation(Long conversationId) {
        return messageRepository.findByConversation_ConversationId(conversationId);
    }

    // 모든 대화 목록을 반환하는 메소드
    public List<Conversation> getAllConversations() {
        return conversationRepository.findAll();
    }

    // 특정 대화를 반환하는 메소드
    public Conversation getConversation(Long conversationId) {
        Optional<Conversation> conversation = conversationRepository.findById(conversationId);
        if(conversation.isPresent()) {
            return conversation.get();
        } else {
            // 적절한 예외 처리나 대체 로직을 수행합니다.
            throw new RuntimeException("Conversation not found!");
        }
    }

    // memberId에 따라 대화 목록을 가져오는 메서드
    public List<Conversation> getAllConversationsByMemberId(Long memberId) {
        return conversationRepository.findByMembers_MemberId(memberId);
    }

    public Member getMember(Long memberId) {
        return memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException("Member not found with id: " + memberId));
    }
}

 

 

7. 타임리프 코드

1.chat.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Chat Application</title>
    <link rel="stylesheet" type="text/css" th:href="@{/css/chat.css}">
    <script th:src="@{/webjars/jquery/jquery.min.js}"></script>
    <script th:src="@{/webjars/sockjs-client/sockjs.min.js}"></script>
    <script th:src="@{/webjars/stomp-websocket/stomp.min.js}"></script>
</head>
<body>

<div class="chat-container">
    <div class="chat-sidebar">
        <!-- 사용자의 대화 목록 -->
        <div class="chat-list">
            <ul>
                <li th:each="conversation : ${conversations}"
                    th:text="${#strings.listJoin(conversation.members.![name], ', ')}"
                    th:data-convo-id="${conversation.conversationId}"
                    th:onclick="'window.location.href=\'' + @{/chat/{memberId}/{conversationId}(memberId=${memberId}, conversationId=${conversation.conversationId})} + '\''">
                </li>
            </ul>
        </div>
    </div>
    <div class="chat-main">
        <!-- 대화 내용 -->
        <div class="chat-header">
            <h3 id="conversationName">Select a chat</h3>
        </div>
        <div class="chat-messages" id="chatWindow">
            <!-- 메시지들이 여기에 표시됩니다. -->
        </div>
        <div class="chat-footer">
            <input type="text" id="messageInput" placeholder="Type a message...">
            <button onclick="sendMessage()">Send</button>
        </div>
    </div>
</div>

<script th:inline="javascript">
    /*<![CDATA[*/
    var stompClient = null;
    var selectedConversationId = null;

    function openConversation(element) {
        var convoId = element.getAttribute('data-convo-id');
        selectedConversationId = convoId;
        $('#conversationName').text(element.textContent);
        $('#chatWindow').empty(); // 이전 대화 내용을 지웁니다.

        if(stompClient) {
            stompClient.disconnect();
        }

        connectToChat(convoId);
    }

    function connectToChat(convoId) {
        var socket = new SockJS('/ws');
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function(frame) {
            console.log('Connected: ' + frame);
            stompClient.subscribe('/topic/messages/' + convoId, function(message) {
                showMessage(JSON.parse(message.body));
            });
        });
    }

    function sendMessage() {
        var messageContent = $('#messageInput').val().trim();
        if(messageContent && selectedConversationId) {
            var chatMessage = {
                content: messageContent,
                conversationId: selectedConversationId
            };
            stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
            $('#messageInput').val('');
        }
    }

    function showMessage(message) {
        $('#chatWindow').append('<div class="message">' + message.content + '</div>');
    }
    /*]]>*/
</script>

</body>
</html>

 

사실 내가 프론트엔드쪽은 아예 몰라서 chatgpt로 많이 구현했다.

그래서 버그 잡기도 굉장히 힘들었는데, 일단 동적페이징을 생각했으나, 생각대로 잘 작동하지 않았다

프론트엔드 디버깅을 하려면

F12눌러서 개발자도구를 띄워서 콘솔창을 띄우는 것 부터 시작이다.

 

사실 아래쪽에 있는 sendmessage나 connectTochat은 동적프로그래밍을 구현하려고 한 흔적이다.

작동하지 않아서 애를 먹었다. 지우기도 뭐해서 그냥 내버려뒀다.

 

1.

<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/sockjs-client/sockjs.min.js}"></script>
<script th:src="@{/webjars/stomp-websocket/stomp.min.js}"></script>

이 스크립트들을 사용하려먼 의존성 주입이 필요했다.

 

//webjars
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:stomp-websocket:2.3.3'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'

 

2. conversation.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Chat Conversation</title>
  <link rel="stylesheet" type="text/css" th:href="@{/css/chat.css}">
  <script th:src="@{/webjars/jquery/jquery.min.js}"></script>
  <script th:src="@{/webjars/sockjs-client/sockjs.min.js}"></script>
  <script th:src="@{/webjars/stomp-websocket/stomp.min.js}"></script>
</head>
<body>

<div class="chat-container">
  <div class="chat-header">
    <h3>Chat Room</h3>
  </div>
  <div class="chat-messages" id="chatWindow">
    <div th:each="message : ${messages}" class="message">
      <p th:text="${message.sender.name + ': ' + message.content}"></p>
    </div>
  </div>
  <div class="chat-footer">
    <input type="text" id="messageInput" placeholder="Type a message...">
    <button onclick="sendMessage()">Send</button>
  </div>
</div>

<script th:inline="javascript">
  /*<![CDATA[*/
  var stompClient = null;
  var selectedConversationId = /*[[${selectedConversation.conversationId}]]*/ 'default';
  // URL에서 senderId 추출
  var senderId = window.location.pathname.split('/')[2];

  $(document).ready(function() {
    connectToChat();
  });

  function connectToChat() {
    var socket = new SockJS('/ws');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
      console.log('Connected: ' + frame);
      stompClient.subscribe('/topic/messages/' + selectedConversationId, function(message) {
        showMessage(JSON.parse(message.body));
      });
    });
  }

  function sendMessage() {
    var messageContent = $('#messageInput').val().trim();
    if(messageContent && selectedConversationId) {
      var chatMessage = {
        content: messageContent,
        conversationId: selectedConversationId,
        senderId: parseInt(senderId) // sender 정보 추가
      };
      stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
      $('#messageInput').val('');
    }
  }

  function showMessage(message) {
    $('#chatWindow').append('<div class="message">' + message.sender.name + ': ' + message.content + '</div>');
  }
  /*]]>*/
</script>

</body>
</html>

 

여기서 내가 설명할 수 있는 부분은 많이 없는데

내가 찾은걸 설명하자면

sendMessage 함수 부분에서 보낼때

변수명과 아까 DTO의 변수명을 맞춰줘야 한다.

그래야만 인식해서 DTO안으로 들어간다.

쿠키

@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult
bindingResult, HttpServletResponse response) {
 if (bindingResult.hasErrors()) {
 return "login/loginForm";
 }
 Member loginMember = loginService.login(form.getLoginId(), 
form.getPassword());
 log.info("login? {}", loginMember);
 if (loginMember == null) {
 bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm";
 }
 //로그인 성공 처리
 //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
 Cookie idCookie = new Cookie("memberId", 
String.valueOf(loginMember.getId()));
 response.addCookie(idCookie);
 return "redirect:/";
}

 

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);

쿠키 생성 로직

 

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
 expireCookie(response, "memberId");
 return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
 Cookie cookie = new Cookie(cookieName, null);
 cookie.setMaxAge(0);
 response.addCookie(cookie);
}

쿠키를 EXPIRE 시켜서 로그아웃 기능을 구현 할 수 있다.

 

세션

세션 ID를 생성하는데, 추정 불가능해야 한다.
UUID는 추정이 불가능하다.
Cookie: mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61
생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.

 

@Component
public class SessionManager {
 public static final String SESSION_COOKIE_NAME = "mySessionId";
 private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
 /**
 * 세션 생성
 */
 public void createSession(Object value, HttpServletResponse response) {
 //세션 id를 생성하고, 값을 세션에 저장
 String sessionId = UUID.randomUUID().toString();
 sessionStore.put(sessionId, value);
 //쿠키 생성
 Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId); response.addCookie(mySessionCookie);
 }
 /**
 * 세션 조회
 */
 public Object getSession(HttpServletRequest request) {
 Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
 if (sessionCookie == null) {
 return null;
 }
 return sessionStore.get(sessionCookie.getValue());
 }
 /**
 * 세션 만료
 */
 public void expire(HttpServletRequest request) {
 Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
 if (sessionCookie != null) {
 sessionStore.remove(sessionCookie.getValue());
 }
 }
 private Cookie findCookie(HttpServletRequest request, String cookieName) {
 if (request.getCookies() == null) {
 return null;
 }
 return Arrays.stream(request.getCookies())
 .filter(cookie -> cookie.getName().equals(cookieName))
 .findAny()
 .orElse(null);
 }
}

 

서블릿 HTTP 세션

@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult
bindingResult, HttpServletRequest request) {
 if (bindingResult.hasErrors()) {
 return "login/loginForm";
 } Member loginMember = loginService.login(form.getLoginId(), 
form.getPassword());
 log.info("login? {}", loginMember);
 if (loginMember == null) {
 bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
 return "login/loginForm";
 }
 //로그인 성공 처리
 //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
 HttpSession session = request.getSession();
 //세션에 로그인 회원 정보 보관
 session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
 return "redirect:/";
}

 

세션의 create 옵션에 대해 알아보자.
request.getSession(true)
세션이 있으면 기존 세션을 반환한다.
세션이 없으면 새로운 세션을 생성해서 반환한다.


request.getSession(false)
세션이 있으면 기존 세션을 반환한다.
세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.


request.getSession() : 신규 세션을 생성하는 request.getSession(true) 와 동일하다.

 

@SessionAttribute

@GetMapping("/")
public String homeLoginV3Spring(
 @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) 
Member loginMember,
 Model model) {
 //세션에 회원 데이터가 없으면 home
 if (loginMember == null) {
 return "home";
 }
 //세션이 유지되면 로그인으로 이동
 model.addAttribute("member", loginMember);
 return "loginHome";
}
@SessionAttribute(name = "loginMember", required = false) Member loginMember

이것을 사용하면 이미 로그인 된 사용자를 찾을 수 있다.

 

세션의 종료 시점


세션의 종료 시점을 어떻게 정하면 좋을까? 가장 단순하게 생각해보면, 세션 생성 시점으로부터 30분 정도로 잡으면 될
것 같다. 그런데 문제는 30분이 지나면 세션이 삭제되기 때문에, 열심히 사이트를 돌아다니다가 또 로그인을 해서 세션
을 생성해야 한다 그러니까 30분 마다 계속 로그인해야 하는 번거로움이 발생한다.
더 나은 대안은 세션 생성 시점이 아니라 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것
이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다. 따라서 30
분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

더보기

Bean Validation

public class Item {
 private Long id;
 @NotBlank
 private String itemName;
 @NotNull
 @Range(min = 1000, max = 1000000)
 private Integer price;
 @NotNull
 @Max(9999)
 private Integer quantity; //...
}

어노테이션을 통해 검증로직을 간편화 할 수 있다.

 

@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null 을 허용하지 않는다.
@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
@Max(9999) : 최대 9999까지만 허용한다.

 

더보기

에러메시지 바꾸기

errors.properties

#Bean Validation 추가
NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

Bean Validation - 오브젝트 오류

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
 //특정 필드 예외가 아닌 전체 예외
 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.reject("totalPriceMin", new Object[]{10000, 
resultPrice}, null);
 }
 }
 if (bindingResult.hasErrors()) {
 log.info("errors={}", bindingResult);
 return "validation/v3/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v3/items/{itemId}";
}

오브젝트 오류 부분만 따로 자바코드로 작성하는 것을 권장한다.

 

더보기

Bean Validation - groups

package hello.itemservice.domain.item;
public interface SaveCheck {
}

저장할때만 쓰이는 코드 SAVECHECK  클래스를 만들었다고 하면

package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
 @NotNull(groups = UpdateCheck.class) //수정시에만 적용
 private Long id;
 @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
 private String itemName;
 @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
 @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, 
UpdateCheck.class})
 private Integer price; @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
 @Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
 private Integer quantity;
 public Item() {
 }
 public Item(String itemName, Integer price, Integer quantity) {
 this.itemName = itemName;
 this.price = price;
 this.quantity = quantity;
 }
}

@Max(value = 9999, groups = SaveCheck.class) 이런식으로  GROPUS 설정을 통해 원할때만 validator를 사용하게 할 수 있다.

 

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, 
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
 //...
}

 

 

정리
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 Item
은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용
폼 객체를 분리해서 사용하기 때문이다.

 

더보기

BindingResult

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, 
RedirectAttributes redirectAttributes) { if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입
니다."));
 }
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
 bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 
1,000,000 까지 허용합니다."));
 }
 if (item.getQuantity() == null || item.getQuantity() >= 10000) {
 bindingResult.addError(new FieldError("item", "quantity", "수량은 최대
9,999 까지 허용합니다."));
 }
 //특정 필드 예외가 아닌 전체 예외
 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은
10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
 }
 }
 if (bindingResult.hasErrors()) {
 log.info("errors={}", bindingResult);
 return "validation/v2/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v2/items/{itemId}";
}

 

핵심은 BindingResult이다.

BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.

BindingResult에 필드에러를 추가 할 수 있다.

 

더보기

오류 메시지 처리

errors.properties를 사용한다.

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

이런 식으로 오류 메시지를 정의 할 수 있다.

그렇다면 파라미터는 어떻게 주느냐?

new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}

이런식으로 오류 객체를 생성하고, 뒤에  new Object[]{1000, 1000000}

를 사용해서 파라미터를 전해준다.

 

더보기

rejectValue() , reject()

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, 
RedirectAttributes redirectAttributes) {
 log.info("objectName={}", bindingResult.getObjectName());
 log.info("target={}", bindingResult.getTarget());
 if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.rejectValue("itemName", "required");
 }
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
 bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, 
null);
 }
 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
 }
 //특정 필드 예외가 아닌 전체 예외
 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
 }
 }
 if (bindingResult.hasErrors()) {
 log.info("errors={}", bindingResult);
 return "validation/v2/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v2/items/{itemId}";
}

 

rejectValue()

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

이런식으로 rejectvalue를 사용 할 수 있다.

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

field : 오류 필드명
errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

더보기

MessageCodesResolver

객체 오류


객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required

 

errors.properties

#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

 

더보기

ValidationUtils

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 

다음과 같이 한줄로 가능, 제공하는 기능은 Empty , 공백 같은 단순한 기능만 제공

 

더보기

Validator

@Component
public class ItemValidator implements Validator {
 @Override
 public boolean supports(Class<?> clazz) {
 return Item.class.isAssignableFrom(clazz);
 }
 @Override
 public void validate(Object target, Errors errors) {
 Item item = (Item) target;
 ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", 
"required");
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() 
> 1000000) {
 errors.rejectValue("price", "range", new Object[]{1000, 1000000}, 
null);
 }
 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 errors.rejectValue("quantity", "max", new Object[]{9999}, null);
 }
 //특정 필드 예외가 아닌 전체 예외
 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, 
null);
 }
 }
 }
}

 

VALIDATOR 사용예시이다.

 

public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}

supports() {} : 해당 검증기를 지원하는 여부 확인(뒤에서 설명)
validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

 

 

더보기

WebDataBinder

@InitBinder
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}

이걸 추가하면 검증기 자동 추가 가능

 

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
 if (bindingResult.hasErrors()) {
 log.info("errors={}", bindingResult);
 return "validation/v2/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v2/items/{itemId}";
}

 

파라미터 앞에 @Validated @ModelAttribute Item item 이렇게 해주면 자동으로 검증 가능

이때 어떤 검증기를 사용할 지 찾는 과정에서 VALIDATOR의 SUPPORTS 메소드가 사용된다.

@Component
public class ItemValidator implements Validator {
 @Override
 public boolean supports(Class<?> clazz) {
 return Item.class.isAssignableFrom(clazz);
 }
 @Override
 public void validate(Object target, Errors errors) {...}
}

올바른 타입을 입력했을 경우 TRUE로 리턴이 되고, 검증이 된다.

 

기본적으로 

messages.properties
label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량
page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정
button.save=저장
button.cancel=취소

이렇게 사용해서 메시지를 정의한다.

 

messages_en.properties
```properties
label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity
page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update
button.save=Save
button.cancel=Cancel

국제화는 이렇게 사용하는데

핵심은 messages_en.properties으로 파일명을 정의하는 것이다.

'스프링' 카테고리의 다른 글

스프링 MVC 2편 쿠키, 세션  (0) 2024.03.11
스프링 MVC 2편 VALIDATION 정리  (0) 2024.03.11
PRG POST/REDIRECT/GET 이란?  (0) 2024.01.31
스프링 GetMapping PostMapping 사용법  (0) 2024.01.31
타임리프 문법 정리  (0) 2024.01.31
DATE_FORMAT(DATE_OF_BIRTH,'%Y-%m-%d') AS DATE_OF_BIRTH

데이트 포맷을 바꿔주는 함수

 

WHERE ADDRESS LIKE '강원도%'

글자 비슷한거를 추출하는 함수

 

 

ROUND(AVG(DAILY_FEE), 0) AS AVERAGE_FEE

AVG(칼럼명) : 평균을 추출하는 함수

ROUND(값, 자리수) : 값을 자리수에 따라 반올림 한다!

0이면 소수점 첫번째 자리 (정수만 출력)

1이면 소수점 두번째 자리 (소수점 첫째자리 까지 출력)

-1이면 일의 자리에서 반올림

 

LIMIT 1

몇번째 튜플 까지 출력할지 설정하는 함수

 

MAX(FAVORITES)

해당 칼럼중에서 가장 많은 값을 출력한다.

SELECT FOOD_TYPE, MAX(FAVORITES)
FROM REST_INFO
GROUP BY FOOD_TYPE

이렇게 쓴다면

FOOD_TYPE들을 그룹핑 한다음, FOOD_TYPE에서 가장 큰 값을 리턴한다.

 

IF(DATEDIFF(END_DATE, START_DATE) >= 30, '장기 대여', '단기 대여')

DATEDIFF 함수 : 날짜간의 차이를 일 수로 반환 해준다.

 

SELECT  CONCAT('/home/grep/src/', BOARD_ID, '/', FILE_ID, FILE_NAME, FILE_EXT) AS FILE_PATH

CONCAT : 문자열을 합쳐준다

 

        WHEN INSTR(TLNO, '-') > 0 THEN TLNO
        WHEN LENGTH(TLNO) = 10 THEN CONCAT(SUBSTRING(TLNO, 1, 3), '-', SUBSTRING(TLNO, 4, 3), '-', SUBSTRING(TLNO, 7, 4))
        WHEN LENGTH(TLNO) = 11 AND SUBSTRING(TLNO, 1, 3) = '010' THEN CONCAT(SUBSTRING(TLNO, 1, 3), '-', SUBSTRING(TLNO, 4, 4), '-', SUBSTRING(TLNO, 8, 4))
        ELSE TLNO

 

INSTR : 문자열 안에 문자가 포함되어있는지 확인해준다

LENGTH : 문자열 길이

SUBSTRIG : 어디서 부터 어디까지 잘라야 되는지 알려줌

'알고리즘 > SQL' 카테고리의 다른 글

SQL IF문, CASE WHEN 사용법  (0) 2024.02.20
서브쿼리 사용법  (0) 2024.02.20
NULL 값을 바꾸는 함수 COALESCE  (0) 2024.02.06
if('2022-10-16' between start_date and end_date, '대여중','대여 가능')

참일경우 앞의 값

거짓일 경우 뒤의 값이 할당 된다.

    CASE 
        WHEN DATEDIFF(END_DATE, START_DATE) >= 30 THEN '장기 대여' 
        ELSE '단기 대여'

WHEN 뒤에는 조건문 THEN 뒤에는 결과

'알고리즘 > SQL' 카테고리의 다른 글

자주 쓰는 sql 문법 모음  (0) 2024.03.02
서브쿼리 사용법  (0) 2024.02.20
NULL 값을 바꾸는 함수 COALESCE  (0) 2024.02.06

중복이 없는 서로 다른 두 컬럼을 JOIN한다고 가정할 때, 일반적으로는:

- inner join 을 A 와 B에 대해 수행하는 것은, A와 B의 교집합을 말합니다. 벤다이어그램으로 그렸을 때 교차되는 부분입니다.

- outer join을 A와 B에 대해 수행하는 것은, A와 B의 합집합을 말합니다. 벤다이어 그램으로 그렸을 때, 합집합 부분입니다.

 

 

JOIN USED_GOODS_BOARD B ON U.USER_ID = B.WRITER_ID

 

Using the code

I am going to discuss seven different ways you can return data from two relational tables. I will be excluding cross Joins and self referencing Joins. The seven Joins I will discuss are shown below:

  1. INNER JOIN
  2. LEFT JOIN
  3. RIGHT JOIN
  4. OUTER JOIN
  5. LEFT JOIN EXCLUDING INNER JOIN
  6. RIGHT JOIN EXCLUDING INNER JOIN
  7. OUTER JOIN EXCLUDING INNER JOIN

For the sake of this article, I'll refer to 5, 6, and 7 as LEFT EXCLUDING JOIN, RIGHT EXCLUDING JOIN, and OUTER EXCLUDING JOIN, respectively. Some may argue that 5, 6, and 7 are not really joining the two tables, but for simplicity, I will still refer to these as Joins because you use a SQL Join in each of these queries (but exclude some records with a WHERE clause).

Inner JOIN

This is the simplest, most understood Join and is the most common. This query will return all of the records in the left table (table A) that have a matching record in the right table (table B). This Join is written as follows:

Hide   Copy Code
SELECT <select_list> 
FROM Table_A A
INNER JOIN Table_B B
ON A.Key = B.Key

Left JOIN

This query will return all of the records in the left table (table A) regardless if any of those records have a match in the right table (table B). It will also return any matching records from the right table. This Join is written as follows:

Hide   Copy Code
SELECT <select_list>
FROM Table_A A
LEFT JOIN Table_B B
ON A.Key = B.Key

Right JOIN

This query will return all of the records in the right table (table B) regardless if any of those records have a match in the left table (table A). It will also return any matching records from the left table. This Join is written as follows:

Hide   Copy Code
SELECT <select_list>
FROM Table_A A
RIGHT JOIN Table_B B
ON A.Key = B.Key

Outer JOIN

This Join can also be referred to as a FULL OUTER JOIN or a FULL JOIN. This query will return all of the records from both tables, joining records from the left table (table A) that match records from the right table (table B). This Join is written as follows:

Hide   Copy Code
SELECT <select_list>
FROM Table_A A
FULL OUTER JOIN Table_B B
ON A.Key = B.Key

Left Excluding JOIN

This query will return all of the records in the left table (table A) that do not match any records in the right table (table B). This Join is written as follows:

Hide   Copy Code
SELECT <select_list> 
FROM Table_A A
LEFT JOIN Table_B B
ON A.Key = B.Key
WHERE B.Key IS NULL

Right Excluding JOIN

This query will return all of the records in the right table (table B) that do not match any records in the left table (table A). This Join is written as follows:

Hide   Copy Code
SELECT <select_list>
FROM Table_A A
RIGHT JOIN Table_B B
ON A.Key = B.Key
WHERE A.Key IS NULL

Outer Excluding JOIN

This query will return all of the records in the left table (table A) and all of the records in the right table (table B) that do not match. I have yet to have a need for using this type of Join, but all of the others, I use quite frequently. This Join is written as follows:

Hide   Copy Code
SELECT <select_list>
FROM Table_A A
FULL OUTER JOIN Table_B B
ON A.Key = B.Key
WHERE A.Key IS NULL OR B.Key IS NULL

+ Recent posts