728x90

개요

스프링 어노테이션에 대해서 정리할려고 한다.

 

@ModelAttribute

  • 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다.
  • 스프링에서는 위 과정을 자동화해주는 @ModelAttribute 기능을 제공한다.
  • 먼저, 요청 파라미터를 바인딩받을 객체를 만든다.

Item 객체

package hello.itemservice.domain.item;
import lombok.Data;
@Data
public class Item {
    private Long Id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
} 
  • @Data : @Getter , @Setter , @ToString , @EqualsAndHashCode , @RequiredArgsConstructor를 자동으로 적용해준다.
  • 하지만 개인 프로젝트에서만 사용하고 실제 협업에서는 사용하지 말자.

 

View 의 일부분

  • th:action="@{/basic/items/add}":
    - 버튼 sumbit 이 발생하면 해당 컨트롤러를 호출해준다.
  • input 의 이름들은 각각 itemName, price, quantity 로 위에 작성한 Item 객체의 필드 네임과 일치한다.
    <form action="item.html" th:action="@{/basic/items/add}" method="post">
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control"
                   placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="form-control" placeholder="수량을 입력하세요">
        </div>
        <hr class="my-4">
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>
            </div>
        </div>
    </form>

 

@RequestParam 과 @ModelAttribute

  • Request 에서 ModelAttribute 로 적용해보는 과정을 정리해본다.

 

 

@RequestParam 으로 요청 파라미터 가져오기

  • form 에 input으로 정의된 데이터들을 가져온다
    @PostMapping("/add")
    public String addItemV1(@RequestParam String itemName ,
                      		@RequestParam int price,
                       		@RequestParam int quantity,
                       		Model model) {

        Item item = new Item();
        item.setItemName(itemName);
        item.setPrice(price);
        item.setQuantity(quantity);

        itemRepository.save(item);
        model.addAttribute("item", item);

        return "basic/item";
    }
  • ModelAttribute를 사용하지 않는 경우 다음과 같은 과정을 통해 코드를 작성해야 한다.
  • @RequestParam을 통해 요청 파라미터를 받는다.
  • 만약 가져와야 하는 데이터가 많다면 코드가 너무 길어질 것이다.

 

 @ModelAttribute 적용1

  • 각각 RequestParam 으로 받았던 데이터를 객체로 받을수 있다.
  • 객체의 필드이름과 form 의 input 타입의 이름만 같으면 된다.
    @PostMapping("/add")
    public String addItemV2(@ModelAttribute("item") Item item) {
        itemRepository.save(item);
        return "basic/item";
    }

 

 @ModelAttribute 적용2

  • ModelAttribute 의 name을 생략할수있다.
    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item) {
        itemRepository.save(item);
        return "basic/item";
    }

 

 @ModelAttribute 적용3

  • 아예 ModelAttribute  자체도 생략이 가능하다.
   @PostMapping("/add")
    // 아예 생략도 가능하다.
    public String addItemV4(Item item) {
        itemRepository.save(item);
        return "basic/item";
    }
 

 

 

주의할 점

  • @RequestParam도 생략할 수 있어 혼란이 발생할 수 있다.
  • 스프링에서는 이러한 혼란을 방지하기 위해 다음과 같은 규칙을 적용하고 있다.
  • String, int, Integer와 같은 단순 타입의 경우 : @RequestParam
  • argument resolver로 지정해둔 타입 외의 나머지 : @ModelAttribute
728x90
728x90

개요

프로젝트 중

@Transactional 을 안 붙여서 오류가 나는 경우가 종종 있어서 이번 기회에 정리해놓을려고 한다.


@Transactional

 

Q. @Transactional 어노테이션 붙이는 이유?

  • 데이터베이스의 상태를 변경하는 메서드나 복수의 데이터베이스 작업을 묶어서 처리할 때 사용한다.
  • 데이터를 데이터베이스에 저장하거나 삭제, 수정하는 메서드처럼 데이터베이스의 상태를 변경하는 경우에 사용한다.

 

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public void registerUser(String username, String email, String password, String passwordCheck, String usernick) throws IOException {
        
        Role role = roleRepository.findByRoleName(RoleName.ROLE_USER)
                    .orElseThrow(() -> new UserNotFoundException("User 역할이 없습니다."));
 
        User user = new User();
        user.setRole(Collections.singleton(role));  // singleton -- 단일 역할 설정
        user.setUserName(username);
        user.setEmail(email);
        user.setPassword(passwordEncoder.encode(password));
        user.setUserNick(usernick);

        userRepository.save(user);
    }

 @Transactional

  • 메서드가 실행되는 동안 트랜잭션을 시작하고, 메서드가 정상적으로 완료되면 커밋(commit)을 하고, 예외가 발생하면 롤백(rollback)을 수행한다.
  • 이 어노테이션을 사용하여 데이터베이스 관련 작업을 안전하게 처리할 수 있다.
  • 기본 값 : @Transactional(readOnly = false)
    • 특징: 위의 예시에서는 registerUser 메서드에 적용되어 있으며, readOnly = false로 설정되어 있어 해당 메서드는 읽기/쓰기 모두 가능한 트랜잭션을 사용합니다.

 

readOnly 속성의 차이점

  1. readOnly = true
    • 이 옵션을 설정하면, 트랜잭션 동안 데이터를 수정하는 쿼리(INSERT, UPDATE, DELETE 등)를 수행할 수 없다.
    • 오직 읽기 전용 쿼리(SELECT)만 가능
    • 데이터를 조회만 할때, 전체 사용자, 게시글 조회 등등
    • 이는 데이터베이스에게 현재 트랜잭션이 데이터를 변경하지 않을 것임을 알려 성능 최적화를 도와준다.
    • 읽기 전용 트랜잭션은 데이터베이스의 트랜잭션 로그를 기록하지 않아도 되어 I/O 부하를 줄일 수 있다.
  2. readOnly = false (기본값)
    • 읽기 및 쓰기 작업 모두를 수행할 수 있는 트랜잭션
    • 데이터베이스에 변경 사항이 발생하면 트랜잭션 로그 기록
    • 쓰기 작업이 필요한 경우에는 이 옵션을 사용
    • 삭제, 등록, 수정 등 데이터베이스의 상태를 변경할 때 사용한다.

 

결론 

데이터베이스에서 삽입, 삭제, 수정 등 데이터베이스의 내용이 변하게 된다면 @Transactional 붙여야 한다.!!

728x90
728x90

개요

이전 글에 이어서 [멋쟁이사자처럼 백엔드 TIL] Spring : 알림 기능 구현 종류 :: 미정 (tistory.com)

DB 알림함을 만들어서 알림기능을 관리하기로 결정했다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

 

 


1. 엔티티 생성

 

Notification.java

@Entity
@Getter @Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Notification {
    
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String message;
    private boolean isRead;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @PrePersist
    public void prePersist() {
        if (createdAt == null) {
            createdAt = LocalDateTime.now();
        }
    }
}

알림을 담을 엔티티를 하나 생성했다. isRead는 읽었는지 안읽었는지 확인하는 칼럼이다.

이때 username은 알림을 받을 사용자이다.

 

 

2. 레포지토리

NotificationRepository.java

public interface NotificationRepository extends JpaRepository<Notification, Long> {
    List<Notification> findByUsername(String username);

    List<Notification> findByUsernameAndIsRead(String username, boolean isRead);

    // '읽음'으로 표시된 알림 중 특정 시간 이전에 생성된 알림을 찾는 메서드
    List<Notification> findByIsReadAndCreatedAtBefore(boolean isRead, LocalDateTime cutoffTime);
}

DB에 저장하기 위한 레포지토리이다. 그리고 스케쥴링을 통해 관리를 하기 위해 메서드를 하나 추가한다.

 

 

 

3. 서비스

NotificationService.java

@Service
public class NotificationService {

    private final NotificationRepository notificationRepository;
    public NotificationService(NotificationRepository notificationRepository) {
        this.notificationRepository = notificationRepository;
    }

	// 알림을 생성
    @Transactional
    public void createNotification(String username, String message) {
        Notification notification = new Notification();
        notification.setUsername(username);
        notification.setMessage(message);
        notification.setRead(false);  // 기본값: 읽지 않음
        notificationRepository.save(notification);
    }

    // 알림 리스트 목록에서 버튼을 누르면 알림을 읽은 상태로 표시
    public void markAsRead(Notification notification) {
        notification.setRead(true);
        notificationRepository.save(notification);
    }

	// 알림이 보낼 때 상태
    @Transactional
    public void markAsRead(Long notificationId) {
        Notification notification = notificationRepository.findById(notificationId)
                .orElseThrow(() -> new RuntimeException("알림을 찾을 수 없습니다."));
        notification.setRead(false);
        notificationRepository.save(notification);
    }

    // 특정 ID로 알림을 가져옴
    public Notification getNotificationById(Long id) {
        return notificationRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("알림을 찾을 수 없습니다."));
    }

    // 사용자에게 보낸 알림 목록을 반환
    public List<Notification> getUnreadNotificationsByUsername(String username) {
        return notificationRepository.findByUsernameAndIsRead(username, false);
    }
}

각 메서드의 역할은 위에 주석으로 설명했다.

markAsRead는 매개변수 값이 달라서 혼동하지 않아야 한다.

 

 

4. 컨트롤러

NotificationRestController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/notifications")
public class NotificationRestController {

    private static final Logger log = LoggerFactory.getLogger(NotificationRestController.class);
    private final NotificationService notificationService;
    private final UserService userService;

    private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    @GetMapping("/stream")
    public SseEmitter streamNotifications(Authentication authentication) {
        SseEmitter emitter = new SseEmitter(60000L); // 60초 타임아웃 설정
        emitters.add(emitter);

        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> {
            emitters.remove(emitter);
            emitter.complete();
        });
        emitter.onError((e) -> {
            emitters.remove(emitter);
            emitter.completeWithError(e);
        });

        // 사용자 인증 확인
        if (authentication == null) {
            emitter.complete();
            return emitter;
        }

        String username = authentication.getName();
        Optional<User> userOptional = userService.findByUserName(username);

        if (!userOptional.isPresent()) {
            emitter.complete();
            return emitter;
        }

        String user = userOptional.get().getUserName();

        // 새로운 쓰레드에서 알림을 전송
        new Thread(() -> {
            try {
                while (true) {
                    if (emitters.contains(emitter)) {
                        List<Notification> notifications = notificationService.getUnreadNotificationsByUsername(user);

                        if (!notifications.isEmpty()) {
                            for (Notification notification : notifications) {
                                if (!notification.isSent()) { // 알림이 전송되지 않았는지 확인
                                    emitter.send(SseEmitter.event()
                                            .name("notification")
                                            .data(notification.getMessage()));

                                    // 알림을 읽음 상태로 업데이트 및 전송됨 상태로 설정
                                    notificationService.markAsRead(notification.getId());
                                }
                            }
                        }
                    } else {
                        break; // emitter가 이미 완료된 경우 반복문 종료
                    }
                    Thread.sleep(10000); // 10초마다 알림을 체크
                }
            } catch (Exception e) {
                log.info(e.getMessage());
                // emitter.completeWithError(e);
            }
        }).start();
        return emitter;
    }
}

서버가 클라이언트로 실시간 데이터를 전송할 수 있게 해주는 코드이다.

메서드를 보면 SseEmitter 객체를 생성하여 반환한다. SseEmitter는 서버가 클라이언트에게 비동기적으로 데이터를 전송할 수 있도록 해준다. SSE 방식으로 알림을 구현한다면 사용되는 객체이다.

  • new SseEmitter(60000L): 60초 타임아웃을 설정하여 SseEmitter를 생성한다.
  • notificationService를 통해 사용자의 읽지 않은 알림 목록을 가져온다.
  • 읽지 않은 알림이 있으면, emitter.send() 메서드를 사용하여 각 알림을 클라이언트에게 전송한다.
  • 전송 후에는 알림을 읽음 상태로 업데이트한다.
  • 만약 알림이 한번 전송된 후에는 다시 전송되지 않도록 관리할 수 있다.

이 코드를 통해 사용자는 새로운 알림이 있을 때마다 즉시 알림을 받을 수 있고 알림은 한번만 울리도록 설정했다.

 

 

 

NotificationController.java

@Controller
@RequiredArgsConstructor
@RequestMapping("/notifications")
public class NotificationController {
    private final NotificationService notificationService;

    /**
     * 로그인한 사용자의 읽지 않은 알림 리스트
     */
    @GetMapping
    public String getNotifications(Model model, Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()) {
            return "redirect:/loginform"; // 로그인 페이지로 리다이렉트
        }
        String username = authentication.getName();
        List<Notification> notifications = notificationService.getUnreadNotificationsByUsername(username);
        model.addAttribute("notifications", notifications);
        return "notifications";
    }

    // 알림을 읽음 상태로 변경하는 메서드
    @PostMapping("/api/notifications/mark-as-sent/{id}")
    @ResponseBody
    public ResponseEntity<String> markAsRead(@PathVariable("id") Long id) {
        Notification notification = notificationService.getNotificationById(id);
        if (notification != null) {
            notificationService.markAsRead(notification);
            return ResponseEntity.ok("Notification marked as read");
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Notification not found");
        }
    }
}

 

알림 목록을 받을 코드와 알림 리스트에서 읽음 버튼을 눌렀을 때 읽음 처리가 될 수 있도록 처리할 메서드를 생성한다.

 

 

5. 특정 행위에 대한 알림을 받을 곳에 코드 추가

@Service
@RequiredArgsConstructor
public class CommentService {

    private final PostRepository postRepository;
    private final CommentRepository commentRepository;
    private final UserRepository userRepository;
    private final NotificationService notificationService;

    /**
     * 댓글 추가
     */
    @Transactional
    public Comment addComment(Long postId, String content) {
        String username = getCurrentUsername();
        if (username == null) {
            throw new IllegalStateException("로그인된 사용자만 댓글을 작성할 수 있습니다.");
        }

        User user = userRepository.findByUserName(username)
                .orElseThrow(() -> new UserNotFoundException("해당 사용자를 찾을 수 없습니다. username: " + username));
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new PostNotFoundException("해당 포스트를 찾을 수 없습니다. postId: " + postId));

        Comment comment = new Comment();
        comment.setPost(post);
        comment.setUser(user);
        comment.setContent(content);
        Comment savedComment = commentRepository.save(comment);

        // 댓글 작성 후 알림 전송
        sendCommentNotification(post, user, content);
        
        return savedComment;
    }

    /**
     * 댓글 작성 시 알림 전송
     */
    private void sendCommentNotification(Post post, User commenter, String content) {
        User postAuthor = post.getUser();        // 게시글 작성자 정보 가져오기
        // 댓글을 작성한 사용자와 게시글 작성자가 동일하지 않을 때만 알림을 보냄
        if (!commenter.getUserId().equals(postAuthor.getUserId())) {
            notificationService.createNotification(postAuthor.getUserName(), commenter.getUserName() + "님이 댓글을 달았습니다: "
            );
        }
    }
}

일단 나는 댓글을 달 때 알림을 받기를 원하니깐 CommentService에서 알림 로직을 추가한다.

위와 같이 알림서비스의 createNotification 메서드를 호출하여 댓글이 달렸을 때 해당 게시글의 작성자한테 알림을 보낸다.

 

 

7. HTML

메인 홈 HTML

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>velog</title>
  <link href="/css/home.css" rel="stylesheet"/>
</head>
<body>

<nav class="navbar navbar-expand-lg navbar-light bg-light shadow-sm">
  <div class="container">
    <a class="navbar-brand text-white" href="#">NewsFeed</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
      <ul class="navbar-nav me-auto mb-2 mb-lg-0">
        <!-- 알림 링크 추가 -->
        <li class="nav-item" th:if="${username != null and username != ''}">
          <a class="nav-link text-white notification-dot" th:href="@{/notifications}">📪</a>
        </li>
      </ul>
      <div th:if="${username != null and username != ''}" class="d-flex align-items-center ms-auto">
        <a class="btn btn-outline-light me-3" th:href="@{/posts/create}">새 글 작성</a>
        <div th:unless="${username == 'admin'}" class="dropdown">
          <a href="#" class="d-flex align-items-center text-decoration-none" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
            <img class="profile-image" th:src="@{${profileImage}}" alt="Profile Image">
            <i class="bi bi-caret-down-fill text-white"></i>
          </a>
          <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="dropdownMenuButton">
            <li><a class="dropdown-item" th:href="@{/mypage}">내 벨로그</a></li>
            <li><a class="dropdown-item" th:href="@{/api/logout}">로그아웃</a></li>
          </ul>
        </div>
      </div>
      <div th:if="${username == null or username == ''}" class="d-flex">
        <a class="btn btn-outline-dark me-2" th:href="@{/loginform}">로그인</a>
      </div>
    </div>
  </div>
</nav>

<main class="container my-5">

  <!-- 알림 배너 -->
  <div id="notification-banner" class="alert alert-info d-none position-fixed top-0 end-0 m-3" role="alert">
    <span id="notification-message">새 알림</span>
    <button type="button" class="btn-close" aria-label="Close"></button>
  </div>
</main>
<script>
  (function(){var w=window;if(w.ChannelIO){return w.console.error("ChannelIO script included twice.");}var ch=function(){ch.c(arguments);};ch.q=[];ch.c=function(args){ch.q.push(args);};w.ChannelIO=ch;function l(){if(w.ChannelIOInitialized){return;}w.ChannelIOInitialized=true;var s=document.createElement("script");s.type="text/javascript";s.async=true;s.src="https://cdn.channel.io/plugin/ch-plugin-web.js";var x=document.getElementsByTagName("script")[0];if(x.parentNode){x.parentNode.insertBefore(s,x);}}if(document.readyState==="complete"){l();}else{w.addEventListener("DOMContentLoaded",l);w.addEventListener("load",l);}})();

  ChannelIO('boot', {
    "pluginKey": "{channerTalk key}"
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', function() {
    const eventSource = new EventSource('/api/notifications/stream');
    const notificationDot = document.querySelector('.notification-dot');

    eventSource.addEventListener('notification', function(event) {
      try {
        // 수신된 데이터를 문자열로 받음
        const notificationMessage = event.data;

        // 메시지와 배너를 업데이트
        const notificationElement = document.getElementById('notification-message');
        notificationElement.textContent = notificationMessage;

        const notificationBanner = document.getElementById('notification-banner');
        notificationBanner.classList.remove('d-none');

        // 빨간색 점 활성화
        if (notificationDot) {
          notificationDot.classList.add('active');
        }

        // 5초 후 배너를 숨김
        setTimeout(() => {
          notificationBanner.classList.add('d-none');
        }, 5000);

      } catch (error) {
        console.error('SSE 메시지 처리 오류:', error);
      }
    });

    eventSource.onerror = function(event) {
      console.error('SSE 연결 오류:', event);
      if (event.readyState === EventSource.CONNECTING) {
        console.error('연결 재시도 중...');
      } else if (event.readyState === EventSource.CLOSED) {
        console.error('연결이 닫혔습니다.');
      }
      console.error('상태:', event.target.readyState);
    };

    document.getElementById('notification-banner')
            .querySelector('.btn-close')
            .addEventListener('click', function() {
              const notificationBanner = document.getElementById('notification-banner');
              notificationBanner.classList.add('d-none');
            });

    window.addEventListener('beforeunload', function() {
      eventSource.close();
    });
  });
</script>
</body>
</html>

메인 홈 화면 폼이다. 여기에서는 알림이 오면 우측 상단에 메시지가 뜨며 📪 여기에 빨간 점이 표시된다. 알림을 읽지 않은 상태에서 표시되고 알림을 읽으면 사리지도록 구현했다. 아래는 위에 폼에 대한 css 코드이다.

body {
    font-family: 'Noto Sans KR', sans-serif;
    background-color: #1E1E1E;
    color: #E0E0E0;
}
.navbar {
    background-color: #121212 !important;
}
.navbar-brand, .navbar-nav .nav-link {
    color: #FFFFFF !important;
}
.btn-outline-light {
    color: #FFFFFF;
    border-color: #FFFFFF;
}
.btn-outline-light:hover {
    background-color: #007bff;
    color: #FFFFFF;
}
.btn-outline-dark {
    color: #FFFFFF;
    border-color: #FFFFFF;
}
.btn-outline-dark:hover {
    background-color: #007bff;
    color: #FFFFFF;
}
.profile-image {
    width: 40px;
    height: 40px;
    border-radius: 50%;
}
.profile-link {
    display: flex;
    align-items: center;
    text-decoration: none;
    color: white;
}
.profile-info {
    display: flex;
    align-items: center;
    margin-bottom: 20px;
}
.profile-info img {
    width: 100px;
    height: 100px;
    border-radius: 50%;
    margin-right: 20px;
}
.profile-info .username {
    font-size: 24px;
    font-weight: bold;
}
.profile-info .greeting {
    font-size: 18px;
    color: #AAAAAA;
}
.tags {
    margin-bottom: 20px;
}
.tags a {
    color: #E0E0E0;
    text-decoration: none;
    margin-right: 10px;
}
.post-title {
    font-size: 24px;
    font-weight: bold;
    margin-bottom: 10px;
}
.post-content {
    color: #AAAAAA;
    margin-bottom: 20px;
}
.emphasized {
    font-weight: bold;
}
.card-img {
    width: 40%;
    height: auto;
    object-fit: cover;
}
.space-between {
    margin-right: 280px;
}
.card {
    height: 400px; 
    overflow: hidden;
    display: flex;
    flex-direction: column;
}
.card-body {
    display: flex;
    flex-direction: column;
    overflow: hidden;
}
.card-text {
    flex-grow: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    white-space: normal;
}
.card-footer {
    margin-top: auto;
}
/* home.css */
.notification-dot {
    position: relative;
    display: inline-block;
}

.notification-dot::after {
    content: '';
    position: absolute;
    top: 0;
    right: 0;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: red;
    transform: translate(50%, -50%);
    display: none;
}

.notification-dot.active::after {
    display: block;
}

 

알림 리스트 HTML

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>내 알림</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<div class="container">
    <h1>내 알림</h1>
    <div>
        <ul>
            <li th:each="notification : ${notifications}" class="notification-card">
                <p class="notification-message" th:text="${notification.message}"></p>
                <div class="notification-actions">
                    <button class="btn btn-outline-dark mark-as-read-button"
                            data-id="[[${notification.id}]]"
                            th:text="${notification.isRead ? '읽음' : '읽음으로 표시'}"></button>
                </div>
            </li>
        </ul>
    </div>
    <a href="/trending" class="home-link">홈으로 돌아가기</a>
</div>
<script>
    $(document).ready(function() {
        $('.mark-as-read-button').click(function() {
            var notificationId = $(this).data('id');
            var button = $(this);

            $.ajax({
                url: '/api/notifications/mark-as-sent/' + notificationId,
                type: 'POST',
                success: function(response) {
                    alert('알림이 읽음으로 표시되었습니다.');
                    button.text('읽음');
                    button.attr('disabled', true);  // 버튼 비활성화
                },
                error: function(xhr) {
                    alert('알림 상태를 변경하는 중 오류가 발생했습니다.');
                }
            });
        });
    });
</script>
</body>
</html>

나한테 울린 알림 목록을 보여주는 폼이다. 여기서 저 버튼을 누르면 읽음 표시가 되어서 디비에서 읽은 상태로 바뀌게 된다.

 

하지만 나는 레디스를 사용하지 않아서 RDB에 그대로 알림이 쌓여져 간다. 그렇기 때문에 스케줄링을 통해 알림을 관리할려고 한다.

SchedulingConfig.java

@Configuration
@EnableScheduling
public class SchedulingConfig {
    // 스케줄링 관련 설정
}

@EnableScheduling 을 붙임으로써 스케쥴링을 하겠다는 의미이다.

@Component
@RequiredArgsConstructor
public class NotificationBatchProcessor {

    private final NotificationRepository notificationRepository;

    /**
     * 주기적으로 호출되어 10분 이상 경과한 읽지 않은 알림을 삭제합니다.
     * 예: 생성된 지 10분 이상 경과한 읽지 않은 알림을 삭제
     */
    @Scheduled(cron = "0 0/5 * * * ?") // 매 5분마다 실행
    public void deleteUnReadNotifications() {
        // 현재 시간 기준으로 10분 이상 경과한 알림을 조회
        LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(10);
        List<Notification> notificationsToDelete = notificationRepository.findByIsReadAndCreatedAtBefore(false, cutoffTime);

        // 알림 삭제
        notificationRepository.deleteAll(notificationsToDelete);
    }
}

스케쥴링같은 경우는 cron 표현을 사용해서 구현한다. [Cron] 크론(cron) 표현식 정리 (tistory.com)

테스트용이라서 5분마다 메서드를 실행하는데, 현재 시간에서 10분 전 시간을 계산하여 cutoffTime에 저장한다.

10분 전에 생성된 알림을 고려해서 삭제한다는 의미이다.

  • notificationRepository.findByIsReadAndCreatedAtBefore(false, cutoffTime)
    • 이 부분에서 isRead가 false(읽지 않은)이고, createdAt이 cutoffTime보다 이전인 알림들을 조회한다.
    • 따라서, 10분 이상 경과한 false인 읽지 않은 알림을 조회하는 것이다.

 

 

이렇게 알림 기능 구현 완료~ 약간 복잡해서 어려웠다.

 

 

728x90
728x90

개요

어떤 특정 행위에 대한 알림 기능은 유저의 요청 없이도 실시간으로 서버의 변경 사항을 웹 브라우저에 갱신해줘야 하는 서비스이다. 하지만 전통적인 Client-Server 모델의 HTTP 통신에서는 이런 기능을 구현하기가 어렵다. 클라이언트의 요청이 있어야만 서버가 응답을 할 수 있기 때문이다. HTTP 기반으로 해당 문제를 해결하려면 다음과 같은 방식들이 있다.

 

 

일단 알림 기능을 위해 사용되는 방법 4가지를 간략하게 정리해보았다. 

 

실시간 통신의 방법

Polling

  • 특징
    • 일정 주기로 서버 API 호출하는 방법
    • 클라이언트에서 5초마다 한 번씩 알림 목록을 호출한다면, 업데이트 내역이 5초마다 갱신되며 변경 사항을 적용할 수 있다.
      • 주기가 길다면? : 데이터의 실시간성을 보장할 수 없다.
      • 주기가 짧다면? : 갱신된 내용 없어도 불필요한 요청이 자꾸 들어오기 때문에 불필요한 서버 부하가 발생한다.

 

Long-Polling

  • 특징
    • Polling과 비슷하나, 업데이트 발생 시에만 응답을 보내는 방법
    • 서버에 요청 들어왔으면 일정 시간 대기하다가 업데이트 된 내용이 있을 경우 웹 브라우저에 응답을 보낸다.
      • 불필요한 응답을 주는 경우를 줄일 수 있다. (장)
      • 연결이 되어있는 경우 실시간으로 데이터가 올 수 있다. (장)
      • 데이터 업데이트가 빈번할 경우, 연결을 위한 요청이 계속 발생하기에 서버 부하 발생한다. (단)

 

SSE (Server-Sent Event)

  • 특징
    • 웹브라우저에서 서버 쪽으로 특정 이벤트를 구독하면, 서버에서 해당 이벤트 발생 시에 웹 브라우저로 이벤트를 보내주는 방법
    • 따라서 한 번만 연결 요청을 보내면, 연결이 종료될 때까지 재연결 과정 없이 서버에서 웹 브라우저로 데이터를 계속해서 보낼 수 있다.
      • 서버에서 웹 브라우저로만 데이터 전송 가능(단방향)
      • 최대 동시 접속 횟수가 제한되어있다.
      • 한번만 연결요청 보내면, 연결 종료 전까지는 재연결 없이 계속 데이터를 전송할 수 있다. (안끊김)

 

Web Socket

  • 특징
    • 서버와 웹브라우저 사이 양방향 통신이 가능한 방법이다.
    • 변경 사항에 빠르게 반응해야하는 채팅이나, 리소스 상태에 대한 지속적 업데이트가 필요한 문서 동시 편집과 같은 서비스에 많이 사용되는 방식이다.
      • 양방향 통신이 지속적으로 이루어진다.
      • 연결을 유지하는 것 자체가 비용이 들기에, 트래픽 많으면 서버에 부담이 된다.

 

결론

  • Polling 방식은 실시간성을 높이려면 그 주기를 짧게 해야 하는데, 트래픽이 많아질 경우 서버에 걸리는 부하가 커지기 때문에 알림 서비스에는 부적합하고 할 수 있다.
  • Long-Polling 역시 마찬가지로, 트래픽이 많아지면 요청도 그만큼 많아지므로 부적합하다.
  • 그렇다면 HTTP 연결 방식에 대한 부담이 적은 SSE와 WebSocket 방식이 남는데, 알림 서비스의 경우 클라이언트에서 서버로 데이터를 전송하지 않아도 되어서 단방향 통신만으로도 구현할 수 있으므로, SSE 방식을 택하는 것이 좋겠다.

 

해결 과정

  • 알림의 경우 전통적인 client-server 모델의 http 통신에서는 구현하기 어렵다.
  • 해당 방식은 클라이언트의 요청이 있어야만 서버가 응답하는데, 알림은 ‘클라이언트의 요청이 없이’ 실시간으로 서버의 변경 사항이 웹 브라우저에 갱신 되어야하기 때문이다.
  • 제시된 4가지 방법 중 http 연결에 대한 부담이 적은 방식은 SSE, Web socket 이며 실시간성도 보장이 된다.
  • 이 중 알림은 단방향 통신만 해도 된다. (서버 → 클라이언트) 그래서 보통 알림은 SSE 방식을 많이 채택한다.

 

알아보니 Spring에서 제공하는 SSE 프레임워크 사용하면 알람 기능이 쉽게 구현될 것 같아 SSE 방식의 알림 기능을 구현하기로 정했다.

 

크게 보면 작업은 아래와 같이 진행될 것이라 예상된다.

  1. 웹 브라우저와 서버 연결하기 (한번 연결하면 재연결 자주 안해도 된다.)
    1. 연결 관련해서 설정해주기 (연결 , 알림 전송 메소드 생성 포함)
  2. 생성해둔 알림 전송 메소드를 각 이벤트가 발생하는 서비스 메소드 내에 추가한다.
    1. 크루장 승인의 경우, 승인 메소드 내에 알림 전송 메소드 포함하여 알림 전송될 수 있도록 한다.

 

이 때, 고민했던 내용들

 

Q. SSE는 실시간 접속 중일 때만 알림을 보낼 수 있다. 사용자가 오프라인일 때는 어떻게 할지?

 

위의 질문에 대해 3가지 해결 방법을 생각해보았다. 

 

A. 

  1. 서버가 사용자의 접속여부를 파악하여서 따로 처리한다. 이 때, 지금 프로젝트는 토큰 방식으로 인증을 하기에, 서버에서 사용자의 접속 여부 알 수 없다. 따라서 접속여부 파악하는 방법?
    1. 접속여부 DB 생성하고, 클라이언트에서 heartbeat 5초에 한번씩 서버로 보내서 접속 여부 파악 → 주로 프론트 개발자가 해야할 일들이라서 패스
  2. 푸시 알람 전송하기.
    SSE의 경우 오프라인일 경우 실시간 전송이 안됨. 물론 푸시 알림으로 처리할 수 있지만 이 부분은 프론트 개발자가 처리해야해서 일단 패스
  3. 알림함 만들기.
    특정 이벤트가 발생했을 때, 아래의 a,b 두 가지를 모두 실행한다.
    1. SSE로 실시간 알림을 전송한다 →
      1. 만약 오프일 경우, 그냥 알림 못받는 것으로 처리
      2. 온라인일 경우, 알림함 빨간 표시 + 팝업창 띄우기 정도
    2. 알림함 DB에 알림 내용을 저장한다. → 온, 오프 상관 없이 모두 DB에 저장하고, t/f로 읽음 여부 판단하기
      1. 알림함은 최초 접속 1회일 때만 조회해서 안읽은 알람 여부 판단하고, 이 후 실시간 알람 왔을 때에만 알림함 한번씩 조회하기. (실시간 알림 안왔으면 알림 조회할 이유 없으니까)

⇒ 결론으로는 3번째 방법으로 진행하기로 했다. 

 

 

 

 

 

 

참고 링크 

[백엔드|스프링부트] 알림 기능은 어떻게 구현하는게 좋을까? | TaemHam

728x90
728x90

개요

기존 이미지 업로드는 로컬에서 임의로 파일을 만들어서 구현했지만 이제는 AWS S3를 사용해서 안전하게 이미지를 저장해볼려고 한다.
용도 : 회원정보를 수정할 때 프로필 이미지를 등록

 

1. Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법

S3에 파일을 업로드하는 방법에는 3가지가 있다.

  • Stream 업로드
  • MultipartFile 업로드
  • AWS Multipart 업로드

1.1 Stream 업로드

Stream 업로드 방식은 파일을 chunk 단위로 읽어서 서버에 전송하는 방식이다. 직렬화된 데이터를 순차적으로 보내므로, 대용량 파일을 안정적으로 전송할 수 있지만, 각 chunk를 순차적으로 전송하기 때문에 전체 파일을 한 번에 업로드하는 방식보다 더 많은 시간이 소요될 수 있다.

1.2 MultipartFile 업로드

MultipartFile 업로드는 Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식이다. 대부분의 웹 개발 프레임워크 및 라이브러리에서 기본적으로 지원하는 방식이므로 구현이 간단하다. 또한 사용자가 파일을 선택하고 업로드 버튼을 클릭하는 것으로 파일을 서버에 업로드할 수 있다. 드어마 파일 전체를 메모리에 로드하므로, 대용량 파일을 처리할 때 메모리 부담이 있을 수 있다. 또한 파일을 서버에 업로드할 때 보안 취약성이 발생할 수 있다.

1.3 AWS Multipart 업로드

AWS Multipart 업로드는 AWS S3에서 제공하는 파일 업로드 방식으로 업로드할 파일을 작은 part로 나누어 개별적으로 업로드한다. 파일의 바이너리가 Spring Boot를 거치지 않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도 된다. 업로드 중에 오류가 발생해도 해당 부분만 재시도할 수 있다. 그러나 복잡한 구현을 필요로 하고 여러 요청을 병렬로 보내므로, 이에 따른 네트워크 및 데이터 전송 비용이 발생할 수 있다.

 

  • 원래 Presigned URL을 이용하는 3번 방식을 통해 구현해보고 싶었으나 다음과 같은 이유로 2번을 사용하려고 한다.
  • 3번의 경우 클라이언트로 부터 파일 업로드 요청을 받으면 프론트는 다시 백엔드로 그 파일을 전달해주고 백엔드에서 다시 S3로 업로드하는 불필요한 과정이 발생한다.
  • 프로젝트 규모가 현재 크지 않고, 빠르게 기능 구현을 해야하는 상황에서 나만의 호기심으로 사용해보기엔 구현 복잡도가 높다.
  • 따라서 현실과 타협해 Multipart 파일을 S3에 업로드하는 방법을 통해 이미지 업로드 기능을 구현하려고 한다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

 

 

 

개발 환경 및 S3 버킷 생성

[Spring] S3 이미지 업로드하기 (velog.io)

S3 에 업로드하기 위해서 aws 가입과 S3 발급을 해야 하는데 나는 이분의 블로그를 보고 따라했다. iam 사용자를 만들고 s3 키를 발급받자.

 

 

의존성 설치

// s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

 

 

필요한 환경변수 세팅

application.yml 파일에 다음과 같이 작성한다.

spring:
   servlet:
      multipart:
          maxFileSize: 10MB # 파일 하나의 최대 크기
          maxRequestSize: 30MB  # 한 번에 최대 업로드 가능 용량
cloud:
  aws:
    credentials:
      access-key: {your access-key}
      secret-key: {your secret-key}
    s3:
      bucket: {your bucket name}
    region:
      static: ap-northeast-2
    stack:
      auto: false
  • MultipartFile를 사용하기로 했으니 적절한 파일 크키와 용량을 지정하고 S3 연동을 위한 설정을 해준다.

 

 

S3Config

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredentials= new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }
}
  • 버전이 올라가면서 작성법이 많이 달라져서 고생을 좀 했지만 엑세스와 시크릿 키를 통해 접근을 허용해주면 된다.

 

 

 

이미지 S3 업로드 구현

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {

    private final AmazonS3 amazonS3;

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

    private final String DIR_NAME = "{your image file name";

    public String upload(String fileName, MultipartFile multipartFile) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));

        String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
        String uploadImageUrl = putS3(uploadFile, newFileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 파일 삭제
        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }

    private String putS3(File uploadFile, String fileName) {
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucket, fileName).toString();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }
  • S3 에 이미지를 업로드 하는 클래스이다.
  • 각 함수들에 대해서 설명해보겠다.

1. upload 함수

public String upload(String fileName, MultipartFile multipartFile) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));

        String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
        String uploadImageUrl = putS3(uploadFile, newFileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 파일 삭제
        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }
}
  • 여기서 MutipartFile을 File 형태로 변환해주어야 한다.
  • 이 과정에서 로컬에서 파일이 복사되어 저장되기 때문에 로컬에 있는 이미지를 삭제해주어야 한다. 

2. putS3 메서드

    private String putS3(File uploadFile, String fileName) {
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucket, fileName).toString();
    }
  • 이 함수는 MultipartFile형태인 파일을 File 형태로 변환하고 업로드될 파일의 이름을 받아와서 S3에 업로드 하는 함수이다.

3. removeNewFile  메서드

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }
  • 이 함수는 로컬에 저장된 파일을 삭제해주는 메서드이다.

4. Convert  메서드

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }
  • 이 함수는 MultipartFile 형태인 파일을 File 형태로 변환해서 S3에 업로드 하는 메서드이다.

5. getExtension  메서드

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }
  • S3에 저장될 파일의 확장자를 정의해준다.

 

 

UserServiceImpl.java

@Override
public User updateProfileImage(Long userId, MultipartFile file) throws IOException {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID"));

    String fileName = userId.toString(); // 사용자 ID를 파일명으로 사용
    String imageUrl = s3Service.upload(fileName, file); // S3에 이미지 업로드 및 URL 반환

    user.setFilename(file.getOriginalFilename()); // 파일 이름 저장
    user.setFilepath(imageUrl); // 파일 경로(URL) 저장

    return userRepository.save(user); // 사용자 정보 업데이트
}
  • S3에 올라갈 때 사용자 Id를 파일명으로 지정하여 올리기로 결정했고
  • 클라이언트에서 사용자가 이미지를 등록했을 때 동작할 서비스 클래스 코드이다.
  • 그리고 엔티티에 파일경로를 저장하기로 해서 user 엔티티에 2개의 칼럼을 추가해준다.

 

User.java

@Entity
@Table(name = "users")
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username; // 이메일

    @Column(nullable = false)
    private String password;

    private String filename; // 파일 이름

    private String filepath; // 파일 경로
}

 

 

 

Controller.java

@RestController
@Slf4j
@RequiredArgsConstructor
public class S3Controller {


    private final S3Service s3Service;
    private final UserService userService;

    @PostMapping("/api/s3/image")
    public ResponseEntity<String> uploadProfileImage(
            @RequestParam("file") MultipartFile file,
            @RequestParam("userId") Long userId) throws IOException {

        User user = userService.updateProfileImage(userId, file);
        log.info("Uploaded file URL: " + user.getFilepath());
        return ResponseEntity.ok(user.getFilepath());
    }
  • 지정한 저 앤드포인트로 요청을 보내서 서버측에서 요청을 처리할 예정이다.

 

 

 

HTML & JS

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
            // 프로필 이미지 업로드
            $('#profile-form').on('submit', function(event) {
                event.preventDefault();

                let formData = new FormData(this);
                formData.append("userId", $('#userId').val()); // 유저 ID 추가

                $.ajax({
                    type: 'POST',
                    url: '/api/s3/image',
                    data: formData,
                    processData: false,
                    contentType: false,
                    success: function(response) {
                        alert('프로필 이미지가 성공적으로 업로드되었습니다.');
                        $('#profile-image').attr('src', response);
                    },
                    error: function(error) {
                        alert('프로필 이미지 업로드에 실패했습니다.');
                    }
                });
            });
        });
    </script>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 당신의 이메일 주소 :: <span th:text="${user.username}"></span>님</p>
<p>안녕하세요, 당신의 이름 :: <span th:text="${user.name}"></span></p>

<!-- 프로필 이미지 편집 섹션 -->
<h3>프로필 이미지 편집</h3>
<input type="hidden" id="userId" th:value="${user.id}"/>
<form id="profile-form">
    <input type="file" name="file" accept="image/*" required />
    <button type="submit">프로필 이미지 업로드</button>
</form>

<img id="profile-image" th:src="${user.filepath != null ? user.filepath : '/files/lioon.png'}" alt="Profile Image" style="max-width: 150px; max-height: 150px;" />

</body>
</html>

자바스크립트 파일로 지정한 앤드포인트로 요청을 보내면 S3에 업로드가 될 것이다. 

기본 사진은 미리 로컬에 지정해둔 사진으로 보여질 수 있도록 했다. 그 후 사용자가 프로필을 변경하면 변경될 수 있도록..

 

 

 

화면단에서 사진도 잘 올라가고 잘 보인다~~~ㅎㅎ

 

 

 

 

 

 

 

 

 

 

 

이렇게 이미지를 S3에 올리는 부분을 수행했다.

 

 

 

728x90
728x90

* Docker-Desktop을 이용해 MySQL DBMS를 실행

프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.

일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를 사용하냐고 물어보면 

도커(Docker)를 사용하는 이유는 무엇일까? (velog.io)

 

도커(Docker)를 사용하는 이유는 무엇일까?

도커를 사용하면 개발 환경을 이미지화 시키고 해당 이미지를 통해 개발 및 배포 환경을 쉽게 컨테이너화 시켜 구축할 수 있다. 이런 도커를 사용하는 이유에 대해 간단히 알아보자.개발을 하다

velog.io

이 분의 글을 읽으면 좋을 것 같다. 협업 과정에서 고충을 겪을 환경설정의 문제를 해결해준다는 점! 

그럼 첫 번째는 MySQL 설치부터 할 것이다.

아래에 있는 각 단계를 따라오면 된다.

 

도커에서 mysql을 사용하기 위해서 할 일은 다음 4가지이다.

  • Windows를 모두 업데이트 한다.
  • Hyper-V(가상화) 또는 WSL2를 이용해 설치를 한다.
  • Windows 버전에 따라서 설치가 어려울 수도 있다. (Home버전?)
  • Docker-Desktop을 설치한다. → 설치만 ! 실행은 나중에

 

1. 도커 설치

  • Windows용 Docker-Desktop 다운로드

Docker Desktop: The #1 Containerization Tool for Developers | Docker

 

Docker Desktop: The #1 Containerization Tool for Developers | Docker

Docker Desktop is collaborative containerization software for developers. Get started and download Docker Desktop today on Mac, Windows, or Linux.

www.docker.com

도커에서 제공해주는 메뉴얼도 있다.

Install Docker Desktop on Windows | Docker Docs

 

Install Docker Desktop on Windows

Get started with Docker for Windows. This guide covers system requirements, where to download, and instructions on how to install and update.

docs.docker.com

 

 

2. Hyper-V(가상화) 또는 WSL2를 이용해 설치

  • 나는 윈도우에서 사용중이라 이 과정을 걸쳤다.
  • 윈도우 검색에서 “기능”이라고 입력하면 윈도우 기능 켜기/끄기가 보여진다.
  • 여기에서 ‘Hyper-V’ 또는 ‘Windows 하이퍼바이저 플랫폼’을 선택한다.
  • Hyper-V는 하이퍼바이저(Hypervisor)라고 한다. 하이퍼바이저는 단일 물리적 머신에서 여러 가상 머신을 실행하는 데 사용할 수 있는 소프트웨어이다.
  • 선택 → 확인 → 윈도우를 재시작

  • 하이퍼바이저가 실행되려면 BIOS에서 가상화 관련된 옵션이 사용가능해야한다.
  • PC마다 BIOS메뉴 구성은 다를 수 있다.
  • 자신의 메인보드 이름으로 구글에서 검색한다.
  • 보통 BIOS는 컴퓨터가 켜지기 전 DEL , F2 키를 빠르게 입력하면 진입 가능하다. 컴퓨터마다 진입 버튼은 다를 수 있으니 찾아봐야 함

 

3. WSL2 설치

  • 관련메뉴얼이다

WSL 설치 | 마이크로소프트 런 (microsoft.com)

  • Windows의 PowerShell을 관리자 권한으로 실행 후 아래 명령어 2줄을 입력해서 WSL을 활성화 한다.
# WSL2 설치
> dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
> dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
  • 이후 윈도우를 재시작한다.

 

3.1. WSL2 Linux 커널 업데이트

  • 본인 PC의 CPU 아키텍처에 맞춰 설치 파일을 다운로드 한다. 보통 x64용

x64 : https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi

ARM64 : https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_arm64.msi

  • Windows의 PowerShell을 관리자 권한으로 실행 후 다음 명령어 실행
# WSL2 Linux 커널 업데이트
> wsl --set-default-version 2

 

 

WSL2 명령 모음

# 사용가능한 Linux 배포판 리스트 보기
> wsl --list --online

# 특정 리눅스 배포판 설치하기 (Linux를 사용해보고 싶으면 나중에 설치해보기)
> wsl --install --distribution <Distribution Name>
# ex
> wsl --install --distribution Ubuntu

# 설치된 Linux 배포판 리스트
> wsl --list --verbose

# 특정 리눅스 배포판 삭제하기
> wsl --unregister <Distribution Name>
# ex
> wsl --unregister ubuntu

 

3.2 도커 실행

  • 이전에 다운로드한 Docker Desktop Installer.exe 파일을 실행하면 WSL2 환겨에서 실행할 것인지 묻는 checkbox가 보일 수 있다. (권장)
  • WSL은 Hyper-V 기반이 아닌데, WSL2는 Hyper-V 기반 !
  • 설치 완료 후, PC를 재시작한다.
  • 이후, docker-desktop을 실행하면 처음 보여지는 화면 동의(Accept)한다.
  • 첫 번쨰 항목을 선택하고 Finish 버튼을 클릭한다.
  • 윈도우가 재시작할 때 docker desktop이 자동으로 재시작되길 원한다면 첫 번째 체크박스를 설정한다. (Start Docker Desktop when you sign in to your computer)

 

3.3 WSL에 설치된 배포본을 확인

  • wsl에 docker-desktop이 설치된 것을 확인할 수 있다.
  • docker-desktop을 설치하고 실행하면 WSL2에 docker가 제공하는 리눅스가 설치된다.
  • 이 리눅스가 docker-desktop이며, docker로 실행되는 이미지들은 해당 리눅스 커널 위에서 동작한다고 보면 된다.
# WSL에 설치된 배포본 확인
> wsl --list --verbose

ex

 

 

 

3.4  MySQL DBMS를 실행하기 위한 docker-compose.yml 파일을 준비

  • Windows 10
    • c:/docker/mysql 폴더에 docker-compose.yml 파일을 작성
    • docker-compose.yml
version: "2"

services:
  vacation-db:
    image: mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "루트계정암호" # "root1234"
      MYSQL_DATABASE: "database이름"     # "examplesdb"
      MYSQL_USER: "아이디"               # "dms2756"
      MYSQL_PASSWORD: "암호"             # "123456789"
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./database/init/:/docker-entrypoint-initdb.d/
      - ./database/datadir/:/var/lib/mysql
    platform: linux/x86_64
    ports:
      - 3306:3306

 

3.5 docker-compose up -d

  • yml 파일이 있는 곳에서 터미널을 열고 MySQL 서버를 실행하는 명령어를 입력한다.
# MySQL 서버 실행
> docker-compose up -d
  • -d 옵션은 백그라운드로 실행하는 옵션이다.
  • 파일 내 volumes 설정은 Docker 안에서 실행되는 리눅스의 /docker-entrypoint-initdb.d/ , var/lib/mysql 폴더가 docker-compose.yml 파일이 원래 있는 경로에 database 폴더를 생성하고, 그 아래의 init, datadir 에 생성되도록 한다.
  • 원래 docker는 종료되면 docker에서 실행된 내용들은 모두 사라진다.
  • 이렇게 docker를 실행하는 컴퓨터 쪽의 디스크와 마운트 시킴으로써 사라지지 않고 계속 사용할 수 있게 할 수 있다.
  • # docker-compose.yml의 일부 volumes: - ./database/init/:/docker-entrypoint-initdb.d/ - ./database/datadir/:/var/lib/mysql

 

docker-compose.yml 파일이 있는 경로

  • docker-compose.yml 파일이 있는 곳에 database 폴더가 있는 것을 볼 수 있다.
  • 해당 폴더 안에 mysql의 data가 저장된다.
  • docker-compose down 명령을 실행하면 mysql 서버가 종료된다. database 폴더를 삭제하고 다시 실행하면 모든 데이터가 삭제된다. (주의)
  • docker ps 명령으로 실행되는 이미지에서 container id를 복사 후 mySQL 이미지에 접속한다.
> docker ps
# mysql 접속
> docker exec -it {복사한conainterID} /bin/bash

 

 

그럼 끝났다.

 

 


* Docker-Desktop을 이용해 Redis를 실행 

위의 과정을 통해 도커가 설치되어 있다는 가정 하에 아래를 진행한다.

 

 

1. 도커 폴더 아래 redis 폴더를 생성한다.

2. redis 폴더 아래 docker-compose.yml 작성한다.

services:
  redis:
    image: redis:latest # 최신 Redis 이미지를 사용
    container_name: redis
    command: ["redis-server", "--requirepass", "{password}", "--appendonly", "yes"]
    ports:
      - "6379:6379" # 로컬의 6379 포트를 Redis 컨테이너의 6379 포트에 매핑
    volumes:
      - ./redis-data:/data # 로컬의 'redis_data' 볼륨을 컨테이너의 /data 디렉토리에 매핑하여 데이터 영속성 유지

volumes:
  redis-data:

3. 터미널을 열어서 아래의 명령어를 입력한다.

# 레디스 이미지 가져오기
> docker pull redis

# 이미지 가져왔는지 확인
> cker images

# 레디스 실행
> docker compose up -d

> docker ps 
# 레디스 컨테이너 접속
> docker exec -it {레디스 컨테이너 아이디} redis-cli

# 레디스 암호
> AUTH {password} 

# 모든 키 검색
> KEYS * 

# 키 값 출력
> KEYS refreshToken:* 

# 해당 키에 대한 값 출력
> GET {키 값} 

# 해당키의 유효 시간 출력
> TTL {키 값} 

# 해당키 삭제
> DEL {키 값}

 

 

 

그럼 레디스도 잘 설치될 것이다.

728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입에 성공 후 비밀번호를 잊어버렸을 시 코드 구현을 하고자 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

1. dto

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class PasswordVerificationRequest {

    private String mail;
    private String tempPassword;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

2. Service

public interface MailService {

    String createTemporaryPassword(String email);

    boolean verifyTemporaryPassword(String email, String tempPassword);

    void sendTemporaryPasswordMail(String email, String tempPassword);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "ch9800113@gmail.com";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 임시 비밀번호 자동 생성 메서드
     */
    private static String generateRandomPassword() {
        int length = 8;
        StringBuilder sb = new StringBuilder(length);
        Random random = new Random();
        for (int i = 0; i < length; i++) {
            sb.append((char) (random.nextInt(10) + '0'));
        }
        return sb.toString();
    }

    /**
     * 임시 비밀번호 전송
     */
    @Override
    public void sendTemporaryPasswordMail(String mail, String tempPassword) {
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("OMG 임시 비밀번호");
            String body = "<h2>OMG에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
            helper.setText(body, true);
            javaMailSender.send(message);
        } catch (MessagingException e) {
            throw new RuntimeException("임시 비밀번호 전송 오류", e);
        }
    }

    /**
     * 임시 비밀번호 생성 및 DB 업데이트
     */
    @Override
    public String createTemporaryPassword(String mail) {
        String tempPassword = generateRandomPassword();
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        user.setPassword(passwordEncoder.encode(tempPassword));
        userRepository.save(user);
        return tempPassword;
    }

    /**
     * 임시 비밀번호 검증
     */
    @Override
    public boolean verifyTemporaryPassword(String mail, String tempPassword) {
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        return passwordEncoder.matches(tempPassword, user.getPassword());
    }
}

 

 

[ 코드 설명 ]

/**
 * 임시 비밀번호 자동 생성 메서드
 */
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    for (int i = 0; i < length; i++) {
        sb.append((char) (random.nextInt(10) + '0'));
    }
    return sb.toString();
}
  • 반환 값: String (문자열)
  • 생성 방식: 이 메서드는 8자리의 숫자로 구성된 문자열을 생성하는 메서드이다.
    • 문자열은 StringBuilder를 사용하여 효율적으로 생성되도록 구현했다.
    • 각 반복에서 random.nextInt(10) + '0'을 통해 0부터 9까지의 숫자를 문자로 변환하여 문자열에 추가한다.
  • StringBuilder 사용이유 ::
    • String은 불변 객체(immutable object)이다. 즉 한 번 생성된 String은 변경할 수 없으며, 문자열의 조작은 새로운 String 객체를 생성하여 처리된다.
    • StringBuilder를 사용하여 문자열을 생성한 후, 최종적으로 toString() 메서드를 호출하여 불변의 String 객체를 반환하도록 구현했다.
    • 위의 코드는 숫자로만 구성했지만, 나중에 보안을 위해 아래처럼 작성하는 것으로 바꾸었다.
  •  
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    for (int i = 0; i < length; i++) {
        sb.append(characters.charAt(random.nextInt(characters.length())));
    }

    return sb.toString();
}
/**
 * 임시 비밀번호 전송
 */
@Override
public void sendTemporaryPasswordMail(String mail, String tempPassword) {
    MimeMessage message = javaMailSender.createMimeMessage();
    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("임시 비밀번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
        helper.setText(body, true);
        javaMailSender.send(message);
    } catch (MessagingException e) {
        throw new RuntimeException("임시 비밀번호 전송 오류", e);
    }
}
  • 반환 값: void
  •  생성 방식 : 이 메서드는 임시비밀번호를 이메일로 전송하는 기능만 수행하고, 결과를 반환할 필요가 없다
    • javaMailSender.send(message); 를 통해 메서드에서 바로 구현하여 바로 메일을 전송하였다.
  • 하지만 저번 포스트에서 회원가입시 이메일 인증 번호 전송 로직을 보면
@Override
public MimeMessage createMail(String mail){
    createNumber(mail);
    MimeMessage message = javaMailSender.createMimeMessage();

    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("이메일 인증번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
        helper.setText(body, true);
    } catch (MessagingException e) {
        e.printStackTrace();
    }

    return message;
}

/**
 * createMail() 메서드의 내용을 이메일 전송
 */
@Async
@Override
public CompletableFuture<Integer> sendMail(String mail) {
    MimeMessage message = createMail(mail);
    javaMailSender.send(message);
    return CompletableFuture.completedFuture(verificationCodes.get(mail));
}
  • 여기서 코드의 반환값은 MimeMessage 이다.
  • 즉 javaMailSender.send(message); 를 하지 않았기 때문에 따로 sendMail 메서드를 통해 이메일 전송을 해줘야 한다.
  • MimeMessage  란? ::
    • MimeMessage  객체는 이메일 메시지를 생성하고 설정하는 데 사용되는 객체이다.
    • 발신자, 수신자, 제목, 본문 내용, 첨부 파일 등 이메일의 모든 구성 요소를 설정할 수 있고 설정된 MimeMessage 객체는 JavaMailSender를 통해 이메일 서버로 전송해야 한다.
  • 나중에 코드 통일성을 위해 하나의 메서드에서 전송될 수 있도록 구현할 예정이다.

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 임시 비밀번호 재발급 발송 메서드
     */
    @PostMapping("/api/users/reset-password")
    public ResponseEntity<String> resetPassword(@RequestBody MailRequest mailRequest) {
        String email = mailRequest.getMail();

        if (userService.existsByUsername(email)) {
            String tempPassword = mailService.createTemporaryPassword(email);
            mailService.sendTemporaryPasswordMail(email, tempPassword);
            return ResponseEntity.ok("임시 비밀번호가 이메일로 발송되었습니다.");
        } else {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("해당 이메일로 가입된 사용자가 없습니다.");
        }
    }

    /**
     * 임시 비밀번호 검증 메소드
     */
    @PostMapping("/api/users/verify-temporary-password")
    public ResponseEntity<String> verifyTemporaryPassword(@RequestBody PasswordVerificationRequest request) {
        boolean isVerified = mailService.verifyTemporaryPassword(request.getMail(), request.getTempPassword());
        return isVerified ? ResponseEntity.ok("Verified") : ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Verification failed");
    }
}
/**
 * 비밀번호 재발급 페이지 이동
 */
@GetMapping("/users/reset-user-password")
public String showResetPasswordForm() {
    return "/user/findPassword";
}

 

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>비밀번호 재발급</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/find-password.css">
</head>
<body>
<form id="emailVerificationForm">

    <h1>비밀번호 재발급</h1>
    <label for="email">가입 이메일</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>
    <button type="button" id="send-code-button">임시 비밀번호 발송</button>
    <span id="emailCheckMessage" class="error"></span>

    <div id="verifyCodeSection">
        <label for="temporaryPassword">임시 비밀번호</label>
        <input type="text" id="temporaryPassword" name="temporaryPassword" placeholder="임시 비밀번호 입력" required />
        <button type="button" id="verify-temporary-password-button">임시 비밀번호 확인</button>
        <span id="verificationMessage" class="error"></span>
    </div>
</form>
<script>
    $(document).ready(function() {
        $('#verifyCodeSection').hide();

        $('#send-code-button').on('click', function() {
            let email = $('#email').val();
            $.ajax({ // 이메일이 데이터베이스에 있는지 확인
                url: '/api/users/check-email',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email }),
                success: function(response) {
                    if (response) {
                        $.ajax({ // 이메일이 존재하면 임시 비밀번호 발송
                            url: '/api/users/reset-password',
                            type: 'POST',
                            contentType: 'application/json',
                            data: JSON.stringify({ mail: email }),
                            success: function(response) {
                                $('#verifyCodeSection').slideDown(); // 인증 코드 입력 섹션 표시
                                alert('임시 비밀번호가 이메일로 발송되었습니다. 이메일을 확인해주세요.');
                            },
                            error: function(error) {
                                alert('임시 비밀번호 발송에 실패했습니다. 다시 시도해주세요.');
                            }
                        });
                    } else {
                        $('#emailCheckMessage').text('해당 이메일로 가입된 사용자가 없습니다.').show();
                    }
                },
                error: function(error) {
                    alert('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.');
                }
            });
        });

        $('#verify-temporary-password-button').on('click', function() {
            const email = $('#email').val();
            const tempPassword = $('#temporaryPassword').val();
            $.ajax({
                url: '/api/users/verify-temporary-password',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email, tempPassword: tempPassword }),
                success: function(response) {
                    if (response === "Verified") {
                        alert("임시 비밀번호가 확인되었습니다. 로그인하세요.");
                        window.location.href = "/signin";
                    } else {
                        $('#verificationMessage').text("임시 비밀번호가 일치하지 않습니다. 다시 시도하세요.").show();
                    }
                },
                error: function(xhr, status, error) {
                    alert("임시 비밀번호 검증에 실패했습니다. 다시 시도하세요.");
                }
            });
        });
    });
</script>
</body>
</html>
  1. 해당 이메일이 디비에 있는지 확인한다.
  2. 이메일이 존재한다면 해당 이메일로 임시 비밀번호를 전송해준다.
  3. 이때 폼에서는 숨겼던 섹션을 표시하고 임시비밀번호를 입력 란을 활성화한다.
  4. 임시비밀번호가 이메일로 전송한 값과 같은지 확인한다.
  5. 이때 임시비밀번호가 디비에 업데이트가 되어서 기존 비밀번호로 인증을 할 수 없게 된다.

 

여기까지 끝-!

 

 

 

 

 


jQuery에서 HTML 요소를 표시하는 두 가지 방법

 

  • jQuery 에서 아래의 두가지가 섹션을 보여주는 방식의 차이라서 정리한다.
// 애니메이션 효과로 서서히 표시
$('#verifyCodeSection').slideDown();

// 즉시 표시
$('#verifyCodeSection').show();

 

 

 

728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입 시 받는 이메일 인증은 사용자 식별 및 보안 강화를 위해 필요한 기술이다. 만약 이메일 인증과 같은 인증 기술이 없다면 한 사람이 10개 혹은 1000개의 계정을 무한대로 생성할 수 있다는 것인데, 이는 스팸이나 부정 사용 등 서비스 품질을 하락시킬 수 있다. 이메일 인증과 관련해서 구현하기까지 수많은 구글링과 시행착오가 있어서 정리해놓을려고 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

 

1. dto

이메일 인증에 필요한 dto 를 작성한다.

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class MailVerificationRequest {

    private String mail;
    private int code;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

 

2. Service

  • 구현하고자 하는 기능에 맞게 코드를 짠다.
public interface MailService {

    MimeMessage createMail(String mail);

    boolean verifyCode(String email, int code);

    CompletableFuture<Integer> sendMail(String mail);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "메일을 보낼 구글 이메일";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 인증 코드 자동 생성 메서드
     */
    public static void createNumber(String email){
        int number = new Random().nextInt(900000) + 100000; // 100000-999999 사이의 숫자 생성
        verificationCodes.put(email, number);
    }

    /**
     * 이메일 전송
     */
    @Override
    public MimeMessage createMail(String mail){
        createNumber(mail);
        MimeMessage message = javaMailSender.createMimeMessage();

        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("이메일 인증번호");
            String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
            helper.setText(body, true);
        } catch (MessagingException e) {
            e.printStackTrace();
        }

        return message;
    }

    /**
     * createMail() 메서드의 내용을 이메일 전송
     */
    @Async
    @Override
    public CompletableFuture<Integer> sendMail(String mail) {
        MimeMessage message = createMail(mail);
        javaMailSender.send(message);
        return CompletableFuture.completedFuture(verificationCodes.get(mail));
    }

    /**
     * 이메일 인증 코드 검증
     */
    @Override
    public boolean verifyCode(String mail, int code) {
        Integer storedCode = verificationCodes.get(mail);
        return storedCode != null && storedCode == code;
    }
}

1) 주어진 이메일 주소에 대해 6자리 인증 코드를 생성하고 verificationCodes 맵에 저장한다.
{이메일 : 인증코드} 형태로 저장될 것이다.

2) 입력한 이메일 주소로 발송할 이메일 메시지를 작성한다.

3) 2에서 생성한 이메일 메시지를 비동기적으로 발송한다.

4) 사용자가 입력한 인증코드와 실제 발송된 인증코드와 일치하는지 확인한다.

 

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 인증번호 발송 메소드
     */
    @PostMapping("/api/users/mail")
    public CompletableFuture<String> mailSend(@RequestBody MailRequest mailRequest) {
        return mailService.sendMail(mailRequest.getMail())
                .thenApply(number -> String.valueOf(number));
    }

    /**
     * 인증번호 검증 메소드
     */
    @PostMapping("/api/users/verify-code")
    public String verifyCode(@RequestBody MailVerificationRequest verificationRequest) {
        boolean isVerified = mailService.verifyCode(verificationRequest.getMail(), verificationRequest.getCode());
        return isVerified ? "Verified" : "Verification failed";
    }
}
  • 해당 앤드포인트로 요청이 들어오면 해당 요청을 수행한다.

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원가입</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/signup.css">
</head>
<body>
<form id="signup-form" action="/signup" method="post">
    <h1>회원가입</h1>
    <hr/>

    <div th:if="${error}" class="error-message">
        <p th:text="${error}"></p>
    </div>

    <label for="email">이메일</label>
    <input type="email" id="email" name="username" placeholder="이메일 입력" required/>
    <button type="button" id="check-email-button" class="light-button">중복확인</button>
    <span id="emailCheckMessage" class="error"></span>

    <button type="button" id="send-code-button" class="light-button" style="display: none;">인증 코드 발송</button>

    <div id="verifyCodeSection">
        <label for="verificationCode">인증 코드</label>
        <input type="text" id="verificationCode" name="verificationCode" placeholder="인증번호 입력" required/>
        <button type="button" id="verify-code-button" class="light-button">이메일 인증</button>
        <span id="verificationMessage" class="error"></span>
    </div>

    <button type="submit" id="signup-button" disabled>회원가입</button>
</form>
<script src="/js/signup.js"></script>
</body>
</html>
$(document).ready(function() {
    function validateForm() {
        let isValid = true;

        // 이메일, 닉네임 중복 및 비밀번호 일치 여부 검사
        if ($('#emailCheckMessage').hasClass('error')) {
            isValid = false;
        }
        $('#signup-button').prop('disabled', !isValid);
        return isValid;
    }

    // 이메일 중복 검사
    // 1. 이메일 중복되면 인증 메일 보내기 버튼은 숨김
    // 2. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
    $('#check-email-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/check-email',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                if (response) {
                    $('#emailCheckMessage').text("아이디가 이미 존재합니다.").removeClass('success').addClass('error');
                    $('#send-code-button').hide();  
                } else {
                    $('#emailCheckMessage').text("사용 가능한 아이디입니다.").removeClass('error').addClass('success');
                    $('#send-code-button').show();  
                }
                validateForm();
            },
            error: function(error) {
                $('#emailCheckMessage').text('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.').removeClass('success').addClass('error');
                $('#send-code-button').hide();  
                validateForm();
            }
        });
    });

    // 인증 메일
    $('#send-code-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/mail',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                $('#verifyCodeSection').show(); 
                alert('인증 메일이 발송되었습니다. 인증 번호를 확인해주세요.');
            },
            error: function(error) {
                alert('메일 발송에 실패했습니다. 다시 시도해주세요.');
            }
        });
    });

    // 인증 코드 확인
    $('#verify-code-button').on('click', function() {
        let email = $('#email').val();
        let code = $('#verificationCode').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/verify-code',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email, code: code }),
            success: function(response) {
                if (response === 'Verified') {
                    $('#verificationMessage').text('인증 성공').removeClass('error').addClass('success');
                } else {
                    $('#verificationMessage').text('인증 실패. 올바른 코드를 입력하세요.').removeClass('success').addClass('error');
                }
            },
            error: function(error) {
                $('#verificationMessage').text('인증 실패. 다시 시도해주세요.').removeClass('success').addClass('error');
            }
        });
    });

    // 인증 코드 발송 버튼 --> 초기 상태에서는 비활성화
    $('#send-code-button').hide();

    // 인증 코드 입력 란 숨기기
    $('#verifyCodeSection').hide();
});
  • 사실 HTML 폼에서 닉네임, 연락처, 생년월일 등 적는 란이 있지만 이메일 인증에 필요한 코드만 남겨두었다.
  1. 이메일이 중복되어 있는지 확인한다. 이 부분은 위에 자바 코드에는 없다.
  2. 이메일이 중복되지 않았다면 해당 이메일로 인증번호를 보낸다.
  3. 인증번호 발송 버튼이 활성화된다. 
// 이메일 중복 검사
    // 1. 이메일 중복되면 인증 메일 보내기 버튼은 숨김
    // 2. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
    $('#check-email-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/check-email',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                if (response) {
                    $('#emailCheckMessage').text("아이디가 이미 존재합니다.").removeClass('success').addClass('error');
                    $('#send-code-button').hide();  // 오류가 있으면 인증 코드 발송 버튼 숨김
                } else {
                    $('#emailCheckMessage').text("사용 가능한 아이디입니다.").removeClass('error').addClass('success');
                    $('#send-code-button').show();  // 이메일 체크 통과 시 버튼 표시
                }
                validateForm();
            },
            error: function(error) {
                $('#emailCheckMessage').text('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.').removeClass('success').addClass('error');
                $('#send-code-button').hide();  // 오류가 있으면 인증 코드 발송 버튼 숨김
                validateForm();
            }
        });
    });

4) 인증코드 입력 섹션이 활성화된다.

// 인증 메일
    $('#send-code-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/mail',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                $('#verifyCodeSection').show(); // 인증 코드 입력 섹션 표시
                alert('인증 메일이 발송되었습니다. 인증 번호를 확인해주세요.');
            },
            error: function(error) {
                alert('메일 발송에 실패했습니다. 다시 시도해주세요.');
            }
        });
    });

5) 해당 이메일과 입력한 코드를 /api/users/verify-code 앤드포인트에서 수행한다.

$('#verify-code-button').on('click', function() {
    let email = $('#email').val();
    let code = $('#verificationCode').val();

    $.ajax({
        type: 'POST',
        url: '/api/users/verify-code',
        contentType: 'application/json',
        data: JSON.stringify({ mail: email, code: code }),
        success: function(response) {
            if (response === 'Verified') {
                $('#verificationMessage').text('인증 성공').removeClass('error').addClass('success');
            } else {
                $('#verificationMessage').text('인증 실패. 올바른 코드를 입력하세요.').removeClass('success').addClass('error');
            }
        },
        error: function(error) {
            $('#verificationMessage').text('인증 실패. 다시 시도해주세요.').removeClass('success').addClass('error');
        }
    });
});

 

 

 

5. 결과

 

위에처럼 작성 후 실행하면

해당 이메일로 인증 번호가 전송된다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

다음 편은 비밀번호 찾기 로직을 정리할 것이다.

728x90

+ Recent posts