imhamburger 님의 블로그
데이터엔지니어 부트캠프 - 네번째 팀프로젝트 (10/24 ~ 10/25) (17주차) 본문
파이널 프로젝트 시작 전에 2개의 프로젝트가 남아있는데 그 중 하나는 자바를 이용한 프로젝트였다.
프로젝트 기간이 짧아 어떻게 할까 고민중에 로그인 기능을 구현하자고 의견을 내었다.
2일이라는 시간 안에 막 엄청난 걸 만들 수는 없을 것 같고...
기능 하나를 제대로 만들어보자. 그리고 파이널 프로젝트 때도 필요한 기술들을 다뤄보자! 해서 결정하게 되었다.
팀프로젝트 주제
- 로그인 기능을 위한 REST API를 Java로 구현
- 메인 로그인 페이지는 Streamlit으로 제작
- 카카오 로그인 연동을 지원
- 로그인 회 회원가입, 회원탈퇴, 회원정보변경 기능 구현
나의 역할
- REST API를 Java로 구현
- 로그인 화면 구현
- 회원가입 화면 구현
- 데이터베이스 설계 및 구축
우리가 사용한 기술
1. Java 17버전
2. Python 3.11버전
3. Docker
4. Spring Boot
5. MariaDB
6. Streamlit
기능 흐름도
1. 사용자가 Streamlit을 통해 정보를 입력하거나 로그인을 시도
2. Streamlit은 입력된 데이터를 Java API에 전달
3. Java API는 데이터를 처리한 후 MariaDB에 저장하거나 데이터를 호출
4. 사용자가 카카오 로그인을 요청하면, Streamlit이 Kakao API를 호출하여 카카오 로그인을 처리
5. 이메일 발송 요청 시, Streamlit이 Google SMTP를 호출하여 이메일을 전송
- 비밀번호 찾기 시, 임시 비밀번호를 발급받는데 이 때 Google SMTP 를 이용하였다.
임시 비밀번호 발급 안내 예시
로그인 기능을 구현하는 것을 처음해보았는데 간단할 줄 알았지만 생각보다 까다로웠다. (매우 까다로움)
우선, 로그인 하고나서와 로그아웃 하는 것이 세션을 초기화 시키고, 뒤로가기할 때 로그인이 되어 있으면 안되는 이런 것들을 해결하는 과정이 어려웠다. 우리는 session 값으로 그냥 초기화하는 방법을 이용했는데 더 나은 방법이 있을 것 같다.
데이터베이스 설계는 table이 우선 한 개라 까다롭지는 않았지만 자바로 API를 개발할 때,
어떻게 값을 던질 것인지? 에 대해 더 깊이 고민해보았다면, API 코드를 중간에 수정하지 않아도 되었을텐데...
아래는 중간에 수정을 한 Controller 코드이다.
Controller.java
@RestController
@RequestMapping("/login")
public class ApiController {
@Autowired
LoginService loginService;
// 모든 로그인 정보 조회
@GetMapping
public List<LoginEntity> list() {
System.out.println("[Controller] get all logins");
return loginService.getLogin();
}
// 특정 ID의 로그인 정보 조회 (RequestParam 사용)
@GetMapping("/find")
public LoginEntity find(@RequestParam(required = false) String id,
@RequestParam(required = false) String phonenumber,
@RequestParam(required = false) String passwd,
@RequestParam(required = false) String email) {
if (id != null && phonenumber == null && passwd == null && email == null) {
return loginService.findById(id);
}
return loginService.findByOptionalParams(id, phonenumber, passwd, email);
}
// C - INSERT
@PostMapping
public void createLogin(@RequestBody LoginEntity loginEntity) {
System.out.println("[Controller] Create login: " + loginEntity);
loginService.createLogin(loginEntity);
System.out.println("INSERT SUCCESSFUL");
}
// U - UPDATE
@PatchMapping("/{id}")
public void updateLogin(@PathVariable String id, @RequestBody LoginEntity loginEntity) {
System.out.println("[Controller] Update login with id: " + id);
loginEntity.setId(id);
loginService.updateLoginById(id, loginEntity);
System.out.println("UPDATE SUCCESSFUL");
}
// D - DELETE
@DeleteMapping("/{id}")
public void deleteLogin(@PathVariable String id) {
System.out.println("[Controller] Delete login with id: " + id);
loginService.deleteLoginById(id);
System.out.println("DELETE SUCCESSFUL");
}
}
위에 GetMapping 을 사용한 로그인 정보 조회 부분에서 처음에는 id 값만 찾을 수 있게 하였었다.
하지만 비밀번호 찾기 기능을 넣을 때 "이메일"로 임시 비밀번호를 발급하는 기능이 있는데, 넣기 위해서는 여러 파라미터값이 있는 것이 좋겠다고 판단하였다. 따라서 중간에 코드를 수정해주었다.
나의 에러 리포트
에러 1) Caused by: org.xml.sax.SAXParseException
Caused by: org.apache.ibatis.builder.BuilderException: Error creating document instance. Cause: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 79; "id" 속성이 필요하며 요소 유형 "select"에 대해 지정되어야 합니다.
at org.apache.ibatis.parsing.XPathParser.createDocument(XPathParser.java:262) ~[mybatis-3.5.14.jar:3.5.14]
at org.apache.ibatis.parsing.XPathParser.<init>(XPathParser.java:127) ~[mybatis-3.5.14.jar:3.5.14]
at org.apache.ibatis.builder.xml.XMLMapperBuilder.<init>(XMLMapperBuilder.java:85) ~[mybatis-3.5.14.jar:3.5.14]
at org.mybatis.spring.SqlSessionFactoryBean.buildSqlSessionFactory(SqlSessionFactoryBean.java:697) ~[mybatis-spring-3.0.3.jar:3.0.3]
... 85 common frames omitted
Caused by: org.xml.sax.SAXParseException: "id" 속성이 필요하며 요소 유형 "select"에 대해 지정되어야 합니다.
발생이유
해당 에러는 JPA에서 xml 파일을 작성할 때 <select id="" ~~ 로 명시하지 않고 처음에 "id"가 PK값인 줄 알고 PK로 설정한 num을 넣었었다. 그런데 parsing 오류가 난 것이다.
해결방법
<mapper namespace="com.login.login.mapper.LoginMapper">
<select id="findAll" resultType="com.login.login.entity.LoginEntity">
SELECT
NUM, FIRSTNAME, LASTNAME, ID, PASSWD, EMAIL, GENDER, BIRTHDAY, PHONENUMBER
FROM LOGIN_TB
ORDER BY num DESC
</select>
xml을 읽을 때 "id"를 찾아 parsing하므로 "id"로 명시해주었더니 해결할 수 있었다.
에러 2) [Streamlit] To fix this error, please pass a unique key argument to st.text_input.
File "/Users/seon-u/.pyenv/versions/3.11.9/envs/s11/lib/python3.11/site-packages/streamlit/runtime/state/widgets.py", line 212, in register_widget_from_metadata
raise DuplicateWidgetID(
streamlit.errors.DuplicateWidgetID: There are multiple identical `st.text_input` widgets with the
same generated key.
When a widget is created, it's assigned an internal key based on
its structure. Multiple widgets with an identical structure will
result in the same internal key, which causes this error.
To fix this error, please pass a unique `key` argument to
`st.text_input`.
발생이유
전에 팀프로젝트할 때도 비슷한 오류였는데 text_input 박스가 여러개이니 고유의 key값을 설정하라는 메세지였다. 근데 text_input은 여러개 써도 원래 에러가 안났었는데 이상했다.
해결방법
알고보니, 내가 text_input 가 있는 함수를 두번 호출해서 생겼던 오류였고 한 번 호출을 하니 해결할 수 있었다.
에러 3) API POST 에러
발생이유
회원가입시 버튼을누르면 API와 연결되어 db에 저장되고 가입이 완료되어야 하는데 계속 연결이 안되는 에러가 발생했다.
해결방법
POST의 경우 header 부분에 "accept"가 아니라 "Content-type"으로 넣어줘야 했다...(이런..)
def load_data(firstname, lastname, id, passwd, email, gender, birthday, phonenumber):
if firstname and lastname and id and passwd and email and gender and birthday and phonenumber:
headers = {'Content-Type': 'application/json'}
params = {
'firstname': firstname,
'lastname': lastname,
'id': id,
'passwd': passwd,
'email': email,
'gender': gender,
'birthday': birthday,
'phonenumber': phonenumber
}
try:
response = requests.post('http://localhost:8888/login', json=params, headers=headers, timeout=7)
#if response.status_code == 201:
st.session_state['page'] = 'success' # 성공 시 페이지 상태 변경
#else:
#st.write("가입에 실패했습니다. 다시 시도해주세요.")
except Exception as e:
st.write("서버가 불안정하오니 나중에 다시 시도해주세요.")
else:
st.write("모든 항목을 입력해 주세요.")
에러 3) Object of type date is not JSON serializable
발생이유
이 오류는 datetime.date 타입의 객체가 JSON 형식으로 직렬화될 수 없다는 것을 의미한다.
해결방법
# 서버에 POST 요청을 보내는 함수
def load_data(firstname, lastname, id, passwd, email, gender, birthday, phonenumber):
if firstname and lastname and id and passwd and email and gender and birthday and phonenumber:
headers = {'Content-Type': 'application/json'}
params = {
'firstname': firstname,
'lastname': lastname,
'id': id,
'passwd': passwd,
'email': email,
'gender': gender,
'birthday': birthday.strftime('%Y-%m-%d'), # date 객체를 문자열로 변환
'phonenumber': phonenumber
}
try:
response = requests.post('http://127.0.0.1:8888/login', json=params, headers=headers, timeout=15)
if response.status_code == 201:
st.session_state['page'] = 'success' # 성공 시 페이지 상태 변경
else:
st.write("가입에 실패했습니다. 다시 시도해주세요.")
except Exception as e:
st.write("서버가 불안정하오니 나중에 다시 시도해주세요.")
st.write(f"오류: {str(e)}")
else:
st.write("모든 항목을 입력해 주세요.")
JSON 데이터를 전송할 때, Python의 datetime.date 객체를 문자열로 변환하였다. JSON은 날짜 타입을 지원하지 않기 때문에, 이를 문자열로 변환한 후 JSON으로 전송하였더니 에러를 해결할 수 있었다.
에러 4) Error: Invalid base64-encoded string: number of data characters (57) cannot be 1 more than a multiple of 4
File "/Users/seon-u/final/login/.venv/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 88, in exec_func_with_error_handling
result = func()
^^^^^^
File "/Users/seon-u/final/login/.venv/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 579, in code_to_exec
exec(code, module.__dict__)
File "/Users/seon-u/final/login/src/login/login.py", line 121, in <module>
login_screen()
File "/Users/seon-u/final/login/src/login/login.py", line 86, in login_screen
login(userid, password)
File "/Users/seon-u/final/login/src/login/login.py", line 63, in login
if user['id'] == id and check_password(password, user['passwd']):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/seon-u/final/login/src/login/login.py", line 54, in check_password
decoded_hashed = base64.b64decode(hashed)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/seon-u/.pyenv/versions/3.11.9/lib/python3.11/base64.py", line 88, in b64decode
return binascii.a2b_base64(s, strict_mode=validate)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
발생이유
비밀번호를 암호화하여 db에 저장시키는 과정에 생긴오류이다.
db에 비밀번호 컬럼을 VARCHAR(50)로 설정하였었는데 글자수가 넘어가면서 생긴 오류이다.
해결방법
db 비밀번호 컬럼을 VARCHAR(50) -> VARCHAR(100)으로 늘려주었더니 해결되었다.
새롭게 알게된 지식
1. bcrypt 암호화와 복호화
사용자가 비밀번호를 등록하면 암호화하여 db에 저장시키는 모듈인 bcrypt를 알게되었다.
파이썬의 bcrypt 모듈은 암호 해시를 생성하고 확인하기 위한 라이브러리로, 안전한 암호 저장을 위해 사용된다.
import bcrypt
# 비밀번호 해시 생성
password = b"my_secure_password"
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
bcrypt는 비밀번호 해시를 생성할 때, 솔트(salt)를 자동으로 생성하고 이를 함께 사용하여 해시를 만든다.
솔트?
솔트는 고유의 임의 데이터로, 동일한 비밀번호라도 솔트가 다르면 서로 다른 해시값이 생성된다.
이를 통해 동일한 비밀번호라도 항상 같은 해시값을 가지지 않게 되어 공격에 대한 안전성을 높인다.
사용자가 로그인할 때 입력한 비밀번호와 저장된 해시값을 비교해야 한다.
# 비밀번호 확인
entered_password = b"my_secure_password"
if bcrypt.checkpw(entered_password, hashed):
print("비밀번호가 일치합니다.")
else:
print("비밀번호가 일치하지 않습니다.")
checkpw() 함수는 입력된 비밀번호와 저장된 해시를 비교하여 일치하는지 여부를 반환한다.
bcrypt는 강력한 해싱 알고리즘과 솔트 기능을 제공하여 무차별 대입 공격(brute-force attack) 및 dictionary attack으로부터 비밀번호를 안전하게 보호할 수 있다!
그.런.데!!
데이터베이스에서 비밀번호 해시를 반복적으로 조회하는 것은 성능에 영향을 미칠 수 있기 때문에,
캐시를 사용하여 이를 최적화해야 한다. 캐시는 자주 조회되는 데이터(예: 비밀번호 해시)를 메모리에 저장하여, 데이터베이스와의 통신을 줄이고 시스템 성능을 높일 수 있다.
캐시는 메모리에 데이터를 저장하여, 자주 사용하는 데이터에 빠르게 접근할 수 있도록 한다.
보통 데이터베이스와의 왕복 시간을 줄이기 위해 사용된다.
캐시는 Redis, Memcached 같은 인메모리 데이터베이스 시스템을 많이 사용한다.
2. OAuth
OAuth는 "Open Authorization"의 약자로, 사용자가 비밀번호를 공유하지 않고도 하나의 서비스에서 다른 서비스에 안전하게 액세스할 수 있도록 해주는 인증 및 권한 부여 프로토콜이다.
예를들어,
Facebook, Google, GitHub 등으로 로그인하는 기능이 OAuth를 기반으로 동작한다.
사용자는 각 서비스의 자격 증명을 이용해 제3의 애플리케이션에 로그인할 수 있다.
OAuth 를 통해 로그인한 유저의 정보를 db에 저장시키는 것 까지는 구현을 못했다. (파이널프로젝트 때는 구현해보자!)
OAuth를 통해 로그인을 하면 사용자의 비밀번호나 민감한 정보는 데이터베이스에 저장되지 않지만, 사용자 관련 정보는 저장될 수 있다.
이름, 이메일 주소, OAuth 제공자 (예: Google, Facebook, Kakao 등), OAuth 제공자로부터 받은 고유 ID (사용자를 식별하기 위한 값) 등.
OAuth 로그인은 비밀번호를 저장하지 않아도 되므로, 보안성이 높아진다. 사용자가 자주 사용하는 소셜 계정(Google, Facebook 등)을 통해 간편하게 로그인할 수 있으며, 클라이언트 애플리케이션은 복잡한 인증 절차를 처리할 필요 없이 OAuth 제공자의 인증 서비스를 활용할 수 있다.
팀프로젝트를 진행하면서...
좋은점
- 신속한 역할 분배: 팀원 간 빠른 역할 분배를 통해 프로젝트를 효율적으로 진행할 수 있었다.
- 원활한 소통: 팀원 간의 적극적인 소통 덕분에 업무 진행이 매끄러웠다.
- 페어 프로그래밍: 페어 프로그래밍을 통해 코드 문제 해결 속도가 향상되었다.
- 핵심 기능 구현 완료: 시간의 제약이 있었지만 필수 기능을 모두 구현해 프로젝트의 기본 목표를 달성할 수 있었다.
- 새롭게 알게 된 지식: 로그인 기능을 구현하면서 암호화라던지 OAuth라는 개념을 알게되었다.
아쉬운 점
- Streamlit 기능 구현 부족: Streamlit의 다양한 기능을 활용하지 못해 로그인 페이지의 완성도가 낮았다.
- JAVA 오류 해결 시간 소요: JAVA에 익숙하지 않아 예상보다 많은 시간을 오류 해결에 소비하게 되었다.
- Branch 전략 미흡: 사전에 branch 전략을 구체적으로 계획하지 않아 코드 기능 구분이 어려웠다.
개선할 점
- 기능 완성도 향상: 로그인 기능의 안정성과 완성도를 더욱 높일 필요가 있다.
- Streamlit 기능 확장: Streamlit의 다양한 기능을 더 적극적으로 활용하여 사용자 경험을 개선할 필요가 있다.
- 체계적인 Branch 전략 수립: 프로젝트 시작 전 branch 전략을 세우고 체계적으로 운영하여 협업 효율을 높여야 한다.
프로젝트 칸반보드
네번째 팀프로젝트를 마치면서...
일주일 만에 Java를 배우고 바로 실전에 투입되다 보니 정신이 하나도 없었다. 파이썬에 익숙해져 있었던 터라, Java로 코드를 짜는 건 생각보다 훨씬 까다로웠다. 평소에 직관적으로 코딩하던 방식에서 벗어나, 객체 지향의 규칙에 맞춰서 생각하고 코드를 구조화하는 과정이 쉽지 않았다. 특히 자료형에 엄격하고, 메모리 관리를 염두에 두며 코딩해야 한다는 점에서 체감된 어려움이 컸다.
그럼에도 불구하고 팀원들이 모두 힘을 모아, 한정된 시간 내에 프로젝트를 완성할 수 있었다는 점이 무척 뿌듯하다. 프로젝트 마감 기한이 촉박했음에도 각자의 역할 분배가 신속하게 이루어졌고, 그 덕분에 기능 구현에 집중할 수 있었다. 특히, 팀원들 간의 커뮤니케이션이 원활하게 이루어져 문제가 발생할 때마다 빠르게 해결할 수 있었고, 덕분에 큰 혼란 없이 프로젝트를 마칠 수 있었다. 모두가 열심히 노력한 덕분에 제시간에 프로젝트를 끝낼 수 있었다.
하지만 아쉬운 점도 없지는 않았다. 개인적으로는 Redis를 프로젝트에 적용하지 못한 것이 가장 아쉬웠다. Redis를 활용해서 성능을 더 향상시킬 수 있었겠지만, 시간 부족으로 인해 계획했던 부분을 생략해야 했다.
한편, 이번 프로젝트에서 GitHub 이슈 작성과 PR 관리에 있어서 더 나아진 모습을 보였다는 점은 긍정적으로 생각하고 있다. 팀원들과의 협업을 좀 더 체계적으로 관리할 수 있었고, 덕분에 코드 리뷰나 피드백도 신속하게 이뤄졌다.
이번 주에는 ADsP 자격증 시험도 있고, 파이널 프로젝트 주제도 정해야 한다. 해야 할 일이 정말 산더미처럼 쌓여 있는 기분이지만, 그래도 '이 또한 지나가리라'는 마음으로 하나씩 차근차근 해결해 나가야 할 것 같다.
'데이터엔지니어 부트캠프' 카테고리의 다른 글
쿠버네티스(Kubernetes) - k3s 로 멀티노드 사용해보기 (1) | 2024.11.05 |
---|---|
데이터엔지니어 부트캠프 - 쿠버네티스 시작하기, Apache HTTP 부하테스트하기 (18주차) (2) | 2024.11.04 |
데이터엔지니어 부트캠프 - 젠킨스(Jenkins) 이해하기&실행해보기 (16주차) (0) | 2024.10.22 |
데이터엔지니어 부트캠프 - 스프링부트(Springboot) 이해하기 (15주차) (3) | 2024.10.20 |
데이터엔지니어 부트캠프 - 세번째 팀프로젝트 (10/5 & 10/7~10/8) (14주차) (2) | 2024.10.10 |