[JAVA PROJECT] (7)

이번에는 댓글관련부분을 작성해보려고 합니다.  댓글에 대한 댓글은 구현하지않았고 자유게시판에만 댓글이 가능하도록 구성하였습니다.

 

1.  엔티티및 레포지로티 설정

1-1 commentEntity.java

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "comment")
public class CommentEntity {

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

    private String content;
    private String writer;
    private LocalDateTime timestamp;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "frboardid")
    private FreeBoardEntity freeBoard;
}

@GeneratedValue(strategy = GenerationType.IDENTITY)

엔티티의 Primary Key값이 db에 의해 자동으로 생성되도록 지정해줍니다.

 

@ManyToOne(fetch = FetchType.LAZY)

다대일관계를 나타내는 어노테이션으로 LAZY로딩 전략은 연관된 객체를 필요로 할때만 로드하여 성능을 최적화시킵니다. 즉, 객체를 사용할때까지 실제 데이터베이스 조회를 연기합니다.

 

@JoinColumn(name = "frboardid")

어노테이션은 현재 엔티티가 참조하는 다른 엔티티의 기본 키를 가리키는 외래 키(Foreign Key) 열을 매핑합니다. 여기서 frboardidCommentEntity가 참조하는 FreeBoardEntity의 기본 키를 나타냅니다.

 

댓글에 대한 id값, 내용, 작성자, 작성시간 그리고 어느게시글에 작성했는지 알기위한 연관관계가 필요합니다.

Freeboard : Reply : User -> 1: N : 1

한게시글에 여러 개의 댓글과 한 유저가 여러개의 댓글을 달 수 있습니다.

 

1-2 CommentRepository.java

public interface CommentRepository extends JpaRepository<CommentEntity, Long> {
	
	// 특정 게시글 ID에 해당하는 모든 댓글을 조회하는 메서드 (MYPAGE 댓글작성목록을 확인할때 사용됩니다.)
	List<CommentEntity> findByFreeBoard_Frboardid(Integer frboardid);
	
	// 특정 게시글 ID에 해당하는 댓글 수를 카운트하는 메서드(MYPAGE 작성한댓글수를 확인할때 사용됩니다)
	int countByFreeBoard_Frboardid(Integer frboardid);
	
	// 특정 게시글과 연관된 모든 댓글을 삭제하는 메서드 (게시글을 삭제했을떄 댓글도 삭제되어야하므로)
	@Transactional
	@Modifying
	@Query("DELETE FROM CommentEntity c WHERE c.freeBoard.frboardid = :frboardid")
	void deleteByFreeBoardId(Integer frboardid);
}

@Transactional은 일련의 데이터베이스 작업들이 모두 성공하거나 실패하는 것을 보장합니다.
이 경우, deleteByFreeBoardId 메서드가 데이터베이스 작업을 수행할 때, 작업들이 모두 성공하거나, 오류 발생 시 롤백(원 상태로 되돌림)이 일어나도록 합니다.

 

@Modifying은 메서드가 데이터를 변경하는 쿼리(예: DELETE, UPDATE)를 실행함을 명시합니다.
@Query와 함께 사용되며, 데이터 조회가 아닌 수정, 삭제 등의 작업을 수행합니다.

 

1-3. 프론트엔드부분 HTML

Freeboard.html

<!-- 댓글 목록 -->
<section>
    <ul class="list-unstyled">
        <li th:each="comment : ${comments}">
            <div class="comment-content" id="comment-${comment.id}">
                <strong th:text="${comment.writer}"></strong>
                <p th:text="${comment.content}"></p>
                <small th:text="${#dates.format(comment.timestamp, 'yyyy-MM-dd HH:mm')}"></small>
                <!-- 댓글 수정 및 삭제 버튼 -->
                <button class="edit-comment" th:data-comment-id="${comment.id}">수정</button>
                <button class="delete-comment" th:data-comment-id="${comment.id}">삭제</button>
            </div>
        </li>
    </ul>
</section>

 

 

 

2. 댓글 작성 부분

2-1.  프론트엔드 html     Freeboard.html

<!-- 댓글 작성 폼 -->
<form th:action="@{/community/freeboard/{frboardid}/addComment(frboardid=${freeboard.frboardid})}" method="post">
    <textarea name="content" required placeholder="댓글을 입력하세요"></textarea>
    <button type="submit">댓글 등록</button>
</form>

 

댓글작성폼을 작성합니다.  이폼은 로그인한 사용자만 사용할수 있도록 해야합니다.

 

2-2.  FreeBoardController.java  comment구문 추가 

            // 댓글 추가 (로그인 필요)
		@PostMapping("/{frboardid}/addComment")
		public String addComment(@PathVariable Integer frboardid, CommentEntity comment, Principal principal) {
		    FreeBoardEntity board = frbService.freeBoardView(frboardid);
		    if (board == null) {
		     
		        return "redirect:/community/freeboard/list";
		    }
		    comment.setFreeBoard(board);// 댓글객체에 조회된 게시글 객체를 연결
		    comment.setWriter(principal.getName());  // 댓글작성자를 현재로그인된 사용자로 연결
		    comment.setTimestamp(LocalDateTime.now());  // 댓글작성시간을 현재시간으로
		    frbService.saveComment(comment); // 댓글객체를 db에 저장
		    return "redirect:/community/freeboard/view?id=" + frboardid;  //댓글추가완료후 상세페이지로 리다이렉트
		}

 

2-3.  FreeBoardService.java  comment구문 추가 

// 댓글저장
		 public CommentEntity saveComment(CommentEntity comment) {
		        return commentRepository.save(comment);
		    }

댓글을 저장할때는 게시글의id를 기반으로 해당게시물을 조회합니다.  saveComment는 설정된 댓글 객체를 데이터베이스에 저장합니다. 

 

 

3. 댓글 수정 및 삭제

3-1.  FreeBoardController.java 수정 및 삭제 구문 추가 

 

댓글수정

@PostMapping("/comment/{commentId}/update")
		public ResponseEntity<?> updateComment(@PathVariable Long commentId, @RequestBody Map<String, String> payload) {
		    CommentEntity existingComment = frbService.findCommentById(commentId).orElse(null); //해당id의 댓글내용을 찾습니다.
		    if (existingComment == null) {
		        return ResponseEntity.notFound().build(); // 댓글이 없는 경우 404 에러 반환
		    }
		    existingComment.setContent(payload.get("content")); // 찾은 댓글의 내용을 새로운 내용으로 업데이트 합니다.
		    frbService.updateComment(existingComment); // 업데이트된 댓글을 저장합니다.
		    return ResponseEntity.ok().build(); // 성공적으로 업데이트한 경우 OK 상태 코드 반환
		}

 

댓글삭제

		@PostMapping("/comment/{commentId}/delete")
		public String deleteComment(@PathVariable Long commentId) {
		    CommentEntity comment = frbService.findCommentById(commentId).orElse(null); 
		    if (comment == null) {
		        return "redirect:community/freeboard/list";   
		    }
		    Integer boardId = comment.getFreeBoard().getFrboardid();
		    frbService.deleteComment(commentId);   // 존재한다면 해당댓글을 삭제합니다.
		    return "redirect:/community/freeboard/view?id=" + boardId;
			}

@PostMapping("/comment/{commentId}/delete"): 특정 댓글 ID에 대한 삭제 요청을 처리합니다.
if (frbService.findCommentById(commentId).isPresent()): 해당 ID의 댓글이 존재하는지 확인합니다.
frbService.deleteComment(commentId): 존재한다면 해당 댓글을 삭제합니다.

 

 

3-2.  FreeBoardService.java 수정 및 삭제 구문 추가 

            // 댓글 업데이트
	    public void updateComment(CommentEntity comment) {
	        commentRepository.save(comment);
	    }
	    // 댓글 삭제
	    public void deleteComment(Long id) {
	        commentRepository.deleteById(id);
	    }

updateComment(CommentEntity comment)

 주어진 CommentEntity를 데이터베이스에 저장합니다. 이미 존재하는 ID의 경우 내용을 업데이트합니다.
deleteComment(Long id)

주어진 ID를 가진 CommentEntity를 데이터베이스에서 삭제합니다.

 

3-3.  프론트엔드 html     Freeboard.html 수정 삭제구문 추가

<div th:each="comment : ${comments}">
    <!-- 댓글 내용 -->
    <div th:text="${comment.content}"></div>
    <!-- 수정 버튼 -->
    <button th:attr="data-comment-id=${comment.id}" class="edit-comment-btn">수정</button>
    <!-- 삭제 버튼 -->
    <button th:attr="data-comment-id=${comment.id}" class="delete-comment-btn">삭제</button>
</div>

 

자바스크립트 Freeboard.js 생성

댓글수정 및 삭제 이벤트 처리

// 댓글 수정
document.querySelectorAll('.edit-comment-btn').forEach(btn => {
    btn.addEventListener('click', function() {
        let commentId = this.dataset.commentId;
        let newContent = prompt("댓글 내용을 수정하세요");
        if(newContent) {
            fetch(`/comment/${commentId}/update`, {
                method: 'POST',
                body: newContent
            }).then(response => {
                if(response.ok) {
                    alert("댓글이 수정되었습니다.");
                    location.reload();
                }
            });
        }
    });
});

// 댓글 삭제
document.querySelectorAll('.delete-comment-btn').forEach(btn => {
    btn.addEventListener('click', function() {
        let commentId = this.dataset.commentId;
        if(confirm("댓글을 삭제하시겠습니까?")) {
            fetch(`/comment/${commentId}/delete`, {
                method: 'POST'
            }).then(response => {
                if(response.ok) {
                    alert("댓글이 삭제되었습니다.");
                    location.reload();
                }
            });
        }
    });
});

자바스크립트를 사용하여 댓글의 수정 및 삭제 작업을 동적으로 처리합니다.
댓글 수정: 사용자가 '수정' 버튼을 클릭하면, 새로운 내용을 입력할 수 있는 프롬프트가 나타납니다. 새로운 내용이 입력되면 AJAX 요청을 통해 서버에 수정 요청을 보냅니다.
댓글 삭제: 사용자가 '삭제' 버튼을 클릭하면, 삭제 확인 메시지가 나타납니다. 사용자가 확인하면 AJAX 요청을 통해 서버에 삭제 요청을 보냅니다.

이번에는 게시글 및 댓글의 CRUD가 가능하도록 자유게시판을 만들어보려고 합니다.

 

1. FreeBoardEntity.java

entit패키지에 새로운 자바 클래스를 생성해줍니다.

@Entity
@Data
@Table(name="freeboard")
public class FreeBoardEntity
{
	@Id
	@GeneratedValue( strategy = GenerationType.IDENTITY  )
	private Integer frboardid;

	private String frboardwriter;
	private String frboardtitle;
	private String frboardcontent;
	private Integer frboardhits; //조회수
	
	@Column(name = "frboardcreatetime")
	private LocalDateTime frboardcreatetime; //생성일자
	 
	@Column(name = "frboardupdatetime")
	private LocalDateTime frboardupdatetime; //수정일자
	
	private String tags;
	
}

 

2.  FreeboardRepository.java 작성

FreeboardEntity에 대한 CRUD작업을 제공하는 JpaRepository를 확장하는 인터페이스인 FreeboardRepository.java를 작성합니다.

@Repository
public interface FreeBoardRepository extends JpaRepository<FreeBoardEntity, Integer>{
		
	}

 

3. 글 쓰기 기능 구현

먼저 글작성 기능을 구현하는 과정에 대해 작성하려고 합니다.

 

3-1.FreeBoardService.java 작성

@Service
public class FreeBoardService {
	
	@Autowired
	private FreeBoardRepository frbreBoardRepository;
    
    // 글 작성하기
	public void FreeBoardWrite( FreeBoardEntity entity ) {
		frbreBoardRepository.save(entity);
	}
}

 

 3-2. FreeboardController.java 작성

@Controller
@RequestMapping("/community/freeboard")
public class FreeboardController {

	// 게시글 작성 페이지을 표시
		@GetMapping("/write")
		   public String commFreeBoardWriteForm(Model model, Principal principal) {
			 
			   // 현재 로그인한 사용자의 이름을 가져옴
			   String username = principal.getName();
	
			   model.addAttribute("user", username);
			
			   
			   return "community/commfreeboardwrite";
		   }
           
    // 작성된 게시글을 처리하고 데이터베이스에 저장
		@PostMapping("/writedo")
		   public String commFreeBoardWritedo( FreeBoardEntity frbEntity,  Model model) {
			LocalDateTime LDTime = LocalDateTime.now(); // LocalDateTime을 호출하여 현재시간을 가져옴 
			frbEntity.setFrboardcreatetime(LDTime); // 게시글 엔티티의 생성시간을 현재시간으로 설정함
			frbEntity.setFrboardhits(0); // 조회수를 0으로 설정함
			
			frbService.FreeBoardWrite( frbEntity ); //서비스의 해당 메서드에 게시글 엔티티를 전달하여 데이터베이스에 저장
			return "redirect:list";
		   }

 

3-3. 프론트엔드 html 

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org">

<head>
 //생략
</head>

<body>
	<div class="container">
		<div class="row"> 
		    <div class="col-lg-9">
			  <div class="layout">
				 <form action="/community/freeboard/writedo" method="post">	
						<h3>게시글 작성</h3> <br/>
							<div class="sub">
								제목&nbsp; <input type="text" name="frboardtitle" style="font-size: 16px;"> &nbsp;&nbsp; 
								작성자&nbsp; <input type="text" th:value="${user}" name="frboardwriter" style="font-size: 16px;" readonly> &nbsp;&nbsp;
								태그&nbsp; <input type="text" name="tags"  style="font-size: 16px;">
							</div>
							  <div class="sub">
				       		 </div>
							<textarea name="frboardcontent" id="frboardcontent"></textarea>
							<input type="submit" value ="작성">
						</form>
						</div>
					</div>	
			</div>
	</div>
</body>
</html>

 

CKEDITOR  

웹기반의 텍스트 에디터로 HTML을 생성하고 수정할수 있도록 도와주는 도구입니다.

텍스트작성부분은 ckeditor을 사용하여 구성하였습니다.

https://www.codingfactory.net/13253  

 

4. 글목록 부분 구현

4-1.FreeBoardService.java 추가

@Service
public class FreeBoardService {
	
	@Autowired
	private FreeBoardRepository frbreBoardRepository;
	
    //.. 글 작성부분 
    
    //  글 리스트 불러오기
	public Page< FreeBoardEntity > freeBoardList( Pageable pageable ){	
		return frbreBoardRepository.findAll( pageable );
	}
    
  }

 

Page<FreeBoardEntity>

Page<>는 Spring Data JPA의 페이징처리를 위한 인터페이스입니다. 

Pageable은 Spring Data의 일부로, 페이지네이션을 위한 정보(페이지 번호, 페이지당 항목 수, 정렬 등)를 담고 있습니다.

 

findAll

연결된 데이터베이스 테이블의 모든 행을 조회하고, 각 행을 해당 엔티티 클래스의 인스턴스로 변환하여 반환합니다.

여기에서는 Pageable 객체를 매개변수로 받아, 해당 조건에 맞게 페이지처리된 게시글데이터를 Page<FreeBoardEntity> 형태로 반환해줍니다.

 

 4-2. FreeboardController.java 추가

@Controller
@RequestMapping("/community/freeboard")
public class FreeboardController {
	 @Autowired
	    private FreeBoardService frbService;
	@GetMapping("/write")
    // 게시글 작성부분

   // 게시판 목록 페이지 표시, Pagination을 사용하여 게시글을 페이지별로 나눔
		@GetMapping("/list")
	    public String commFreeBoardList( Model model , @PageableDefault( page = 0, size = 10, sort = "frboardid",  //첫페이지를0으로 설정하고 페이지당 10개의 게시글을 출력, frboardid필드를 기준으로 내림차순
	    								direction=Sort.Direction.DESC ) Pageable pageable) {
			Page<FreeBoardEntity> list = frbService.freeBoardList(pageable);
			int nowpage = list.getPageable().getPageNumber() + 1; //현재페이지번호
			int startpage = Math.max( nowpage - 3 , 1); //시작페이지번호
			int endpage = Math.min( nowpage+3 , list.getTotalPages()); //종료페이지번호

			model.addAttribute("nowpage", nowpage);
			model.addAttribute("startpage", startpage);
			model.addAttribute("endpage", endpage);
			model.addAttribute("list", frbService.freeBoardList( pageable ));
			
			return "community/commfreeboardlist";
	    }
	}

Page

1. 현재 페이지 번호 (nowpage): 사용자가 현재 보고 있는 페이지 번호입니다. 이는 페이지네이션 컨트롤에서 '현재 페이지'를 강조하는 데 사용됩니다.
getPageNumber() 메소드는 현재 페이지의 인덱스를 반환합니다. 일반적으로 페이지 인덱스는 0부터 시작하므로, 사용자 친화적인 표시를 위해 1을 더해줍니다.
시작 페이지 번호 (startpage) 및 종료 페이지 번호 (endpage): 이들은 페이지네이션 바에서 표시할 페이지 범위를 결정합니다. 예를 들어, 사용자가 5번 페이지를 보고 있다면, 시작 페이지는 2, 종료 페이지는 8로 설정될 수 있습니다. 
이렇게 하면 사용자는 현재 페이지 주변의 페이지로 쉽게 이동할 수 있습니다.
startpage는 현재 페이지에서 3을 뺀 값이나, 최소 1로 설정합니다. endpage는 현재 페이지에서 3을 더한 값이나, 최대 페이지 수로 설정합니다.
freeBoardList(pageable) 메서드는 Pageable 객체에 따라 필요한 페이지의 게시글 목록을 반환합니다.

 

4-3. 프론트엔드 html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org">

<head>
    <!-- 생략 -->
</head>

<body>
    <div class="loggedin-status" th:if="${#authorization.expression('isAuthenticated()')}" style="display: block;"></div>
    
    <div class="container">
        <div class="row">
            <!-- Main content -->
            <div class="col-lg-9 mb-3">
                <div class="row text-left mb-5"> </div>
                <!-- End of post 1 -->
                <h4>자유게시판</h4><br/>
                <div th:each="freeboard : ${list}" class="card row-hover pos-relative py-3 px-3 mb-3 border-warning border-top-0 border-right-0 border-bottom-0 rounded-0">
                    <div class="row align-items-center">
                        <div class="col-md-8 mb-3 mb-sm-0">
                            <h5>
                                <a th:text="${freeboard.frboardtitle}" style="color: black;" th:href="@{/community/freeboard/view(id=${freeboard.frboardid})}"></a>
                            </h5>
                            <p class="text-sm">
                                <span th:text="${#temporals.format(freeboard.frboardcreatetime, 'yyyy-MM-dd')}" class="text-black"></span>
                                <span class="op-6">  by </span>
                                <a class="text-black" th:text="${freeboard.frboardwriter}"></a> 
                            </p>
                            <div th:if="${freeboard.tags != null}" class="text-sm op-5">
                                <span th:each="tag : ${freeboard.tags.split(',')}">
                                    <a class="text-black mr-2" th:href="'#'" th:text="'#' +${tag.trim()}"></a>
                                </span>
                            </div>
                        </div>
                        <div class="col-md-4 op-7">
                            <div class="row text-center op-7">
                                <div class="col px-1">
                                    <i class="ion-ios-chatboxes-outline icon-1x"></i>
                                    <span class="d-block text-sm" th:text="${commentCounts[__${freeboard.frboardid}__]} + ' 댓글' "></span>
                                </div>
                                <div class="col px-1">
                                    <i class="ion-ios-eye-outline icon-1x"></i>
                                    <span class="d-block text-sm" th:text="${freeboard.frboardhits} + ' 조회' "></span>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="paging_block">
                    <ul class="pagination">
                        <!-- Previous Page Link -->
                        <li class="page-item" th:if="${list.hasPrevious()}">
                            <a class="page-link" th:href="@{/community/freeboard/list(page=${list.number - 1})}" aria-label="Previous">
                                <span aria-hidden="true">&laquo;</span>
                            </a>
                        </li>
                        <!-- Page Number Links -->
                        <li class="page-item" th:each="pageNum : ${#numbers.sequence(startpage, endpage)}" th:class="${pageNum - 1 == list.number ? 'active' : ''}">
                            <a class="page-link" th:href="@{/community/freeboard/list(page=${pageNum - 1})}" th:text="${pageNum}"></a>
                        </li>
                        <!-- Next Page Link -->
                        <li class="page-item" th:if="${list.hasNext()}">
                            <a class="page-link" th:href="@{/community/freeboard/list(page=${list.number + 1})}">
                                <span aria-hidden="true">&raquo;</span>
                            </a>
                        </li>
                    </ul>
                </div>  
                <a  class="btn btn-lg btn-block btn-success rounded-0 py-4 mb-3 bg-op-6 roboto-bold write-button" 
                    style="background-color: #7A297B;" th:href="@{/community/freeboard/write}">게시글 작성</a>
            </div>
        </div>
    </div>
</body>
</html>

 

 

5. 글상세보기 부분구현

 목록페이지에서 게시글제목을 클릭했을때 출력되는 글상세보기 페이지를 구현할 것입니다.

 

5-1. FreeBoardService.java 구문추가

@Service
public class FreeBoardService {
	
	@Autowired
	private FreeBoardRepository frbreBoardRepository;
	
	// 글 작성하기
	// 글 리스트 불러오기

	// 글 상세보기 페이지 불러오기
    public FreeBoardEntity freeBoardView( Integer id ) {
            return frbreBoardRepository.findById( id ).get() ;
        }

findById(id)

데이터베이스에서 주어진 id를 가진 FreeBoardEntity 객체를 찾습니다. 만약 해당 ID의 게시글이 데이터베이스에 존재한다면, 이 메서드는 Optional<FreeBoardEntity> 타입의 객체를 반환합니다.

 

5-2. FreeboardController.java 추가

@GetMapping( "/view" ) 
			public String boardView( Model model , Integer id, FreeBoardEntity frbEntity) {
				FreeBoardEntity frbTemp = frbService.freeBoardView(id); //id에 해당하는 게시글을 불러옵니다.
				if(frbTemp.getFrboardhits() == null) {
					int hits = 0 ;	 //조회수가null일경우0으로 설정하고, 
					frbTemp.setFrboardhits( hits );  
				}else { 
					int hits = frbTemp.getFrboardhits() + 1 ; //그렇지않으면 조회수를 1증가시킵니다.
					frbTemp.setFrboardhits( hits );
				}
				
				frbService.FreeBoardWrite( frbTemp );
				model.addAttribute( "freeboard", frbService.freeBoardView(id)); //상세보기할 게시글정보를 model에 추가합니다.
			
				
				return "community/commfreeboardview"; //해당페이지로 return합니다.
			}

 

5-3. 프론트엔드 html 

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org">
<head>
    <!-- 생략 -->
</head>
    
<body>
    <!-- Navbar Start -->
    <nav class="navbar navbar-expand-lg bg-white navbar-light shadow sticky-top p-0">
        <a href="/" class="navbar-brand d-flex align-items-center px-4 px-lg-5">
            <h2 class="m-0 text-primary" style="font-family: 'Nanum Gothic', sans-serif;">
                <img src="/img/winelogo.jpg" alt="Image" width="50" height="50">
                KOWASA &nbsp;&nbsp; <h5>한국와인 정보 커뮤니티</h5>
            </h2>
        </a>
        <button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarCollapse">
            <div class="navbar-nav ms-auto p-4 p-lg-0">
                <a href="/" class="nav-item nav-link active">Home</a>
                <div class="nav-item dropdown">
                    <a href="/search" class="nav-item nav-link active">와인검색</a>  
                </div>
                <div class="nav-item dropdown">
                    <a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown">커뮤니티</a>
                    <div class="dropdown-menu fade-up m-0">
                        <a href="/community/freeboard/list" class="dropdown-item">자유게시판</a>
                        <a href="/community/eventboard/list" class="dropdown-item">행사정보</a>
                    </div>
                </div>
                <div class="nav-item dropdown">
                    <a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown">와인파트너스</a>
                    <div class="dropdown-menu fade-up m-0">
                        <a href="/partners/producer/list" class="dropdown-item">생산자</a>
                        <a href="/partners/shop/list"class="dropdown-item">샵/레스토랑 정보</a>
                        <a href="/partners/job/list" class="dropdown-item">구인구직</a>
                    </div>
                </div>
            </div>
            <a href="/login" class="btn btn-primary py-4 px-lg-5 d-none d-lg-block" th:if="${#authorization.expression('isAnonymous()')}">
                Login<i class="fa fa-arrow-right ms-3"></i>
            </a>
            <div class="btn btn-primary py-4 px-lg-5 d-none d-lg-block" th:if="${#authorization.expression('isAuthenticated()')}">
                <p style="margin-top: 10px;"><span th:text="${#authentication.name}"></span>님, 환영합니다.</p>
                <button style="padding: 2px 5px;" onclick="location.href='/logout'">로그아웃</button>
                <button style="padding: 2px 5px;" onclick="location.href='/mypage'">마이페이지</button>
            </div>
        </div>
    </nav>
    <!-- Navbar End -->
    <!-- Back to Top -->
    <a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a> 
    
    <!-- Page content-->
    <div class="container mt-5">
        <div class="row">
            <div class="col-lg-8">
                <!-- Post content-->
                <article>
                    <!-- Post header-->
                    <header class="mb-4">
                        <!-- Post title-->
                        <h1 class="fw-bolder mb-1"> <a th:text="${freeboard.frboardtitle}"></a> </h1>
                        <!-- 작성자 및 등록일 -->
                        <div class="text-muted fst-italic mb-2">
                            작성자 <a th:text="${freeboard.frboardwriter}"></a> 
                            | 작성일 <a th:text="${freeboard.frboardcreatetime.toLocalDate()}"></a>
                            | 조회수 <a th:text="${freeboard.frboardhits}"></a>
                        </div>
                    </header>
                    <!-- 글 내용 -->
                    <section class="mb-5">
                        <div class="content-section" th:utext="${freeboard.frboardcontent}"></div>
                    </section>
                </article>
            </div>
        </div>
    </div>
</body>
</html>

 

6. 글 삭제하기 

게시판을 삭제하는 부분을 구현해보겠습니다.

 

6-1. FreeBoardService.java 구문추가

@Service
public class FreeBoardService {
	
	@Autowired
	private FreeBoardRepository frbreBoardRepository;

	// 글 작성하기
    
	//  글 리스트 불러오기
    
	// 글 상세보기 불러오기
	
	// 글 삭제하기
	public void freeBoardDelete( Integer id ) {
		
        // 게시글 삭제
        frbreBoardRepository.deleteById(id);
	}

@Autowired 

FreeBoardRepository라는 필드에 대한 의존성 주입을 나타냅니다.

 

freeBoardDelete메소드 특정id를 가진 게시물을 데이터베이스에서 삭제합니다.

 

 

6-2. FreeBoardController.java 구문추가

@Controller
@RequestMapping("/community/freeboard")
public class FreeboardController {
	 @Autowired
	    private FreeBoardService frbService;

	 	// 게시판 목록 
		
		// 게시글 작성 
        
                 // 게시판 상세보기 
	
		 // 게시글 삭제
		@PostMapping( "/delete" ) 
		public String boardDelete( @RequestParam("id") Integer id) {
				
				frbService.freeBoardDelete(id);
				return "redirect:list";
			}

@RequestParam("id") Integer id

id라는 이름의 파라미터를 정수형으로 받아들이도록 지시, 이파라미터는 삭제할 게시물의 id를 나타냅니다.

 

restful형식을 준수하자면 http메서드 @deleteMapping으로 작성해야된다고 알고 있었으나 구글링을 해본결과 편의성을 따졌을때  POST 메서드 요청 메세지를 보내는 것도 나쁘지 않은 방법이라고 합니다.

 

참고- [API 설계] DELETE request 요청/처리/응답에 관한 소소한 고민

https://humblego.tistory.com/18

 

6-3. 프론트엔드 부분 작성하기  Freeboard.js

 

// AJAX를 사용하여 댓글 삭제 처리
 function deleteComment(commentId) {
	  if (confirm('댓글을 삭제하시겠습니까?')) {
			  $.ajax({
				       url: `/community/freeboard/comment/` + commentId + `/delete`,
				       type: 'POST',
				       success: function(result) {
				                $('#comment-' + commentId).remove();
				            },
				       error: function(error) {
				               alert("댓글 삭제에 실패했습니다.");
				            }
				        });
				    }
				}
			
			    
			   $(document).ready(function() {
				    $('#delete-post-form').on('submit', function(event) {
				        var confirmed = confirm('게시글을 삭제하시겠습니까?'); 
				        if (!confirmed) {
				            event.preventDefault(); 
				        }
				    });
				});

 

 

7. 글 수정하기

마지막으로 작성한 글을 수정하는 부분을 작성해보겠습니다.

 

7-1. FreeBoardController.java 구문추가

       // 게시글 수정 
		@GetMapping( "/modify/{id}" ) 
			public String boardModify( @PathVariable( "id" ) Integer id, Model model) {
				
				model.addAttribute("freeboard", frbService.freeBoardView(id));
				return "community/commfreeboardmodify";
			}
		
       // 수정된 게시글을 처리하고 데이터베이스에 업데이트
		@PostMapping( "/update/{id}" ) 
			public String boardUpdate( @PathVariable( "id" ) Integer id, @ModelAttribute FreeBoardEntity frbEntity) {
				
				FreeBoardEntity boardTemp = frbService.freeBoardView(id);
				boardTemp.setFrboardtitle(frbEntity.getFrboardtitle());
				boardTemp.setFrboardcontent(frbEntity.getFrboardcontent());
			
				LocalDateTime LDTime = LocalDateTime.now();
				boardTemp.setFrboardupdatetime(LDTime);//수정시간을 현재시간으로 설정합니다
				
				boardTemp.setTags(frbEntity.getTags());
				frbService.FreeBoardWrite(boardTemp);
				return "redirect:/community/freeboard/view?id=" + id;
				//수정 작업 후에는 사용자를 수정된 게시글의 상세 보기 페이지로 리다이렉트합니다.
                     //이때 해당 게시글의 ID를 쿼리 파라미터로 전달합니다.
	
			}

 

7-2.프론트엔드 추가 html

 

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org">

<head>
	   //생략
       
       <script src="https://cdn.ckeditor.com/4.16.0/standard/ckeditor.js"></script>
</head>

<body>
	<div class="layout">
		<form th:action="@{/community/freeboard/update/{id}(id=${freeboard.frboardid})}" method="post">	
			<div class="sub">
				제목: <input type="text" name="frboardtitle" th:value="${freeboard.frboardtitle}"> 작성자 :<input type="text"  th:value="${freeboard.frboardwriter}" name="frboardwriter">
			</div>
			<textarea name="frboardcontent" id="frboardcontent" th:text="${freeboard.frboardcontent}"></textarea>
			<input type="submit" value ="글 수정">
		</form>
	</div>
    
     <script>
	    document.addEventListener("DOMContentLoaded", function() {
	        CKEDITOR.replace('frboardcontent');
	    });
	</script>
	

</body>
</html>

 

글작성부분과 동일하게 ckeditor을 사용했습니다.

 

 

📜게시글 조회 관련 로직 다시한번 살펴보기

1. 게시글 조회 요청처리

사용자가 글목록페이지에서 글제목링크를 클릭할때,  FreeBoardControllerboardView 메서드가 출력됩니다.

 

 

 

@GetMapping( "/view" ) 
			public String boardView( Model model , Integer id, FreeBoardEntity frbEntity) {
				FreeBoardEntity frbTemp = frbService.freeBoardView(id);
				if(frbTemp.getFrboardhits() == null) {
					int hits = 0 ;
					frbTemp.setFrboardhits( hits );
				}else {
					int hits = frbTemp.getFrboardhits() + 1 ;
					frbTemp.setFrboardhits( hits );
				}
                frbService.FreeBoardWrite( frbTemp );
                
                return "community/commfreeboardview";
   }

 

boardView 메서드 는 id 매개변수를 통해 사용자가 조회하려는 게시글의 ID를 받습니다.

 

2. 게시글 조회 증가 로직

boardView 메서드내에서 frbService.freeBoardView(id)를 호출하여 데이터베이스에서 해당게시글을 조회합니다.

조회된 게시글의 조회수(frboardhits) 를 확인합니다.

조회수가 null이면 조회수를 0으로 설정하고, 그렇지않으면 현재 조회수에 1을 더해 조회수를 증가시킵니다.

 

3.업데이트된 게시글 저장
조회수가 업데이트된 FreeBoardEntity 객체는 다시 frbService.FreeBoardWrite(frbTemp)를 호출하여 데이터베이스에 저장됩니다. 여기서 FreeBoardWrite 메서드 FreeBoardRepository의 save 메서드를 사용해 게시글을 업데이트합니다.


4. 사용자에게 게시글 표시
마지막으로 업데이트된 게시글 정보를 모델에 추가하고, 해당 게시글을 표시하는 뷰(community/commfreeboardview)로 리턴합니다.

 

view에는 이렇게 작성했습니다.

<span class="d-block text-sm" th:text="${freeboard.frboardhits} + ' 조회' "></span>

 

 

이번에는 DB에 저장되어있는 국산와인에 대한 값을 와인명 및 조건별 검색을 통해 출력하는 페이지를 만들어보려고 합니다.

 

1. 국산와인데이터 DB저장  chatgpt활용

우선 국산 와인에 대한 데이터는 공공데이터포털(DATA.GO.KR)이라는 웹사이트에서 전통주정보 데이터를 사용하였습니다. 하지만 제가 필요한 부분은 와인과 관련된 부분이니 필터링이 필요합니다.

 

 chatgpt를 활용하여 csv파일을 와인부분만 필터링한  sql파일로 변환하려고 합니다.

chatgpt4 에  csv파일을 업로드하고 명령어로 와인이 포함된 데이터를 sql insert명령어로 제공해달라고 요청합니다.

그러면 chatgpt가 데이터구조를 파악한뒤 와인이 포함된 행만 필터링하여 sql명령어로 변환한뒤에 sql파일을 다운로드 할수 있는 링크를 제공해줍니다.

 

하지만 문제가 있었으니.. 이미지는 공공데이터에 포함되어있지않았습니다.. 

결국 해당되는 와인의 이미지를 하나하나씩 구글링한후에 이미지를 저장하였습니다.. 

이미지를  /img/wine_images/image1.jpg 처럼 img폴더에 저장한뒤  다시한번 chatgpt에게 파일제공 명령어를 통해 update sql명령문을 작성하여 파일로 제공해 달라고 요청합니다. 

chatgpt를 잘활용한다면 번거로운작업을 좀더 쉽게 처리할수 있을듯합니다..

 

2. Search.java  JPA엔티티 작성

와인에 대한 속성값과 이러한 속성들을 데이터베이스의 테이블과 매핑시키는 엔티티클래스를 작성합니다.

와인의 속성을 나타내는 변수를 선언합니다.

@Data               
@NoArgsConstructor  
@AllArgsConstructor 
@Entity
public class Search {
	 @Id
	    @GeneratedValue(strategy = GenerationType.IDENTITY)
	    private Long id;
	    
	    @Column(name = "wine_name")
	    private String wineName;
	    
	    @Column(name = "alcohol_degree")
	    private Integer alcoholDegree;
	    
	    private String specification;
	    
	    @Column(name = "main_ingredient")
	    private String mainIngredient;
	    
	    private String manufacturer;
	    
	    @Column(name = "image_url")
	    private String imageUrl;
	    
	    private String region;
	    
	}

@Entity  클래스가 jpa엔티티임을 나타냅니다.  클래스가 데이터베이스 테이블에 매핑되는 객체임을 의미합니다.

@Data   @NoArgsConstructor @AllArgsConstructor Lombok라이브러리를 사용하여 getter/setter메소드를 자동으로 생성합니다.

3. SearchRepository.java 작성

Search엔티티에 대한 CRUD작업을 수행할수 있는 SearchRepository 인터페이스를 작성합니다. 이 인터페이스는 Spring Data JPA의 JpaRepository를 상속받아 기본적인 CRUD 메소드를 자동으로 제공받습니다.

public interface SearchRepository extends JpaRepository<Search, Long> {
	
	 // 와인 이름에 주어진 키워드가 포함된 검색 결과를 반환하는 메서드
	 List<Search> findByWineNameContaining(String keyword);
	 
	 // 원료에 주어진 원료가 포함된 검색 결과를 반환하는 메서드
	 List<Search> findByMainIngredientContaining(String ingredient);
	 
	 // 용량에 주어진 값이 포함된 검색 결과를 반환하는 메서드
	 List<Search> findBySpecificationContaining(String volume);
	 
	
	}

4. SearchService작성

SearchRepository를 주입받아 필요한 비즈니스 로직을 수행하는 서비스클래스를 작성합니다.

@Service
public class SearchService {

    private final SearchRepository searchRepository;
    private String lastShownManufacturer = null;
    private String lastShownRegion = null;


    public SearchService(SearchRepository searchRepository) {
        this.searchRepository = searchRepository;
    }

	//키워드 기반 검색: 와인 이름에 특정 키워드가 포함된 와인을 검색합니다.
    @Transactional(readOnly = true)
    public List<Search> searchByKeyword(String keyword) {
        return searchRepository.findByWineNameContaining(keyword);
    }

	//원료 기반 검색: 특정 원료를 포함하는 와인을 검색합니다.
    @Transactional(readOnly = true)
    public List<Search> searchByIngredient(String ingredient) {
        return searchRepository.findByMainIngredientContaining(ingredient);
    }
    
    //전체 검색: 모든 와인 정보를 조회합니다.
    @Transactional(readOnly = true)
    public List<Search> findAll() {
        return searchRepository.findAll();
    }

 

@Transactional 

각 메소드가 데이터베이스와의 상호작용을 하는 동안 일관된 데이터 상태를 유지하도록 합니다.

readOnly = true

데이터를 변경하지 않는 조회 작업에 사용됩니다.

 

5. SearchController.java 작성

사용자의 와인검색 요청을 처리하고, 이에 대한 결과값의 응답을 반환하는 controller 클래스를 작성해봅시다.

@Controller
public class SearchController {
	
	private final SearchRepository searchRepository;

   
    public SearchController(SearchRepository searchRepository) {
        this.searchRepository = searchRepository;
    }
	
	// 와인검색 페이지 
	@GetMapping("/search")
	public String showSearchPage() {
	    return "search/search";
	}

	//와인명 검색 
	@GetMapping("/search/integrated")
	@ResponseBody 
	public List<Search> integratedSearch(@RequestParam String keyword) {
	    return searchRepository.findByWineNameContaining(keyword);
	}
	
	//조건별 검색 
	@GetMapping("/search/detailed")
	@ResponseBody 
	public List<Search> detailedSearch(
	        @RequestParam String ingredient,
	        @RequestParam String region,
	        @RequestParam String volume,
	        @RequestParam String degree) {
	    
	    List<Search> results = searchRepository.findAll();
	    
	    // if구문: 사용자가 지정한 조건을 기반으로 와인을 검색하고자 할때 실행됩니다.
	    // 원료별 검색  
	    if (!ingredient.isEmpty()) { //  user가 원료를 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
	    	results = filterByIngredient(results, ingredient);  
	    }

	    // 지역별 검색
	    if (!region.isEmpty()) {  //  user가 지역을 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
	        results = filterByRegion(results, region);
	    }

	    // 용량별 검색
	    if (!volume.isEmpty()) { //  user가 용량을 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
	        results = filterByVolume(results, volume); 
	    }

	    // 도수별 검색
	    if (!degree.isEmpty()) { //  user가 도수를 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
	        results = filterByDegree(results, degree); 
	    }

	    return results;
	}
	    
	// 원료기반 필터링을 수행합니다.
	private List<Search> filterByIngredient(List<Search> results, String ingredient) { //filterByIngredient 메서드는 사용자가 지정한 원료에 따라 와인을 필터링합니다.
		List<String> ingredientList = Arrays.asList(ingredient.split(",")); //사용자가 입력한 원료 문자열을 쉼표(,)로 분리하여 배열로 변환합니다.
		if (!"기타".equals(ingredient)) { //"기타".equals(ingredient)가 false인 경우 (사용자가 구체적인 원료를 선택한 경우)
			return results.stream()
			        .filter(search -> containsAnyIngredient(search, ingredientList)) //containsAnyIngredient 메서드를 사용하여, 전체와인리스트(search)중에서 사용자가 선택한 사용자가 선택한원료가 있는 와인을 필터링합니다.
			        .collect(Collectors.toList());  //true로 반환된 list를 모아서 반환합니다
		} else { //"기타".equals(ingredient)가 true인 경우 (사용자가 '기타'를 선택한 경우)
			return results.stream() 
			        .filter(search -> !Arrays.asList("포도", "머루", "사과", "복숭아", "복분자").contains(search.getMainIngredient())) //전체와인리스트(search)중에서 "포도", "머루", "사과", "복숭아", "복분자"를 제외한 다른 원료로 만든 와인을 필터링합니다.
			        .collect(Collectors.toList());  //true로 반환된 list를 모아서 반환합니다
		}
	}
	// 전체와인리스트 중 user가 선택한 원료 목록중 하나 이상을 포함하는지 확인
	private boolean containsAnyIngredient(Search search, List<String> ingredientList) { 
	    return ingredientList.stream().anyMatch(ingredient -> search.getMainIngredient().contains(ingredient)); //anyMatch 어떤 원료라도 주어진 조건(contains(ingredient)을 만족하면 true를 반환
	}													 // 주어진 와인 객체의 mainIngredient 필드가 해당 원료를 포함하고 있는지 검사합니다.

	private List<Search> filterByRegion(List<Search> results, String region) {
		List<String> regionList = Arrays.asList(region.split(","));
		if (!"기타".equals(region)) {
			return results.stream()
			        .filter(search -> regionList.contains(search.getRegion()))
			        .collect(Collectors.toList());
		} else {
			return results.stream()
			        .filter(search -> !Arrays.asList("경상북도", "충청북도", "충청남도", "경상남도", "강원도").contains(search.getRegion()))
			        .collect(Collectors.toList());
		}
	}

	private List<Search> filterByVolume(List<Search> results, String volume) {
		List<String> volumeList = Arrays.asList(volume.split(","));
		return results.stream()
		        .filter(search -> volumeList.stream().anyMatch(vol -> search.getSpecification().contains(vol)))
		        .collect(Collectors.toList());
	}

	private List<Search> filterByDegree(List<Search> results, String degree) {
		List<String> degreeList = Arrays.asList(degree.split(","));
		return results.stream()
		        .filter(search -> matchesDegree(search, degreeList))
		        .collect(Collectors.toList());
	}

	private boolean matchesDegree(Search search, List<String> degreeList) {
	    double alcohol = search.getAlcoholDegree();
	    return degreeList.stream().anyMatch(deg -> {
	        switch(deg) {
	            case "10이하":
	                return alcohol <= 10;
	            case "10-11도":
	                return alcohol > 10 && alcohol <= 11;
	            case "11-12도":
	                return alcohol > 11 && alcohol <= 12;
	            case "12-13도":
	                return alcohol > 12 && alcohol <= 13;
	            case "14이상":
	                return alcohol >= 14;
	            default:
	                return false;
	        }
	    });
	}
}

 

Stream   스트림이란 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소로 정의할수 있습니다.

스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있습니다.

https://zangzangs.tistory.com/171 

스트림에 대한 chatgpt 검색결과

https://chat.openai.com/share/ab329fe5-a68b-4ee1-943d-0bf03cdccf0a

 

5.  프론트엔드 구현 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wine Search Page</title>
    <script src="/js/search.js" defer></script>
    <meta content="" name="keywords">
    <meta content="" name="description">

    <!-- Favicon -->
    <link href="img/favicon.ico" rel="icon">

    <!-- Google Web Fonts -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Barlow:wght@600;700&family=Ubuntu:wght@400;500&display=swap" rel="stylesheet"> 

    <!-- Icon Font Stylesheet -->
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">

    <!-- Libraries Stylesheet -->
    <link href="lib/animate/animate.min.css" rel="stylesheet">
    <link href="lib/owlcarousel/assets/owl.carousel.min.css" rel="stylesheet">
    <link href="lib/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet" />

    <!-- Customized Bootstrap Stylesheet -->
    <link href="css/bootstrap.min.css" rel="stylesheet">

    <!-- Template Stylesheet -->
    <link href="/css/style.css" rel="stylesheet">
    <!-- Search1 Stylesheet -->
    <link href="css/search1.css" rel="stylesheet">
</head>
<body>
		<!-- Navbar Start -->
		    <nav class="navbar navbar-expand-lg bg-white navbar-light shadow sticky-top p-0">
		        <a href="/" class="navbar-brand d-flex align-items-center px-4 px-lg-5">
		            <h2 class="m-0 text-primary" style="font-family: 'Nanum Gothic', sans-serif;">
		  <img src="img/winelogo.jpg" alt="Image" width="50" height="50">
		  KOWASA &nbsp;&nbsp; <h5>한국와인 정보 커뮤니티</h5>
		</h2>
		        </a>
		        <button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
		            <span class="navbar-toggler-icon"></span>
		        </button>
		        <div class="collapse navbar-collapse" id="navbarCollapse">
		            <div class="navbar-nav ms-auto p-4 p-lg-0">
		                <a href="/" class="nav-item nav-link active">Home</a>
		                <div class="nav-item dropdown">
		                    <a href="search" class="nav-item nav-link active">와인검색</a>
		                    
		                </div>
		                <div class="nav-item dropdown">
		                    <a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown">커뮤니티</a>
		                    <div class="dropdown-menu fade-up m-0">
		                        <a href="/community/freeboard/list" class="dropdown-item">자유게시판</a>
		                        <a href="/community/eventboard/list" class="dropdown-item">행사정보</a>
		                    </div>
		                </div>
		                <div class="nav-item dropdown">
		                    <a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown">와인파트너스</a>
		                    <div class="dropdown-menu fade-up m-0">
		                        <a href="/partners/producer/list" class="dropdown-item">생산자</a>
		                        <a href="/partners/shop/list"class="dropdown-item">샵/레스토랑 정보</a>
		                        <a href="/partners/job/list" class="dropdown-item">구인구직</a>
		                    </div>
		                </div>
		            </div>
		           	<a href="/login" class="btn btn-primary py-4 px-lg-5 d-none d-lg-block" th:if="${#authorization.expression('isAnonymous()')}">
						    Login<i class="fa fa-arrow-right ms-3"></i>
						</a>
						<div class="btn btn-primary py-4 px-lg-5 d-none d-lg-block" th:if="${#authorization.expression('isAuthenticated()')}">
						    <p style="margin-top: 10px;"><span th:text="${#authentication.name}"></span>님, 환영합니다.</p>
						    <button style="padding: 2px 5px;" onclick="location.href='/logout'">로그아웃</button>
						    <button style="padding: 2px 5px;" onclick="location.href='/mypage'">마이페이지</button>
						</div>
		     	   </div>
		    </nav>
		    <!-- Navbar End -->
		
		<div class="s008">
		    <form>
		        <div class="inner-form">
		        	<h4>와인 검색</h4><br/>
		            <div class="basic-search">
		                <div class="input-field">
		                    <input id="integratedSearchInput" type="text" placeholder="와인명으로 검색">
		                    <div class="icon-wrap" onclick="integratedSearch()">
		                        <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20">
		                            <path d="M18.869 19.162l-5.943-6.484c1.339-1.401 2.075-3.233 2.075-5.178 0-2.003-0.78-3.887-2.197-5.303s-3.3-2.197-5.303-2.197-3.887 0.78-5.303 2.197-2.197 3.3-2.197 5.303 0.78 3.887 2.197 5.303 3.3 2.197 5.303 2.197c1.726 0 3.362-0.579 4.688-1.645l5.943 6.483c0.099 0.108 0.233 0.162 0.369 0.162 0.121 0 0.242-0.043 0.338-0.131 0.204-0.187 0.217-0.503 0.031-0.706zM1 7.5c0-3.584 2.916-6.5 6.5-6.5s6.5 2.916 6.5 6.5-2.916 6.5-6.5 6.5-6.5-2.916-6.5-6.5z"></path>
		                        </svg>
		                    </div>
		                </div>
		            </div>
					<div class="advance-search">
					    <span class="desc">조건별 검색</span>
					    <div class="row">
					        <div class="input-field">
					            <div class="input-select">
					                <select data-trigger="" name="ingredient">
					                    <option placeholder="" value="">원료별</option>
					                    <option value="포도">포도</option>
					                    <option value="머루">머루</option>
					                    <option value="사과">사과</option>
					                    <option value="복숭아">복숭아</option>
					                    <option value="복분자">복분자</option>
					                    <option value="기타">기타</option>
					                </select>
					            </div>
					        </div>
					        <div class="input-field">
					            <div class="input-select">
					                <select data-trigger="" name="region">
					                    <option placeholder="" value="">지역별</option>
					                    <option value="경상북도">경상북도</option>
					                    <option value="충청북도">충청북도</option>
					                    <option value="충청남도">충청남도</option>
					                    <option value="경상남도">경상남도</option>
					                    <option value="강원도">강원도</option>
					                    <option value="기타">기타</option>
					                </select>
					            </div>
					        </div>
					    </div>
					    <div class="row second">
					       <div class="input-field">
					            <div class="input-select">
					                <select data-trigger="" name="volume">
					                    <option placeholder="" value="">용량별</option>
					                    <option value="300ml">300ml</option>
					                    <option value="375ml">375ml</option>
					                    <option value="500ml">500ml</option>
					                    <option value="750ml">750ml</option>
					                </select>
					            </div>
					        </div>
					        <div class="input-field">
					            <div class="input-select">
					                <select data-trigger="" name="degree">
					                    <option placeholder="" value="">도수별</option>
					                    <option value="10이하">10도이하</option>
					                    <option value="10-11도">10-11도</option>
					                    <option value="11-12도">11-12도</option>
					                    <option value="12-13도">12-13도</option>
					                    <option value="14이상">14도 이상</option>
					                </select>
					            </div>
					        </div> 
					    </div>
					    		 <div class="group-btn">
					                <button class="btn-delete" id="delete">초기화</button>
					                <button class="btn-search" onclick="detailedSearch(event)">조건별검색</button>
					            </div>
					            <div class="result-count">
					               TOTAL <span id="resultCount">0 </span>
					            </div>
					        </div>
					    </div>
					</div>
		<div class="inner-form" id="searchResults"></div>
		
		 <!-- Footer Start -->
	    <div class="container-fluid bg-dark text-light footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
	        <div class="container py-5">
	            <div class="row g-5">
	                <div class="col-lg-3 col-md-6">
	                    <h4 class="text-light mb-4">주소</h4>
	                    <p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>서울특별시 강남구 테헤란로1길</p>
	                    <p class="mb-2"><i class="fa fa-phone-alt me-3"></i>010-7583-7320</p>
	                    <p class="mb-2"><i class="fa fa-envelope me-3"></i>kowasa@wine.com</p>
	                    <div class="d-flex pt-2">
	                        <a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
	                        <a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
	                        <a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
	                        <a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
	                    </div>
	                </div>
	                <div class="col-lg-3 col-md-6">
	                    <h4 class="text-light mb-4">영업시간</h4>
	                    <h6 class="text-light">월요일 - 금요일:</h6>
	                    <p class="mb-4">오전9시 - 저녁6시</p>
	                </div>
	                <div class="col-lg-3 col-md-6">
	                    <h4 class="text-light mb-4">서비스</h4>
	                    <a class="btn btn-link" href="">국산와인 B2B 상담</a>
	                    <a class="btn btn-link" href="">판매전략</a>
	                    <a class="btn btn-link" href="">전시회 일정안내 </a>
	                    <a class="btn btn-link" href="">파트너스 등록</a>
	               
	                </div>
	                <div class="col-lg-3 col-md-6">
	                    <h4 class="text-light mb-4">와인 뉴스레터 구독</h4>
	                    <p>매달 다양한 와인관련 소식을 이메일로 받아보세요!</p>
	                    <div class="position-relative mx-auto" style="max-width: 400px;">
	                        <input class="form-control border-0 w-100 py-3 ps-4 pe-5" type="text" placeholder="이메일주소를 입력해주세요">
	                        <button type="button" class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">등록</button>
	                    </div>
	                </div>
	            </div>
	        </div>
	        <div class="container">
	            <div class="copyright">
	                <div class="row">
	                    <div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
	                        &copy; <a class="border-bottom" href="#">Kowasa</a>, All Right Reserved.
	
	                       
	                    </div>
	                    <div class="col-md-6 text-center text-md-end">
	                        <div class="footer-menu">
	                            <a href="">Home</a>
	                            <a href="">Cookies</a>
	                            <a href="">Help</a>
	                            <a href="">FQAs</a>
	                        </div>
	                    </div>
	                </div>
	            </div>
	        </div>
	    </div>
    <!-- Footer End -->
		
		<!-- Add this before the end of the body tag -->
		<script src="js/extention/choices.js"></script>
		
</body>
</html>

 

 

 

아이디 비밀번호 찾기 기능은 크게 User 모델, UserService 및 UserController클래스, 그리고 프론트엔드 html 및 css, js파일을 사용하여 구성할수 있습니다

 

1. User.java 수정

resetToken 추가

사용자가 비밀번호를 잊었을때 , 비밀번호 재설정 링크 생성에 사용되는 고유 토큰을 저장하는데  사용됩니다.

토큰은 보통 UUID 방식으로 생성되며,  아이디 / 비밀번호 찾기 요청이 들어오면 이토큰을 생성하여 User객체에 저장하고 사용자의  이메일로 해당토큰을 포함한 링크를 보냅니다.

 

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User implements UserDetails {
    // ...[기존코드]

    @Column(length = 64)
    private String resetToken;  // 비밀번호 재설정을 위한 토큰

    // ...[생략된 UserDetails 구현 관련 메서드]
}

 

2. UserService.java 수정

createPasswordResetTokenForUser 메소드 추가

public String createPasswordResetTokenForUser(User user) {
    String token = UUID.randomUUID().toString();
    user.setResetToken(token);
    userRepository.save(user);
    return token;
}

 

사용자가 비밀번호 재설정 요청을 했을때, 토큰을 생성합니다. 이 토큰을 통해 사용자를 식별합니다.

UUID.randomUUID().toString() 방법을 사용하여 무작위 토큰을 생성합니다.

생성된 토큰을 User 엔티티 resetToken 필드에 저장하고 db에 업데이트합니다.

 

sendPasswordResetEmail 메소드 추가

 public void sendPasswordResetEmail(User user, String token) throws MessagingException {

        // 현재 시간 설정
        LocalDateTime now = LocalDateTime.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
        String formattedNow = now.format(formatter);

        // 비밀번호 변경 링크
        String resetLink = "http://localhost:8080/changePw?token=" + token;

        // 이메일 메시지 구성
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

        helper.setTo(user.getEmail());
        helper.setSubject("코와사 비밀번호 재설정 메일");

        // HTML 메시지 설정
        String htmlMsg = "<p>안녕하세요.</p>" +
                         "<p>회원님은 " + formattedNow + "에 비밀번호 찾기 요청을 하셨습니다.</p>" +
                         "<p>코와사는 특정 회원의 비밀번호를 확인할 수 없기 때문에 아래 버튼을 통해 비밀번호를 초기화해주세요.</p>" +
                         "<p>회원 아이디: " + user.getUsername() + "</p>" +
                         "<a href='" + resetLink + "' style='color: white; text-decoration: none; padding: 10px 20px; background-color: #1a73e8; border-radius: 5px; display: inline-block;'>비밀번호 변경</a>";

        // HTML 형식으로 메시지를 설정합니다.
        helper.setText(htmlMsg, true);

        // 메일 발송
        mailSender.send(message);
    }

 

사용자에게 아이디확인 및 비밀번호 재설정 링크를 포함한 이메일을 보내어 비밀번호를 재설정할수 있도록 할수 있습니다.  JavaMailSender 이메일발송서비스를 사용하여 이메일을 구성하고 발송합니다.

이메일 내용에는 아이디가 출력되도록 작성하고  비밀번호 재설정페이지로 연결하는 링크를 포함시킵니다. 

링크를 클릭하면 사용자는 비밀번호를 재설정할수 있는 웹페이지changPw.html로 redirect됩니다.

 

changeUserPassword 메소드 추가

public void changeUserPassword(User user, String newPassword) {
    user.setPassword(passwordEncoder.encode(newPassword)); // 비밀번호 암호화
    userRepository.save(user);
}

 

사용자가 링크로 접속하여 입력한 새로운비밀번호를 db에 저장합니다.

PasswordEncoder을 사용하여 새비밀번호를 암호화합니다.

 

3. UserController.수정

 1. 아이디 확인 및 비밀번호 재설정 요청 처리  /resetPassword

// 아이디확인 및 비밀번호 재설정 요청을 처리하는 메서드
    @PostMapping("/resetPassword")
    public String handleResetPasswordRequest(@RequestParam String email, Model model) {
        User user = userService.findByEmail(email);
        if (user != null) {
            try {
                // 토큰 생성 및 사용자에 저장
                String token = userService.createPasswordResetTokenForUser(user);
                // 비밀번호 재설정 이메일 발송
                userService.sendPasswordResetEmail(user, token);
                // 비밀번호 재설정 요청이 성공적으로 처리되었다는 메시지 표시
                model.addAttribute("message", "아이디찾기 및 비밀번호 재설정 이메일이 발송되었습니다.");
                return "login/findIdPw"; // 비밀번호 재설정 이메일이 발송되었다는 메시지를 표시하는 뷰로 리다이렉트
            } catch (MessagingException e) {
                // 이메일 발송 중 오류가 발생한 경우 에러 메시지 표시
                model.addAttribute("error", "비밀번호 재설정 이메일 발송 중 오류가 발생했습니다.");
                return "login/findIdPw";
            }
        } else {
            // 사용자를 찾을 수 없는 경우 에러 메시지 표시
            model.addAttribute("error", "해당 이메일 주소를 가진 사용자를 찾을 수 없습니다.");
            return "login/findIdPw";
        }
    }

 

사용자가 이메일 주소를 입력하면 이메일주소를 기반으로 해당사용자를 찾고 비밀번호 재설정 토큰을 생성합니다( UserservicecreatePasswordResetTokenForUser 메서드 사용)

 

생성된 토큰과 함께 비밀번호 재설정 링크가 포함된 이메일을 사용자에게 발송합니다. (UserServicesendPasswordResetEmail 메소드 사용)

 

이메일 발송후 사용자에게 alert메시지를 표시합니다. 만약 해당하는 사용자를 찾을수 없다면 alert창을 출력후 아이디 비밀번호찾기 페이지로 return합니다.

 

2. 비밀번호 변경부분 처리  /changePw

//비밀번호 변경 페이지
    @GetMapping("/changePw")
    public String showChangePasswordPage(@RequestParam("token") String token, Model model) {
        User user = userService.getUserByPasswordResetToken(token);
        if (user != null) {
        	model.addAttribute("username", user.getUsername());
            model.addAttribute("token", token);
            return "login/changePw"; 
        } else {
            // 토큰이 유효하지 않거나 만료된 경우
            model.addAttribute("error", "비밀번호 재설정 요청이 유효하지 않습니다.");
            return "error";
        }
    }

    // 비밀번호 변경 요청을 처리하는 메서드
    @PostMapping("/changePw")
    public String changeUserPassword(@RequestParam("token") String token,
                                     @RequestParam("password") String newPassword,
                                     Model model) {
        User user = userService.getUserByPasswordResetToken(token);
        if (user != null && newPassword != null) {
            userService.changeUserPassword(user, newPassword);
            model.addAttribute("message","비밀번호가 변경되었습니다.");
            return "login/message"; // 비밀번호 변경 성공 페이지로 이동
        } else {
            // 토큰이 유효하지 않거나 새 비밀번호가 제공되지 않은 경우
            model.addAttribute("error", "비밀번호 변경에 실패했습니다.");
            return "login/changePw"; // 비밀번호 재설정 페이지로 다시 이동
        }
    }

 

사용자가 비밀번호 재설정 링크를 클릭했을때 비밀번호 변경을 위한 페이지를 표시합니다.@GetMapping("/changePw

Url에서 사용자가 비밀번호를 요청했을때 생성햇던 token 파라미터를 받고 이를 통해 사용자가 식별합니다. 유효한 토큰인 경우에는 비밀번호 변경 페이지로 이동하고  토큰이 유효하지않거나 만료된경우 오류메이지를 표시하고 아이디비밀번호 변경 페이지로 return합니다. 

 

토큰이 유효하고 사용자가 새로운 비밀번호를 업데이트 하고(UserService changeUserPassword 메소드 사용) 사용자에게 alert메세지를 출력합니다. 만약 토큰이 유효하지않거나 비밀번호를 입력하지 않았을 경우 alert창을 출력합니다.

 

 

4. Html 및 css, java script  작성

아이디 비밀번호 찾기 요청페이지  findIdPW.html 작성

/reserPassword  엔드포인트로 POST요청

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" >
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>계정정보찾기</title>
    <!-- Bootstrap 4 CSS CDN -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.2/css/bootstrap.min.css" />
    <!-- Fontawesome CSS CDN -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.min.css" />
    <link rel="stylesheet" th:href="@{css/findidpw.css}" />
</head>
<body class="bg-info full-height">
<!-- Forgot Password Form Start -->
<div class="container h-100">
     <div class="row justify-content-center align-items-center h-100">
        <div class="col-lg-7 bg-white p-4">
        	 <h2 class="m-0" style="color: #D81324; padding-bottom: 40px;">
			    <img src="img/winelogo.jpg" alt="Image" width="50" height="50">
			    KOWASA <br/>
			    <button onclick="history.back()" class="btn btn-secondary btn-sm" style="float: right; margin-top: -50px;">
			        <i class="fas fa-arrow-left"></i> 뒤로가기
			    </button>
			</h2>
            <h1 class="text-center font-weight-bold text-primary" style="padding-bottom: 20px;">이메일로 계정찾기<br/></h1>
            <hr class="my-3" />
            <p class="lead text-center text-secondary" style="padding-bottom: 20px;">
            	아이디/비밀번호는 가입 등록한 메일 주소로 알려드립니다.<br/>
            	가입할 때 등록한 메일 주소를 입력하고 "발송" 버튼을 클릭해주세요.<br/>
            </p>
            <h5 style="padding-bottom: 20px;"> 가입 등록한 이메일 주소</h5>
            <form th:action="@{/resetPassword}" method="post" class="px-3" id="forgot-form">
                <div th:id="${forgotAlert}" th:text="${message}"></div>
                <div class="input-group input-group-lg form-group">
                    <div  class="input-group-prepend">
                        <span class="input-group-text rounded-0"><i class="far fa-envelope fa-lg"></i></span>
                    </div>
                    <input type="text" name="email" class="form-control rounded-0" placeholder="E-Mail" required />
                </div>
                <div class="form-group">
                    <input type="submit" id="forgot-btn" value="발송" class="btn btn-primary btn-lg btn-block myBtn" />
                </div>
            </form>
        </div>
    </div>
</div>
<!-- Forgot Password Form End -->
<!-- jQuery CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script th:src="@{js/findidpw.js}"></script> 
</body>
</html>

 

 

비밀번호 변경 페이지  changePw.html 작성

/changePw 엔드포인트로 POST요청

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" >
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>계정정보찾기</title>
    <!-- Bootstrap 4 CSS CDN -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.2/css/bootstrap.min.css" />
    <!-- Fontawesome CSS CDN -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.min.css" />
    <link rel="stylesheet" th:href="@{css/findidpw.css}" />
   
  
</head>
<body class="bg-info full-height">
    <!-- Forgot Password Form Start -->
    <div class="container h-100">
        <div class="row justify-content-center align-items-center h-100">
            <div class="col-lg-7 bg-white p-4">
                <h2 class="m-0" style="color: #D81324; padding-bottom: 40px;">
                    <img src="img/winelogo.jpg" alt="Image" width="50" height="50">
                    KOWASA <br/>
                </h2>
                <h1 class="text-center font-weight-bold text-primary" style="padding-bottom: 20px;">비밀번호 변경<br/></h1>
                <hr class="my-3" />
                <form action="/changePw" method="post">
                    <input type="hidden" name="token" th:value="${token}"/>
                    <div class="input-group input-group-lg form-group">
                        <div class="input-group-prepend">
                            <span class="input-group-text rounded-0"><i class="fas fa-user"></i></span>
                        </div>
                        <input type="text" id="username" name="username" th:value="${username}" class="form-control rounded-0" placeholder="아이디" readonly/>
                    </div>
                    <div class="input-group input-group-lg form-group">
                        <div class="input-group-prepend">
                            <span class="input-group-text rounded-0"><i class="fas fa-lock"></i></span>
                        </div>
                        <input type="password" id="password" name="password" class="form-control rounded-0" placeholder="새로운 비밀번호"/>
                    </div>
                    <div class="input-group input-group-lg form-group">
                        <div class="input-group-prepend">
                            <span class="input-group-text rounded-0"><i class="fas fa-lock"></i></span>
                        </div>
                        <input type="password" id="confirmPassword" name="confirmPassword" class="form-control rounded-0" placeholder="비밀번호 재확인"/>
                    </div>
                    <div class="form-group">
                        <input type="submit" value="비밀번호 변경" class="btn btn-primary btn-lg btn-block myBtn" />
                    </div>
                </form>
            </div>
        </div>
    </div>
    <!-- Forgot Password Form End -->
    <!-- jQuery CDN -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</html>

 

 

그다음으로 구글아이디를 통해 로그인하는 부분을 구현하고자 합니다.

시작하기에 앞서 Oauth 2.0에 대해 알아봅시다. 

 

OAuth 2.0?

인터넷 사용자가 웹사이트나 애플리케이션에 자신의 계정 정보를 공유하지 않고도, 다른 서비스의 자원에 접근할 수 있게 해주는 개방형 표준입니다. 이 표준은 사용자의 개인정보 보호와 보안을 강화하기 위해 개발되었습니다.

 

OAuth 2.0의 핵심 개념/ 특징

  • 토큰 기반 인증: OAuth 2.0은 사용자의 ID와 비밀번호와 같은 자격 증명 대신에, 토큰을 이용하여 서비스 간의 인증과 권한 부여를 수행합니다. 이 토큰은 제한된 시간 동안만 유효하며, 특정한 권한을 가집니다.
  • 주요 파트: :

            리소스 소유자(Resource Owner): 일반적으로 서비스를 사용하는 최종 사용자입니다.

            리소스 서버(Resource Server): 사용자 데이터를 보유하고 있는 서버입니다.(ex네이버,구글,다음)

            클라이언트(Client): 사용자 대신 리소스 서버에 접근하려는 애플리케이션입니다.

            (ex 사용하려는 웹사이트나 어플리케이션)

            인증 서버(Authorization Server): 클라이언트에게 접근 토큰을 발급하는 서버입니다.

  • 프로세스(Flow): OAuth 2.0은 다양한 인증 프로세스을 제공합니다. 이들은 사용 사례와 애플리케이션의 유형에 따라 다릅니다. 대표적인 프로세스로는 Authorization Code Flow, Implicit Flow, Password Flow, Client Credentials Flow 등이 있습니다.

그중에서 평상시 가장많이 사용되는 Authorization Code Flow 에 대해 좀더 자세히 살펴보겠습니다.

과정설명:

  1. 클라이언트 등록 및 로그인 요청: 사용자가 클라이언트(예: 웹 애플리케이션)에서 'Google 로그인' 버튼을 클릭하여 인증을 시작합니다. 클라이언트는 인증 서버로 리디렉션하는 요청을 보냅니다.
  2. 사용자 인증 및 동의: 사용자는 Google 인증 페이지에서 로그인을 하고, 클라이언트 애플리케이션이 요청한 권한에 대해 동의합니다.
  3. 인증 코드 전송: 사용자가 동의하면 Google은 클라이언트에게 인중 코드를 리디렉션 URL을 통해 전송합니다.
  4. 인증 토큰 요청: 클라이언트는 받은 인증 코드를 사용하여 인증 서버에 접근 토큰을 요청합니다.
  5. 사용자 정보 획득: 클라이언트는 획득한 접근 토큰을 사용하여 사용자의 정보를 얻습니다. 이 정보는 사용자 데이터베이스에 저장하거나 업데이트할 수 있습니다.
  6. 인증된 사용자에게 서비스 제공: 사용자 인증이 성공하면 클라이언트는 사용자에게 서비스를 제공합니다. 예를 들어, 'Hello World :)' 메시지를 화면에 보여줍니다.

다시정리하자면 사용자가 자신의 정보를 직접 클라이언트에게 제공하지 않고도, 클라이언트가 사용자의 동의를 받아 인증 서버로부터 접근 토큰을 안전하게 얻을 수 있도록 해줍니다. 그리고 이 접근 토큰을 사용하여 리소스 서버로부터 보호된 자원에 접근할 수 있습니다. 이 과정은 Spring Security와 같은 보안 프레임워크를 통해 구현될 수 있습니다.

 

 

구글로그인을 OAuth2.0을 구현하기 위해서는 우선 구글API콘솔에서 OAuth2 설정을 해야합니다.

https://console.cloud.google.com/apis/

 

1. google cloud에 접속하여 프로젝트 만들기를 선택하고 API 및 서비스 링크를 클릭하여 이동합니다.

2. 좌측의 OAuth 동의화면을 클릭한후 UserType을 외부로 선택한후 만들기 버튼을 클릭합니다.

3. 앱정보등 필수부분을 작성한뒤  저장버튼을 클릭합니다.

4. 좌측 사용자인증정보를 클릭한후 OAuth 클라이언트ID 사용자인증정보만들기를 클릭합니다.

5. 어플리케이션 유형 및 승인된 리디렉션 URI를 입력합니다. (ex: http://localhost:8080/login/oauth2/code/google)

6. 클라이언트ID 및 클라이언트 보안비밀번호가 생성됩니다.

 

 

1. 의존성추가 pom.xml

<dependencies>
	<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-oauth2-client</artifactId>
		</dependency>
</dependencies>

 

2. application-oauth.properties 등록

양식에 맞춰 google cloud에서 부여받은 클라이언트id및 보안비밀번호를 입력합니다.

# Google
spring.security.oauth2.client.registration.google.client-id=클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀
spring.security.oauth2.client.registration.google.scope=profile,email

 

3. OAuth2UserService

PrincipalOauth2UserService

UserInfo엔드포인트에서 사용자의 속성을 가져오고 OAuth2Usesr타입의 AuthenticatedPrincipal을 리턴하는 DefaultOAuth2UserService를 구현한 클래스입니다.

 

프로세스: 구글로그인버튼->구글로그인창->로그인완료->인증코드리턴(OAuth-Client라이브러리)->AccessToken요청

userRequest정보 -> loadUser 함수호출-> 구글로부터 회원프로필을 받아옵니다.

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = super.loadUser(userRequest);
        System.out.println("getAttributes : " + oauth2User.getAttributes());

        String provider = userRequest.getClientRegistration().getRegistrationId(); // Google
        String providerId = oauth2User.getAttribute("sub"); // Google ProviderId Sub
        String shortenedProviderId = providerId.length() > 5 ? providerId.substring(0, 5) : providerId; // providerId에서 길이 5만큼의 문자열을 추출
        String username = provider+"_"+shortenedProviderId; // google_123124
        String password = "1234"; // 크게 의미 없음.
        String email = oauth2User.getAttribute("email");
        String role = "ROLE_USER";

        User userEntity = userRepository.findByUsername(username);

        if(userEntity == null){
            userEntity = User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            userRepository.save(userEntity);
        }

        return new PrincipalDetails(userEntity, oauth2User.getAttributes());
    }
}

loadUser메소드

구글로 부터 받은 userRequest 데이터에 대한 후처리를 하는 함수입니다.

 

OAuth2User 

getAttributes메소드를 통해 로그인한 사용자의 정보를 추출합니다.

 

마지막으로 인증된 사용자의 정보와 OAuth2 제공자로부터 받은 속성을 포함한 PrincipalDetails 객체를 반환합니다. 이 객체는 애플리케이션 내에서 사용자의 인증 정보를 관리하는 데 사용됩니다.

 

4. PrincipalDetails

PrincipalDetails클래스는 스프링시큐리티의 'UserDetails' 및 'OAuth2User'인터페이스를 구현하며 , 사용자의 인증정보를 관리하는 역할을 합니다. 

public class PrincipalDetails implements UserDetails, OAuth2User {

    private User user; 
    private Map<String,Object> attributes;

    // 일반 로그인 생성자
    public PrincipalDetails(User user){
        this.user = user;
    }

    // OAuth 로그인 생성자
    public PrincipalDetails(User user,Map<String,Object> attributes){
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    // 해당 User의 권한을 리턴하는곳.
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });
        return collect;
    }
    
    // 소셜 로그인 여부를 확인하는 메소드
    public boolean isSocial() {
        return attributes != null && !attributes.isEmpty();
    }
    


    // User 의 password 리턴
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String getName() {
        return null;
    }
}

 

getAuthorities

사용자의 권한을 return합니다. 이는 GrantedAuthority의 객체 컬렉션으로 사용자의 역할을 나타냅니다.

getAttributes

소셜로그인과정에서 제공된 사용자의 속성을 return합니다.  
isSocial

사용자가 소셜로그인으로 인증되었는지 여부를 확인합니다. 사용자속성이 존재하면 소셜로그인으로 포함됩니다.

 

5.SecurityConfig 수정

@Override
	 protected void configure(HttpSecurity http) throws Exception {
	   	         .formLogin()
	             .loginPage("/login")
	             .successHandler(customAuthenticationSuccessHandler())
                 .failureHandler(userLoginFailHandler)
	         .and()
	         .logout()
	             .logoutUrl("/logout")
	             .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
	             .logoutSuccessHandler(customLogoutSuccessHandler())
	             .invalidateHttpSession(true)
	             .deleteCookies("JSESSIONID")
	             .clearAuthentication(true)
	             .permitAll()
             .and()
	         .oauth2Login()
             .loginPage("/login")
             .userInfoEndpoint()
             .userService(principalOauth2UserService);
	 }

oauth2관련 구문을 추가합니다.

3. 로그인 / 로그아웃 기능 구현하기

 UserDetailsServiceImpl  생성

UserDetailsServiceImpl 클래스는 Spring Framework의 보안 기능과 연동되어 사용자 인증 및 권한 부여에 사용됩니다.

사용자인증과정에서 사용자의 자격을 검증하고 권한을 부여하는데 사용됩니다.  UserRepository를 사용하여 데이터베이스에서 사용자 정보를 조회하고, 조회된 정보를 바탕으로 UserDetails 객체를 생성하여 반환합니다.

package com.wine.demo.service;

import java.util.ArrayList;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.wine.demo.model.User;
import com.wine.demo.repository.UserRepository;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("id에 해당하는 유저를 찾을수 없습니다 : " + username);
        }
        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), new ArrayList<>());
    }
}

 

  • loadUserByUsername() 사용자의 이름을 기반으로 사용자의 정보를 검색하고 UserDetails 객체로 반환합니다. 만약 사용자를 찾을수 없다면 UsernameNotFoundException을 발생시킵니다. 

 

Handler 생성

CustomAuthenticationSuccessHandler

로그인 성공시 호출되는 핸들러입니다. 사용자가 성공적으로 로그인하면 이 핸들러가 실행됩니다.

package com.wine.demo.handler;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    public CustomAuthenticationSuccessHandler(String defaultTargetUrl) {
        setDefaultTargetUrl(defaultTargetUrl);
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        // 세션에 사용자 이름 설정
        request.getSession().setAttribute("username", authentication.getName());

        // 세션에서 이전 페이지 URL 가져오기
        String prevPage = (String) request.getSession().getAttribute("prevPage");
        if (prevPage != null) {
            request.getSession().removeAttribute("prevPage"); // 사용 후 세션에서 삭제
        }

        // 이전 페이지가 없는 경우 홈페이지로 리디렉션, 있으면 해당 페이지로 리디렉션
        response.sendRedirect(prevPage != null ? prevPage : getDefaultTargetUrl());
    }
}

 

UserLoginFailHandler

로그인 실패 시 호출되는 핸들러입니다. 사용자가 로그인에 실패하면 이 핸들러가 실행되어 사용자에게 오류 메시지를 표시합니다.

package com.wine.demo.handler;

import java.io.IOException;
import java.net.URLEncoder;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

@Component
public class UserLoginFailHandler extends SimpleUrlAuthenticationFailureHandler {

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
        String errorMessage;
        if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해 주세요.";
        } else if (exception instanceof InternalAuthenticationServiceException) {
            errorMessage = "내부적으로 발생한 시스템 문제로 인해 요청을 처리할 수 없습니다. 관리자에게 문의하세요.";
        } else if (exception instanceof UsernameNotFoundException) {
            errorMessage = "계정이 존재하지 않습니다. 회원가입 진행 후 로그인 해주세요.";
        } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
            errorMessage = "인증 요청이 거부되었습니다. 관리자에게 문의하세요.";
        } else {
            errorMessage = "알 수 없는 이유로 로그인에 실패하였습니다 관리자에게 문의하세요.";
        }
        
        errorMessage = URLEncoder.encode(errorMessage, "UTF-8");
        setDefaultFailureUrl("/login?error=true&exception=" + errorMessage);
 
        super.onAuthenticationFailure(request, response, exception);
	}
}

 

CustomLogoutSuccessHandler

로그아웃 성공 시 호출되는 핸들러입니다. 사용자가 로그아웃하면 이 핸들러가 실행됩니다.

package com.wine.demo.handler;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {

	@Override
	public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
	    response.sendRedirect("/login");
	}
}

 

3. SecurityConfig 설정

CustomAuthenticationSuccessHandler, UserLoginFailHandlerCustomLogoutSuccessHandler 등을 통해 로그인 성공, 실패, 로그아웃 시의 동작을 설정합니다.

package com.wine.demo.config;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity

public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	 @Autowired
	 private UserDetailsService userDetailsService;
     
     @Autowired
	 private UserLoginFailHandler userLoginFailHandler;
     
     @Bean
	 public AuthenticationSuccessHandler successHandler() {
	     return new CustomAuthenticationSuccessHandler(
	         SecurityConstants.DEFAULT_SUCCESS_URL
	     );
	 }
	 
     
     @Override
	 protected void configure(HttpSecurity http) throws Exception {
	   	         .formLogin()
	             .loginPage("/login")
	             .successHandler(customAuthenticationSuccessHandler())
                 .failureHandler(userLoginFailHandler)
	         .and()
	         .logout()
	             .logoutUrl("/logout")
	             .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
	             .logoutSuccessHandler(customLogoutSuccessHandler())
	             .invalidateHttpSession(true)
	             .deleteCookies("JSESSIONID")
	             .clearAuthentication(true)
	             .permitAll();
	 }
     
     @Override
	 public void configure(WebSecurity web) throws Exception {
	      }
	 
	 @Bean
	 public CustomLogoutSuccessHandler customLogoutSuccessHandler() {
	     return new CustomLogoutSuccessHandler();
	 }
     @Bean
	 public UserLoginFailHandler userLoginFailHandler() {
	     return new UserLoginFailHandler();
	 }
    }

 

UserService 수정

package com.wine.demo.service;

import

@Service
public class UserService {

	@Autowired
		private UserRepository userRepository;
	@Autowired
		private VerificationCodeRepository verificationCodeRepository;
	@Autowired
		private JavaMailSender mailSender;
	@Autowired
		private AuthenticationManager authenticationManager;
          
          // 로그인 처리를 담당하는 메서드  사용자 이름과 비밀번호로 로그인을 시도하고, 인증 결과를 반환합니다.
    public boolean loginUser(String username, String password) {
        try {
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(username, password));
            return authentication.isAuthenticated();
        } catch (UsernameNotFoundException | BadCredentialsException ex) {
            return false;
        }
    }
    
   		 // ..기존 회원가입 메서드

 

UserController 수정

package com.wine.demo.controller;


@Controller
public class UserController {

 // 로그인페이지
     @GetMapping("/login")
     public String showLoginPage(HttpServletRequest request, HttpSession session) {
     	 String referrer = request.getHeader("Referer"); // 이전에 방문한 페이지의 url을 가져옴
    	    if (referrer != null && !referrer.contains("/login")) {
    	        session.setAttribute("prevPage", referrer); // 이전 페이지의 url을 세션에 저장
    	    } 
    	    return "login/login";
    	}

     // 로그인 요청을 처리 
     @PostMapping("/login")
     public String loginUser(@RequestParam String username, @RequestParam String password, HttpSession session, Model model) {
         if (username == null || password == null) { 
             model.addAttribute("error", "사용자 이름 또는 비밀번호가 누락되었습니다.");//입력누락에 대한 간단한 검증만 수행하고 로그인시도및 실패처리는 handler가 담당
             return "login";
         }

         if (userService.loginUser(username, password)) {
             session.setAttribute("username", username); //로그인정보 세션에 저장
             String prevPage = (String) session.getAttribute("prevPage"); // 이전에 방문한 페이지로 리다이렉트(session의 prevpage에서 이전페이지정보를 가져옴)
             session.removeAttribute("prevPage"); //페이지url데이터 세션에서 삭제
             return "redirect:" + (prevPage != null ? prevPage : "/"); // 이전 페이지가 null이 아니면 해당페이지로 null이면 홈페이지로 리다이렉트
         } else {
             model.addAttribute("error", "잘못된 사용자 이름 또는 비밀번호입니다.");
             return "login";
         }
     }
     
     
     //..기존 회원가입관련 메서드

 

로그인 페이지 생성  login.html

회원가입과 마찬가지로 login bootstrap을 검색하시면 다양한 폼을 찾을수 있습니다.

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
  	<title>KOWASA 로그인</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

	<link href="https://fonts.googleapis.com/css?family=Lato:300,400,700&display=swap" rel="stylesheet">

	<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
	
	<link rel="stylesheet" href="css/style_login.css">

	</head>
	<body>
	<section class="ftco-section">
		<div class="container">
			<div class="row justify-content-center">
				<div class="col-md-6 text-center mb-5">
					<h2 class="heading-section"></h2>
				</div>
			</div>
			<div class="row justify-content-center">
				<div class="col-md-12 col-lg-10">
					<div class="wrap d-md-flex">
						<div class="img" style="background-image: url(img/bg-1.jpg);">
						</div>
						<div class="login-wrap p-4 p-md-5">
			      	<div class="d-flex">
			      		<div class="w-100">
							    <div style="display: flex; align-items: center; flex-direction: row;">
							         <a href="/"><img src="img/winelogo.jpg" alt="Image"  width="50" height="50"></a>
							        <div style="margin-left: 10px;">
							           <a href="/" class="m-0" style="color: #D81324; font-size:24px;" >KOWASA</a>
							        </div>
							    </div>
							    
							     <h3 class="mb-4" style="margin-top: 20px;">로그인</h3>
							</div>
								<div class="w-100">
									<p class="social-media d-flex justify-content-end">
										<a href="/oauth2/authorization/google" class="social-icon d-flex align-items-center justify-content-center"><span class="fa fa-google"></span></a>
									</p>
								</div>
			      	</div>
							<form id="loginForm" action="/login" method="post" class="signin-form">
					      		<div class="form-group mb-3">
					      			<label class="label" for="username">아이디</label>
					      			<label class="label" style="float: right;"><input type="radio" id="adminUser" name="userType" value="admin">관리자</label>
					      		<label class="label" style="float: right;"><input type="radio" id="generalUser" name="userType" value="general" checked>일반회원</label>
					      			<input type="text" name="username" id="username" class="form-control" placeholder="아이디를 입력해주세요" required>
					      		</div>
					      		
					            <div class="form-group mb-3">
					            	<label class="label" for="password">비밀번호</label>
					              	<input type="password" name="password" id="password" class="form-control" placeholder="비밀번호를 입력해주세요" required>
					            </div>
					            <div class="form-group">
					            	<button type="submit" class="form-control btn btn-primary rounded submit px-3">로그인</button>
					            </div>
					             <div th:if="${param.error}">
							        <p class="error-message" th:text="${param.exception}"></p>
							    </div>
					            
					            <div class="form-group d-md-flex">
					            	<div class="w-50 text-left">
						            	<label class="checkbox-wrap checkbox-primary mb-0">아이디 기억하기
											  <input type="checkbox" id="remember-id">
											  <span class="checkmark"></span>
												</label>
									</div>
									<div class="w-50 text-md-right">
										<a href="/findIdPw">아이디/비밀번호 찾기</a>
									</div>
					            </div>
					         
					        </form>
			          <p class="text-center">아직 회원이 아니신가요? <a  href="/register">회원가입</a></p>
			        </div>
			      </div>
				</div>
			</div>
		</div>
	</section>

	<script src="/js/jquery_login.min.js"></script>
  	<script src="/js/popper_login.js"></script>
  	<script src="/js/bootstrap_login.min.js"></script>
  	<script src="/js/main_login.js"></script>
  	<script src="/js/remember_id.js"></script>

	</body>
</html>

 

css와 js부분은 분량이 길어질거같아 생략했습니다..

 

1. 스프링 시큐리티 설정 추가하기

security dependency 추가하기
해당 의존성을 추가하고 라이브러리를 받아옵니다.

//pom.xml
<dependencies>
	<dependency>
	        <groupId>org.springframework.boot</groupId>
	        <artifactId>spring-boot-starter-security</artifactId>
	</dependency>
</dependencies>

 

애플리케이션을 실행하고 기존에 작성했던 localhost:8080/community/freeboard URL에 접근했을 때, 스프링 시큐리티에서 제공하는 로그인 페이지로 이동됩니다.

스프링 시큐리티에서 기본적으로 제공하는 아이디는 user이고, 비밀번호는 애플리케이션을 실행할 때마다 콘솔창에 출력해서 보여줍니다.

 

스프링 시큐리티를 추가하는 것만으로도 모든 요청이 인증을 필요로 하게 됩니다. 하지만 매번 스프링 시큐리티 콘솔창에서 비밀번호를 찾아 입력할 수 없기 때문에 회원 가입 기능과 각 페이지마다 필요한 권한처리를 하도록 하겠습니다.

인증이 필요없는 경우: 게시물조회
인증이 필요한 경우: 게시물작성, 댓글작성

 

스프링 시큐리티 설정하기
설정을 관리할 config라는 패키지를 만들고 스프링시큐리티를 설정을 처리할 SecurityConfig 클래스를 만들겠습니다.

 

package com.wine.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • @EnableWebSecurity: WebSecurityConfigurerAdapter를 상속받는 클래스에 해당 어노테이션을 선언하면 SpringSecurityFilterChain이 자동으로 포함됩니다.
  • void configure(HttpSecurity http): http 요청에 대한 보안을 설정합니다.
  • PasswordEncoder passwordEncoder(): 비밀번호를 그대로 저장하지 않고 BCryptPasswordEncoder의 해시 함수를 이용하여 암호화처리합니다.
        

2. 회원가입 기능 구현하기

 사용자 정보를 나타내는 엔티티를 작성합니다. 이 클래스는 데이터베이스의 'user' 테이블과 연결되어 있으며, 사용자의 이메일, 비밀번호, 역할 등을 포함합니다.

UserEntity생성

package com.wine.demo.model;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.Collection;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User implements UserDetails {

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

    private String username;

    private String password;

    private String email;
    
    @Column(length = 64)
    private String resetToken;

    private boolean enabled;

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }
}

 

UserRepository 생성

User엔티티를 만들었다면 데이터베이스에 저장 할수 있도록 UserRepository를 만들겠습니다.

package com.wine.demo.repository;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Integer> {
	
	// 사용자 이름으로 사용자를 찾는 메서드
    User findByUsername(String username);
    // 이메일로 사용자를 찾는 메서드
    User findByEmail(String email);
}
  • User findByUser(String username): 회원 가입시 중복된 회원이 있는지 검사하기 위해 ID로 회원을 검사하는 메소드를 작성합니다.
  • Member findByEmail(String email): 회원 가입시 중복된 회원이 있는지 검사하기 위해 이메일로 회원을 검사하는 메소드를 작성합니다.

UserService 생성

service 패키지를 만들고 UserService 클래스를 작성합니다.

package com.wine.demo.service;

import com.wine.demo.model.User;
import com.wine.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;

@Service
public class UserService {
    @Autowired
        private UserRepository userRepository;
   
     // 새로운 사용자를 저장하거나 기존 사용자 정보를 업데이트합니다.
    public void save(User user, boolean encodePassword) {
        if (encodePassword) {
            user.setPassword(passwordEncoder.encode(user.getPassword()));
        }
        userRepository.save(user);
    }
    
    // 사용자의 현재 비밀번호가 맞는지 확인합니다.
    public boolean checkCurrentPassword(String currentPassword) {
        User user = getAuthenticatedUser();
        if (user == null) {
            throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
        }
        return passwordEncoder.matches(currentPassword, user.getPassword());
    }

@Autowired 어노테이션이 없이 의존성 주입이 가능합니다.

 

UserController생성

controller패키지 안에 UserController를 작성합니다.  ID중복확인, 인증이메일 전송 조건을 처리하고 모든 조건이 충족되면 새사용자를 저장하고 로그인 페이지로 redirect 합니다. 만약 조건이 충족되지않으면 다시 회원가입 페이지로 돌아갑니다.

package com.wine.demo.controller;


@Controller
public class UserController {
    // 회원가입 페이지
        @GetMapping("/register")
        public String register() {
            return "login/register";
        }

        // id 중복확인
        @PostMapping("/checkUsername")
        @ResponseBody
        public ResponseEntity<Boolean> checkUsername(@RequestBody Map<String, String> payload) { //json형식
            String username = payload.get("username");
            boolean isAvailable = userService.checkUsernameAvailability(username);
            return new ResponseEntity<>(isAvailable, HttpStatus.OK);
        }

        // 인증이메일 전송처리
        @PostMapping("/sendVerificationEmail")
        @ResponseBody
        public ResponseEntity<String> sendVerificationEmail(@RequestBody Map<String, String> payload) {
            String email = payload.get("email");
            try {
                userService.sendVerificationEmail(email);
            } catch (MailException e) {
                return new ResponseEntity<>("이메일 전송에 실패했습니다: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
            } catch (Exception e) {
                return new ResponseEntity<>("서버 에러: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
            }
            return new ResponseEntity<>("이메일 전송에 성공했습니다. 이메일을 확인해주세요!", HttpStatus.OK);
        }

        // 회원가입 요청을 처리
        @PostMapping("/register")
        @ResponseBody   // HTTP 요청 본문의 내용을 자바 객체로 변환하여 매개변수에 바인딩. 
        				이 경우에는 요청 본문은 Map<String, String> 형태로 변환
        public String processRegister(@RequestBody Map<String, String> payload) {

            String username = payload.get("username");
            String password = payload.get("password");
            String passwordConfirm = payload.get("passwordConfirm");
            String email = payload.get("email");
            String verificationCode = payload.get("verificationCode");

            if (!password.equals(passwordConfirm)) {
                return "register";
            }

            VerificationCode code = userService.findVerificationCode(verificationCode, email);

            if (code != null) {
                User user = new User();
                user.setUsername(username);
                user.setPassword(password);
                user.setEmail(email);
                user.setEnabled(true);
                user.setRole("USER");
                userService.save(user, true);
                return "redirect:/login";
            } else {
                return "register";
            }
        }

 

register.html 작성

Controller에서 작성한 메소드 리턴값에 맞게 경로를 지정합니다. 

저는 resources하위인 templatese에서 아래에  login 폴더를 만들고 그 아래에 register.html을 작성했습니다. 부트스트랩 register 를 검색하면 다양한 폼이 있어서 다양하게 꾸밀 수 있습니다.

<!DOCTYPE html>
<html lang="ko" class="signup">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>KOWASA 회원가입</title>

    <!-- Font Icon -->
    <link rel="stylesheet" href="fonts/material-icon/css/material-design-iconic-font.min.css">

    <!-- Main css -->
    <link rel="stylesheet" href="css/style_register.css">
</head>
<body>
    <div class="main">
        <section >
            <div class="container">
                <div class="signup-content">
                    <form method="POST" id="signup-form" class="signup-form">
                        <h2 class="form-title">회원가입</h2>
                        <div class="form-group" style="display: flex; justify-content: space-between; align-items: center;">
                            <input type="text" class="form-input" name="name" id="username" placeholder="아이디" style="flex-grow: 1; margin-right: 20px;"/>
                            <input type="button" name="submit" id="checkUsername" class="form-submit" value="아이디중복확인" style="width: 150px;"/>
                        </div>
                        <div id="usernameResult"></div>
                        <div class="form-group">
                            <input type="password" class="form-input" name="password" id="password" placeholder="비밀번호"/>
                            <span toggle="#password" class="zmdi zmdi-eye field-icon toggle-password"></span>
                        </div>
                        <div class="form-group">
                            <input type="password" class="form-input" name="re_password" id="passwordConfirm" placeholder="비밀번호 재확인"/>
                        </div>
                        <div class="form-group" style="display: flex; justify-content: space-between; align-items: center;">
                            <input type="email" class="form-input" name="email" id="email" placeholder="이메일" style="flex-grow: 1; margin-right: 20px;"/>
                            <input type="button" name="sendVerificationCode" id="sendVerificationCode" class="form-submit" value="인증번호전송" style="width: 150px;"/>
                        </div>
                         <div class="form-group">
                            <input type="text" class="form-input" name="verificationCode" id="verificationCode" placeholder="인증번호를 입력해주세요"/>
                        </div>
                        
                        <div class="form-group">
                            <input type="submit" name="submit" id="submit" class="form-submit" value="회원가입"/>
                        </div>
                    </form>
                    <p class="loginhere">
                        이미 가입을 하셨나요? <a href="/login" class="loginhere-link">로그인</a>
                    </p>
                </div>
            </div>
        </section>

    </div>

    <!-- JS -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="js/main_register.js"></script>
    <script src="js/register.js"></script>
</body>
</html>

 

 

 

1