노노그래머스 2차
한국소프트웨어산업협회 주관 [회원사 채용연계형 MSA기반 Full Stack 개발 전문가 양성과정 3차]
2차 프로젝트 (11/14~11/23)
NONOGrammers¶
- 팀명 : 도트리키재기
- 팀원 : 유승희(팀장), 전승현, 송기영, 이성수
2차 프로젝트 목표¶
- Migration: MyBatis Spring Data JPA
- Migration: Html+CSS+Vanila JS Vue.js
- Migration: thymeleaf RestAPI
- 코드의 재사용성 및 유지보수성 향상
Technical Skills
Backend
Frontend
- 역할 분담
- Vue.js + JPA 전환과 이전에 구현하지 못했던 기능을 구현하는 것에 우선순위를 두고,
새로 개발할 기능 중 구현하고 싶은 작업들을 각각 선택하는 것으로 역할을 분담했습니다.
- 공통 작업 : JPA, Vue.js 전환
- 개인 작업 : 구현하고 싶은 기능, 이전에 구현하지 못했던 기능
- 새로운 기능 추가 : 관리자, 친구 시스템, 채팅, 대결 시스템, 알림 등
2차 프로젝트 종료 (11/23)¶
- 새로운 변화
git Hooks with husky
를 통한 Commit Convention 실수 방지
약속된 Commit Convention을 지키지 않거나, Commit message에 Jira의 Task 번호를 작성하는 것을 잊지 않도록 강제하는 환경을 가졌습니다
- 개발 일정
- 짧은 개발 일정, 그리고 migration의 어려움을 겪게되면서 새로운 기능 추가 보다는 migration에 집중했습니다
- Repository 구조
- - vue.js를 사용하게 되면서 Front 레포지토리를 따로 생성하였습니다
- service 레이어를 구축하여 코드 로직을 분리하였습니다
구조
project-root/
│
├── src/
│ ├── main/
│ │ ├── java/com/dottree/nonogrammers/
│ │ │ ├── config/
│ │ │ │ ├── jwt/
│ │ │ │ │ ├── JwtAuthenticationFilter.java
│ │ │ │ │ ├── JwtAuthorizationFilter.java
│ │ │ │ │ └── JwtProperties.java
│ │ │ │ ├── CorsConfig.java
│ │ │ │ ├── MyConfig.java
│ │ │ │ ├── RedisUtil.java
│ │ │ │ └── SpringSecurityConfig.java
│ │ │ ├── controller/
│ │ │ │ ├── MainController.java
│ │ │ │ ├── MyPageController.java
│ │ │ │ ├── PostController.java
│ │ │ │ └── UserController.java
│ │ │ ├── dao/
│ │ │ │ ├── CommentMapper.java
│ │ │ │ ├── MainMapper.java
│ │ │ │ ├── PostMapper.java
│ │ │ │ └── UserMapper.java
│ │ │ ├── repository/
│ │ │ │ ├── PostRepository.java
│ │ │ │ ├── ...
│ │ │ │ └── UserRepository.java
│ │ │ ├── entity/
│ │ │ │ ├── Board.java
│ │ │ │ ├── ...
│ │ │ │ └── User.java
│ │ │ ├── domain/
│ │ │ │ ├── UserDTO.java
│ │ │ │ ├── ...
│ │ │ │ └── PostDTO.java
│ │ │ ├── service/
│ │ │ │ ├── UserService.java
│ │ │ │ ├── ...
│ │ │ │ └── PostDTO.java
│ │ │ └── NonogrammersApplication.java
│ │ ├── resources/
│ │ │ ├── static/
│ │ │ │ └── images/
│ │ └── banner.txt
│ └── test/java/com/dottree/nonogrammers/NonogrammersApplicationTests.java
├── gradle/wrapper/
├── README.md
├── ...
└── .gitignore
project-root/
│
├── src/
│ ├── assets/
│ │ ├── css/
│ │ ├── fonts/
│ │ ├── images/
│ │ └── ...
│ ├── components/
│ │ ├── user/
│ │ ├── nono/
│ │ ├── ...
│ │ └── Header.vue
│ ├── js/
│ │ ├── axiosHandler.js
│ │ ├── ...
│ │ └── nonodot.js
│ ├── pages/
│ │ ├── Login.vue
│ │ ├── Join.vue
│ │ ├── ...
│ │ └── MyPage.vue
│ ├── router/
│ │ └── index.js
│ ├── stores/
│ │ ├── auth.store.js
│ │ └── mypage.js
│ ├── main.js
│ └── App.vue
├── .env
├── package.json
├── index.html
├── tailwind.config.js
├── postcss.config.js
├── ...
└── .gitignore
구현 기능 ¶
MyBatis -> JPA¶
MyBatis와 JPA를 함께 사용하기
MyBatis와 JPA를 함께 사용할 때 발생하는 오류를 해결하기 위해 Config 파일 추가
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
@Configuration
@MapperScan(value={"com.dottree.nonogrammers.dao"})
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sessionFactory.setTypeAliasesPackage("com.dottree.nonogrammers.dao");
return sessionFactory.getObject();
}
}
2023-11-06T14:56:39.894+09:00 ERROR 15338 --- [ Test worker] o.s.boot.SpringApplication : Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'mainController' defined in file [/Users/yshee/workspaces/projects/nonogrammers-solve-with-nonogram/build/classes/java/main/com/dottree/nonogrammers/controller/MainController.class]:
Unsatisfied dependency expressed through constructor parameter 0:
Error creating bean with name 'mainMapper' defined in file [/Users/yshee/workspaces/projects/nonogrammers-solve-with-nonogram/build/classes/java/main/com/dottree/nonogrammers/dao/MainMapper.class]:
Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required
JPA 매핑 전략 변경
Java는 카멜 케이스를 사용하지만, DB는 관례상 스네이크 케이스를 사용하므로 다음과 같은 코드 추가
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
JPA Auditing
createdAt
, updatedAt
등 컬럼의 생성일/수정일을 자동화하기 위해 사용
...
@EntityListeners(AuditingEntityListener.class)
@Builder(toBuilder = true)
public class User implements UserDetails {
...
@CreatedDate
@Column(nullable = false, updatable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime createdAt;
@LastModifiedDate
@Column(columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime updatedAt;
@EnableJpaAuditing
@SpringBootApplication//(exclude= DataSourceAutoConfiguration.class)
@ComponentScan(basePackages = {"com.dottree.nonogrammers", "templates"})
public class NonogrammersApplication {
public static void main(String[] args) {
SpringApplication.run(NonogrammersApplication.class, args);
}
}
@EntityListeners(AuditingEntityListener.class)
: 엔티티를 DB에 적용하기 전후에 커스텀 콜백을 요청할 수 있는 어노테이션@CreatedDate
: 해당 엔티티가 생성되는 시각을 자동으로 삽입@LastModifiedDate
: 해당 엔티티가 수정되는 시각을 자동으로 삽입
참고
- JPA 매핑 전략
- JPA Auditing
- CreationTimestamp vs CreatedDate : CreationTimestamp는 Hibernate 종속, CreatedDate는 SpringData 종속이다
Spring Security + JWT + Pinia¶
Spring Security + jjwt를 이용하여 JWT 기반의 인증/인가 프로세스를 구축하고, 클라이언트에서는 Pinia를 이용하여 사용자 정보를 저장 및 관리합니다
참고
아쉬운점
- 노노그래머스는 JWT 인증이 완료되면 서버에서 인증 완료와 함께 사용자 정보를 반환하는 방식을 사용하고 있습니다.
클라이언트 측에서 JWT를 분해하여 사용자 정보를 추출하는 방법, 사용자 정보를 제공하는 End-point를 따로 생성하여 관리하는 방법 등등 여러 방식이 존재하는데 어떤 방식이 가장 보편적이면서도 성능이 좋은 방법인 지 알지 못한 채로 끝나버린 게 아쉽습니다
그러나 버전이 업데이트되면서 시크릿 키를 랜덤 방식으로 지정하게 되었고, 임의의 문자열로 지정할 수 없어 개발 과정에서 불편함이 있었습니다.
이메일 인증 및 검증¶
사용자 이메일을 인증할 때, Redis를 이용하여 5분동안 인증번호 검증
- 회원가입 시 이메일 인증 : 인증번호를 입력하는 방식
- 비밀번호 찾기 시 이메일 인증 : 링크를 이용하여 인증하는 방식
public void sendJoinEmail(String email) {
String randomNumber = generateRandomNumber();
redisUtil.setData(randomNumber, email, 300000);
emailService.sendNumberEmail(email, randomNumber);
}
public void sendResetPasswordEmail(String email) throws MessagingException {
String token = generateToken();
redisUtil.setData(token, email, 300000);
emailService.sendLinkEmail(email, token);
}
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final StringRedisTemplate redisTemplate;
// key:value 데이터 추출
public String getData(String key) {
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
return valueOperations.get(key);
}
// key:value 데이터 저장
public void setData(String key, String value, long duration) {
System.out.println("key = " + key);
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
Duration expireDuration = Duration.ofMillis(duration);
valueOperations.set(key, value, expireDuration);
}
// key:value 데이터 삭제
public void deleteData(String key) {
redisTemplate.delete(key);
}
}
@Service
public class EmailService {
private final JavaMailSender javaMailSender;
public EmailService(JavaMailSender javaMailSender) {
this.javaMailSender = javaMailSender;
}
public void sendNumberEmail(String to, String number) {
String text = "안녕하세요!\n" +
"노노그래머스입니다.\n " +
"인증 번호는 " + number + "입니다.\n" +
"감사합니다.";
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject("[노노그래머스] 회원가입 이메일 인증 메일입니다.");
message.setText(text);
javaMailSender.send(message);
}
public void sendLinkEmail(String to, String token) throws MessagingException {
String verificationUrl = String.format("http://localhost:5173/reset-password?email=%s&code=%s", to, token);
String text = "<h1> 안녕하세요! 노노그래머스입니다. </h1>" +
"<br><p> 아래 링크를 클릭하면 이메일 인증이 완료됩니다. </p>" +
"<br><a href='" + verificationUrl + "'>이메일 인증하기</a><br>" +
"<br><p>감사합니다.</p>";
MimeMessage message = javaMailSender.createMimeMessage();
message.setRecipients(MimeMessage.RecipientType.TO, to);
message.setSubject("[노노그래머스] 회원가입 이메일 인증 메일입니다.");
message.setText(text, "utf-8", "html");
javaMailSender.send(message);
}
}
참고
- redis는 도커를 사용해서 실행
docker run --name nono-redis -p 6379:6379 -d redis
- build.gralde
implementation 'org.springframework.boot:spring-boot-starter-mail'
- Gmail SMTP 설정
- 스프링부트 구글 이메일 발송
- 스프링부트 이메일
- 스프링부트 네이버 이메일, 링크 클릭 인증
- 스프링부트+Redis
- 이메일 인증
- 인증번호 전송
- 스프링부트 Redis 이메일 인증
axios interceptor¶
axios의 interceptor를 이용하여
- 모든 request의 로그인 여부를 판단하고, 조건에 따라 JWT를 Authorization 헤더에 담는 코드 구현
- 모든 response의 Status code를 검토하여 401 또는 403인 경우 권한이 없음으로 인지
(API 호출 코드의 중복을 줄이기 위해서 구현했습니다 )
// Request Interceptor
axios.interceptors.request.use((config) => {
// 변수 설정
const isLoggedIn = (null === user.value) ? false : true ;// 로그인 여부
const publicApiPaths = import.meta.env.VITE_PUBLIC_API_PATHS.split(','); // 로그인이 필요하지 않은 API 목록
const isPublicApiPath = publicApiPaths.includes(new URL(config.url).pathname); // 로그인이 필요하지 않은 API 여부
// 조건 만족 시, 헤더에 Authorization 추가
if (isLoggedIn && !isPublicApiPath)
config.headers['Authorization'] = `Bearer ${user.value.token}`;
return config;
}, (error) => {
// 요청 에러 처리
return Promise.reject(error);
});
vue-router beforeEach¶
vue-router의 beforeEach
기능을 이용하여 페이지 이동 전 로그인 여부 검토 및 페이지 이동 처리
// 모든 라우터 이동 전에 실행
router.beforeEach(async (to) => { // to: 탐색 될 경로 위치 객체, from: 탐색 전 현재 경로 위치 객체
console.log("beforeEach")
const authStore = useAuthStore();
const { user, returnUrl } = storeToRefs(authStore);
// 로그인이 필요하지 않은 페이지 목록
const publicPages = import.meta.env.VITE_PUBLIC_PAGES.split(',');
const authRequired = !publicPages.includes(to.path);
// 로그인이 필요한 페이지에 접근하려고 할 때, 로그인이 되어있지 않으면 로그인 페이지로 이동
if (authRequired && user.value===null) {
if (to.path.startsWith('/detail')) {
return;
}
returnUrl.value = to.fullPath;
alert("로그인이 필요합니다!")
router.push('/login');
}
});
Quote
Document
Post
Book
- 스프링 부트 3 백엔드 개발자 되기 (지은이: 신선영 | 출판사: 골든래빗)