์์ ์ ๋ํ ํด์ฆ๋ฅผ ๋ด๊ณ ์น๊ตฌ์ ๊ณต์ ํ๋ ํ์ด ๋๋ ์น ์๋น์ค
"๋ณต ๋๋๋ฏธ๊ฐ ๋์ด ๋๋ฅผ ํด์ฆ๋ก ๊ณต์ ํ๊ณ , ๋ณต ๋ฐ์ผ๋ฏธ๊ฐ ๋์ด ์น๊ตฌ์ ๋ง์์ ๋งํ๋ณด์ธ์."
| HyungGeun BE |
[MiYeon] PM |
[HyeWon] FE |
[YunA] FE |
[ChaeWon] DE |
[JiWon] DE |
- ํ๋ก์ ํธ๋ช : Fortunetoss
- ๊ธฐ๊ฐ: 2024.12.20 ~ 2025.01.23 (5์ฃผ)
- ์ฃผ์ : ์์ ์ ๋ํ ํด์ฆ๋ฅผ ๋ง๋ค์ด ๊ณต์ ํ๊ณ , ์น๊ตฌ๊ฐ ํด์ฆ๋ฅผ ํ๋ฉฐ ๋๋ด๊ณผ ํ์ด์ ๋๋๋ ์๋น์ค
- ๋ฐฐํฌ:
Fortunetoss ํํ์ด์ง(ํ์ฌ ๋ฐฐํฌ ์ค๋จ)
- JPA์ QueryDSL์ ์ฌ์ฉํ ๋๋ฉ์ธ ์ค์ฌ ์ค๊ณ
- ์ฐ๊ด๊ด๊ณ์ ์๋ช ์ฃผ๊ธฐ๋ฅผ ๊ณ ๋ คํ ์ํฐํฐ ์ค๊ณ
- @Valid ๊ธฐ๋ฐ ์ ํจ์ฑ ๊ฒ์ฆ ๋ฐ ์ปค์คํ Validator ์ ์ฉ
- OAuth2 / JWT / Refresh Token ๊ธฐ๋ฐ ์ธ์ฆ ๊ตฌ์กฐ
- ํ ์คํธ ์ฝ๋ ๋์ ํ์ฌ ๊ฐ๋ฐ์ ์์ ์ฅ์น ํ๋ณด ๋ ธ๋ ฅ
|
|

๋ฌธ์ ์์ฑ ๋ฐ ๋๋ด ์
๋ ฅ

์ ๋ต์ยท์ค๋ต์ ํต๊ณ ํ์ธ

๋ฌธ์ ํ์ด ๋ฐ ๋๋ด ํ์ธ

๊ฒฐ๊ณผ ๊ณต์ ๋ฐ ์ ๊ท ํด์ฆ ์์ฑ
@Test
@DisplayName("์ผ๋ฐ๋ก๊ทธ์ธ_ํ ํฐ๋ฐ๊ธ์ฑ๊ณต")
void ๋ก๊ทธ์ธ_์ฑ๊ณต() throws Exception {
JoinDTO joinDTO = new JoinDTO("goorm94@naver.com", "password1234&");
String loginRequest = objectMapper.writeValueAsString(joinDTO);
MvcResult result = mockMvc.perform(post("/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginRequest))
.andExpect(cookie().exists("refresh"))
.andExpect(header().exists("access"))
.andExpect(status().isOk())
.andReturn();
String accessToken = result.getResponse().getHeader("access");
String refreshToken = result.getResponse().getCookie("refresh").getValue();
assertNotNull(accessToken);
assertNotNull(refreshToken);
assertEquals(joinDTO.getUsername(), jwtUtil.getUsername(accessToken));
}๐ง QueryDSL (๋ณต์ฃผ๋จธ๋ ์กฐํ)
/**
* Many ๊ธฐ์ค ์กฐํ๋ก ํ์ด์ง์ด ๊ฐ๋ฅ
* LuckyPouch โ Shape, QuestionCustom ์ ๋ชจ๋ ๋จ์ผ ์ฐธ์กฐ ๊ด๊ณ
* fetch join ์ ์ฌ์ฉํด ์ฐ๊ด๋ ๋ฐ์ดํฐ๋ฅผ ์ฆ์ ๋ก๋ฉ (N+1 ๋ฐฉ์ง)
* ํ์ด์ง(offset + limit) ์ ์ค๋ณต ๋ฐ์ดํฐ ๋ปฅํ๊ธฐ ์์
*/
public Slice<LuckyPouch> findUsersLuckyPouches(User findUser, Pageable pageable) {
List<LuckyPouch> luckyPouches = queryFactory.select(luckyPouch)
.from(luckyPouch)
.join(luckyPouch.shape, shape).fetchJoin()
.leftJoin(luckyPouch.questionCustom).fetchJoin()
.where(luckyPouch.user.eq(findUser))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.orderBy(
questionCustomIsNotNullDesc(),
luckyPouch.questionCustom.createdAt.desc()
)
.fetch();
boolean hasNext = luckyPouches.size() > pageable.getPageSize();
if (hasNext) luckyPouches.remove(pageable.getPageSize());
return new SliceImpl<>(luckyPouches, pageable, hasNext);
}๐ฆ์กฐ๊ฑด ๊ธฐ๋ฐ ์ ๋ ฌ (Conditional Ordering)
private OrderSpecifier<Integer> questionCustomIsNotNullDesc() {
return new CaseBuilder()
.when(luckyPouch.questionCustom.isNotNull()).then(1)
.otherwise(0)
.desc();
}๐๏ธ์๋ธ์ฟผ๋ฆฌ ์ฌ์ฉ
public Question findRandomQuestion(User findUser){
Question findQuestion = queryFactory.select(question)
.from(question)
.where(question.id.notIn(
queryFactory.select(pouch.question.id)
.from(pouch)
.join(pouch.user, user)
.where(user.eq(findUser))
))
.limit(1)
.fetchOne();
return findQuestion;
}๐ ์ ํจ์ฑ ๊ฒ์ฆ (์ปค์คํ ์ ๋ํ ์ด์ + Validator ์ ์ฉ)
๋๋ค์ ํ์ ์ ์ฝ์ ์ํด @Nickname ์ ๋ํ
์ด์
๊ณผ Validator ์ง์ ์ ์
โ ๋น์ฆ๋์ค ๋ก์ง๊ณผ ์
๋ ฅ ๊ฒ์ฆ์ ์์ ํ ๋ถ๋ฆฌ
// ์ปค์คํ
์ ๋ํ
์ด์
์ ์
@Constraint(validatedBy = NicknameValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Nickname {
String message() default "๋๋ค์์ 10์ ์ด๋ด, ํน์๋ฌธ์ยท๋์ด์ฐ๊ธฐยท์ด๋ชจ์ง ๋ถ๊ฐ";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator ๊ตฌํ
public class NicknameValidator implements ConstraintValidator<Nickname, String> {
// ์กฐ๊ฑด: 10์ ์ด๋ด, ํน์๋ฌธ์/๋์ด์ฐ๊ธฐ/์ด๋ชจ์ง ํฌํจ ๊ธ์ง
private static final String PATTERN = "^[a-zA-Z0-9ใฑ-ใ
ใ
-ใ
ฃ๊ฐ-ํฃ]{1,10}$";
@Override
public boolean isValid(String nickname, ConstraintValidatorContext context) {
return nickname != null && nickname.matches(PATTERN);
}
}
// DTO + ์ปจํธ๋กค๋ฌ ์ ์ฉ
public class NicknameRequest {
@NotNull(message = "๋๋ค์์ ํ์ ์
๋ ฅ ๊ฐ์
๋๋ค.") // ์ถ๊ฐ
@Nickname
private String nickname;
}
@PostMapping("/users/validate")
public ResponseEntity<ResponseDto<?>> validateUser(@Valid @RequestBody NicknameRequest request) {
return new ResponseEntity<>(
new ResponseDto<>("success", "user validate success", null, null, 200),
HttpStatus.OK);
}๐ฑ ๋๋ฉ์ธ ์ด๊ธฐํ (์๋ช ์ฃผ๊ธฐ ํ์ฉ)
@Component
@RequiredArgsConstructor
public class ShapeInitializer {
private final ShapeRepository shapeRepository;
@PostConstruct
public void init() {
if (shapeRepository.count() == 0) {
List<String> shapes = List.of("A", "B", "C", "D", "E", "F", "G", "H");
shapes.forEach(s -> shapeRepository.save(Shape.builder().domain(s).build()));
System.out.println("Shape ๋ฐ์ดํฐ ์ด๊ธฐํ ์๋ฃ");
}
}
}๐งน ํ์ํํด ๋ก์ง (์ฐ๊ด๊ด๊ณ ์ฐธ์กฐ ๋ฐ ์๋ช ์ฃผ๊ธฐ ๊ณ ๋ ค)
@Transactional
public void deleteUser() {
User currentUser = getCurrentUser();
pouchRepository.deleteByUserId(currentUser.getId());
luckyPouchRepository.findByUserId(currentUser.getId()).forEach(pouch -> {
if (pouch.getQuestionCustom() != null) {
luckyPouchRepository.delete(pouch);
answerRepository.deleteByQuestionCustomId(pouch.getQuestionCustom().getId());
questionCustomRepository.deleteById(pouch.getQuestionCustom().getId());
}
});
luckyPouchRepository.deleteByUserId(currentUser.getId());
userRepository.delete(currentUser);
}์ด๋ฒ ํ๋ก์ ํธ๋ ํ ๋จ์์์ ๋๋ฉ์ธ ์ค์ฌ ์ค๊ณ(JPA + QueryDSL) ๋ฅผ ์ ๊ทน์ ์ผ๋ก ์ ์ฉํด ๋ณด์๋ค.
๋จ์ CRUD ์ค์ฌ์ ๋ฐฑ์๋๊ฐ ์๋๋ผ, ์ํฐํฐ ๊ฐ ์ฐ๊ด๊ด๊ณ์ ์๋ช
์ฃผ๊ธฐ๋ฅผ ๊ณ ๋ฏผํ๋ฉฐ ์ค๊ณํ๋ค๋ ์ ์ด ๊ฐ์ฅ ํฐ ์ฐจ์ด์๋ค.
- QueryDSL๋ก ๋ณต์กํ ์กฐ๊ฑด ๊ฒ์๊ณผ ํ์ด์ง ๋ก์ง์ ์ง์ ๊ตฌํํ๋ฉฐ, SQL๊ณผ ORM ์ฌ์ด์ ๊ท ํ์ ์ฒด๊ฐํ๋ค.
- OAuth2 + JWT ์ธ์ฆ ๊ตฌ์กฐ๋ฅผ ๊ตฌ์ถํ๋ฉด์ ํ ํฐ์ ๋ฐ๊ธยท๊ฐฑ์ ยท๋ง๋ฃ ํ๋ฆ์ ์ฝ๋๋ก ๋ช ํํ ๋ค๋ค๋ค.
- ์ปค์คํ @Valid ๊ฒ์ฆ๊ธฐ๋ฅผ ๊ตฌํํด ์ ๋ ฅ๊ฐ ๊ฒ์ฆ์ ์๋น์ค ๋ก์ง๊ณผ ๋ถ๋ฆฌํ๋ ๊ตฌ์กฐ์ ์ฌ๊ณ ๋ฅผ ์ตํ๋ค.
- ์๋ช ์ฃผ๊ธฐ(@PostConstruct) ๋ฅผ ํ์ฉํด ๋๋ฉ์ธ ๋ฐ์ดํฐ๋ฅผ ์ด๊ธฐํํ๋ฉฐ Spring Lifecycle์ ์ดํดํ๋ค.
๋ค์ํ ์ง๊ตฐ์ ํ์๋ค๊ณผ ํ์ ํ๋ฉฐ ์ํตํ๋ ๊ณผ์ ์ด ์ฆ๊ฑฐ์ ๊ณ , ๊ฐ์์ ์ญํ ์์ ๋์จ ๋ค์ํ ์๊ฒฌ์ ๋ค์ ์ ์์ด ์ ์ตํ ๊ฒฝํ์ด์๋ค.
๐ฌ โ5์ฃผ๊ฐ์ ์งง์ ํ์ ์ด์์ง๋ง, ๊ฐ๋ฐ ๊ธฐ์ ๋ฟ๋ง ์๋๋ผ ํ์ ๊ณผ ์ํต ๋ฅ๋ ฅ๊น์ง ๋ฐฐ์ธ ์ ์์๋ ๋ป๊น์ ์๊ฐ์ด์๋ค.โ
ยฉ 2025 Team Bokkie โ Fortunetoss



