Skip to content

Conversation

@devjun10
Copy link
Contributor

@devjun10 devjun10 commented Jan 28, 2024

📝 구현 내용

구현한 내용은 다음과 같습니다. 회원정보 상세보기는 아직 HTML 페이지가 완성되지 않아, 조금 더 다듬고 올리도록 하겠습니다.

  • 로그인 기능 구현.
  • Session 만료시간 관리.
  • 회원정보 상세보기
  • 사용자 탈퇴/삭제
  • Jacoco / Sonarqube 적용






✨ 로그인

세션은 애플리케이션 내부에 저장했으며, 이는 SessionManager를 통해 관리됩니다.

public interface SessionManager {
    Session createSession(Long userId);
}
@Component
public class SessionStore implements SessionManager {

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

    @Override
    public Session createSession(Long userId) {
        String uuid = randomUUID().toString();
        LocalDateTime after15Minutes = now().plusMinutes(FIFTEEN_MINUTES);

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






세션 만료 시간을 관리하는 요구사항이 있었기 때문에, 이는 세션 클래스 내부에 만료 시간을 두어 세션을 처리할 수 있도록 했습니다.

public class Session {

    private final Long userId;
    private final String sessionId;
    private final LocalDateTime expired;

    ......

}






🔎 Nio2EndPoint

스프링에서 사용자 요청이 서블릿 컨테이너에 도달하기 전, 다음과 같은 과정을 거칩니다. 이는 비동기로 이루어지는데, 이전에 구현한 내용은 동기적으로 모든 것을 처리했습니다.

image

  1. Acceptor
  2. Poller
  3. Executors
  4. Http11Processor
  5. CoyoteAdaptor
  6. Servlet Container






따라서 이를 개선하기 위해 다음과 같이 Channel, Selector 등 NIO의 구성요소를 이용해, 사용자 요청이 비동기적으로 처리되도록 바꾸고 있습니다. 이 부분은 어느 선까지 구현해야 할지, 아직 결정하지 못한 상태로, 미션을 진행하면서 조금씩 리팩토링할 예정입니다.

@Slf4j
public class Acceptor implements Runnable {

    ......

    @Override
    public void run() {
        while (true) {
            try {
                selector.select();
                Set<SelectionKey> selectedKeys = selector.selectedKeys();
                Iterator<SelectionKey> keys = selectedKeys.iterator();
                while (keys.hasNext()) {
                    SelectionKey key = keys.next();
                    if (key.isAcceptable()) {
                        acceptSocket(key, selector);
                    } else if (key.isReadable()) {
                        read(key);
                    } else if (key.isWritable()) {
                        write(key);
                    }
                    keys.remove();
                }
            } catch (IOException exception) {
                throw new RuntimeException(exception);
            }
        }
    }

    ......

}






쓰레드 풀을 만들어 쓰레드를 재사용 했습니다. 다만, 이 부분에서 read/write 부분의 구현이 조금 아쉬운데, read와 write가 한 메서드에서 이루어지기 때문입니다. 이 부분은 추후 시간이 된다면 수정할 예정입니다.

@Slf4j
public class Acceptor implements Runnable {

    private static final int FIXED_THREAD_COUNT = 32;
    private static final int BUFFER_CAPACITY = 1024;

    private final Selector selector;
    private final AbstractEndpoint<SocketChannel> nio2EndPoint;
    private final ExecutorService service = newFixedThreadPool(FIXED_THREAD_COUNT);

    ....

    private void read(SelectionKey key) {
        log.debug("READ");
        NioSocketWrapper socketWrapper = null;
        try {
            SocketChannel socketChannel = (SocketChannel) key.channel();
            ByteBuffer buffer = allocate(BUFFER_CAPACITY);
            socketWrapper = new NioSocketWrapper(socketChannel, buffer);
            socketWrapper.flip();

            socketChannel.register(selector, OP_WRITE);
            key.attach(socketWrapper);
        } catch (IOException exception) {
            if (socketWrapper != null) {
                HttpServletResponse response = socketWrapper.getResponse();
                response.setStatus(INTERNAL_SERVER_ERROR);
            }
        }
    }

    private void write(SelectionKey key) {
        log.debug("WRITE");
        SocketChannel socketChannel = (SocketChannel) key.channel();
        service.submit((Runnable) key.attachment());
        try {
            socketChannel.register(selector, OP_READ);
        } catch (IOException exception) {
            Object object = key.attachment();
            if (object != null) {
                HttpServletResponse response = (HttpServletResponse) object;
                response.setStatus(INTERNAL_SERVER_ERROR);
            }
        }
    }
}

- 설정 값을 컨트롤러에서 변경하고, 이후 프로세스에서는 그 상태 값을 사용하는 것에 집중할 수 있도록 하기 위해 리팩토링
- 하나의 메서드가 너무 길기 때문에 파일의 양식을 알 수 있도록 파싱하는 메서드 분리
- URI Get 메서드를 Uri로 변경
- Url Get 메서드를 Uri로 변경
- 문자열을 더하는 +를 가장 앞으로 보냄
- Header에 쿠키를 넣기 위한 메서드 추가
- 로거 어노테이션 사용
- Session, SessionManager 클래스 생성
- 로그인 시 쿠키를 내려주도록 응답 설정
- 테스트 메서드 접근 제어자 제거
- 메서드 네이밍 변경
- 값 객체가 아닌 문자열을 반환하도록 변경
- SocketChannel과 비동기를 활용하기 위해 파싱 부분 변경
- Request 생성자 변경
- Response 응답 양식 변경
- Application 생성자 조합으로 구동방식 변경
@devjun10 devjun10 added ⚙️ refactor 의사결정이 있는 코드 수정 🐝 test 테스트코드 작성 🚄 feat 기능 개발, 모듈 구현, 기능 개발을 위한 코드 추가 📝 docs 문서 작성 labels Jan 28, 2024
@devjun10 devjun10 requested a review from J-MU January 28, 2024 13:00
@devjun10 devjun10 self-assigned this Jan 28, 2024
- Session 체크
- 권한이 없을 경우 예외 처리
- 권한 관련 유틸 클래스 추가
- 쿠키가 제대로 파싱되지 않는 이슈 처리
- 응답 방식 변경
- 예외 발생 시 정적 리소스를 활용하도록 리팩토링
log.info("username: {}, password: {}", username, password);

validator.validateSignUp(username, password);
validator.validateLoginInfo(username, password);
Copy link

Choose a reason for hiding this comment

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

validateSignUp에서 validateLoginInfo로 변경하신 이유가 뭔가요?

Copy link
Contributor Author

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.

다시보니 둘 다 있네요 ㅎㅎ 이대로 두겠습니다~!

@Component
public class UserValidator {

    public void validateSignUpInfo(
        String username,
        String password
    ) {
        if (username == null || password == null) {
            throw new InvalidParameterException(username, password);
        }
        if (username.isBlank() || password.isBlank()) {
            throw new InvalidParameterException(username, password);
        }
    }

    public void validateLoginInfo(
        String username,
        String password
    ) {
        if (username == null || password == null) {
            throw new InvalidParameterException(username, password);
        }
        if (username.isBlank() || password.isBlank()) {
            throw new InvalidParameterException(username, password);
        }
    }

    public void validateSessionId(Long userId) {
        if (userId == null) {
            throw new UnAuthorizedException();
        }
    }
}

public Map<String, String> getCookiesMap() {
return cookiesMap;
}

Copy link

Choose a reason for hiding this comment

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

L43: this.cookiesMap

this키워드가 가독성에 도움을 준다고 생각하시나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

가독성은 잘 모르겠어요. 저도 이 부분이 일관성이 없을 때가 조금 있는데, 앞으로는 한 방식으로 통일하도록 해볼게요.

private final List<String> cookies;
private final Map<String, String> cookiesMap;
private final Map<String, Cookie> cookiesMap;
public static final Cookies emptyCookies = new Cookies();
Copy link

Choose a reason for hiding this comment

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

필드로 List cookies를 선언해놓으신 이유가 무엇인가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

이거 필요없는 것 같아요. 삭제하겠습니다. 👍

void cookieGetValueTest() {
Cookies cookies = new Cookies(List.of("name=value"));
assertEquals("value", cookies.getValue("name"));
assertEquals("value", cookies.getValue("name").value());
Copy link

Choose a reason for hiding this comment

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

이건 디미터의 법칙에 위반되는 구현 아닌가요?

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.

일단 맞아요. 다만 디미터의 법칙을 깬다고 문제되진 않을 것 같아 사용했는데, cookies.getValue("name")을 하면 Cookie가 나오고, 해당 쿠키의 값을 반환하해요. 즉, 연관된 클래스의 연관된 값이 나오기 때문에 상관없다고 판단했습니다. 그래도 Cookie를 선언하고, 별도로 value를 하는 게 조금 더 좋아 보이긴 하네요. 반영하겠습니다. 👍



제가 이전에 MU님 코드에 디미터 법칙을 말했던 건, getDeclaringClass 메서드는 Clazz 타입을 반환하고, newInstance 메서드는 T 타입을 반환했기 때문이었어요. newInstance가 Exception을 선언해야 하기도 하고. 즉, 서로 연관된 문맥이 다르면 디미터의 법칙을 지키는 게 좋지만, 비슷한 문맥이라면 같이 쓰더라도 상관없다고 생각합니다. 이건 개인적 판단이라 MU가 판단해 주세요.

Class<?> clazz = User.class.getDeclaringClass( );
Object object  = clazz.newInstance( );

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 class Cookies {

    ......

    private final Map<String, Cookie> cookiesMap;
    public static final Cookies emptyCookies = new Cookies();

    ......

    public String getValue(String name) {
        Cookie findCookie = this.cookiesMap.get(name);
        if(findCookie != null) {
            return findCookie.getValue( );  
        }
        return null;
    }

    ......

}

@DisplayName("사용자 이름이 Null 또는 빈 값이면 InvalidParameterException이 발생한다.")
void usernameNullOrBlank(String parameter) {
assertThatThrownBy(() -> validator.validateSignUp(parameter, "helloworld"))
assertThatThrownBy(() -> validator.validateSignUpInfo(parameter, "helloworld"))
Copy link

Choose a reason for hiding this comment

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

메서드 명을 변경하신 이유가 뭔가요?
SignUpInfo가 기존 메서드보다 특별히 하는 일을 더 잘 나타낸다고 생각하시나요?이유가 뭔가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

이 부분은 제 실수 같아요. 반영하겠습니다. 👍

assertAll(
() -> assertEquals(GET, requestLine.getHttpMethod()),
() -> assertEquals("/index.html", requestLine.getRequestUrl()),
() -> assertEquals("/index.html", requestLine.getRequestUri()),
Copy link

Choose a reason for hiding this comment

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

url -> uri로 변경하신 이유가 뭔가요?
url과 uri의 차이를 설명해주세요.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

요청 라인에서 실제 위치를 반환할 수도, 식별자가 필요할 수도 있기 때문에 URI가 더 적절하다고 판단했습니다. URL은 실제 자원이 위치하는 경로를 말하고, URI는 자원의 위치 뿐 아니라 식별자도 나타냅니다. URI가 더 넓은 개념이고요!


public class LoginUser {

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 값으로 들어오며, 값이 없다는 것을 명시적으로 나타낼 수 있기 때문에 사용했습니다.

String password
) {
return new LoginUser(1L, password, "");
}
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.

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


@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. 다중 인자 지원

Copy link

@kochungcheon kochungcheon left a comment

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 충돌에 대해 어떻게 생각하시나요


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.

오 좋네요!


@Slf4j
@Service
public class UserLoginService implements UserLoginUseCase {

Choose a reason for hiding this comment

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

현재 해당 인터페이스에 대한 구현체는 한개이네요! 구현체가 한개일 것으로 예상될 때도 인터페이스 사용하는 게 좋을 지 의견을 듣고 싶습니다!


private boolean isStaticResource(String[] startLineArray) {
return startLineArray.length >= 2;
}

Choose a reason for hiding this comment

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

private static final 선언 기준이 궁금합니다! 2의 경우 왜 사용안하셨나요!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

요청라인을 파싱할 때, >=2 조건을 상수로 뽑아낼 때, 2를 어떻게 상수로 표현할지 안떠올랐습니다. 적절한 표현이 있을까요?


public ModelMap addAttribute(
private final Map<String, Object> map = new LinkedHashMap();

Choose a reason for hiding this comment

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

LinkedHashMap을 사용하신 이유가 궁금합니다!

Copy link

@kochungcheon kochungcheon left a comment

Choose a reason for hiding this comment

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

고생하셨습니다!

@auto-prbot auto-prbot merged commit 313ad4f into dev Jan 31, 2024
@devjun10 devjun10 deleted the step2 branch February 6, 2024 17:43
) {

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 때 반영하겠습니다.

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.

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

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.

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

@devjun10 devjun10 restored the step2 branch February 19, 2024 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📝 docs 문서 작성 🚄 feat 기능 개발, 모듈 구현, 기능 개발을 위한 코드 추가 ⚙️ refactor 의사결정이 있는 코드 수정 🐝 test 테스트코드 작성

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants