앱의 메인 기능 개발에 앞서, 앱을 사용하기 위해서는 로그인이 필수이다.(일단 처음 기획이 그랬었기 때문에..) 따라서 앱 시작 후 가장 먼저 해야 할 일이 회원가입 그리고 로그인을 할 수 있도록 해야 한다.
일단 이를 위해서는 백앤드 작업이 필요한데..... 기존 작업돼 있던 코드를 보니 Python으로 개발이 되어있었다.
왠지 모르게 파이썬은 어렵게 느껴져 시도하기가 싫었고, node.js도 할 수는 있겠으나... 그래도 이왕 해보는 거 Kotlin 그리고 Spring Boot를 이용해 REST API를 구현해보고 싶었다.
개발환경.
언어: Java, Kotlin 둘 중에 뭐라도 상관없었음.
Spring Boot Version: 3.0.0 - 그래도 버전이 높은 게 좋지 않을까 하는 기대감
빌드관리도구: Gradle(Groovy, Kotlin), Maven - 둘 다 뭔지 몰랐음.
IDE: Intellij IDEA
JDK 버전: 20 - 참고한 책에서 예제를 20으로 쓰고 있었음.
DB: MySQL 8.0.33 - 맥에 설치된 버전
으로 가고 싶었으나....
최종적인 개발 환경은 아래와 같다.
언어: Java
Spring Boot Version: 2.7.2
빌드관리도구: Gradle(Groovy)
IDE: Intellij IDEA
JDK 버전: 17
DB: MySQL 8.0.3
JDK 버전을 골라봅시다.
iOS를 개발할 때와 마찬가지로 항상 최신(거의?) IDE의 최신 SDK를 활용해서 개발해야 한다고 생각했으나... 이쪽 세계는 무언가 다른 것 같았다.
같은 팀 안드로이드 개발자(P)께 듣기로 안드로이드에서는 JDK8, Gradle에서는 다른 버전을 사용하고 있다고 들은 것 같기도 하고...(11이랬나..)
주변의 Java를 사용하시는 개발자분들께 여쭤보면 대부분 8, 11 그리고 조금 드물게 17 버전을 사용하고 게신다고 한다. 그 이유가 궁금해서 찾아보니.. LTS라는 단어가 자주 등장한다.
LTS는 Long Term Support의 약자로 수년간 업데이트를 보장하고 안정성에 중점을 두고 개발된 버전이라고 한다. 그래서 LTS 중 상위 버전인 17 버전을 선택하게 되었다.
환경변수 설정
내 PC(Mac) 기준이긴 하지만 jdk가 설치(위치?)되어있는 디렉터리는 /Library/Java/JavaVirtualMachines/이었다.
우연인지 운명인지 모르겠지만 JDK 17 버전이 이미 설치가 되어있었다.
JDK를 사용하기 위해서는 JAVA_HOME을 환경변수로 지정해주어야 한다고 하는데...
export JAVA_HOME=/Library/Java/JavaVirtualMachines/amazon-corretto-17.jdk/Contents/Home
이렇게 하면 된다고 한다. 그리고 PATH에 bin디렉터리를 추가해주어야 한다고 한다.
블로그 등에 있는 코드를 그대로 복붙 하기가 무서웠던 게, 윈도 사용 시절 Java 설치 시 환경변수를 복붙 했다가 부팅이 안 되는 불상사가.. 생겼던 기억이 있어서 조금 더 조심스럽게 수정했다.
$ echo $PATH
# /opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin
먼저 PATH를 출력해서 나온 경로들을 복사한 후
$ export PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:$JAVA_HOME/bin
맨 끝에 콜론(:)을 쓰고 추가해 주었다. 근데 터미널을 다시 켜보니... 사라져 버렸다.
다른 방법을 찾아보니 /etc/paths에 직접 추가해 주면 된다고 하여 추가해 주니 새로 터미널을 켜보니 잘 저장되어있는 것을 확인할 수 있었다. (근데 사실 IDE에서 알아서 해줄 것 같다..)
Java vs Kotlin && SpringBoot 3.0.0 vs 2.7.2
1. API 사용 문제: 카장 큰 문제는 주로 블로그 등에 잘 설명되어 있는 예제 코드들이 3.0.0에서는 Deprecated 된 것들이 많았다.(들어보니 Deprecated 되더라도 기능을 막지는 않는다고 한다.)
2. ChatGPT 도와줘!: 여기에서도 주로 버전 2를 기준으로 많이 설명해주고 있다.
3. 코드 예시: Kotlin 예제보다 Java예제가 훨씬 많이 나온다. 코틀린으로 검색해도 대부분의 검색 결과가 Java로 되어있는 예시들을 보여준다.
이러한 이유로... Java -> Kotlin -> Java로 언어를 바꿔가면서 삽질 진행 끝에 결국 Java를 선택하게 되었다.
빌드 관리 도구 Gradle vs Maven
Maven의 기능을 Gradle이 포함하고 있다고 하고 주로 Gradle을 많이 사용한다고 해서 Gradle을 선택했다.
Java, Kotlin 중 Java를 고른 이유는 Gradle 문법에서는 큰 차이가 없어 보였기 때문이기도 하고, 프로젝트에서 사용하는 언어를 Java로 선택했기 때문에 Java를 그대로 사용하기로 했다.
유저 데이터
Repository에서 MySQL데이터를 다루기 위해 JDBC 및 JPA를 사용하도록 했다.
1. 스키마
- id: Primary Key로 auto_increment로 둠
- password : Bcrypt hash 길이에 따라 char 60
- role: 유저와 관리자로 나눔.
2. 엔티티
@Entity
@Table(name = "Users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private RoleType role;
@Column(nullable = false, updatable = false)
@Temporal(TemporalType.TIMESTAMP)
private Date registeredDate = new Date();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public RoleType getRole() {
return role;
}
public void setRole(RoleType role) {
this.role = role;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return role.getAuthorities().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
인증 방식
가장 애를 먹었던 부분이 SecurityConfiguration이었다. 유저 로그인 및 유지, API호출 권한 등을 관리하기 위해 JWT(json web token)을 사용하고자 하였으나, Gradle에서 'org.springframework.boot:spring-boot-starter-security'를 추가만 했을 뿐인데.. 모든 response가 상태코드 401로 내려왔다.
인증된 유저에 한해서만 리퀘스트를 허용하도록 되어있고, 이를 설정하기 위해서는 아래와 같이 보안 설정을 해줘야 했다.
1. WebSecurityConfigurerAdapter를 상속하는 클래스를 정의해서 HttpSecurity를 구성하는 코드를 작성한다.
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private MyUserDetailsService userDetailsService;
private JwtRequestFilter jwtRequestFilter;
public SecurityConfig(MyUserDetailsService userDetailsService, JwtRequestFilter jwtRequestFilter) {
this.userDetailsService = userDetailsService;
this.jwtRequestFilter = jwtRequestFilter;
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests().antMatchers("/api/auth/signUp", "/api/auth/signIn", "/api/auth/get").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
}
2. OncePerRequestFilter를 상속하는 JwtFilter를 작성하여 위의 구성에 필터로 추가해주어야 한다.
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private MyUserDetailsService userDetailsService;
@Value("${jwt.secret}")
private String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
username = extractUsername(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
filterChain.doFilter(request, response);
}
private String extractUsername(String token) {
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
return claims.getSubject();
}
private Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
private Boolean isTokenExpired(String token) {
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
return claims.getExpiration().before(new Date());
}
}
3. @RestController를 작성하여 사용자의 요청 메서드(@Get, @Post, @Put, @Update, @Delete 등) + (Mapping) 어노테이션을 통해 각각의 요청을 알맞은 Controller, Service 등을 통해 처리한다.
@RestController
public class UserController {
private UserRepository userRepository;
private AuthenticationManager authenticationManager;
private JwtUtil jwtUtil;
private MyUserDetailsService userDetailsService;
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
public UserController(UserRepository userRepository, @Lazy AuthenticationManager authenticationManager, JwtUtil jwtUtil, MyUserDetailsService myUserDetailsService) {
this.userRepository = userRepository;
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
this.userDetailsService = myUserDetailsService;
}
@PostMapping("/api/auth/signUp")
public ResponseEntity<?> signUp(@Validated @RequestBody User user) {
if (userRepository.existsByEmail(user.getEmail())) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
user.setPassword(passwordEncoder().encode(user.getPassword()));
userRepository.save(user);
return ResponseEntity.ok("User registered successfully");
}
@PostMapping("/api/auth/signIn")
public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthRequest request) throws Exception {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
);
} catch (BadCredentialsException e) {
throw new Exception("Incorrect username or password", e);
}
final UserDetails userDetails = userDetailsService
.loadUserByUsername(request.getEmail());
final String token = jwtUtil.generateToken(userDetails);
final String refreshToken = jwtUtil.generateRefreshToken(userDetails);
return ResponseEntity.ok(new AuthResponse(token, refreshToken));
}
}
회원가입(iOS)
1. 유효성 검사
사용자가 입력한 이메일, 비밀번호, 비밀번호 확인, 닉네임의 입력 상태에 따라 회원가입 버튼이 활성화될 수 있도록 모델을 작성하였다.
struct RegistrationModel {
var email: String = ""
var password: String = ""
var passwordConfirm: String = ""
var nickname: String = ""
var isValidEmail: Bool {
let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let predicate = NSPredicate(format:"SELF MATCHES %@", regex)
return predicate.evaluate(with: email)
}
var isValidPassword: Bool {
return password == passwordConfirm
&& password.count >= 8
&& password.count <= 20
}
var isValidNickname: Bool {
!nickname.isEmpty
}
var isValidForm: Bool {
return isValidEmail
&& isValidPassword
&& isValidNickname
}
}
Interactor에서는 사용자가 입력 한 값이 바뀔 때마다 모델 변경 후 폼이 유효한지에 대한 결과를 통해 버튼을 업데이트하도록 했다.
func action(_ action: RegistrationPresentableListenerAction) {
switch action {
case let .emailChanged(email):
model.email = email
presenter.action(.activateButton(model.isValidForm))
case let .passwordChanged(password):
model.password = password
presenter.action(.activateButton(model.isValidForm))
case let .passwordConfirmChanged(passwordConfirm):
model.passwordConfirm = passwordConfirm
presenter.action(.activateButton(model.isValidForm))
case let .nicknameChanged(nickname):
model.nickname = nickname
presenter.action(.activateButton(model.isValidForm))
case .registrationButtonTapped:
guard model.isValidForm else { return }
service.requestRegistration(model) { [weak presenter] result in
switch result {
case .success:
DispatchQueue.main.async {
presenter?.action(.showSuccessPopup)
}
case let .failure(error):
DispatchQueue.main.async {
presenter?.action(.showErrorPopup(error.localizedDescription))
}
}
}
case .alertOkButtonTapped:
listener?.request(.registrationSucceed)
}
}
또한 회원가입 버튼이 눌렸을 경우 성공 여부에 따라 alert를 출력해 주고 확인 버튼이 눌리면 로그인 화면으로 이동되도록 했다.
import Foundation
import Alamofire
protocol RegistrationServiceable {
func requestRegistration(_ model: RegistrationModel, completion: @escaping (Result<Void, Error>) -> Void)
}
struct RegistrationService: RegistrationServiceable {
func requestRegistration(_ model: RegistrationModel, completion: @escaping (Result<Void, Error>) -> Void){
let parameters: [String: Any] = [
"email": model.email,
"password": model.password,
"nickname": model.nickname,
"role": "ROLE_USER"
]
AF.request("http://127.0.0.1:8081/api/auth/signUp", method: .post, parameters: parameters, encoding: JSONEncoding.default)
.response { response in
let statusCode = response.response?.statusCode ?? 0
guard statusCode != 409 else {
completion(.failure(CustomError(message: "이미 존재하는 이메일입니다.")))
return
}
if (200..<300).contains(statusCode) {
completion(.success(()))
} else {
completion(.failure(CustomError(message: "알 수 없는 오류")))
}
}
}
}
사용자가 입력한 정보로 파라미터를 구성하여 Request를 날린 후 409(Conflict)를 아이디 중복으로 처리하여 화면에 표시하도록 하였다. 그 외의 응답에서는 정상 응답으로 간주(나중에 에러 처리는 수정할 예정)하여 회원가입이 완료되도록 했다.
회원가입 완료 후 DB에 입력한 내용이 잘 등록된 것을 확인해 볼 수 있었다.
생각보다 간단할 줄 알았는데, 간단한 API 구현도 이만큼 힘들지 몰랐다....(백앤드 분들 대단하십니다.) 다음 글에서는 로그인 기능 및 프로필 조회 기능을 구현하면서 생기는 일들에 대해 정리해보고자 한다. 특히 iOS 개발과 SpringBoot 프레임워크에서 의존성 주입 방식이 많이 다른 것 같아 놀랐다. 이 부분에 대해서도 정리해보고 싶다. (@Bean... 같은 거?)
References
magazine/post/java-long-term-support-lts
https://www.oracle.com/java/technologies/java-se-support-roadmap.html
'iOS' 카테고리의 다른 글
(iOS) Link fast: Improve build and launch times - WWDC22 앞부분 정리 (0) | 2023.08.06 |
---|---|
(iOS) Swift Macros 찍먹해보기 (0) | 2023.07.03 |
(iOS) 유니플로거 리팩토링(2) 튜토리얼 (0) | 2023.06.07 |
(iOS) 유니플로거 리팩토링(1) XCFramework (4) | 2023.05.07 |
(iOS) Library vs Framework(3) (0) | 2023.04.06 |