서비스 이용자는 일반 회원(USER)과 체육관 정보를 기입하고 운영하는 관장(MANAGER)으로 구분하였다.
@GetMapping("/joinSelect")
public String joinSelect() {
return "user/joinSelect";
}
@GetMapping("/join/{role}")
public String joinFormView(@PathVariable("role") String role, Model model) {
// 선택한 유형에 따라 가입 페이지로 이동
if (role.equals("user")) {
model.addAttribute("joinForm", new JoinForm());
return "user/joinForm";
} else if (role.equals("manager")) {
model.addAttribute("joinManagerForm", new JoinManagerForm());
return "manager/joinManagerForm";
}
return "redirect:/";
}
회원가입 시 가입유형을 선택할 수 있는 페이지를 구성하여 USER 또는 MANAGER를 선택할 수 있게 설정하였다.
-Controller-
@PostMapping("/join/user")
public String join(@Validated @ModelAttribute("joinForm") JoinForm joinForm, BindingResult bindingResult) {
// 아이디 중복 체크
if (JoinService.duplicateMemberId(joinForm.getMemId())) {
bindingResult.rejectValue("memId", "duplicate.id");
}
// validation에 부합하지 않으면 error 리턴
if (bindingResult.hasErrors()) {
log.error("errors: {}", bindingResult.getAllErrors());
return "user/joinForm";
}
// 조건부에 부합하면 db에 저장
JoinService.join(createUser(joinForm));
return "redirect:/gym/login";
}
-회원가입에 필요한 값들을 검사하는 validation-
@Component
public class JoinValidator implements Validator {
private static final Map<String, String> REGEXP_MAP = Map.of(
"memId", "^[a-z0-9]{5,20}$",
"password", "^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,16}$",
"name", "^[가-힣]+$",
"phone", "^01(?:0|1|[6-9])(?:\\d{3}|\\d{4})\\d{4}$"
);
private static final List<String> REQUIRED_FIELDS = List.of(
"memId", "password", "name",
"phone", "zipcode"
);
@Override
public boolean supports(Class<?> clazz) {
return JoinForm.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
JoinForm form = (JoinForm) target;
for (String requiredField : REQUIRED_FIELDS) {
String value = getFieldValue(form, requiredField);
if (!hasText(value) && !errors.hasFieldErrors(requiredField)) {
errors.rejectValue(requiredField, "required");
} else if (REGEXP_MAP.containsKey(requiredField) && !Pattern.matches(REGEXP_MAP.get(requiredField), value)) {
errors.rejectValue(requiredField, "pattern");
}
}
}
private String getFieldValue(JoinForm form, String requiredField) {
return switch (requiredField) {
case "memId" -> form.getMemId();
case "password" -> form.getPassword();
case "name" -> form.getName();
case "phone" -> form.getPhone();
case "zipcode" -> form.getZipcode();
default -> "";
};
}
}
-Service-
@Transactional
public Long join(Member member) {
member.passwordEncoding(passwordEncoder.encode(member.getPassword()));
memberRepository.save(member);
return member.getId();
}
// 아이디 중복확인
public boolean duplicateMemberId(String memId) {
boolean manager = managerRepository.findByManageId(memId).isPresent();
boolean user = memberRepository.findByMemId(memId).isPresent();
return manager || user;
}
-errors.properties-
required = 필수 정보입니다.
required.memId = 아이디: 필수 정보입니다.
required.password = 비밀번호: 필수 정보입니다.
required.name = 이름: 필수 정보입니다.
required.memNick = 닉네임: 필수 정보입니다.
required.phone = 휴대전화번호: 필수 정보입니다.
required.zipcode = 주소: 필수 정보입니다.
required.gymName = 체육관 이름: 필수 정보입니다.
required.gymPrice = 체육관 회비: 필수 정보입니다.
required.gymPhone = 체육관 전화번호: 필수 정보입니다.
required.gender = 성별: 필수 정보입니다.
required.age = 나이: 필수 정보입니다.
#패턴
pattern = 적합한 형식이 아닙니다.
pattern.memId = 5~20자의 영문 소문자, 숫자만 사용 가능합니다.
pattern.password = 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해 주세요.
pattern.memName = 한글 이름만 가능합니다
pattern.memNick = 2~8자의 한글, 영문 대/소문자, 숫자만 사용 가능합니다.
pattern.memPhoneNum = 휴대전화번호가 정확한지 확인해 주세요.
pattern.gymPhone = 전화번호가 정확한지 확인해 주세요.
range.age = 만 5세 이상부터 만 99세 이하만 등록 가능합니다.
duplicate.id = 이미 존재하는 아이디입니다.
NotBlank.memId = 아이디를 입력해주세요.
NotBlank.password = 비밀번호를 입력해주세요.
typeMismatch = 숫자만 가능합니다.
typeMismatch.gymPrice = 가격: 숫자만 가능합니다.
typeMismatch.age = 나이: 숫자만 가능합니다.
회원가입 시 아이디 중복 체크와 조건부를 설정하여 조건부에 부합할 때 회원가입을 할 수 있도록 설정하였다.
아이디 중복방지를 하기 위해 기존 데이터에서 중복을 확인하는 로직을 넣어 체크하였고, 다른 값들에 대해서는 validation을 통해 조건에 부합하지 않으면 bindingResult에 error를 담아 반환하였다. error메세지는 errors.properties에 등록하여 관리하였으며, 비밀번호는 PasswordEncoder를 통해 암호화하여 저장하였다.
-Controller-
@PostMapping("/join/manager")
public String joinManager(@Validated @ModelAttribute("joinManagerForm") JoinManagerForm joinForm, BindingResult bindingResult) {
if (JoinService.duplicateMemberId(joinForm.getMemId())) {
bindingResult.rejectValue("memId", "duplicate.id");
}
if (bindingResult.hasErrors()) {
log.error("errors: {}", bindingResult.getAllErrors());
return "manager/joinManagerForm";
}
JoinService.joinManager(createManagerAndGym(joinForm));
return "redirect:/gym/login";
}
-Service-
@Transactional
public Long joinManager(Gym managerAndGym) {
Manager manager = managerAndGym.getManager();
manager.passwordEncoding(passwordEncoder.encode(manager.getPassword()));
gymRepository.save(managerAndGym);
return manager.getId();
}
-Entity-
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Gym {
@Id @GeneratedValue
@Column(name = "gym_id")
private Long id;
private String gymName;
private Integer gymPrice;
private String gymPhoneNum;
private Address address;
@ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "manager_id")
private Manager manager;
public Gym(String gymName, Integer gymPrice, String gymPhoneNum, Address address, Manager manager) {
this.gymName = gymName;
this.gymPrice = gymPrice;
this.gymPhoneNum = gymPhoneNum;
this.address = address;
this.manager = manager;
}
public void updateGym(String gymName, Integer gymPrice, String gymPhoneNum, Address address) {
this.gymName = gymName;
this.gymPrice = gymPrice;
this.gymPhoneNum = gymPhoneNum;
this.address = address;
}
}
Gym Entity에 cascade를 설정함으로써 Gym을 등록할 때 연관관계에 있는 Manager도 자동으로 등록 한다.
Manager와 User를 상속을 통해 하나의 테이블로 처리하지 않은 이유는 Gym 테이블과 Manager 테이블이 연관관계를 가지기 때문에 구성요소의 차이가 크고 각각의 역할을 독립적으로 관리하는 것이 유지보수 측면에서 유리하기 때문이다.
-Controller-
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult,
@RequestParam(value = "redirect", defaultValue = "/") String redirectUrl,
HttpServletRequest request) {
if (bindingResult.hasErrors()) {
log.info("errors: {}", bindingResult.getAllErrors());
return "user/loginForm";
}
LoginUserSession loginMember = loginService.login(loginForm.getMemId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("userLoginId", "아이디 또는 비밀번호를 확인해주세요.");
return "user/loginForm";
}
request.getSession().setAttribute(LoginSessionConst.LoginSESSION_KEY, loginMember);
return "redirect:" + redirectUrl;
}
-Service-
public LoginUserSession login(String memId, String password) {
log.debug("memId: {}, password: {}", memId, password);
Member findUser = memberRepository.findByMemId(memId)
.filter(member -> passwordEncoder.matches(password, member.getPassword()))
.orElse(null);
Manager findManager = managerRepository.findByManageId(memId)
.filter(manager -> passwordEncoder.matches(password, manager.getPassword()))
.orElse(null);
log.debug("findUser: {}", findUser);
log.debug("findManager: {}", findManager);
if (findUser != null) {
return createLoginUserSession(findUser);
} else if (findManager != null) {
return createLoginManagerSession(findManager);
} else {
return null;
}
}
public LoginUserSession createLoginUserSession(Member member) {
return new LoginUserSession(member.getId(), member.getMemId(), member.getRole(), getMyGymList(member.getId()));
}
public Set<String> getMyGymList(Long id) {
return memberRepository.getMyGymList(id)
.stream()
.map(secureIdEncryptor::encryptId)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public LoginUserSession createLoginManagerSession(Manager manager) {
Set<String> gymIds = gymRepository.findByManagerId(manager.getId())
.stream()
.map(Gym::getId)
.map(secureIdEncryptor::encryptId)
.collect(Collectors.toCollection(LinkedHashSet::new));
return new LoginUserSession(manager.getId(), manager.getManageId(), manager.getRole(), gymIds);
}
-interceptor-
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getRequestURI().startsWith("/error")) {
return true;
}
String uri = request.getRequestURI();
HttpSession session = request.getSession();
if (session == null || session.getAttribute(LoginSessionConst.LoginSESSION_KEY) == null) {
response.sendRedirect("/gym/login?redirect=" + uri);
return false;
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
로그인은 회원가입과 마찬가지로 validation을 설정하여 값이 일치하지 않거나 아이디, 비밀번호가 일치하지 않으면 error를 반환하게 하였다.
또한, 로그인 성공 시 역할에 따라 세션을 저장하여 로그인 상태를 유지할 수 있도록 하였다. 인터셉터를 통해 로그인이 되어 있지 않은 상태에서 다른 페이지의 접근을 제한하였다.
@Service
@Transactional
@RequiredArgsConstructor
public class UpdateService {
private final ChildRepository childRepository;
private final MemberRepository memberRepository;
private final ManagerRepository managerRepository;
private final GymRepository gymRepository;
public boolean updateChild(EditChildForm editChildForm) {
Child findChild = childRepository.findById(editChildForm.getId())
.orElseThrow(() -> new DataNotFoundException("해당 데이터를 찾을 수 없습니다."));
findChild.updateChild(editChildForm.getBelt(), new Period(editChildForm.getStartDate(), editChildForm.getEndDate()));
return false;
}
public boolean updateUser(String memberId, MyPageForm myPageForm) {
Member findMember = memberRepository.findByMemId(memberId)
.orElseThrow(() -> new DataNotFoundException("해당 데이터를 찾을 수 없습니다."));
findMember.updateMember(myPageForm.getName(), myPageForm.getPhoneNumber(),
new Address(myPageForm.getZipCode(), myPageForm.getRoadName(), myPageForm.getDetailAddress()));
return true;
}
public boolean updateManager(String memberId, MyPageManagerForm myPageForm) {
Manager findManager = managerRepository.findByManageId(memberId)
.orElseThrow(() -> new DataNotFoundException("해당 데이터를 찾을 수 없습니다."));
findManager.updateManager(myPageForm.getName(), myPageForm.getPhoneNumber());
return true;
}
public boolean updateMyChild(MyChildEditForm form) {
Child findChild = childRepository.findById(form.getId())
.orElseThrow(() -> new DataNotFoundException("해당 데이터를 찾을 수 없습니다."));
findChild.updateChildInfo(form.getName(), form.getAge(), form.getGender(), form.getPhoneNumber());
return false;
}
public boolean updateMyGym(MyGymForm form) {
Gym findGym = gymRepository.findById(form.getGymId())
.orElseThrow(() -> new DataNotFoundException("해당 데이터를 찾을 수 없습니다."));
findGym.updateGym(form.getGymName(), form.getPrice(), form.getGymPhoneNum(),
new Address(form.getZipCode(), form.getRoadName(), form.getDetailAddress()));
return false;
}
public void deleteGymFromChild(Long childId) {
Child child = childRepository.findById(childId)
.orElseThrow(() -> new DataNotFoundException("해당 데이터를 찾을 수 없습니다."));
child.removeGym();
}
}
마이페이지에서는 내 정보 확인 및 수정이 가능하다. USER는 체육관에 등록한 자녀의 정보를 확인 및 수정이 가능하고, MANAGER는 자신이 운영하는 체육관의 정보를 확인 및 수정이 가능하다. 업데이트는 따로 쿼리를 작성하지 않고 JPA 더티체킹(Dirty Checking)을 처리하였다.
Dirty Checking
JPA의 더티 체킹(Dirty Checking) 이란 영속성 컨텍스트가 관리하는 엔티티 객체의 초기 상태(Snapshot) 와 변경된 상태 를 비교하여, 변경이 감지되면 자동으로 UPDATE 쿼리를 생성하고 실행하는 기능
-Controller-
@PostMapping("/search")
@ResponseBody
public List<NaverSearchResultForm> gymListSubmit(@RequestBody SearchForm form) {
log.debug("searchForm: {}", form.getSearchQuery());
return naverSearchService.getSearchResult(form.getSearchQuery());
}
-Service-
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class NaverSearchService {
@Value("${naver.url.search.local}")
private String baseUrl;
@Value("${naver.client.id}")
private String clientId;
@Value("${naver.client.secret}")
private String clientPwd;
private final GymService gymService;
private static final ObjectMapper objectMapper = new ObjectMapper();
public List<NaverSearchResultForm> getSearchResult(String query) {
log.debug("query parameter: {}", query);
try {
WebClient webClient = WebClient.builder()
.baseUrl(baseUrl)
.build();
JsonNode json = webClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("query", query)
.queryParam("display", 5)
.build())
.header("X-Naver-Client-Id", clientId)
.header("X-Naver-Client-Secret", clientPwd)
.retrieve()
.bodyToMono(JsonNode.class)
.block();
List<NaverSearchResultForm> result = new ArrayList<>();
if (json != null) {
for (JsonNode jsonNode : json.findValue("items")) {
log.debug("json node: {}", jsonNode);
String title = jsonNode.get("title")
.asText()
.replaceAll("<b>", " ")
.replaceAll("</b>", "")
.trim();
int idx = jsonNode.get("address").asText().indexOf(" ") + 1;
String address = jsonNode.get("address").asText().substring(idx);
idx = jsonNode.get("roadAddress").asText().indexOf(" ") + 1;
String roadAddress = jsonNode.get("roadAddress").asText().substring(idx);
double mapx = jsonNode.get("mapx").asDouble() / 10_000_000;
double mapy = jsonNode.get("mapy").asDouble() / 10_000_000;
log.debug("title: {}", title);
log.debug("address: {}", address);
Long findGymId = gymService.findSelectedGym(title, address, roadAddress);
if (findGymId != null) {
result.add(new NaverSearchResultForm(findGymId, title, address, mapx, mapy));
}
}
result.forEach(item -> log.debug("item: {}", item));
}
return result;
} catch (WebClientResponseException e) {
log.error("네이버 API 호출 오류: {}", e.getMessage());
throw new ExternalApiException("네이버 API 호출 중 오류가 발생했습니다.");
} catch (Exception e) {
log.error("예기치 못한 오류 발생: {}", e.getMessage());
throw new CustomServiceException("검색 서비스에서 예기치 못한 오류가 발생했습니다.");
}
}
}
회원은 체육관에 등록하기 위해서 원하는 지역을 선택하거나 체육관의 이름으로 검색하면 검색어를 받아 서비스단에서 WebClient를 통해 네이버 Search API를 호출하여 json형태로 데이터를 받는다.
서비스에 등록되어 있는 체육관인지 확인하여 네이버 MAP API를 통해 화면에 체육관 위치 및 주소 등의 정보를 볼 수 있도록 하였다. 회원은 등록 할 체육관을 선택하여
수강생의 정보를 입력한 뒤 결제를 하면 체육관 신규등록이 완료된다.
-Controller-
@PostMapping("/confirm")
public ResponseEntity<?> confirm(@RequestBody PayRequest payRequest,
@Login LoginUserSession userSession,
HttpSession session) throws IOException, InterruptedException {
HttpResponse response = requestConfirm(payRequest);
ChildRegisterForm childInfo = (ChildRegisterForm) session.getAttribute("save" + payRequest.getOrderId());
if (response.statusCode() == HttpStatus.OK.value()) {
try {
paymentService.save(changePayment(response, session, userSession.getId(), childInfo.getName()));
return ResponseEntity.ok("Payment successful");
} catch (Exception e) {
log.debug("error: {}", e.getMessage());
requestPaymentCancel(payRequest.getPaymentKey(), "결제 승인 후 저장 중 오류 발생. 결제가 취소되었습니다.");
return ResponseEntity.badRequest()
.body(PaymentErrorResponse.builder()
.code(500)
.message("결제 승인 후 저장 중 오류 발생. 결제가 취소되었습니다.")
.build());
}
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("error");
}
public HttpResponse requestConfirm(PayRequest confirmPaymentRequest) throws IOException, InterruptedException {
String tossOrderId = confirmPaymentRequest.getOrderId();
String amount = String.valueOf(confirmPaymentRequest.getAmount());
String tossPaymentKey = confirmPaymentRequest.getPaymentKey();
// 승인 요청에 사용할 JSON 객체 생성
JsonNode requestObj = objectMapper.createObjectNode()
.put("orderId", tossOrderId)
.put("amount", amount)
.put("paymentKey", tossPaymentKey);
// ObjectMapper를 사용하여 JSON 객체를 문자열로 변환
String requestBody = objectMapper.writeValueAsString(requestObj);
// 결제 승인 API 호출
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tossPaymentsConfig.url + "/confirm"))
.header("Authorization", getAuthorizations())
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
.build();
return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
}
Toss Payments API를 사용하여 결제 팝업에서 결제를 진행하면 해당 컨트롤러를 호출하여 결제 승인 요청하였다. HttpClient를 사용한 이유는 결제 승인 요청 후 반드시 응답을 받아야 하기 때문에 동기방식인 HttpClient를 사용하였다.
WebClient와 HttpClient의 차이점
WebClient는 비동기 방식이고 HttpClient는 동기 방식이다. WebClient는 높은 동시성을 가지기 때문에 여러 API를 호출하거나 성능이 중요한 곳에 적합하고 HttpClient는 반드시 응답을 받아야하는 경우 적합하다.
회원이 회비 납부기한 내에 언제든 원하는 때에 납부를 가능하게 하여 기존방식의 문제점인 납부 일자를 통일하여 회원이 원하는 기간에 제출하지 못하는 문제를 해결하였다.
회원은 게시판을 통해 등록한 체육관의 공지를 확인 할 수 있고, 이벤트 성으로 진행되는 프로그램을 신청할 수 있다.
관장은 특별프로그램이나 체육관에 대한 공지사항을 게시하여 회원들에게 정보공유를 할 수 있다.
-Controller-
@GetMapping("/manage")
public String manage(@RequestParam(value = "gymId") String encryptedGymId,
@RequestParam(value = "ctg", required = false) String ctg,
HttpServletRequest request,
@Login LoginUserSession userSession, Model model) {
Long gymId = (Long) request.getAttribute("gymId");
List<GymInfoDto> gymNames = gymService.findGymNames(userSession.getId());
List<ParentsInfoForm> childInMyGyms = manageService.findChildInMyGyms(gymId, ctg);
model.addAttribute("ctg", ctg);
model.addAttribute("gymNames", gymNames);
model.addAttribute("childInMyGyms", childInMyGyms);
return "gym/manageForm";
}
-Repository-
@Override
public List<ParentsInfoForm> getChildInfo(Long gymId, String ctg) {
List<Tuple> results = queryFactory.select(
Projections.constructor(
ParentsInfoForm.class,
member.id,
member.memName,
member.memPhoneNum,
member.address.roadName,
member.address.detailAddress
),
Projections.constructor(
ChildInfoForm.class,
child.id,
child.childName.as("name"),
child.childAge.as("age"),
child.childGender.as("gender"),
child.belt,
child.period.startDate,
child.period.endDate
)
)
.from(child)
.join(child.member, member)
.join(child.gym, gym)
.where(
child.gym.id.eq(gymId),
ctgEq(ctg)
)
.fetch();
Map<String, ParentsInfoForm> tupleMap = new LinkedHashMap<>();
for (Tuple tuple : results) {
ParentsInfoForm parents = tuple.get(0, ParentsInfoForm.class);
ChildInfoForm child = tuple.get(1, ChildInfoForm.class);
parents.getChildren().add(child);
tupleMap.computeIfAbsent(parents.getMemName(), k -> parents).getChildren().add(child);
}
return new ArrayList<>(tupleMap.values());
}
회원 관리에서는 전반적인 회원의 정보 확인 및 등록 기간 수정, 띠 수정, 회원정보 삭제, 잔여 수강기간에 대한 정보를 확인할 수 있다.
또한, 수강생의 정보에 부모의 정보를 함께 노출하였다. 동일한 체육관에 다니는 다수의 자녀가 수강하였을 때를 감안하여 부모의 성함을 기준으로 그룹화하여 데이터를 처리하였다.
-Controller-
@GetMapping("/manage/event")
public String eventListForm(@RequestParam(value = "gymId") String encryptedGymId,
@RequestParam(value = "eventId", required = false) Long eventId,
HttpServletRequest request,
@Login LoginUserSession userSession, Model model) {
Long gymId = (Long) request.getAttribute("gymId");
if (eventId != null) {
List<EventParticipantForm> participants = manageService.getParticipants(eventId);
model.addAttribute("participants", participants);
}
List<GymInfoDto> gymNames = gymService.findGymNames(userSession.getId());
List<ManageEventForm> events = eventService.getEvents(gymId);
model.addAttribute("gymId", encryptedGymId);
model.addAttribute("eventList", events);
model.addAttribute("gymNames", gymNames);
return "event/eventListForm";
}
활동 관리는 관장이 특수활동 등 공지로 게시한 프로그램을 신청한 회원들의 목록을 확인 할 수 있다.
위에 설명한 두 가지의 기능을 통하여 회원이 등록기간 만료일 전 회비를 납부하면, 등록 기간이 자동으로 갱신된다. 관장은 등록기간 만료도래일이 다가오는 회원을 한눈에 확인할 수있으며, 특수활동프로그램을 신청한 회원의 목록도 한번에 확인할 수있기 때문에 보다 효율적인 회원 관리를 할 수 있다.