티스토리 뷰

반응형

로그인 황금 코드✨ 만들기

로그인에 대한 구현이 끝나간다. 이쯤에 내가 스프링부트에서 로그인을 구현하기 위해 고민했던 것들과 그것의 구현이 어떤 식으로 진행 됐는지 작성해보는 것이 좋을 것 같아 이 글을 진행해본다.
아직 초보 개발자이고 배우는 과정이라 완벽한 코드거나, 완전하지 않을 수 있다는 점을 참고해서 초보개발자가 로그인을 구현해가는 고민의 흐름(?) 정도로만 봐줬으면 좋겠다. 의견이 있으면 자유롭게 코멘트도 환영하고 있다.

이 프로젝트의 컨셉

이 프로젝트에 목적은 최대한 롬복을 이용해 상용구를 줄이고 간결한 코드를 위해 노력했다.

또한 최근에 읽었던 객체지향 관련 책에서 봤던 인상적인 글귀가 있었다.

결국에 우리가 객체지향을 사용해 설계과정을 거쳐 객체지향에 입각해 코드를 만드는 이유는 비즈니스에 따라 코드가 변해야하기 때문이고, 우리는 그 변화를 최소한으로 만들기 위해 객체지향의 원리를 이용하는 것이라는 글귀였다.

이러한 것을 생각해 확장에 열려있는 코드를 짜기 위해 노력해보았다.

사전 설정

스펙

  • spring-boot
  • maven
  • mybatis
  • lombok

참고

  • git-cz를 이용해 커밋 컨벤션을 관리했다. 살아생전, 이렇게 커밋 컨벤션을 잘 관리해본적이 없다. 이 것은 짱이다.
  • auto-save 를 이용해 린터 처리했다.

멤버

  • 멤버는 이메일 아이디 로그인으로 구현했다.
package com.giftclub.member;

import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;

import java.time.LocalDate;

@Getter
@Setter
@Builder
public class Member {

    private Long memberId;

    @NonNull
    private String memberEmail;

    @NonNull
    private String memberName;

    @NonNull
    private String memberPassword;
    private LocalDate memberBirth;
}
  • @NonNull 키워드를 이용해 validation 을 적용했다. nullcheck 를 별도로 진행하지 않아도 request 에서 Member 객체를 보낼때 @NonNull 처리가 되어있는 필드가 빈 값일 경우 에러가 발생한다.
  • 모든 필드가 getter,setter 가 필요하다고 생각했기 때문에 따로 예외처리를 하지 않았다.

멤버 콘트롤러 테스트

  • TDD에 입각해 테스트를 하지 않았지만, 리팩토링에 들어가기전 작성해 API 테스트 대용으로 활용했다. 콘트롤러를 호출해보는 것으로 API 가 정상 작동하는지 여부를 확인하는 코드를 활용해 리팩토링 검수 시간을 줄였다.
package com.giftclub.member;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.giftclub.member.request.MemberLoginRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.transaction.annotation.Transactional;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class MemberControllerTest {

    @Autowired
    protected ObjectMapper objectMapper;
    @Autowired
    private MockMvc mockMvc;

    @Test
    @Transactional
    @Rollback(true)
    void signup() throws Exception {

        Member member =
                Member.builder().memberEmail("tes2@com").memberName("w").memberPassword("df").build();

        String content = objectMapper.writeValueAsString(member);
        mockMvc
                .perform(
                        MockMvcRequestBuilders.post("/members/signup")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content))
                .andExpect(status().isOk());
    }

    @Test
    void login() throws Exception {

        MemberLoginRequest memberLoginRequest
                = MemberLoginRequest.builder().memberEmail("tes@com").memberPassword("df").build();

        String content = objectMapper.writeValueAsString(memberLoginRequest);
        mockMvc
                .perform(
                        MockMvcRequestBuilders.post("/members/login")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content))
                .andExpect(status().isOk());
    }
}


로그인 방식 선택과 구현

목차

  • 어노테이션 기반 Mybatis vs Xml 기반 Mybatis

  • 비밀번호 암호화 선택

  • 로그인 서비스 구현

  • 로그인 콘트롤러 구현


Annotation 기반 Mybatis vs Xml 기반 Mybatis

가장 처음한 고민이었다. Mybatis 도큐먼트가 설정위주의 페이지가 너무 많아 한참 해맸다. 그 결과 , Query를 표현하는 방법으로 Annotation 기반과 Xml기반이 있다는 사실을 알게 되었다.

처음에는 Annotation으로 하는 것이 쿼리가 바로 눈앞에 보여 훨씬 직관적이라고 생각을 했기 때문에 Annotation을 다는 형태로 작업을 진행했다.

하지만, 메소드가 많아지고, Query가 복잡해진다면 코드가 한 번에 눈에 들어올 수 없다는 생각 일 들었고, 이 서비스가 나중에 학습과정에서 JPA , Hibernate 등의 ORM 프레임워크로 연결할 수 있다는 생각이 들어 Xml 로 Query 를 따로 빼 관리하게 하였다.

package com.giftclub.common.config;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import javax.sql.DataSource;

@Configuration
@MapperScan(basePackages = "com.giftclub.mapper")
public class DatabaseConfig {

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dataSource);
        final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        sessionFactory.setMapperLocations(resolver.getResources("classpath:mapper/*.xml"));
        return sessionFactory.getObject();
    }

    @Bean
    public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory)
            throws Exception {
        final SqlSessionTemplate sqlSessionTemplate = new SqlSessionTemplate(sqlSessionFactory);
        return sqlSessionTemplate;
    }
}

또한, MapperScan과 XmlMapperLocation을 잘 찾게 하기 위해 Mapper 를 따로 패키지로 분리해 관리하는 형태로 작업을 진행했다.

이것을 모르고, MapperScan 을 전범위로 했다가 빈등록이 안되어 애를 먹었었다.

package com.giftclub.mapper;

import com.giftclub.member.Member;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberMapper {

    public void insertMember(Member member);

    public boolean checkEmailExists(String memberEmail);

    public Member getMemberByMemberEmail(String memberEmail);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.giftclub.mapper.MemberMapper">

    <insert id="insertMember" parameterType="com.giftclub.member.Member" useGeneratedKeys="true" keyProperty="memberId">
            INSERT INTO Member(member_email, member_name, member_password, member_birth) VALUES (#{memberEmail}, #{memberName}, #{memberPassword}, #{memberBirth}  );
 </insert>

    <select id="checkEmailExists" parameterType="com.giftclub.member.Member" resultType="boolean">
    SELECT     IF(COUNT(*) = 1, 1, 0) FROM Member WHERE member_email =#{memberEmail} ;
</select>

    <select id="getMemberByMemberEmail" parameterType="com.giftclub.member.Member"
            resultType="com.giftclub.member.Member">
    SELECT member_id, member_email, member_name, member_password, member_birth FROM Member WHERE member_email =#{memberEmail} ;
</select>

    <select id="deleteMemberByMemberEmail" parameterType="com.giftclub.member.Member">
    DELETE   FROM Member WHERE member_email =#{memberEmail} ;
</select>

</mapper>
  • 예약어는 대문자로 작성하는 것이 가독성이 좋다. (인텔리제이가 하이라이트 빵빵 해준다.)
  • * 을 사용하는 것은 실수할 여지가 있고, 보안상 취약한 것을 보여줄 확률이 있기때문에 명시적으로 필드를 기입하는 습관을 들일 필요가 있다.
  • 반환타입이 필요없을때는 안보내주는 것이 답이다.

비밀번호 암호화

스프링 시큐리티를 완벽하진 않지만, 사용해본 적이 있기 때문에 스프링 시큐리티를 도입할까 생각했지만, 직접 구현해보면서 비밀번호에 대해 이해를 하는 것이 더 좋을 것 같아 직접 암호화 알고리즘을 선택해 암호화를 하기로 했다.

암호화 알고리즘

  • 정말 많은 고민을 했다. 삼각측량법에 의거하는 것 처럼 내가 2번 암호화를 사용할까? 암호화의 항목에 대해서 암호화 알고리즘을 바꿀 수도 있을까? 라는 이런 저런 생각을했다.

  • 하지만, 좀 더 코드가 수정이 덜 되는 쪽으로, OCP 개방 폐쇄의 원칙에 따라 만들어 보고 싶었기 때문에 암호화에 대해 세분화하여

    • 암호화 인터페이스 설계
    • 그에 대한 구현체인 알고리즘

    형태로 코드를 작성했다.

  • SHA256 이라는 대표적인 단방향 암호화알고리즘을 사용했다.(구체적인 설명)

암호화 인터페이스 설계
package com.giftclub.common.security;

public interface Encoder {

    public boolean matches(String rawPassword, String encodedPassword);

    public String encode(String memberPassword);
}

⚡런타임 예외

  • 처음에 암호화하는 과정에 NoSuchAlgorithmError 에 대해 CheckedExceptionRuntimeException이 아니라는 사실을 모르고 순수하게 에러문을 출력하는 형태로 구현했었다가 코드리뷰를 받고 놀랬었다.
  • 그래서 자바 예외 처리에 대해 다시 공부해보는 시간을 가졌고, 비밀번호 알고리즘이 존재하지 않는 경우, 사용자에게 무조건 500에러를 내서 개발자와 사용자에게 알려야 된다고 생각을 했다.
  • RuntimeException 을 상속받은 커스텀 예외를 만들어 NoSuchAlgorithmError 예외 발생시, 바로 커스텀예외로 빠지게 했고, 에러명을 넣어 무슨 에러인지를 명시해줌으로써 에러가 난 지점을 알리게 해주었다.
  @Override
    public String encode(String st) {

        try {
            MessageDigest md = MessageDigest.getInstance("");
            md.update(st.getBytes());
            return bytesToHex(md.digest());
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            //   throw new EncoderNoSuchAlgorithmException("SHA256EncoderNoSuchAlgorithmException", e);
        }
        return "";
    }

이 코드는 런타임 에러가 나지 않는다. 따라서, 회원가입에 성공하지만, 실제로 디비에는 password 가 빈값으로 들어간다.

반드시 RuntimeException을 발생시켜 어디 부분에서 이상이 있는지를 알려줘야한다.

    @Override
    public String encode(String st) {

        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(st.getBytes());
            return bytesToHex(md.digest());
        } catch (NoSuchAlgorithmException e) {
            throw new EncoderNoSuchAlgorithmException("SHA256EncoderNoSuchAlgorithmException", e);
        }
    }

    @Override
    public boolean matches(String rawSt, String encodedSt) {
        return this.encode(rawSt).equals(encodedSt);
    }
}

로그인 서비스

로그인은 내가 아는 방식이 크게, 토큰 기반 로그인과 세션 기반 로그인이 있었다. 이 2 개의 TRADE OFF 에 대해 고민해보는 시간을 가졌다. 세션 로그인으로 선택했는데, 아직 확신은 안든다.

이러한 고민에서 수정이 있더라도 수정을 줄일 수 있도록 LoginService 를 인터페이스로 분리해서 구현체를 두는 형태로 구현했다.

package com.giftclub.member;

/**
 * 개방 폐쇄 원칙을 지키기 위한
 * <세션 기반, 토큰 기반> 로그인 기능을 고려한 로그인 인터페이스입니다.
 */
public interface LoginService {

    public String login(String memberEmail, String memberPassword);
}

토큰기반 로그인 VS 세션 로그인

https://hackernoon.com/auth-headers-vs-jwt-vs-sessions-how-to-choose-the-right-auth-technique-for-apis-57e15edd053

  • 정말 많은 고민을 가진 부분이고, 이 글을 블로그에 적겠다고 다짐한 날부터 계속 고민을 했다. ( 아직도 고민중이다. 물론 답이 없다는 것을 알고 있지만 tradeoff 에 대해서 아는 것은 유의미할 것이라 생각한다. )
세션 로그인
  • 장점

    • 서버에서 관리하기 때문에 보안이 유리하다.
  • 단점

    • scale out 으로 여러 서버를 사용하는 상황에서 세션 불일치 전략에 대해 고민해야한다.
    • 세션 서버를 두기로 한경우, 그것에 대한 각각의 trade off를 따져 세션 서버를 선택해야한다.
    • 모바일웹에서 로그인하는 경우, 유동 IP 에 따라 세션이 바뀌기 때문에 이동하면, 로그인이 풀릴수 있는 위험성이 있다.
토큰 기반 로그인
  • 장점
    • 세션 불일치에 문제에 자유롭다.
    • 모바일 웹에서 IP 변경 문제에 자유롭다.
  • 단점
    • 요청에 헤더가 길어진다는 것은 패킷의 크기가 커져 대역폭을 많이 사용하기 때문에 성능에 영향을 끼칠 수 있다.
    • 보안문제에 있어 세션방식에 비해 취약하다. XSS 공격 대비 등을 해줘야한다.
실제 구현
package com.giftclub.member;

import com.giftclub.common.exception.LoginFailedException;
import com.giftclub.common.security.Encoder;
import com.giftclub.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;


/**
 * 세션을 이용하는 콘트롤러. 세션을 이용할 예정인 메소드들만 모아놓음.
 */
@Service
@RequiredArgsConstructor
public class SessionLoginService implements LoginService {

    final String LOGIN_MEMBER_ID = "LOGIN_MEMBER_ID";
    private final HttpSession httpSession;
    private final MemberMapper memberMapper;
    private final Encoder encoder;

    @Override
    public String login(String memberEmail, String memberPassword) {

        Member matchMember = memberMapper.getMemberByMemberEmail(memberEmail);
        if (matchMember == null | !encoder.matches(memberPassword, matchMember.getMemberPassword())) {
            throw new LoginFailedException("사용자가 존재하지 않거나 비밀번호가 틀렸습니다.");
        }
        setLoginMemberId(matchMember.getMemberId());
        return null;  // 💜 


    }

    public void logout() {
        httpSession.removeAttribute(LOGIN_MEMBER_ID);
    }

    private Long getLoginMemberId() {

        return (Long) httpSession.getAttribute(LOGIN_MEMBER_ID);
    }

    private void setLoginMemberId(Long memberId) {

        httpSession.setAttribute(LOGIN_MEMBER_ID, memberId);
    }
}
  • return null

    1. 토큰 로그인 서비스와 세션 로그인 서비스를 추상화하는 로그인 인터페이스에서 반환값을 String 으로 지정했기 때문에 반환값이 필요했다.
    2. 처음에는 CommonResult 라는 성공 응답용 Response 객체를 따로 만들어 반환하게 구현을 했다. 이후, 코드 리뷰와 고민을 통해 HTTP 상태코드로 충분히 파악할 수 있는 결과에 대해 약속을 추가적으로 지정하는 것이 아닌가라는 고민점이 생겼기 때문에 문자열 "OK"를 반환하는 형태로 재구현했다.
    3. 문자열로 표현하는건 메소드를 호출하는 입장에서 어떤 문자열이 리턴될지 예상하기 힘들고, 잘못된 값을 리턴하더라도 컴파일 타임에 검증이 힘들기 때문에 버그 발생 가능성을 높인다고 한다. 굳이 리턴을 해주지 않아도 예외가 발생하지 않는다면 정상 처리가 되었다고 처리해줘도 될 것 같아 null을 리턴하는 형태로 구현했다.
  • 롬복 기능 최대한 활용하기

    1. @RequiredArgsConstructor 을 이용해 의존성을 주입하는 형태로 상용구 코드의 중복을 줄였다. (도큐먼트) : final이나 @NonNull인 필드 값만 파라미터로 받는 생성자 만들어준다.
  • 세션 주입받기

    1. 세션을 주입받아 사용함으로써, 자원의 효과적인 관리를 노렸다.
  • 변하지 않는 상수 final 처리

    1. LOGIN_MEMBER_ID의 경우, 값이 변하는 경운 없고, 개발자 실수로 건드릴 경우 문제가 발생하기 때문에, final 처리를 통해 변경을 막았다.

로그인 콘트롤러

package com.giftclub.member;

import com.giftclub.member.request.MemberLoginRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/members")
public class MemberController {

    private final MemberService memberService;
    private final LoginService loginService;


    @PostMapping("/login")
    public String login(@RequestBody MemberLoginRequest memberLoginRequest) {

        return loginService.login(
                memberLoginRequest.getMemberEmail(), memberLoginRequest.getMemberPassword());
    }

    @PostMapping("/signup")
    public Member signup(@RequestBody Member member) {

        memberService.validateSignUp(member);
        return memberService.signup(member);
    }


}
  • /members:: URL 원칙은 참고문헌에서 읽은 것을 지켜보고자 노력했다.
  • MemberLoginRequest 라는 아이디, 비밀번호를 담는 클래스를 따로 빼줬다. 처음에는 Map 으로 구현을 했었는데, 나중에 회원 등급이 생길 수도 있고, 그런 수정이나 명시적으로 보이는 부분에 있어서 클래스로 구현하는 것이 더 좋을 것이라는 코드리뷰와 판단이 있었기에 따로 분리해주었다.
package com.giftclub.member.request;

import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;

@Getter
@Builder
public class MemberLoginRequest {

    @NonNull
    String memberEmail;

    @NonNull
    String memberPassword;

}

후기

아직 모든 기능을 완벽하게 구현한 것이 아니라, 계속 고민에 고민을 거듭해야겠지만, 진짜 코드는 재밌는 것 같다.

아는 만큼 보인다고 했었나, 다시보고 보면 또 새로운게 나온다. 익숙함에서 벗어나 왜인지에 대해 계속 고찰하는 과정을 계속 안고가는 개발자가 되고싶다


전체코드

https://github.com/KilJaeeun/gift-club


참고문헌

https://developer.mozilla.org/ko/docs/Web/HTTP/Headers
https://hackernoon.com/auth-headers-vs-jwt-vs-sessions-how-to-choose-the-right-auth-technique-for-apis-57e15edd053
스프링 도큐먼트

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함