기능설명
- 회원가입
홍익대학교 이메일 인증을 거친 후 아이디, 비밀번호, 닉네임, 홍익대학교 이메일을 받아 아이디, 닉네임, 홍익대학교 이메일 중 어느하나라도 기존 DB에 저장되어 있는 값과 겹치면 회원가입 불가
- 로그인
아이디와 비밀번호를 입력받음
- 투표
1. 투표
재학생만 최대 3개의 가게에 투표 가능함. 재학생이 아니거나 이미 투표한 경우에는 투표가 불가능함
2. 자기투표조회
자기 자신이 현재까지 어떤 가게에 투표했는지 목록을 볼 수 있음
3. 월별결과보기
특정 연월의 투표결과를 볼 수 있음 (비재학생의 경우도 볼 수 있음)
4. 결과보기
현재 진행 중인 투표의 결과를 볼 수 있음 (비재학생의 경우도 볼 수 있음)
-> 회원가입과 로그인 기능에 대해서는 간단하게 넘어가도 될 듯함
코드설명
- 회원가입
public ResponseEntity<ResponseDTO> signUp(MemberRequestDTO memberRequestDTO) throws Exception {
String nickname = memberRequestDTO.getNickname();
String userId = memberRequestDTO.getUserId();
String password = memberRequestDTO.getPassword();
String email = memberRequestDTO.getEmail();
if (nickname == null || nickname.isEmpty()) { //userId 와 password 는 validity 검사 대상임
ResponseDTO responseDTO = ResponseDTO.builder()
.status(400)
.message("닉네임을 입력해주세요")
.build();
return new ResponseEntity<>(responseDTO, HttpStatus.BAD_REQUEST);
} else if (email == null || email.isEmpty()) {
ResponseDTO responseDTO = ResponseDTO.builder()
.status(400)
.message("이메일 주소를 입력해주세요")
.build();
return new ResponseEntity<>(responseDTO, HttpStatus.BAD_REQUEST);
}
else {
boolean isAlreadyExist = memberRepository.existsByUserIdOrEmailOrNickname(
userId, email, nickname);
if (isAlreadyExist) {
ResponseDTO responseDTO = ResponseDTO.builder()
.status(400)
.message("이미 가입한 사용자입니다.")
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(responseDTO);
}
Member member = Member.builder()
.nickname(nickname)
.userId(userId)
.password(password)
.email(email)
.voteAvailable(true)
.build();
System.out.println("회원정보가 입력됨" + member);
memberRepository.save(member);
ResponseDTO responseDTO = ResponseDTO.builder()
.status(200)
.message("성공")
.build();
return ResponseEntity.status(HttpStatus.OK).body(responseDTO);
}
}


회원가입에서 기본이 되는 코드
회원가입 할 때 요청으로 받는 requestDTO가 로그인 때 requestDTO와 공유 중이기 때문에 아이디와 비밀번호는 유효성검사의 대상이나 닉네임과 이메일주소는 유효성검사의 대상이 아니기 때문에 수동으로 유효성검사를 진행함
- 로그인
package com.hongchelin.service.login;
import com.hongchelin.Domain.Token;
import com.hongchelin.Repository.MemberRepositoryInterface;
import com.hongchelin.Repository.TokenRepositoryInterface;
import com.hongchelin.service.JWT.JWTFilter;
import com.hongchelin.dto.Request.MemberRequestDTO;
import com.hongchelin.dto.user.ResponseDTO;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
public class LoginMainService {
private final MemberRepositoryInterface memberRepository;
private final TokenRepositoryInterface tokenRepository;
private final JWTFilter jwtFilter;
public LoginMainService(MemberRepositoryInterface memberRepository,
JWTFilter jwtFilter,
TokenRepositoryInterface tokenRepository) {
this.memberRepository = memberRepository;
this.jwtFilter = jwtFilter;
this.tokenRepository = tokenRepository;
}
public ResponseEntity<ResponseDTO> login(String secret, MemberRequestDTO memberRequestDTO) {
System.out.println(memberRequestDTO);
System.out.println("요청확인");
String userId = memberRequestDTO.getUserId();
String password = memberRequestDTO.getPassword();
Integer count = memberRepository.countByUserIdAndPassword(userId, password);
if (count == 1) { //정보 있음. 로그인
String refreshToken = jwtFilter.createRefreshToken(secret, userId);
ResponseDTO responseDTO = ResponseDTO.builder()
.status(200)
.message("성공")
.accessToken(jwtFilter.createToken(secret, userId))
.refreshToken(refreshToken)
.build();
Token token = Token.builder()
.userId(userId)
.refreshToken(refreshToken)
.build();
tokenRepository.save(token);
System.out.println(responseDTO);
return ResponseEntity.status(HttpStatus.OK).body(responseDTO);
} else { //로그인 정보 없음
ResponseDTO responseDTO = ResponseDTO.builder()
.status(400)
.message("아이디 또는 비밀번호가 올바르지 않습니다.")
.build();
System.out.println(responseDTO);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(responseDTO);
}
}
}


로그인에서의 메인이 되는 코드
로그인 시 회원정보가 있을 경우에는 토큰을 만들어서 반환해주고 아닐 경우 에러를 반환한다.
- 투표
package com.hongchelin.service.Vote;
import com.hongchelin.Domain.*;
import com.hongchelin.Repository.*;
import com.hongchelin.exceptions.CannotFoundDbElementException;
import com.hongchelin.exceptions.UnauthorizedException;
import com.hongchelin.service.JWT.JWTFilter;
import com.hongchelin.dto.Request.voteRequstDTO;
import com.hongchelin.dto.Response.StoreForVoteResponseDTO;
import com.hongchelin.dto.user.ResponseDTO;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
@Service
public class VoteService {
private final JWTFilter jwtFilter;
private final MemberRepositoryInterface memberRepository;
private final StoreForVoteRepositoryInterface storeForVoteRepository;
private final StoreRepositoryInterface storeRepositoryInterface;
private final VoteRecordRepository voteRecordRepository;
private final PastStoreRepositoryInterface pastStoreRepositoryInterface;
public VoteService(JWTFilter jwtFilter,
MemberRepositoryInterface memberRepository,
StoreForVoteRepositoryInterface storeForVoteRepository,
StoreRepositoryInterface storeRepositoryInterface,
VoteRecordRepository voteRecordRepository, PastStoreRepositoryInterface pastStoreRepositoryInterface) {
this.jwtFilter = jwtFilter;
this.memberRepository = memberRepository;
this.storeForVoteRepository = storeForVoteRepository;
this.storeRepositoryInterface = storeRepositoryInterface;
this.voteRecordRepository = voteRecordRepository;
this.pastStoreRepositoryInterface = pastStoreRepositoryInterface;
}
public ResponseEntity<StoreForVoteResponseDTO> voteMainService(
String secret,
voteRequstDTO voteRequstDTO,
HttpServletRequest request) throws UnauthorizedException {
Date date = new Date();
List<Long> votedIds = voteRequstDTO.getVotedIdList();
List<StoreForVote> storeForVoteList = new ArrayList<>(); //저장용 리스트 / 저장할 때 사용함
boolean validity = jwtFilter.getTokenFromHeader(secret, request).getValidity();
if (!validity) {
throw new UnauthorizedException();
}
String userId = jwtFilter.getTokenFromHeader(secret, request).getMemberInfo().getIdentifier();
boolean voteAvailable = memberRepository.findByUserId(userId).get(0).isVoteAvailable(); //사용자 투표여부 조사
if (!voteAvailable) { //이미투표한사용자
StoreForVoteResponseDTO storeForVoteResponseDTO = StoreForVoteResponseDTO.builder()
.status(200)
.message("이미 투표한 사용자입니다.")
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(storeForVoteResponseDTO);
}
if (votedIds == null || votedIds.isEmpty()) { //사용자가 투표한 상점들의 id 리스트 - 메서드 인자로 제공됨
StoreForVoteResponseDTO storeForVoteResponseDTO = StoreForVoteResponseDTO.builder()
.status(400)
.message("반드시 하나를 선택해주세요.")
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(storeForVoteResponseDTO);
}
if (votedIds.size() > 3) { //3개이상 선택불가
StoreForVoteResponseDTO storeForVoteResponseDTO = StoreForVoteResponseDTO.builder()
.status(400)
.message("3개 이상은 선택이 불가합니다.")
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(storeForVoteResponseDTO);
}
for (Long id : votedIds) { //리스트를 순회하며 아이디를 따고 상점정보를 가져와 저장함
Store store = storeRepositoryInterface.findById(id);
if (store != null) { //DB에 값이 있는 경우
StoreForVote storeForVote = storeForVoteRepository.findByStoreId(id);
if (storeForVote != null) {
storeForVoteList.add(storeForVote);
} else {
throw new CannotFoundDbElementException();
}
} else { //DB에 값이 없는 경우 - 저장 취소 및 오류 반환
StoreForVoteResponseDTO storeForVoteResponseDTO = StoreForVoteResponseDTO.builder()
.status(400)
.message("입력된 값이 존재하지 않습니다.")
.build();
storeForVoteList.clear();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(storeForVoteResponseDTO);
}
}
for (StoreForVote storeForVote : storeForVoteList) { // 인자로 받은 값들이 모두 정상. DB에 저장하는 과정
Integer votedCount = storeForVote.getVotedCount() + 1;
storeForVote.setVotedCount(votedCount);
storeForVoteRepository.save(storeForVote);
}
StoreForVoteResponseDTO storeForVoteResponseDTO = StoreForVoteResponseDTO.builder()
.status(200)
.message("성공")
.build();
modifyUserVoteAvailable(userId);
for (long ids : votedIds) {
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM");
String formattedDate = formatter.format(date);
VoteRecord voteRecord = VoteRecord.builder()
.userId(userId)
.votedId(ids)
.whenForVote(formattedDate)
.build();
voteRecordRepository.save(voteRecord);
}
return ResponseEntity
.status(HttpStatus.OK)
.body(storeForVoteResponseDTO);
}
public ResponseDTO modifyUserVoteAvailable(String userId) {
Member member = memberRepository.findByUserId(userId).get(0);
member.setVoteAvailable(false);
memberRepository.save(member);
return ResponseDTO.builder()
.status(200)
.build();
}
}
투표메인
투표한 가게의 아이디를 배열로 받아 배열을 순회하면서 해당 아이디가 유효한지 검사, 모든 요소들이 유효하다면 차례로 투표 후보들에게서 득표수를 1 증가시킨다.
잘못된 가게의 아이디가 들어온 경우, 아이디를 하나도 전달하지 않은 경우, 아이디를 3개 초과로 전달한 경우에 대해서 각각 조건문으로 exception 처리를 했다.
package com.hongchelin.service.Vote;
import com.hongchelin.Domain.StoreForVote;
import com.hongchelin.Repository.StoreForVoteRepositoryInterface;
import com.hongchelin.dto.Response.StoreForVoteResponseDTO;
import com.hongchelin.dto.Response.StoreResponseEntityDTO;
import com.hongchelin.service.StoreConverterService;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class VoteResultService {
private final StoreForVoteRepositoryInterface storeForVoteRepository;
private StoreConverterService storeConverterService;
public VoteResultService(StoreForVoteRepositoryInterface storeForVoteRepository,
StoreConverterService storeConverterService) {
this.storeForVoteRepository = storeForVoteRepository;
this.storeConverterService = storeConverterService;
}
public ResponseEntity<StoreResponseEntityDTO> voteResult () {
List<StoreForVote> voteList = getStoresForVote();
return storeConverterService.convert(voteList);
}
public List<StoreForVote> getStoresForVote() {
return storeForVoteRepository.findAll();
}
}
투표조회
현재 투표 중인 가게들이 있는 DB 전체를 가져와 보여준다. 다만 투표 중인 가게들이 있는 DB 테이블은 정규화되어 있어 가게ID, 득표수만 표출할 수 있기 때문에 storeConverterService 객체를 호출하여 이를 사용자가 보기 좋게 바꿔 득표율 순으로 정렬한다.
package com.hongchelin.service;
import com.hongchelin.Domain.PastStoreForVote;
import com.hongchelin.Domain.Store;
import com.hongchelin.Domain.StoreForVote;
import com.hongchelin.Repository.StoreRepositoryInterface;
import com.hongchelin.dto.Response.StoreResponseDTO;
import com.hongchelin.dto.Response.StoreResponseEntityDTO;
import com.hongchelin.exceptions.CannotFoundDbElementException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class StoreConverterService {
private final StoreRepositoryInterface storeRepository;
public StoreConverterService(StoreRepositoryInterface storeRepository) {
this.storeRepository = storeRepository;
}
public ResponseEntity<StoreResponseEntityDTO> convert(List<StoreForVote> storeForVotes) {
List<StoreResponseDTO> storesList = storeForVotes.stream()
.map(storeForVote -> {
Store store = storeRepository.findById(storeForVote.getStoreId());
if (store == null) {
throw new CannotFoundDbElementException();
}
return StoreResponseDTO.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.storeLocation(store.getStoreLocation())
.storeInfoOneline(store.getStoreInfoOneline())
.storeImg(store.getStoreImg())
.votedCount(storeForVote.getVotedCount())
.build();
})
.sorted((s1, s2) -> s2.getVotedCount() - s1.getVotedCount())
.collect(Collectors.toList());
StoreResponseEntityDTO storeResponseEntityDTO = StoreResponseEntityDTO.builder()
.status(200)
.message("성공")
.stores(storesList)
.build();
return ResponseEntity
.status(HttpStatus.OK)
.body(storeResponseEntityDTO);
}
public ResponseEntity<StoreResponseEntityDTO> convertPast(List<PastStoreForVote> storeForVotes) {
List<StoreResponseDTO> storesList = storeForVotes.stream()
.map(pastStoreForVote -> {
Store store = storeRepository.findById(pastStoreForVote.getStoreId());
if (store == null) {
throw new CannotFoundDbElementException();
}
return StoreResponseDTO.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.storeLocation(store.getStoreLocation())
.storeInfoOneline(store.getStoreInfoOneline())
.storeImg(store.getStoreImg())
.votedCount(pastStoreForVote.getVotedCount())
.whenForVote(pastStoreForVote.getWhenForVote())
.isSelected(pastStoreForVote.isSelected())
.build();
})
.sorted((s1, s2) -> s2.getVotedCount() - s1.getVotedCount())
.collect(Collectors.toList());
StoreResponseEntityDTO storeResponseEntityDTO = StoreResponseEntityDTO.builder()
.status(200)
.message("성공")
.stores(storesList)
.build();
return ResponseEntity
.status(HttpStatus.OK)
.body(storeResponseEntityDTO);
}
}
컨버터
storeForVote와 pastStoreForVote 두 객체가 컨버터를 필요로하기 때문에 두 개의 메서드를 짰다. 제너릭 쓰면 좀 더 리팩토링이 쉬울 것 같은데 그럼 또 상속관계로 묶는 등의 일이 커지기 때문에 우선은 보류
투표후보들이 있는 DB에서 가져온 요소들을 리스트에 담아 컨버터에 보내면 스트림을 통해 이를 가게 정보를 담아 보낼 수 있도록 한다.
package com.hongchelin.service.Vote;
import com.hongchelin.Domain.PastStoreForVote;
import com.hongchelin.Domain.VoteRecord;
import com.hongchelin.Repository.PastStoreRepositoryInterface;
import com.hongchelin.Repository.VoteRecordRepository;
import com.hongchelin.dto.Response.PastStoreForVoteResponseDTO;
import com.hongchelin.exceptions.UnauthorizedException;
import com.hongchelin.service.JWT.JWTFilter;
import com.hongchelin.service.StoreConverterService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AccessMyVoteService {
private JWTFilter jwtFilter;
private VoteRecordRepository voteRecordRepository;
private PastStoreRepositoryInterface pastStoreRepository;
private final StoreConverterService storeConverterService;
public AccessMyVoteService(JWTFilter jwtFilter,
VoteRecordRepository voteRecordRepository,
PastStoreRepositoryInterface pastStoreRepository,
StoreConverterService storeConverterService) {
this.jwtFilter = jwtFilter;
this.voteRecordRepository = voteRecordRepository;
this.pastStoreRepository = pastStoreRepository;
this.storeConverterService = storeConverterService;
}
public ResponseEntity<PastStoreForVoteResponseDTO> accessMyVoteService(HttpServletRequest request, String secret) throws UnauthorizedException {
boolean validity = jwtFilter.getTokenFromHeader(secret, request).getValidity();
if (!validity) {
throw new UnauthorizedException();
}
String userId = jwtFilter.getTokenFromHeader(secret, request).getMemberInfo().getIdentifier();
List<VoteRecord> recordList = voteRecordRepository.findByUserId(userId);
System.out.println(recordList);
List<PastStoreForVote> pastStoreForVoteList = new ArrayList<>();
for (VoteRecord record : recordList) {
System.out.println(record);
System.out.println(record.getVotedId());
long votedId = record.getVotedId();
String whenForVote = record.getWhenForVote();
PastStoreForVote pastStoreForVote = pastStoreRepository.findByIdAndWhenForVote(votedId, whenForVote);
pastStoreForVoteList.add(pastStoreForVote);
}
PastStoreForVoteResponseDTO pastStoreForVoteResponseDTO = PastStoreForVoteResponseDTO.builder()
.status(200)
.message("성공")
.storeForPastVote(storeConverterService.convertPast(pastStoreForVoteList).getBody().getStores())
.build();
return ResponseEntity.ok(pastStoreForVoteResponseDTO);
}
}
내투표조회하기
JWT토큰을 필요로한다.

voteRecord라는 별도의 DB테이블에 유저아이디와 가게id, 투표한 달의 정보가 들어있어 이를 가져온다.
package com.hongchelin.service;
import com.hongchelin.Domain.PastStoreForVote;
import com.hongchelin.Domain.Store;
import com.hongchelin.Domain.StoreForSelected;
import com.hongchelin.Domain.StoreForVote;
import com.hongchelin.Repository.PastStoreRepositoryInterface;
import com.hongchelin.Repository.StoreForSelectedRepository;
import com.hongchelin.Repository.StoreForVoteRepositoryInterface;
import com.hongchelin.Repository.StoreRepositoryInterface;
import com.hongchelin.dto.user.ResponseDTO;
import jakarta.transaction.Transactional;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Service
public class ModifyDBInEndDayService {
private final StoreForSelectedRepository storeForSelectedRepository;
private final StoreForVoteRepositoryInterface storeForVoteRepository;
private final StoreRepositoryInterface storeRepository;
private final PastStoreRepositoryInterface pastStoreRepository;
Date date = new Date();
public ModifyDBInEndDayService(StoreForVoteRepositoryInterface storeForVoteRepository,
StoreRepositoryInterface storeRepository,
StoreForSelectedRepository storeForSelectedRepository,
PastStoreRepositoryInterface pastStoreRepository) {
this.storeForVoteRepository = storeForVoteRepository;
this.storeRepository = storeRepository;
this.storeForSelectedRepository = storeForSelectedRepository;
this.pastStoreRepository = pastStoreRepository;
}
@Transactional
@Scheduled(cron = "* 05 00 1 * *")
public /*ResponseEntity<ResponseDTO>*/ void modifyInEnd() {
List<StoreForVote> stores = storeForVoteRepository.findTop3ByOrderByVotedCountDesc();
List<StoreForSelected> selectedList = new ArrayList<>();
List<PastStoreForVote> unSelectedList = new ArrayList<>();
List<PastStoreForVote> selected = new ArrayList<>();
for (StoreForVote store : stores) {
long storeId = store.getStoreId();
Store individualizedStore = storeRepository.findById(storeId);
long selectedStoreId = individualizedStore.getId();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM");
String whenForSelected = formatter.format(date);
Integer votedCount = store.getVotedCount();
StoreForSelected storeForSelected = StoreForSelected.builder()
.WhenSelected(whenForSelected)
.idInDb(selectedStoreId)
.votedCount(votedCount)
.build();
selectedList.add(storeForSelected);
/* 여기서부터는 PAST_FOR_STORE 에 추가할 부분입니다.*/
PastStoreForVote pastStoreForVote = PastStoreForVote.builder()
.isSelected(true)
.whenForVote(whenForSelected)
.storeId(storeForSelected.getIdInDb())
.votedCount(storeForSelected.getVotedCount())
.build();
selected.add(pastStoreForVote);
}
for (PastStoreForVote pastStoreForVote : selected) {
long storeId = pastStoreForVote.getStoreId();
pastStoreRepository.save(pastStoreForVote);
storeForVoteRepository.deleteByStoreId(storeId);
}
Iterable<StoreForVote> storeForVotes = storeForVoteRepository.findAll();
for (StoreForVote storeForVote : storeForVotes) {
Store individualizedStore = storeRepository.findById(storeForVote.getStoreId());
long selectedStoreId = individualizedStore.getId();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM");
String whenForSelected = formatter.format(date);
Integer votedCount = storeForVote.getVotedCount();
PastStoreForVote pastStoreForVote = PastStoreForVote.builder()
.whenForVote(whenForSelected)
.storeId(selectedStoreId)
.votedCount(votedCount)
.isSelected(false)
.build();
unSelectedList.add(pastStoreForVote);
}
for (StoreForSelected store : selectedList) {
storeForSelectedRepository.save(store);
}
for (PastStoreForVote pastStoreForVote : unSelectedList) {
pastStoreRepository.save(pastStoreForVote);
storeForVoteRepository.deleteByStoreId(pastStoreForVote.getStoreId());
}
ResponseDTO responseDTO = ResponseDTO.builder()
.status(200)
.message("성공")
.build();
//return ResponseEntity.ok(responseDTO);
}
}
코드 리팩토링 좀 할 걸 그랬나
매달 1일 0시 5분에 기존 투표후보들 중에서 득표율 상위 3개의 가게만 뽑아 홍슐랭으로 선정한 뒤 선정된 가게들이 있는 db로 따로 이동시키고, 투표후보들은 선정여부 담아서 과거투표기록용 DB테이블로 이동시킨 다음 투표후보들이 있는 DB 테이블을 지움. 즉, 기존 지난달 투표를 정리하고 새로운 투표를 받기 위한 사전작업의 자동화
구현 중 오류
- 회원가입
- 로그인
- 투표
'팀 프로젝트 > [2025][GDG]홍대 맛집 아카이빙 프로젝트' 카테고리의 다른 글
| [GDG] GDG 프로젝트 트랙 4기 FIL (0) | 2025.09.02 |
|---|---|
| [GDG] GDG 프로젝트트랙 4기 백엔드 후기 (6) | 2025.08.28 |
| [GDG] 개발코스 6주차 WIL (0) | 2025.08.20 |
| [GDG] 홍대 맛집 아카이빙 프로젝트 #21 - 카카오지도 API 짧게 (2) | 2025.08.18 |
| [GDG] 홍대 맛집 아카이빙 프로젝트 #20 - mySQL과 각종 코드 보강하기 (6) | 2025.08.18 |