-
Notifications
You must be signed in to change notification settings - Fork 1
Feat: S2 기반 위치 관리 기능 구현 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
📊 Code Coverage Report
|
Test Results182 tests 182 ✅ 6s ⏱️ Results for commit 7ee71d3. ♻️ This comment has been updated with latest results. |
|
멋진 PR 입니다! 제가 이해한 바가 맞는지 확인차 댓글 남깁니다. 요약하자면, 새로운 '띱' 요청이 들어왔을 때 그 주변에 있는 사용자들을 S2 기반으로 빠르게 찾아내는 핵심 로직을 구현해 주셨군요! 정말 고생 많으셨습니다. 이제 여기에
이 두개까지 구현되면 저희가 기획했던 실시간 위치 기반 기능의 큰 그림이 완성될 것 같습니다. 👍👍👍 |
|
|
||
| // targetCellId의 userIds만 가져오기 | ||
| List<UUID> userIds = targetCellIds.stream() | ||
| .map(locationRepository::findUserIdsByCellId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
targetCellIds 리스트에는 현재 위치 Cell과 주변 이웃 Cell을 합쳐 최대 9개의 Cell ID가 들어있는데요,
해당 지점에서 findUserIdsByCellId 메소드를 각 각 한번씩, 총 9번 호출하게 되어서 Redis 서버와 최대 9번의 개별적인 네트워크 통신을 순차적으로 수행하게 될지 모른다는 우려가 있을 수 있는 것 같습니다.
한 번 확인해 봐주실 수 있을까용?? 가능하다면, Redis 파이프라이닝으로 개선이 가능할 지 여부도 확인해주세용
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
넵 말씀하신대로 레디스 파이프라인으로 구현하였더니
cellCount=9, usersPerCell=10000
데이터에 대해
Single Calls: 564 ms
Pipeline: 197 ms
위와 같은 성능개선이 이루어졌습니다!
| } | ||
| } | ||
|
|
||
| public void saveUserLocation(UUID userId, UpdateMyLocationRequest request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
한 가지 엣지 케이스가 궁금해 질문드립니다. 동시성 문제, 경쟁 상태에 관한 질문인데요,
만약 네트워크가 불안정한 사용자가 아주 짧은 간격으로 위치 업데이트 요청을 두 번 보내서, 두 요청이 거의 동시에 서버에서 처리되는 상황을 가정해 보았습니다.
이때 두 요청을 처리하는 스레드가 모두 사용자의 이전 위치를 거의 동시에 읽어 간다면, 결과적으로 사용자가 두 개의 다른 Cell에 동시에 존재하는 데이터 불일치 상태가 발생할 가능성이 있지 않을까 싶습니다.
이렇게 되면 사용자가 이미 떠난 위치의 '띱' 알림을 받는 경험을 할 수도 있을 것 같아 의견 여쭙니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 넵 이해했습니다. 찰나의 순간에 두 요청이 섞이면 사용자가 두 위치에 모두 존재할 수도 , 두 위치에 모두 존재하지 않을 수도 있다는 말씀이시죠?
그럼 처음 위치 한번만 validate 후 위치의 삭제와 저장을 원자적으로 처리하도록 수정하겠습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lua script 방식 도입하여 유저 위치 데이터의 삭제 및 삽입 프로세스가 원자적으로 이루어지게 수정하였습니다!
GitJIHO
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다 👍
아키텍쳐 관점에서 헷갈리실 수 있는 부분이 있으셨을 것 같은데, 전반적으로 잘 적용해 주신 것 같아 좋네요!
비즈니스 로직 관점에서는 전체 서비스측면에서 제가 아직 이해가 미흡한 부분이 있어 확실하게 장담하기는 힘들어서, 추후 적용해보면서 만약 문제가 있다면 함께 잡아갈 수 있으면 좋을 것 같습니다. 현재까지는 괜찮아 보이네요!
@RequireAuth : 인증이 필요한 메서드를 위한 어노테이션(메서드단)
@Login : 인증 파라미터 (AuthUser 도메인을 반환)
위 두개의 어노테이션 서버에 적용되어있습니다 :)
| public static GetNeighborsRequest of(UUID userId, double lat, double lng) { | ||
| return GetNeighborsRequest.builder() | ||
| .userId(userId) | ||
| .lat(lat) | ||
| .lng(lng) | ||
| .build(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 요즘 개선해나가고 있는 부분인데, 레코드타입에는 보통 빌더를 쓰는게 권장되지 않는다고 하더라구요.
팩토리 생성자는 괜찮지만 빌더는 오히려 레코드의 간편성을 해치거나 불필요한 보일러플레이트 코드가 발생할 수 있다고 하네요..!
물론 제 코드에도 아직 남아있어서 참고용으로 생각해주시면 좋을 것 같아요 ㅎㅎ,,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 넵 동의합니다!
| @@ -0,0 +1,59 @@ | |||
| package com.knu.ddip.location.application.scheduler; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RedisTemplate, RedisConnection등과 같은 외부 시스템 의존성이 있기 때문에
스케쥴러의 특성상 운영계층보다는 인프라스트럭처 하위가 헥사고날 관점에서 좀 더 적절한 위치가 아닐까 싶네요
스케쥴러 자체 구현체를 변경할 필요까지는 없을 것 같아 포트와 어댑터를 구분할 필요까지는 없고 현재 코드대로 위치만 변경하면 괜찮을 것 같습니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 넵 말씀하신대로 외부 시스템 의존하는 부분이 있어 인프라쪽에 더 잘 어울릴 것 같더라구요. 그래서 스케줄 실행하는 자체는 응용 계층에 두고 인프라단에 외부 의존성 관련 코드들을 옮겨 실행하도록 리펙터링하였습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
의존성 코드만 옮기는 방식도 좋네요 👍
| public class LocationEntity implements Persistable<String> { | ||
|
|
||
| @Id | ||
| private String cellId; | ||
|
|
||
| public static LocationEntity create(String cellId) { | ||
| return LocationEntity.builder() | ||
| .cellId(cellId) | ||
| .build(); | ||
| } | ||
|
|
||
| @Override | ||
| public String getId() { | ||
| return cellId; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isNew() { | ||
| return cellId == null; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cellId에 대한 자동생성 로직을 두지 않았기 때문에 하이버네이트단의 select 쿼리발생 방지를 위해 isNew를 두신게 맞을까용
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
네네 정확합니다! 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
다시 확인해보니 벌크 삽입이 jdbc를 통해 이루어지게 구현되어 있는데 이때는 isNew 결과에 상관없이 들어간다고 하네요. 실제로 찍히는 쿼리 로그를 통해 확인해봤는데 실제로도 그렇더라구요!
jdbc 쿼리 로그 확인해보고 싶으시면
spring.datasource.url 뒤에
&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=2147483647
를 추가해주시고
logging.level.com.mysql.cj: trace
를 추가해주시면 됩니다!
| @Override | ||
| public int getBatchSize() { | ||
| return cellIds.size(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
배치 사이즈를 cellIds의 개수만큼 설정하신 것 같은데, 평균적으로 몇개의 사이즈정도 나올까요? 서비스 특성상 과도하게 많아지지는 않을지 고민이네요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재는 셀들이 경북대로 제한되어 있어 정확하게 330개가 나옵니다. 현재는 괜찮지만 추후 서비스 확장시에는 배치사이즈에 대한 고민이 필요할 듯 하네요.
| jdbcTemplate.batchUpdate(sql, | ||
| new BatchPreparedStatementSetter() { | ||
| @Override | ||
| public void setValues(PreparedStatement ps, int i) throws SQLException { | ||
| String cellId = cellIds.get(i); | ||
| ps.setString(1, cellId); | ||
| } | ||
|
|
||
| @Override | ||
| public int getBatchSize() { | ||
| return cellIds.size(); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
saveAll에 대한 배치처리는 넘 좋습니다 👍
| @Operation(summary = "위치 갱신", | ||
| description = "위도와 경도로 현재 내 위치를 갱신한다.") | ||
| ResponseEntity<Void> updateMyLocation( | ||
| // @Login AuthenticateUser user, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RequireAuth 와 @Login 어노테이션 이미 존재합니다 :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 그렇군요. 해당 어노테이션 사용해서 수정 후 커밋하겠습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
넵 다만, 아직 전체 인증 로직에 대한 몇개의 부족한 엔드포인트가 있고 우선순위가 뒤로 밀려있어서 실제 테스트를 위해서는 이 점 고려하여 적용해주시면 감사드리겠습니다!
| # MySQL | ||
| spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true | ||
| spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true | ||
| spring.datasource.username=${MYSQL_USER} | ||
| spring.datasource.password=${MYSQL_PASSWORD} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MySQL이라서 SQL를 전송하는 네트워크단에서 배치 적용이 되는군요,,! PostgreSQL에서는 하이버네이트단의 배치처리까지만 가능해서 신선하네요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 넵! 저 옵션을 켜줘야 JDBC를 통한 벌크 삽입이 작동한다고 하더라구요
| private final JdbcTemplate jdbcTemplate; | ||
|
|
||
| private final RedisTemplate<String, String> redisTemplate; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재는 jdbc와 redis에 대한 의존성이 동시에 존재하는데, 나중에 리팩토링 과정에서 이 둘을 분리하여 적용한다면 좀 더 유연한 구조가 되지 않을까 싶네요 :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
그렇네요. 레디스와 JPA와 JDBC에 대한 의존을 서로 나누는 것이 좋겠네요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
일단 역할 기반으로 조회와 쓰기로 분리해보았습니다!
수정 사항
해당 부분 수정하였습니다! |
GitJIHO
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
전반적으로 효율성과 성능 측면에서 많은 개선이 있는 것 같아 좋네요! 👍
궁금한 부분에 대한 코멘트 몇개 남겨두었습니다.
로직적으로는 큰 문제 없어보여서 미리 approve 하겠습니다!
| @@ -0,0 +1,59 @@ | |||
| package com.knu.ddip.location.application.scheduler; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
의존성 코드만 옮기는 방식도 좋네요 👍
| @PostConstruct | ||
| public void init() { | ||
| saveUserLocationScript = new DefaultRedisScript<>(); | ||
| saveUserLocationScript.setResultType(String.class); | ||
| saveUserLocationScript.setScriptSource( | ||
| new ResourceScriptSource(new ClassPathResource("luascript/save_user_location.lua")) | ||
| ); | ||
| } | ||
|
|
||
| @Override | ||
| public void deleteAll() { | ||
| locationJpaRepository.deleteAllInBatch(); | ||
| } | ||
|
|
||
| @Override | ||
| public void saveAll(List<String> cellIds) { | ||
| String sql = """ | ||
| INSERT INTO locations (cell_id) VALUES (?) | ||
| """; | ||
|
|
||
| jdbcTemplate.batchUpdate(sql, | ||
| new BatchPreparedStatementSetter() { | ||
| @Override | ||
| public void setValues(PreparedStatement ps, int i) throws SQLException { | ||
| String cellId = cellIds.get(i); | ||
| ps.setString(1, cellId); | ||
| } | ||
|
|
||
| @Override | ||
| public int getBatchSize() { | ||
| return cellIds.size(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| @Override | ||
| public void saveUserIdByCellIdAtomic(String newCellId, boolean cellIdNotInTargetArea, String encodedUserId) { | ||
| String userIdKey = createUserIdKey(encodedUserId); | ||
| String cellIdUsersKey = createCellIdUsersKey(newCellId); | ||
| String cellIdExpiriesKey = createCellIdExpiriesKey(newCellId); | ||
|
|
||
| long now = System.currentTimeMillis(); | ||
| long expireAt = now + TTL_SECONDS * 1000L; | ||
| int cellIdNotInTargetAreaFlag = cellIdNotInTargetArea ? 1 : 0; | ||
|
|
||
| redisTemplate.execute( | ||
| saveUserLocationScript, | ||
| Arrays.asList(userIdKey, cellIdExpiriesKey, cellIdUsersKey), | ||
| newCellId, encodedUserId, String.valueOf(TTL_SECONDS), String.valueOf(expireAt), String.valueOf(cellIdNotInTargetAreaFlag) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오,, 루나스크립트로 원자적으로 실행하도록 할 수 있군요
하나 배워갑니다 💯 👍
| @Override | ||
| public List<String> findUserIdsByCellIds(List<String> targetCellIds) { | ||
| RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory(); | ||
|
|
||
| if (connectionFactory == null) return null; // throw xxx | ||
|
|
||
| try (RedisConnection conn = connectionFactory.getConnection()) { | ||
| conn.openPipeline(); | ||
|
|
||
| List<byte[]> keys = targetCellIds.stream() | ||
| .map(LocationKeyFactory::createCellIdUsersKey) // "cell:{id}:users" 형태 | ||
| .map(k -> k.getBytes(StandardCharsets.UTF_8)) | ||
| .collect(Collectors.toList()); | ||
|
|
||
| for (byte[] key : keys) { | ||
| conn.sMembers(key); | ||
| } | ||
|
|
||
| List<Object> rawResults = conn.closePipeline(); | ||
|
|
||
| return rawResults.stream() | ||
| .map(result -> (Set<byte[]>) result) | ||
| .filter(Objects::nonNull) | ||
| .flatMap(set -> set.stream() | ||
| .map(bytes -> new String(bytes, StandardCharsets.UTF_8)) | ||
| ) | ||
| .collect(Collectors.toList()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋네요 👍 추후에 유저가 많아질 경우에 블로킹에 대비한 처리를 도입하거나 redis자체를 클러스터형식으로 변경하여 pipeline 도입으로 인한 오버헤드를 관리할 수 있을 것 같네용
| @PutMapping | ||
| @Operation(summary = "위치 갱신", | ||
| description = "위도와 경도로 현재 내 위치를 갱신한다.") | ||
| ResponseEntity<Void> updateMyLocation( | ||
| @Login AuthUser user, | ||
| UpdateMyLocationRequest request | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RequireAuth를 메서드 혹은 클래스단에 붙여야 인터셉터에서 정상 처리를 하여 @Login어노테이션 사용이 가능해집니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 그렇군요 수정하겠습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RestController
@RequiredArgsConstructor
public class LocationController implements LocationApi {
private final LocationService locationService;
@Override
@RequireAuth
public ResponseEntity<Void> updateMyLocation(AuthUser user, UpdateMyLocationRequest request) {
locationService.saveUserLocationAtomic(user.getId(), request);
return ResponseEntity.ok().build();
}
}@GitJIHO 이렇게 붙혀주면 될까요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
네 맞습니다 !
| @Component | ||
| @RequiredArgsConstructor | ||
| public class GeoJsonInitializer implements ApplicationRunner { | ||
|
|
||
| private final LocationService locationService; | ||
|
|
||
| @Override | ||
| public void run(ApplicationArguments args) { | ||
| locationService.loadAndSaveGeoJsonFeatures(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 db 세팅 로직을 애플리케이션 재시작시마다 실행되도록 하신 이유가 있나요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
타겟 영역이 추후 변경되거나 확장될 수도 있고 이에 대해 모두 삭제했다가 어플리케이션 시작시 다시 삽입하는 방식이 가장 깔끔하다고 생각했습니다. 현재 데이터 크기에 대해서는 괜찮은 것 같은데 추후 타겟 영역이 너무 커지게 되면 다른 방식을 생각해보아야 할 것 같긴 하네요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 의도는 이해했습니다
다만 다중 인스턴스 환경으로 변경할 경우 CD 과정 중 하나의 인스턴스가 업데이트될 경우에도 인스턴스들이 공통으로 사용하는 DB의 영역 데이터들이 잠시 비게되어 오류가 나지 않을까 걱정이네요
실제로 aws ECS를 도입할 의향이 있어 말씀드립니다 !
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 그렇군요. 일단 레디스 분산락이나 shed lock 기반으로 해결해보겠습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
일단 schedule은 저번에 사용한 shedlock 기반 방식으로 해결하였고, ApplicationRunner 의 초기화는 redis 분산락 기반으로 해결하였습니다.
현재 분산락 관련 로직을 다 짤지 고민했다가 한번만 실행되는 로직은 따로 구현하는게 낫다고 생각하여
인프라단에
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisOneTimeRunner implements OneTimeRunner {
private final RedissonClient redisson;
@Override
public void runOnce(String lockName, Runnable task) {
RLock lock = redisson.getLock(lockName);
boolean acquired = false;
try {
acquired = lock.tryLock(0, 30, TimeUnit.MINUTES);
if (!acquired) {
return;
}
task.run();
} catch (InterruptedException e) {
log.error("Interrupted while waiting for lock {}", lockName, e);
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}그리고 응용 단에는
public interface OneTimeRunner {
void runOnce(String lockName, Runnable task);
}
@Component
@RequiredArgsConstructor
public class GeoJsonInitializer implements ApplicationRunner {
public static final String GEOJSON_INIT_LOCK_KEY = "lock:geojson:init";
private final OneTimeRunner oneTimeRunner;
private final LocationService locationService;
@Override
public void run(ApplicationArguments args) {
oneTimeRunner.runOnce(
GEOJSON_INIT_LOCK_KEY,
() -> locationService.loadAndSaveGeoJsonFeatures()
);
}
}이런식으로 적용해보았습니다
| String cellIdExpiriesKey = createCellIdExpiriesKey(newCellId); | ||
|
|
||
| long now = System.currentTimeMillis(); | ||
| long expireAt = now + TTL_SECONDS * 1000L; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
비즈니스적으로 궁금한 부분입니다만,
타임아웃을 해당 시간으로 둔 이유가 뭔지 알 수 있을까요?
백그라운드에서 앱을 실행하며 위치를 갱신하는 엔드포인트를 지속하여 호출하지 않을 것이라 생각해
타임아웃 시간이 지나면 유저가 그 시간동안 앱을 접속하지 않은 것이므로 현재 유저 위치가 정확하지 않을 것이라 가정해 두신 것인가요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
넵 맞습니다. 일단은 사용자의 위치에 시간 제한을 두기 위한 것이긴 한데 경우에 따라 더 줄이는 것이 정확성에 더 도움이 될 것 같긴 하네요
#️⃣ 연관된 이슈
📚 배경
📝 작업 내용
초기 경북대학교 영역 데이터 저장
유저 위치 저장
띱 요청 생성 시 이웃 userIds 조회
초기 화면에서 인접한 요청 가져오기
유저 ID 용량을 줄이기 위한 인코딩 도입
📸 스크린샷
x
💬 리뷰 요구사항
아직 아키텍처가 익숙하지 않아 아키텍처 쪽으로 많은 조언 부탁드립니다
제가 생각한 유저 위치 삭제 / 삽입 프로세스에 엣지 케이스나 궁금하신 부분이 있다면 편하게 말씀해주세요
그리고 나중에 로그인 한 유저를 알 수 있는 @Login 과 같은 커스텀 어노테이션 만들어주시면 감사하겠습니다
리뷰 감사합니다.
✏ Git Close
close #21