백엔드로 진로를 정한 이후... 종강 후부터 계속 인프런 강의로 백엔드 강의 듣고있다가 생각나서 잠깐 해 본 프로젝트!!
방학동안 매일 3개 목표 정해서 기록하고 있는데 매번 칠하기도 귀찮고 이걸 웹으로 만들면 편하겠다 싶어서 도전해봄
(사실 한 3일 정도 시도해봤다가 뭔소린지 모르겠어서 개망함)
그러다가 갑자기 성공해서 기록하는 일지... 사실 별 거 없긴 한데 백엔드 제일 기초부분 다루는 거라 나름 공부됐기도 하고 무엇보다 내가 만들고 싶었던거 만들어서 뿌듯함 ㅎ.ㅎ
-완성 화면 (자세한 동작 영상은 09. 실행으로 ㄱ)


25.01.09(목) 시작~완성
00. 프로젝트 구상
총 2개의 페이지 종류로 나눠진다.
1. 메인 페이지: 그리드 형식으로 100개의 칸이 띄워지며 각 칸에는 1월 1일부터 100일의 날짜가 써져있다. 해당 날짜를 클릭하면 날짜 페이지로 이동한다. 상단에는 제목이 써져있고, 그 아래에는 3/100일 식으로 진행바가 표시된다. 각 날짜 페이지에서 모든 항목이 완료된 날짜만 해당 칸이 초록색으로 바뀌고, 그 일 수에 따라 진행바가 증가하는 형식이다.
2. 날짜 페이지: 모든 페이지에 동일한 내용으로 3개의 체크리스트가 나타난다.(내 목표... 공부/운동/산책 ㅎ.ㅎ) 사용자가 각 항목을 체크 하면 밑줄로 그어지고, 저장 버튼을 누르면 데이터베이스에 저장된다. 3개를 전부 체크하면, 메인페이지로 나갔을 때 해당 날짜 칸이 초록색으로 표시된다.
백엔드 프로젝트이기 때문에 프론트는 간단하게만 하고... 제일 중요한건 체크 여부가 제대로 동작하고 서버에 저장돼서 서버를 껐다 켜도 잘 유지되게 하는 것이다.
01. 프로젝트 생성
스프링부트 쓸 거라 아래 사이트에서 먼저 프로젝트를 만들어 준다.
https://start.spring.io/
Dependencies는 Spring Web, Spring Data JPA(sql), MariaDB Driver(데베), Thymeleaf(프론트), Lombok(@Getter등) 사용
build.gradle -> open해서 시작 (intellij 사용)
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'rabo'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
02. 데이터베이스 설정
MariaDB 사용 -> cmd로 설정

1. mysql 실행
mysql -u root -p
2. 현재 데이터베이스 확인
SHOW DATABASES;
3. 데이터베이스 생성
CREATE DATABASE name;
4. 해당 데이터베이스 선택
USE name;
5. 테이블 생성
CREATE USER 'userId'@'localhost' IDENTIFIED BY 'password';
6. 권한 부여
GRANT ALL PRIVILEGES ON challenge.* TO 'userId'@'localhost';
7. 저장
FLUSH PRIVILEGES;
+. 조회
SELECT * FROM name;
SHOW COLUMNS FROM name;
+.삭제
DROP DATABASE name;
application.properties
데이터베이스 정보 연동
spring.datasource.url=주소
spring.datasource.username=이름
spring.datasource.password=비밀번호
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
03. 디렉토리 구성

-패키지
| domain | Entity 클래스 |
| repository | JPA로 DB와 상호작용 |
| controller | 페이지 매핑, 사용자 요청 처리 |
| service | 비즈니스 로직 처리 |
기본적으로 필요한 4가지로 구성하였고, 각각 java class를 추가하였다.
또한, 이번 프로젝트는 100일 챌린지로 처음에 100개의 날짜가 초기화되어야 하기 때문에 애플리케이션이 시작될 때 실행되는 로직을 추가하였다. (CommandLineRunner 이용)
DataInitializer.java
package rabo.checklist;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import rabo.checklist.domain.ChallengeChecklist;
import rabo.checklist.repository.ChallengeChecklistRepository;
import java.time.LocalDate;
import java.util.stream.IntStream;
@Component
public class DataInitializer {
private final ChallengeChecklistRepository repository;
public DataInitializer(ChallengeChecklistRepository repository) {
this.repository = repository;
}
@Bean
public CommandLineRunner initializeData() {
return args -> {
//데이터 이미 있을 경우 초기화 건너뜀
if (repository.count() == 0) {
LocalDate startDate = LocalDate.of(2025, 1, 1);
IntStream.range(0, 100).forEach(i -> {
ChallengeChecklist checklist = new ChallengeChecklist();
checklist.setDate(startDate.plusDays(i));
repository.save(checklist);
});
System.out.println("100일 챌린지 초기화됨");
}
};
}
}
04. domain
Lombok의 @Getter @Setter 이용 (setting -> Annotation에서 Enable 켜주기)

ChallengeChecklist.java
id
date: 100개의 날짜 정보
task1Completed: 3개의 체크박스 상태 기록 (기본 false)
allCompleted: 3개 항목이 모두 완료되었는지 기록 (모두 완료되면 녹색으로 칠해져야 돼서 필요함)
package rabo.checklist.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
@Entity
@Table(name = "challenge_checklist")
@Getter @Setter
public class ChallengeChecklist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private LocalDate date;
@Column(nullable = false)
private boolean task1Completed = false;
@Column(nullable = false)
private boolean task2Completed = false;
@Column(nullable = false)
private boolean task3Completed = false;
@Column(nullable = false)
private boolean allCompleted = false;
}
05. Repository
ChallengeChecklistRepository.java
class말고 interface로 만들어서 extends로 JpaRepository를 상속받는다. JpaRepository에서 아래처럼 기본적으로 getById랑 findAll을 사용할 수 있게 해준다.

여기에 추가로 날짜별로 검색하는 것이 필요하므로 findByDate만 써주었다.
Spring Data JAP는 메서드 이름을 분석하여 SQL 쿼리를 자동으로 생성해주기 때문에, 규칙에 따라 작성하면 따로 구현할 필요가 없다.
findBy: 데이터를 조회하는 메서드임을 나타냄
Date: 엔티티의 data(컬럼) 기준으로 검색하라는 뜻
package rabo.checklist.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import rabo.checklist.domain.ChallengeChecklist;
import java.time.LocalDate;
public interface ChallengeChecklistRepository extends JpaRepository<ChallengeChecklist, Long> {
ChallengeChecklist findByDate(LocalDate date);
}
06. Controller
ChallengeController.java
repository는 service에서 직접 건드리므로, controller에선 service만 선언해서 주입해준다.
페이지별로 나누어서 매핑시킨다.
1. 메인 페이지: @GetMapping("/") -> 100개 챌린지 정보 다 가져온 후 완료된 날짜를 세서 모델에 챌린지 정보와 완료날짜 수를 담아서 템플릿 뷰로("main") 넘긴다.
@GetMapping("/")
public String mainPage(Model model) {
List<ChallengeChecklist> challenges = challengeService.getAllChallenges();
int completedDays = challengeService.getCompletedDays();
model.addAttribute("challenges", challenges);
model.addAttribute("completedDays", completedDays);
return "main"; // main.html
}
2. 날짜 페이지: @GetMapping("/date/{date}") -> @PathVariable로 파라미터에 있는 날짜 정보를 받아서 parse한 후 해당 날짜에 있는 체크리스트 정보를 가져와서 모델에 담고 템플릿 뷰("date")로 넘긴다.
@GetMapping("/date/{date}")
public String datePage(@PathVariable String date, Model model) {
LocalDate parsedDate = LocalDate.parse(date);
ChallengeChecklist checklist = challengeService.getChallengeByDate(parsedDate);
model.addAttribute("checklist", checklist);
model.addAttribute("date", date);
return "date"; // date.html
}
3. 날짜 페이지: @PostMapping("/date/{date}/update) -> 사용자가 체크한 정보를 post 해야되기 때문에 update로 메서드를 추가해야 한다. @PathVariable로 파라미터에 있는 날짜 정보를 받고, @RequestParam으로 체크 여부를 전달한다.(기본 false로 표시해줘야 체크 안 할 때도 무사히 정보가 넘어간다!! 안하면 체크 안할 때 저장안되고 오류남...)
@PathVariable: URL 경로 path에서 데이터를 추출하여 매핑 경로에 이용할 때 사용 ("/date/{date})
@RequestParam: URL ? 뒤에 오는 쿼리 파라미터 처리에 이용 (/date?task1=ture&task2=false&task3=false)
post한 후 그 페이지 그대로 있도록 redirect 하였다. (그냥 경로로 쓰면 새로고침할 때 계속 정보가 post돼서 오류 생길 수 있다... redirect로 하면 처음만 post고 새로고침하면 get으로 와서 변경이 안되어 안전하다.)
@PostMapping("/date/{date}/update")
public String updateChecklist(@PathVariable String date,
@RequestParam(defaultValue = "false") boolean task1,
@RequestParam(defaultValue = "false") boolean task2,
@RequestParam(defaultValue = "false") boolean task3) {
LocalDate parsedDate = LocalDate.parse(date);
challengeService.updateChecklist(parsedDate, task1, task2, task3);
return "redirect:/date/" + date;
}
+ 전체 코드
package rabo.checklist.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import rabo.checklist.domain.ChallengeChecklist;
import rabo.checklist.service.ChallengeService;
import java.time.LocalDate;
import java.util.List;
@Controller
public class ChallengeController {
@Autowired
private ChallengeService challengeService;
@GetMapping("/")
public String mainPage(Model model) {
List<ChallengeChecklist> challenges = challengeService.getAllChallenges();
int completedDays = challengeService.getCompletedDays();
model.addAttribute("challenges", challenges);
model.addAttribute("completedDays", completedDays);
return "main"; // main.html
}
@GetMapping("/date/{date}")
public String datePage(@PathVariable String date, Model model) {
LocalDate parsedDate = LocalDate.parse(date);
ChallengeChecklist checklist = challengeService.getChallengeByDate(parsedDate);
model.addAttribute("checklist", checklist);
model.addAttribute("date", date);
return "date"; // date.html
}
@PostMapping("/date/{date}/update")
public String updateChecklist(@PathVariable String date,
@RequestParam(defaultValue = "false") boolean task1,
@RequestParam(defaultValue = "false") boolean task2,
@RequestParam(defaultValue = "false") boolean task3) {
LocalDate parsedDate = LocalDate.parse(date);
challengeService.updateChecklist(parsedDate, task1, task2, task3);
return "redirect:/date/" + date;
}
}
07. Service
service에서 직접 repository를 사용하므로 먼저 선언하며 주입해준다.
controller에서 필요했던 함수들을 repository를 이용해서 구현한다.
ChallengeService.java
1. getAllCallenges: Jpa에서 기본으로 쓸 수 있는 findAll()로 DB에서 다 찾아와서 List로 반환한다.
public List<ChallengeChecklist> getAllChallenges() {
return repository.findAll();
}
2. getCallengeByDate: 날짜별로 검색할 수 있도록 그 전에 인터페이스에서 추가한 함수를 불러온다.
public ChallengeChecklist getChallengeByDate(LocalDate date) {
return repository.findByDate(date);
}
3. getCompletedDays: 100일 중에 모두 완료된 날짜가 며칠인지 계산한다. (메인페이지 진행바 위함)
stream을 통해 루프를 돌면서 filter로 조건에 맞는 것(다 완료된 날짜)만 count한다.
public int getCompletedDays() {
return (int) repository.findAll().stream()
.filter(ChallengeChecklist::isAllCompleted)
.count();
}
4. updateChecklist: post 컨트롤러에서 param으로 체크박스 정보를 넘긴 것으로, 해당 날짜 객체에 set으로 체크 여부를 저장한다. AllCompleted는 &&으로 연산 후 DB에 save로 저장한다. 또한, 다른 메서드에선 단순히 데이터를 read만 하므로 트랜잭션이 필요 없지만 update에선 데이터 read/write가 하나로 묶여서 처리되어야 오류가 발생하지 않으므로 @Transactional을 사용해서 원자성을 보장해야 한다. (일부만 저장되지 않도록 함, all or nothing)
@Transactional
public void updateChecklist(LocalDate date, boolean task1, boolean task2, boolean task3) {
ChallengeChecklist checklist = repository.findByDate(date);
if (checklist != null) {
checklist.setTask1Completed(task1);
checklist.setTask2Completed(task2);
checklist.setTask3Completed(task3);
checklist.setAllCompleted(task1 && task2 && task3);
repository.save(checklist);
}
}
+전체 코드
package rabo.checklist.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import rabo.checklist.domain.ChallengeChecklist;
import rabo.checklist.repository.ChallengeChecklistRepository;
import java.time.LocalDate;
import java.util.List;
@Service
public class ChallengeService {
@Autowired
private ChallengeChecklistRepository repository;
public List<ChallengeChecklist> getAllChallenges() {
return repository.findAll();
}
public ChallengeChecklist getChallengeByDate(LocalDate date) {
return repository.findByDate(date);
}
@Transactional
public void updateChecklist(LocalDate date, boolean task1, boolean task2, boolean task3) {
ChallengeChecklist checklist = repository.findByDate(date);
if (checklist != null) {
checklist.setTask1Completed(task1);
checklist.setTask2Completed(task2);
checklist.setTask3Completed(task3);
checklist.setAllCompleted(task1 && task2 && task3);
repository.save(checklist);
}
}
public int getCompletedDays() {
return (int) repository.findAll().stream()
.filter(ChallengeChecklist::isAllCompleted)
.count();
}
}
08. html
controller에서 return으로 템플릿 뷰로 넘긴 페이지는 "main"과 "date"이다.
resources -> templates에 main.html과 date.html을 만들어 준다.
main.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MAIN</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
line-height: 1.6;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.progress-container {
max-width: 1200px;
margin: 20px auto;
padding: 20px;
background-color: white;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.progress-text {
font-size: 1.2em;
margin-bottom: 10px;
text-align: center;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #90EE90;
transition: width 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: white;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 15px;
padding: 20px;
}
.date-box {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: white;
border: 1px solid #ddd;
border-radius: 8px;
text-decoration: none;
color: #333;
font-weight: bold;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.date-box:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.completed {
background-color: #90EE90;
border-color: #70DD70;
}
@media (max-width: 768px) {
.grid-container {
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 10px;
}
body {
padding: 10px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>100일 챌린지</h1>
<div class="progress-container">
<div class="progress-text" th:text="${completedDays} + '/100일 완료'"></div>
<div class="progress-bar">
<div class="progress-fill" th:style="'width: ' + ${completedDays} + '%'"></div>
</div>
</div>
<div class="grid-container">
<a th:each="challenge : ${challenges}"
th:href="@{'/date/' + ${challenge.date}}"
class="date-box"
th:classappend="${challenge.allCompleted} ? 'completed'"
th:text="${#temporals.format(challenge.date, 'MM/dd')}">
</a>
</div>
</div>
</body>
</html>
controller에서 "challenges", "completedDays" 이름으로 모델을 넘겼다. 이걸 각각 타임리프로 ${...} 받아서 출력한다.
date.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DATE</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
line-height: 1.6;
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 30px;
background-color: white;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.todo-item {
display: flex;
align-items: center;
padding: 15px;
background-color: #f8f8f8;
border-radius: 8px;
transition: all 0.3s ease;
}
.todo-item:hover {
background-color: #f0f0f0;
}
.todo-item label {
display: flex;
align-items: center;
width: 100%;
cursor: pointer;
font-size: 1.1em;
}
.todo-item input[type="checkbox"] {
margin-right: 15px;
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-item input[type="checkbox"]:checked + span {
text-decoration: line-through;
color: #888;
}
button {
width: 100%;
padding: 12px;
margin-top: 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1.1em;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #45a049;
}
.back-link {
display: block;
text-align: center;
margin-top: 20px;
color: #666;
text-decoration: none;
}
.back-link:hover {
color: #333;
}
@media (max-width: 768px) {
.container {
padding: 20px;
margin: 10px;
}
.todo-item {
padding: 12px;
}
}
</style>
</head>
<body>
<div class="container">
<h1 th:text="'챌린지: ' + ${date}"></h1>
<form th:action="@{'/date/' + ${date} + '/update'}" method="post">
<div class="todo-list">
<div class="todo-item">
<label>
<input type="checkbox" name="task1" th:checked="${checklist.task1Completed}">
<span>공부</span>
</label>
</div>
<div class="todo-item">
<label>
<input type="checkbox" name="task2" th:checked="${checklist.task2Completed}">
<span>산책</span>
</label>
</div>
<div class="todo-item">
<label>
<input type="checkbox" name="task3" th:checked="${checklist.task3Completed}">
<span>운동</span>
</label>
</div>
</div>
<button type="submit">저장하기</button>
</form>
<a href="/" class="back-link">← 메인 페이지로 이동</a>
</div>
</body>
</html>
controller에서 "checklist", "date" 이름으로 모델을 넘겼다. 이걸 각각 타임리프로 ${...} 받아서 출력한다.
09. 실행
서버를 키면...
완성!!!
처음에 구상했던 기능들 다 완료해서 완벽 끝~~~
그런데 제일 문제점....
아니 이거 내가 쓸라고 만든 건데,,,,
그래서 모바일에서도 화면 잘 보이도록 조정했는데,,,,
...이번에 처음 안건데 백엔드가 들어간... 그러니까 데베를 쓰는 거는 서버 배포가 복잡한데다 유료였음 하하하
전에 여행 사이트 등 프론트 프로젝트했을 때는 깃허브 페이지로 바로 간단하게 공유해서 이번에도 그렇게 하려고 했는데... 깃허브 페이지는 html 등만 가능하더라고요 아놔
그래서 그 다음날 1월 10일에 진짜 몇시간동안 도전해봄
koyeb 사용해서 무료로 하나 웹앱 배포할 수 있다길래 해봤는데 뭐 계속해도 다양하게 오류가 나
보통은 AWS나 리눅스 서버로 하는 것 같은데 이건 좀 복잡해보여서 공부를 하고 해야 될 것 같음...
그래도 이번에 하면서 진짜 신기했던 점:
사실 체크리스트 자체는 작년에 처음 프론트 공부했을 때 만들어봤었는데, 그때는 새로고침하자마자 날라가서 충격먹었었음..ㅋㅋㅋ
완전 허무했다가 이번에 데베 연동하면서 새로고침하든가 서버 껐다 키든가 뭔 짓을 해도 안 날라가고 유지되는 거 보면서 활짝 웃다
지금 현재 그 유명한 인프런 백엔드 코스로 계속 들으면서 이제 mvc2편 다 끝나가는데 빨리 jpa활용1까지 듣고 다른 것도 만들어 보고 싶음
아니면 지금 만든 거 확장시켜서 로그인 / 마이페이지 / 진행사항 게임 처럼 표시하기 / 체크리스트 자유 수정 / 완료 개수 여부에 따라 다른 색으로 표시 등 기능 추가해서 확장시키고 서버 배포까지 해서 완벽하게 만드는 것도 좋을 듯
일단 현재 기본 버전으로 완성된 것으로 공부해봄
진짜 끝~~~~~

'🌟Project > Challenge - 100일 챌린지 웹' 카테고리의 다른 글
| [토이프로젝트 개발일지 04] 일기 생성 기능 추가 / 메인 페이지 보완 (4) | 2025.01.21 |
|---|---|
| [토이프로젝트 개발일지 03] 산책 경로 저장 기능 추가 (카카오 지도 api) (2) | 2025.01.16 |
| [토이프로젝트 개발일지 02] Spring Boot, MariaDB 배포(CloudType) (1) | 2025.01.12 |