Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
31f28c3
refactor: Controller에서 응답 상태를 결정하도록 리팩토링
devjun10 Jan 28, 2024
c6700be
refactor: RequestLine 파싱 메서드 분리
devjun10 Jan 28, 2024
6ee28a0
refactor: LoginController를 MappingRegistry에 추가
devjun10 Jan 28, 2024
82a7e21
refactor: Checkstyle, PMD 설정 반영
devjun10 Jan 28, 2024
410f375
test: Checkstyle, PMD 설정 반영
devjun10 Jan 28, 2024
34fb99c
refactor: Checkstyle, PMD 설정 반영
devjun10 Jan 28, 2024
e871fa4
feature: 쿠키 추가 메서드 추가
devjun10 Jan 28, 2024
4964077
refactor: ModelAndView 생성자 리팩토링
devjun10 Jan 28, 2024
f903a35
refactor: Checkstyle, PMD 설정 반영
devjun10 Jan 28, 2024
4961d8a
refactor: @Slf4j 어노테이션 사용
devjun10 Jan 28, 2024
fc38099
refactor: CookieMap value 타입 변경
devjun10 Jan 28, 2024
9fec195
test: CookieMap value 타입 변경 반영
devjun10 Jan 28, 2024
77c2908
feat: 로그인 API 개발
devjun10 Jan 28, 2024
e2f2b3d
test: Checkstyle, PMD 설정 반영
devjun10 Jan 28, 2024
3474f6f
test: 리팩토링 내용 반영
devjun10 Jan 28, 2024
a354f17
refactor: 사용자 Password Get 메서드 리팩토링
devjun10 Jan 28, 2024
8680118
refactor: NIO2 비동기를 활용하도록 하기 위한 리팩토링
devjun10 Jan 28, 2024
c111de5
refactor: Application 구동 방식 리팩토링
devjun10 Jan 28, 2024
120eca8
docs: 요구사항 정리
devjun10 Jan 28, 2024
31aefb2
refactor: 세션 만료 시간 리팩토링
devjun10 Jan 28, 2024
448da6f
feat: 사용자 정보 상세보기 기능 구현
devjun10 Jan 28, 2024
437bb90
refactor: Http 클래스 파싱 리팩토링
devjun10 Jan 28, 2024
8b4d715
refactor: 정적 리소스 처리 로직 추가
devjun10 Jan 28, 2024
2aee685
refactor: DispatcherServlet 포워딩 로직 변경
devjun10 Jan 28, 2024
86366f9
feat: 사용자 정보 상세보기 View 추가
devjun10 Jan 28, 2024
476b0bb
feat: ExceptionHandler 추가
devjun10 Jan 28, 2024
7ace8d2
feat: @GetMapping 어노테이션 추가
devjun10 Jan 28, 2024
0c8e9be
test: 사용자 동시 가입 테스트 복구
devjun10 Jan 28, 2024
29d2a54
test: 로그인 통합 테스트 작성
devjun10 Jan 28, 2024
6fb162c
feat: 사용자 삭제 기능 구현
devjun10 Jan 28, 2024
888f0d8
test: 사용자 삭제 통합 테스트 작성
devjun10 Jan 28, 2024
0c4ca1a
refactor: HeaderUtils 피드백 반영
devjun10 Jan 29, 2024
5848e30
test: 사용자 로그인 통합 테스트 작성
devjun10 Jan 29, 2024
3ce9889
test: 세션 단위 테스트 작성
devjun10 Jan 29, 2024
6651a30
test: LoginUser 단위 테스트 작성
devjun10 Jan 29, 2024
47425a5
test: UserValidator 단위 테스트 작성
devjun10 Jan 29, 2024
7bf9371
test: 사용자 단위 테스트 추가
devjun10 Jan 29, 2024
8996bdf
test: HeaderUtils 단위 테스트 작성
devjun10 Jan 29, 2024
115e351
test: LoginUserFixture 추가
devjun10 Jan 29, 2024
ed2d131
refactor: Checkstyle, PMD 설정 반영
devjun10 Jan 29, 2024
4086fef
refactor: 불필요한 Validation 조건 제거
devjun10 Jan 29, 2024
499210d
refactor: 사용자 PK 검증 파라미터 이름 변경
devjun10 Jan 29, 2024
6ed6cf0
refactor: 세션 검증에서 isValid 메서드 인자 추가
devjun10 Jan 29, 2024
2ebb21f
refactor: 204 No Content 상태코드 추가
devjun10 Jan 29, 2024
ee949c3
chore: Jacoco, Sonarqube 설정 추가/적용
devjun10 Jan 29, 2024
21a2a98
test: 디미터의 법칙 피드백 반영
devjun10 Jan 29, 2024
b28f766
refactor: 사용하지 않는 리스트 제거
devjun10 Jan 29, 2024
0dae279
refactor: 정적 류 렌더링 리팩토링
devjun10 Jan 30, 2024
34fe3e1
refactor: ExceptionHandler 추가
devjun10 Jan 30, 2024
654720c
chore: 의존성 괄호 추가
devjun10 Jan 31, 2024
077e319
chore: 정적 자원 추가
devjun10 Jan 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
<br>
<br>



## 📝 공통 요구사항

1. 전체 미션은 7단계로 나뉘어져 있으며, 각 Step에는 `필수/선택 구현 사항`, `학습 목표`가 주어집니다.
Expand All @@ -36,17 +34,42 @@

네트워크로 부터 전송된 데이터를 파싱해 사용자 정보를 저장한다.

- [ ] 정적 페이지를 화면에 띄운다.
- [ ] 프론트 컨트롤러 패턴을 적용한다.
- [ ] 데이터베이스는 애플리케이션 내부 인메모리 데이터베이스를 사용한다.
- [ ] 어떤 정보를 저장할 지는 자유롭게 정의한다.
- [x] 정적 페이지를 화면에 띄운다.
- [x] 프론트 컨트롤러 패턴을 적용한다.
- [x] 데이터베이스는 애플리케이션 내부 인메모리 데이터베이스를 사용한다.
- [x] 어떤 정보를 저장할 지는 자유롭게 정의한다.

<br>
<br>

### 학습 목표
1. 네트워크로 부터 애플리케이션까지 데이터가 어떻게 전송되는지 학습한다.
2. DispatcherServlet, 프론트 컨트롤러 패턴의 개념과 동작 원리를 학습한다.

- [ ] 네트워크로 부터 애플리케이션까지 데이터가 어떻게 전송되는지 학습한다.
- [ ] DispatcherServlet, 프론트 컨트롤러 패턴의 개념과 동작 원리를 학습한다.
- [ ] DispatcherServlet의 동작 원리를 이해한다.
<br>
<br>
<br>
<br>
<br>
<br>

## Step2. 로그인 기능을 구현한다.

사용자 정보를 바탕으로 로그인 기능을 구현한다.

1. 로그인 기능을 구현한다.
- 세션을 이용해 구현한다.
- 세션은 애플리케이션 내부에 저장/관리한다.
- 세션 유지 시간을 제한 한다.
- [선택] 최근 로그인 기록과 아이피를 식별할 수 있도록 한다.

2. 개인 정보 상세 조회 기능을 개발한다.
- [선택] 이미지 업로드 기능을 구현한다.

<br>
<br>

### 학습 목표
- HTTP 특징에 대해 학습한다.
- 쿠키/세션에 대해 학습한다.
- 세션 관리 방법에 대해 학습한다.
67 changes: 67 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,70 @@
plugins {
id("org.sonarqube") version("4.2.1.3168")
id("jacoco")
}

dependencies {
implementation(project(":mvc"))
}

tasks.named("test") {
useJUnitPlatform()
finalizedBy jacocoTestReport
}

jacoco {
toolVersion = project.getProperty("jacocoVersion")
}

jacocoTestReport {
dependsOn test
reports {
xml.required = true
csv.required = false
html.required = true
xml.destination file("${buildDir}/jacoco/index.xml")
csv.destination file("${buildDir}/jacoco/index.csv")
html.destination file("${buildDir}/jacoco/index.html")
}

afterEvaluate {
classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(dir: it, excludes: [])
})
)
}
finalizedBy("jacocoTestCoverageVerification")
}

jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true
element = "CLASS"
limit {
counter = "BRANCH"
value = "COVEREDRATIO"
minimum = 0.50
}
}
}
}

sonarqube {
properties {
property("sonar.host.url", System.getenv("SONAR_QUBE_SERVER_URL"))
property("sonar.login", System.getenv("SONAR_QUBE_TOKEN"))
property("sonar.sources", "src/main/java")
property("sonar.tests", "src/test/java")
property("sonar.language", "java")
property("sonar.projectKey", System.getenv("SONAR_PROJECT_KEY"))
property("sonar.projectName", System.getenv("SONAR_PROJECT_NAME"))
property("sonar.java.source", 17)
property("sonar.sourceEncoding", "UTF-8")
property("sonar.java.binaries", "${buildDir}/classes")
property("sonar.test.inclusions", "")
property("sonar.exclusions", "")
property("sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/jacoco/index.xml")
}
}
11 changes: 8 additions & 3 deletions app/src/main/java/project/server/app/BlogApplication.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package project.server.app;

import project.server.mvc.springframework.context.Application;
import project.server.mvc.Application;
import static project.server.mvc.tomcat.PortFinder.findPort;

public class BlogApplication {

public static void main(String[] args) throws Exception {
Application.run(args);
public static void main(String[] args) {
String packages = "project";
final int port = findPort(args);
final int tomcatThreadCount = 200;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스레드 수 200개로 고정시키신 이유가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 자료를 참조해 200개로 맞추었습니다. 다만 이는 현재 내 CPU나 메모리 사용량 등 다른 추가적인 정보를 토대로 이를 선택하는게 좋을 것 같으므로 추후 문제가 있다면 수정하겠습니다.

Application application = new Application(packages, port, tomcatThreadCount);
application.start();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import project.server.mvc.servlet.http.HttpStatus;

public enum ErrorCodeAndMessages implements ErrorCodeAndMessage {
INVALID_SESSION(HttpStatus.UN_AUTHORIZED, "세션이 만료되었습니다."),
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "올바른 값을 입력해주세요."),
PAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "페이지를 찾을 수 없습니다.");
PAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "페이지를 찾을 수 없습니다."),
UN_AUTHORIZED(HttpStatus.UN_AUTHORIZED, "권한이 존재하지 않습니다.");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

새로운 예외 메시지를 생성하고 싶을 때마다 enum 값이 추가되게 되나요? 만약 국가별로 다른 예외메시지를 띄워야되는 것처럼 예외 메시지에 변경이 일어나게 된다면 어떻게 처리하게 되나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

국가별로 다른 언어를 처리해야 하면 MessageSource 사용, Enum에 필드 추가 정도가 있을 것 같습니다. 만약 다국어가 많다면, 다른 방법을 사용할 것 같은데요, 이번 미션에서는 한국어 하나만 신경 쓰면 되기 때문에 다른 선택지는 고려하지 않았습니다.


private final HttpStatus httpStatus;
private final String errorMessage;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package project.server.app.common.exception;

import static project.server.app.common.codeandmessage.failure.ErrorCodeAndMessages.INVALID_SESSION;

public class SessionExpiredException extends RuntimeException {

public SessionExpiredException() {
super(INVALID_SESSION.getErrorMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package project.server.app.common.exception;

import static project.server.app.common.codeandmessage.failure.ErrorCodeAndMessages.UN_AUTHORIZED;

public class UnAuthorizedException extends RuntimeException {

public UnAuthorizedException() {
super(UN_AUTHORIZED.getErrorMessage());
}
}
59 changes: 59 additions & 0 deletions app/src/main/java/project/server/app/common/login/LoginUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package project.server.app.common.login;

import static java.time.LocalDateTime.now;
import java.util.Objects;

public class LoginUser {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그인한 유저에 대해서 객체로 만들어두니까 책임과 역할이 분리되서 좋은 것 같습니다!

private final Long userId;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userId가 Wrapper타입이여야 했던 특별한 이유가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자 아이디가 long이면 값이 없다면 0으로 초기화 될거에요. Long이면 값이 없다면 null 값으로 들어오며, 값이 없다는 것을 명시적으로 나타낼 수 있기 때문에 사용했습니다.

private final String loginIp;
private boolean valid;

public LoginUser(
Long userId,
String loginIp
) {
this.userId = userId;
this.loginIp = loginIp;
}

public LoginUser(Session session) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

세션으로 로그인을 구현한 이유가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요구사항에 세션으로 로그인을 구현한다 는 조건이 있었습니다. 👀

this.userId = session.userId();
this.loginIp = null;
this.valid = session.isValid(now());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

username자리에 password를 넣으시는 이유가 뭐죠? 의도하신게 맞나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 수정했는데 커밋에는 이렇게 남아있네요. 제 실수고, 변경됐습니다.


public Long getUserId() {
return userId;
}

public String getLoginIp() {
return loginIp;
}

public boolean isValid() {
return valid;
}

@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object == null || getClass() != object.getClass()) {
return false;
}
LoginUser loginUser = (LoginUser) object;
return userId.equals(loginUser.userId);
}

@Override
public int hashCode() {
return Objects.hash(userId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

단순하게 userId를 리턴하는 거에 비해서 Objects.hash를 이용하는 것이 이점이 있을까요?
속도면에서도 hash collision을 줄인다는 면에서도 아무런 이점이 없다고 느껴져요.
어차피 userId는 유니크한 값이니까요. 어떻게 생각하시나요?

Copy link
Contributor Author

@devjun10 devjun10 Jan 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Objects.hash 메서드는 사실 IntelliJ가 정의해주는대로 사용하고 있었는데, 학습해보니 이는 아래 두 가지 장점이 있네요. 이를 사용한다고 해서 성능이 좋아지거나, 혹은 이미 유니크한 값을 더 좋은 방법으로 식별/판별할 것 같지는 않구요, Null-Safe, 다중 인자 지원 정도 때문에 사용하는 것 같습니다.

  1. Null-Safe
  2. 다중 인자 지원

}

@Override
public String toString() {
return String.format("userId:%s", userId);
}
}
41 changes: 41 additions & 0 deletions app/src/main/java/project/server/app/common/login/Session.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package project.server.app.common.login;

import java.time.LocalDateTime;
import java.util.Objects;

public record Session(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

세션과 쿠키의 차이점에 대해 설명해주세요!

Long userId,
String sessionId,
LocalDateTime expiredAt
) {

public boolean isValid(LocalDateTime expiredAt) {
return this.expiredAt.isAfter(expiredAt);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

세션 키가 만료되면 Map에 남아있게 되나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 구현에서는 세션이 만료되더라도 키가 Map에 남아있습니다. 간단하게 스케줄러를 사용해서 불필요한 세션을 주기적으로 제거해 주는 작업이 추가되면 좋을 것 같네요. 다음 PR 때 반영하겠습니다.

}

public String getUserIdAsString() {
return userId.toString();
}

@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object == null || getClass() != object.getClass()) {
return false;
}
Session session = (Session) object;
return sessionId.equals(session.sessionId);
}

@Override
public int hashCode() {
return Objects.hash(sessionId);
}

@Override
public String toString() {
return String.format("userId:%s, sessionId:%s, expiredAt:%s", userId, sessionId, expiredAt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package project.server.app.common.login;

import java.time.LocalDateTime;
import static java.time.LocalDateTime.now;
import java.util.Map;
import java.util.Optional;
import static java.util.UUID.randomUUID;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import project.server.app.core.web.user.application.SessionManager;
import project.server.mvc.springframework.annotation.Component;

@Slf4j
@Component
public class SessionStore implements SessionManager {

private static final int FIFTEEN_MINUTES = 15;
private static final Map<Long, Session> factory = new ConcurrentHashMap<>();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HashMap 대신 ConcurrentHashMap을 사용하신 이유가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 세션, 쿠키와 같은 값들이 가로채지지 않으면 사용자 자신만 접근하기 때문에 HashMap을 사용해도 상관없습니다. 다만 사용자가 거의 없는 애플리케이션이기 그대로 두었습니다.

Copy link

@kmularise kmularise Jan 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConcurrentHashMap을 사용하면 동기화로 인한 성능저하가 크다고 생각하시나요? ConcurrnetHashMap의 동기화방식에 대해 설명해주세요. hashCode가 같은 세션이 동시에 여러개 생성되면 HashMap에서는 어떻게 되나요?


Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

웹계층에서 사용자의 상태를 유지할 때 단점은 무엇이 있을까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자 상태를 유지하면 가장 우려되는 점은 서버의 확장입니다. 멀티 서버 환경에서, 한 서버에 사용자 정보가 담겨있을 때, 로드 밸런싱을 사용하면, 해당 사용자가 이미 로그인 했더라도 로그인 하지 않은 것처럼 동작합니다. 다만, 이번 미션에서는 이를 고려하지 않아도 되기 때문에 서버에 세션 정보를 저장했습니다.

@Override
public Session createSession(Long userId) {
String uuid = randomUUID().toString();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

randomUUID에 대해서 설명해주세요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

또한 UUID 충돌에 대해 어떻게 생각하시나요

LocalDateTime after15Minutes = now().plusMinutes(FIFTEEN_MINUTES);

Session newSession = new Session(userId, uuid, after15Minutes);
factory.put(userId, newSession);
return newSession;
}

@Override
public Optional<Session> findByUserId(Long userId) {
return Optional.ofNullable(factory.get(userId));
}
}
39 changes: 39 additions & 0 deletions app/src/main/java/project/server/app/common/utils/HeaderUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package project.server.app.common.utils;

import java.util.Map;
import project.server.mvc.servlet.http.Cookie;
import project.server.mvc.servlet.http.Cookies;

public final class HeaderUtils {

private static final String SESSION_ID = "sessionId";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 좋네요!


private HeaderUtils() {
throw new AssertionError("올바른 방식으로 생성자를 호출해주세요.");
}

public static Long getSessionId(Cookies cookies) {
Map<String, Cookie> cookiesMap = cookies.getCookiesMap();
Cookie findCookie = cookiesMap.get(SESSION_ID);
if (findCookie != null) {
return extractSessionId(findCookie);
}
return null;
}

private static Long extractSessionId(Cookie cookie) {
if (cookie.value() != null) {
String sessionId = cookie.value();
return parseLong(sessionId);
}
return null;
}

private static Long parseLong(String sessionId) {
try {
return Long.valueOf(sessionId);
} catch (NumberFormatException exception) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package project.server.app.core.domain.user;

public enum Deleted {
TRUE,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean 값이 아닌 enum을 사용하신 이유가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이는 취향인데요, 저는 조금 더 명시적인 것을 좋아합니다. Boolean 값을 사용하나 Enum 값을 사용하나 차지하는 데이터는 1~2byte라 성능상 큰 차이가 없으며, 추가로 Enum을 사용하면 특정 값이 아닐 경우, 예외를 발생시키는 것과 같은 선택지 제한을 둘 수 있습니다. 따라서 이를 사용했습니다.

Char를 사용해도 큰 상관 없구요.





아래는 이전에 학습하면서 봤던 자료들인데요, 한 번 참조해보실 것을 추천드립니다.

FALSE
}
Loading